FLARE-On 13 write-ups

RE with AI

flare-on 13: catthief

RC4 that restarts for every HTTP body, a custom Huffman, and five JPEGs with the flag written on a pirate scroll.

Me: 7:03, 18.22M tokens, $4.66, didn't step in.
Truix: 3:37, 6.15M tokens, $1.63, stepped in once.

Muse, Meta's model in my separate run a week later, opened two cat pictures, found no flag, and started theorizing about steganography. The next picture had the flag written on a scroll. It called this an "embarrassing near-miss" in its notes. Fair enough.

In my original FLARE-On run, Sol split a Rust executable and a packet capture between two RE agents: CatBinary worked out what the executable put on the wire, while CatTraffic reconstructed the HTTP bodies and wrote a separate decoder. Both recovered the same five JPEGs, byte for byte. Getting those images back meant restarting RC4 for every body and parsing a Huffman header that doesn't end on a byte boundary.

Start with the six HTTP exchanges

5-catthief.7z contains two inputs:

File SHA-256
catthief/catthief.exe 904e67200ac373a8929e04e799a805d57f0032325e6cf0bf808c69721cb94c22
catthief/capture.pcapng bdc90661caa86c1d9ccff8d04b1cef5ac656237813e17091687ebb0fa34decec

The executable is Windows x64. CatBinary used the byte-identical copy at catthief/re-analysis/working/catthief-CatBinary.exe. This one needs no live target: the executable and capture contain everything needed to recover the files offline.

powershell codewrap
Get-FileHash -Algorithm SHA256 catthief/catthief.exe
Get-FileHash -Algorithm SHA256 catthief/capture.pcapng

Open the capture in Wireshark and apply:

text codewrap
http.request || http.response

Add tcp.stream as a column. There are six conversations from 192.168.56.103 to 192.168.56.102:8080. Each contains one POST /exfil and an HTTP 200 response. Across those six requests, the first body is only 264 bytes. The other five are several megabytes each.

The client starts with READY; each nonempty server reply names the file to upload in the next request.

TShark gives the same view without clicking through the packets:

powershell codewrap
& 'C:/Program Files/Wireshark/tshark.exe' -r catthief/capture.pcapng -Y 'http.request || http.response' -T fields -e frame.number -e tcp.stream -e http.request.method -e http.request.uri -e http.response.code -e http.content_length

These are reassembled HTTP body sizes. A multi-megabyte request spans many TCP packets, so taking the payload from the packet carrying its HTTP header would give you an incomplete file.

The binary contains rustls and ring library strings. Those were distracting here. The capture has readable HTTP headers, including the route and body lengths. This connection is plain HTTP with a separate transform applied to the bodies.

The Accept-Encoding: gzip request header describes the response encoding the client accepts, not the uploaded bytes. Sol still had to recover the application's own packing.

Wireshark's Follow → HTTP Stream view lets you read headers and check direction. For the decoder, export the reassembled objects into a fresh directory:

powershell codewrap
New-Item -ItemType Directory re-analysis/cattraffic/objects-reader | Out-Null
& 'C:/Program Files/Wireshark/tshark.exe' -r catthief/capture.pcapng -q --export-objects http,re-analysis/cattraffic/objects-reader

All the objects use the route basename exfil. TShark adds numeric suffixes in capture order:

Stream POST frame Request object Request bytes Response frame Response object Response bytes
0 7 exfil 264 11 exfil(1) 36
1 616 exfil(2) 3,121,086 623 exfil(3) 37
2 1027 exfil(4) 2,736,058 1032 exfil(5) 40
3 1675 exfil(6) 3,061,432 1678 exfil(7) 36
4 2271 exfil(8) 3,079,606 2287 exfil(9) 40
5 2800 exfil(10) 2,842,789 2806 No body 0

The last response contains HTTP headers but no body. There are eleven exported bodies to account for, not twelve, and no missing sixth image.

Find the code that builds the body

While CatTraffic worked on those boundaries, CatBinary followed the application strings through the Rust binary. I'd take the literal /exfil URL over digging through rustls any day.

In IDA, Shift+F12 gets you to the strings. Search for the combined literal containing:

