← Back to Writeups
HTBN/ASteganography

Stomach Bug

XESXOR8/23/20263 min read
#steganography#htb#n/a

Stomach Bug

Platform: Metactf | Category: Steganography | Type: Challenge | Difficulty: Easy | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-10 | Status: Solved Techniques: nested_qr_decoding, ordered_hex_reassembly, utf8_to_latin1_byte_recovery

Summary

Task: a web endpoint continuously streams printable junk mixed with indexed hex chunks that hide an image. Solution: extract the numbered hex records, rebuild the PNG, decode two QR layers, repair UTF-8-expanded bytes with latin1 re-encoding, then base64-decode the final payload.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: metactf | ID: 20260410_metactf_stomach_bug
  • Tags: base64, qr_code, dawgctf, png_reconstruction, hex_dump, streaming_response
  • Indicators: an HTTP response streams forever as an attachment instead of returning a normal page, numbered hex lines like |000|...|161| can be concatenated by index into a binary file, the recovered image is grayscale and visually contains a QR code, QR output looks like corrupted binary until UTF-8 text is re-encoded as latin1 bytes
  • Source: 20260410_metactf_stomach_bug.md

Foothold

Vulnerability / Misconfiguration

  1. Nested_qr_decoding
  2. Ordered_hex_reassembly
  3. Utf8_to_latin1_byte_recovery
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • nested_qr_decoding
  • ordered_hex_reassembly
  • utf8_to_latin1_byte_recovery
  • Tags: base64, qr_code, dawgctf, png_reconstruction, hex_dump, streaming_response

Original Writeup

<details><summary>Click to expand original content</summary>

Description

Source challenge: Stomach Bug

URL: https://stomachbug.umbccd.net

English summary: the endpoint returns an endless attachment named spew.txt. Inside the stream, useful data appears as numbered hex records mixed with distracting printable ASCII lines.

Analysis

Recon observations:

  1. The server does not return a normal HTML page; it starts downloading spew.txt and keeps streaming.
  2. The body alternates between sliding printable ASCII text and lines of the form |000|... through |161|....
  3. The numbered lines are hex-only payload chunks. Sorting by index and concatenating them reconstructs a valid PNG.
  4. The recovered image is a 625x625 grayscale QR code.
  5. Decoding that QR yields PNG bytes that were expanded through UTF-8 text encoding, so they must be converted back with .decode("utf-8").encode("latin1") before opening the nested image.
  6. The second QR contains a base64 string, which decodes directly to the flag.

Solution

Extraction pipeline:

  1. Download only a short slice of the endless response.
  2. Regex-extract all numbered hex chunks.
  3. Sort them by numeric index and concatenate the hex payload.
  4. Convert the hex to bytes and save the first PNG.
  5. Decode the first QR.
  6. Repair the UTF-8-expanded PNG bytes with .decode("utf-8").encode("latin1").
  7. Decode the nested QR.
  8. Base64-decode the nested QR text to recover the flag.
#!/usr/bin/env python3

import base64
import io
import re

import requests
from PIL import Image
from pyzbar.pyzbar import decode


URL = "https://stomachbug.umbccd.net"
LINE_RE = re.compile(r"^\|(\d{3})\|([0-9a-fA-F]+)$")


def decode_qr(image_bytes: bytes) -> bytes:
    img = Image.open(io.BytesIO(image_bytes))
    result = decode(img)
    if not result:
        raise RuntimeError("QR code not found")
    return result[0].data


def fetch_sample(max_lines: int = 2000) -> str:
    lines = []
    with requests.get(URL, stream=True, timeout=15) as r:
        r.raise_for_status()
        for i, line in enumerate(r.iter_lines(decode_unicode=True), 1):
            if line is not None:
                lines.append(line)
            if i >= max_lines:
                break
    return "\n".join(lines)


def main() -> None:
    sample = fetch_sample()

    chunks = {}
    for line in sample.splitlines():
        m = LINE_RE.match(line.strip())
        if m:
            idx = int(m.group(1))
            chunks[idx] = m.group(2)

    if not chunks:
        raise RuntimeError("No numbered hex chunks found")

    first_png = bytes.fromhex("".join(chunks[i] for i in sorted(chunks)))
    qr1 = decode_qr(first_png)

    nested_png = qr1.decode("utf-8").encode("latin1")
    qr2 = decode_qr(nested_png)

    flag = base64.b64decode(qr2).decode()
    print(flag)


if __name__ == "__main__":
    main()
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR