Wireless Connections
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 read-only • copy allowed9cb52461110f750022fa9783bc57337353629e265097fc397d99c0da1a2761d6
Basic identification was enough to establish the platform and SDK versions:
bash read-only • copy allowedunzip 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:
| Partition | Offset | Size | Finding |
|---|---|---|---|
nvs | 0x9000 | 0x5000 | erased (0xff) |
app0 | 0x10000 | 0x330000 | active application |
app1 | 0x340000 | 0x330000 | erased |
spiffs | 0x670000 | 0x180000 | erased |
| |
The relevant partitions can be extracted directly from the full flash dump:
bash read-only • copy alloweddd 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 read-only • copy allowedblink_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 read-only • copy allowedpython3 esp_image_to_elf.py app0.bin app0.elf
The essential conversion logic was:
python read-only • copy allowedimage = 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 read-only • copy allowed0x3fc96c00 -> 0x3c0a012b -> "bugged26" 0x3fc96c04 -> 0x3c0a0134 -> "SpyHouse"
FUN_42002ccc passed these values to FUN_42003e40 as follows:
c read-only • copy allowedFUN_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 read-only • copy allowedfor (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 read-only • copy allowedpacked = 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 read-only • copy allowed#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 read-only • copy allowedcc -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:
bugged26is demonstrably the password argument toWiFi.begin, but the corresponding submission was rejected. - NVS, app1, and SPIFFS: all three partitions contain only erased
0xffbytes. - 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.