text codewrap
READYhttp://192.168.56.102:8080/exfilERROR

It's at VA 0x1400C9130, original file offset 0xC7930. The nearby src\compress.rs string gives another place to look, though the Rust source itself isn't included.

The reference near 0x140002A00 leads into the large challenge routine at 0x1400029AA. Within it, 0x1400034B3 builds and XOR-decodes the 25-byte key buffer just before the upload path's first RC4 call.

All addresses here use the preferred image base, 0x140000000:

text codewrap
RVA = VA - 0x140000000

For the .text windows shown here, the file offset is RVA - 0xC00. That relationship doesn't apply to every section.

If you want the same bounded windows through IDASQL, open the private copy once:

powershell codewrap
& '<IDA 9.3>/idasql.exe' -s catthief/re-analysis/working/catthief-CatBinary.exe --http 0 --bind 127.0.0.1

Use the localhost endpoint printed by that process in another terminal. Replace <port> with its port:

powershell codewrap
curl.exe --silent --show-error --fail-with-body --max-time 60 --data-binary "SELECT * FROM binary; SELECT disasm_range(0x1400034B3,0x140003510); SELECT disasm_range(0x1400052F1,0x14000539E); SELECT disasm_range(0x140005284,0x1400052DE); SELECT disasm_range(0x1400041D0,0x1400041F5);" http://127.0.0.1:<port>/query

When finished inspecting the database, stop the IDASQL server you started:

powershell codewrap
curl.exe --silent --show-error --max-time 60 -X POST http://127.0.0.1:<port>/shutdown

This first window is VA 0x1400034B3 through 0x14000350B, RVA 0x34B3 through 0x350B, starting at file offset 0x28B3:

x86asm codewrap
1400034b3: movups  [rbp+0C80h+var_289], xmm8
1400034bb: mov     rax, 407576C41004A6Ch
1400034c5: mov     [rbp+0C80h+var_279], rax
1400034cc: mov     [rbp+0C80h+var_271], 7
1400034d3: xor     eax, eax
1400034d5: cmp     rax, 19h
1400034d9: jz      short loc_1400034E8
1400034db: xor     byte ptr [rbp+rax+0C80h+var_289], 33h
1400034e3: inc     rax
1400034e6: jmp     short loc_1400034D5
1400034e8: lea     rsi, [rbp+0C80h+var_BA8]
1400034ef: mov     rcx, rsi
1400034f2: lea     rdx, [rbp+0C80h+var_289]
1400034f9: call    sub_1400052E2
1400034fe: mov     rcx, rsi
140003501: mov     rdx, [rbp+0C80h+var_D8]
140003508: mov     r8, rbx
14000350b: call    sub_140005281

That constructs a 25-byte buffer and XORs every byte with 0x33.

The first 16 bytes come from xmm8. Its earlier load, at 0x140002A49, is:

x86asm codewrap
movaps xmm8, cs:xmmword_1400C9010

The constant is at VA 0x1400C9010, RVA 0xC9010, file offset 0xC7810. Read it directly:

powershell codewrap
xxd -g 1 -s 0xC7810 -l 16 catthief/catthief.exe

The encoded bytes are:

text codewrap
5a 02 02 6c 51 00 6c 43 02 46 5d 57 00 41 02 5d

The next eight bytes come from the immediate 0x0407576C41004A6C. The store is little-endian, so its bytes in memory are:

text codewrap
6c 4a 00 41 6c 57 07 04

The final byte is 07.

That gives 16 + 8 + 1 = 25 bytes. 19h in the loop is decimal 25. Notice the encoded zero bytes: this loop uses a fixed length, so it doesn't stop at the first zero.

You can reproduce the key derivation without copying the answer into your decoder:

powershell codewrap
python -c "from pathlib import Path; encoded=Path('catthief/catthief.exe').read_bytes()[0xC7810:0xC7820]+(0x0407576C41004A6C).to_bytes(8,'little')+bytes([7]); print(bytes(b^0x33 for b in encoded))"

The result is:

text codewrap
b'i11_b3_p1und3r1n_y3r_d474'

