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.
- small.tar
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:
- The default container command is
/entrypoint ruby /1680322826
- From the order, it’s safe to assume
/entrypoint
is the same in all images, and the ruby source code changes between them. - An environment variable named
ENC
containing base64 data? Decodes to gibberish but might be the encrypted flag.
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:
- xor each number in
DATA
(the stuff after__END__
in the initial ruby code) with x (3230824802
) ->e
- obtains user input and trims it ->
i
- compares that the bytes in the user input are equal with the values in
e
, exiting with code1
(0.succ
) if there are any differences - exits with
1
if the sizes are different
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:
- During the build process, the source code was added then removed. As you may know, Docker images keep each intermediary build step in separate “layers”, so we can still look at the source code, we’ll just have to be a little bit clever to extract it.
- There’s a new environment variable,
X
. Assuming the entrypoint really hasn’t changed, the mx0 binary is what’s using this. - A variable is set during the build process; we should look for that in the go code.
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
Home About Contact