edit

NorthSec 2025: Containers

A three-challenge reverse engineering track from NorthSec 2025’s CTF competition. Challenge source code.

Each challenge in this track consisted of a file containing a number of docker images, each with the same entrypoint but different code to check a password.

Small (1/3)

While the Bonsecours is obviously a cruise ship, civilian ships can be chartered to carry cargo in containers. These containers are smaller than what you are used to see for haulage. To request a physical container, you need to prove ownership of the container to the stevedor.

This cargo is interesting to us. Try to extract the hidden secrets in this container.

docker load -i small.tar
docker run --rm -it small:p1

Let’s follow the instructions. We’re given three docker images: small:p1, small:p2, small:p3.

Running any of them shows us a password entry screen:

$ docker run --rm -it small:p1
                                    .',
                                    ;;:.
                                    ;::'
                                    :::.
             ...                    '::                     ...
             ,,:;.                 .,::..                .';;:.
              ';::;        ..',;:::::,::::::;,..        .::::.
               .,::;   ..,;::::;;:;;,,,;;;:'::::;;'.   .::;.
                  .::,;:::,;,;::;,,;,,,;,,;::;,;,::::,;:;.
                  .;:;;:;,:;;:;,,;::'  ::;,',::;:;,::,::.
                 ,::::':::,,::::::::ccc::::::::;';::,;::::.
               .:;,:',l,  .::::::::cOdXc::::::::;  .cc.::':;
              '::::'l;':;.;::::::cod0kXdol:::::::'':;'l;,:::;
             ,:;:;'l.::::::::::lodxkkkkkxxolc:::::::::';c.::;:
            .::;;'d.::::::::lxxllllllllllllllkxl:::::::;,o.:;::
.,;::;,'..  ::::.x.::::::::::cokOc,,,,,,,,o0xoc:::::::::,:c':::' ...,,;;;,,
 ..';::::::::':'d.',:::::::::::d:,:;:cc;cOXK:::::::::::;, k.:,;::::::::;,..
           ::::.O   :::::::::::dc;:' .xllOXK:::::::::::.  ;:,:::.
           ::::'d.:::::::::::cdo; ,: .Xxcdoodl::::::::::::.o':::.
           ::,:.d.::::::::coo:.'. .;,:0OdX0looldc:::::::::.o':,:.
           ;:::.O.:::::::cO'.' ':;;,..xxxk0dXOocdd:::::::'c,;:::
           .:::,c;,:::::::dl.c::.    .XXXkddkK0:Oc:::::::.k.:::;
            ::,:.x.::::::::xx.       .XXXXXXOxcOc:::::::'o';:,:.
            .::::.d.  ':::::x,       .XXXXXXXdxl:::::  .c,,:::,
             '::':.l..::::::ck,      .XXXXXXkxo::::::;.l,;;;:;
          ..,::::::':c,;:::codK'     .XXXXX0kkolc:::,:c';:::::;'..
      .'::::;.  ;:::;'::.;coxO00xxkkkOXXXXX0KOkdl:',l,,;:::. .'::::'.
     ,;;;;'.     .;;::'                             .::':'      .,::;,.
     ...           .,.         C   V   S   S       .,.

                        B  O  N  S  E  C  O  U  R  S


________________________________________________________________________________

🔐 Please enter the password to unlock this container                               
________________________________________________________________________________

Let’s take a look at the image with docker history to know what we’re dealing with.

$ docker history small:p1 --no-trunc --format '{{.CreatedBy}}'
CMD ["ruby" "/1680322826"]
COPY tmp/sx0.rb /1680322826 # buildkit
ENV ENC=DyEx9qCn4lTwGwOBc3cYu94XwbT # snip...
ENTRYPOINT ["/entrypoint"]
ENV TERM=xterm-256color
COPY /entrypoint /entrypoint # buildkit
CMD ["irb"]
RUN /bin/sh -c set -eux;  mkdir "$GEM_HOME";  chmod 1777 "$GEM_HOME" # buildkit
ENV PATH=/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ENV BUNDLE_SILENCE_ROOT_WARNING=1 BUNDLE_APP_CONFIG=/usr/local/bundle
ENV GEM_HOME=/usr/local/bundle
RUN /bin/sh -c set -eux; apk add --no-cache --virtual .ruby-builddeps # snip...
ENV RUBY_DOWNLOAD_SHA256=018d59ffb52be3c0a6d847e22d3fd7a2c52d0ddfee249d3517a0c8c6dbfa70af
ENV RUBY_DOWNLOAD_URL=https://cache.ruby-lang.org/pub/ruby/3.4/ruby-3.4.1.tar.xz
ENV RUBY_VERSION=3.4.1
ENV LANG=C.UTF-8
RUN /bin/sh -c set -eux;  mkdir -p /usr/local/etc;  echo 'gem: --no-document' >> /usr/local/etc/gemrc # buildkit
CMD ["/bin/sh"]
ADD alpine-minirootfs-3.21.2-x86_64.tar.gz / # buildkit

