How I started, and which tools
Not in the firmware at all, but on the bus. The car shows the track title over USB and leaves the Bluetooth Audio screen blank, so I first logged CAN with a PCAN to see what the BT module actually puts out: ISO-TP text frames on 0x4B1, framed exactly like the USB text on 0x4C7 — and the cluster ignoring them. Before opening a single byte of firmware I knew the data was already arriving and being discarded, which made the target narrow from day one. Only then did I dump the cluster.
That was July 2026, with no ARM background and no IDA licence — headless Ghidra was enough for all of it.
Since you asked about AI:
the model generation matters more than anything else in the setup. I tried several of what was available then, GLM 5.2 among them, and none was usable for this — plausible readings of disassembly that fell apart the moment a script checked them. Opus 4.8 was the first that actually coped, and from there the project moved in weeks instead of stalling. With a weaker model you will spend your time debugging hallucinations rather than firmware.
One anecdote from that period, since it concerns this forum. Early on Opus 4.8 was reluctant to touch the firmware at all — it kept hedging about modifying a safety-related ECU and steering me towards "safer" options. I told it not to be scared: people do this every day, in the open, with dumps, documentation and recovery procedures, microhacker being the obvious example. After that it stopped arguing and got on with the work. So this forum is itself a kind of precedent — it is what let me show the tool that ECU reverse engineering is normal documented practice rather than something reckless.
Now to your actual question — whether Claude or Codex can do this
"without the need of a decompiler". No. And the decompiler is precisely the part you cannot take away: the model never sees a byte of the binary. Here is the real chain, layer by layer, with real output from this project.
Layer 0 — what is in the file
Code: Select all
py deasm.py hex 0x236f0 0x20
0x000236f0: 1C01 D01A 1C20 F7FF FCF5 4A21 0161 1889 |..... ....J!.a..|
Nobody reads this. The model never receives it.
Layer 1 — disassembly (bytes to instructions)
Same region through my Capstone script (big-endian Thumb, PC-relative literals resolved inline). The left column is Layer 0, the right column is what it means:
Code: Select all
0x236f4: 1C 20 adds r0, r4, #0
0x236f6: F7 FF FC F5 bl #0x230e4 <- the four bytes this project turns on
0x236fa: 4A 21 ldr r2, [pc, #0x84] ; =0x0007a0dc
0x23700: 5C 09 ldrb r1, [r1, r0] <- type byte fetched from that table
0x23702: 29 01 cmp r1, #1
0x23706: 29 02 cmp r1, #2
0x2370a: 29 03 cmp r1, #3
0x23712: F7 FF FD AA bl #0x2326a <- the USB text path
F7FF FCF5 is
bl 0x230e4. The patch replaces those four bytes with
F0 5F FD A3 =
bl 0x83240, the code cave. That is the entire CAN-side modification.
Layer 2 — decompilation (instructions to C)
Ghidra, processor spec ARM:BE:32:v4t, raw output — nothing renamed, nothing typed by hand:
Code: Select all
void FUN_000236cc(int param_1)
{
char cVar1;
int iVar2;
iVar2 = *(int *)(DAT_00023740 + param_1 * 4);
FUN_00023036(param_1);
if ((*(uint *)(iVar2 + 0x30) & *(uint *)(param_1 * 8 + DAT_00023758 + 0x76) & 0xffffbfff) != 0) {
iVar2 = FUN_000230e4(param_1);
cVar1 = *(char *)(param_1 * 0x20 + DAT_00023780 + iVar2);
if (cVar1 == '\x01') { FUN_000234c2(param_1,iVar2); }
else if (cVar1 == '\x02') { FUN_00023628(param_1,iVar2); }
else if (cVar1 == '\x03') { FUN_0002326a(param_1,iVar2); }
}
FUN_00022fe8(param_1);
return;
}
That is the text the model reads. No names, no types, no comments — param_1 is the bus, iVar2 is the mailbox, and the whole routing decision is cVar1: one byte pulled from DAT_00023780.
Reading this next to the USB handler is what cracked the project. The frame's own content decides nothing; a table byte does. So the question stopped being "how does the display work" and became "what does that table hold for 0x4B1". Answer: 2 — a dead 2-byte signal, so the text is discarded before ISO-TP reassembly ever runs. And 0x236F6 is the last point
before the split, which is why the hook goes there and nowhere else.
How the model actually gets that text out of Ghidra
There is no "model looking at Ghidra". Throughout this project it was
headless — no GUI, no plugin, no live connection. A ~20-line Java post-script prints decompiled C to stdout, and that text is what the model reads. This is what produced the listing above:
Code: Select all
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.address.*;
public class DecompOne extends GhidraScript {
public void run() throws Exception {
DecompInterface di = new DecompInterface();
di.openProgram(currentProgram);
for (String a : getScriptArgs()) {
Address addr = currentProgram.getAddressFactory().getAddress(a);
Function f = getFunctionContaining(addr);
if (f == null) { println("NO FUNCTION CONTAINING " + a); continue; }
DecompileResults res = di.decompileFunction(f, 90, monitor);
println("===== " + f.getName() + " @ " + f.getEntryPoint() + " =====");
println(res.decompileCompleted() ? res.getDecompiledFunction().getC()
: "FAILED: " + res.getErrorMessage());
}
}
}
Code: Select all
support\analyzeHeadless.bat "D:\...\firmware_convers\ghidra_re" convers_re ^
-process -noanalysis -readOnly ^
-scriptPath <dir> -postScript DecompOne.java 0x236cc
Code: Select all
INFO Opening existing project: ...\ghidra_re\convers_re
INFO REPORT: Processing read-only project file: /1412_main_blk0_00005000.bin
INFO REPORT: Execute script: DecompOne.java '0x236cc'
INFO DecompOne.java> PROGRAM: 1412_main_blk0_00005000.bin base=00005000
INFO DecompOne.java> ===== FUN_000236cc @ 000236cc =====
INFO DecompOne.java> void FUN_000236cc(int param_1) { ... }
-noanalysis -readOnly is the part that matters: it reuses the analysis already in the project and cannot modify it, so the same command a month later returns identical text. Every address I have quoted in this thread came out of that path — re-run it against your own import and you get the same output.
Import once with the processor spec set explicitly (ARM:BE:32:v4t, base 0x5000) and after that Ghidra is a batch tool you query, not an IDE you sit in.
And Ghidra is not in the loop for everything. Function boundaries come from a local scan: fmap.py walks the image for Thumb prologues (B5xx = push {..,lr}, E92D = push.w {..,lr}) and builds fmap.pkl — function starts, a bl cross-reference index, and every literal each function loads.
Code: Select all
n_starts = 3820 first = 0x5478 ... last = 0xf8f58
3820 entry points, no Ghidra required. That is what lets deasm.py do "disassemble from here to the end of
this function".
(I have since added the GhidraMCP plugin, which lets the model query and rename inside a live Ghidra session — convenient, and names accumulate in the database instead of the chat log. None of the work described here used it.)
What most of the image actually looks like
FUN_000236cc above is unusually clean. The USB handler, unedited and trimmed:
Code: Select all
void FUN_0002326a(int param_1,uint param_2)
{
...
int extraout_r1;
iVar8 = *(int *)(DAT_0002332c + iVar5);
iVar3 = param_2 * 0x10 + iVar8;
do { } while ((int)((uint)*(ushort *)(iVar3 + 0x80) << 0x17) < 0);
*(int *)(iVar8 + 0x30) = 1 << (param_2 & 0xff);
local_24 = *(ushort *)(iVar3 + 0x80);
local_20 = *(undefined2 *)(iVar3 + 0x84);
...
}
Ugly, but look at line 4: param_2 * 0x10 + iVar8, then + 0x80. That is
mailbox = base + mb*16 + 0x80 — the mailbox addressing my cave uses, lifted from the stock code rather than from a datasheet. The do/while above it is the mailbox busy-wait, which the cave also has to respect.
Where the decompiler stops and scripts start
Notice it wrote DAT_00023780, not the table address — that is a literal-pool slot, not the table. The model cannot resolve it, and if you let it guess it will guess confidently and wrongly. So: a script. Twenty lines, one question answered exactly.
Code: Select all
# find_refs.py - find 32-bit literal pool references to target addresses
import struct, sys, os
BASE = 0x5000
CODE = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "images", "main_code_0x5000.bin"), "rb").read()
for target in [int(x, 16) for x in sys.argv[1:]]:
tb, refs, pos = struct.pack(">I", target), [], 0
while True:
p = CODE.find(tb, pos)
if p < 0: break
refs.append(BASE + p); pos = p + 1
print("0x%05x: %s" % (target, ", ".join("0x%05x" % r for r in refs) or "(none)"))
Code: Select all
py find_refs.py 0x7a0dc 0x79440 0x8eff0
0x7a0dc: 0x23780
0x79440: (none)
0x8eff0: 0xd73f0
DAT_00023780 holds 0x7A0DC, and that table is referenced from exactly one place in the whole image. Also useful: 0x79440 — the FlexCAN acceptance table — has no literal reference at all, because it is reached as a struct offset. Don't go hunting for code that "points at" it.
My tools folder holds about forty scripts like this.
That is what the AI actually produced in this project — not conclusions, but the tooling that turns a guess into a fact.
On naming strings and CAN/UDS IDs
You are right that this is where the handholds are, and I used them. String search plus cross-references will label maybe ten percent of the functions for free, and on the CAN side there are tables worth naming immediately: the acceptance list at 0x79440, the message-type table at 0x7A0DC, the media field-handler list at 0x8EFF0. The protocol constants fall out of those — field 0x46 title, 0x42 artist, 0x3F album; source byte 0x12 Bluetooth, 0x13 USB, 0x15 CD; IDs 4C7 USB, 4B0/4B1 Bluetooth, 4C1 CD. That made the RX framework readable in an afternoon.
But it is not what solved it. What solved it was a
differential: the cluster already renders track text — for USB. So I never had to understand the display stack, the font renderer or the screen manager at all, only the one point where the two paths diverge — a single byte in a table.
It also hands you a test oracle before you write any patch. Inject [0x46][0x12][01][01]"TEST" on the
working USB ID 0x4C7 — field "title", source byte "Bluetooth" — and "TEST" appears on the Bluetooth Audio screen. That proves the whole downstream pipeline already handles Bluetooth, and that the only missing piece is delivery of the 0x4B1 text.
So before naming anything, look for a feature that already works and is a sibling of the one you want. Far cheaper than understanding the subsystem.
How we found the space for the cave
Not from the forum — a scan. The VBF erases the whole code partition 0x5000-0x100000, so anything in that range is fair game provided nothing uses it. Long runs of a single fill byte are inter-function alignment padding and end-of-image filler:
Code: Select all
# find_cave.py - runs of fill bytes in the code partition
BASE = 0x5000
CODE = open("images/main_code_0x5000.bin", "rb").read()
MIN = 296 # the size the cave needs
runs, i, n = [], 0, len(CODE)
while i < n:
b = CODE[i]
if b in (0x00, 0xFF, 0xEF):
j = i
while j < n and CODE[j] == b: j += 1
if j - i >= MIN: runs.append((BASE + i, j - i, b))
i = j
else:
i += 1
runs.sort(key=lambda r: -r[1])
for start, ln, b in runs[:8]:
print("0x%08x %4d B fill 0x%02X" % (start, ln, b))
Code: Select all
0x000ddc64 103840 B fill 0xEF
0x000fb1f4 19972 B fill 0xEF
0x000d8900 5888 B fill 0xEF
0x00014514 2796 B fill 0xEF
0x00083240 713 B fill 0x00
0x83240 — 713 zero bytes sitting between functions — is where the 296-byte cave went on 14.12, the version I run in my own car. The large 0xEF runs at the tail are erase filler and are equally usable in principle.
Note that all of this is per-image. On another firmware version — 15.07, say — the picture from this same scan looks different, so the cave address, the fill byte and every hook anchor will most likely be different values, and they have to be measured on that image rather than carried over. Nothing is published for any version other than 14.12 at the moment.
Either way the apply script
re-verifies the region is still empty before it writes and refuses otherwise, because "unused flash" is a property of one specific image, not of the ECU. My byte probe across the whole Convers+ family (47 VBFs / 38 APP images) turned up plenty of images with a big enough hole and zero matching patch anchors. A hole is necessary, never sufficient.
On your point that this makes patches "almost potentially instable" — I would put the risk somewhere else entirely. Code does not become unstable because of
where it lives; padding between functions executes exactly like any other flash, and the cave is as deterministic as the code around it. The three things that actually destabilise a patch are:
- The region being free in your image but not in the next version. Handled by asserting the bytes and refusing, never by assuming.
- The hook site. Mine sits in the CAN receive path and runs for every frame on the bus, including the ones carrying speed, RPM and warnings. So the cave has to preserve every register it touches, stay re-entrant-safe and be short. That is where a cluster gets destabilised — not at the address the code happens to occupy.
- The state you keep. I needed 24 bytes of SRAM scratch for ISO-TP reassembly, and finding RAM the firmware genuinely never touches took me longer than writing the cave did — the obvious-looking gaps in .bss are not free at all.
Get those three right and a cave patch is as stable as the stock code it sits next to. Get them wrong and having the full source would not have saved you either.
The arbiter
Unicorn: image mapped at 0x5000, SRAM at 0x40000000, peripherals stubbed, one function called directly with the registers I choose. Real run against a stock dump — it patches a throwaway copy and A/Bs the two:
Code: Select all
Patch state: STOCK (hook @0x236f6 = f7 ff fc f5)
[apply_patch_v3.py] version: 14.12 anchors hook/rend/perbus/dlist/SABC/STORE: OK
[apply_patch_v3.py] CAVE 296 B @0x83240; bl->cave f0 5f fd a3
Same 0x4B1 frame, same method, driven through the real hook @0x236f4:
BEFORE stock title='' artist='' -> PASS (dropped, as it should be)
AFTER patched title='Strangers' artist='Kenya Grace' -> PASS (reaches the store)
renderer title='Strangers' artist='Kenya Grace' -> PASS (drawn on screen)
CONTROL wrong 0x4B0 title='' artist='' -> PASS (correctly ignored)
Identical firmware, identical frame, the patch as the only variable. That ran hundreds of times before anything went near the car. tools/emulate.py in the repo does exactly this on your own dump — no car, no CAN adapter.
Rather than just offer the harness, here is the useful core of it. This is the whole idea in 40 lines: map the code partition and SRAM, then call one firmware function directly with the registers you choose. It runs as-is — the output below is real:
Code: Select all
# mini_harness.py - the smallest useful Unicorn harness for the Convers+ firmware
# Demo: call the cluster's own strcpy (0x74f20) and watch it copy a string in SRAM.
# pip install unicorn
from unicorn import *
from unicorn.arm_const import *
BASE = 0x5000 # code partition load address
SRAM = 0x40000000
SRAMS = 0x00040000 # 256 KB is plenty for experiments
STACK = SRAM + SRAMS - 0x400
code = open("images/main_code_0x5000.bin", "rb").read()
uc = Uc(UC_ARCH_ARM, UC_MODE_THUMB | UC_MODE_BIG_ENDIAN) # ARMv4T, BE32
uc.mem_map(BASE & ~0xFFF, (len(code) + 0x2000) & ~0xFFF)
uc.mem_write(BASE, code)
uc.mem_map(SRAM, SRAMS)
DONE = 0x00004000 # fake return address
uc.mem_map(DONE & ~0xFFF, 0x1000)
def call(addr, **regs):
for name, val in regs.items():
uc.reg_write(globals()["UC_ARM_REG_" + name.upper()], val)
uc.reg_write(UC_ARM_REG_SP, STACK)
uc.reg_write(UC_ARM_REG_LR, DONE | 1) # Thumb bit
uc.emu_start(addr | 1, DONE, count=200_000)
return uc.reg_read(UC_ARM_REG_R0)
src, dst = SRAM + 0x100, SRAM + 0x200
uc.mem_write(src, b"Kenya Grace\x00")
uc.mem_write(dst, b"\x00" * 32)
call(0x74f20, r0=dst, r1=src) # strcpy(dst, src)
out = uc.mem_read(dst, 16).split(b"\x00")[0].decode()
print("dst after call: %r" % out)
Three details matter more than the rest of the file.
UC_MODE_THUMB | UC_MODE_BIG_ENDIAN — get the endianness wrong and the instruction stream decodes to plausible nonsense instead of erroring, the biggest time sink on this target.
LR pointed at a fake, mapped return address with the Thumb bit set — that is what lets emu_start stop cleanly when the function returns. And
call it, don't boot it: no reset vector, no clock setup, no peripherals, because none of that is needed to ask one function what it does.
From there you grow it: a UC_HOOK_MEM_WRITE to log every store (that is how I found which SRAM the media handlers fill), and stub anything touching hardware by writing a bare "bx lr" over its entry point. That is all tools/emulate.py really is, plus the plumbing to patch and A/B a dump.
On recovering full source instead of patching free space
You have completely decompiled a Flash in IDA before, so you know the size of that job far better than I do. I would still argue it is the wrong target here, for practical rather than theoretical reasons:
- Verification. With a cave the diff is ~300 bytes and I can prove byte-for-byte that nothing else moved; regression testing is "rebuild, compare hashes". With a full rebuild you have a megabyte of new binary and no way to prove equivalence. The frightening failure mode is not "won't boot" — you would notice that. It is a timing-sensitive corner of the ABS / odometer / DTC path misbehaving six months later, in a safety-related component.
- The image is not just code. Absolute addresses are baked into data everywhere: the message-type table at 0x7A0DC, the field-handler list at 0x8EFF0, the acceptance filter table, display lists, calibration blobs. Recompiling relocates everything, so all of that has to be regenerated correctly — and much of it you would only discover by having it break.
- Toolchain identity. The original is an early-2000s ARM compiler; matching its ABI, struct layout and optimisation behaviour is a project of its own. Below the APP image there is also a bootloader I still do not have (it is in no VBF), plus the VBF block/CRC16 layer on top — the Flashpack itself.
Where I fully agree with you: hand-writing Thumb is the ugliest part of this. I wrote a small Python Thumb assembler because I wanted byte-level control, and it does not scale. The realistic middle ground is not full source, it is
re-sourcing individual functions: write the cave in C, build with arm-none-eabi-gcc (-mbig-endian -mthumb -march=armv4t -ffreestanding -nostdlib, -Ttext fixed at the cave address) and link the stock routines in as absolute symbols. New code in C, stock code untouched and still byte-verifiable. I have not done it yet — the BE32 plus Thumb combination needs checking in binutils first — but that is the next thing I would try, and it is a different order of magnitude from recompiling the whole image.