SECURITY RESEARCH / EMBEDDED / OFFENSIVE SEC1.DK
← back to blog
Hardware RE / ST-Link clone / ARM Cortex-M3

Two Bricked Devices
and a Two-Byte Fix

A Black Magic Probe build on a Geehy APM32 ST-Link clone sat in DFU forever after a cold boot, while genuine J-Link OB firmware — flashed to the identical address — came up instantly with zero host software. The bootloader's own launch routine has no validation logic at all, which makes that difference genuinely strange. This is the full account: the theory that bricked two sealed units, the one that turned out to be a documented technique aimed at the wrong page, and the real gate — one comparison, in a different page entirely, fixed with two bytes.

Target Geehy APM32F103C8 Bricked 2 units Jun. 2026
theories killed
4
bricked
2
final patch
2B
the actual gate bootloader page 8, 0x08002210
; the bootloader's own launch decision — verified by disassembly
; of Bootloader_2.0.bin, traced from reset, not guessed at:

0x0800221C: ldrb r0, [r4]       ; load a state byte
0x0800221E: cmp  r0, #7         ; compare to dfuMANIFEST
0x08002220: bne  #0x80021d4     ; not manifest — skip launch
0x08002222: bl   #0x8000f2e     ; only reached when state == 7

; the patch — one instruction, nothing else touched:
0x08002220:  D8 D1  →  00 BF   /* bne → nop */

7 is the standard USB DFU state dfuMANIFEST — reached only after a host finishes a download and explicitly signals completion. The bootloader was never refusing anything. It was waiting for a host that, on a cold boot with nothing attached, simply isn't there.

The setup: a sealed Geehy APM32F103C8T6 ST-Link V2 clone, no SWD access, no case open — the only way in is the ST bootloader at 0x08000000 over USB. stlink-tool flashes Black Magic Probe to 0x08004000 and sends a GO command to start it. The itch was that BMP needed that command after every cold boot, while J-Link OB — flashed to the identical address on the identical bootloader — came up on its own with nothing attached at all.

The bootloader's own launch routine, disassembled directly, makes the puzzle sharper rather than easier: it loads a stack pointer and a reset vector from 0x08004000 and jumps. No marker check, no checksum, no encryption. It will launch anything sitting there, instantly, without complaint — which means it should treat BMP and J-Link identically. It doesn't. Working out why took six theories, two bricked devices, and a rewritten call-graph trace before landing on the real answer.

hypothesis log — why J-Link OB auto-boots and BMP doesn’t
H1
A 16-byte device-derived magic tag (0xA50027D3) at the top of flash gates the launch.
Killed: reverse-engineered the exact AES-128 derivation from the official installer, replicated it byte-for-byte, embedded it correctly. Zero effect — this bootloader is V2-class, and the V2 image contains neither the tag constant nor any code that checks for it. The mechanism is real, just on V2.1/V3 hardware, not this one.
H2
A separate app-validity marker (0x47A53C15) at app-base+0x100 is the real gate.
Killed the same way: present at the right offset with a valid vector table in genuine V2.1 firmware, completely absent from this V2 bootloader and from the working J-Link binary.
H3
BMP's build is missing the RCC_CFGR clock-tree setup that J-Link's has.
A real byte-level difference — J-Link's image references the clock registers as literals, BMP's didn’t. Killed once the actual platform.c source showed BMP calls rcc_clock_setup_pll() unconditionally too; it just uses a struct-driven API that doesn’t leave bare addresses in the literal pool. The byte pattern was an artifact of which API was used, not a missing clock init.
H4
Erase bootloader page 9 (0x08002400) and write a branch straight to 0x08004000.
Real technique, documented, reportedly what SEGGER’s own installer does on some hardware. Bricked two sealed devices before the actual content of that page was ever checked against this specific binary — see below.
A DFU-state check at 0x08002210, inside page 8, gates the launch — not page 9 at all.
Found by tracing the real call graph from reset forward, function by function, refusing to trust any disassembly that drifted into a literal pool. Confirmed: unconditional path, every boot, ending in a comparison against the DFU state machine.

The page-9 technique is real — it's a documented way some ST-Link-class bootloaders get patched into auto-launching. The mistake wasn’t the technique. It was trusting that a description of someone else’s bootloader binary was a safe description of this one, without checking first.

