SunshineCTF 2025 - PlutoChat [237]


✉️ Challenge Overview

We got two files: a 64-bit ELF binary and a pcap capture.

First thing, I started with the pcap. It had 22 packets over loop-able 31337 port and not all of them carried data.

Wireshark overview of the pcap file, showing 22 packets over TCP port 31337.

Using tcp.len > 0, they left me with 8 packets carrying actual payloads.

Wireshark filtered view showing 8 packets with data.

Confusing about the pcap, I moved to the binary. I ran strings on the binary and saw some fun stuff:

Type a username to send a message to, or 'EXIT' to exit:
EXIT
Exiting PlutoChat...
Send a message to %s (or 'EXIT' to select a new user):
Login successful! Welcome to PlutoChat!
%d %d
New message from %s: %s
There was an error initializing PlutoChat. Please try again.
Login to PlutoChat
Username:
Password:
Could not connect to PlutoChat servers. Try again later!
127.0.0.1

Looks like a chat client.

🧠 Investigation and Discovery

I loaded the binary into Ghidra to trace how the encryption works. The function that eventually calls send() builds the message like this: it allocates a buffer, writes a random 4-byte header plus the plaintext length, prepares the cipher state, encrypts the message, and then sends [header][length][ciphertext].

Functions breakdown:

  • FUN_00101687
    Generates a random 4-byte header using two rand() calls. This header acts as the seed for key expansion.

  • FUN_00101510
    Expands the 4-byte header into an 80-byte key:

    1. Repeatedly rotates the seed and collects 20 words.
    2. Rearranges them using the 20-byte permutation table (DAT_00104100).
    3. Runs the result through the 256-byte substitution table (DAT_00104120) with feedback XOR.

    Example view of the function

  • FUN_00101389
    Implements RC4 Key Scheduling Algorithm (KSA). Initializes the S-box with 0–255, then scrambles it with the 80-byte key.

  • FUN_00101285
    A helper that swaps two entries in the RC4 state array. Used by both KSA and PRGA.

  • FUN_001012e5
    Implements RC4 PRGA. Updates indices, swaps values, and outputs one keystream byte at a time.

  • FUN_00101452
    The encryption loop. Takes each plaintext byte, XORs it with the next keystream byte from FUN_001012e5, and produces ciphertext.

  • FUN_001014bf
    A keystream “warm-up” function. It discards a specified number of initial PRGA bytes (classic RC4 mitigation).

  • FUN_001014f8
    Simple 32-bit rotate-left, used during key expansion.

  • FUN_001016a7
    The “message sender.” It ties everything together:

    1. Generates a random header.
    2. Sets up the RC4 state with FUN_00101510 + FUN_00101389.
    3. Encrypts plaintext with FUN_00101452.
    4. Sends [header][length][ciphertext] via send().

🔧 Decrypting the Messages

Since I already knew that there are 8 packets with messages, using my best friend ChatGPT, I generated a script to pull the payloads from the pcap:

...
def parse_pcap(path):
    with open(path,'rb') as f:
        data = f.read()
    off = 24
    while off + 16 <= len(data):
        ts_sec, ts_usec, incl_len, orig_len = struct.unpack('<IIII', data[off:off+16])
        off += 16
        pkt = data[off:off+incl_len]
        off += incl_len
        yield ts_sec + ts_usec/1e6, pkt
def parse_ipv4_tcp(pkt):
    if len(pkt) < 14 or pkt[12:14] != b'\x08\x00':
        return None
    ip = pkt[14:]
    ihl = (ip[0] & 0x0F)*4
    if len(ip) < ihl:
        return None
    if ip[9] != 6:  # not TCP
        return None
    tcp = ip[ihl:]
    if len(tcp) < 20:
        return None
    doff = ((tcp[12] >> 4) & 0x0F)*4
    if len(tcp) < doff:
        return None
    payload = tcp[doff:]
    src = '.'.join(map(str, ip[12:16]))
    dst = '.'.join(map(str, ip[16:20]))
    sport = int.from_bytes(tcp[0:2],'big')
    dport = int.from_bytes(tcp[2:4],'big')
    return (src, sport, dst, dport), payload

def reassemble_streams(pcap_path):
    streams = defaultdict(bytearray)
    for t,pkt in parse_pcap(pcap_path):
        parsed = parse_ipv4_tcp(pkt)
        if not parsed:
            continue
        key, payload = parsed
        if payload:
            streams[key] += payload
    return streams

def extract_frames_from_buffer(buf):
    frames = []
    off = 0
    while True:
        if off + 8 > len(buf):
            break
        header = buf[off:off+4]
        ln = int.from_bytes(buf[off+4:off+8], 'little')
        if ln <= 0:
            off += 1
            continue
        if off + 8 + ln > len(buf):
            break
        ct = buf[off+8:off+8+ln]
        frames.append((header, ct))
        off += 8 + ln
    return frames
...

And the result is:

[0] c6237f77 7eba8d0f617bf90990dad469793c700fa16724ea028c3d4db57e0c80a077e0d5
[1] 7fc8fe7f f31cd383bb6fdd8f41a8924099f79eda967f039fda8253b668843bd32cd8e6b...
...

Then I built a decryptor:

...
def rol32(x, r):
    r &= 31
    return ((x << r) & 0xffffffff) | (x >> (32 - r))

def key_from_header(header_le_bytes: bytes) -> bytes:
    seed = int.from_bytes(header_le_bytes, 'little') & 0xffffffff
    words, val = [], seed
    for _ in range(20):
        words.append(val)
        val = rol32(val, val & 0xF)
    w = words[:]
    for i in range(20):
        j = PERM20[i]
        w[i], w[j] = w[j], w[i]
    b = bytearray()
    for v in w: b += v.to_bytes(4,'little')
    out, prev = bytearray(80), 0
    for i in range(80):
        out[i] = SUB256[b[i]] ^ prev
        prev = out[i]
    return bytes(out)

def rc4_init(key: bytes):
    S = list(range(256))
    j = 0
    for i in range(256):
        j = (j + S[i] + key[i % len(key)]) & 0xFF
        S[i], S[j] = S[j], S[i]
    return S

def rc4_crypt(S_init, data: bytes) -> bytes:
    S, i, j, out = S_init[:], 0, 0, bytearray(len(data))
    for n in range(len(data)):
        i = (i + 1) & 0xFF
        j = (j + S[i]) & 0xFF
        S[i], S[j] = S[j], S[i]
        K = S[(S[i] + S[j]) & 0xFF]
        out[n] = data[n] ^ K
    return bytes(out)

def decode_message(pt: bytes) -> str:
    try:
        t = pt[0]
        nlen = pt[1]
        name = pt[2:2+nlen].decode('utf-8','replace')
        text = pt[2+nlen:].decode('utf-8','replace')
        return f"type=0x{t:02x} name={name} text={text}"
    except Exception:
        return pt.decode('utf-8','replace')
...

The permutation and substitution are derived from the binary’s data sections as I mentioned earlier.

🎉 The Decrypted Messages

Once I ran it, the decrypted messages popped out.

Decrypted messages from the chat application.
sun{S3cur1ty_thr0ugh_Obscur1ty_1s_B4D}

🧙🏻‍♀️ Resources