Neven Marić
Hidden Investigations CTF 2026

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.

  • hardware
  • esp32
  • tasmota
  • berry
  • bytecode
  • littlefs
  • mqtt
  • aes-gcm
  • hmac
  • key-derivation
  • pcap

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.txt inside 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}

The epoch is encoded little-endian (bytes().add(epoch, 4)), matching the successful decrypt; big-endian fails.

Tooling

  • littlefs-python to mount/extract the LittleFS partition.
  • scapy to reassemble TCP and parse the MQTT PUBLISH payloads.
  • Custom pure-data Berry v4 disassembler built from pinned Tasmota source headers.
  • cryptography (AESGCM) + stdlib hmac/hashlib to reproduce the key unwrap.

All work was performed entirely offline; no captured IP was contacted.

The flagRevealhiCTF{b3rry_r0t4t10n_c94934d76344ab1151e03afd}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.