Disassembling the real Bootloader_2.0.bin and cross-referencing every direct call into that page found eight separate call sites from across the bootloader’s core logic, landing on what reads as a shared block-copy routine — the kind of generic memcpy helper used throughout for buffer handling:

caller addresscalls into page 9
0x080003E80x08002540
0x080006AA0x08002518
0x080007960x08002518
0x08000EDC0x08002468
0x08000F760x08002412
0x08000F880x08002518
0x080011240x08002518
0x080011740x08002518

Erase that page and every one of those eight call sites breaks — not as an acceptable tradeoff in exchange for auto-boot, but as a bootloader that can no longer service its own command loop. That's a bricked bootloader, not a bricked feature. Whatever SEGGER's actual installer targets, on whatever bootloader build it's written against, it isn't this address on this image.

The actual cost: two sealed units, no SWD or UART access prepared in advance, no recovery path. The technique itself wasn't the error. Not checking what was already living at the target address, on the specific binary in hand, before erasing it, was.

The fix for trusting another disassembly was a tracer built specifically to distinguish real code from data: walk forward from a known-good entry point, stop hard at the first genuine terminator, and treat anything that decodes to an instruction that doesn’t exist on Cortex-M3 — a coprocessor opcode showing up mid-function is the giveaway — as proof of having drifted into a literal pool, not a reason to keep going.

verified call chain from reset, every link unconditional
reset → init() → 0x8002198 → 0x08002210

That chain runs once, every single boot, with no dependency on a host being present. At the end of it sits the actual decision — the same snippet from the top of this post:

0x08002210 — the real launch gate
ldrb r0, [r4]
cmp  r0, #7
bne  #0x80021d4
bl   #0x8000f2e

7 is dfuMANIFEST in the standard USB DFU state machine — the state a device reaches only after a host completes a download and explicitly signals it’s done. That’s what a GO command drives the bootloader into. Nothing about a cold boot with no host attached will ever produce that state on its own.

This check sits inside page 8 — a different page from the memcpy utility entirely. That made a surgical fix possible: read the real 1024 bytes of page 8, change exactly the two that matter, write the rest back identical.

page 8, offset 0x220 — 1022 of 1024 bytes unchanged
D8 D1  →  00 BF   /* bne → nop */
comparison result discarded; execution falls through into the launch call unconditionally

The replacement page is applied by the running application itself, once, on boot: read the live patch site first, confirm it’s the exact untouched original (not already patched, not something unexpected), confirm the page group isn’t hardware write-protected, then unlock, erase, and reprogram. Idempotent by construction — every boot after the first sees the patch already in place and returns immediately.

Result: a plain power cycle, zero stlink-tool involvement at any point, comes up as Black Magic Probe on its own. Page 9’s memcpy utility, and every one of its eight callers, is never touched.

lujji’s write-up on installing Black Magic via the ST-Link bootloader supplied two real, useful things: the observation that the bootloader leaves the USB peripheral already enumerated when it hands off to the app — which is why the app itself now resets the USB peripheral and pulls D+ low on boot, forcing the host to notice the handoff instead of assuming nothing changed — and the anecdote that SEGGER’s own firmware reportedly patches a bootloader page directly. That second detail is accurate as a description of what SEGGER does somewhere. It just isn’t a safe instruction for what to do to this binary, which is the distinction that cost two devices to learn properly.

Not perfectly consistent yet: occasionally it takes a couple of replugs rather than coming up first try. The evidence points at USB host-side power management — the XHCI controller dropping into runtime-suspend during longer idle periods — rather than anything left in the firmware. A bare unplug/replug with no flash step in between, confirmed with zero host tool involvement, is the cleanest reproduction, and it's reliable enough to stop thinking about day to day.

A documented technique names an address. It doesn’t promise your binary agrees with it.

The page-9 patch wasn’t wrong because it was guessed — it's a real, working mechanism, described accurately, from a credible source. It was wrong because the address it names was treated as a property of the technique rather than a property of one specific binary that happened to be different from this one. The actual fix only existed once the real bootloader image was disassembled, every caller into the target region was cross-referenced, and the patch site was picked based on what was demonstrably there — not on what a write-up about different hardware said should be there.