I Became the Action Replay for My Truck

Root in my truck’s Android head unit was trapped by SELinux — until I flipped one byte, rewrote my identity, and raced Mario Kart on the dashboard.

Reverse Engineering#android#selinux#memory-forensics#frida#cars

The year is 2003, and I am seven years old. I have Pokémon Sapphire, a Game Boy Advance SP, and an Action Replay.

The Action Replay came with codes, long strings of hexadecimal. I sat under the blue light of the SP and copied them over with the D-pad, one character at a time, through an on-screen keyboard that made every typo expensive. Then I started the game. Ninety-nine Rare Candies. A wall I could walk through. Rooms the game had never meant to let me enter.

Sometimes I copied a code wrong. An edited Pokémon would come back as a Bad Egg, the game’s ominous placeholder for creature data it could no longer make sense of. The Action Replay did not know I was one hex digit off. It wrote exactly what I told it to write.

I did not know what an address was. I did not know that the cartridge sitting between the Game Boy and the game could name a location in RAM, or that it changed the game by changing the bytes at that location. I only knew that if I copied the code correctly, the world changed. The game believed I had 99 items because its memory said I did. The wall stopped being a wall because one value underneath the map said I could pass through it.

Twenty-three years later, I found the same arrangement inside my own truck. This time the reward at the end of the codes was not ninety-nine Rare Candies. It was Mario Kart, running on the dashboard of a parked 2024 Chevrolet Colorado, steered by the truck itself.

Root, with conditions

My 2024 Chevrolet Colorado’s head unit runs Android as a guest on top of a QNX host. I had root on both sides, but the two root shells were not equivalent.

Inside Android, my SSH process ran as u:r:netutils_wrapper:s0. The user ID was zero, but SELinux still gave the process a specific identity. Files, sockets, services, and processes had identities of their own, and the policy decided which combinations were allowed. Root answered the old Unix question, but Android was asking another one.

That became obvious when I tried to work with the services behind Binder. The shell had an impressive name and very little authority.

From QNX, though, I could reach the Android guest’s physical memory through /dev/mem.

That changed the shape of the problem. Android could assign rules to everything inside its world, but the layer underneath Android could edit the bytes that world was built from. I had become the Action Replay: a small, privileged thing below the game, able to change the world above it by changing RAM.

The hexadecimal strings are 64-bit addresses now, and the hidden rooms belong to an Android head unit. The mechanism is almost embarrassingly similar.

So is the failure mode. I crashed the guest many times by poking the wrong place in memory. Reboot, correct the address, try again. Bad Eggs then; guest crashes now.

What /dev/mem looks like

From QNX, /dev/mem is a single flat window over the guest’s entire physical RAM: billions of bytes on one long line, with no labels, no types, and no structure announced. Every kernel object I cared about was somewhere in there, but nothing in the bytes said where. Reaching the right one meant narrowing, from the whole address space down to a single offset.

Where the enforcing byte lives
Where the enforcing byte livesA representative kernel-image byte field, not a real capture. The texture is schematic; the region's size and the marked cell's position are the true, load-bearing facts. One byte in forty-four million decides whether SELinux enforces.

A Hilbert space-filling map of a representative guest kernel image of about 44 million bytes. Neighbouring addresses stay adjacent, shaded by a schematic code, data, and padding texture. One spot-coloured cell, marked by a callout, is where the single enforcing byte would sit.

View source values
How the representative kernel image maps onto each Hilbert grid, and which cell holds the enforcing byte.
ViewportRegion bytesGridBytes per cellTarget cell
Desktop44,040,19264×64 = 4,09610,7522,632
Mobile44,040,19232×32 = 1,02443,008658
How the representative kernel image maps onto each Hilbert grid, and which cell holds the enforcing byte.
ViewportRegion bytesGridBytes per cellTarget cell
Desktop44,040,19264×64 = 4,09610,7522,632
Mobile44,040,19232×32 = 1,02443,008658