Of note:

Let’s look at the ruby source before making more conclusions.

$ docker ps --format "{{.ID}}"
c61c7afffc23
$ docker cp c61c7afffc23:/1680322826 sx0.rb
Successfully copied 3.58kB to /home/xm/nsec/containers/small/sx0.rb
$ cat sx0.rb 
require 'base64'
eval(Base64.urlsafe_decode64([90, 71, 86, 109, 73, 72, 103, 103, 80, 83, 66, 108, 100, 109, 70, 115, 75, 67, 74, 98, 78, 84, 69, 115, 73, 68, 85, 119, 76, 67, 65, 49, 77, 83, 119, 103, 78, 68, 103, 115, 73, 68, 85, 50, 76, 67, 65, 49, 77, 67, 119, 103, 78, 84, 73, 115, 73, 68, 85, 50, 76, 67, 65, 48, 79, 67, 119, 103, 78, 84, 66, 100, 73, 105, 107, 117, 98, 87, 70, 119, 75, 67, 89, 54, 89, 50, 104, 121, 75, 83, 53, 113, 98, 50, 108, 117, 76, 110, 82, 118, 88, 50, 107, 75, 90, 71, 86, 109, 73, 71, 53, 118, 75, 67, 111, 112, 73, 68, 48, 103, 90, 88, 104, 112, 100, 67, 65, 119, 76, 110, 78, 49, 89, 50, 77, 106, 90, 88, 78, 122, 67, 109, 85, 57, 82, 69, 70, 85, 81, 83, 53, 121, 90, 87, 70, 107, 76, 110, 78, 119, 98, 71, 108, 48, 76, 109, 49, 104, 99, 72, 116, 112, 100, 67, 53, 48, 98, 49, 57, 112, 76, 110, 78, 108, 98, 109, 81, 111, 79, 108, 52, 115, 101, 67, 108, 57, 67, 109, 107, 57, 90, 50, 86, 48, 99, 121, 89, 117, 89, 50, 104, 118, 98, 88, 65, 75, 90, 83, 53, 54, 97, 88, 65, 111, 97, 83, 53, 105, 101, 88, 82, 108, 99, 121, 107, 117, 98, 87, 70, 119, 101, 50, 108, 48, 76, 110, 74, 108, 90, 72, 86, 106, 90, 83, 103, 54, 80, 84, 48, 112, 102, 83, 53, 121, 90, 87, 112, 108, 89, 51, 82, 55, 97, 88, 82, 57, 76, 109, 86, 104, 89, 50, 104, 55, 98, 109, 57, 57, 67, 109, 53, 118, 75, 69, 82, 66, 86, 69, 69, 112, 73, 71, 108, 109, 73, 71, 85, 117, 99, 50, 108, 54, 90, 83, 65, 104, 80, 83, 66, 112, 76, 110, 78, 112, 101, 109, 85, 75].map(&:chr).join))
__END__
3230824790 3230824784 3230824794 3230824704 3230824704 3230824787 3230824786 3230824786 3230824790 3230824710 3230824711 3230824785 3230824708 3230824789 3230824788 3230824785 3230824795 3230824704 3230824788 3230824786 3230824794 3230824790 3230824795 3230824710 3230824710 3230824784 3230824704 3230824787 3230824789 3230824704 3230824791 3230824795

Decoding the eval’d base64 reveals the real source:

def x = eval("[51, 50, 51, 48, 56, 50, 52, 56, 48, 50]").map(&:chr).join.to_i
def no(*) = exit 0.succ#ess
e=DATA.read.split.map{it.to_i.send(:^,x)}
i=gets&.chomp
e.zip(i.bytes).map{it.reduce(:==)}.reject{it}.each{no}
no(DATA) if e.size != i.size

In summary, this file:

If everything’s the same, the programs just finishes normally with exit code 0.

This brings credibility to thoughts from earlier: the password is verified by the per-image code, the common entrypoint probably decrypts ENC with the input if the verifier returns 0, so there’s no point in reversing the entrypoint binary.

Anyways, we can obtain the first password by xoring and decoding the data ourselves:

>>> bytes([51, 50, 51, 48, 56, 50, 52, 56, 48, 50])
b'3230824802'
>>> [int(s)^3230824802 for s in "3230824790 3230824784 3230824794 3230824704 3230824704 3230824787 3230824786 3230824786 3230824790 323\
0824710 3230824711 3230824785 3230824708 3230824789 3230824788 3230824785 3230824795 3230824704 3230824788 3230824786 3230824794 323082\
4790 3230824795 3230824710 3230824710 3230824784 3230824704 3230824787 3230824789 3230824704 3230824791 3230824795".split(" ")]
[52, 50, 56, 98, 98, 49, 48, 48, 52, 100, 101, 51, 102, 55, 54, 51, 57, 98, 54, 48, 56, 52, 57, 100, 100, 50, 98, 49, 55, 98, 53, 57]
>>> bytes(_)
b'428bb1004de3f7639b60849dd2b17b59'

Go back to our running instance of small:p1 and input this, and sure enough, we get a part of the flag:

✅ Congrats, you found part 1 of the flag! Here it is: FLAG-6b44cae751              

small:p2 and small:p3 are largely the same thing again: decode base64, xor the data and decode, input into the password prompt, get your congratulations, combine into the first flag, worth 2 points.

The final flag 1: FLAG-6b44cae75180580cb18e643e57c8a9cafc1c3e1f

Medium (2/3)

Good. This was a smaller container, but the merchandise inside will be important to the team for the operation.

Now, try to steal more important loot from this container.

This time, there are 10 images; medium:p1 to medium:p10. This establishes the trend: we’re gonna get more and more images, so we should start automating our solution if we want to finish the challenge.

Running the first image yields the same screen, but looking at the image deeper shows it has changed pretty deeply:

$ docker history medium:p1 --no-trunc --format '{{.CreatedBy}}'
CMD ["/o"]
ENV X=674
ENV ENC=#snip...
RUN /bin/sh -c rm /mx0.go # buildkit
RUN /bin/sh -c go build -ldflags="-X 'main.pff=749'" -o /o /mx0.go # buildkit
COPY tmp/mx0.go /mx0.go # buildkit
ENTRYPOINT ["/entrypoint"]
ENV TERM=xterm-256color
COPY /entrypoint /entrypoint # buildkit
WORKDIR /go
RUN /bin/sh -c mkdir -p "$GOPATH/src" "$GOPATH/bin" && chmod -R 1777 "$GOPATH" # buildkit
COPY /target/ / # buildkit
ENV PATH=/go/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ENV GOPATH=/go
ENV GOTOOLCHAIN=local
ENV GOLANG_VERSION=1.23.6
RUN /bin/sh -c apk add --no-cache ca-certificates # buildkit
CMD ["/bin/sh"]
ADD alpine-minirootfs-3.21.2-x86_64.tar.gz / # buildkit

Of note:

Extracting the image file we were given with tar xf medium.tar reveals the folder with all the data we want: blobs/sha256/. With almost a hundred files in it though, we probably should find something to direct us.

When working with the intermediate layers of docker images, TUI tool dive is extremely useful.

Diving in and navigating to the COPY tmp/mx0.go /mx0.go, we can see something interesting in the layer properties:

Digest: sha256:446b433e8df25e83f25387443a79392314f120ab4f1f66bd19684bcd69ce4c3f

And sure enough, blobs/sha256/446b433e8df25e83f25387443a79392314f120ab4f1f66bd19684bcd69ce4c3f is the file we want:

mx0.go

import (
	bff "bufio"
	ooo "os"
	sco "strconv"
	sss "strings"
)

var sol string
var scn = bff.NewScanner(ooo.Stdin)
var pff = "" // 
var pnn int

func main() {
	in := scn.Text()
	m := map[int]byte{}
	xs := sss.Split(sol, " ")
	sm := false

	for _, p := range xs {
		if sm { // 
			sm = !sm
			continue
		}
		c, i, _ := sss.Cut(p, ":")
		m[_x(_x(_i(i), 0x42), _x(pnn, _n()))-
			_i(ooo.Getenv("X"))] = byte(
			_x(_i(c), _i(ooo.Getenv("X"))),
		)
		sm = !sm
	}

	if _x(len(in), len(m)) != 0 {
		ooo.Exit(1)
	}

	for idx, c := range m {
		if in[idx] != c {
			ooo.Exit(1)
		}
	}
}

func init() {
	pnn = _i(pff)
	scn.Scan()
	sol = `667:87 @272$
 662:67 %577
$ 659:80 %846
	 660:69 @767
	 711:79 %952
	 707:78 %779

 661:86 @345_$ 656:85 @64
_ 710:81 ^410
_ 659:72 @195_
 708:65 ^995	
 704:45 %973
$ 705:73 @783	
 658:84 %625$_ 704:71 ^411$_ 657:90 ^907_$ 661:92 @422	_ 662:64 @389_
 656:83 @10
	 711:75 @74
	 708:82 ^987
_ 705:66 %1011
$ 657:74 %388_
 656:95 %794$
 663:44 %610$_ 656:88 ^795_
 707:91 %725
$ 661:89 ^932_
 707:70 ^586	
 661:94 ^604	
 704:93 @707_
 658:68`
}