Its hex representation is:

text codewrap
69 31 31 5f 62 33 5f 70 31 75 6e 64 33 72 31 6e
5f 79 33 72 5f 64 34 37 34

The suffix _y3r_d474 is a convenient check that you handled the immediate's byte order correctly.

RC4 starts over for every body

The key is readable now. Before feeding it several megabytes, the remaining question is where the state resets.

The call at 0x1400034F9 goes to sub_1400052E2, the RC4 key-scheduling routine. Its destination state pointer has been moved into R8 just before this window.

This is VA 0x1400052F1 through 0x140005395, RVA 0x52F1 through 0x5395, beginning at file offset 0x46F1:

x86asm codewrap
1400052f1: xor     r9d, r9d
1400052f4: lea     rdi, [rsp+220h+var_120]
1400052fc: mov     ecx, 40h ; '@'
140005301: xor     eax, eax
140005303: rep stosd
140005305: mov     [rsp+r9+220h+var_120], r9b
14000530d: inc     r9
140005310: cmp     r9, 100h
140005317: jnz     short loc_140005305
140005319: xor     r11d, r11d
14000531c: mov     bl, 19h
14000531e: mov     r9, rsp
140005321: lea     r10, [rsp+220h+var_120]
140005329: xor     r14d, r14d
14000532c: movzx   eax, r14b
140005330: div     bl
140005332: cmp     r14, 100h
140005339: jz      short loc_140005383
14000533b: movzx   eax, al
14000533e: imul    eax, -19h
140005341: movzx   eax, al
140005344: mov     ecx, 100h
140005349: mov     rdi, r9
14000534c: mov     rsi, r10
14000534f: rep movsb
140005351: mov     cl, [rsp+r14+220h+var_220]
140005355: add     r11b, cl
140005358: add     eax, r14d
14000535b: movzx   eax, al
14000535e: add     r11b, [rdx+rax]
140005362: movzx   eax, r11b
140005366: mov     sil, [rsp+rax+220h+var_220]
14000536a: mov     [rsp+r14+220h+var_220], sil
14000536e: inc     r14
140005371: mov     [rsp+rax+220h+var_220], cl
140005374: mov     ecx, 100h
140005379: mov     rdi, r10
14000537c: mov     rsi, r9
14000537f: rep movsb
140005381: jmp     short loc_14000532C
140005383: lea     rsi, [rsp+220h+var_120]
14000538b: mov     ecx, 100h
140005390: mov     rdi, r8
140005393: rep movsb
140005395: and     word ptr [r8+100h], 0

There is a lot of copying around a small algorithm.

rep stosd clears 64 DWORDs, or 256 bytes. The loop at 0x140005305 then initializes the permutation with S[i] = i, stopping at 0x100.

R14 is the key-scheduling index i. R11B holds j. The divisor in BL is 0x19, the 25-byte key length. The division, multiplication by -25, and addition calculate i % 25.

The byte additions at 0x140005355 and 0x14000535E implement:

text codewrap
j = (j + S[i] + key[i % 25]) & 255

The stores at 0x14000536A and 0x140005371 swap S[i] and S[j]. The repeated movsb operations copy temporary permutation buffers; they don't imply extra cipher rounds.

At the end, the word-sized clear at [r8+0x100] resets two adjacent bytes. Those are the stream indices i and j.

The second call in the upload block, sub_140005281, is the RC4 byte generator. Here RCX points to that state, RDX points to the body, and R8 is the body length.

This window is VA 0x140005284 through 0x1400052DC, RVA 0x5284 through 0x52DC, starting at file offset 0x4684:

x86asm codewrap
140005284: mov     r9b, [rcx+100h]
14000528b: mov     al, [rcx+101h]
140005291: inc     r9b
140005294: movzx   r9d, r9b
140005298: xor     r10d, r10d
14000529b: cmp     r8, r10
14000529e: jz      short loc_1400052DE
1400052a0: lea     r11d, [r9+r10]
1400052a4: mov     [rcx+100h], r11b
1400052ab: movzx   r11d, r11b
1400052af: mov     bl, [rcx+r11]
1400052b3: add     al, bl
1400052b5: mov     [rcx+101h], al
1400052bb: movzx   esi, al
1400052be: mov     dil, [rcx+rsi]
1400052c2: mov     [rcx+r11], dil
1400052c6: mov     [rcx+rsi], bl
1400052c9: add     bl, [rcx+r11]
1400052cd: movzx   r11d, bl
1400052d1: mov     r11b, [rcx+r11]
1400052d5: xor     [rdx+r10], r11b
1400052d9: inc     r10
1400052dc: jmp     short loc_14000529B

