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.
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
| Viewport | Region bytes | Grid | Bytes per cell | Target cell |
|---|---|---|---|---|
| Desktop | 44,040,192 | 64×64 = 4,096 | 10,752 | 2,632 |
| Mobile | 44,040,192 | 32×32 = 1,024 | 43,008 | 658 |
| Viewport | Region bytes | Grid | Bytes per cell | Target cell |
|---|---|---|---|---|
| Desktop | 44,040,192 | 64×64 = 4,096 | 10,752 | 2,632 |
| Mobile | 44,040,192 | 32×32 = 1,024 | 43,008 | 658 |
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.
- find_enforcing_byte():
- fd = open("/dev/mem")
- base = profile.image_base
- size = profile.image_size
- require base, size match profile
- h = hash(read immutable ranges)
- require h == profile.image_hash
- addr = base + profile.sstate_off
- win = read8(addr)
- require win matches enforcing
- return addr + 1 # enforcing byte
registers
| fd | 3 |
|---|---|
| base | 0x0000000040080000 |
| size | 0x0000000002a00000 |
| hash | 0xe6faf09d29bd |
| expected | 0xe6faf09d29bd |
| match | yes — exact build |
| sstate | 0x0000000041b7e000 |
| win | 00 01 00 01 01 01 01 00 |
| target | 0x0000000041b7e001 |
stack
- #0find_enforcing_byte
output
Full step trace (10 steps)
| # | where | op | changed | note |
|---|---|---|---|---|
| 0 | line 2 | fd = 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. |
| 1 | line 3 | base = profile.image_base | base=0x0000000040080000 | The saved profile names the kernel image's physical base. |
| 2 | line 4 | size = profile.image_size | size=0x0000000002a00000 | and its physical size. |
| 3 | line 5 | require base, size match profile | Base and size must match the profile before anything deeper is trusted. | |
| 4 | line 6 | h = hash(read immutable ranges) | hash=0xe6faf09d29bd | Hash the ranges of the image that never change at runtime. |
| 5 | line 7 | require h == profile.image_hash | expected=0xe6faf09d29bd, match=yes — exact build | Breakpoint: a build that merely looked similar would fail here. This is the exact kernel image the profile was cut from. |
| 6 | line 8 | addr = base + profile.sstate_off | sstate=0x0000000041b7e000 | selinux_state sits at a known offset within this exact image. |
| 7 | line 9 | win = read8(addr) | win=00 01 00 01 01 01 01 00 | Read the eight-byte window at that address. |
| 8 | line 10 | require win matches enforcing | The window matches the exact enforcing pattern. This is the right structure, not a look-alike. | |
| 9 | line 11 | return addr + 1 | target=0x0000000041b7e001 | Offset 1 within the window. This is the exact byte the flip tool will change. |
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 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.
- flip_enforcing():
- base = phys_of(selinux_state)
- read1 = read8(base) # uncached
- read2 = read8(base) # uncached
- read3 = read8(base) # uncached
- require read1 == read2 == read3
- require read1 matches enforcing
- recheck(base) # re-read before write
- write8(base, set_byte(read1, 1, 0x00))
- read1 = read8(base) # uncached
- read2 = read8(base) # uncached
- read3 = read8(base) # uncached
- require only offset 1 moved
- return PERMISSIVE
registers
| base | 0x00000000bf3a2000 |
|---|---|
| read1 | 00 01 00 01 01 01 01 00 |
| read2 | 00 01 00 01 01 01 01 00 |
| read3 | 00 01 00 01 01 01 01 00 |
| agree | yes — 3/3 identical |
| pattern | enforcing |
| enforcing | 0x00 |
| mode | enforcing |
memory
stack
- #0flip_enforcing
output
Full step trace (13 steps)
| # | where | op | changed | note |
|---|---|---|---|---|
| 0 | line 2 | base = phys_of(selinux_state) | base=0x00000000bf3a2000, read1=—, read2=—, read3=—, agree=—, pattern=—, enforcing=0x01, mode=enforcing | The live selinux_state window bound to this exact kernel image. enforcing is the byte at offset 1. |
| 1 | line 3 | read1 = read8(base) | read1=00 01 00 01 01 01 01 00 | First uncached read through /dev/mem. |
| 2 | line 4 | read2 = read8(base) | read2=00 01 00 01 01 01 01 00 | Second uncached read. |
| 3 | line 5 | read3 = read8(base) | read3=00 01 00 01 01 01 01 00 | Third uncached read. |
| 4 | line 6 | require read1 == read2 == read3 | agree=yes — 3/3 identical | All three reads must agree before any write is considered. |
| 5 | line 7 | require read1 matches enforcing | pattern=enforcing | The window matches the exact enforcing pattern. Only the transition 1 -> 0 at offset 1 is accepted. |
| 6 | line 8 | recheck(base) | Breakpoint: re-read immediately before writing. The other seven bytes will be copied back unchanged. | |
| 7 | line 9 | write8(base, set_byte(read1, 1, 0x00)) | enforcing=0x00 | The aligned eight-byte prefix is rewritten. Byte 1 goes 01 -> 00; the window becomes 00 00 00 01 01 01 01 00. |
| 8 | line 10 | read1 = read8(base) | read1=00 00 00 01 01 01 01 00 | First uncached read-back after the write. |
| 9 | line 11 | read2 = read8(base) | read2=00 00 00 01 01 01 01 00 | Second read-back. |
| 10 | line 12 | read3 = read8(base) | read3=00 00 00 01 01 01 01 00 | Third read-back. |
| 11 | line 13 | require only offset 1 moved | agree=yes — only offset 1 moved | Exactly one byte moved, 1 -> 0. An uncertain write would be a reason to read, never to retry. |
| 12 | line 14 | return PERMISSIVE | mode=permissive | The running guest is permissive. The policy and labels are untouched; a reboot restores the clean enforcing state. |
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 */
};
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:
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.
- resolve_sid(task):
- cred = task->cred
- security = cred->security
- sid = *(u32*)(security + 0x4)
- name = policy_sid_to_name(sid)
- return sid
- main:
- task = find_task("guest-sshd")
- sid = resolve_sid(task)
- verify_identity(task)
- donor = resolve_sid(system_server)
- recheck(task)
- write32(security + 0x4, donor)
- readback_and_verify(task)
registers
| task | 0xffffff8003a1c000 |
|---|---|
| cred | 0xffffff8003a1c4c8 |
| security | 0xffffff8009b7e200 |
| sid | 0x4bc |
| name | netutils_wrapper |
| donor | 0x4bc |
memory
stack
- #0main
output
Full step trace (12 steps)
| # | where | op | changed | note |
|---|---|---|---|---|
| 0 | line 8 | find_task("guest-sshd") | task=0xffffff8003a1c000, cred=0x0, security=0x0, sid=0x0, name=—, donor=0x0 | One of two guest-sshd processes, found by walking init_task. |
| 1 | line 9 | call resolve_sid(task) | Step into to follow the pointer chase; step over to skip it. | |
| 2 | line 2 | cred = task->cred | cred=0xffffff8003a1c4c8 | task_struct -> cred. |
| 3 | line 3 | security = cred->security | security=0xffffff8009b7e200 | cred -> security (the task_security_struct). |
| 4 | line 4 | sid = *(u32*)(security + 0x4) | sid=0x498 | sid is the four bytes at offset 0x4: 98 04 00 00 = 0x498 = 1176. |
| 5 | line 5 | name = policy_sid_to_name(sid) | name=netutils_wrapper | The live policy names 1176 netutils_wrapper. |
| 6 | line 6 | return sid | Return to main. | |
| 7 | line 10 | verify_identity(task) | comm, task address, gid, mm, and start time must match before and after. | |
| 8 | line 11 | donor = resolve_sid(system_server) | donor=0x4bc | system_server resolves to SID 1212 = 0x4bc. |
| 9 | line 12 | recheck(task) | Breakpoint: re-verify kernel and task identity immediately before the write. | |
| 10 | line 13 | write32(security + 0x4, 0x4bc) | sid=0x4bc | 98 04 00 00 becomes bc 04 00 00. The SID SELinux enforces is now 1212. |
| 11 | line 14 | readback_and_verify(task) | name=system_server | Physical read-back and the guest's own /proc view agree. |
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.
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.
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.