func _i(s string) int     { i, _ := sco.Atoi(s); return i }
func _x(a int, b int) int { return a ^ b }
func _n() int             { return _x(105, 43) } // 

For the most part, we can ignore the code here. I did read to understand it during the CTF, but it wasn’t necessary. The real important part is the one at the end:

if _x(len(in), len(m)) != 0 {
	ooo.Exit(1)
}

for idx, c := range m {
	if in[idx] != c {
		ooo.Exit(1)
	}
}

The length of the input and each character is compared to something generated earlier, which didn’t use the input.

So all we have to do is remove the code reading the input, change the last part to creating an array and initializing it from the generated values, run the code and we have our password.

Our new code:

a := make([]byte, len(m))
for idx, c := range m {
	a[idx] = c
}
println(string(a))

We do find usage of X and pff in the code as expected, though, so we should write a quick command to obtain those.

$ docker history medium:p1 --no-trunc --format "{{.CreatedBy}}" | grep "pff\|ENV X"
ENV X=674
RUN /bin/sh -c go build -ldflags="-X 'main.pff=749'" -o /o /mx0.go # buildkit

Copy the value of pff into the code, and run.

$ X=674 go run mx0.go
eac1e360baf44cb72772a32097d12fb5

This time, the first part of the flag is smaller, but sure enough, we do get one: FLAG-d2.

Now to do all of that nine more times. Or, well, let’s make it a bit quicker. The files in the blobs/sha256 folder actually start with their names, so we can write a quick script that copies them all out somewhere more useful, without having to dive into each image.

from pathlib import Path
import shutil

for f in Path(".").iterdir():
    c = f.read_bytes()
    if c[:2] == b"mx" and c[3:6] == b".go":
        shutil.copy(f, c[:6].decode()+".orig")

The difference between the files seems to be the sol string in init, and the values for X and pff. We do have a bit of a copy-paste job now to run our modified code with the correct sol, X and pff values for each of the 10 images, but it’s nothing more than a bit of copy-paste. Combine the parts and submit for 3 points.

Flag 2: FLAG-d2a240d053e4814c54c74389eefbdb945z4cfe0b71b6249ce8a35307248991d86

Large (3/3)

Now that you got a medium secret, try to steal bigger loot from this container.

This time, we have a whole 35 images. We’ll want to really automate as much as we can, copy-pasting 4 times per image was already very annoying when there were 10 images.

Moreover, these images are constructed from pure hate:

