Runtime root, HTTPS interception and remote access on a Galaxy S24 Ultra (SM-S928B, e3q) with a locked bootloader. Driven from a PC over adb, with SSH as a transparent failover.
The repository is two things, in the order they matter:
| Directory | What it is |
|---|---|
root/ |
A port of the GhostLock exploit to the global S24 Ultra on S6/DZE1, which was not a supported target |
ghostwire/ |
The console that drives everything after root: trust stores, proxy, SSH, instrumentation, cleanup |
The exploit is CVE-2026-43499, "GhostLock": a use-after-free in rtmutex/PI-futex, published by NebulaSec.
Credit for the bug, the write primitive and the shape of the chain goes there. The published profile targets e2s (SM-S926B, Galaxy S24+); this bench runs e3q.
| Field | Value |
|---|---|
| Device | SM-S928B (e3q, Snapdragon 8 Gen 3), global |
| Firmware | S928BXXS6DZE1 |
| Kernel | 6.1.145-android14-11-33419968-abS928BXXS6DZE1 |
| OS | One UI 8.5 / Android 16 |
| Bootloader | Locked, fuse already burned |
The root is soft: it lives in the running kernel and dies on reboot.
Nothing on the device is modified to get there: no patched boot image, no unlocked bootloader, no OEM unlock, no burned fuse.
Five distinct failures, each with its own signature. All five are documented next to the code they affect, in target.h.
| # | Failure | Fix |
|---|---|---|
| 1 | Write lands, table is dead | APP_FOPS_TABLE_MIRROR_OFF 0x1e80 |
| 2 | physmap alias points elsewhere every boot | APP_FOPS_KIMAGE_TARGET |
| 3 | Slab search never hits a real object | MM_STRUCT_SZ 0x400 |
| 4 | One missed window kills the whole run | SLIDE_PHYSICAL_SLOT_DELAYS_USEC |
| 5 | Slide brute-forced instead of read | tslide + APP_SLIDE_VIA_LOGGER |
Symptom. The fops-hijack write demonstrably worked: it flipped ashmem SET_SIZE from 0 to -1, so the bytes reached the kernel. The resulting table still did nothing.
Cause. fake_fops (payload+0x2000) effectively points at reclaimed-page offset 0x1e80 (SKB_DATA_DELTA=-0x1000 plus a 0x180 gap). The e2s profile mirrors the fops table at that offset. The e3q profile omitted it.
Fix. Mirror it: APP_FOPS_TABLE_MIRROR_OFF 0x1e80.
Symptom. The write target was valid on one boot and garbage on the next.
Cause. kaslr_region in the device tree randomises phys_load per boot, so the physmap alias data_addr(SYM) moves every time.
Fix. APP_FOPS_KIMAGE_TARGET. The kernel's .data is writable through its kimage alias too, and the kimage slide is already known, so the write is aimed there instead (slide-only). Same .data bytes, same safe rb-leaf.
Symptom. The KernelSnitch stage confirmed no candidate, silently, and produced nothing.
Cause. MM_STRUCT_SZ was 0x500. The running DZE1 kernel's mm_struct cache holds 1024-byte objects, 32 per order-3 (32 KiB) slab, read straight off the device:
/proc/slabinfo → objsize 1024, objperslab 32
At 0x500 the search stepped the slab in 1280-byte strides that never coincide with the real objects.
Fix. MM_STRUCT_SZ 0x400, matching the same-SoC e1s/e2s profiles.
Symptom. One bad window killed the run with no retry:
pselect ret=0
slot=1 write window failed
Cause. The oracle treated a missed window as fatal. It is not: a timed-out attempt performs no kernel write, so re-arming is safe.
Fix. SLIDE_PHYSICAL_SLOT_DELAYS_USEC sweeps the enter-delay around the 50 ms point that works. Before triggering, /proc/self/task/<tid>/wchan is checked for do_select to confirm the waiter is actually parked.
Two independent sources replaced the base exploit's brute force.
root/tslide.c is a standalone oracle that reads the current boot's slide through tracefs. It is deterministic, and run.sh re-reads it every round, because the slide changes on every boot, including one caused by a failed attempt.
Inside the payload, APP_SLIDE_VIA_LOGGER recovers the slide sideways instead of running the fragile probe/fingerprint pipe dance. A gate read (physmap-addressed, no slide needed) pulls the nfulnl_logger .data page and takes the slide from its first qword. physmap is not randomised on arm64 (commit 1db780bafa4c), so the object sits at a fixed address.
From the CVE
- the bug
- the write primitive (rb-erase)
- the architecture of the chain: fops hijack → configfs → physical R/W over a pipe →
selinux=0→ UMH → root daemon
From this repository
- the tracefs slide oracle
- the five target corrections above
- the runner that ties them together
This is a port and adaptation, not a different exploitation of the CVE.
Measured on this bench, and it is not subtle:
| Condition | Result |
|---|---|
| ~3 h uptime, heavy use | 3 rounds failed in a row with wait_requeue_pi ret=-1 errno=110 (ETIMEDOUT); the UAF trigger never fires |
| Straight after a boot | Took on the 1st attempt, twice out of two |
run.sh reads the uptime during preflight and says so. --reboot handles it, and a cold boot (power off, power on) beats a reboot.
./root/run.sh # root, with verification
./root/run.sh --check # preflight only, does not touch the device
./root/run.sh --reboot # reboot first when uptime is highPreflight aborts when the device kernel is not the supported string. The payload carries offsets for that build; on another one it fails or takes the device down. --force overrides at your own risk.
Never set
P0_ASHMEM_PROBEorP0_CFG_DEBUG. They crash: apreadon a fake fops withoutSET_NAMEdereferences NULL. The core runs without probes.
Rebuilding, the fixed target and post-factory-reset steps are in root/README.md.
I was doing pentest work with KernelSU Next and decided to update the device firmware to a newer build and redo root with KSU Next. I hadn't read anything about it beforehand, and got a real scare when I found out One UI 8.5 no longer allows unlocking the bootloader, which took my whole chain with it. I had already burned the fuse on this S24 Ultra, and I wasn't about to leave it as a paperweight.
Then I saw the recent CVE, GhostLock, and I'm very grateful to NebulaSec for it. Right now GhostLock is one of the best things to happen to mobile pentesting: you get root at runtime and the device stays intact. No modified boot, no bootloader, no OEM unlock, no burned fuse. And it gets more reliable when you adapt the exploit to your own scenario, under your own control.
That is what I did with the global S24 Ultra on S6/DZE1, and the port is my contribution back.
Root is the beginning, not the goal.
Everything a mobile pentest needs afterwards is a sequence of fussy device commands with non-obvious constraints:
- put the CA in the right trust store
- point the global proxy at Burp, and prove the phone can reach it
- keep a way back in when adb drops
- start an instrumentation server without the OEM's kernel killing it
- undo all of it cleanly when you are done
Ghostwire is those steps as a console. Each one is a button that reflects the device's real state, an equivalent CLI action, and a comment explaining why it is done that way, because most of these steps have an obvious version that does not work.
gw # TUI (or: python -m ghostwire)
gw status # state as text
gw proxy-on # run one action and exit (an invalid name lists them all)Samsung's DEFEX SIGKILLs any binary executed as root from outside the system partition, memfd included. So the console never runs settings, pm or am as root:
| Call | Identity | How |
|---|---|---|
dev.sh(...) |
uid 2000, shell domain | adb shell, or over SSH via uiddrop.so |
dev.root(...) |
uid 0 | the root daemon the exploit planted |
uiddrop.so is what makes the SSH path work: LD_PRELOADing it into a /system/bin/sh performs setcon(u:r:shell:s0) + setuid(2000), turning an SSH-root session into a real adb shell.
Servers (dropbear, frida-server) come up through LD_PRELOAD or a bind-mount over a system binary, entering by dlopen rather than execve, which is what DEFEX inspects.
Device is transport-agnostic. adb is the fast path; if it drops, sh() and root() fall through to SSH on their own and the orchestration keeps working.
An SSH ControlMaster connection is reused across commands, so a full status over SSH takes about 8 seconds instead of 60.
Disable dev mode hides Developer options (a RASP tell) without dropping adb. Only when SSH is confirmed up and uiddrop.so is staged does it also cut USB debugging, so it can never lock you out.
A proxy is reachable when the phone can open it. That is not a question the host can answer about itself.
A host reaches its own LAN address over loopback, so 13.0.0.109:8080 answers instantly on the machine running Burp even when a VPN kill switch, AP client isolation or a firewall means nothing else on the network can open a socket to it.
So the probe runs on the device: curl through the proxy to http://burp/cert, with nc as a fallback:
Find Burppicks the host address the device can actually reachRoute to Burprefuses to set a proxy it cannot provestatusre-checks, instead of trusting the configured value
When the LAN path is genuinely blocked, Route to Burp names the host-side cause to look at and falls back to adb reverse, reporting that the fallback dies with the adb connection rather than pretending it is equivalent.
Testing this from the host is exactly how a dead LAN path once got misread as a certificate problem for hours.
| Requirement | Notes |
|---|---|
adb |
with the device authorised |
| Python ≥ 3.11 | plus textual and rich |
openssl |
either OpenSSL or the LibreSSL macOS ships |
sshpass |
for the SSH failover |
frida-tools |
only for the instrumentation group |
| Burp Suite | listening on the proxy port |
| the root package | see root/README.md |
pip install -e . # installs the `gw` command
cp ghostwire.toml.example ghostwire.toml # then edit the values
./native/build.sh # builds bin/uiddrop.so (needs the NDK)1. Acquire root
The exploit above. Shell domain, gone on reboot.
2. Find Burp
Locates the Burp process, proves which port speaks proxy protocol and which speaks MCP, then picks the host address the device can reach.
3. Route to Burp
Sets the global proxy. Runs as adb shell (uid 2000), so it does not hit the DEFEX that kills settings as root. Writes http_proxy and global_http_proxy_host/port, because Android keeps it in both: clearing only http_proxy left the proxy alive in the other two, which was the "invalid certificate with the proxy off" case.
4. Trust CA · browsers
Pulls the CA from Burp itself through the proxy and installs it in the user store. This is what unblocked ERR_CERT_AUTHORITY_INVALID, and what makes Chrome and Samsung Internet trust it: a user CA is exempt from Certificate Transparency, while the same CA in the system store would be rejected by Chrome 99+ for having no SCTs. Apps have ignored user certs since Android 7, which is why both paths exist.
For an app with its own network_security_config, use Trust CA · apps instead: it mounts the CA into the system store (APEX) via a tmpfs in init's namespace plus a zygote restart. Chromium may reject that one over CT.
Open an HTTPS site on the device and check Burp's history.
The things that cost real time on hardware.
A global proxy and a VPN (SuperProxy) at once resets the connection before TLS, which looks exactly like a certificate error. Route to Burp detects an active tun/ppp and refuses. One path at a time.
Long-lived processes cache the ProxySelector in their own HTTP stack and keep talking to Burp over keep-alive. Kill interception kills the holders too.
Not of the whole SubjectPublicKeyInfo (RFC 5280 §4.2.1.2). Getting it wrong makes every validator report "trust anchor not found". Ghostwire detects a wrong SKI and reissues with the same key when the .p12 is available.
The root and the mounts die on reboot, but settings do not. A global proxy left pointing at a dead Burp takes the phone's internet with it, so the guard clears it on the next boot.
Everything written to the device uses unsignposted 15-character names, kept in one place in ghostwire/const.py, so neither a RASP nor a forensic pass matches on a name.
frida-server comes up disguised:
- argv0
[kworker/u16:6] - port
47000, not 27042 - running as root via a bind-mount over an ART apex binary, which gets past DEFEX Safeplace while keeping
libart.soreachable
The root package uses the same naming.
A RASP runs in EL0 inside the app and only sees what that process sees. Robustness means having no EL0 artifact.
Scan exposure. Probes the device the way a RASP would and scores each tell (LOUD / MED / LOW / PASS / ASSET). It reads the trust store as root on purpose: the app does not read the file, it sees it through AndroidCAStore, so the ground truth is whether the file exists.
Go dark. Removes the EL0 tells that can go over adb: proxy, user CA, system CA mount (unmounted in init and in the zygote), frida binary. Reversible.
Hide in kernel. Roadmap. file_operations hooks on /proc and /sys that lie per reader (TracerPid, maps, port, enforce, mounts) only for the target PID. Needs the exploit's kernel R/W; there is no honest way to do it over adb alone.
Both are first-class. Where the two disagree, the platform is detected once and each branch is a real implementation rather than a fallback.
| Fact | Linux | macOS |
|---|---|---|
| Process list | /proc/<pid>/cmdline |
ps -ax -o pid= -o command= |
| Listening TCP | /proc/net/tcp{,6} |
lsof -nP -iTCP -sTCP:LISTEN |
| Interfaces | ip -4 -o addr show |
ifconfig |
File size (run.sh) |
stat -c %s |
stat -f %z |
| NDK toolchain | linux-x86_64 |
darwin-x86_64 |
That lives in ghostwire/hostenv.py. The shell scripts feature-detect instead, since they cannot import it.
The certificate work uses only flags that both OpenSSL and LibreSSL accept, so macOS needs nothing beyond the /usr/bin/openssl it ships with.
ghostwire/
cli.py entry point (TUI by default; status/single action from the CLI)
tui.py Textual console · toggle buttons reflect real state
controls.py control definitions, shared by the TUI and the CLI
device.py device access: adb primary, SSH failover (uid 2000 / uid 0)
hostenv.py host facts that differ between Linux and macOS
config.py ghostwire.toml
const.py device names and paths (single source, opaque)
certs.py openssl work (fetch, SKI, reissue)
ops.py the operations: root, proxy, ca, ssh, adb, frida, status
stealth.py anti-RASP hardening: rasp_scan, go_dark, kernel_hide (scaffold)
discover.py Burp autodiscovery (proxy + MCP), verified from the device
native/
uiddrop.c the shell-failover .so
build.sh NDK build, host toolchain detected automatically
root/ the root package (GhostLock port for e3q)
run.sh preflight, slide, exploit, verification
bin/ prebuilt binaries
src/ Makefile rebuildable source
Own device, authorised research, pentest bench.
