# Phantom Protocol — Reverse Engineering Write-Up

**Category:** Reverse Engineering  
**Challenge:** Phantom Protocol  
**Flag format:** `CYBERLEAGUE{...}`  
**Files:** `scrambler.bin`, `traffic.pcap`  
**Solving mode:** Fully offline

---

## 1. Challenge Description

> A defunct messaging service left behind a single captured session and the raw bytecode of the module that scrambled it. Every session on this protocol opens with the same fixed banner, so you have something to check your work against: `[PhantomLink v1.0]\n`. Everything needed to regenerate the exact keystream that produced this traffic is in that tiny blob. Pull the module apart, rebuild its logic, and let the traffic tell you what it is hiding.
>
> Solvable entirely offline from the two files provided.

The challenge gives us two artifacts:

```text
phantom-protocol.zip
├── scrambler.bin   # 38 bytes
└── traffic.pcap    # 1035 bytes
```

The intended path is:

```text
scrambler.bin
    ↓
recover PRNG / scrambling logic
    ↓
recover the keystream
    ↓
use the known banner as known plaintext
    ↓
verify the generated stream
    ↓
decrypt TCP payloads in the PCAP
    ↓
recover the flag
```

---

# 2. Initial Triage

First inspect the archive:

```bash
unzip -l phantom-protocol.zip
```

Output:

```text
Length      Name
------      ----
38          scrambler.bin
1035        traffic.pcap
```

The binary is only **38 bytes**, which strongly suggests it is not a normal native executable. Dumping the raw bytes gives:

```text
63 de ad be ef 60 00 52 60 00 35 62 19 66 0d 02
63 3c 6e f3 5f 01 64 01 00 00 00 00 06 80 60 00
52 60 01 60 1f f3
```

There is no ELF/PE header. Several bytes correspond to familiar stack-machine/EVM-style opcodes such as `PUSHn`, `MSTORE`, `CALLDATALOAD`, arithmetic operations, `DUP1`, and `RETURN`.

This is therefore best approached as a **small stack-based bytecode VM**, rather than a native binary.

---

# 3. Disassembly of `scrambler.bin`

A manual disassembly is enough because the bytecode is only 38 bytes long.

| Offset | Bytes | Instruction | Meaning |
|---:|---|---|---|
| `0x00` | `63 de ad be ef` | `PUSH4 0xDEADBEEF` | Push constant `DEADBEEF` |
| `0x05` | `60 00` | `PUSH1 0x00` | Memory offset `0` |
| `0x07` | `52` | `MSTORE` | Store 32-byte word at memory[0] |
| `0x08` | `60 00` | `PUSH1 0x00` | Calldata offset `0` |
| `0x0A` | `35` | `CALLDATALOAD` | Read 32-byte value from calldata |
| `0x0B` | `62 19 66 0d` | `PUSH3 0x19660D` | Push multiplier `A` |
| `0x0F` | `02` | `MUL` | Multiply input by `A` |
| `0x10` | `63 3c 6e f3 5f` | `PUSH4 0x3C6EF35F` | Push increment `C` |
| `0x15` | `01` | `ADD` | Add `C` |
| `0x16` | `64 01 00 00 00 00` | `PUSH5 0x0100000000` | Push modulus `2^32` |
| `0x1C` | `06` | `MOD` | Reduce modulo `2^32` |
| `0x1D` | `80` | `DUP1` | Duplicate state |
| `0x1E` | `60 00` | `PUSH1 0x00` | Memory offset `0` |
| `0x20` | `52` | `MSTORE` | Store generated state |
| `0x21` | `60 01` | `PUSH1 0x01` | Return offset `1` |
| `0x23` | `60 1f` | `PUSH1 0x1F` | Return length `31` |
| `0x25` | `f3` | `RETURN` | Return generated bytes |

The central arithmetic is unmistakable:

```text
state = (input * 0x19660D + 0x3C6EF35F) mod 0x100000000
```

In decimal:

```text
A = 0x0019660D = 1,664,525
C = 0x3C6EF35F = 1,013,904,223
M = 0x100000000 = 4,294,967,296 = 2^32
```

So the bytecode implements the classic 32-bit **Linear Congruential Generator (LCG)** recurrence:

```text
S[n] = (A * S[n-1] + C) mod 2^32
```

---

# 4. Recognizing the PRNG