R10 counts body bytes. For each byte, the function advances i, adds S[i] to j, swaps the two permutation entries, and selects S[(S[i] + S[j]) & 255]. The instruction at 0x1400052D5 XORs that keystream byte into the body in place.

The upload path calls KSA at 0x1400034F9 immediately before PRGA at 0x14000350B. The response path does the same thing at 0x1400041DA and 0x1400041F0.

So every request body starts with a fresh RC4 state. Every response body also starts with a fresh RC4 state. Keeping one continuous stream across the capture is wrong, even if you preserve packet order perfectly: carrying the state from the control request into the first filename response would use the wrong part of the keystream.

The recovery script implements that by making the state local to each rc4() call:

python codewrap
KEY = b"i11_b3_p1und3r1n_y3r_d474"

def rc4(data: bytes) -> bytes:
    state = list(range(256))
    j = 0
    for i in range(256):
        j = (j + state[i] + KEY[i % len(KEY)]) & 255
        state[i], state[j] = state[j], state[i]
    i = j = 0
    out = bytearray(len(data))
    for pos, byte in enumerate(data):
        i = (i + 1) & 255
        j = (j + state[i]) & 255
        state[i], state[j] = state[j], state[i]
        out[pos] = byte ^ state[(state[i] + state[j]) & 255]
    return bytes(out)

The cheapest check is the first response, exfil(1). It is only 36 bytes. With a fresh state, it decrypts to:

text codewrap
C:\Users\flare\Desktop\important.jpg

The plaintext bytes are:

text codewrap
43 3a 5c 55 73 65 72 73 5c 66 6c 61 72 65 5c 44
65 73 6b 74 6f 70 5c 69 6d 70 6f 72 74 61 6e 74
2e 6a 70 67

The remaining nonempty responses decode to:

text codewrap
C:\Users\flare\Desktop\anothercat.jpg
C:\Users\flare\Desktop\yetanothercat.jpg
C:\Users\flare\Desktop\catcatcat.jpg
C:\Users\flare\Desktop\myfavoritecat.jpg

The response names the next file to upload. exfil(7) asks for catcatcat.jpg; the image itself is in the following request, exfil(8).

The JPEG starts five bits earlier than you might expect

The server responses are RC4-only. The outgoing bodies have another layer: the client compresses them, then applies RC4.

A correctly decrypted upload still doesn't start with JPEG magic. The first large upload begins:

text codewrap
20 00 05 f3 d7 00 e1 01 01 ...

The tiny control request begins:

text codewrap
20 00 00 00 00 40 00 00 ...

The src\compress.rs string, bit writer, and 256-entry table of small code lengths pointed to Huffman; assigning canonical codes from those lengths recovered READY and then all five JPEGs.

In the challenge routine, the calls at 0x1400033B8 and 0x1400033C7 pass widths 11 and 32 to the bit writer, sub_1400053FE. The following writes at 0x1400033D9 and 0x1400033EE serialize the symbol-length table and packed data. The writer processes bits most significant first.

The frame layout is:

Bit offset Width Field
0..10 11 bits Alphabet size, 256
11..42 32 bits Packed-data byte count
43..2090 256 × 8 bits Huffman code length for each byte value, in symbol order
2091 onward Packed byte count × 8 bits Huffman-coded payload, MSB-first

The 32-bit field is the number of compressed bytes. It is not the decoded image size.

The header is:

text codewrap
11 + 32 + 256*8 = 2091 bits

That puts the payload three bits into byte 261. A parser that rounds the header up to 262 bytes and starts decoding there discards the first five payload bits.

The total physical body length still works out to:

text codewrap
ceil((2091 + packed_byte_count*8) / 8)

CatBinary initially described the format as a fixed 256-byte table plus an eight-byte length/header area, before resolving the bit writer. Its first READY test also decoded to READYDDDD because it read through the physical end of the buffer. Restricting the decoder to the declared encoded-bit end changed that to READYDD. The last two extra letters belong there.

The declared packed payload can contain padding that decodes as extra symbols, and the last physical byte has alignment bits outside that payload that must not be decoded at all.

The control message makes this easy to see. Its only used symbols produce these canonical codes:

Symbol Code
D 00
R 01
Y 10
A 110
E 111

The bits are 01 111 110 00 10 | 00 00, or R E A D Y | D D: 12 data bits plus four zero padding bits fill the two packed bytes. The correct bounded result is:

text codewrap
b'READYDD'

I much prefer debugging this on seven letters rather than waiting for an image viewer to complain.

Diagram of the six HTTP exchanges, their exported bodies, and the unaligned Huffman header

The exchanges and bit layout in one diagram. The payload starts inside byte 261, because apparently a byte boundary was too much to ask.

Rebuild the canonical Huffman table

The frame stores code lengths rather than a tree or explicit bit strings. Canonical Huffman codes save the decoder from having to reproduce how the encoder built its tree.

To reconstruct the canonical codes, discard symbols with length zero, then sort the remaining symbols by (length, symbol). Start the code value at zero. Whenever the length increases, left-shift the code by the increase. Assign that code to the current symbol, increment it, and continue.

re-analysis/cattraffic/recover.py uses a lookup table rather than walking a tree for every bit. If the longest code is width bits, a shorter code occupies every lookup entry sharing its prefix. With width 3, D=00 fills both 000 and 001. Each entry stores both the symbol and the number of bits consumed.

The decompressor:

python codewrap
HEADER_BITS = 11 + 32 + 256 * 8
HEADER_BYTES = (HEADER_BITS + 7) // 8

def decompress(data: bytes) -> tuple[bytes, int]:
    """Decode a 256-symbol MSB-first canonical Huffman frame.

    Returns (decoded bytes, packed byte count); decoded trailing bytes may be
    padding symbols because the frame stores encoded length, not decoded length.
    """
    if len(data) < HEADER_BYTES:
        raise ValueError("Truncated Huffman header")
    header = int.from_bytes(data[:HEADER_BYTES], "big") >> (8 * HEADER_BYTES - HEADER_BITS)
    lengths = (header & ((1 << 2048) - 1)).to_bytes(256, "big")
    packed_len = (header >> 2048) & 0xFFFFFFFF
    alphabet = header >> (2048 + 32)
    if alphabet != 256:
        raise ValueError(f"Unsupported Huffman alphabet: {alphabet}")
    if (HEADER_BITS + packed_len * 8 + 7) // 8 != len(data):
        raise ValueError("Encoded byte count does not match frame length")
    symbols = sorted((length, symbol) for symbol, length in enumerate(lengths) if length)
    if not symbols:
        raise ValueError("Empty Huffman alphabet")
    width = max(lengths)
    min_length = symbols[0][0]
    if width > 15:
        raise ValueError(f"Unexpected Huffman code width: {width}")
    if sum(1 << (width - length) for length, _ in symbols) != 1 << width:
        raise ValueError("Invalid Huffman code lengths")
    lookup = [None] * (1 << width)
    code = last_length = 0
    for length, symbol in symbols:
        code <<= length - last_length
        base = code << (width - length)
        repeat = 1 << (width - length)
        lookup[base : base + repeat] = [(symbol, length)] * repeat
        code += 1
        last_length = length
    if any(entry is None for entry in lookup):
        raise ValueError("Incomplete Huffman lookup")

    # Look at up to width bits without reading past the actual encoded bit count.
    # A zero-filled spare byte permits a final lookahead at the file boundary.
    data += b"\0"
    bit_pos = HEADER_BITS
    end_bit = HEADER_BITS + packed_len * 8
    output = bytearray()
    while bit_pos + min_length <= end_bit:
        pos = bit_pos >> 3
        offset = bit_pos & 7
        bits = ((data[pos] << 8) | data[pos + 1]) >> (16 - width - offset)
        symbol, length = lookup[bits & ((1 << width) - 1)]
        if bit_pos + length > end_bit:
            break
        output.append(symbol)
        bit_pos += length
    return bytes(output), packed_len