docker history
$ docker history large:p1 --no-trunc --format '{{.CreatedBy}}'
COPY /lib890f.so /usr/lib/x86_64-linux-gnu/lib890f.so # buildkit
ENV ENC=#snip...
COPY tmp/9ZNswABRyV8pQdynL9QFBrfYWR_nmb-c_WO4LaTPPrw= /EmU/X/yWRtP/GvgTgrgqf/-04/c/Tf/mM/A2/p/YhM # buildkit
COPY tmp/du4Dw-zAklS88IJzraYvOfp4HhX9oalIB4ylpyBuDTw= /sbR/kgV5/UbAETYhD3BZTT/9Lfqq-DDvjQa3/lWLctE # buildkit
COPY tmp/-RTLSDeRlgtck0ClIlfvgXQ2TFgnZj0D-928oW3j_fg= /Y/HvaGs/Cfoc9P/1/q/QV1/4GG/7W5d/gXl_/m/6w # buildkit
COPY tmp/36p_bD0ftVSSyL-c5pHvshQ9wpc4ak_y3eMyqfwXsTc= /raLWSFO4gdf-Ao/KuO/fm2KK/OH4lzc/zm/S/Q # buildkit
COPY tmp/0Qwpe9GL8wlyR8ideA7gDl2g2dec8wsWn5xKQbvnu_U= /ROjbB/rH/Oflo4/c/TzUX/bCwjs/dmzJh/Caq/X/RHg # buildkit
COPY tmp/QhRgTv6JrIj095GrTnwu00lOkSffOHhZqBCksKZZQ48= /qWOYvByo/K_zT4zfD/M/9/H/3f/_A/u/ND_P/lbsI # buildkit
COPY tmp/uVd9sC4xC3rJfVy1Na6iF-yqpFBXSJMWF0iy5vqY3YU= /bFl/P/fYFHL21WQ/RZy/7/B/wQzP3pwwQuJp4Yro0 # buildkit
COPY tmp/CQC7t8KpvWquZGEGfZRgppl8zRv8E1YepGP76vxy3Mg= /AZ4/sc/-63k/I/sHPjXgN/6/hsHAxOkl/plbJHw # buildkit
COPY tmp/PDI4I_FtVMw4a1mfhEantR8TaCNklx7ULrjNM7b3H7Y= /LXpmyZHw/hR5VVIlI1tbrspsMjv/yMDpps/cuq_bTL0 # buildkit
COPY tmp/qpW7B-GCGSu2DDQ5UF3GzC09SJi84dPbWaLiFTocUD8= /TH/arq/iuvXu58BLN/E/Op/o2/1/34ullYnz/R/jM # buildkit
CMD ["sh" "/TH/arq/iuvXu58BLN/E/Op/o2/1/34ullYnz/R/jM"]
ENV E=sh /TH/arq/iuvXu58BLN/E/Op/o2/1/34ullYnz/R/jM
COPY /UC/6/Hs84/iF/mZN/86oa6Ol/L-cs/6hMjHl1P/b/0 /UC/6/Hs84/iF/mZN/86oa6Ol/L-cs/6hMjHl1P/b/0 # buildkit
ENV LP=HZf7LAjHjj/Jx/lH1x/fGaSm/6GlE9K5/NKMy/Q
COPY tmp/l0 /HZf7LAjHjj/Jx/lH1x/fGaSm/6GlE9K5/NKMy/Q # buildkit
ENTRYPOINT ["/entrypoint"]
ENV TERM=xterm-256color
COPY /entrypoint /entrypoint # buildkit
CMD ["/usr/local/bin/bun"]
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
WORKDIR /home/bun/app
RUN |2 BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 # snip...
ENV BUN_INSTALL_BIN=/usr/local/bin
ARG BUN_INSTALL_BIN=/usr/local/bin
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bun-node-fallback-bin
RUN /bin/sh -c mkdir -p /usr/local/bun-node-fallback-bin && ln -s /usr/local/bin/bun /usr/local/bun-node-fallback-bin/node # buildkit
COPY /usr/local/bin/bun /usr/local/bin/bun # buildkit
COPY docker-entrypoint.sh /usr/local/bin # buildkit
# debian.sh --arch 'amd64' out/ 'bullseye' '@1740355200'

There presumably being javascript is promising, but we also have a native binary without source and a good few layers of possibly important random files.

This time let’s enter with a shell to see what’s up with the entrypoint.

hate.
$ docker run --rm -it --entrypoint sh large:p1

# cat /TH/arq/iuvXu58BLN/E/Op/o2/1/34ullYnz/R/jM 

#!/usr/bin/env bash
# 
set -e
export C="${C}55a4"
#
n=`head -n "$((323 - 207))" "/$LP" | tail -n 1`;exec bash "/$n" #

# head -n "$((323 - 207))" "/$LP" | tail -n 1

LXpmyZHw/hR5VVIlI1tbrspsMjv/yMDpps/cuq_bTL0

# cat LXpmyZHw/hR5VVIlI1tbrspsMjv/yMDpps/cuq_bTL0

#!/usr/bin/env bash
# 
set -e
export C="${C}479b"
#
n=`head -n "$((565 - 511))" "/HZf7LAjHjj/Jx/lH1x/fGaSm/6GlE9K5/NKMy/Q" | tail -n 1`;exec bash "/$n" #

# head -n "$((565 - 511))" "/HZf7LAjHjj/Jx/lH1x/fGaSm/6GlE9K5/NKMy/Q" | tail -n 1 

AZ4/sc/-63k/I/sHPjXgN/6/hsHAxOkl/plbJHw

# cat AZ4/sc/-63k/I/sHPjXgN/6/hsHAxOkl/plbJHw

#!/usr/bin/env bash
# 
set -e
export C="${C}9b96"
#
n=`head -n "$((457 - 395))" "/HZf7LAjHjj/Jx/lH1x/fGaSm/6GlE9K5/NKMy/Q" | tail -n 1`;exec bash "/$n" #
# head -n "$((457 - 395))" "/HZf7LAjHjj/Jx/lH1x/fGaSm/6GlE9K5/NKMy/Q" | tail -n 1
bFl/P/fYFHL21WQ/RZy/7/B/wQzP3pwwQuJp4Yro0

After a few too many layers of wrapping, we reach the bun execution:

#!/usr/bin/env bash