This kernel did not expose BTF, the type metadata a memory tool could normally use to recover structure layouts, so I could not simply ask it where anything was. I bound the tool to this exact build instead. The profile recorded the kernel image’s physical base and size, which located the image inside all that RAM. Then I hashed the ranges of the image that never change at runtime and compared them with the profile: a build that merely looked similar failed the hash, so I knew I was reading the exact kernel the offsets were cut for. Inside that image, selinux_state sat at a known offset, and inside the structure the byte I wanted sat at offset 1.

Finding the byte in physical memory
Finding the byte in physical memory aarch64 pc 9/9 ▸ return addr + 1
  1. find_enforcing_byte():
  2. fd = open("/dev/mem")
  3. base = profile.image_base
  4. size = profile.image_size
  5. require base, size match profile
  6. h = hash(read immutable ranges)
  7. require h == profile.image_hash
  8. addr = base + profile.sstate_off
  9. win = read8(addr)
  10. require win matches enforcing
  11. return addr + 1 # enforcing byte

registers

fd3
base0x0000000040080000
size0x0000000002a00000
hash0xe6faf09d29bd
expected0xe6faf09d29bd
matchyes — exact build
sstate0x0000000041b7e000
win00 01 00 01 01 01 01 00
target0x0000000041b7e001

stack

  1. #0find_enforcing_byte

output

enforcing byte @ 0x41b7e001
Full step trace (10 steps)
#whereopchangednote
0line 2fd = open("/dev/mem")fd=3, base=0x0, size=0x0, hash=—, expected=—, match=—, sstate=0x0, win=—, target=0x0/dev/mem is a flat window over the guest's entire physical RAM. Nothing in it is labelled or typed.
1line 3base = profile.image_basebase=0x0000000040080000The saved profile names the kernel image's physical base.
2line 4size = profile.image_sizesize=0x0000000002a00000and its physical size.
3line 5require base, size match profileBase and size must match the profile before anything deeper is trusted.
4line 6h = hash(read immutable ranges)hash=0xe6faf09d29bdHash the ranges of the image that never change at runtime.
5line 7require h == profile.image_hashexpected=0xe6faf09d29bd, match=yes — exact buildBreakpoint: a build that merely looked similar would fail here. This is the exact kernel image the profile was cut from.
6line 8addr = base + profile.sstate_offsstate=0x0000000041b7e000selinux_state sits at a known offset within this exact image.
7line 9win = read8(addr)win=00 01 00 01 01 01 01 00Read the eight-byte window at that address.
8line 10require win matches enforcingThe window matches the exact enforcing pattern. This is the right structure, not a look-alike.
9line 11return addr + 1target=0x0000000041b7e001Offset 1 within the window. This is the exact byte the flip tool will change.
Illustrative trace. A worked example of the narrowing search. Addresses and the truncated hash are representative; the base/size and image-hash gates, the offset-1 enforcing byte, and the enforcing window pattern are from the post.

Replay the narrowing search from /dev/mem. Step through anchoring the kernel image by physical base and size, hashing its immutable ranges and matching the saved profile at the breakpoint, then computing the selinux_state address and confirming the enforcing window before the exact byte address is returned.

Only then did the address I was about to write have any right to be trusted.

The byte that says “enforce”

The bypass was even smaller than the SID I would change later. In the kernel, SELinux keeps its live state in a structure that includes a Boolean named enforcing:

struct selinux_state {
    bool disabled;
#ifdef CONFIG_SECURITY_SELINUX_DEVELOP
    bool enforcing;
#endif
    bool checkreqprot;
    bool initialized;
    /* ... */
};
The selinux_state window, byte by byteThe live kernel structure, byte by byte. The whole bypass is the one byte at offset 1.

The eight-byte selinux_state window laid out by offset. Offset 0 holds disabled, offset 1 holds enforcing, offset 2 holds checkreqprot, offset 3 holds initialized, and offsets 4 through 7 hold further live state. The enforcing Boolean is the single byte at offset 1.

