Summary
An ESP32 Tasmota32 v15.6.0 flash image contains a custom Berry agent plus a
LittleFS partition holding a vault (device id, per-epoch keyring, sealed
record). A short MQTT capture delivers the VaultRotate request (epoch +
nonce) the gateway authenticated. Reconstructing the agent's unseal()
routine from the Berry bytecode and replaying the KDF/AEAD offline recovers
the sealed record.
Flag: hiCTF{b3rry_r0t4t10n_c94934d76344ab1151e03afd}
Files
flash.bin- 4 MiB ESP32 flash, physical offsets preserved.mqtt.pcap- MQTT 3.1.1 lab traffic (historical state + a TCP retransmit).upstream/Tasmota-v15.6.0.tar.gz- pinned source (Berry +berry_port).
1. Partition table
ESP32 partition table at offset 0x8000:
| label | type/subtype | offset | size |
|---|---|---|---|
| nvs | 1/2 | 0x9000 | 0x5000 |
| otadata | 1/0 | 0xe000 | 0x2000 |
| safeboot | 0/0 | 0x10000 | 0xd0000 |
| app0 | 0/0x10 | 0xe0000 | 0x2d0000 |
| spiffs | 1/0x82 | 0x3b0000 | 0x50000 |
The partition labelled spiffs actually holds LittleFS (disk format 2.0,
erase block 4096) per recovery.txt.
2. Carve and read LittleFS
1dd if=flash.bin of=/tmp/lfs.img bs=1 skip=$((0x3b0000)) count=$((0x50000))
Read with littlefs-python (mount via LittleFS(block_size=4096, block_count=128),
fs.context.buffer = image, fs.mount()). Extracted:
/autoexec.be-load("vault.bec")/vault.bec- 1464 bytes, Berry bytecode v4 (32-bit int/float)/vault/device.id- 16 bytesf5ed39bba88daddd7159956e7ee8254b/vault/keyring.bin- 108 bytes = 3 × (4-byte LE epoch || 32-byte key)/vault/sealed.bin- 74 bytes = 12-byte IV || 46-byte ciphertext || 16-byte tag
keyring entries: epoch 6, 7, 8 with 32-byte keys each.
3. MQTT traffic
1tshark -r mqtt.pcap -Y mqtt -T fields -e mqtt.topic -e mqtt.msg
Publishes on farm/gw-01/cmnd/VaultRotate (JSON, hex payloads):
- epoch 7:
{"device":"f5ed…254b","epoch":7,"nonce":"9bb1…65f0"} - epoch 8:
{"device":"f5ed…254b","epoch":8,"nonce":"1a3d…2606"}
(nonce is a 32-byte hex value; the epoch-8 publish is a TCP
retransmission/duplicate of the same rotation.)
4. Decode the Berry agent
Use the pure-Python Berry port from the pinned source:
1import sys; sys.path.insert(0,'.../lib/libesp32/berry')
2from berry_port import be_vm, be_bytecode, be_debug
3vm = be_vm.be_vm_new()
4cl = be_bytecode.be_bytecode_load(vm, 'vault.bec')
5be_debug.be_dumpclosure(cl) # + walk proto.ptab for sub-protos
The port's defaults (BE_INTGER_TYPE=0, BE_USE_SINGLE_FLOAT=1) already match
the bytecode header (BE CD FE 04 00 …).
unseal(request) reconstructed:
req = json.load(request); verifybytes(req.device) == read_blob("vault/device.id").epochmust beintand>= 1;noncemust be 32 bytes.- Look up the 32-byte key for
epochinvault/keyring.bin(entries of4 + 32bytes); missing epoch → errorepoch, more than one →duplicate epoch. info = bytes().fromstring("tasmota-vault/unwrap/v2") + bytes("00")→b"tasmota-vault/unwrap/v2\x00".derived = HMAC_SHA256(key, info + device.id + epoch_le4 + nonce).gcm = crypto.AES_GCM(derived, sealed[0:12])plaintext = gcm.decrypt(sealed[12:58]);gcm.tag() == sealed[58:74].
Note the vm uses inclusive ranges for slices, so the split is IV [0:12),
ciphertext [12:58), tag [58:74).
5. Recover the record
1import hmac, hashlib
2from cryptography.hazmat.primitives.ciphers.aead import AESGCM
3key = bytes.fromhex("770a76ce441e8722482aa05dcc8f06424a4ade0c3cf0f0e01676a9d0ac69ef66") # epoch 8
4dev = bytes.fromhex("f5ed39bba88daddd7159956e7ee8254b")
5non = bytes.fromhex("1a3dfb1382455e073f25450c8c8cee24ba89b0a924f86b7103a732ab902a2606")
6msg = b"tasmota-vault/unwrap/v2\x00" + dev + (8).to_bytes(4,"little") + non
7dk = hmac.new(key, msg, hashlib.sha256).digest()
8iv, ct, tag = sealed[:12], sealed[12:58], sealed[58:]
9pt = AESGCM(dk).decrypt(iv, ct + tag, None)
Only the epoch-8 request (with a NUL separator) yields a valid GCM tag (the epoch-7 historical request does not authenticate).
Result: hiCTF{b3rry_r0t4t10n_c94934d76344ab1151e03afd}
Flag
hiCTF{b3rry_r0t4t10n_c94934d76344ab1151e03afd}