The constants are the key observation:

```text
A = 1664525
C = 1013904223
M = 2^32
```

Therefore:

```text
S[n] = (1664525 * S[n-1] + 1013904223) mod 2^32
```

For the captured traffic, the effective keystream byte is the **least-significant byte** of each generated 32-bit state:

```text
K[n] = S[n] & 0xff
```

and the traffic is XORed with that stream:

```text
P[n] = C[n] XOR K[n]
```

where:

- `P` = plaintext
- `C` = ciphertext
- `K` = keystream

This can be verified directly from the known banner in the challenge description.

---

# 5. Exploiting the Known Banner

The first plaintext is known:

```text
[PhantomLink v1.0]\n
```

Its bytes are:

```text
5b 50 68 61 6e 74 6f 6d 4c 69 6e 6b 20 76 31 2e 30 5d 0a
```

The first TCP ciphertext payload from the PCAP is:

```text
d9 a9 6c f2 b8 49 17 1a 26 a8 42 f0 1e f3 11 d1 62 d4 5e
```

Because XOR is reversible:

```text
K = C XOR P
```

we obtain:

```text
82 f9 04 93 d6 3d 78 77 6a c1 2c 9b 3e 85 20 ff 52 89 54
```

So the first keystream byte is:

```text
K1 = 0x82
```

---

# 6. Reducing the LCG to One Byte

Because the wire format only consumes the low byte, the full 32-bit recurrence can be reduced modulo 256.

Take the LCG equation:

```text
S[n] = (A*S[n-1] + C) mod 2^32
```

and look only at the low byte:

```text
K[n] = (A*K[n-1] + C) mod 256
```

The constants reduce to:

```text
A mod 256 = 0x0D = 13
C mod 256 = 0x5F = 95
```

Therefore:

```text
K[n] = (13*K[n-1] + 95) mod 256
```

The first observed byte is `0x82`.

If the initial state byte is `x`, then:

```text
0x82 = (13*x + 95) mod 256
```

so:

```text
13*x ≡ 0x82 - 0x5F (mod 256)
13*x ≡ 35 (mod 256)
```

Since:

```text
13^-1 mod 256 = 197 = 0xC5
```

we get:

```text
x ≡ 35 * 197 (mod 256)
x ≡ 0xEF (mod 256)
```

Thus:

```text
initial_state mod 256 = 0xEF
```

---

# 7. Recovering the Full Seed

The known-plaintext step only recovers the low byte. However, the bytecode also contains the explicit constant:

```text
DE AD BE EF
```

The low byte of that value is:

```text
0xDEADBEEF & 0xff = 0xEF
```

which exactly matches the value recovered from the banner.

Therefore the full initial state/seed is:

```text
SEED = 0xDEADBEEF
```

This is an important distinction:

```text
0xEF
```

is only the **recoverable low byte from the ciphertext**, while:

```text
0xDEADBEEF
```

is the **full 32-bit value confirmed by the bytecode artifact**.

---

# 8. Verify the Generated LCG Stream

Now start from:

```text
S0 = 0xDEADBEEF
```

and repeatedly apply:

```text
S[n] = (1664525*S[n-1] + 1013904223) mod 2^32
```

The first states are:

```text
S1 = 0x6AABDF82
S2 = 0xF5E918F9
S3 = 0x09896E04
S4 = 0x38A32193
S5 = 0x637339D6
S6 = 0xD83A273D
S7 = 0x37FA3F78
S8 = 0xFC25FC77
S9 = 0xD4932F6A
S10 = 0x4E2697C1
...
```

Taking only the low byte:

```text
82 f9 04 93 d6 3d 78 77 6a c1 2c 9b 3e 85 20 ff 52 89 54 ...
```

This exactly matches the keystream obtained by XORing the encrypted banner with:

```text
[PhantomLink v1.0]\n
```

So we now have a validated decryption mechanism.

---

# 9. Inspecting the PCAP

The TCP payloads are:

```text
1. 192.168.1.200:1337 -> 192.168.1.100:54321
   length = 19
   ciphertext =
   d9a96cf2b849171a26a842f01ef311d162d45e

2. 192.168.1.200:1337 -> 192.168.1.100:54321
   length = 26
   ciphertext =
   f4c3a1abe857345d8b5d701e6b026dccd6562e7df47884b881fe

3. 192.168.1.100:54321 -> 192.168.1.200:1337
   length = 18
   ciphertext =
   d7b748dc95762723228473cb76c46eab1dc4

4. 192.168.1.200:1337 -> 192.168.1.100:54321
   length = 38
   ciphertext =
   e6995db7fbb88607aa3de2a10174ffc04120429d4936e3624ddb87c65e08e81df5040cf6b49e
```

The important protocol detail is that the keystream state is maintained **per TCP direction**.

So the server's stream is:

```text
server packet #1
    ↓ continue same state
server packet #2
    ↓ continue same state
server packet #3
```

while the client-to-server direction starts from its own initial state:

```text
client packet #1
    ↓
independent stream
```

This explains why simply resetting the LCG for every packet would fail on the second and fourth server packets.

---

# 10. Decrypting the Server Stream

For the first server packet:

```text
ciphertext:
d9 a9 6c f2 b8 49 17 1a 26 a8 42 f0 1e f3 11 d1 62 d4 5e

keystream:
82 f9 04 93 d6 3d 78 77 6a c1 2c 9b 3e 85 20 ff 52 89 54
```

XOR gives:

```text
[PhantomLink v1.0]\n
```

Continue the same server-side state into the second packet and it decrypts to:

```text
Welcome! Send the secret: 
```

The third server payload, after continuing the stream again, decrypts to:

```text
CYBERLEAGUE{ph4nt0m_pr0t0c0l_d3c0d3d}
```

---

# 11. Decrypting the Client Stream

The client-to-server payload starts a separate direction, so it starts from the same initial seed but with its own stream state.

Decrypting the 18-byte client payload gives:

```text
UNLOCK_THE_PHANTOM
```

Thus the recovered conversation is:

```text
SERVER → CLIENT
[PhantomLink v1.0]

SERVER → CLIENT
Welcome! Send the secret: 

CLIENT → SERVER
UNLOCK_THE_PHANTOM

SERVER → CLIENT
CYBERLEAGUE{ph4nt0m_pr0t0c0l_d3c0d3d}
```

This provides a complete end-to-end validation of the reverse-engineering result.

---

# 12. Final Algorithm

The whole challenge reduces to this pseudocode:

```text
seed = 0xDEADBEEF

for each TCP direction:
    state = seed

    for each ciphertext byte:
        state = (state * 1664525 + 1013904223) mod 2^32
        keystream = state & 0xFF
        plaintext = ciphertext_byte XOR keystream
```

The known banner is used to verify the initial stream:

```text
ciphertext XOR "[PhantomLink v1.0]\\n"
    ==
first LCG low-byte outputs
```

Once this equality holds, the remainder of the PCAP can be decrypted deterministically.

---

# 13. Solver

The following Python 3 solver is fully offline. It:

1. Opens the supplied ZIP.
2. Extracts the arithmetic constants from `scrambler.bin`.
3. Recovers the `DEADBEEF` seed from the bytecode.
4. Parses classic Ethernet/IPv4/TCP PCAP records using only the Python standard library.
5. Maintains one LCG state per TCP direction.
6. Decrypts all TCP payloads.
7. Searches the plaintext for the `CYBERLEAGUE{...}` flag.

