Min Beste Venn

1,135 HTTP HEAD requests, all 404 — the information is in the shape of the **request paths**, not the responses

2026.09.30 NNS CTF 2026 68 pts Forensics
FLAG NNS{1_l0v3_ch4tt1ng_w1th_m1n_b3st3_v3nn_1n_th3_cl0ud5}

Description: Signal wasn’t secure enough so we moved to something else. I heard about something called chatflare, and it seemed interesting.

Files: capture.pcap

Analysis

The pcap contains 3662 packets / 27.8s of traffic, entirely a single TCP conversation of 1135 HTTP HEAD requests to static.notion-static.com (impersonating Notion’s real static asset CDN, fronted by Cloudflare). Every single response is 404 Not Found — so the response body/status code carries no information.

Request paths follow one of four shapes:

/cf<sessionid>/h/s<n>.css               (handshake, 12x)
/cf<sessionid>/c/s<n>.css               (handshake, 19x)
/cf<sessionid>/d/h/<sender>/<num>.css   (377x)
/cf<sessionid>/d/c/<sender>/<num>.css   (727x)

<sender> is 0 or 1 — the two chat participants. <num> looked at first like it might be octal-encoded data (only digits 0-7 appear), but decoding it directly just reproduces a boring monotonic counter (0..255 cycling) — a red herring / cache-warming decoy to make the traffic look like normal CDN prefetching.

The real covert channel is the cf-cache-status response header (HIT / MISS), Cloudflare’s real per-edge cache signal — exactly what “chatflare” is hinting at: the two parties are smuggling data through Cloudflare’s shared cache state instead of the request/response bodies.

Treating HIT = 1, MISS = 0, and reading the bits in request order, grouped by sender, 8 bits per byte, channel d/c/1/* (sender 1’s “c” channel) decodes to:

NNS{1_l0v3_ch4tt1ng_w1th_m1n_b3st3_v3nn_1n_th3_cl0ud5}

(the first ~30 bytes of the stream are null-byte padding/preamble; the very first couple of bits are corrupted, likely from packets missing at the very start of the capture, but the payload resyncs perfectly one byte later, so the recovered flag is unambiguous). Channel d/h/1/* decodes to a plaintext side-message: can i haz flag? — the request that prompted the reply containing the flag.

Solution script

import subprocess, re

def get_rows():
    out_resp = subprocess.run(
        ["tshark", "-r", "capture.pcap", "-Y", "http.response", "-T", "fields",
         "-e", "frame.number", "-e", "http.request_in", "-e", "http.response.line"],
        capture_output=True, text=True).stdout
    status_by_reqframe = {}
    for line in out_resp.splitlines():
        parts = line.split("\t")
        if len(parts) < 3:
            continue
        m = re.search(r"cf-cache-status:\s*(\w+)", parts[2])
        if m:
            status_by_reqframe[parts[1]] = m.group(1)

    out_req = subprocess.run(
        ["tshark", "-r", "capture.pcap", "-Y", "http.request", "-T", "fields",
         "-e", "frame.number", "-e", "http.request.uri"],
        capture_output=True, text=True).stdout
    rows = []
    for line in out_req.splitlines():
        fnum, uri = line.split("\t")
        rows.append((fnum, uri, status_by_reqframe.get(fnum, "?")))
    return rows

groups = {}
for fnum, uri, status in get_rows():
    m = re.match(r"/cf\d+/d/(h|c)/(\d+)/(\d+)\.css", uri)
    if not m:
        continue
    typ, sender, num = m.groups()
    groups.setdefault((typ, sender), []).append(status)

bits = "".join("1" if s == "HIT" else "0" for s in groups[("c", "1")])
flag = "".join(chr(int(bits[i:i+8], 2)) for i in range(0, len(bits) - 7, 8))
print(flag)

Flag

NNS{1_l0v3_ch4tt1ng_w1th_m1n_b3st3_v3nn_1n_th3_cl0ud5}