← Back to Writeups
CUSTOMMediumHardware

Wireless Connections

XESXOR9/2/20266 min read
#hardware#custom#medium#flash_partition_extraction#esp_image_reconstruction#xtensa_decompilation#known_plaintext_bruteforce#linux

Wireless Connections

Platform: CACTF2026 | Category: Hardware | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-28 | Status: Solved Techniques: flash_partition_extraction, esp_image_reconstruction, xtensa_decompilation, known_plaintext_bruteforce

Summary

Task: An 8 MiB ESP32-S3 flash dump hides a relay key behind a decoy Wi-Fi credential and an address-derived stream. Solution: Rebuild the active app as Xtensa ELF, recover its LCG decryptor, and brute-force the bounded 24-bit seed.

Wireless Connections — CACTF2026

Description

Stormbound scouts recovered an ESP32_S3_DevKitC-1U listening node; recover the relay key hidden inside its firmware.

The supplied archive contained a complete ESP32-S3 flash image. The objective was to locate and recover the hidden relay key in HTB{...} format.

Initial Triage

The extracted firmware.bin was exactly 8 MiB and had SHA-256:

text
9cb52461110f750022fa9783bc57337353629e265097fc397d99c0da1a2761d6

Basic identification was enough to establish the platform and SDK versions: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

bash
unzip challenge_download
shasum -a 256 firmware.bin
python3 -m esptool image-info firmware.bin

esptool identified an ESP32-S3 image built with ESP-IDF v5.5.1-255-g07e9bf4970 and Arduino 3.3.2. Parsing the partition table at flash offset 0x8000 produced this layout:

PartitionOffsetSizeFinding
nvs0x90000x5000erased (0xff)
app00x100000x330000active application
app10x3400000x330000erased
spiffs0x6700000x180000erased
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The relevant partitions can be extracted directly from the full flash dump:

bash
dd if=firmware.bin of=nvs.bin bs=1 skip=$((0x9000)) count=$((0x5000))
dd if=firmware.bin of=app0.bin bs=1 skip=$((0x10000)) count=$((0x330000))
dd if=firmware.bin of=app1.bin bs=1 skip=$((0x340000)) count=$((0x330000))
dd if=firmware.bin of=spiffs.bin bs=1 skip=$((0x670000)) count=$((0x180000))

Since NVS, the alternate application slot, and SPIFFS were completely erased, persistent configuration and filesystem storage were ruled out. The active application was the only meaningful path. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Reconstructing an Xtensa ELF

Running strings over app0.bin revealed three unusually early application strings:

text
blink_task
bugged26
SpyHouse

An ESP application image is not directly equivalent to an ELF: it contains an image header followed by address/length records and their segment data. The supplied esp_image_to_elf.py parsed the six segment records and emitted a minimal ELF32 file whose program headers mapped each segment to its original virtual address:

bash
python3 esp_image_to_elf.py app0.bin app0.elf

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The essential conversion logic was:

python
image = open("app0.bin", "rb").read()
segment_count = image[1]
entry = struct.unpack_from("<I", image, 4)[0]
cursor = 24

segments = []
for _ in range(segment_count):
    address, size = struct.unpack_from("<II", image, cursor)
    cursor += 8
    segments.append((address, image[cursor:cursor + size]))
    cursor += size

After writing standard ELF32 headers with machine type EM_XTENSA (94), app0.elf could be imported into Ghidra using language Xtensa:LE:32. PyGhidra scripting was used for repeatable cross-reference collection and decompilation because the normal headless Java/Python attempts were unreliable in this environment. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The Decoy Wi-Fi Credential

Cross-references from the custom strings led through initialized data pointers:

text
0x3fc96c00 -> 0x3c0a012b -> "bugged26"
0x3fc96c04 -> 0x3c0a0134 -> "SpyHouse"

FUN_42002ccc passed these values to FUN_42003e40 as follows:

c
FUN_42003e40(WiFi, "SpyHouse", "bugged26", 0, 0, 1);

This matches the Arduino station call WiFi.begin(ssid, passphrase, channel, bssid, connect). Therefore SpyHouse is the SSID and bugged26 is only its password. The tempting submission HTB{bugged26} was rejected, confirming that this visible credential was a decoy rather than the relay key. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Recovering the Real Decryptor

The same setup function waits until the station reports a connected state, reads three address bytes, packs them into a 24-bit integer, multiplies that value by 0x01000193, and passes the result to FUN_42002ca0. It also passes a 32-byte DROM blob located at runtime address 0x3c0b103b.

Ghidra decompiled FUN_42002ca0 to a small stream decryptor:

c
for (i = 0; i != length; i++) {
    state = state * 0x0019660d + 0x3c6ef35f;
    output[i] = input[i] ^ (uint8_t)(state >> 16);
}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

All arithmetic is modulo 2^32. The state is advanced before each byte, and bits 16 through 23 form the one-byte XOR keystream. This confirms that the DROM blob and the LCG—not NVS or the Wi-Fi password—are the actual flag path.

The initial state calculation is:

text
packed = address_byte_0 << 16 | address_byte_1 << 8 | address_byte_2
state  = packed * 0x01000193 mod 2^32

Only 24 bits are unknown. Because the required flag format supplies the generic known prefix HTB{, candidates can be rejected after decrypting four bytes. The search produced exactly one packed value, 0x7cdfa1 (bytes 124.223.161). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Reproducible Solver

The following standalone solver accepts the 32-byte DROM value as a hexadecimal command-line argument. Copy those bytes from Ghidra's listing at 0x3c0b103b; keeping the recovered secret out of source code also makes the solver reusable.

c
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int nibble(char c) {
    if (c >= '0' && c <= '9') return c - '0';
    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
    if (c >= 'A' && c <= 'F') return c - 'A' + 10;
    return -1;
}
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

int main(int argc, char **argv) {
    uint8_t enc[32], out[33];
    const uint8_t known[4] = {'H', 'T', 'B', '{'};

    if (argc != 2 || strlen(argv[1]) != 64) {
        fprintf(stderr, "usage: %s <64 hex characters from DROM>\n", argv[0]);
        return 1;
    }
    for (int i = 0; i < 32; i++) {
        int hi = nibble(argv[1][2 * i]);
        int lo = nibble(argv[1][2 * i + 1]);
        if (hi < 0 || lo < 0) return 1;
        enc[i] = (uint8_t)((hi << 4) | lo);
    }

    for (uint32_t packed = 0; packed < 0x1000000; packed++) {
        uint32_t state = packed * 0x01000193u;
        int match = 1;
        for (int i = 0; i < 4; i++) {
            state = state * 0x0019660du + 0x3c6ef35fu;
            if ((enc[i] ^ (uint8_t)(state >> 16)) != known[i]) {
                match = 0;
                break;
            }
        }
        if (!match) continue;
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

        state = packed * 0x01000193u;
        for (int i = 0; i < 32; i++) {
            state = state * 0x0019660du + 0x3c6ef35fu;
            out[i] = enc[i] ^ (uint8_t)(state >> 16);
        }
        out[32] = 0;
        printf("packed=%06x plaintext=%s\n", packed, out);
    }
}

Compile and run it with:

bash
cc -O3 -o bruteforce_seed bruteforce_seed.c
./bruteforce_seed '<64_HEX_CHARACTERS_FROM_DROM>'

The unique result is a complete 31-character HTB{...} value followed by a NUL byte. That terminator also denies alternate wrappers or an incomplete plaintext interpretation. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Failed Approaches

  • Visible password as flag: bugged26 is demonstrably the password argument to WiFi.begin, but the corresponding submission was rejected.
  • NVS, app1, and SPIFFS: all three partitions contain only erased 0xff bytes.
  • radare2 analysis: the available installation lacked a usable Xtensa analysis plugin.
  • Standard Ghidra headless scripting: Java/Python integration issues impeded automation; PyGhidra successfully imported, analyzed, cross-referenced, and decompiled the reconstructed ELF. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The prior CTF Writeup entry 20260609_hackthebox_espresso was useful for the generic ESP flash-parsing and image-to-ELF workflow. The LCG and address-derived seed logic here were recovered independently from this firmware. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