set -e;set -o pipefail
w=256sum;m=mk;s=sha;e=ec;c=ho;t=temp;one=un;
t=$("${m}${t}");"${e}${c}" -n "${C}" > "$t"
"${e}${c}" "e6a51149e5185d328d8a5790fa99fc1d91f6549e3ef556b20f242104eda68d8f $t" | "${s}${w}" -c &> /dev/null
LB="lib890f" "b${one}" "/UC/6/Hs84/iF/mZN/86oa6Ol/L-cs/6hMjHl1P/b/0" "${C}"

Or with the unnecessary variables removed for readability:

t=$(mktemp); echo -n "${C}" > "$t"
echo "e6a51149e5185d328d8a5790fa99fc1d91f6549e3ef556b20f242104eda68d8f $t" | sha256sum
LB="lib890f" bun "/UC/6/Hs84/iF/mZN/86oa6Ol/L-cs/6hMjHl1P/b/0" "${C}"

And great, we’ve found our javascript file:

minified source code
import{dlopen as W,FFIType as z,suffix as X,JSCallback as Y}from"bun:ffi";var{CryptoHasher:Z}=globalThis.Bun;var M=170;function $(q){let j=new Z("sha256");return j.update(q),j.digest("hex")}async function H(){for await(let q of console)return q;return""}async function J(){let{LB:q}=process.env;var j=[];return j.push(q),j.push(X),j.join(".")}var N=process.argv[2];if($(N)!="a26c34b92360dbb031d060f16a68eb5cd3f73674fa6479fc4522c7aff9693fe0")process.exit(1);var Q=await H(),K=JSON.parse("[220,133,246,184,233,42,32,111,200,212,219,153,40,233,176,143,245,172,111,175,33,149,218,240,169,76,50,195,196,136,236,168]");for(G=0;G<K.length;G++)K[G]=-Q.charCodeAt(G)||0;var G,k=W(await J(),{process:{args:[z.cstring,z.cstring,z.pointer],returns:z.int}}),m=84,A=2,B=255,L={[m]:({i:q,value:j})=>{K[q]+=j^37712},[A]:({i:q,value:j})=>{K[q]-=j^51943},[B]:(q)=>{if(K.every(R(0)))process.exit(0);else process.exit(1)}};function R(q){return(j)=>j==q}var g=new Y((q,j,V)=>{return L[+j]({i:q,value:V,op:j}),M},{returns:"int",args:[z.int,z.int,z.int]}),x=k.symbols.process(Buffer.from(process.argv[2]),Buffer.from(Q),g.ptr);if(x!=M)process.exit(1);

Or after adding some whitespace and renaming variables:

slightly unminified source code
import{dlopen,FFIType,suffix as ffisuffix,JSCallback}from"bun:ffi";
var{CryptoHasher}=globalThis.Bun;
var M=170;
function sha256hex(q){
    let j=new CryptoHasher("sha256");
    return j.update(q),j.digest("hex")
}
async function H(){
    for await(let q of console)return q;
    return""
}
async function J(){
    let{LB}=process.env;
    var j=[];
    return j.push(LB),j.push(ffisuffix),j.join(".")
}
var arg2=process.argv[2];
if(sha256hex(arg2)!="e6a51149e5185d328d8a5790fa99fc1d91f6549e3ef556b20f242104eda68d8f")process.exit(1);
var Q=await H(),
K=JSON.parse("[235,38,80,150,211,208,103,83,117,243,228,82,57,139,212,56,168,55,238,202,229,244,208,204,179,49,178,45,26,142,102,80]");
for(G=0;G<K.length;G++)K[G]=-Q.charCodeAt(G)||0;
var G,k=dlopen(await J(),{
    process:{
        args:[FFIType.cstring,FFIType.cstring,FFIType.pointer],
        returns:FFIType.int
    }
}),
m=84,
A=2,
B=255,
L={
    [m]:({i:q,value:j})=>{K[q]+=j^23334},
    [A]:({i:q,value:j})=>{K[q]-=j^38353},
    [B]:(q)=>{
        if(K.every(R(0)))
            process.exit(0);
        else process.exit(1)
    }
};
function R(q){
    return(j)=>j==q
}
var g=new JSCallback((q,j,V)=>{
    return L[+j]({i:q,value:V,op:j}),M
},{returns:"int",args:[FFIType.int,FFIType.int,FFIType.int]}),
x=k.symbols.process(Buffer.from(process.argv[2]),Buffer.from(Q),g.ptr);
if(x!=M)process.exit(1);

So, we’re giving the native library from earlier access to three JS functions: two that modify an array initialized our input somehow, and one that exits, with the exit code determined by whether all values in the array are 0.

