Stormbound's scouts swept a shuttered Suncourt townhouse after a chain of forged writs began reaching Crownspire before their couriers did. Behind a wall plate they found a crude listening node stamped with a single useful marking, ESP32_S3_DevKitC-1U. Somewhere inside it lies the relay key used to pass whispers between rival agents. Cut the hidden channel before the next false order moves through the realm.
An 8 MB flash dump from an ESP32-S3. The relay key is encrypted with a keystream seeded
from the device's own WiFi MAC - a value that exists only on the physical hardware. But the
unknown part is three bytes, and the plaintext starts with HTB{.
$ ls -la
-rw-r--r--@ 1 feyrer staff 8388608 Jun 7 12:31 firmware.bin
$ file firmware.bin
firmware.bin: DOS executable (COM)
file is guessing wrong - it is a raw flash image, not a host executable. The
board marking in the description gives the architecture.
Neither. One firmware image, offline.
ESP-IDF puts the partition table at a fixed offset, 0x8000. The
aa50 magic confirms it right away.
$ dd if=firmware.bin bs=1 skip=32768 | xxd | head -6
00000000: aa50 0102 0090 0000 0050 0000 6e76 7300 .P.......P..nvs.
00000020: aa50 0100 00e0 0000 0020 0000 6f74 6164 .P....... ..otad
00000040: aa50 0010 0000 0100 0000 3300 6170 7030 .P........3.app0
00000060: aa50 0011 0000 3400 0000 3300 6170 7031 .P....4...3.app1
00000080: aa50 0182 0000 6700 0000 1800 7370 6966 .P....g.....spif
000000a0: aa50 0103 0000 7f00 0000 0100 636f 7265 .P..........core
Espressif's own parser turns that into a readable table:
$ curl -sO https://raw.githubusercontent.com/espressif/esp-idf/master/components/partition_table/gen_esp32part.py
$ dd if=firmware.bin bs=1 skip=$((0x8000)) count=3072 of=part.bin
$ python3 gen_esp32part.py part.bin
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 20K
otadata, data, ota, 0xe000, 8K
app0, app, ota_0, 0x10000, 3264K
app1, app, ota_1, 0x340000, 3264K
spiffs, data, spiffs, 0x670000, 1536K
coredump, data, coredump,0x7f0000, 64K
Conclusion: Carve out each partition with dd and see which ones hold
anything.
The obvious places for a key are NVS (the key-value store), SPIFFS (the filesystem) or a coredump.
$ hexdump -C part4-app1.bin
00000000 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff |................|
*
00330000
app1, nvs, spiffs and coredump are all
erased flash - 0xFF throughout. Only otadata has a few bytes,
saying the device is booting slot 0.
Conclusion: Nothing was ever written at runtime. The key is not stored anywhere - it has to come out of the application binary itself.
Extract the DROM segment of app0 - where string constants live - and grep.
$ esptool image-info part3-app0.bin
Segments Information
0 0x22d40 0x3c0a0020 0x00000018 DROM
1 0x05248 0x3fc96c00 0x00022d60 DRAM
2 0x08060 0x40374000 0x00027fb0 IRAM
3 0x9a404 0x42000020 0x00030018 IROM
4 0x0aae4 0x4037c060 0x000ca424 IRAM
5 0x00020 0x50000200 0x000d4f10 RTC_DATA
$ dd if=part3-app0.bin bs=1 skip=$((0x18)) count=$((0x22d40)) of=seg0-drom.bin
$ strings -n 4 seg0-drom.bin | grep -i htb
(nothing)
What the strings do contain is suggestive:
bugged26
SpyHouse
Conclusion: WiFi credentials, but no flag - so the key is stored encrypted. The challenge title and the ESP32 context point at ESP-NOW, whose pairing uses a primary/local master key. Time to read the code.
Load the IROM segment into Ghidra (language: Xtensa, little endian, 32-bit). Many functions are Arduino/ESP-IDF SDK; the interesting one is short and obviously custom.
void FUN_00002c80(int param_1, int param_2, int param_3, int param_4)
{
iVar2 = DAT_0000000c;
iVar1 = DAT_00000008;
for (iVar3 = 0; iVar3 != param_3; iVar3 = iVar3 + 1) {
param_4 = param_4 * iVar1 + iVar2;
*(byte *)(param_2 + iVar3) = (byte)((uint)param_4 >> 0x10) ^ *(byte *)(param_1 + iVar3);
}
}
A stream cipher: advance a state, XOR byte 2 of it into the plaintext. The constants live in another segment and have to be looked up there:
DAT_00000008 = 0019660D multiplier
DAT_0000000c = 3C6EF35F increment
DAT_0000002c = 01000193 initial multiplier for the seed (= FNV prime)
Conclusion: A textbook Linear Congruential Generator (LCG),
state = state * 0x19660D + 0x3C6EF35F, with the keystream taken from bits
16..23. The seed is mac_bytes * 0x01000193 - the first three bytes of the
WiFi MAC, multiplied by the Fowler-Noll-Vo (FNV) prime.
The MAC is burned into the chip's eFuses. It is not in a flash dump, so it cannot be read out.
Three bytes is 2^24 = 16.7 million possibilities - and the plaintext is known to start with
HTB{. That makes it a known-plaintext search over a trivially small space.
Testing only the first byte before doing any more work keeps it fast:
s = (seed * LCG_MUL + LCG_ADD) & MASK
if (((s >> 16) & 0xFF) ^ blob[0]) != ord('H'):
continue # ~255/256 of candidates die here
Conclusion: The unknown that was supposed to bind the key to one physical device is small enough to enumerate, and the flag format supplies the oracle to recognise the hit.
#!/usr/bin/env python3
"""
Recover the relay key from the ESP32-S3 firmware.
The sketch never stores the key. It connects to the WiFi network SpyHouse
using the password bugged26, then takes three bytes from the WiFi driver
(a MAC address), folds them with the FNV prime into a seed, and uses that
seed to drive a linear congruential generator whose output is XORed over a
32-byte blob in flash:
seed = (b0<<16 | b1<<8 | b2) * 0x01000193
for i in 0..31:
seed = seed * 0x0019660d + 0x3c6ef35f
out[i] = (seed >> 16) ^ blob[i]
Those three bytes are not in the image - they only exist on the live device.
But 24 bits is a small space, and the plaintext has to start with "HTB{",
so the seed can simply be searched.
"""
# Every constant below was read out of Ghidra. The DAT_ symbols are literal
# pool entries at the start of seg3.bin, which was loaded at base 0; the
# addresses in the right-hand column are where to find them again.
#
# seg3.bin IROM, real load address 0x42000020, loaded in Ghidra at 0
# seg0.bin DROM, load address 0x3C0A0020
#
# setup() is FUN_00002cac, the stream cipher is FUN_00002c80.
FNV_PRIME = 0x01000193 # DAT_0000002c - folds the 3 MAC bytes into a seed
LCG_MUL = 0x0019660D # DAT_00000008 - LCG multiplier, used by FUN_00002c80
LCG_ADD = 0x3C6EF35F # DAT_0000000c - LCG increment, same function
MASK = 0xFFFFFFFF # the CPU truncates to 32 bits, Python does not
BLOB_ADDR = 0x3C0B103B # DAT_00000030 - first argument of FUN_00002c80
DROM_BASE = 0x3C0A0020 # load address of seg0.bin, from the segment table
LENGTH = 0x20 # third argument of FUN_00002c80, and the buffer size
def decrypt(blob, seed):
out = bytearray()
for c in blob:
seed = (seed * LCG_MUL + LCG_ADD) & MASK
out.append(((seed >> 16) & 0xFF) ^ c)
return bytes(out)
def main():
drom = open('seg0.bin', 'rb').read()
blob = drom[BLOB_ADDR - DROM_BASE:][:LENGTH]
print('ciphertext: %s' % blob.hex(' '))
print()
for value in range(1 << 24):
seed = (value * FNV_PRIME) & MASK
# cheapest possible rejection: check the first byte before doing more
s = (seed * LCG_MUL + LCG_ADD) & MASK
if (((s >> 16) & 0xFF) ^ blob[0]) != ord('H'):
continue
out = decrypt(blob, seed)
if out.startswith(b'HTB{'):
print('mac bytes = %06x -> seed 0x%08x' % (value, seed))
print(out.decode('latin-1'))
return
print('no seed found')
if __name__ == '__main__':
main()
$ python3 solve.py
ciphertext: 10 a0 68 e7 5d e6 e7 0f 12 bb b2 f3 ca b4 d2 02 cb de d8 20 0d e5 68 92 42 00 be 14 f0 7b ba 01
mac bytes = 7cdfa1 -> seed 0x65940a73
HTB{solv3d_w1th_xt3ns4_dec00mp}
7c:df:a1 is an Espressif OUI prefix, which is a good sign the recovered value
is the real MAC rather than a collision.
HTB{solv3d_w1th_xt3ns4_dec00mp}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Carve the image | Partition table at 0x8000. Everything except app0 is erased
flash, so nothing was stored at runtime - the key must be inside the application. |
| 2 | No plaintext strings | The DROM segment yields WiFi credentials but no flag, so the key sits there encrypted. |
| 3 | Find the cipher | Ghidra with the Xtensa language on the IROM segment. One short custom function: an
LCG (*0x19660D + 0x3C6EF35F) whose bits 16..23 are XORed into the
data. |
| 4 | The seed | Seeded from the first three bytes of the WiFi MAC times the FNV prime
0x01000193. The MAC lives in eFuses, not in flash - so it is genuinely
absent from the handout. |
| 5 | Brute force it | 2^24 candidates, and HTB{ as known plaintext. Checking one byte before
decrypting the rest rejects 255 of every 256 candidates immediately. |
The design intent is clear: bind the key to one physical device so a flash dump alone is worthless. It fails on the size of the binding value. Three bytes of MAC feel like a secret because they are unavailable, but availability is not entropy - 16.7 million candidates fall in seconds, and the flag format hands over the distinguisher for free. The same scheme with the full six-byte MAC would have been out of reach.