HEADER_BYTES is 262 because reading the header requires part of that last byte. The right shift removes the five payload bits from the integer used to parse the header. It doesn't remove them from the original data buffer. The decode loop then starts at bit 2091, so those bits are still there when needed.

The code-length check expresses completeness in units of the longest code. A code of length length covers 2**(width - length) entries in the lookup table. The sum must cover exactly 2**width entries.

The captured frames have these code lengths:

Body Packed bytes Code-length distribution
exfil 2 251 unused symbols, three 2-bit codes, two 3-bit codes
exfil(2) 3,120,824 Three 7-bit codes, 247 8-bit codes, six 9-bit codes
exfil(4) 2,735,796 One 6-bit code, one 7-bit code, 246 8-bit codes, eight 9-bit codes
exfil(6) 3,061,170 Eight 7-bit codes, 232 8-bit codes, sixteen 9-bit codes
exfil(8) 3,079,344 Eleven 7-bit codes, 223 8-bit codes, twenty-two 9-bit codes
exfil(10) 2,842,527 Two 7-bit codes, 250 8-bit codes, four 9-bit codes

The two-byte lookahead is enough for the code widths in this capture, which top out at nine bits. It isn't a general 15-bit decoder despite the looser width check: width + offset must fit within those 16 lookahead bits.

Recover all five images

The RC4 and Huffman functions above are the core of recover.py. The rest of the script supplies the object mapping, checks the decoded boundaries, and writes the images only after all inputs have decoded successfully. Those two functions and the driver below make up the complete script.

The driver and its imports:

python codewrap
import argparse
from pathlib import Path
import sys

def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--objects", type=Path, required=True, help="tshark --export-objects http directory")
    parser.add_argument("--output", type=Path, required=True, help="new output directory")
    args = parser.parse_args()
    if not args.objects.is_dir():
        parser.error("--objects must be an existing directory")
    if args.output.exists():
        parser.error("--output must not exist (recovery never overwrites files)")
    names = ["exfil", "exfil(2)", "exfil(4)", "exfil(6)", "exfil(8)", "exfil(10)"]
    reply_names = [f"exfil({i})" for i in (1, 3, 5, 7, 9)]
    for name in names + reply_names:
        if not (args.objects / name).is_file():
            parser.error(f"Missing exported HTTP body: {name}")
    replies = [rc4((args.objects / name).read_bytes()).decode("utf-8") for name in reply_names]
    for i, reply in enumerate(replies):
        print(f"server reply {i}: {reply}")
    # Decode first; a malformed sample must not create a partial output directory.
    decoded = []
    for stream, name in enumerate(names):
        body = (args.objects / name).read_bytes()
        plaintext, packed_len = decompress(rc4(body))
        if stream == 0:
            if not plaintext.startswith(b"READY"):
                raise ValueError("The control message does not begin with READY")
            print(f"stream 0: {len(body)} HTTP bytes; {packed_len} packed bytes; {plaintext!r}")
            continue
        if not plaintext.startswith(b"\xff\xd8"):
            raise ValueError(f"Stream {stream} does not begin with JPEG SOI")
        eoi = plaintext.rfind(b"\xff\xd9")
        if eoi < 0 or len(plaintext) - (eoi + 2) > 8:
            raise ValueError(f"Stream {stream} has no terminal JPEG EOI within 8 bytes of its end")
        clean = plaintext[: eoi + 2]
        print(f"stream {stream}: {len(body)} HTTP bytes; {packed_len} packed bytes; "
              f"JPEG {len(clean)} bytes; {len(plaintext) - len(clean)} padding bytes; "
              f"name {Path(replies[stream-1].replace(chr(92), '/')).name}")
        decoded.append((stream, clean))
    args.output.mkdir(parents=True)
    for stream, body in decoded:
        path = args.output / f"stream-{stream}.jpg"
        path.write_bytes(body)
        print(f"wrote {path}")
    return 0

