Photo by Jørgen Larsen / Unsplash

Writeup: The Wired - EspilonCTF (Intro)

Feri Harjulianto

Overview

We're given access to a machine that administered a fleet of ESP32-based agents running a custom C2 framework called ESPILON. The agents communicate with a coordinator over TCP using encrypted protobuf messages. Our goal is to impersonate an agent, complete the authentication handshake, and retrieve the flag from the coordinator.


Step 1: Reconnaissance via Navi Shell

Connecting to port 33402 gives us a restricted bash shell on "Lain's machine." The filesystem contains:

~/
├── README_FIRST.txt    # Challenge intro
├── notice.txt          # Auth requirements
├── comms/              # Operator messages (protocol handshake docs)
├── dumps/              # Firmware ELF/BIN images per device
├── journal/            # Lain's personal notes (trust indicators)
├── logs/               # Boot logs, flash logs, network attempts
├── notes/              # Protocol spec, crypto notes, changelog
├── tools/              # devices.json, probe.py, flash.py
└── wired/              # Network captures, decoy messages

Key Findings

Protocol (notes/protocol.txt):

  • Transport: TCP, one message per frame
  • Framing: base64( encrypt( protobuf( AgentMessage ) ) ) + \n
  • Encryption: ChaCha20 (32-byte key, 12-byte nonce, counter=0)
  • Auth: Implicit - if the coordinator can decrypt and parse your message, you're authenticated

AgentMessage Protobuf Schema:

message AgentMessage {
  string device_id = 1;
  enum Type { INFO=0, ERROR=1, DATA=2, LOG=3, CMD_RESULT=4 }
  Type type = 2;
  string source = 3;
  string request_id = 4;
  bytes payload = 5;
  bool eof = 6;
}

Handshake Flow (comms/msg_ops_20260114.txt):

  1. Agent sends AGENT_INFO (type=0) with payload=device_id
  2. Coordinator responds with session_init, argv[0] = session token
  3. Agent sends CMD_RESULT (type=4) with request_id=session_token, payload=device_id
  4. Coordinator responds with privileged data

Device Registry (tools/devices.json):

  • 7 devices registered, crypto keys marked [STRIPPED]
  • Core node: ce4f626b (hostname: navi-core) - the privileged "Eiri_Master" role
  • Only the core node triggers the privileged response path

Step 2: Identifying the Decoy

Lain's journal warns that "the other Lain" tampered with files:

  • notes/hardening.txt contains a development key: Xt9Lm2Qw7KjP4rNvB8hYc3fZ0dAeU6sG / nonce: W4kR7nM9pQx2
  • Journal entry 2026-01-17: "Someone appended a section to my hardening notes. I didn't write it. The key listed there is not the one I deployed."
  • The core binary (dumps/ce4f626b/bot-lwip.elf) was stripped of symbols

This means the key in hardening.txt is a planted decoy. The real key must be extracted from the firmware.


Step 3: Extracting the Real Key from Firmware

Since the core binary is stripped, we use a non-stripped ELF from another device (all devices share the same key from the same build):

# Extract 32-byte alphanumeric strings (key candidates)
strings dumps/7f3c9a12/bot-lwip.elf | grep -E '^[A-Za-z0-9]{32}$'

Result: 7Kj2mPx9LwR4nQvT1hYc3bFz8dAeU6sG

# Extract 12-byte alphanumeric strings (nonce candidates)
strings dumps/7f3c9a12/bot-lwip.elf | grep -E '^[A-Za-z0-9]{12}$'

Result: X3kW7nR9mPq2

Real credentials:

  • Key: 7Kj2mPx9LwR4nQvT1hYc3bFz8dAeU6sG
  • Nonce: X3kW7nR9mPq2

Step 4: Building the Exploit

The exploit script performs the two-step handshake:

#!/usr/bin/env python3
import socket, base64
from Crypto.Cipher import ChaCha20

KEY = b"7Kj2mPx9LwR4nQvT1hYc3bFz8dAeU6sG"
NONCE = b"X3kW7nR9mPq2"
DEVICE_ID = "ce4f626b"