I guess it’s finally time to look at this library. Decompiling in ghidra and renaming reveals a pattern:

decompiled process function
undefined8 process(char *arg2, char *userInput, code *js_callback) {
  uint value;
  int i;
  size_t len;
  int _i;
  
  sum = 0;
  len = strlen(arg2);
  for (_i = 0; _i < (int)len; _i = _i + 1) {
    sum = arg2[_i] + sum;
  }
  len = strlen(userInput);
  value = atoi("2804");
  i = atoi("31");
  (*js_callback)(i,2,value ^ 40806);
  value = atoi("2736");
  i = atoi("4");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2796");
  i = atoi("1");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2739");
  i = atoi("5");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2696");
  i = atoi("4");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2692");
  i = atoi("27");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2759");
  i = atoi("6");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2808");
  i = atoi("20");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2700");
  i = atoi("1");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2661");
  i = atoi("27");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2711");
  i = atoi("0");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2705");
  i = atoi("27");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2716");
  i = atoi("16");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2725");
  i = atoi("13");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2712");
  i = atoi("19");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2665");
  i = atoi("10");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2812");
  i = atoi("6");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2738");
  i = atoi("24");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2718");
  i = atoi("1");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2705");
  i = atoi("31");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2710");
  i = atoi("19");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2727");
  i = atoi("8");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2745");
  i = atoi("20");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2698");
  i = atoi("15");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2715");
  i = atoi("31");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2738");
  i = atoi("16");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2733");
  i = atoi("10");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2714");
  i = atoi("29");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2719");
  i = atoi("28");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2739");
  i = atoi("2");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2730");
  i = atoi("8");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2784");
  i = atoi("8");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2711");
  i = atoi("23");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2722");
  i = atoi("26");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2720");
  i = atoi("25");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2692");
  i = atoi("10");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2712");
  i = atoi("14");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2714");
  i = atoi("14");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2707");
  i = atoi("20");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2748");
  i = atoi("4");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2751");
  i = atoi("2");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2715");
  i = atoi("17");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2771");
  i = atoi("25");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2717");
  i = atoi("9");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2712");
  i = atoi("3");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2725");
  i = atoi("23");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2734");
  i = atoi("21");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2739");
  i = atoi("7");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2748");
  i = atoi("15");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2595");
  i = atoi("11");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2692");
  i = atoi("16");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2613");
  i = atoi("19");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2735");
  i = atoi("12");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2618");
  i = atoi("22");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2724");
  i = atoi("29");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2968");
  i = atoi("1");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2697");
  i = atoi("10");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2733");
  i = atoi("22");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2718");
  i = atoi("27");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2727");
  i = atoi("30");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2728");
  i = atoi("19");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2714");
  i = atoi("24");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2710");
  i = atoi("7");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2717");
  i = atoi("0");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2705");
  i = atoi("11");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2724");
  i = atoi("0");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2713");
  i = atoi("0");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2726");
  i = atoi("12");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2741");
  i = atoi("24");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2738");
  i = atoi("30");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2577");
  i = atoi("25");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2709");
  i = atoi("21");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2789");
  i = atoi("4");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2710");
  i = atoi("10");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2741");
  i = atoi("15");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2627");
  i = atoi("18");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2723");
  i = atoi("0");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2733");
  i = atoi("2");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2705");
  i = atoi("7");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2782");
  i = atoi("26");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2751");
  i = atoi("13");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2756");
  i = atoi("9");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2565");
  i = atoi("17");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2697");
  i = atoi("7");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2745");
  i = atoi("6");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2746");
  i = atoi("25");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2694");
  i = atoi("14");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2727");
  i = atoi("12");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2709");
  i = atoi("19");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2728");
  i = atoi("5");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2735");
  i = atoi("22");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2706");
  i = atoi("28");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2717");
  i = atoi("17");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2690");
  i = atoi("24");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2811");
  i = atoi("4");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2741");
  i = atoi("31");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2792");
  i = atoi("1");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2623");
  i = atoi("2");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2706");
  i = atoi("17");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2731");
  i = atoi("9");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2753");
  i = atoi("28");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2771");
  i = atoi("18");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2785");
  i = atoi("16");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2725");
  i = atoi("27");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2739");
  i = atoi("29");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2793");
  i = atoi("11");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2805");
  i = atoi("14");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2709");
  i = atoi("14");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2759");
  i = atoi("21");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2717");
  i = atoi("18");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2704");
  i = atoi("24");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2769");
  i = atoi("23");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2736");
  i = atoi("3");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2718");
  i = atoi("31");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2695");
  i = atoi("6");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2729");
  i = atoi("26");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2750");
  i = atoi("7");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2749");
  i = atoi("28");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2733");
  i = atoi("5");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2730");
  i = atoi("15");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2797");
  i = atoi("22");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2709");
  i = atoi("30");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2737");
  i = atoi("29");
  (*js_callback)(i,2,value ^ 0x9f66);
  value = atoi("2808");
  i = atoi("13");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2785");
  i = atoi("11");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2812");
  i = atoi("12");
  (*js_callback)(i,0x54,value ^ 0x5191);
  value = atoi("2775");
  i = atoi("3");
  (*js_callback)(i,0x54,value ^ 0x5191);
  (*js_callback)(len & 0xffffffff,0xff,sum);
  return 0xaa;
}