```python
#!/usr/bin/env python3

import argparse
import re
import struct
import zipfile

BANNER = b"[PhantomLink v1.0]\n"
FLAG_RE = re.compile(rb"CYBERLEAGUE\{[^}\r\n]+\}")


def parse_bytecode(bytecode: bytes):
    """Recover seed, multiplier, increment and modulus from the bytecode."""

    seed = None
    a = None
    c = None
    mod = None

    i = 0
    while i < len(bytecode):
        op = bytecode[i]
        i += 1

        # PUSHn opcodes occupy 0x60..0x7f.
        if 0x60 <= op <= 0x7f:
            n = op - 0x5f
            if i + n > len(bytecode):
                raise ValueError("Truncated PUSH instruction")

            value = int.from_bytes(bytecode[i:i + n], "big")

            # PUSH4 DEADBEEF
            if n == 4 and value == 0xDEADBEEF:
                seed = value

            # PUSH3 A immediately before MUL
            if (
                n == 3
                and i + n < len(bytecode)
                and bytecode[i + n] == 0x02
            ):
                a = value

            # PUSH4 C immediately before ADD
            if (
                n == 4
                and i + n < len(bytecode)
                and bytecode[i + n] == 0x01
            ):
                c = value

            # PUSH5 MOD immediately before MOD
            if (
                n == 5
                and i + n < len(bytecode)
                and bytecode[i + n] == 0x06
            ):
                mod = value

            i += n

    if None in (seed, a, c, mod):
        raise ValueError("Could not recover all LCG parameters")

    return seed, a, c, mod


def iter_tcp_payloads(pcap: bytes):
    """Parse classic Ethernet/IPv4/TCP PCAP records."""

    if len(pcap) < 24:
        raise ValueError("PCAP too small")

    magic = pcap[:4]

    if magic == b"\xd4\xc3\xb2\xa1":
        endian = "<"
    elif magic == b"\xa1\xb2\xc3\xd4":
        endian = ">"
    else:
        raise ValueError("Unsupported PCAP byte order")

    _, _, _, _, _, snaplen, network = struct.unpack(
        endian + "IHHIIII", pcap[:24]
    )

    # LINKTYPE_ETHERNET
    if network != 1:
        raise ValueError(f"Unsupported link type: {network}")

    offset = 24

    while offset + 16 <= len(pcap):
        _, _, incl_len, _ = struct.unpack(
            endian + "IIII", pcap[offset:offset + 16]
        )
        offset += 16

        if snaplen and incl_len > snaplen:
            raise ValueError("Invalid captured length")

        if offset + incl_len > len(pcap):
            raise ValueError("Truncated packet")

        frame = pcap[offset:offset + incl_len]
        offset += incl_len

        if len(frame) < 14:
            continue

        ether_type = struct.unpack(">H", frame[12:14])[0]
        l2 = 14

        # Optional single 802.1Q VLAN tag.
        if ether_type == 0x8100 and len(frame) >= 18:
            ether_type = struct.unpack(">H", frame[16:18])[0]
            l2 = 18

        if ether_type != 0x0800:  # IPv4
            continue

        if len(frame) < l2 + 20:
            continue

        ip = frame[l2:]
        version = ip[0] >> 4
        ihl = (ip[0] & 0x0F) * 4

        if version != 4 or ihl < 20 or len(ip) < ihl:
            continue

        if ip[9] != 6:  # TCP
            continue

        total_len = struct.unpack(">H", ip[2:4])[0]
        total_len = min(total_len, len(ip))

        src_ip = ".".join(map(str, ip[12:16]))
        dst_ip = ".".join(map(str, ip[16:20]))

        tcp = ip[ihl:total_len]
        if len(tcp) < 20:
            continue

        src_port, dst_port = struct.unpack(">HH", tcp[:4])
        data_offset = ((tcp[12] >> 4) & 0x0F) * 4

        if data_offset < 20 or len(tcp) < data_offset:
            continue

        payload = tcp[data_offset:]

        if payload:
            yield src_ip, src_port, dst_ip, dst_port, payload


def next_keystream_byte(state, a, c):
    state = (state * a + c) & 0xFFFFFFFF
    return state, state & 0xFF


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "archive",
        nargs="?",
        default="phantom-protocol.zip"
    )
    args = parser.parse_args()

    with zipfile.ZipFile(args.archive) as zf:
        bytecode = zf.read("scrambler.bin")
        pcap = zf.read("traffic.pcap")

    seed, a, c, mod = parse_bytecode(bytecode)

    print(f"[+] seed = 0x{seed:08x}")
    print(f"[+] A    = 0x{a:08x}")
    print(f"[+] C    = 0x{c:08x}")
    print(f"[+] MOD  = 0x{mod:08x}")

    if mod != 0x100000000:
        raise ValueError("Unexpected modulus")

    packets = list(iter_tcp_payloads(pcap))
    if not packets:
        raise ValueError("No TCP payloads found")

    # Verify the first payload against the known banner.
    first_cipher = packets[0][4]

    if len(first_cipher) < len(BANNER):
        raise ValueError("First payload shorter than banner")

    test_state = seed
    recovered = bytearray()

    for byte in first_cipher[:len(BANNER)]:
        test_state, ks = next_keystream_byte(test_state, a, c)
        recovered.append(byte ^ ks)

    if bytes(recovered) != BANNER:
        raise ValueError("Known-banner verification failed")

    print("[+] banner verification OK")

    # One independent state per TCP direction.
    states = {}
    plaintexts = []

    for src_ip, src_port, dst_ip, dst_port, ciphertext in packets:
        direction = (src_ip, src_port, dst_ip, dst_port)

        if direction not in states:
            states[direction] = seed

        state = states[direction]
        plaintext = bytearray()

        for byte in ciphertext:
            state, ks = next_keystream_byte(state, a, c)
            plaintext.append(byte ^ ks)

        states[direction] = state
        plaintexts.append((direction, bytes(plaintext)))

    for direction, plaintext in plaintexts:
        src_ip, src_port, dst_ip, dst_port = direction
        print(f"{src_ip}:{src_port} -> {dst_ip}:{dst_port}")
        print(f"    {plaintext!r}")

    for _, plaintext in plaintexts:
        match = FLAG_RE.search(plaintext)
        if match:
            print()
            print(f"[+] FLAG = {match.group().decode()}")
            return

    raise SystemExit("[-] Flag not found")


if __name__ == "__main__":
    main()
```

