DEV Community

Andrey Sazonov
Andrey Sazonov

Posted on

Building a Mac-native Subaru ECU tuning tool in Rust

If you want to tune a Subaru ECU on a Mac in 2026, you have three options:

  1. Run a Windows VM with RomRaider (works, but it's 2026 and I'm not running Parallels for a hobby project).
  2. Hope someone's Wine + J2534-DLL stack still compiles (it doesn't, on Apple Silicon).
  3. Buy a separate Windows laptop.

I picked option four — write a Rust toolkit that talks to the ECU directly
through the Tactrix Openport 2.0 cable, no J2534 DLL anywhere in sight. Three
months later it dumps the full 1 MiB firmware over CAN in 44 seconds and the
binary is byte-for-byte identical to what EcuFlash produces on Windows. The
code lives at firefighter-19/tuneforge.

This post is about the three most interesting bits of the journey: reverse-
engineering Subaru's SecurityAccess seed/key, talking to the Tactrix cable
without J2534, and gluing it all into a one-binary dump pipeline.

The problem the hard way: Subaru's SecurityAccess

Modern Subaru ECUs (2007+) reject most reads in the default UDS session.
They want you to climb a small ladder first:

StartDiagnosticSession 0x10 0x03    →  ExtendedDiagnosticSession
SecurityAccess 0x27 0x01            →  ECU returns a 4-byte "seed"
SecurityAccess 0x27 0x02 <key>      →  ECU validates, returns "67 02" (granted)
StartDiagnosticSession 0x10 0x02    →  ProgrammingSession (now we can RAM-write)
Enter fullscreen mode Exit fullscreen mode

The key is a function of the seed. Subaru's nicest property: that function
is a Feistel cipher with 16 32-bit round-keys, and the round-keys live
inside the ROM itself at offset 0x05972C. Different firmware images →
different round-keys → different f(seed).

This was the part I expected to take an evening and ended up taking a week.
The good news: I didn't have to break the cipher. I extracted the round-key
table from a known-good ROM dump (got the dump from EcuFlash on a borrowed
Windows laptop — one-time bootstrap) and the implementation reduces to:

pub fn subaru_genkey_can(seed: [u8; 4], round_keys: &[u32; 16]) -> [u8; 4] {
    let mut l = u32::from_be_bytes(seed[..4].try_into().unwrap());
    let mut r = !l;  // initial complement is part of the Subaru variant
    for &rk in round_keys.iter() {
        let f = feistel_round(r, rk);
        let new_r = l ^ f;
        l = r;
        r = new_r;
    }
    [...]
}
Enter fullscreen mode Exit fullscreen mode

The Feistel round itself is a couple of rotates and XORs — nothing
cryptographically scary. What makes it Subaru's is which round-keys you use,
and those are firmware-specific. The CAN variant and the K-Line variant use
different Feistel constants too, which cost me a couple of days because I
naively assumed they'd share the algorithm.

Validation was straightforward: capture a successful EcuFlash session in
Wireshark, find the 27 01 <seed> request and 27 02 <key> response, run
my code on the seed, compare. When they matched on the first try I assumed
I had a bug and re-verified three times.

Aside on legality: this is read-only stuff. Reading your own ECU's
calibration via a documented diagnostic service isn't bypassing anything;
it's the same data RomRaider has been reading for 20 years. I have not
implemented flash-write and don't plan to.

Tactrix without J2534

The Tactrix Openport 2.0 ships with a J2534 DLL on Windows that gives you a
nice high-level API: PassThruConnect, PassThruReadMsgs, etc. On macOS
this DLL doesn't exist, doesn't have an equivalent, and emulating it through
Wine is — to use a technical term — terrible.

What the DLL hides is a very normal USB device with two bulk endpoints. The
real wire protocol is AT-commands:

ati                          # init
ata                          # query device
ato0033 0x1                  # open protocol 0x33 = ISO9141 with flag 0x1
att0033 0x4 …                # set tx timing
atf0033 …                    # configure receive filters
Enter fullscreen mode Exit fullscreen mode

…and you push frames into the OUT endpoint, read replies from IN. That's
the whole API. It's documented in the Tactrix firmware source which they
released on GitHub years ago and most people never noticed.

In Rust this is rusb. Tactrix-specific glue lives in tuneforge-io and
exposes the same Transport trait the rest of the codebase uses:

pub trait Transport: Send {
    fn write_all(&mut self, data: &[u8], timeout: Duration) -> IoResult<()>;
    fn read_frame(&mut self, buf: &mut [u8], timeout: Duration) -> IoResult<usize>;
    fn purge(&mut self) -> IoResult<()>;
    fn description(&self) -> &'static str;
    fn set_baud(&mut self, _: u32) -> IoResult<()> { /* default: unsupported */ }
}
Enter fullscreen mode Exit fullscreen mode

With the same trait abstracting away whether the bytes are flowing over
serial K-Line at 4800 baud, Tactrix-ISO9141 K-Line, or Tactrix-ISO15765 CAN
at 500 kbps — and a MockTransport for tests — the protocol code never has
to care.

The one rough edge: macOS won't let libusb claim an unclaimed USB device
without root. So tuneforge ssm-init --tactrix needs sudo. There's no
nice fix without shipping a system extension, which I'm not going to do for
a hobby tool. The GUI shows a clear "rerun with sudo" message in the error
state of the ECU-tools modal and that's about as good as it gets on modern
macOS.

The dump in 44 seconds

With seed/key working and Tactrix bytes flowing, the actual ROM dump is a
sequence:

  1. OBD-II identify (Mode 09 PID 02 + 06): grab the VIN and the Calibration Verification Number. Doubles as a "did we plug in the right car?" check.
  2. ExtendedDiagnosticSession (10 03) + SecurityAccess (27 01/02) with our generated key. This unlocks the ProgrammingSession.
  3. ProgrammingSession (10 02) + RequestDownload (34 04 33), followed by 64× TransferData (36) frames carrying the encrypted OpenECU Subaru SH7058 OCP CAN Kernel V1.07. The encryption between 36 payloads and what actually ends up in RAM is the ECU bootloader's business — we just stream the bytes EcuFlash streams.
  4. StartRoutine (31 01 02 02 02) — the kernel takes over the ECU.
  5. The kernel speaks a tiny 1-byte protocol over CAN (01 = read at address, 03 = jump). Loop reads in 2 KB chunks at 500 kbps. 1 MiB takes the wire ~42 seconds + ~2 seconds for the rest.

Most of the credit for understanding the kernel protocol belongs to
fenugrec/npkern and
james-portman/subaru-ecu-flashing.
The dump bytes match the EcuFlash output exactly (same SHA-256), which
gives a strong "we're doing this right" signal.

The kernel-upload code lives in a separate crate (tuneforge-kernel)
licensed GPL-3.0+, because it's derived from GPL-3 upstream. The rest of
the workspace stays under GPL-2.0+ and only pulls the kernel crate in when
you opt in via --features kernel-upload. This is a tidy way to keep
license boundaries explicit at the Cargo level rather than in a header
comment everyone ignores.

The boring stuff that mattered

A few things that aren't glamorous but made the project actually finish:

  • A real fixture. I committed a known-good ROM dump (fixtures/forester-xt-2007-4E42504007.bin) and the integration tests load it. Half the protocol bugs I had would have stayed hidden without a byte-for-byte target to diff against. The dump command does the same diff at the end of each run and complains loudly if anything drifted.
  • MockTransport for tests. A FIFO of "byte sequences the device will return" is enough to unit-test the entire SSM/OBD-II/UDS stack without the car. 220+ tests run in under a second.
  • egui for the GUI. Immediate-mode means the editor's "live diff against baseline" and the logger's XY-plot are like 30 lines of code each. No state machines, no observers, no "models". Resize the window, scroll, drag a cell — it just redraws.
  • clippy -D warnings as a CI gate. I started with the pedantic group and ~300 warnings, spent an afternoon classifying them into "real problems" (~10) and "doesn't apply to our embedded byte-crunching code" (~290), put the latter in a workspace allow-list with one-line justifications, and now anything red is actually red.

Where this is going

The editor + logger + diagnostics + read-rom pipeline is feature-complete
for my own car (a 2007 USDM Forester XT). I'm not implementing flash-write
without a donor ECU, which means tuneforge isn't a full ECU-tuning loop —
yet. What it is, today, is the parts of that loop that don't require risking
a brick. For me that covers ~95% of what I actually do with the car.

The fun bits if you're poking around the repo: the seed/key code is in
crates/tuneforge-kernel/src/seed_key.rs, the Tactrix transport is in
crates/tuneforge-io/src/tactrix/, the full dump-rom orchestrator (with
Wireshark-comment-grade documentation of each phase) is in
crates/tuneforge-kernel/src/orchestrator.rs, and the GUI panels are in crates/tuneforge-gui/src/panels/.

If you read this and now want to dump your own Subaru on a Mac:

brew install libusb
curl --proto '=https' --tlsv1.2 -LsSf \
    https://github.com/firefighter-19/tuneforge/releases/latest/download/tuneforge-cli-installer.sh | sh
sudo tuneforge dump-rom-can --output ./my-rom.bin
Enter fullscreen mode Exit fullscreen mode

It's not done. A lot of RomRaider's depth is still un-ported (advanced editor features, more vendor protocols, flash-write is an explicit non-goal for now — no donor ECU).

Issues, ideas, and PRs are genuinely welcome.

Source, license info, and the full slice-by-slice progress doc are at
https://github.com/firefighter-19/tuneforge.


Built on a 2007 USDM Subaru Forester XT, ROM 4E42504007, Tactrix Openport
2.0, Apple Silicon Mac. Powered by RomRaider
XML definitions, fenugrec/npkern
kernel work, and a lot of Wireshark.

Top comments (0)