Only 132 Bytes…
By Anthony Mattas

We took advantage of an unsigned 132-byte header to run our own code on the DEF CON 34 badge. This let us access and record secrets from the device's display without causing any damage or setting off its self-destruct feature.
Responsible Disclosure
I worked on my own DEF CON 34 badge and worked with its, bunnie on disclosure. The DC34 badge isn't a production device yet, but it is real hardware, so I made sure to follow responsible disclosure prior to sharing this article.
The Short Version
The badge checks the signature over the firmware bodies but ignores the 132-byte header of the boot loader. The signature block is padded to fill a 4 KB page, which keeps the executable code aligned, and the first jump skips past all that padding to reach the real entry point. The header is where the previous boot stage hands off control, so by changing a few unsigned bytes there, I got the processor to run my own code before the loader started. My shell code pulls the protected challenge secrets from memory, shows them as hex on the screen so I can record them, when done I restore the original firmware and the badge boots as if nothing happened.
The Analogy, for People Who Don't Do This for a Living
Imagine each firmware image as a sealed envelope. The letter inside is protected by a wax seal, similar to the Ed25519 signature the badge checks before trusting what's inside. But a note on the outside, like "start reading at page 1," isn't protected by the seal and isn't checked.
An attacker could write, "actually, start reading at page 70" on the envelope and add their own page. The seal stays intact, the letter inside doesn't change, the verification still works, and the reader starts at the spot the attacker chose.
What Is This Badge?
The DEF CON 34 electronic badge is built on the BaoSec (bao1x) platform and runs the Xous microkernel operating system, created by a longtime community member, bunnie.
This is real security hardware, not just a blinking circuit board. It uses a RISC-V processor with process isolation, virtual memory, and signed boot. The badge has hardware-sealed key slots in resistive RAM, managed by the chip itself. Each boot stage checks the next with cryptographic verification. It also has an OLED display, LEDs for unique light patterns, a camera for QR codes, and USB. It supports FIDO2 and U2F, so it can work as a hardware security key.
The badge runs a light-breeding game. Each badge has its own unique light pattern, like a genome, encrypted with a key specific to that badge. When you scan another badge's QR code, the two patterns combine using AES-256-GCM-SIV authenticated encryption. Every new pattern is unique, and because of the encryption, you can't fake a breeding without the real key.
The Challenge
Bunnie set up a challenge: if you can get your badge into developer mode without erasing the secrets, you win, but only if you show how you did it. In return, he gets a free security audit.
The catch is a self-destruct feature. The badge is set up so that loading custom firmware puts it into developer mode and erases all its secrets. The hardware root keys, game key, and flag value are wiped before your code runs. This dead man's switch is the main part of the challenge. The goal was to access the sealed key material without triggering the self-destruct. This meant running our own code on the badge while keeping its secrets safe. Every design choice in this attack, from only changing unsigned bytes to lowering privilege on purpose, was made to avoid triggering the erase. We managed to do it.
How the Badge Boots
The badge has a four-stage boot chain, each stage checking an Ed25519 signature on the next before handing off. Boot0 is ROM-burned into the chip and immutable. It verifies and jumps to boot1, the first-stage bootloader. Boot1 verifies the loader, which is the second-stage bootloader and where our attack lives. The loader verifies and boots the kernel, which is Xous and all the apps running on top of it.
This is a textbook secure boot chain, and structurally it is sound. Each stage checks the cryptographic signature of the next one before handing off control, and if anything has been tampered with, boot stops. It also detects images signed with the intentionally public developer key, which is the trigger that arms the self-destruct.
The main point is that the signature check works, but the real issues are what it actually covers and where the code starts running.
Three unsigned details add up to a complete break.
The Image Header Is Outside the Hash
Every signed firmware image starts with a 132-byte header. The first four bytes are a jump instruction, and this is the byte the processor starts executing at. The next sixty-four bytes are the Ed25519 signature itself. After that comes a four-byte field that states how much of the header the signature actually covers, followed by sixty bytes of additional authenticated data. The signed body of the image begins only after all of that, at byte 132.
| Offset | Size | Contents |
|---|---|---|
0x00 | 4 | jal the jump instruction that execution enters at |
0x04 | 64 | the Ed25519 signature itself |
0x44 | 4 | aad_len how much of the header the signature covers |
0x48 | 60 | aad (additional authenticated data) |
0x84 | … | start of the signed body ← signature covers HERE onward |
The signature check hashes bytes from byte 132 onward. The header is, by definition, not covered by the hash of the thing it prefixes, because you cannot sign a field that contains the signature. The designers knew this and even commented it in the source code, noting that the jump instruction and the signature itself are not protected:
/// The jump instruction and the signature itself are not protected
pub const UNSIGNED_LEN: usize = SignatureInFlash::sealed_data_offset();
But the loader starts running the image at offset zero, right at the unsigned jump instruction. That alone would be enough for an attack, but there's more.
The Authenticated Length Is 37, Not 60
The header field that states how much of the additional authenticated data is folded into the signed payload is set to 37 in production, because the FIDO2 verification path signs only the first 37 bytes of that data together with a hash of the body. So those first 37 bytes are genuinely authenticated and untouchable.
The remaining bytes of that field, roughly twenty-three of them, are not. They sit beyond the authenticated length; they are unused, and on the production image they are all zeros. That is about twenty-three bytes of free, unsigned space sitting inside a signed image. We verified this directly against the real firmware: the first word is the jump instruction, the authenticated length reads as 37, and the trailing header bytes are all zero.
word0 (jal): 6f000030
aad_len @0x44: 37
bytes 0x6D..0x84: 0000000000000000000000000000000000000000000000
The Updater Writes Wherever You Point It
Boot1 has a console that exposes an update command over USB mass storage. It checks that each block's destination address falls inside the allowed flash window, and that's it. There is no signature check on individual blocks, no ordering, and no requirement that you're writing a complete image. An attacker can control the block addresses, the length, and the payload.
There is one more gift. Writes to the resistive RAM go through a 32-byte line-based read-modify-write. That means you can overwrite just the four-byte jump instruction at the start of the loader while the sixty-four-byte signature immediately after it is preserved byte for byte, allowing the image you just modified to still verify.
The three things come together: the entry point is unsigned and can be changed, there is unused space in the header that is also unsigned and writable, and the updater lets you write any bytes to any address within the flash window.
The Exploit: Three Hops to Victory
Twenty-odd bytes of unsigned header is not enough to do anything interesting, so the attack uses three hops.
The first hop replaces the four-byte stock jump instruction at the start of the loader with a jump into the unsigned header tail. Execution now lands in that dead space instead of at the real loader entry.
The second hop is a twenty-byte springboard living in the unauthenticated tail of the header. It is just two instructions: load the upper bits of the stage-two address, then jump there.
lui t0, 0x6008C ; load upper bits of stage-2 address
jalr x0, 0(t0) ; jump there
Finally, the third hop is the real payload, about 2.9 kilobytes, parked in the unsigned dead gap between where the loader image ends and where the kernel's signature block begins. Nothing exists to verify this region, and nothing else uses it.
[stage1] 20B @ 0x60060070: b7c2086067800200000000000000000000000000
[blockA] 4B @ 0x60060000: 6f000007 (jal x0,+0x70)
[stage2] 2900B @ 0x6008c000..0x6008cb54 (ceiling 0x6009fd00)
[uf2] 14 blocks, 7168B
The whole attack uses fourteen update blocks, totaling about seven kilobytes.
Why the Secrets Survive
On the next boot, the signature check passes. Then the developer-key check runs and finds no developer key, since the production key still signs the image and we did not change the signature or the signed part. So, the erase policy never triggers. Boot continues into our code with all secrets still there.
A Twist: Being Root Is Not Enough
Our payload runs in machine mode, which is RISC-V's highest privilege level, like ring zero on x86. You might think that means the game is over, but it's not.
The two target secrets live in hardware-sealed key slots. Before jumping to the loader, boot1 seals its keys, and the sealing hardware does not ask how privileged you are. It asks who you are. Access is gated on the address-space identifier, essentially which process you are. The answer the hardware wants is the identity of the keystore process. Machine mode, despite being the most privileged level, is explicitly excluded from the sealed slots. Supervisor mode running as the keystore's identity is not.
So the payload deliberately drops its own privilege. It saves off the machine trap vector, installs its own trap handler, then builds a tiny two-entry page table in scratch memory. The page table identity-maps the resistive RAM and the peripheral region so virtual addresses equal physical ones and nothing else needs to change. It points the address translation register at that page table under the keystore's identity, clears the trap-delegation registers (that part matters in a second), sets the previous-privilege field to supervisor mode exactly, and returns down into a supervisor-mode stub. That stub is now running as the keystore, so it reads both thirty-two-byte secrets into scratch memory and calls back up to machine mode.
As the badge's source code says, in machine mode you can create any identity you want, so running any code in the bootloader lets you bypass the controls. That is the main point. The seal assumes that only trusted code runs in machine mode at this stage of boot. The header gap breaks that assumption, and everything else follows from there.
Three Failures Worth Keeping
The constants in the exploit are a fossil record of what did not work, and each one is a nicer lesson than the success.
The first failure was setting the page-table entries valid, readable, writable, accessed, and dirty, but not executable. The demotion succeeded and then immediately faulted the instant it tried to run. Adding the execute flag fixed it.
The next failure was trying to drop privilege by just setting the supervisor bit in the previous-privilege field, but boot1 enters the payload with that field already at machine mode. Setting one more bit still leaves you at machine mode. The return "worked," but I was still in machine mode, and the sealed-slot read hung the bus. The fix was to clear both privilege bits first, then set only the one you actually want.
Third failure was the vanishing syscall. Boot1 delegates all traps to supervisor mode before it jumps, so when my supervisor-mode code made a system call, it routed to a supervisor trap handler that doesn't exist. The badge just froze, no error, nothing. To fix that clear the delegation registers on the way down and put them back on the way out.
Debugging all of this on hardware without a console, debugger, or serial output is why the builder includes a diagnostic mode. It shows checkpoint stripes on the display after each stage, working like a simple print statement, eight pixel columns at a time.
Getting Secrets Out Through the Screen
At this stage of boot, the software stack is not running yet: there is no network, no USB, and nothing to send data over. The only output is the display, so getting data out means someone with a camera takes a picture.
The first approach to this that actually worked was the simplest: dump both thirty-two-byte secrets as hex on the display, hold it there for ten to forty seconds, and stop. Someone snaps a photo, transcribes the hex, and that's your proof.
The font comes from boot1's own six-by-twelve pixel font sheet, which the builder extracts at build time and embeds in the payload. There is a deliberate trick here: the glyph order is chosen so that a hex digit's value is also its position in the font, which makes the rendering loop a lookup-table-free operation with essentially zero overhead.
But, there was one problem discovered on the first hardware attempt. The normal boot path never turns the display on. Only the update path initializes the panel. On a normal boot, the display's SPI controller is still clock-gated, so the first attempt to poll a transfer-status register spins forever, which shows up as a black screen, no USB, and no clue why.
As a result, about a third of the payload is just a hand-assembled port of the display driver: pin muxing, panel power sequencing, a reset pulse, ungating the SPI clock for the display, mode and divider config, the panel init stream, and a full-white flush as a sign-of-life. Every hardware wait has a timeout-and-continue around it, because I learned the hard way during a later attempt which got stuck halfway through a flush on a busy-bit assumption that held for a few transfers and then didn't.
The Multi-Page Dump: Paging Key Material Across the Screen
Those first two values proved the attack, but they do not finish the challenge. Getting the badge's game key also requires its nuisance keys and chaff keys, several kilobytes of material that no one is going to transcribe off a screen one nibble at a time. So the dump variant turns the display into a slow, one-way serial port.
After the same identity demotion used for the two secrets, the payload copies the key-derivation material into scratch memory, appends a checksum over the whole block, and then pages it across the display as a grid of hexadecimal glyphs, one page at a time, looping forever.
Each page is a sixteen-by-ten grid using the same font: the first two cells show the page number, and the rest show data. It stays on each page for about a second, cycles through all the pages, and repeats.
Two design choices in this approach are the page numbers on screen and the checksum over the stream. Together they make the whole thing self-auditing: the decoder never has to trust that it read any single frame correctly, because it finds out at the end.
Reading It Back
When you record a screen with a phone you get shaky, half-blurry video of a glowing blue rectangle that flips every second, full of hex digits and a checksum. Just reading it right would be hard enough, but the decoder goes through four steps to pull usable data out of that mess.
First it finds the screen and splits the video into pages. Every frame gets thresholded on the blue glow to locate the panel corners, warped flat, and brightness-corrected. Each frame is then registered against a running reference, and the key insight is simple: two frames showing the same page match tightly, but a page flip doesn't. That gap is clean enough to segment the whole video into per-page runs without ever reading a page number.
Once a run is isolated, the aligned frames get median-stacked to crush noise. Now you need a character grid, but you can't place it from the detected screen outline. That outline tracks the glow halo around the display, not the pixel grid itself, and the distortion is enough to throw character alignment off. So instead the grid gets fit from the stacked image directly. Frequency-domain autocorrelation recovers the exact character spacing, and then a search locks down the origin and the row/column offsets.
With the grid in place, sixteen templates, one per hex digit, get matched against each cell with a small jitter search. The gap between the best and second-best match gives a confidence score per cell. Known problem pairs like 0/8 and 6/B get corrected templates so they stop fooling the matcher.
Finally, everything has to be stitched together and verified. Page numbers live in the weakest corner of the panel, so trusting them one by one is asking for trouble. Instead a sequence-reconstruction pass assumes they increment by one, and the structure across hundreds of runs resolves the ordering collectively. The absolute starting point gets brute-forced by trying every rotation and keeping whichever one's checksum passes. After that, two checks seal it: the hand-transcribed root seed and challenge flag have to land at their known positions or the whole thing gets rejected. For near-misses there's a repair tool that exploits the linearity of the checksum. Low-confidence cells are the suspects, and because flipping a byte shifts the checksum by a predictable amount, you can search single and double corrections directly instead of guessing blind.
What We Captured
From Badge B, a rev A0 board, we pulled both challenge secrets: the root seed from data slot 256 and the challenge flag from data slot 260. Both are thirty-two bytes and sit at their documented addresses in the sealed key region. The badge's secrets came through untouched, which is the whole point of the challenge: you get developer-level code execution without tripping the self-destruct.
With the multi-page dump adding the nuisance and chaff key material, the PDDB master key is derived offline by re-implementing the keystore's own key schedule: the root seed and the nuisance keys feed the key-derivation function that produces the base master key, and the chaff keys are folded in (the firmware reads them in a random order each boot, but the fold is order-independent, so having all of them is enough). That master key unlocks the PDDB, and the game key k0, and is derived from it and validated against the badge's own published diagnostic hash and the publicly disclosed key fragment.
Everything below is published with permission. To keep from dumping every raw secret, flag 1 and the derived PDDB master key are shown as SHA-256 digests. The game key k0 is published in full so anyone can check it against the badge's diagnostic hash.
k0 (game key, AES-256-GCM-SIV):
<K0>
k0 verification (public):
SHA-256(k0)[0:4] = dca9ea49 (shown on the badge's Diagnostics screen)
disclosed fragment = 7ad84ed0e00aec0499ede65615e1da51 (from the official challenge page)
SHA-256(flag 1) = 8e817665bab84a5131b08b9c7f2be4773d45ee86eaed25389212c9183c4c057a
SHA-256(PDDB master key) = 2f995eb865427ba2f4f363a76d5165fc0e03c271616b6b96a7926109e2c1aed7
What Else We Found Along the Way
The audit surfaced several other issues, all reported to the vendor. The highlights, roughly ranked by severity, follow.
Within the chip, access to the key database is flat. There is no process-to-process access control so that any process can read any key; there is no password on the keystore, and the game key sits in plaintext in the vault process's memory for its lifetime. Taken on its own, that means any code-execution bug in any process yields the game key, and the signed-boot bypass is the cleanest path to it rather than the only one.
It is worth being fair about the intended threat model, though. This part is designed as a security component, a chip meant to be integrated onto a larger board, and its model expects the surrounding system to provide the outer layers of defense in depth, such as host-side isolation, a secure enclosure, and attestation. Several of the layers one might expect are meant to live off the chip, at integration time, rather than inside it. The on-chip surface should still be hardened where it can be cheaply, and scrubbing long-lived key material from process memory is a reasonable ask. Still, flat internal access is partly a deliberate division of responsibility rather than simply an oversight.
Every process gets executable data pages, which blows up write-once-execute. There's a one-line bug in the loader that unconditionally marks all loaded sections as executable, including writable data and zero-initialized segments. The conditional check that was supposed to gate this is dead code on the very next line. So every process ends up with RWX pages at fixed addresses, and if you have any write primitive at all, shellcode injection is trivial.
On this badge build, the USB console is an unauthenticated command shell. Debug injection is on, so every byte you send to USB serial becomes a keystroke in the console. No button press, no user interaction, no auth. The source code's own comment admits this is an exploit path. Like the flat key access, this comes back to the threat model: the USB console is a bring-up and debug convenience, not a production feature, and the security chip as shipped wouldn't expose USB at all so that the door wouldn't exist on a production part. Worth flagging though, because it's live on badges in people's hands, and a debug interface this powerful is easy to leave on by accident.
Inter-process deserialization is unchecked, and this one is a longer-standing architectural concern in Xous, not a badge-specific bug. Messages get deserialized with a zero-copy access that does no validation or bounds checking, and the length field that controls how many bytes to read comes straight from the sender. The microkernel's IPC leans on this unchecked zero-copy access across trust boundaries, and the property predates this device. It shows up wherever Xous runs. I'm flagging it here because it's the substrate several other issues build on, and it deserves dedicated research rather than a one-line finding. Deeper research on where sender-controlled deserialization crosses a privilege or trust boundary in Xous would be worth doing on its own.
There's a pre-auth clock attack via QR codes. Both the time-setting QR handler and the password-auth QR handler set the system clock with no approval prompt and before any mode check. On a badge that stores TOTP secrets, an attacker could roll the clock forward, read future codes off the screen, roll it back, and walk away with precomputed codes for every enrolled account.
The custom FIDO2 code has several bugs, though the upstream portions it is built on are clean. There are several unchecked accesses on absent fields that panic the FIDO thread with just two USB packets, a PIN-length check that accepts an out-of-spec value and permanently locks out PIN setup, and a stale-state problem in the large-blob handler.
Roadblocks and Dead Ends
Getting to the header gap wasn't a straight line. I chased a few other promising attacks and burned time on each before abandoning them.
The first idea was to overflow an inter-process message page through the camera. The badge deserializes those messages without bounds checks, so the plan was to overflow a message page and get a downstream process to read past it. The most attacker-controllable input into that path is QR: point the camera at a crafted code and let the decoder hand an oversized payload downstream. Doesn't work. To actually overflow the page, you need a QR symbol carrying more data than the camera can physically resolve and decode. The bug is real in the code, but the hardware is accidentally protecting it.
The next candidate was the same machinery but reached through the on-screen name field. The text-entry and modal input path feeds into the same unchecked deserialization, so it looked like a second front door to the same bug. That one's structurally dead, not just impractical: the sending side panics at the same length threshold that would overflow the receiver, so the process crashes before the malformed page ever gets handed off.
Then there was the BIO coprocessor. The chip has a coprocessor that can run arbitrary code at high speed, which would have been a far cheaper path to memory than a boot-chain exploit, if it could reach system RAM. It cannot. I tested it directly on hardware by trying to read known bytes out of the loader image through it and got zeros back, because the memory-access filter is closed on real silicon.
Only after these dead ends did attention turn to the boot chain itself, and to the 132 bytes nobody signs.
Putting the Badge Back
Not every variant I tested hands control back, and it's worth being specific about which ones do. The early builds self-revert. After the read, the payload restores the trap vector, restores the delegation registers, sets up the calling convention the real loader expects, puts the privilege field back to machine mode, and returns to the stock loader entry point, exactly where the first jump instruction would have gone. The badge finishes booting as nothing happened. Secrets intact, no erase triggered, no developer-mode tripwire.
The hold-and-dump builds don't hand back. That's by design. They freeze or loop on the display forever so you can photograph or film the screen, and they never return to normal boot on their own. Power cycling kills the display, but the patched loader is still in flash, so the next boot just runs the payload again. To get the badge back to normal, you reflash the stock loader image through the regular update process. That rewrites the header and leaves the device completely stock. Reflashing is how you bring any patched badge back regardless of which variant you ran.
Working With AI Models
I did this work with LLM assistance, and I want to be discuss what doing this type of security work with them looks like, both because it shaped how the attack came together and because low-level hardware work turns out to be a pretty revealing stress test for these tools. One thing I do want to clarify, I do have trusted access for cybersecurity work on both Anthropic's Claude and OpenAI's ChatGPT, and this was a scoped assignment I was cleared to do.
The absolute first thing worth saying is that no model found the exploit on its own. Every one of them needed a lot of hand-holding. They required human provided hypotheses, hardware recon, and triage of dead ends. The models definitely accelerated things once pointed in a direction. The fact that this chip is fully open, with public source and public schematics, made that way easier, which cuts both ways.
The biggest source of friction was refusals. Even with trusted access and a clearly scoped penetration test, several Anthropic models, namely Fable 5, Opus 5, and Opus 4.8, declined to engage with the task. Opus 4.6, by contrast, was exceptionally helpful, though it still declined to handle some of the extracted key material. The model that ultimately got the work over the finish line was Kimi K3; it was not as strong as Opus overall, but it was willing to work through the final steps that the others stopped short of. OpenAI's GPT SOL, on the Ultra tier, was the weakest fit for the hardware work here, both less effective than the others at the low-level reasoning and significantly slower.
This is not meant as criticism of any vendor's safety policies, since refusing dual-use requests is a reasonable default. But for authorized users, it does slow down the work, and the best tools were those that could tell the difference between an approved project and misuse.
Takeaways
Sign the code you actually run, not just what you include. The signature covered the payload fully, but it did not cover the entry point, which is the most important byte for control flow. Unused and not reachable are different claims. The header tail was unused, zero-filled padding. Unused padding inside a region an attacker can write is not padding. It is a code cache.
Range checks aren't the same as real validation. The updater only checked if the address was inside the flash window, not if the result was a valid, signed image. The source code even notes that the range check stops the updater from being a full arbitrary-write tool, but right after that, it still lets you write to the first byte of the loader — the unsigned jump instruction. Any partial write to a signed file should make it invalid.
Fine-grained line writes have pros and cons. The 32-byte read-modify-write size that makes resistive RAM easy to program is also what lets you change four bytes without affecting the signature just after them.
Isolation that relies on a trusted execution context also inherits its bugs. The identity-based slot sealing is solid hardware, but it assumed nothing untrusted would run in machine mode before sealing the slots. The header gap broke that assumption.
A screen with a fixed grid of glyphs is really a data channel, not just a picture. When the display is your only output, treat it like a protocol: use a fixed layout, a known font, and cross-checks to turn a photo of hex into reliable data.
It is also worth noting the difference between open and closed hardware. This device's security might end up better than a lot of closed hardware. The tradeoff is that open hardware needs to be updated more often early on, since issues are found, shared, and fixed in public. This is not a problem unique to open designs; closed chips get hacked too, as YubiKey did in earlier firmware. Openness changes when and how fixes are made public, not whether they are needed.
Tools and Artifacts
I'll be updating this post shortly with the current POC code.
The disclosure package has two halves: the exploit builder and the offline decode-and-derive pipeline.
The exploit builder is build-only and never touches hardware. It includes a small two-pass assembler with label resolution, a font extractor that pulls glyphs from boot1's font sheet, and the stage builders for each variant. Before it produces anything, it re-reads the actual stock loader and asserts that the jump instruction hasn't moved, the authenticated length is still 37, the header tail is still zero, and the payload still fits under the kernel signature block. That last check matters more than it sounds. The whole attack is a stack of assumptions about someone else's binary layout, so catching a broken assumption at build time means a failed build, not a bricked badge.
The other half is the offline pipeline: the video decoder that turns a phone recording of the paged hex screen into a verified dump, a checksum-syndrome repair tool for near-miss decodes, and the key-derivation script that validates the result against the badge's published diagnostic hash and the disclosed key fragment.
The build variants weren't really a menu, more of a ladder where each one answered whatever question the last one raised. Started with an eight-byte hand-back probe to confirm writes landed. Added a checkpoint-stripe mode to get eyes on a boot that had no console. Moved to the hold read that proved the break, then the full build that made it repeatable, then the dump build that scaled it to the full key material. A separate transfer-diagnostic pattern helped chase down display and DMA glitches along the way. That last builder, the whole ladder in one file, is what I'm planning to post as a Gist.
Note: this may not be the last word on the DC34 badge… stay tuned!
Comments
Sign in to join the conversation
No comments yet. Be the first to share your thoughts!