Skip to content

Latest commit

 

History

History
280 lines (215 loc) · 13.5 KB

File metadata and controls

280 lines (215 loc) · 13.5 KB

Running the firmware without a car

Every patch address in this project was found by reading disassembly, and every claim about what a patch does was an inference from that reading. Inferences are where the car trips came from.

tools/ppcemu.py executes the shipped image instead. It does not boot the unit — there is no VxWorks, no display, no DBUS. It calls one function at a time on an emulated PowerPC core, with a stack, a scratch region and stubs for whatever that function calls. That is enough to answer the question a patch actually raises: does control reach the code I think it reaches, and under what conditions?

Doing this to the AUX patches found that one of the two edits in aux-autoswitch cannot work, and never could. The details are below.

Why this is possible at all

The SoC is a Freescale MPC5121e — an e300 core, which is plain 32-bit big-endian PowerPC. Unicorn's PowerPC backend runs it directly. There is no custom silicon to model because we never execute anything that touches the peripherals: a function that would talk to hardware is stubbed at its call site.

The application image is not encrypted either (see the analysis notes), so tools/unpack.py produces exactly the bytes the CPU would fetch, based at 0x01000000.

uv run tools/unpack.py SMEG_PLUS_UPG/NAV/AppBin/f_BigQuick.bin app_nav.bin
uv run tools/ppcemu.py app_nav.bin --call 0x02247858 --arg 0x60000000 \
    --patches patches/aux-autoswitch.json --module NAV --trace

A whole comparison — six runs of a 39 MB image, stock against patched — takes well under a second, so this belongs in the edit loop, not in a special session.

What it can and cannot tell you

Reachability is proof. If the emulator shows a branch is taken, that branch is taken for those register and memory values; the CPU has no opinion about where the values came from.

Behaviour is not. A stub returning 0 is an assumption you wrote down, not something observed on a car. The emulator makes assumptions explicit and executable — which is the whole gain — but it cannot tell you whether the unit's real state matches them. Where a finding below depends on a stub, it says so.

Unmapped memory is recorded rather than fatal: the emulator maps a zero page and notes the access, so the list of "state this function expected to exist" falls out of a run.

What it found

1. The IsAUXSRCAvailable() patch does what it claims

Stock, with a zeroed object, the function finds a null pointer at this+0x50dfc and takes the failure path into the kernel. Patched, it returns 1 in two instructions. This one was already confirmed on hardware; the emulator agrees.

2. The +0x10c early-exit patch is inert

This is the second half of aux-autoswitch, and it has never been observed doing anything on a car. The emulator shows why — two independent reasons, either of which is sufficient.

It patches a branch that is never taken. The early exit fires when GetMediaDevice(mgr, AUX, &obj) returns non-zero, and that call fails only when the AUX media device is not registered. Registration was executed, not read:

C_HMI_MEDIA_APP_BASE init  @ 0x022bf934
  -> register devices      @ 0x022b833c        (unconditional; every early-out
                                                 branch in init rejoins before it)
       AddDevice(type 0) …(type 1)…(type 2)…(type 3)…(type 5)
       if (this->0x51450)  AddDevice(type 4)   <- the only conditional

Running that function fills the table with types {0, 1, 2, 3, 5} — AUX (type 5) among them, unconditionally, with its source manager taken from this+0x50e3c, which is allocated and stored unconditionally at 0x022bcac8. So GetMediaDevice(AUX) succeeds, the status is 0, and the beq at +0x10c falls through with or without the patch.

And if it were taken, the patch still would not help. GetMediaDevice writes nothing to its out-param on the failure path:

int GetMediaDevice(mgr, type, out) {       // 0x022f3750
    status = -1;
    dev = FindDevice(mgr, type);           // 0x022f3608 — linear scan, count at mgr+0xc0
    if (dev == NULL) return status;        // -1, and *out is left exactly as it was
    out[0x00] = dev[0x00]; … out[0x10] = dev[0x10]; …
    return 0;
}

out is the local object the handler just zero-initialised, and out[0x10] is the source manager the handler needs. Nopping the early exit lets execution continue with that field still null — straight into the guard at 0x02303468, which returns. The patch buys exactly one extra call, SetMediaDeviceState(AUX, 2), and then exits at the same place:

AUX media device stock patched
absent returns at +0x10c returns at +0x14c, having called SetMediaDeviceState
present reaches ActivateSource reaches ActivateSource — identical

The patch removes an early exit whose own precondition is what the following code depends on. There is no configuration in which it reaches ActivateSource and stock does not.

!!! danger "Do not nop the source-manager guard as well"

The obvious follow-up — also removing the `beq` at `0x02303468` — calls
`ActivateSource` with a **null `this`**: `0x0230346c` loads that field straight into
`r3`. On the HMI thread. Leave it alone.

3. The handler's real gates, in order

void HandleAudioAuxInputStatusChnged(this) {       // 0x0230331c
    app = this->0x50df4;                           // 0x022ad2c0, a plain getter
    if (app == NULL) return;                       // gate 1  @ 0x02303358
    ctor(&obj);                                    // zeroes obj, obj[0x10] included
    GetAuxStatus(app, &signal);                    // 0x025cb258 — return value IGNORED
    state = (signal != 0);
    if (state == this->0x51449) return;            // gate 2  @ 0x023033d4  (change detector)
    this->0x51449 = state;
    if (GetMediaDevice(mgr, AUX, &obj)) return;    // gate 3  @ 0x02303428  (never taken)
    SetMediaDeviceState(mgr, AUX, 2);
    if (obj[0x10] == NULL) return;                 // gate 4  @ 0x02303468  (never taken
    ActivateSource(obj[0x10], true);               //          once gate 3 passes)
}

Two things in there matter beyond the patch:

Gate 2 means the handler only acts on a transition. An AUX signal that is already present when the state is first recorded produces no activation, because nothing changed.

GetAuxStatus's return value is discarded. It returns -1 without touching the out-param if its proxy (obj->0xc) is null — and the handler reads the untouched local as "no signal". A failed query is indistinguishable from a genuine absence of signal, and lands in gate 2 as "no change". That is a silent failure mode, and it is the shape of failure that matches the symptom.

4. aux-sticky was broken, and only executing it showed that

The patch's second edit retargets the "AUX signal absent" branch to the shared return path, so an activated AUX is not handed back to radio when the signal drops. It shipped as b +0x140 — the right displacement, with the condition dropped. Unconditional, so the branch was taken regardless of the signal, and the activate path immediately below it became unreachable.

bytes at 0x02303434 signal appears signal vanishes
419e0058 (stock, beq +0x58) activates releases
48000140 (shipped, b +0x140) does nothing does nothing
419e0140 (fixed, beq +0x140) activates does not release

Reading the patch does not show this; the displacement is correct and +0x140 really is the return path. Running it does, immediately. This is the case the emulator was worth building for.

5. The logging is gated by one global, and it is zero

Log_msg begins if ((GetLogMask() & level) == 0) return;. GetLogMask reads a single global at 0x036d42a8 — past the end of the image, so BSS, so zero at boot — and exactly one instruction anywhere in the image writes it, through ten SetTrace wrappers that are vtable entries only.

Emulating Log_msg directly: with the mask at 0, it bails after 47 instructions and never reaches the formatting code; with the mask forced, it runs 271 and calls its sink.

But the sink is stubbed. Log_msg makes exactly two calls — GetLogMask, and then 0x010346d0, which is li r3,0 ; blr. That is the same address diagnostic-logging redirects, so 0x010346d0 is not "the stub called instead of Log_msg" but the sink Log_msg itself calls. Both halves of the firmware's logging end at the same no-op, and forcing the mask only buys you the formatting before the message is discarded.

??? note "Correcting an earlier reading of this page"

An earlier version of this page said the patched handler emits its log line. It does not;
what was measured was execution reaching past the mask gate, which is not the same thing.
Correcting that is what the emulator is for, and it is also a reminder that *reachability
is proof and behaviour is not* — the caveat at the top of this page applies to conclusions
drawn here too.

That has a direct consequence for diagnostic-logging, which redirects the ~6700 compiled-out call sites to the real logger: on its own it emits nothing, because every redirected site lands in Log_msg and hits the same gate. The image has two logging mechanisms and this one only unblocks the wrong half.