Run it with:

```bash
python3 solve.py phantom-protocol.zip
```

Expected result:

```text
[+] seed = 0xdeadbeef
[+] A    = 0x0019660d
[+] C    = 0x3c6ef35f
[+] MOD  = 0x100000000
[+] banner verification OK

192.168.1.200:1337 -> 192.168.1.100:54321
    b'[PhantomLink v1.0]\n'

192.168.1.200:1337 -> 192.168.1.100:54321
    b'Welcome! Send the secret: '

192.168.1.100:54321 -> 192.168.1.200:1337
    b'UNLOCK_THE_PHANTOM'

192.168.1.200:1337 -> 192.168.1.100:54321
    b'CYBERLEAGUE{ph4nt0m_pr0t0c0l_d3c0d3d}\n'

[+] FLAG = CYBERLEAGUE{ph4nt0m_pr0t0c0l_d3c0d3d}
```

---

# 14. Why the Solution Works

There are four independent checks that make the result reliable:

### Check 1 — Bytecode structure

The 38-byte module directly encodes:

```text
MULTIPLIER = 0x19660D
INCREMENT  = 0x3C6EF35F
MODULUS    = 0x100000000
```

These form an LCG recurrence.

### Check 2 — Known plaintext

The known banner produces:

```text
82 f9 04 93 d6 3d 78 77 6a c1 2c 9b 3e 85 20 ff 52 89 54
```

which is exactly the low-byte sequence generated by the LCG.

### Check 3 — Seed consistency

Known plaintext recovers:

```text
seed mod 256 = 0xEF
```

while the bytecode contains:

```text
0xDEADBEEF
```

whose low byte is also:

```text
0xEF
```

### Check 4 — Full conversation decrypts coherently

All observed application data becomes readable protocol messages, culminating in a correctly formatted flag.

The combination of these checks eliminates the possibility that the banner match is accidental.

---

# 15. Lessons / Takeaways

This challenge is primarily a **reverse-engineering + known-plaintext** problem rather than a brute-force problem.

The useful workflow is:

```text
1. Identify the file format.
2. Disassemble the tiny VM manually.
3. Isolate the arithmetic recurrence.
4. Determine which part of the state is used on the wire.
5. Exploit the fixed plaintext banner.
6. Recover the unknown state information.
7. Cross-check against constants embedded in the module.
8. Track stream state correctly across TCP directions.
9. Decrypt the entire session.
```

The key mathematical observation is that the low byte of the 32-bit LCG follows its own recurrence:

```text
K[n] = (13*K[n-1] + 95) mod 256
```

which makes the known banner an immediate oracle for validating the PRNG.

---

# 16. Flag

```text
CYBERLEAGUE{ph4nt0m_pr0t0c0l_d3c0d3d}
```

---

## Appendix — Useful One-Liners

### Dump the binary

```bash
od -An -tx1 -v scrambler.bin
```

### List files in the challenge

```bash
unzip -l phantom-protocol.zip
```

### Extract the archive

```bash
unzip phantom-protocol.zip
```

### Run the solver

```bash
python3 solve.py phantom-protocol.zip
```

---

**Final flag:** `CYBERLEAGUE{ph4nt0m_pr0t0c0l_d3c0d3d}`