On this build, enforcing was the byte at offset 1. Its value was 1.

SELinux saw a security state. I saw eight bytes:

00 01 00 01 01 01 01 00

The second byte was the whole bypass. Change it from 01 to 00, and the running guest became permissive:

00 00 00 01 01 01 01 00

This was the Action Replay moment. I did not ask Android to run setenforce. I did not persuade the policy to permit my shell. From QNX, I changed the kernel memory that recorded whether SELinux should enforce its decisions.

The write itself was tiny. The discipline was all in the reading that came before it.

Before writing, the tool read the selinux_state window three times through /dev/mem. All three reads had to agree, and the bytes had to match either the exact enforcing pattern or the exact permissive pattern. The two accepted patterns differed at one offset only, with the transition 1 -> 0.

There was one hardware-shaped complication. The physical address of the enforcing byte could not be expressed by the QNX dd seek I was using, so the tool rewrote the aligned eight-byte prefix instead. The other seven bytes were copied back unchanged. Then it performed three more uncached reads and verified that the enforcing byte was the only byte that moved.

No automatic retry. If the write returned an uncertain result, the safe next operation was a read.

Flipping the enforcing byte
Flipping the enforcing byte aarch64 pc 7/12 ▸ write8(base, set_byte(read1, 1, 0x00))
  1. flip_enforcing():
  2. base = phys_of(selinux_state)
  3. read1 = read8(base) # uncached
  4. read2 = read8(base) # uncached
  5. read3 = read8(base) # uncached
  6. require read1 == read2 == read3
  7. require read1 matches enforcing
  8. recheck(base) # re-read before write
  9. write8(base, set_byte(read1, 1, 0x00))
  10. read1 = read8(base) # uncached
  11. read2 = read8(base) # uncached
  12. read3 = read8(base) # uncached
  13. require only offset 1 moved
  14. return PERMISSIVE

registers

base0x00000000bf3a2000
read100 01 00 01 01 01 01 00
read200 01 00 01 01 01 01 00
read300 01 00 01 01 01 01 00
agreeyes — 3/3 identical
patternenforcing
enforcing0x00
modeenforcing

memory

selinux_state @ 0x00000000bf3a2000
0xbf3a200000 00 00 01 01 01 01 00........

stack

  1. #0flip_enforcing

output

Full step trace (13 steps)
#whereopchangednote
0line 2base = phys_of(selinux_state)base=0x00000000bf3a2000, read1=—, read2=—, read3=—, agree=—, pattern=—, enforcing=0x01, mode=enforcingThe live selinux_state window bound to this exact kernel image. enforcing is the byte at offset 1.
1line 3read1 = read8(base)read1=00 01 00 01 01 01 01 00First uncached read through /dev/mem.
2line 4read2 = read8(base)read2=00 01 00 01 01 01 01 00Second uncached read.
3line 5read3 = read8(base)read3=00 01 00 01 01 01 01 00Third uncached read.
4line 6require read1 == read2 == read3agree=yes — 3/3 identicalAll three reads must agree before any write is considered.
5line 7require read1 matches enforcingpattern=enforcingThe window matches the exact enforcing pattern. Only the transition 1 -> 0 at offset 1 is accepted.
6line 8recheck(base)Breakpoint: re-read immediately before writing. The other seven bytes will be copied back unchanged.
7line 9write8(base, set_byte(read1, 1, 0x00))enforcing=0x00The aligned eight-byte prefix is rewritten. Byte 1 goes 01 -> 00; the window becomes 00 00 00 01 01 01 01 00.
8line 10read1 = read8(base)read1=00 00 00 01 01 01 01 00First uncached read-back after the write.
9line 11read2 = read8(base)read2=00 00 00 01 01 01 01 00Second read-back.
10line 12read3 = read8(base)read3=00 00 00 01 01 01 01 00Third read-back.
11line 13require only offset 1 movedagree=yes — only offset 1 movedExactly one byte moved, 1 -> 0. An uncertain write would be a reason to read, never to retry.
12line 14return PERMISSIVEmode=permissiveThe running guest is permissive. The policy and labels are untouched; a reboot restores the clean enforcing state.
Illustrative trace. A worked example of the enforcing-byte flip. The physical base is representative; the eight selinux_state bytes 00 01 00 01 01 01 01 00, the offset-1 enforcing byte, and the 1 -> 0 transition to permissive are from the post.

