Min Beste Venn
1,135 HTTP HEAD requests, all 404 — the information is in the shape of the **request paths**, not the responses
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}