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

There is an auto-run no auto-run.

Everyone “knows” J-Link OB launches on its own and ST-Link clones need a tool to kick them. I went looking for the marker that makes the difference. There is no marker. There is no difference. There is one cmp.

Author Niel Nielsen Target Geehy APM32F103C8 Method static disassembly Jun. 2026
jump_to_app — sole caller STLinkV2.J16.S4.bin
; the only path to the application, inside the USB command loop
0x0800221c:  ldrb   r0, [r4]        ; received command byte
0x0800221e:  cmp    r0, #7          ; the GO command?
0x08002220:  bne    0x80021d4       ; no  -> loop, stay in bootloader
0x08002222:  bl     0x8000f2e       ; yes -> jump to 0x08004000

Reset never reaches this. The application launches only when a USB host sends command 7.

I have a sealed Geehy APM32F103C8T6 ST-Link V2 clone — the cheap aluminium-tube kind — that I wanted running Black Magic Probe firmware. No case access, so no SWD, so the only way in is the ST bootloader already sitting at 0x08000000 that answers over USB.

That part worked. stlink-tool flashes BMP into the application region at 0x08004000, the bootloader hands over, BMP enumerates. The catch is that it only hands over when stlink-tool tells it to — every plug-in leaves you in the bootloader until something issues the jump. A udev rule papers over it, but it nagged at me, because J-Link OB doesn’t need that. Convert a clone with SEGGER’s tool and it just comes up as a J-Link, every time, no host-side nudge.

So what does SEGGER’s firmware have that BMP’s doesn’t? That question became a hunt for a marker — a flag, an option byte, a magic word — that makes the bootloader auto-launch one image and not the other. What follows is the log of that hunt, wrong turns included, because the wrong turns are the point.

Before I had the bootloader binary I reasoned about the application images — and reasoning produced one plausible mechanism after another. Each survived until a byte contradicted it. Keeping the casualties visible is more honest than a tidy story, and more useful: it shows which evidence retired which idea.

hypothesis log — why the bootloader “prefers” J-Link OB
H1
It checks the app’s stack pointer; BMP’s is invalid.
Killed: BMP word 0 = 0x20001000, a valid SRAM address. The documented SP-in-RAM check would pass.
H2
The vector-table layout differs — base or reset vector.
Killed: all three images base at 0x08004000 with valid Thumb reset vectors. Structurally interchangeable.
H3
The converter writes an option-byte marker the loader reads.
Killed: J-Link OB carries FPEC keys + 0x40022000 but no 0x1FFFF800 reference. No code to write option bytes.
H4
It’s a backup-register / RTC flag set at conversion.
Killed: no BKP (0x40006C00) or PWR (0x40007000) address anywhere in the OB image.
H5
A per-vendor “app valid” signature at a fixed offset.
Killed by the disassembly below — there is no validity read of the app region at boot at all.
The bootloader never auto-runs. The app launches only on USB command 7.
Confirmed: one caller of the jump routine, gated by cmp r0, #7. Reset falls into the USB loop and waits.

The lesson isn’t any single theory. It’s that I produced five confident mechanisms faster than I could verify one — and stopped only when I read the code instead of the tea leaves.

The first real data came from dumping the first two words of each application image — initial stack pointer, then reset vector. If the bootloader gated on the vector table, the launchable image and the stuck one would differ here.

imagesizeSP (word 0)reset (word 1)
ST-Link (genuine app)25 8720x200035480x0800a209
J-Link OB44 6360x200018b80x0800eb85
Black Magic Probe~17 KB0x200010000x08016539

Three firmwares, three vendors, one structure: every stack pointer lands in SRAM, every reset vector is a valid Thumb address in flash, all three base at 0x08004000. To any sane boot check they are identical. Whatever separated them was not in the image — which, after H1 through H4, finally pointed at the one binary I hadn’t looked at: the bootloader itself.

The full-flash recovery image — STLinkV2.J16.S4.bin, 64 KB based at 0x08000000 — contains the bootloader. That is the code that decides whether to jump. Everything before this was inference about images that get jumped to; this is the thing doing the jumping.