Replay the enforcing-byte flip. Step through the three uncached reads of the selinux_state window, watch them agree and match the enforcing pattern, then set a breakpoint on the recheck and step the write that changes offset 1 from 01 to 00. The memory pane shows 00 01 00 01 01 01 01 00 becoming 00 00 00 01 01 01 01 00.

That put SELinux into permissive mode. It did not unload the policy or change the labels, and it did not travel through SELinux’s normal userspace notification path. Rebooting the guest restored the clean enforcing state.

It also did not make my SSH process system_server. That was the next cheat code.

The field that answers “who am I?”

The useful part of the Linux source is surprisingly small. In the Android 5.4 SELinux code, the security state attached to a task’s credentials looks like this:

struct task_security_struct {
    u32 osid;          /* SID prior to last execve */
    u32 sid;           /* current SID */
    u32 exec_sid;      /* exec SID */
    u32 create_sid;    /* fscreate SID */
    u32 keycreate_sid; /* keycreate SID */
    u32 sockcreate_sid;/* fscreate SID */
};
task_security_struct, field by fieldSix u32 fields in 24 bytes. The one that answers who a task is sits at offset 0x4.

The 24-byte task_security_struct as six consecutive four-byte fields: osid at offset 0x00, sid at 0x04, exec_sid at 0x08, create_sid at 0x0c, keycreate_sid at 0x10, and sockcreate_sid at 0x14. The current SID is the four bytes at offset 0x04.

The truck was running Android 12 on Linux 5.4.233-qgki-08056-g1cb1a8ef790f-dirty. The exact profile I recovered for that build agreed with the Android 5.4 source layout: the structure was 24 bytes, and sid was the four-byte integer at offset 0x4.

That integer was the answer to “who am I?”

SELinux saw a SID. I saw an integer. Then the kernel’s own symbols gave the integer its name back.

My SSH daemons had SID 1176, which the live policy named netutils_wrapper. The running system_server process had SID 1212. The names were not hard-coded into the task objects. Each task carried a number; the meaning lived elsewhere, in the policy loaded into the guest.

That separation made the second step feel like another Action Replay code. From inside Android, netutils_wrapper and system_server were security identities with different places in the world. From underneath Android, the immediate difference between them was 36: SID 0x498 became 0x4bc. In little-endian memory, 98 04 00 00 became bc 04 00 00.

Finding the right four bytes

Knowing the structure did not give me a safe address to write.

Again, I could not ask the guest’s missing BTF to describe its structures. I had to bind the SID tool to the exact build.

The profile recorded the complete Linux version banner, the kernel image’s physical base and size, the location of init_task, the offsets needed to walk task_struct and cred, and the layout of task_security_struct. It also recorded a hash of the kernel configuration. If any of those facts disagreed with the running guest, the tool stopped.

Starting from init_task, it walked the kernel’s process list and found all 409 live tasks. For each target SSH process, it followed:

From task to identityEach arrow is another pointer that has to translate, land on the expected shape, and survive a reread. The loaded policy turns the final integer back into a name.

A left-to-right pointer chase. task_struct points through cred to the task_security_struct, whose four-byte sid field at offset 0x4 holds 1176, which the loaded policy names u:r:netutils_wrapper:s0.

Every arrow was another pointer that could be stale, malformed, or simply wrong. The reader checked that addresses were canonical kernel addresses, translated the virtual field through the guest’s live page tables, and required the logical read and the underlying physical bytes to agree.

It also checked task identity. A PID can disappear and be reused while a slow external reader is working. Matching the number was not enough; the process name, task address, group ID, memory descriptor, and start time had to describe the same task before and after the read.

The paranoia came from experience. A wrong Action Replay code gave me a Bad Egg. A wrong kernel address could overwrite anything in the guest. The machine would still do exactly what I told it to do; that was the problem.

Changing the answer

The dry run found two current guest-sshd processes. Both had SID 1176. It independently found the live system_server donor at SID 1212.

The commit path rechecked the kernel identity, task identity, credential pointer, security pointer, virtual-to-physical translation, and current SID immediately before each write. It wrote each unique credential once. There was no blind retry: an interrupted or uncertain write became a reason to inspect the current state, not an excuse to send the same bytes again.

Then it read the physical memory back and followed the normal logical path again. Both views had to report SID 1212.

Inside the guest, /proc/<pid>/attr/current now named both SSH processes:

u:r:system_server:s0

A new command through the same SSH service could list 259 Binder services.

This was not the SELinux bypass. The enforcing byte was already zero. The SID rewrite was the post-step that gave the existing SSH processes the Binder-facing identity I needed, and both physical memory and the guest’s own /proc view agreed that it had worked.

The change was RAM-only. Restarting the daemon created new credentials. Rebooting Android rebuilt the world from its boot-time state. SID 1212 disappeared from my SSH process as cleanly as a cheat vanished when I powered off the Game Boy.

Rewriting a SID from below
Rewriting a SID from below aarch64 pc 10/11 ▸ write32(security + 0x4, 0x4bc)
  1. resolve_sid(task):
  2. cred = task->cred
  3. security = cred->security
  4. sid = *(u32*)(security + 0x4)
  5. name = policy_sid_to_name(sid)
  6. return sid
  7. main:
  8. task = find_task("guest-sshd")
  9. sid = resolve_sid(task)
  10. verify_identity(task)
  11. donor = resolve_sid(system_server)
  12. recheck(task)
  13. write32(security + 0x4, donor)
  14. readback_and_verify(task)

registers

task0xffffff8003a1c000
cred0xffffff8003a1c4c8
security0xffffff8009b7e200
sid0x4bc
namenetutils_wrapper
donor0x4bc

memory

task_security_struct @ 0xffffff8009b7e200
0xffffff8009b7e20098 04 00 00 bc 04 00 00........
0xffffff8009b7e20898 04 00 00 00 00 00 00........
0xffffff8009b7e21000 00 00 00 00 00 00 00........

stack

  1. #0main

output

Full step trace (12 steps)
#whereopchangednote
0line 8find_task("guest-sshd")task=0xffffff8003a1c000, cred=0x0, security=0x0, sid=0x0, name=—, donor=0x0One of two guest-sshd processes, found by walking init_task.
1line 9call resolve_sid(task)Step into to follow the pointer chase; step over to skip it.
2line 2cred = task->credcred=0xffffff8003a1c4c8task_struct -> cred.
3line 3security = cred->securitysecurity=0xffffff8009b7e200cred -> security (the task_security_struct).
4line 4sid = *(u32*)(security + 0x4)sid=0x498sid is the four bytes at offset 0x4: 98 04 00 00 = 0x498 = 1176.
5line 5name = policy_sid_to_name(sid)name=netutils_wrapperThe live policy names 1176 netutils_wrapper.
6line 6return sidReturn to main.
7line 10verify_identity(task)comm, task address, gid, mm, and start time must match before and after.
8line 11donor = resolve_sid(system_server)donor=0x4bcsystem_server resolves to SID 1212 = 0x4bc.
9line 12recheck(task)Breakpoint: re-verify kernel and task identity immediately before the write.
10line 13write32(security + 0x4, 0x4bc)sid=0x4bc98 04 00 00 becomes bc 04 00 00. The SID SELinux enforces is now 1212.
11line 14readback_and_verify(task)name=system_serverPhysical read-back and the guest's own /proc view agree.
Illustrative trace. A worked example of the exact-profile write path. Addresses are representative; the struct layout, the SID values 1176 and 1212, and the little-endian bytes are from the post.

