HTB Cyber Apocalypse 2026 - The Salt Crown: Hardware / A Fault in the Cinder (Hard)

Author: Hubert Feyrer / hubertf - 28.07.2026

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.

1. Download

$ 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
FileWhat it is
cinder_boot.elfthe 4700-byte measured-boot ROM, Arm Cortex-M3 at 24 MHz
factory_spi.srsigrok capture of the flash bus - both firmware slots with their 128-byte manifests
factory_service.srsigrok capture of the secure element bus during a genuine authorized boot
TARGET.pdfthe datasheet - and, as it turns out, the answer
rig.pyclient library for the three bench TCP lines

2. Docker/nc - what we get

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 containsRoleWhat it does
RIGfault rigpower, strap, trigger, glitch pulse, capture
LAanalyzer clampreturns captures as gzipped VCD, 9 channels
VAULTRUNErecovery consoleprints 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.

3. Analysis steps

3.1 Map the bench (success)

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.

Bench topology of the Cinderbound coffer The coffer contains a Cortex-M3 boot ROM, a secure element on a 4 MHz SPI bus, and a flash chip on a second SPI bus. Three instruments reachable over TCP are shown in blue: a fault rig driving the glitch pulse into the CPU, an analyser clamp tapping the buses, and a recovery console. Two capture files shipped in the handout are shown in green, each at the bus it was recorded from. fault rig power, trigger, pulse GLITCH_OUT the coffer Cortex-M3 24 MHz, boot ROM acts on the verdict secure element holds the record and BOOT_PCR SPI, 4 MHz factory_service.sr flash two firmware slots factory_spi.sr analyser clamp 9 channels, gzipped VCD recovery console prints the offer taps both buses signet live instrument, reachable over TCP capture file shipped in the handout

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.

3.2 Decode what an authorized boot looks like (success)

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.

3.3 Find where the verdict is acted on (success)

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
Ghidra call graph of the boot ROM, with FUN_000002c2 highlighted

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.

3.4 Build a trigger pattern (success)

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.

3.5 Count the delay (success)

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.

3.6 Fire it (success)

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.

3.7 Turn the flash capture into files (success)

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.

PulseView showing factory_spi.sr: CS_FLASH, SCK, MOSI and MISO

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:

  1. The container. A .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.
  2. The bus. SPI mode 0 (CPOL=0, CPHA=0), MSB first - one byte is eight rising 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.
  3. The protocol. What the boot ROM sends is an 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.

3.8 Lift the firmware - and get the manifest size wrong (failed)

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.

3.9 Reverse the sealing scheme, and prove it offline (success)

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.

3.10 Hunt for the 32-byte block (failed)

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 familyTests
every 32-byte window of every handout file, raw and hashed67,124
field variants of the measurement, once it was found57,600
manifest substrings, with and without the label prefix24,768
prefix constants x block candidates, both byte orders11,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.

3.11 Try the bench levers (failed)

Before falling back on cryptanalysis, walk the levers the rig exposes - rig.py hard-codes one value for each, which is worth questioning.

LeverResult
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.

3.12 The resolution: one word in the datasheet (success)

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.

4. Solution

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:

FilePurpose
build.shevery derived artefact, in dependency order, offline
count.pythe cycle count, one line per instruction
slots.pyflash capture -> both firmware images and their manifests
signet.pybench capture -> ticket.b64
factory.pythe bench record, decrypted from the handout alone
disasm.pyThumb-2 disassembly of a lifted image
vcddec.pythe clamp's VCD -> SPI transactions
spidec.pythe same for the handout's sigrok captures

5. Run it

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.

Flag

HTB{a_f4ls3_n0t3_0p3ns_th3_tru3_s34l}

6. Summary of how the exploit works

#StageMechanism
1Present 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.
2Time 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.
3Glitch: 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.
4Rebuild 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.
5Decrypt 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.

A loose end not chased

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.

What to do differently next time

  1. Read the datasheet for verbs, not just for values. Every constant in the measurement was already in the notes; the word that said what to do with them was not.
  2. Treat an internal contradiction as a result. pool = 0 and pool = 0xc0 in adjacent calls was a finding about the artefact, not noise to route around.
  3. Build the offline oracle first. Decrypting the bench record cost an afternoon and made every later search a yes/no question instead of a judgement call.