Across the entire 16 KB bootloader, the app base 0x08004000 is referenced exactly once. It’s loaded inside a single short routine:

jump_to_app — VA 0x08000f2e
0x08000f2e:  push   {r4, lr}
0x08000f30:  ldr    r0, [pc, #0x290]   ; r0 = 0x08004000  (app base)
0x08000f32:  ldr    r1, [r0, #4]       ; r1 = app reset vector
0x08000f3a:  ldr    r0, [r0]           ; r0 = app initial SP
0x08000f3c:  subs   r0, #8
0x08000f3e:  bl     0x80023c0          ; install SP / VTOR
0x08000f42:  ldr    r0, [r4, #4]       ; reload reset vector
0x08000f48:  bx     r0                ; jump — unconditionally

Look at what isn’t there. It reads the stack pointer and the reset vector, installs them, and branches. No range test on the SP. No comparison against 0xFFFFFFFF. No signature. No marker. This routine runs whatever sits at 0x08004000, no questions asked. H1 and H5 die right here: the bootloader doesn’t validate the app, so it can’t be refusing BMP for failing validation.

Then who calls it?

If the jump is unconditional, the decision lives in the caller. There is exactly one, and it sits inside the USB request loop — the same state machine that handles DFU commands:

sole caller — VA 0x08002222
0x0800221c:  ldrb   r0, [r4]        ; received command byte
0x0800221e:  cmp    r0, #7          ; the GO command
0x08002220:  bne    0x80021d4       ; anything else -> keep looping
0x08002222:  bl     0x8000f2e       ; command 7 -> jump to app

And the reset handler? It calls init, then drops straight into this USB loop and stays there. It never touches the app region, never peeks at 0x08004000, never attempts a jump. I confirmed the negative directly: the app base appears in precisely one place in the whole bootloader, and that place is the routine above.

The mechanism, in one line: this bootloader has no power-on auto-jump. The application is reachable only when a USB host sends command 7. stlink-tool with no arguments sends exactly that.

If the bootloader can’t auto-run anything, then “J-Link OB launches on its own” has to mean something other than what it looks like. It does. The J-Link software stack is always resident on the host, and it issues the GO command on enumeration — automatically, invisibly. SEGGER didn’t make their firmware more launchable. They made the host send command 7 for you.

Which is, mechanically, identical to a udev rule firing stlink-tool when the clone appears. BMP and J-Link OB are in exactly the same position: both are inert app images that this bootloader will only enter on command. One vendor hides the command in a driver; the other leaves it to you. There was never a firmware difference to find — the asymmetry was entirely host-side automation.

Every “convert ST-Link to X” tool is the same trick wearing different coats: flash an app, then send command 7.

This closes a project I’d half-started: an in-application updater to replace the ST bootloader so BMP could own reset and truly auto-run. The disassembly says that was the only path to genuine plug-in auto-run — because the stock bootloader is command-gated by design, the alternatives are exactly two:

goalrequirementon a sealed unit
True auto-run on plug-inReplace the bootloader (own reset)needs SWD — out of reach
Effective auto-runAutomate command 7 host-sideudev rule — already done
For the sealed-case crowd: replacing the bootloader means writing flash page 0 with no SWD recovery if it goes wrong. The bootloader’s own region is write-protected against the very tool you’d reach for, and clearing that protection on F1 erases the option block — mishandle the RDP byte and the part mass-erases itself. The command-gated loader you were trying to escape is also the thing keeping the device unbrickable over USB.

So the working setup — BMP in the app region, launched by a udev-triggered GO command — isn’t a workaround for a better solution waiting to be discovered. It is the solution, and it’s the same architecture SEGGER ships, minus the proprietary daemon. The udev rule is the auto-run.

The honest takeaway isn’t about ST-Link at all. Five times I produced a clean, confident explanation from indirect evidence — application headers, embedded constants, register-address scans — and five times it was wrong in a way only a direct read could expose. The hunt didn’t end when I got smarter about the apps. It ended when I stopped reasoning about the apps and disassembled the binary that actually makes the decision. When the cost of a wrong answer is a brick you can’t open, “probably” is not a finding. The cmp r0, #7 is.