def chacha20_crypt(data):
    cipher = ChaCha20.new(key=KEY, nonce=NONCE)
    return cipher.encrypt(data)

def encode_varint(value):
    result = []
    while value > 0x7f:
        result.append((value & 0x7f) | 0x80)
        value >>= 7
    result.append(value & 0x7f)
    return bytes(result)

def encode_string_field(field_num, value):
    tag = (field_num << 3) | 2
    encoded = value.encode() if isinstance(value, str) else value
    return encode_varint(tag) + encode_varint(len(encoded)) + encoded

def encode_varint_field(field_num, value):
    tag = (field_num << 3) | 0
    return encode_varint(tag) + encode_varint(value)

def build_agent_message(device_id, msg_type, request_id="", payload=b""):
    msg = encode_string_field(1, device_id)
    if msg_type != 0:
        msg += encode_varint_field(2, msg_type)
    if request_id:
        msg += encode_string_field(4, request_id)
    if payload:
        msg += encode_string_field(5, payload)
    return msg

def send_frame(sock, plaintext):
    sock.sendall(base64.b64encode(chacha20_crypt(plaintext)) + b"\n")

def recv_frame(sock):
    buf = b""
    while True:
        c = sock.recv(1)
        if not c: return None
        if c == b"\n": break
        buf += c
    return chacha20_crypt(base64.b64decode(buf))

def parse_protobuf(data):
    fields, i = {}, 0
    while i < len(data):
        tag, consumed = parse_varint(data[i:]); i += consumed
        field_num, wire_type = tag >> 3, tag & 0x7
        if wire_type == 0:
            val, consumed = parse_varint(data[i:]); i += consumed
            fields[field_num] = val
        elif wire_type == 2:
            length, consumed = parse_varint(data[i:]); i += consumed
            fields[field_num] = data[i:i+length]; i += length
    return fields

def parse_varint(data):
    result, shift = 0, 0
    for i, byte in enumerate(data):
        result |= (byte & 0x7f) << shift; shift += 7
        if not (byte & 0x80): return result, i + 1
    return result, len(data)

# === Main exploit ===
sock = socket.create_connection(("espilon.net", 33403), timeout=10)
sock.settimeout(10)

# Step 1: AGENT_INFO (type=0)
info_msg = build_agent_message(DEVICE_ID, 0, payload=DEVICE_ID.encode())
send_frame(sock, info_msg)

# Receive session_init, extract token from field 3 (argv[0])
resp = parse_protobuf(recv_frame(sock))
session_token = resp[3].decode()

# Step 2: CMD_RESULT (type=4) with session token
cmd_msg = build_agent_message(DEVICE_ID, 4, request_id=session_token,
                               payload=DEVICE_ID.encode())
send_frame(sock, cmd_msg)

# Receive flag
resp2 = parse_protobuf(recv_frame(sock))
print(resp2[3].decode())  # ESPILON{th3_w1r3d_kn0ws_wh0_y0u_4r3}
sock.close()

Step 5: Execution

$ python3 solve.py

=== Step 1: Sending AGENT_INFO ===
[RX] decrypted: ce4f626b | session_init | ea42273c95e631d0c602f1afbff36279 | handshake

=== Step 2: Sending CMD_RESULT with token ===
[RX] decrypted: ce4f626b | flag | ESPILON{th3_w1r3d_kn0ws_wh0_y0u_4r3}

Key Takeaways

  1. Don't trust convenient answers - The key in hardening.txt was a deliberate trap. Lain's journal warned about planted data.
  2. Firmware analysis - The real key was embedded as a plaintext constant in the unstripped ELF binary, extractable with strings.
  3. Protocol reconstruction - Understanding the exact framing (protobuf -> ChaCha20 -> base64 -> newline) and two-step handshake was essential.
  4. Identity matters - Only the core node (ce4f626b / Eiri_Master) triggered the privileged response containing the flag.

Flag

ESPILON{th3_w1r3d_kn0ws_wh0_y0u_4r3}
CTFWriteup