Flashing both patches is worse than either: diagnostic-logging repoints the sink at Log_msg, so with the mask also forced the two call each other. One call re-enters Log_msg three times before unwinding in emulation; on the unit that runs on every log call in the firmware.

A working diagnostic needs 0x010346d0 pointed at something that really writes. That is now patches/diagnostic-logsink.json: VxWorks logMsg at 0x00484a94, whose signature matches what the caller has already set up. Emulated, the patched sink jumps to that address and the emulator reports an unmapped fetch — which is the expected result, since the kernel is not part of the application image, and it confirms the branch target. Where that output surfaces on the unit is still open. See Patch reference.

6. One device type is unreachable firmware-wide

Device type 4 is registered only when the byte at this+0x51450 is non-zero. That byte is written in exactly two places in the entire 39 MB image — both constructors, both storing 0. Nothing anywhere sets it. Whatever type 4 is (the surrounding evidence points at Jukebox), the firmware as shipped can never register it.

This is worth knowing mainly as a warning: a device type being absent from the table is not evidence about AUX, because at least one type is absent by construction.

What this changes

The conclusion in the analysis notes — that the patch is not the problem and the DBUS message is not arriving — still stands, and is now better supported. What changes is the standing of the second edit: it is not "a fix that has not been confirmed", it is a fix that provably cannot fire. Effort spent flashing it is spent.

The open question is upstream of this function entirely: is HandleAudioAuxInputStatusChnged ever entered, and if it is, does the status query return a signal? The handler logs its own name on every exit, so a working log would answer the first half at once — but as finding 5 above records, this build has no log output path, and giving it one is still open.

The whole chain, gate by gate, with the state of each link, is in The AUX chain. The short version: every link is a null check, the four inside the handler have now been executed, and the one that has never been observed is the first — whether the audio client's listener was ever registered. A single null pointer there explains every symptom at once, which makes it the first thing to look for in the log.

Reproducing this

The tests in tests/test_ppcemu.py cover the emulator itself against assembled-in-test images, so they run without any firmware. The findings above need your own package:

uv run tools/unpack.py SMEG_PLUS_UPG/NAV/AppBin/f_BigQuick.bin app_nav.bin

# 1. the IsAUXSRCAvailable patch, stock vs patched
uv run tools/ppcemu.py app_nav.bin --call 0x02247858 --arg 0x60000000
uv run tools/ppcemu.py app_nav.bin --call 0x02247858 --arg 0x60000000 \
    --patches patches/aux-autoswitch.json --module NAV

# 2. the device table — types registered, with the type-4 flag clear
uv run tools/ppcemu.py app_nav.bin --call 0x022b833c --arg 0x60000000

The handler comparison needs two stubs, so it is a short script rather than a command:

import struct, sys

sys.path.insert(0, "tools")
from ppcemu import Emulator, SCRATCH
from unicorn.ppc_const import UC_PPC_REG_4

IMG = open("app_nav.bin", "rb").read()
DEV = SCRATCH + 0x9000
REAL = {0x022F3750, 0x02368080, 0x022F3608}  # GetMediaDevice, ctor, FindDevice


def run(patched, device_found):
    e = Emulator(IMG)
    if patched:
        e.apply_patch_file("patches/aux-autoswitch.json", "NAV")
    e.stub_all, e.stub_default, e.run_for_real = True, 0x60002000, REAL
    e.stub(
        0x025CB258,
        lambda uc: uc.mem_write(  # the AUX signal appears
            uc.reg_read(UC_PPC_REG_4), struct.pack(">I", 1)
        ),
    )
    e.stub(0x022F3608, DEV if device_found else 0)  # FindDevice
    e.write(DEV, b"\x00" * 0x20)
    e.write_u32(DEV + 0x10, 0xC0FFEE00)  # the device's source manager
    e.call(0x0230331C, [SCRATCH])
    return e.reached(0x0273A248), e.call_sequence()  # ActivateSource?


for patched in (False, True):
    for found in (False, True):
        print(patched, found, run(patched, found)[0])