The user input… isn’t really used? In fact, the two functions that modify the array are always called with constant arguments!

This is very notable. Because the two functions exposed are addition and substraction, whatever the value of K[i] before process was before, afterwards it’s just gonna be K[i] + something. And we know the process exits with 0 (the password is deemed valid) when K[i] == 0 for all indices.

And remember K’s initialization line: for(G=0;G<K.length;G++)K[G]=-Q.charCodeAt(G)||0;

It’s the negative value of each byte in the user input.

Let’s say we input a (97), K[0] would be initalized to -97. If this is valid, K[0] will be 0 at the end, when the third function is called. Or in other words, the something added by the process function is our target password value.

If we just call process with a K array full of 0, and then print K at the end, we’ve obtained the password.

Turns out this one was quite similar to the medium challenge after all. We just need a way to patch the code and run it again.

I’ll spare you a look at the other images: all of the shell wrapping is different, the “libraries” are different, and the two addition/subtraction functions have different right operands for the xor operation. K is also created using a different string but since it’s immediately overwritten, that’s probably a red herring.

Since the path to all of the shell scripts is always in a file point to by the LP environment variable, and the libs have different but still distinctive names, we can automate everything quite easily.

pwn.js
import { dlopen, FFIType, JSCallback } from "bun:ffi";
import { readdir } from "node:fs/promises";

// Init buffers
let K = [];
let _a=[],_b=[];
for(let G = 0; G < 32; G++) {
    K.push(0);
    _a.push(32);
    _b.push(32);
}

// Find process function lib file
const libs = await readdir("/usr/lib/x86_64-linux-gnu");
const matchingLibs = libs.filter(x => x.endsWith(".so") && x.indexOf("-") == -1);
if (matchingLibs.length !== 1) throw new Exception("lib match failed");

let lib = dlopen("/usr/lib/x86_64-linux-gnu/"+matchingLibs[0], {
    process:{
        args:[FFIType.cstring,FFIType.cstring,FFIType.pointer],
        returns:FFIType.int
    }
});

// Find js source file
let jsFile;
const re = /"b\${one}" "([^"]+)/;
for (let line of (await Bun.file("/"+process.env.LP).text()).split("\n")) {
    try {
        let file = Bun.file("/"+line);
        let content = await file.text();

        if (content.indexOf("LB=\"") != -1) {
            jsFile = re.exec(content)[1];
        }
    } catch {}
}
if (jsFile === undefined) throw new Exception("js match failed");

// Extract xor values from source
const js = (await Bun.file(jsFile).text());
const xadd = parseInt((/K\[q\]\+=j\^(\d+)/).exec(js)[1]);
const xsub = parseInt((/K\[q\]\-=j\^(\d+)/).exec(js)[1]);

// Callbacks
const callbacks = {
    [84]: ({i:q,value:j})=>{K[q]+=j^xadd},
    [2]:  ({i:q,value:j})=>{K[q]-=j^xsub},
    [255]:(q)=>{
        console.log(String.fromCharCode.apply(null,K));
    }
};

var muxcallback = new JSCallback(
    (i,op,value) => { return callbacks[+op]({i, value, op}),170 },
    { returns: "int", args: [FFIType.int, FFIType.int, FFIType.int] }
)

// pwn!
lib.symbols.process(Buffer.from(_a), Buffer.from(_b), muxcallback.ptr);

Run this script in one of the docker images and it’ll print out the password:

$ docker run --entrypoint bun -v "./pwn.js:/pwn.js" --rm -it large:p1 /pwn.js
7cb8c5c6deffd55cc7f5e5e48f6b3676

Run the image separately to get your part of the flag. At 35 copy-pastes, we’re doing about as much work as the 40-ish copy-pastes of the Medium challenge. Putting together the 35 parts (each two characters), we get the final flag, giving us 5 points.

Flag 3: FLAG-822cf98e330afe10b01cd97c07b2b69a948f9b1899fee9c518d5c0eb9a6c4d91a


HomeAboutContact