if __name__ == "__main__":
    try:
        sys.exit(main())
    except (OSError, UnicodeError, ValueError) as error:
        sys.exit(f"Error: {error}")

The transform order is visible in one line:

python codewrap
plaintext, packed_len = decompress(rc4(body))

Responses go through rc4() alone. Requests go through RC4 and then Huffman decoding.

The frame doesn't contain an exact decoded-byte count, so the driver checks the JPEG's own boundaries. It requires SOI, ff d8, at the start, then finds a terminal EOI, ff d9, with no more than eight decoded bytes after it. It won't silently discard arbitrary amounts of trailing output.

CatBinary's first fast image decoder reported one extra zero byte after each JPEG. Its loop checked whether a symbol started before end, but didn't reject a symbol whose full code crossed that boundary. The Python decoder has the additional bit_pos + length > end_bit check and reported zero trailing bytes for all five images. After the EOI boundary was applied, the two implementations produced the same JPEGs.

Run the script against your fresh exports:

powershell codewrap
python re-analysis/cattraffic/recover.py --objects re-analysis/cattraffic/objects-reader --output re-analysis/cattraffic/recovered-reader

recovered-reader must not already exist. If you're using Sol's existing exports, change the input to re-analysis/cattraffic/objects and keep a fresh output directory.

The script printed:

text codewrap
server reply 0: C:\Users\flare\Desktop\important.jpg
server reply 1: C:\Users\flare\Desktop\anothercat.jpg
server reply 2: C:\Users\flare\Desktop\yetanothercat.jpg
server reply 3: C:\Users\flare\Desktop\catcatcat.jpg
server reply 4: C:\Users\flare\Desktop\myfavoritecat.jpg
stream 0: 264 HTTP bytes; 2 packed bytes; b'READYDD'
stream 1: 3121086 HTTP bytes; 3120824 packed bytes; JPEG 3123324 bytes; 0 padding bytes; name important.jpg
stream 2: 2736058 HTTP bytes; 2735796 packed bytes; JPEG 2738892 bytes; 0 padding bytes; name anothercat.jpg
stream 3: 3061432 HTTP bytes; 3061170 packed bytes; JPEG 3065529 bytes; 0 padding bytes; name yetanothercat.jpg
stream 4: 3079606 HTTP bytes; 3079344 packed bytes; JPEG 3084399 bytes; 0 padding bytes; name catcatcat.jpg
stream 5: 2842789 HTTP bytes; 2842527 packed bytes; JPEG 2844616 bytes; 0 padding bytes; name myfavoritecat.jpg

The script writes stream-1.jpg through stream-5.jpg. All five recovered images are 2816×1536 RGB JPEGs. Pillow found no EXIF, ICC profile, or JPEG comment through its metadata fields.

For catcatcat.jpg, check the fourth output:

powershell codewrap
Get-FileHash -Algorithm SHA256 re-analysis/cattraffic/recovered-reader/stream-4.jpg
Start-Process re-analysis/cattraffic/recovered-reader/stream-4.jpg

It should be 3,084,399 bytes with SHA-256:

text codewrap
fc78f7b3ac326e5925a77f431169d43494af6637b8b136d1a89b38c4287bc635

Read the scroll

catcatcat.jpg is the fourth large upload, TCP stream 4, object exfil(8). The initial READY exchange is stream 0 and doesn't count as an image.

Open it and look at the parchment beside the cat. The text is in the image pixels, which explains why searching the capture or JPEG metadata for an email address wouldn't find it.

This was the third image Muse checked, not the third upload. There were only five pictures.

Both of Sol's RE agents recovered the image, and there it was.

The recovered JPEG with the flag inked across a parchment scroll

A crop of the scroll. No steganography required.

The two lines read:

text codewrap
th3r3_b3_tr34sure_1n_
th4t_pc4p@flare-on.com

Join them without a space or newline. CTFd accepted it. Treasure in the PCAP, written on a pirate scroll beside a cat. I don't think the hint could have been much more literal.

The flag: