Skip to content
0tax00Public

About

Runtime root, HTTPS interception and remote access on a Galaxy S24 Ultra (SM-S928B, e3q) with a locked bootloader.

Resources

Stars

3 stars

Watchers

1 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

Ghostwire

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 Ghostwire console: device status on the left, modules in the middle, log on the right


Part 1: Porting GhostLock to the S24 Ultra

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.

The target

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.

What the port had to fix

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

1. The write landed, but installed a dead table

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.

2. Physical KASLR made the physmap alias wrong

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.

3. The slab search never landed on a real object

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.

4. A missed wake window aborted the entire oracle

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.

5. The KASLR slide is read, not guessed

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.

Inherited vs. ours

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.

Heap state decides whether it works

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.

Running it

./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 high

Preflight 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_PROBE or P0_CFG_DEBUG. They crash: a pread on a fake fops without SET_NAME dereferences NULL. The core runs without probes.

Rebuilding, the fixed target and post-factory-reset steps are in root/README.md.


Why this exists

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.


Part 2: What Ghostwire is

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)

Two identities, and the distinction is load-bearing

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.

adb is primary, SSH is failover

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.

Reachability is measured from the device

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 Burp picks the host address the device can actually reach
  • Route to Burp refuses to set a proxy it cannot prove
  • status re-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.


Setup

Requirements

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

Install

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)

A typical run: intercepting an app's HTTPS

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.


Notes from the bench

The things that cost real time on hardware.

Double proxy resets the connection

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.

Clearing the config does not close open sockets

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.

The CA's SKI is SHA-1 of the BIT STRING contents

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 reboot guard cleans up what outlives the root

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.


Artifacts on the device

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.so reachable

The root package uses the same naming.

Anti-RASP group

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.


Linux and macOS

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.

Layout

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.

About

Runtime root, HTTPS interception and remote access on a Galaxy S24 Ultra (SM-S928B, e3q) with a locked bootloader.

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages