HardwareHidden Investigations CTF 2026
Tasmota Vault
A retired ESP32 farm gateway runs official Tasmota32 v15.6.0 with a custom Berry agent. Its 4 MiB flash image (flash.bin) carries a LittleFS filesystem holding the compiled agent (vault.bec), a key ring, a device id, and a sealed record.
What this one teaches
- Parsing a LittleFS partition from a raw ESP32 flash dump by reading the partition table offsets.
- Disassembling Berry v4 bytecode as pure data from Tasmota source headers rather than running a full emulator.
- Reconstructing the HMAC-SHA256 key derivation and AES-256-GCM unwrap chain from decompiled Berry logic.
Task description
A farm gateway was retired during a vault-key rotation. Its flash still holds a sealed record, and the recovery lab preserved a short broker session. Recover the record the gateway could authenticate.
The gateway uses official ESP32 Tasmota firmware with a custom Berry agent. The attachment is a reproducible laboratory fixture;
recovery.txtinside the archive describes its acquisition assumptions and the matching tooling. Analyze both the flash image and the MQTT traffic.This challenge is entirely offline. No physical board, running broker, emulator, brute force, or connection to the captured IP addresses is required.
- Category: Hardware (offline)
- Challenge ID: ceda2443-9f44-4e57-b25d-7db7307049fd
- Flag:
hiCTF{b3rry_r0t4t10n_c94934d76344ab1151e03afd} - Submission result:
goodFlag(“The flag is correct.”)
Approach
Flash extraction
ESP32 partition table at offset 0x8000:
| label | type | subtype | offset | size |
|---|---|---|---|---|
| nvs | data | 0x02 | 0x9000 | 0x5000 |
| otadata | data | 0x00 | 0xe000 | 0x2000 |
| safeboot | app | 0x00 | 0x10000 | 0xd0000 |
| app0 | app | 0x10 | 0xe0000 | 0x2d0000 |
| spiffs | data | 0x82 | 0x3b0000 | 0x50000 |
Per recovery.txt, the partition labelled spiffs is actually LittleFS (disk
version 2.0, 4096-byte erase block). Mounting that region (offset 0x3b0000, size
0x50000) with littlefs-python yields:
/autoexec.be 18 -> load("vault.bec")
/vault.bec 1464 -> compiled Berry agent (bytecode v4)
/vault/device.id 16
/vault/keyring.bin 108 -> 3 entries of (uint32 LE id || 32-byte key), ids 6,7,8
/vault/sealed.bin 74
Recovering the Berry agent
vault.bec is Berry bytecode v4 (32-bit int + single-precision float, per
recovery.txt). Rather than execute the upstream Berry port, the bytecode container
(see src/be_bytecode.c) and instruction encoding (src/be_decoder.h,
src/be_opcodes.h) from the pinned Tasmota v15.6.0 source were used to write a
standalone disassembler that only reads the .bec as data.
Key encoding facts: 32-bit instruction, fields OP[31:26] RA[25:18] RKB[16:9] RKC[8:0],
Bx = ins & 0x3FFFF, sBx = Bx - 131071. Constant type tags: BE_INT=1, BE_REAL=2,
BE_BOOL=3, BE_STRING=16, BE_CLASS=17.
Decompiled unseal(request) logic:
req = json.load(request) # must be a map
dev = read_blob('vault/device.id') # 16 bytes
assert size(dev) == 16
assert bytes(req['device']) == dev # hex string -> bytes
epoch = req['epoch'] # int, >= 1
nonce = bytes(req['nonce']) # hex string -> 32 bytes
keyring = read_blob('vault/keyring.bin') # size % 36 == 0
# find the single entry whose uint32 LE id == epoch (duplicate -> error, none -> error)
key = keyring[i+4 : i+36] # 32-byte key for that epoch
msg = bytes().fromstring('tasmota-vault/unwrap/v2')
+ bytes('00') # single 0x00 byte
+ dev # device id, 16 bytes
+ bytes().add(epoch, 4) # epoch, 4-byte little-endian
+ nonce # 32 bytes
dk = crypto.HMAC_SHA256(key).update(msg).out() # 32-byte derived key
sealed = read_blob('vault/sealed.bin') # >= 29 bytes
iv = sealed[0:12]
ct = sealed[12:58] # 46 bytes
tag = sealed[58:74] # 16 bytes
aes = crypto.AES_GCM(dk, iv) # AES-256-GCM, no AAD
pt = aes.decrypt(ct)
assert aes.tag() == tag # else 'authentication tag' error
return pt
MQTT capture
mqtt.pcap is lab Ethernet/IPv4/TCP MQTT 3.1.1 (with retransmissions). The agent
subscribes to farm/gw-01/cmnd/VaultRotate. Reassembling the broker->gateway stream
(10.42.0.5:1883 -> 10.42.0.23) shows two QoS1 PUBLISHes on that topic:
{"device":"f5ed39bba88daddd7159956e7ee8254b","epoch":7,
"nonce":"9bb1ebe2f5663269d3be1348ee57dc7dbce22b373243aebd67897581cf6f65f0"}
{"device":"f5ed39bba88daddd7159956e7ee8254b","epoch":8,
"nonce":"1a3dfb1382455e073f25450c8c8cee24ba89b0a924f86b7103a732ab902a2606"}
device matches vault/device.id. Epoch 7 is a historical (rotated-out) request;
epoch 8 is the one the gateway could still authenticate.
Solve
For each (epoch, nonce), derive dk and attempt AES-256-GCM decrypt of
sealed.bin:
- epoch 7 ->
InvalidTag - epoch 8 -> authenticates
- derived key
e7c9ce35c78cb5f4d65753e15986b2aed0190a462204fc13cbe8c5162cda51a7 - plaintext
hiCTF{b3rry_r0t4t10n_c94934d76344ab1151e03afd}
- derived key
The epoch is encoded little-endian (bytes().add(epoch, 4)), matching the successful
decrypt; big-endian fails.
Tooling
littlefs-pythonto mount/extract the LittleFS partition.scapyto reassemble TCP and parse the MQTT PUBLISH payloads.- Custom pure-data Berry v4 disassembler built from pinned Tasmota source headers.
cryptography(AESGCM) + stdlibhmac/hashlibto reproduce the key unwrap.
All work was performed entirely offline; no captured IP was contacted.
The flagReveal
hiCTF{b3rry_r0t4t10n_c94934d76344ab1151e03afd}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.