Replay the exact-profile tool. It walks task_struct to cred to security, reads SID 1176 (netutils_wrapper) from the task_security_struct, then writes 1212 (system_server) — the four bytes at offset 0x4 changing from 98 04 00 00 to bc 04 00 00. Step into resolve_sid to follow the pointer chase, drop a breakpoint, and watch the register, memory, and stack panes update.

The hidden room was a racetrack

Every Action Replay code I ever entered was in service of something small and specific. Walk through this wall. Hold ninety-nine of that item. Meet a Pokémon the game would never have handed me. The codes were never the point; the room on the other side was.

So here is the room. With SELinux permissive and my SSH shell wearing system_server, the guest stopped arguing with me. I could reach the Binder services that broker the truck’s own hardware, install what I wanted, and rewrite it while it ran. What I wanted was Mario Kart.

Underneath the dashboard, the head unit is an ordinary Android device with an unusually good set of inputs. It has a steering wheel with a real angle sensor, pedals with real travel, and a climate system whose knobs and buttons are just more entries on the car service — the same service that had been invisible to me until the SID rewrite handed my process a name Binder was willing to talk to. Android Automotive publishes all of it as vehicle properties. The moment I could read those properties, they were controllers.

So I ran a Wii emulator on the guest and wired the truck into it with Frida. The hooks are small and dumb, which is the fun of it: read a vehicle property, translate it into a controller axis or a button press, hand it to the emulator. Player one steers with the wheel and drives with the pedals. The car is the controller.

Player one: the truck's own wheel and pedals

A vertical clip of Mario Kart running on the 2024 Chevrolet Colorado head unit. The physical steering wheel turns the kart and the accelerator pedal drives it forward.

Player two races from the center screen. There was no second wheel to give the passenger, so I gave them the climate panel instead: the fan-speed knob became the steering axis, and the Max A/C button became item use — the shell, the banana, the shell. It is a genuinely terrible way to play Mario Kart, and it works completely.

Player two steers with the fan-speed knob and throws items with Max A/C.
Player two: steering with the A/C fan knob

A vertical clip of the passenger playing Mario Kart from the center screen, steering with the climate fan-speed knob and firing items with the Max A/C button.

None of it survives a key cycle. Reboot the guest and the emulator is gone, the Frida hooks are gone, and the enforcing byte is back to 01. The wheel goes back to being a wheel and the A/C button goes back to being cold air. The room closes the moment the power does, exactly like a cheat that vanished when I switched off the Game Boy. That it was ever open at all still came down to a single byte and four more.

The reader I wanted afterward

The exact profile worked, but it was an Action Replay code in another sense: it belonged to one kernel build. Change the build and all those offsets become suspect.

Afterward, I wrote guest_selinux.py, a more general, read-only version of the tool I wanted at the beginning. On kernels that expose BTF, it can use the kernel’s own type information to find selinux_state, walk the live SID table, turn SIDs back into user, role, type, sensitivity, and category names, inspect task and socket labels, and read bounded evidence from the Access Vector Cache.

It does not patch anything. Its job is to earn the right to give the bytes names.

A pointer is just an eight-byte integer until its address translates, its destination has the expected shape, its neighbors agree with it, and a second read says the object did not move. A SID is just another integer until the loaded policy turns it into u:r:system_server:s0. Every step replaces a little machine shorthand with language.

This is the satisfying part: not applying a policy file from outside and hoping it matches, but asking the running kernel what its own numbers mean.

At seven, I treated hexadecimal as authority. Someone else handed me an address, and my job was to enter it faithfully. If the game changed in an impossible way, that was proof enough that the code had worked.

Now every address is provisional. It has to be aligned, translatable, structurally plausible, bound to the exact machine in front of me, and stable across a reread. The magic did not survive because I avoided learning how it worked. It survived the explanation.

The cheat code is still hexadecimal. Nobody hands it to me anymore.