$ Forensics: Jetpack Drift

BITSCTF 2026 By TaklaMan
Flag: BITSCTF{LF1_st4nds_4_1ost_fr0m_1ns1d3}

Summary of Method

I reconstructed the HTTP transfer from packet captures, decoded the chunked stream, rebuilt encrypted chunk order using hash chaining, decrypted with the recovered credential logic, carved the embedded PNG from decrypted output, and read the flag from that image.

Main Investigation Points

  1. Parsed chall.pcap (Linux cooked v2 / SLL2) and manually reassembled TCP streams.
  2. Identified the key flow:
    • GET /send-chunks.php
    • Large binary HTTP response with Transfer-Encoding: chunked
  3. Extracted supporting files from adjacent HTTP requests:
    • /encryption.py (AES-CTR encryption/chaining logic)
    • /database.sql (credential dataset)
  4. Decoded chunked payload and split records in this format:
    • NXTCHNKHASH:<64-hex>DATA:<ciphertext>
  5. Reconstructed correct record order by hash-chain traversal:
    • start chunk = ciphertext hash not referenced by any NXTCHNKHASH
    • follow NXTCHNKHASH pointers until terminal zero-hash
  6. Correlated web clue + DB row and selected password:
    • tyler13bradley1VL7p6Rcli8mxgkh
  7. Decrypted each ordered chunk using:
    • key0 = SHA256(password)
    • pt_i = AES-CTR(key_i, iv=key_i[:16]).decrypt(ct_i)
    • key_{i+1} = SHA256(pt_i)
  8. Carved embedded PNG signature from plaintext blob and extracted the image.
  9. Read flag text from the recovered image.

Main Program

#!/usr/bin/env python3
import struct
import hashlib
from pathlib import Path
from collections import defaultdict
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

PCAP = Path("chall.pcap")
OUT = Path("recovered_report")
OUT.mkdir(exist_ok=True)

def parse_pcap_sll2_ipv4_tcp(path):
    with path.open("rb") as f:
        gh = f.read(24)
        le = gh[:4] == b"\\xd4\\xc3\\xb2\\xa1"
        ph = "<IIII" if le else ">IIII"
        packets = []
        while True:
            h = f.read(16)
            if not h:
                break
            ts, us, inc, _ = struct.unpack(ph, h)
            d = f.read(inc)
            packets.append((ts + us / 1e6, d))
    return packets

def collect_tcp_payloads(pkts):
    flows = defaultdict(list)
    for ts, d in pkts:
        if len(d) < 20:
            continue
        ip = d[20:]  # SLL2 header is 20 bytes
        if len(ip) < 20 or (ip[0] >> 4) != 4:
            continue
        total_len = struct.unpack("!H", ip[2:4])[0]
        if len(ip) < total_len:
            continue
        ip = ip[:total_len]
        if ip[9] != 6:
            continue
        ihl = (ip[0] & 0x0F) * 4
        tcp = ip[ihl:]
        if len(tcp) < 20:
            continue
        sp, dp, seq, ack, off_flags, *_ = struct.unpack("!HHIIHHHH", tcp[:20])
        doff = ((off_flags >> 12) & 0x0F) * 4
        if len(tcp) < doff:
            continue
        payload = tcp[doff:]
        src = ".".join(map(str, ip[12:16]))
        dst = ".".join(map(str, ip[16:20]))
        flows[(src, sp, dst, dp)].append((seq, ts, payload))
    return flows

def reassemble(segs):
    seen = set()
    items = []
    for seq, ts, p in sorted(segs, key=lambda x: (x[0], x[1])):
        if not p:
            continue
        k = (seq, p)
        if k in seen:
            continue
        seen.add(k)
        items.append((seq, p))
    if not items:
        return b""
    base = min(s for s, _ in items)
    out = bytearray()
    for seq, p in items:
        off = seq - base
        end = off + len(p)
        if end > len(out):
            out.extend(b"\\x00" * (end - len(out)))
        for i, b in enumerate(p):
            if out[off + i] == 0:
                out[off + i] = b
    return bytes(out)

def decode_chunked(body):
    i = 0
    out = bytearray()
    while True:
        j = body.find(b"\\r\\n", i)
        n = int(body[i:j].split(b";", 1)[0], 16)
        i = j + 2
        if n == 0:
            break
        out.extend(body[i:i+n])
        i += n
        assert body[i:i+2] == b"\\r\\n"
        i += 2
    return bytes(out)

def aes_ctr_dec(data, key):
    dec = Cipher(algorithms.AES(key), modes.CTR(key[:16])).decryptor()
    return dec.update(data) + dec.finalize()

pkts = parse_pcap_sll2_ipv4_tcp(PCAP)
flows = collect_tcp_payloads(pkts)

# Reassemble key HTTP flow
req = reassemble(flows[("172.18.0.1", 46460, "172.18.0.10", 80)])
resp = reassemble(flows[("172.18.0.10", 80, "172.18.0.1", 46460)])
hdr, body = resp.split(b"\\r\\n\\r\\n", 1)
decoded = decode_chunked(body)
(OUT / "send_chunks.decoded").write_bytes(decoded)

# Parse NXTCHNKHASH records
pos = []
i = 0
while True:
    j = decoded.find(b"NXTCHNKHASH:", i)
    if j < 0:
        break
    pos.append(j)
    i = j + 1

records = []
for k, p in enumerate(pos):
    q = pos[k+1] if k+1 < len(pos) else len(decoded)
    rec = decoded[p:q]
    nxt = rec[12:76].decode()
    assert rec[76:81] == b"DATA:"
    enc = rec[81:]
    records.append({
        "nxt": nxt,
        "enc": enc,
        "hash": hashlib.sha256(enc).hexdigest()
    })

# Order by hash chain
hmap = {r["hash"]: i for i, r in enumerate(records)}
ref = {r["nxt"] for r in records if r["nxt"] != "0"*64}
start = [i for i, r in enumerate(records) if r["hash"] not in ref][0]

order = []
i = start
while True:
    order.append(i)
    nxt = records[i]["nxt"]
    if nxt == "0"*64:
        break
    i = hmap[nxt]

# Password recovered during DB analysis
password = "1VL7p6Rcli8mxgkh"
key = hashlib.sha256(password.encode()).digest()

plain = bytearray()
for idx in order:
    pt = aes_ctr_dec(records[idx]["enc"], key)
    plain.extend(pt)
    key = hashlib.sha256(pt).digest()

plain = bytes(plain)
(OUT / "plain.bin").write_bytes(plain)

# Carve PNG
sig = b"\\x89PNG\\r\\n\\x1a\\n"
off = plain.find(sig)
assert off != -1, "PNG signature not found"
png = plain[off:]
(OUT / "carved.png").write_bytes(png)

print("Done. Wrote:", OUT / "carved.png")

Recovered Flag

BITSCTF{LF1_st4nds_4_1ost_fr0m_1ns1d3}