From 2eaa83c643d3ba500376fa8e2ebd0f0d271d7e0a Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Wed, 17 Jun 2026 17:35:52 +0100 Subject: [PATCH 01/21] chore(casting): adopt the Casting plane Generated by tools/cast.py: root settings + build-logic (law repo, convention dep), root build (rust-gates + comment-hygiene + the hand-authored dependencyGuard), gradle/libs.versions.toml, deny.toml, detekt + license config, .editorconfig, .githooks, .mold version. core.hooksPath is set to .githooks (pre-commit gatesFast, pre-push gatesFull); both skip cleanly until a gradle wrapper exists. --- .context/.gitignore | 9 +-- .context/experimental/.gitkeep | 0 .editorconfig | 32 +++++++++++ .githooks/pre-commit | 9 +++ .githooks/pre-push | 10 ++++ .mold/structure-version | 1 + build-logic/build.gradle.kts | 14 +++++ build-logic/settings.gradle.kts | 19 +++++++ build.gradle.kts | 84 ++++++++++++++++++++++++++++ config/detekt/detekt.yml | 35 ++++++++++++ config/license/allowed-licenses.json | 13 +++++ deny.toml | 42 ++++++++++++++ gradle/dependency-guard.config | 10 ++++ gradle/libs.versions.toml | 10 ++++ settings.gradle.kts | 25 +++++++++ 15 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 .context/experimental/.gitkeep create mode 100644 .editorconfig create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push create mode 100644 .mold/structure-version create mode 100644 build-logic/build.gradle.kts create mode 100644 build-logic/settings.gradle.kts create mode 100644 build.gradle.kts create mode 100644 config/detekt/detekt.yml create mode 100644 config/license/allowed-licenses.json create mode 100644 deny.toml create mode 100644 gradle/dependency-guard.config create mode 100644 gradle/libs.versions.toml create mode 100644 settings.gradle.kts diff --git a/.context/.gitignore b/.context/.gitignore index 1245a9e..c0a6e92 100644 --- a/.context/.gitignore +++ b/.context/.gitignore @@ -1,9 +1,10 @@ # .context/.gitignore - managed by the Mold stamp # -# experimental/ is a persistent scratch workspace for decompiled artifacts, -# pulled device files, and in-flight analysis. Contents are intentionally not -# versioned; commit specific subpaths only when they become stable deliverables -# (then move them out of experimental/ into src/, docs/, etc.). +# experimental/ is a persistent scratch workspace for decompiled +# artifacts, pulled device files, and in-flight analysis. Contents +# are intentionally not versioned; commit specific subpaths only when +# they become stable project deliverables (then move them out of +# experimental/ into the correct location: src/, docs/, etc.). experimental/* !experimental/.gitkeep diff --git a/.context/experimental/.gitkeep b/.context/experimental/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d72e272 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,32 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{kt,kts}] +# Line-length ceiling 300: the standard across these projects. A generous +# ceiling so the gate fires only on genuinely unreadable lines. Note ktlint +# cannot auto-fix an over-long line (it will not break it for you), so a line +# past 300 must be wrapped by hand — rare at this ceiling. +max_line_length = 300 +# Non-auto-fixable comment-placement rules: ktlint flags these but will not fix +# them, so they cannot belong to a deterministic auto-format gate. Both guard +# legitimate, readable patterns (labelled Java-interop bool args; an explanatory +# comment on a single-line if-guard). Comment placement is a sign-plane (review) +# concern, not a bump-plane (auto-fix) one. +ktlint_standard_comment-wrapping = disabled +ktlint_standard_if-else-wrapping = disabled + +[*.{c,cc,cpp,h,hpp}] +indent_size = 2 + +[*.{yml,yaml,toml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..ca4d119 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,9 @@ +#!/bin/sh +# Mold casting hook: the fast structural gate (gatesFast) on commit. +# A convenience pre-check, never the wall: `--no-verify` walks past this, the +# release build does not (ADR-0002). gatesFast also runs on every variant build. +set -e +if [ -x ./gradlew ]; then + exec ./gradlew gatesFast --quiet +fi +echo "mold pre-commit: no ./gradlew found; skipping gatesFast" >&2 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..9c54d31 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,10 @@ +#!/bin/sh +# Mold casting hook: the full wall (gatesFull) on push. +# A convenience pre-check, never the wall: `--no-verify` walks past this, the +# release build does not (ADR-0002). gatesFull also binds at assembleRelease / +# bundleRelease. Inert where there is no push target (local-only developer). +set -e +if [ -x ./gradlew ]; then + exec ./gradlew gatesFull --quiet +fi +echo "mold pre-push: no ./gradlew found; skipping gatesFull" >&2 diff --git a/.mold/structure-version b/.mold/structure-version new file mode 100644 index 0000000..7ac4e5e --- /dev/null +++ b/.mold/structure-version @@ -0,0 +1 @@ +0.1.13 diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000..b4d4498 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,14 @@ +// Generated by tools/cast.py. VERSION-PIN LOCATION #2 of 2: the convention jar so +// the project's convention plugins can apply mold.kotlin-quality and mold.gates by +// id. Keep in lockstep with the root build's pin (both == castingVersion). +plugins { + `kotlin-dsl` +} + +dependencies { + // The mold casting law. The AGP/Kotlin gradlePlugin deps below let Gradle + // generate the type-safe plugin accessors the convention plugins use. + implementation("mold.casting:convention:0.1.13") + // implementation(libs.android.gradlePlugin) + // implementation(libs.kotlin.gradlePlugin) +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..d7a9d21 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,19 @@ +// Generated by tools/cast.py. build-logic is a standalone included build, so it +// does NOT inherit the root's repositories or version catalog: the law repo is wired +// here SECOND (miss it -> "Could not find mold.casting:convention"), and the same +// gradle/libs.versions.toml is imported by path so the catalog stays one SSOT. +dependencyResolutionManagement { + repositories { + maven { url = uri("file:///mnt/companion/president/maven-casting") } // mold casting law repo + gradlePluginPortal() + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0a8a823 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,84 @@ +// Generated by tools/cast.py (the Casting plane). +plugins { + id("mold.rust-gates") version "0.1.13" + id("mold.comment-hygiene") version "0.1.13" +} + +// The module-edge dependency guard has NO published plugin (the one gate that is +// project-authored): cast.py emits the task + matrix parser here. Fill in +// gradle/dependency-guard.config with your tier matrix; an illegal edge then fails +// `check` (-> build), not just a review. See casting-adoption.md. +tasks.register("dependencyGuard") { + group = "verification" + description = "Fails any project->project edge outside gradle/dependency-guard.config." + val matrixFile = file("gradle/dependency-guard.config") + val modules = subprojects + doLast { + val matrix = DependencyMatrix.parse(matrixFile.readText()) + val violations = modules.flatMap { module -> + module.configurations + .flatMap { it.dependencies } + .filterIsInstance() + .map { it.path } + .distinct() + .filter { it != module.path } + .filterNot { matrix.permits(module.path, it) } + .map { "${module.path} -> $it" } + }.distinct().sorted() + if (violations.isNotEmpty()) { + throw GradleException( + "Dependency guard: ${violations.size} edge(s) outside the matrix:\n" + + violations.joinToString("\n") { " $it" }, + ) + } + logger.lifecycle("Dependency guard: ${modules.size} modules, every edge within the matrix.") + } +} + +subprojects { + tasks.matching { it.name == "check" }.configureEach { + dependsOn(rootProject.tasks.named("dependencyGuard")) + } +} + +// A contract target matches face-precise (contract:state); any other by bare tier. +class DependencyMatrix(private val allowed: Map>) { + fun permits(source: String, target: String): Boolean { + val targetSegments = target.split(":").filter { it.isNotEmpty() } + val spec = if (targetSegments.firstOrNull() == "contract" && targetSegments.size >= 2) { + "contract:${targetSegments[1]}" + } else { + targetSegments.firstOrNull().orEmpty() + } + return spec in (allowed[tierOf(source)] ?: emptySet()) + } + + private fun tierOf(path: String): String = + path.split(":").firstOrNull { it.isNotEmpty() }.orEmpty() + + companion object { + fun parse(text: String): DependencyMatrix { + val allowed = text.lineSequence() + .map { it.substringBefore('#').trim() } + .filter { it.isNotEmpty() } + .associate { line -> + require("->" in line) { "dependency-guard.config: rule has no '->': `$line`" } + val tier = line.substringBefore("->").trim() + val specs = line.substringAfter("->").trim() + .split(Regex("\\s+")).filter { it.isNotEmpty() }.toSet() + tier to specs + } + return DependencyMatrix(allowed) + } + } +} + +// Cross-wire the cargo-root rust gates into each module's wall: the cargo root is a +// plain project, so rust-gates' in-plugin fold finds no local gatesFast/Full; this +// binding makes a broken crate fail the same wall (enforcement.md, ADR-0010). +subprojects { + tasks.matching { it.name == "gatesFast" } + .configureEach { dependsOn(rootProject.tasks.named("rustGatesFast")) } + tasks.matching { it.name == "gatesFull" } + .configureEach { dependsOn(rootProject.tasks.named("rustGatesFull")) } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..33da8da --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,35 @@ +# Casting-plane detekt config — the WHOLE active rule set, not an override. +# The mold.kotlin-quality convention plugin sets buildUponDefaultConfig = false, +# so detekt's 200-rule default set is OFF and only the rules turned on below run. +# +# enforcement.md scopes this gate to two things and nothing else: +# - swallowed / empty / over-generic exceptions (bump) +# - a LargeClass / LongMethod hard ceiling (bump at the ceiling only) +# The soft size budget is a sign-plane (judgment) concern, not a deterministic +# gate, so detekt enforces only the hard ceiling here. Formatting belongs to +# Spotless/ktlint, never detekt — no formatting or style rules live in this file. +# +# Per ADR-0007 (certainty over severity): gate only what we are certain about. +# Widening this set is an ADR plus a canary promotion, not an edit. + +# EmptyCatchBlock is in detekt's empty-blocks ruleset, not exceptions. +empty-blocks: + active: true + EmptyCatchBlock: + active: true + +exceptions: + active: true + SwallowedException: + active: true + TooGenericExceptionCaught: + active: true + +complexity: + active: true + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 diff --git a/config/license/allowed-licenses.json b/config/license/allowed-licenses.json new file mode 100644 index 0000000..85cf8f7 --- /dev/null +++ b/config/license/allowed-licenses.json @@ -0,0 +1,13 @@ +{ + "_comment": "Casting-plane JVM license allowlist read by the first-party checkLicense gate in mold.jvm-supply: an external dependency whose POM license matches none of these regexes fails gatesFull. Mirrors the deny.toml policy for the Rust half. A dependency whose license the gate cannot read directly (e.g. declared only in a parent POM) is frozen with a per-module entry ({ \"moduleName\": \"group:artifact\" }) — the brownfield mechanism. Widening the license SET (not a per-module freeze) is an ADR plus a canary promotion.", + "allowedLicenses": [ + { "moduleLicense": ".*Apache.*" }, + { "moduleLicense": ".*MIT.*" }, + { "moduleLicense": ".*BSD.*" }, + { "moduleLicense": ".*ISC.*" }, + { "moduleLicense": ".*Unicode.*" }, + { "moduleLicense": ".*CC0.*" }, + { "moduleLicense": ".*Zlib.*" }, + { "moduleLicense": ".*Public Domain.*" } + ] +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..e038a73 --- /dev/null +++ b/deny.toml @@ -0,0 +1,42 @@ +# Casting-plane Rust supply gate (enforcement.md: license allowlist + advisories, +# the cargo-deny half of the polyglot supply line). cargo-deny 0.19.x schema. +# Widening the allowlist is an ADR plus a canary promotion, not a silent edit. + +[graph] +all-features = true + +[licenses] +# Allowlist only: a crate whose license is not listed here fails the build. +# This is the deterministic "unlicensed/disallowed crate -> bump" gate. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.9 +# First-party workspace crates (publish = false) are your own source, not a +# supply-line dependency: gate the licenses of what you pull IN, not what you +# write. Unpublished crates are skipped; an external crate is still gated. +private = { ignore = true } + +[bans] +multiple-versions = "warn" +wildcards = "deny" +# Internal workspace path/git deps legitimately carry no version; exempt them so only +# EXTERNAL wildcard requirements (the real supply risk) fail the ban. +allow-wildcard-paths = true + +[sources] +unknown-registry = "deny" +unknown-git = "deny" + +# [advisories] is intentionally minimal: the RustSec advisory DB is a fetched +# feed (network, like OWASP dependency-check's NVD feed). The license + bans + +# sources checks above are fully offline and deterministic. +[advisories] +yanked = "deny" diff --git a/gradle/dependency-guard.config b/gradle/dependency-guard.config new file mode 100644 index 0000000..deda00b --- /dev/null +++ b/gradle/dependency-guard.config @@ -0,0 +1,10 @@ +# The module-edge tier matrix, made executable. The :dependencyGuard task reads each +# module's tier off its Gradle path and fails any project->project edge this matrix +# does not list. Adding an edge is a recorded decision: it changes here. +# +# Format: -> ... +# - a contract target is matched face-precise: contract:config | contract:state +# - any other target is matched by bare tier: core | domain | ui | app +# +# Greenfield seed: fill in your tiers. An empty file permits NO edges (every +# project->project edge fails) until you list them. A line without '->' is rejected. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..354b292 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,10 @@ +# Greenfield seed. build-logic imports this by path so the version pins stay one +# source of truth. Mirrors the law's enforcement toolchain; uncomment the matching +# implementation lines in build-logic/build.gradle.kts when JVM modules arrive. +[versions] +agp = "9.2.0" +kotlin = "2.3.21" + +[libraries] +android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f4a8e2e --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,25 @@ +// Generated by tools/cast.py (the Casting plane). The law Maven repo is the FIRST +// pluginManagement repository so the mold.* plugins resolve before any other source. +pluginManagement { + includeBuild("build-logic") + repositories { + maven { url = uri("file:///mnt/companion/president/maven-casting") } // mold casting law repo + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "resetprop-rs" + +// Add your tier graph here, e.g.: +// include(":app") +// include(":core:foo") From aabd8b13e80dd7d062dee05e9b63b6808664d8c8 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Wed, 17 Jun 2026 17:35:53 +0100 Subject: [PATCH 02/21] chore: remove decorative divider comments Box-drawing and dash rules the comment-hygiene gate flags as decorative banners; they carry no information the code does not. --- crates/resetprop/src/seal/arena.rs | 12 ------------ crates/resetprop/src/seal/elf.rs | 18 ------------------ crates/resetprop/src/seal/hook.rs | 20 -------------------- crates/resetprop/src/seal/ptrace.rs | 18 ------------------ crates/resetprop/tests/ptrace_core_smoke.rs | 6 ------ crates/resetprop/tests/tier_a_child_smoke.rs | 12 ------------ 6 files changed, 86 deletions(-) diff --git a/crates/resetprop/src/seal/arena.rs b/crates/resetprop/src/seal/arena.rs index 2f7f12f..a352bd6 100644 --- a/crates/resetprop/src/seal/arena.rs +++ b/crates/resetprop/src/seal/arena.rs @@ -10,9 +10,7 @@ use std::path::Path; use super::maps::{parse_maps, MapEntry}; use crate::error::{Error, Result}; -// ───────────────────────────────────────────────────────────────────────────── // Syscall numbers and flag constants (REGISTRY §1 canonical values) -// ───────────────────────────────────────────────────────────────────────────── // Syscall numbers (asm-generic/unistd.h via linux-arm64-abi.md §1) pub(crate) const NR_OPENAT: u64 = 56; @@ -149,9 +147,7 @@ pub(crate) fn find_scratch_slot(bytes: &[u8]) -> Option { Some(min.next_multiple_of(8)) } -// ───────────────────────────────────────────────────────────────────────────── // RemapFlags — direction selector for remote_remap_private -// ───────────────────────────────────────────────────────────────────────────── /// Direction of the arena remap — `Private` seals (blocks writes from /// propagating), `Shared` restores init's original view (unseal). @@ -176,9 +172,7 @@ impl RemapFlags { } } -// ───────────────────────────────────────────────────────────────────────────── // verify_init_identity — M1 init-identity guard (runs BEFORE any RemoteAttach) -// ───────────────────────────────────────────────────────────────────────────── /// Read `/proc//comm` — the kernel's per-process command name, written /// with a trailing newline. Authored fresh for the M1 guard; no other @@ -221,9 +215,7 @@ fn check_init_identity(comm: &str, maps: &[MapEntry]) -> Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // RemoteAttach — RAII guard that detaches on drop -// ───────────────────────────────────────────────────────────────────────────── /// RAII guard that group-stops init's **entire** thread group on construction /// and resumes every frozen thread on Drop. @@ -290,9 +282,7 @@ impl Drop for RemoteAttach { } } -// ───────────────────────────────────────────────────────────────────────────── // remote_remap_private — the core Tier A seal primitive -// ───────────────────────────────────────────────────────────────────────────── /// Remap init's mapping of `arena_path` as `MAP_PRIVATE|MAP_FIXED` /// (Tier A seal) or `MAP_SHARED|MAP_FIXED` (unseal) via remote syscalls @@ -561,11 +551,9 @@ pub(crate) unsafe fn remote_remap_private( Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // Tier A orchestrators — thin compositions over find_arena_mapping + // remote_remap_private. These are the public seam consumed by // `PropSystem::seal_arena` / `::unseal_arena` and by the T5 smoke test. -// ───────────────────────────────────────────────────────────────────────────── /// Tier A seal: remap init's writable view of `arena_path` as /// `MAP_PRIVATE|MAP_FIXED`. Thin composition of `find_arena_mapping` + diff --git a/crates/resetprop/src/seal/elf.rs b/crates/resetprop/src/seal/elf.rs index 5815508..611f655 100644 --- a/crates/resetprop/src/seal/elf.rs +++ b/crates/resetprop/src/seal/elf.rs @@ -23,9 +23,7 @@ use std::ptr; use crate::error::{Error, Result}; -// ----------------------------------------------------------------------------- // Constants -// ----------------------------------------------------------------------------- /// ELF magic — first four bytes of `e_ident`. `/usr/include/elf.h:103-107`. pub const ELFMAG: [u8; 4] = [0x7f, b'E', b'L', b'F']; @@ -86,9 +84,7 @@ pub const STB_WEAK: u8 = 2; /// Reserved section index meaning "undefined". `/usr/include/elf.h:413`. pub const SHN_UNDEF: u16 = 0; -// ----------------------------------------------------------------------------- // ELF64 structs (exact layouts per /usr/include/elf.h) -// ----------------------------------------------------------------------------- /// ELF64 file header. Layout: `/usr/include/elf.h:81-97`. Total: 64 bytes. #[repr(C)] @@ -155,9 +151,7 @@ pub struct Elf64_Sym { } const _: () = assert!(mem::size_of::() == 24); -// ----------------------------------------------------------------------------- // LibcElfView — owned file bytes + resolved dynamic table offsets -// ----------------------------------------------------------------------------- /// Parsed view of an Android arm64 `libc.so`. /// @@ -184,9 +178,7 @@ pub struct LibcElfView { pub(crate) syment: usize, } -// ----------------------------------------------------------------------------- // vaddr → file offset translation -// ----------------------------------------------------------------------------- /// Translate a linking-view virtual address to a file offset using the PT_LOAD /// map. Returns `None` if `vaddr` falls outside every PT_LOAD range. @@ -201,9 +193,7 @@ fn vaddr_to_foff(pt_loads: &[(u64, u64, u64)], vaddr: u64) -> Option { None } -// ----------------------------------------------------------------------------- // Unaligned struct read helper -// ----------------------------------------------------------------------------- /// Read a `#[repr(C)]` POD struct of size `N` from `bytes[off..off+N]` without /// alignment requirements. Returns `Error::ElfParse` on bounds overflow. @@ -231,9 +221,7 @@ fn read_struct(bytes: &[u8], off: usize, what: &str) -> Result { Ok(unsafe { ptr::read_unaligned(ptr) }) } -// ----------------------------------------------------------------------------- // parse_libc_elf — public entry point for P03 -// ----------------------------------------------------------------------------- /// Parse an Android arm64 `libc.so` from a `File` handle into a [`LibcElfView`]. /// @@ -380,9 +368,7 @@ pub fn parse_libc_elf(file: &File) -> Result { }) } -// ----------------------------------------------------------------------------- // GNU_HASH lookup (T2) -// ----------------------------------------------------------------------------- /// Width of a single bloom-filter word, in bits. Matches bionic's /// `kBloomMaskBits = sizeof(ElfW(Addr)) * 8` for arm64. @@ -547,9 +533,7 @@ pub fn gnu_lookup(view: &LibcElfView, name: &str) -> Option { } } -// ----------------------------------------------------------------------------- // Linear symbol-table fallback (T3) -// ----------------------------------------------------------------------------- /// Linear scan of `.dynsym` for `name`, returning the matched symbol's /// `st_value` or `None` on miss / malformed bounds. @@ -628,9 +612,7 @@ pub fn resolve_symbol(view: &LibcElfView, name: &str) -> Result { result.ok_or_else(|| Error::SymbolNotFound(name.into())) } -// ----------------------------------------------------------------------------- // Tests -// ----------------------------------------------------------------------------- #[cfg(test)] impl LibcElfView { diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index 29f0502..da93d21 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -40,9 +40,7 @@ use crate::seal::ptrace::{ setregset, wait_stop, write_remote, PTRACE_CONT, }; -// ───────────────────────────────────────────────────────────────────────────── // Stage-B constants (REGISTRY §1 canonical flag values) -// ───────────────────────────────────────────────────────────────────────────── /// 4 KiB — one base page on AArch64. Mirrors `BOOTSTRAP_PAGE_SIZE` in /// `seal::arena` but kept local to keep the stage-B constant block self- @@ -69,9 +67,7 @@ const PATH_STAGE_OFFSET: u64 = 2048; /// pipelines share identical scan behaviour. const LIBC_SCAN_LIMIT: usize = 64 * 1024; -// ───────────────────────────────────────────────────────────────────────────── // P04 T3 — hook-page layout and i-cache sync constants -// ───────────────────────────────────────────────────────────────────────────── /// Hook page byte offset where [`build_hook_body_bytes`]'s 140-byte body /// lands in the file-backed mapping. Held at 1024 rather than 0 to keep @@ -821,9 +817,7 @@ pub(crate) mod encoder { } } -// ───────────────────────────────────────────────────────────────────────────── // P04 T2 — pure hook-body encoder -// ───────────────────────────────────────────────────────────────────────────── /// Canonical 35-word (140-byte) hook-body layout. /// @@ -965,9 +959,7 @@ pub fn build_hook_body_bytes( body.iter().flat_map(|w| w.to_le_bytes()).collect() } -// ───────────────────────────────────────────────────────────────────────────── // P04 T3 — trampoline installer + i-cache sync -// ───────────────────────────────────────────────────────────────────────────── /// Execute a single `isb` in the tracee by staging `isb ; brk #0` at /// `scratch_pc`, flipping `pc`, resuming, waiting for the brk trap, and @@ -1286,9 +1278,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // P04 T4 — lock-list mechanics (pure helpers + public seal / unseal) -// ───────────────────────────────────────────────────────────────────────────── /// Append `name` + entry-NUL + empty-sentinel-NUL to a lock-list buffer in /// the atomic-append write order. Returns the new `cur_len`, i.e. the byte @@ -1601,9 +1591,7 @@ mod tests { _drop_compiles::(); } - // ───────────────────────────────────────────────────────────────────── // P04 T1 — A64 encoder submodule tests - // ───────────────────────────────────────────────────────────────────── /// Round-trips a 16-byte absolute-target trampoline built from the /// encoder helpers against the canonical byte pattern from @@ -1739,9 +1727,7 @@ mod tests { let _ = super::encoder::ldrb_imm(0, 0, 4096); } - // ───────────────────────────────────────────────────────────────────── // P04 T2 — build_hook_body_bytes tests - // ───────────────────────────────────────────────────────────────────── /// Verifies [`build_hook_body_bytes`] serialises the 35-word template /// with the three patch regions filled in the correct byte positions. @@ -1960,9 +1946,7 @@ mod tests { } } - // ───────────────────────────────────────────────────────────────────── // T16 / H1 — independent encoding oracle - // ───────────────────────────────────────────────────────────────────── /// The golden trampoline image, assembled out-of-band from /// `oracle/hook_body.s` by a real ARM64 assembler (`aarch64-linux-gnu-as` @@ -2057,9 +2041,7 @@ mod tests { } } - // ───────────────────────────────────────────────────────────────────── // P04 T4 — lock-list mechanics tests - // ───────────────────────────────────────────────────────────────────── /// Builds a dummy `HookHandle` for NUL-rejection paths that never reach /// ptrace. Both `hook_page == 0` and `lock_list_page == 0` short-circuit @@ -2168,9 +2150,7 @@ mod tests { assert!(matches!(err, Error::InvalidKey)); } - // ───────────────────────────────────────────────────────────────────── // T04 / M2 — verify-after-write read-back gate - // ───────────────────────────────────────────────────────────────────── /// The two words `install_trampoline` POKEs, packed exactly as they land /// in tracee memory: `word_lo` (`ldr x16,[pc,#8]` | `br x16`) then the diff --git a/crates/resetprop/src/seal/ptrace.rs b/crates/resetprop/src/seal/ptrace.rs index fcd4b0b..618a31b 100644 --- a/crates/resetprop/src/seal/ptrace.rs +++ b/crates/resetprop/src/seal/ptrace.rs @@ -34,9 +34,7 @@ pub use arch::active::{ TRAP_INSN, }; -// ───────────────────────────────────────────────────────────────────────────── // PTRACE request numbers — from bionic/libc/kernel/uapi/linux/ptrace.h -// ───────────────────────────────────────────────────────────────────────────── /// `PTRACE_CONT` — resume a stopped tracee. source: linux/ptrace.h:17 pub const PTRACE_CONT: c_int = 7; @@ -87,9 +85,7 @@ pub const PTRACE_EVENT_STOP: u32 = 128; /// source: linux/elf.h:301 pub const NT_PRSTATUS: c_int = 1; -// ───────────────────────────────────────────────────────────────────────────── // Internal helpers -// ───────────────────────────────────────────────────────────────────────────── /// Wrap the current `errno` as [`Error::PtraceOp`]. /// @@ -146,9 +142,7 @@ fn classify_seize_err(pid: Pid) -> Error { Error::PtraceAttach(err) } -// ───────────────────────────────────────────────────────────────────────────── // ptrace primitives -// ───────────────────────────────────────────────────────────────────────────── /// `PTRACE_SEIZE` — attach without stopping the tracee. Sets /// `PTRACE_O_TRACESYSGOOD` atomically via the `data` argument so subsequent @@ -372,9 +366,7 @@ pub fn ptrace_detach(pid: Pid) -> Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // Thread-group stop — T15 / Defect B (freeze every init thread before any poke) -// ───────────────────────────────────────────────────────────────────────────── /// Upper bound on re-scan passes before a thread group is declared /// non-convergent. Init carries a small, near-static thread count, so a real @@ -588,9 +580,7 @@ pub(crate) fn detach_thread_group(tids: &[Pid]) -> Result<()> { first_err.map_or(Ok(()), Err) } -// ───────────────────────────────────────────────────────────────────────────── // Word-granularity tracee memory IO — PTRACE_PEEKDATA / PTRACE_POKEDATA -// ───────────────────────────────────────────────────────────────────────────── /// `PTRACE_PEEKDATA` — read one 64-bit word from `addr` in the tracee. /// @@ -668,9 +658,7 @@ pub fn ptrace_poketext(pid: Pid, addr: u64, value: u64) -> Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // Cross-process memory IO — process_vm_{readv,writev} partial-transfer loops -// ───────────────────────────────────────────────────────────────────────────── /// Loop `process_vm_readv` until the entire `buf` has been filled from /// `remote_addr` in the tracee, or the kernel returns an error. @@ -763,9 +751,7 @@ pub(crate) unsafe fn write_remote(pid: Pid, remote_addr: u64, buf: &[u8]) -> Res Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── // remote_syscall — stage `svc #0 ; brk #0` and run one syscall in the tracee -// ───────────────────────────────────────────────────────────────────────────── /// Execute `syscall_no(args...)` inside `pid` by staging an 8-byte /// `svc #0 ; brk #0` blob at `scratch_pc`, resuming until the `brk` traps, @@ -876,9 +862,7 @@ pub unsafe fn remote_syscall( Ok(ret) } -// ───────────────────────────────────────────────────────────────────────────── // remote_syscall_via_poke — same as remote_syscall, but PEEK/POKEDATA scratch -// ───────────────────────────────────────────────────────────────────────────── /// Execute `syscall_no(args...)` inside `pid` by staging an 8-byte /// `svc #0 ; brk #0` blob at `scratch_pc` via [`ptrace_peektext`] / @@ -980,9 +964,7 @@ pub(crate) unsafe fn remote_syscall_via_poke( Ok(ret) } -// ───────────────────────────────────────────────────────────────────────────── // Unit tests -// ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { diff --git a/crates/resetprop/tests/ptrace_core_smoke.rs b/crates/resetprop/tests/ptrace_core_smoke.rs index 13a9a49..7867755 100644 --- a/crates/resetprop/tests/ptrace_core_smoke.rs +++ b/crates/resetprop/tests/ptrace_core_smoke.rs @@ -39,9 +39,7 @@ use resetprop::seal::{ /// source: asm-generic/unistd.h:461 (`__NR_getpid = 172`). const NR_GETPID: u64 = 172; -// ───────────────────────────────────────────────────────────────────────────── // Helpers (verbatim from phases/seal/references/test-harness-patterns.md §3) -// ───────────────────────────────────────────────────────────────────────────── /// Fork a child running `child_body`; return the child pid to the parent. /// Safety: `child_body` must not return (it should `_exit`, `loop`, or `panic!`). @@ -89,9 +87,7 @@ impl Drop for ChildGuard { } } -// ───────────────────────────────────────────────────────────────────────────── // Child body — inherits the pre-fork RWX scratch page via COW and blocks -// ───────────────────────────────────────────────────────────────────────────── fn child_body() -> ! { loop { @@ -104,9 +100,7 @@ fn child_body() -> ! { } } -// ───────────────────────────────────────────────────────────────────────────── // Test — round-trip getpid() through remote_syscall -// ───────────────────────────────────────────────────────────────────────────── #[test] #[ignore = "requires ptrace_scope<=1 or CAP_SYS_PTRACE; run with: cargo test -p resetprop --test ptrace_core_smoke -- --ignored --test-threads=1"] diff --git a/crates/resetprop/tests/tier_a_child_smoke.rs b/crates/resetprop/tests/tier_a_child_smoke.rs index 60128ff..ccedb9c 100644 --- a/crates/resetprop/tests/tier_a_child_smoke.rs +++ b/crates/resetprop/tests/tier_a_child_smoke.rs @@ -51,9 +51,7 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::time::Duration; -// ───────────────────────────────────────────────────────────────────────────── // Constants -// ───────────────────────────────────────────────────────────────────────────── const PAGE_SIZE: usize = 4096; const ARENA_PAGES: usize = 4; @@ -62,9 +60,7 @@ const SENTINEL_OFFSET: usize = 128; const SENTINEL_PRE: u8 = 0xAA; const SENTINEL_POST: u8 = 0xBB; -// ───────────────────────────────────────────────────────────────────────────── // Helpers (verbatim from phases/seal/references/test-harness-patterns.md §3) -// ───────────────────────────────────────────────────────────────────────────── /// Fork a child running `child_body`; return the child pid to the parent. /// Safety: `child_body` must not return (it should `_exit`, `loop`, or `panic!`). @@ -117,7 +113,6 @@ fn sleep_ms(ms: u64) { std::thread::sleep(Duration::from_millis(ms)); } -// ───────────────────────────────────────────────────────────────────────────── // Child path propagation // // The `fn() -> !` fork bound precludes closure captures, so the child body @@ -125,13 +120,10 @@ fn sleep_ms(ms: u64) { // by the parent immediately before `fork()`. After `fork()`, the child // inherits the same virtual-memory image via COW page tables, so the value // stored in `CHILD_PATH` is visible to the child without any IPC. -// ───────────────────────────────────────────────────────────────────────────── static CHILD_PATH: OnceLock = OnceLock::new(); -// ───────────────────────────────────────────────────────────────────────────── // Child body — mmaps the tempfile MAP_SHARED and write-loops a sentinel -// ───────────────────────────────────────────────────────────────────────────── fn child_body_mmap_loop() -> ! { // SAFETY: All libc calls below are the standard open/mmap/usleep FFI @@ -181,10 +173,8 @@ fn child_body_mmap_loop() -> ! { } } -// ───────────────────────────────────────────────────────────────────────────── // Third-observer read path — independent file handle, never aliases the // child's mapping. Demonstrates the inode's ground-truth state. -// ───────────────────────────────────────────────────────────────────────────── fn read_file_byte(path: &Path, offset: usize) -> u8 { let mut f = OpenOptions::new() @@ -198,9 +188,7 @@ fn read_file_byte(path: &Path, offset: usize) -> u8 { buf[0] } -// ───────────────────────────────────────────────────────────────────────────── // Test -// ───────────────────────────────────────────────────────────────────────────── #[test] #[ignore = "requires ptrace_scope<=1; run with --ignored --test-threads=1"] From ec9d8e90df9b40e39438c70d67f1efce5731594f Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Wed, 17 Jun 2026 17:35:53 +0100 Subject: [PATCH 03/21] chore: mark internal crates publish=false Workspace path-dep consumers, not crates.io artifacts. cargo-deny's wildcard-path ban applies to public crates; this exempts them. --- crates/propdetect-bionic/Cargo.toml | 1 + crates/propdetect/Cargo.toml | 1 + crates/resetprop-cli/Cargo.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/propdetect-bionic/Cargo.toml b/crates/propdetect-bionic/Cargo.toml index ef2d439..b276948 100644 --- a/crates/propdetect-bionic/Cargo.toml +++ b/crates/propdetect-bionic/Cargo.toml @@ -1,4 +1,5 @@ [package] +publish = false # internal workspace crate, not a crates.io artifact name = "propdetect-bionic" version = "0.1.0" edition = "2021" diff --git a/crates/propdetect/Cargo.toml b/crates/propdetect/Cargo.toml index 493cd3f..edc123a 100644 --- a/crates/propdetect/Cargo.toml +++ b/crates/propdetect/Cargo.toml @@ -1,4 +1,5 @@ [package] +publish = false # internal workspace crate, not a crates.io artifact name = "propdetect" version = "0.1.0" edition = "2021" diff --git a/crates/resetprop-cli/Cargo.toml b/crates/resetprop-cli/Cargo.toml index a39ca82..52634bd 100644 --- a/crates/resetprop-cli/Cargo.toml +++ b/crates/resetprop-cli/Cargo.toml @@ -1,4 +1,5 @@ [package] +publish = false # internal workspace crate, not a crates.io artifact name = "resetprop-cli" version = "0.5.0" edition = "2021" From 001735259f20f5dcee2e549fdbe3cae1ad8a36bd Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Wed, 17 Jun 2026 17:35:53 +0100 Subject: [PATCH 04/21] refactor(propdetect): sort findings by severity key Satisfies clippy unnecessary_sort_by under -D warnings. Severity is Copy + Ord, so Reverse keeps the existing descending order. --- crates/propdetect/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/propdetect/src/main.rs b/crates/propdetect/src/main.rs index 06df3ca..e58eeeb 100644 --- a/crates/propdetect/src/main.rs +++ b/crates/propdetect/src/main.rs @@ -90,7 +90,7 @@ fn run_detect(sys: &PropSystem) -> Result<(), String> { return Ok(()); } - findings.sort_by(|a, b| b.severity.cmp(&a.severity)); + findings.sort_by_key(|f| std::cmp::Reverse(f.severity)); println!("=== propdetect report ({} properties scanned) ===\n", all_props.len()); From cc733852389083c80c22ac7bfeede35acd674449 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Wed, 17 Jun 2026 17:35:56 +0100 Subject: [PATCH 05/21] chore: format the workspace with rustfmt Adopt the cargoFmtCheck gate. The tree diverged from rustfmt 1.96.0 defaults; this is the formatting-only pass, kept separate from the substantive changes above. --- crates/propdetect-bionic/src/main.rs | 166 +++++++++++--- crates/propdetect/src/heuristics.rs | 107 +++++++-- crates/propdetect/src/main.rs | 57 +++-- crates/propdetect/src/snapshot.rs | 7 +- .../propdetect/tests/detect_manipulation.rs | 75 ++++-- crates/resetprop-cli/src/main.rs | 27 ++- crates/resetprop/src/area.rs | 49 +++- crates/resetprop/src/bionic.rs | 21 +- crates/resetprop/src/compact.rs | 70 +++++- crates/resetprop/src/context.rs | 57 +++-- crates/resetprop/src/dict.rs | 63 +++++- crates/resetprop/src/error.rs | 8 +- crates/resetprop/src/harvest.rs | 15 +- crates/resetprop/src/info.rs | 3 +- crates/resetprop/src/inspect.rs | 13 +- crates/resetprop/src/lib.rs | 70 +++--- crates/resetprop/src/mock.rs | 214 +++++++++++++----- crates/resetprop/src/persist/io.rs | 14 +- crates/resetprop/src/persist/mod.rs | 20 +- crates/resetprop/src/persist/proto.rs | 33 ++- crates/resetprop/src/seal/arena.rs | 25 +- crates/resetprop/src/seal/elf.rs | 39 ++-- crates/resetprop/src/seal/hook.rs | 38 ++-- crates/resetprop/src/seal/maps.rs | 9 +- crates/resetprop/src/seal/ptrace.rs | 58 +++-- crates/resetprop/src/trie.rs | 24 +- crates/resetprop/tests/elf_fixture_smoke.rs | 9 +- crates/resetprop/tests/ptrace_core_smoke.rs | 16 +- crates/resetprop/tests/tier_a_child_smoke.rs | 9 +- 29 files changed, 959 insertions(+), 357 deletions(-) diff --git a/crates/propdetect-bionic/src/main.rs b/crates/propdetect-bionic/src/main.rs index b3e45d0..c00db3c 100644 --- a/crates/propdetect-bionic/src/main.rs +++ b/crates/propdetect-bionic/src/main.rs @@ -42,12 +42,24 @@ struct Snapshot { } extern "C" fn foreach_cb(pi: *const prop_info, cookie: *mut c_void) { - - extern "C" fn read_cb(cookie: *mut c_void, name: *const c_char, value: *const c_char, serial: u32) { + extern "C" fn read_cb( + cookie: *mut c_void, + name: *const c_char, + value: *const c_char, + serial: u32, + ) { let collector = unsafe { &*(cookie as *const PropCollector) }; - let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned(); - let value = unsafe { CStr::from_ptr(value) }.to_string_lossy().into_owned(); - collector.props.lock().unwrap().insert(name, PropValue { value, serial }); + let name = unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned(); + let value = unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned(); + collector + .props + .lock() + .unwrap() + .insert(name, PropValue { value, serial }); } unsafe { @@ -75,20 +87,32 @@ fn main() -> ExitCode { Some("--snapshot") => { let path = match args.get(1) { Some(p) => p, - None => { eprintln!("usage: propdetect-bionic --snapshot "); return ExitCode::FAILURE; } + None => { + eprintln!("usage: propdetect-bionic --snapshot "); + return ExitCode::FAILURE; + } }; cmd_snapshot(Path::new(path)) } Some("--diff") => { let (a, b) = match (args.get(1), args.get(2)) { (Some(a), Some(b)) => (a, b), - _ => { eprintln!("usage: propdetect-bionic --diff "); return ExitCode::FAILURE; } + _ => { + eprintln!("usage: propdetect-bionic --diff "); + return ExitCode::FAILURE; + } }; cmd_diff(Path::new(a), Path::new(b)) } - Some("-h" | "--help") => { print_usage(); ExitCode::SUCCESS } + Some("-h" | "--help") => { + print_usage(); + ExitCode::SUCCESS + } None => cmd_detect(), - Some(s) => { eprintln!("unknown arg: {s}"); ExitCode::FAILURE } + Some(s) => { + eprintln!("unknown arg: {s}"); + ExitCode::FAILURE + } } } @@ -121,21 +145,88 @@ fn cmd_detect() -> ExitCode { } let known_prefixes: std::collections::HashSet<&str> = [ - "ro", "persist", "sys", "init", "net", "gsm", "ril", "dalvik", "hw", - "wifi", "bluetooth", "dhcp", "service", "selinux", "debug", "log", - "ctl", "vendor", "config", "security", "cache", "dev", "vold", - "media", "audio", "camera", "pm", "am", "wm", "input", - "telephony", "phone", "ims", "runtime", "libc", "wrap", "drm", - "apex", "adb", "external_storage", - ].into_iter().collect(); + "ro", + "persist", + "sys", + "init", + "net", + "gsm", + "ril", + "dalvik", + "hw", + "wifi", + "bluetooth", + "dhcp", + "service", + "selinux", + "debug", + "log", + "ctl", + "vendor", + "config", + "security", + "cache", + "dev", + "vold", + "media", + "audio", + "camera", + "pm", + "am", + "wm", + "input", + "telephony", + "phone", + "ims", + "runtime", + "libc", + "wrap", + "drm", + "apex", + "adb", + "external_storage", + ] + .into_iter() + .collect(); let numeric_hints: &[&str] = &[ - "debuggable", "secure", "adb", "enabled", "connected", "locked", - "booted", "ready", "completed", "running", "active", "supported", - "present", "available", "configured", "mounted", "visible", - "count", "size", "max", "min", "timeout", "delay", "interval", - "level", "version", "index", "id", "port", "pid", "uid", - "width", "height", "density", "dpi", "fps", "encrypted", + "debuggable", + "secure", + "adb", + "enabled", + "connected", + "locked", + "booted", + "ready", + "completed", + "running", + "active", + "supported", + "present", + "available", + "configured", + "mounted", + "visible", + "count", + "size", + "max", + "min", + "timeout", + "delay", + "interval", + "level", + "version", + "index", + "id", + "port", + "pid", + "uid", + "width", + "height", + "density", + "dpi", + "fps", + "encrypted", ]; for (name, pv) in &props { @@ -157,7 +248,8 @@ fn cmd_detect() -> ExitCode { // serial analysis: counter is bits 1-15 (lower half, excluding dirty bit 0) let counter = (pv.serial >> 1) & 0x7FFF; - let is_init = name.starts_with("ro.") || name.starts_with("dalvik.") || name.starts_with("persist."); + let is_init = + name.starts_with("ro.") || name.starts_with("dalvik.") || name.starts_with("persist."); if !is_init && counter == 0 && pv.value == "0" { println!("[WARN] serial: [{name}]=0 serial=0 (possible hexpatch artifact)"); warnings += 1; @@ -178,10 +270,25 @@ fn cmd_diff(a: &Path, b: &Path) -> ExitCode { serde_json::from_str(&data).map_err(|e| format!("{}: {e}", p.display())) }; - let before = match load(a) { Ok(s) => s, Err(e) => { eprintln!("{e}"); return ExitCode::FAILURE; } }; - let after = match load(b) { Ok(s) => s, Err(e) => { eprintln!("{e}"); return ExitCode::FAILURE; } }; + let before = match load(a) { + Ok(s) => s, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + let after = match load(b) { + Ok(s) => s, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; - println!("=== property diff ({} -> {} props) ===\n", before.total_count, after.total_count); + println!( + "=== property diff ({} -> {} props) ===\n", + before.total_count, after.total_count + ); let mut added = 0u32; let mut removed = 0u32; @@ -190,7 +297,10 @@ fn cmd_diff(a: &Path, b: &Path) -> ExitCode { for (name, old) in &before.props { match after.props.get(name) { - None => { println!("- [{name}] = {}", old.value); removed += 1; } + None => { + println!("- [{name}] = {}", old.value); + removed += 1; + } Some(new) if old.value != new.value => { println!("~ [{name}] {} -> {}", old.value, new.value); changed += 1; @@ -215,7 +325,7 @@ fn cmd_diff(a: &Path, b: &Path) -> ExitCode { fn print_usage() { eprintln!( -"propdetect-bionic - non-root property detector (bionic FFI) + "propdetect-bionic - non-root property detector (bionic FFI) Uses __system_property_foreach, same API as any Android app. diff --git a/crates/propdetect/src/heuristics.rs b/crates/propdetect/src/heuristics.rs index 3bf236e..6311329 100644 --- a/crates/propdetect/src/heuristics.rs +++ b/crates/propdetect/src/heuristics.rs @@ -31,23 +31,97 @@ const COUNT_MIN: usize = 300; const COUNT_MAX: usize = 800; const KNOWN_PREFIXES: &[&str] = &[ - "ro", "persist", "sys", "init", "net", "gsm", "ril", "dalvik", "hw", - "wifi", "bluetooth", "dhcp", "service", "selinux", "debug", "log", - "ctl", "vendor", "config", "security", "cache", "dev", "vold", - "media", "audio", "camera", "pm", "am", "wm", "input", - "telephony", "phone", "ims", "setupwizard", "tombstoned", - "runtime", "heapprofd", "libc", "wrap", "sendbug", "drm", - "keyguard", "crypto", "apex", "adb", "external_storage", + "ro", + "persist", + "sys", + "init", + "net", + "gsm", + "ril", + "dalvik", + "hw", + "wifi", + "bluetooth", + "dhcp", + "service", + "selinux", + "debug", + "log", + "ctl", + "vendor", + "config", + "security", + "cache", + "dev", + "vold", + "media", + "audio", + "camera", + "pm", + "am", + "wm", + "input", + "telephony", + "phone", + "ims", + "setupwizard", + "tombstoned", + "runtime", + "heapprofd", + "libc", + "wrap", + "sendbug", + "drm", + "keyguard", + "crypto", + "apex", + "adb", + "external_storage", ]; const NUMERIC_NAME_HINTS: &[&str] = &[ - "debuggable", "secure", "adb", "enabled", "connected", "locked", - "booted", "ready", "completed", "running", "active", "supported", - "present", "available", "configured", "mounted", "visible", - "encrypted", "verified", "charged", "docked", "usb", "charging", - "count", "size", "max", "min", "timeout", "delay", "interval", - "level", "version", "index", "id", "port", "pid", "uid", - "width", "height", "density", "dpi", "fps", + "debuggable", + "secure", + "adb", + "enabled", + "connected", + "locked", + "booted", + "ready", + "completed", + "running", + "active", + "supported", + "present", + "available", + "configured", + "mounted", + "visible", + "encrypted", + "verified", + "charged", + "docked", + "usb", + "charging", + "count", + "size", + "max", + "min", + "timeout", + "delay", + "interval", + "level", + "version", + "index", + "id", + "port", + "pid", + "uid", + "width", + "height", + "density", + "dpi", + "fps", ]; const INIT_TIME_PREFIXES: &[&str] = &[ @@ -87,7 +161,10 @@ pub fn check_orphan_names(props: &[PropEntry]) -> Vec { if known.contains(prefix) { continue; } - if !prefix.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') { + if !prefix + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') + { findings.push(Finding { severity: Severity::Critical, check: "orphan_name", diff --git a/crates/propdetect/src/main.rs b/crates/propdetect/src/main.rs index e58eeeb..8dc7b07 100644 --- a/crates/propdetect/src/main.rs +++ b/crates/propdetect/src/main.rs @@ -86,17 +86,32 @@ fn run_detect(sys: &PropSystem) -> Result<(), String> { findings.extend(heuristics::check_name_coherence(sys.areas())); if findings.is_empty() { - println!("propdetect: no anomalies detected ({} properties scanned)", all_props.len()); + println!( + "propdetect: no anomalies detected ({} properties scanned)", + all_props.len() + ); return Ok(()); } findings.sort_by_key(|f| std::cmp::Reverse(f.severity)); - println!("=== propdetect report ({} properties scanned) ===\n", all_props.len()); + println!( + "=== propdetect report ({} properties scanned) ===\n", + all_props.len() + ); - let crit = findings.iter().filter(|f| f.severity == heuristics::Severity::Critical).count(); - let warn = findings.iter().filter(|f| f.severity == heuristics::Severity::Warn).count(); - let info = findings.iter().filter(|f| f.severity == heuristics::Severity::Info).count(); + let crit = findings + .iter() + .filter(|f| f.severity == heuristics::Severity::Critical) + .count(); + let warn = findings + .iter() + .filter(|f| f.severity == heuristics::Severity::Warn) + .count(); + let info = findings + .iter() + .filter(|f| f.severity == heuristics::Severity::Info) + .count(); for f in &findings { println!("[{}] {}: {}", f.severity, f.check, f.detail); @@ -119,8 +134,10 @@ fn run_diff(before: &Path, after: &Path) -> Result<(), String> { return Ok(()); } - println!("=== property diff ({} -> {} properties) ===\n", - snap_before.total_count, snap_after.total_count); + println!( + "=== property diff ({} -> {} properties) ===\n", + snap_before.total_count, snap_after.total_count + ); for e in &entries { match &e.kind { @@ -140,12 +157,26 @@ fn run_diff(before: &Path, after: &Path) -> Result<(), String> { } println!(); - let added = entries.iter().filter(|e| matches!(e.kind, snapshot::DiffKind::Added { .. })).count(); - let removed = entries.iter().filter(|e| matches!(e.kind, snapshot::DiffKind::Removed { .. })).count(); - let changed = entries.iter().filter(|e| matches!(e.kind, snapshot::DiffKind::Changed { .. })).count(); - let serial = entries.iter().filter(|e| matches!(e.kind, snapshot::DiffKind::SerialChanged { .. })).count(); - - println!("summary: +{added} added, -{removed} removed, ~{changed} changed, s{serial} serial-only"); + let added = entries + .iter() + .filter(|e| matches!(e.kind, snapshot::DiffKind::Added { .. })) + .count(); + let removed = entries + .iter() + .filter(|e| matches!(e.kind, snapshot::DiffKind::Removed { .. })) + .count(); + let changed = entries + .iter() + .filter(|e| matches!(e.kind, snapshot::DiffKind::Changed { .. })) + .count(); + let serial = entries + .iter() + .filter(|e| matches!(e.kind, snapshot::DiffKind::SerialChanged { .. })) + .count(); + + println!( + "summary: +{added} added, -{removed} removed, ~{changed} changed, s{serial} serial-only" + ); Ok(()) } diff --git a/crates/propdetect/src/snapshot.rs b/crates/propdetect/src/snapshot.rs index 2f75d27..2b65766 100644 --- a/crates/propdetect/src/snapshot.rs +++ b/crates/propdetect/src/snapshot.rs @@ -48,14 +48,13 @@ pub fn capture(sys: &PropSystem) -> Snapshot { } pub fn save(snapshot: &Snapshot, path: &Path) -> Result<(), String> { - let json = serde_json::to_string_pretty(snapshot) - .map_err(|e| format!("serialize: {e}"))?; + let json = serde_json::to_string_pretty(snapshot).map_err(|e| format!("serialize: {e}"))?; std::fs::write(path, json).map_err(|e| format!("write {}: {e}", path.display())) } pub fn load(path: &Path) -> Result { - let data = std::fs::read_to_string(path) - .map_err(|e| format!("read {}: {e}", path.display()))?; + let data = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; serde_json::from_str(&data).map_err(|e| format!("parse {}: {e}", path.display())) } diff --git a/crates/propdetect/tests/detect_manipulation.rs b/crates/propdetect/tests/detect_manipulation.rs index 0df33de..d978fcf 100644 --- a/crates/propdetect/tests/detect_manipulation.rs +++ b/crates/propdetect/tests/detect_manipulation.rs @@ -68,17 +68,26 @@ fn detect_hexpatch_delete() { let coherence_findings = propdetect_heuristics::check_name_coherence(&areas); println!("\n=== HEXPATCH DETECTION RESULTS ==="); - for f in name_findings.iter().chain(&value_findings).chain(&serial_findings).chain(&coherence_findings) { + for f in name_findings + .iter() + .chain(&value_findings) + .chain(&serial_findings) + .chain(&coherence_findings) + { println!("[{}] {}: {}", f.severity, f.check, f.detail); } // value_anomaly should catch the stealth "0" values - assert!(!value_findings.is_empty(), - "value_anomaly should flag hexpatch stealth values"); + assert!( + !value_findings.is_empty(), + "value_anomaly should flag hexpatch stealth values" + ); // name_coherence should show NO mismatches (hexpatch renames both trie + prop_info) - assert!(coherence_findings.is_empty(), - "hexpatch should maintain trie/prop_info name consistency"); + assert!( + coherence_findings.is_empty(), + "hexpatch should maintain trie/prop_info name consistency" + ); // the original names must be gone assert!(area.get("ro.lineage.version").is_none()); @@ -96,7 +105,11 @@ fn detect_plain_delete() { area.delete("ro.custom.romname").unwrap(); let after_count = count_props(&area); - assert_eq!(before_count - 2, after_count, "plain delete removes from enumeration"); + assert_eq!( + before_count - 2, + after_count, + "plain delete removes from enumeration" + ); let areas = areas_from(&path); @@ -109,7 +122,8 @@ fn detect_plain_delete() { } // trie_structure should detect orphan nodes left behind by delete - let orphan_hits: Vec<_> = trie_findings.iter() + let orphan_hits: Vec<_> = trie_findings + .iter() .filter(|f| f.detail.contains("orphan")) .collect(); println!("orphan findings: {}", orphan_hits.len()); @@ -161,13 +175,19 @@ fn snapshot_diff_catches_hexpatch() { } // hexpatch: original name disappears, new name appears - assert!(removed.contains(&"ro.lineage.version".to_string()), - "original prop name should show as removed in diff"); - assert_eq!(added.len(), 1, - "exactly one new prop name should appear (the renamed one)"); + assert!( + removed.contains(&"ro.lineage.version".to_string()), + "original prop name should show as removed in diff" + ); + assert_eq!( + added.len(), + 1, + "exactly one new prop name should appear (the renamed one)" + ); // the added prop should have value "0" - let added_entry = diffs.iter() + let added_entry = diffs + .iter() .find(|d| matches!(d.kind, propdetect_snapshot::DiffKind::Added { .. })) .unwrap(); if let propdetect_snapshot::DiffKind::Added { value } = &added_entry.kind { @@ -175,8 +195,10 @@ fn snapshot_diff_catches_hexpatch() { } // total count should be unchanged - assert_eq!(snap_before.total_count, snap_after.total_count, - "hexpatch must not change total property count"); + assert_eq!( + snap_before.total_count, snap_after.total_count, + "hexpatch must not change total property count" + ); } #[test] @@ -202,22 +224,29 @@ fn snapshot_diff_catches_plain_delete() { println!("\n=== SNAPSHOT DIFF (PLAIN DELETE) ==="); for d in &diffs { match &d.kind { - propdetect_snapshot::DiffKind::Removed { value } => println!("- [{}] = {value}", d.name), + propdetect_snapshot::DiffKind::Removed { value } => { + println!("- [{}] = {value}", d.name) + } propdetect_snapshot::DiffKind::Added { value } => println!("+ [{}] = {value}", d.name), _ => {} } } - let removed: Vec<_> = diffs.iter() + let removed: Vec<_> = diffs + .iter() .filter(|d| matches!(d.kind, propdetect_snapshot::DiffKind::Removed { .. })) .map(|d| d.name.clone()) .collect(); - let added: Vec<_> = diffs.iter() + let added: Vec<_> = diffs + .iter() .filter(|d| matches!(d.kind, propdetect_snapshot::DiffKind::Added { .. })) .collect(); assert!(removed.contains(&"ro.lineage.version".to_string())); - assert!(added.is_empty(), "plain delete should not add any properties"); + assert!( + added.is_empty(), + "plain delete should not add any properties" + ); assert_eq!(snap_after.total_count, snap_before.total_count - 1); } @@ -234,11 +263,15 @@ fn delete_compact_leaves_no_orphans() { let areas = areas_from(&path); let trie_findings = propdetect_heuristics::check_trie_structure(&areas); - let orphan_hits: Vec<_> = trie_findings.iter() + let orphan_hits: Vec<_> = trie_findings + .iter() .filter(|f| f.detail.contains("orphan")) .collect(); - assert!(orphan_hits.is_empty(), - "found {} orphan findings after delete+compact", orphan_hits.len()); + assert!( + orphan_hits.is_empty(), + "found {} orphan findings after delete+compact", + orphan_hits.len() + ); } fn count_props(area: &PropArea) -> usize { diff --git a/crates/resetprop-cli/src/main.rs b/crates/resetprop-cli/src/main.rs index 1f9bf00..6f83def 100644 --- a/crates/resetprop-cli/src/main.rs +++ b/crates/resetprop-cli/src/main.rs @@ -107,7 +107,10 @@ fn run() -> Result<(), String> { "--timeout" => { i += 1; let s = arg_val(&args, i, "--timeout")?; - timeout_secs = Some(s.parse::().map_err(|_| "--timeout requires a number".to_string())?); + timeout_secs = Some( + s.parse::() + .map_err(|_| "--timeout requires a number".to_string())?, + ); } "-h" | "--help" => { print_usage(); @@ -139,9 +142,7 @@ fn run() -> Result<(), String> { return Err("--if-diff and --if-match are mutually exclusive".to_string()); } if conditional_set && conditional_delete { - return Err( - "--if-diff/--if-match cannot be combined with --delete-if-exist".to_string(), - ); + return Err("--if-diff/--if-match cannot be combined with --delete-if-exist".to_string()); } if any_conditional && any_top_level_op { return Err( @@ -274,11 +275,11 @@ fn run() -> Result<(), String> { } Ok(()) } - Err(e @ (Error::HookInstallFailed(_) | Error::ElfParse(_) | Error::SymbolNotFound(_))) => { - Err(format!( - "Tier B hook install failed: {e}. Try --seal-arena for Tier A fallback." - )) - } + Err( + e @ (Error::HookInstallFailed(_) | Error::ElfParse(_) | Error::SymbolNotFound(_)), + ) => Err(format!( + "Tier B hook install failed: {e}. Try --seal-arena for Tier A fallback." + )), Err(e) => Err(format!("seal failed: {e}")), }; } @@ -417,8 +418,7 @@ fn persist_read_op(positional: &[String]) -> Result<(), String> { } fn load_file(sys: &PropSystem, path: &str, init: bool, verbose: bool) -> Result<(), String> { - let content = - std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?; + let content = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?; let mut count = 0u32; for line in content.lines() { @@ -439,7 +439,10 @@ fn load_file(sys: &PropSystem, path: &str, init: bool, verbose: bool) -> Result< .map_err(|e| format!("failed to set {name}: {e}"))?; count += 1; if verbose { - eprintln!("set{}: [{name}]=[{value}]", if init { "(init)" } else { "" }); + eprintln!( + "set{}: [{name}]=[{value}]", + if init { "(init)" } else { "" } + ); } } diff --git a/crates/resetprop/src/area.rs b/crates/resetprop/src/area.rs index 7d2cc77..e57a00a 100644 --- a/crates/resetprop/src/area.rs +++ b/crates/resetprop/src/area.rs @@ -38,10 +38,18 @@ impl PropArea { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; - let c_path = CString::new(path.as_os_str().as_bytes()) - .map_err(|_| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid path")))?; - - let flags = if writable { libc::O_RDWR } else { libc::O_RDONLY }; + let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid path", + )) + })?; + + let flags = if writable { + libc::O_RDWR + } else { + libc::O_RDONLY + }; let fd = unsafe { libc::open(c_path.as_ptr(), flags | libc::O_NOFOLLOW) }; if fd < 0 { return Err(std::io::Error::last_os_error().into()); @@ -57,7 +65,9 @@ impl PropArea { let file_size = stat.st_size as usize; if file_size < HEADER_SIZE { unsafe { libc::close(fd) }; - return Err(Error::AreaCorrupt(format!("file too small: {file_size} bytes"))); + return Err(Error::AreaCorrupt(format!( + "file too small: {file_size} bytes" + ))); } let prot = if writable { @@ -67,7 +77,14 @@ impl PropArea { }; let ptr = unsafe { - libc::mmap(std::ptr::null_mut(), file_size, prot, libc::MAP_SHARED, fd, 0) + libc::mmap( + std::ptr::null_mut(), + file_size, + prot, + libc::MAP_SHARED, + fd, + 0, + ) }; unsafe { libc::close(fd) }; @@ -143,7 +160,10 @@ impl PropArea { pub(crate) fn atomic_u32(&self, offset: usize) -> &AtomicU32 { assert!(offset + 4 <= self.len); - assert!(offset.is_multiple_of(4), "AtomicU32 requires 4-byte alignment, got offset {offset}"); + assert!( + offset.is_multiple_of(4), + "AtomicU32 requires 4-byte alignment, got offset {offset}" + ); unsafe { AtomicU32::from_ptr(self.base.add(offset) as *mut u32) } } @@ -217,7 +237,12 @@ impl PropArea { return Err(Error::AreaFull); } if bu - .compare_exchange_weak(current, current + aligned as u32, Ordering::AcqRel, Ordering::Acquire) + .compare_exchange_weak( + current, + current + aligned as u32, + Ordering::AcqRel, + Ordering::Acquire, + ) .is_ok() { return Ok(HEADER_SIZE + current as usize); @@ -231,8 +256,12 @@ impl PropArea { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; - let c_path = CString::new(path.as_os_str().as_bytes()) - .map_err(|_| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid path")))?; + let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid path", + )) + })?; let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_NOFOLLOW) }; if fd < 0 { diff --git a/crates/resetprop/src/bionic.rs b/crates/resetprop/src/bionic.rs index 066caec..1b90b59 100644 --- a/crates/resetprop/src/bionic.rs +++ b/crates/resetprop/src/bionic.rs @@ -14,8 +14,7 @@ mod inner { Option, *mut c_void, ) -> c_int; - type WaitFn = - unsafe extern "C" fn(*const c_void, u32, *mut u32, *const libc::timespec) -> bool; + type WaitFn = unsafe extern "C" fn(*const c_void, u32, *mut u32, *const libc::timespec) -> bool; struct BionicFns { find: FindFn, @@ -140,17 +139,17 @@ mod inner { ctx.results } - pub(crate) fn wait_prop( - name: &str, - old_serial: u32, - timeout: Option, - ) -> Option { + pub(crate) fn wait_prop(name: &str, old_serial: u32, timeout: Option) -> Option { let fns = fns()?; let wait = fns.wait?; let pi = CString::new(name).ok().and_then(|cname| unsafe { let p = (fns.find)(cname.as_ptr()); - if p.is_null() { None } else { Some(p) } + if p.is_null() { + None + } else { + Some(p) + } }); // null pi = wait on global serial (prop doesn't exist yet) @@ -168,7 +167,11 @@ mod inner { let mut new_serial: u32 = 0; let ok = unsafe { (wait)(pi_ptr, old_serial, &mut new_serial, ts_ptr) }; - if ok { Some(new_serial) } else { None } + if ok { + Some(new_serial) + } else { + None + } } } diff --git a/crates/resetprop/src/compact.rs b/crates/resetprop/src/compact.rs index ae87f0a..dda52b1 100644 --- a/crates/resetprop/src/compact.rs +++ b/crates/resetprop/src/compact.rs @@ -28,7 +28,13 @@ pub(crate) fn compact(area: &PropArea) -> Result { }); } - collect(area, area.data_offset(), &mut allocs, &mut trie_offsets, &mut long_props)?; + collect( + area, + area.data_offset(), + &mut allocs, + &mut trie_offsets, + &mut long_props, + )?; allocs.sort_by_key(|a| a.offset); allocs.dedup_by_key(|a| a.offset); @@ -64,7 +70,9 @@ pub(crate) fn compact(area: &PropArea) -> Result { let old_end = area.data_offset() + area.bytes_used().load(AO::Acquire) as usize; if cursor < old_end { - unsafe { std::ptr::write_bytes(area.base().add(cursor), 0, old_end - cursor); } + unsafe { + std::ptr::write_bytes(area.base().add(cursor), 0, old_end - cursor); + } } let new_used = (cursor - area.data_offset()) as u32; @@ -88,7 +96,10 @@ fn collect( (TRIE_HEADER_SIZE + namelen + 1 + 3) & !3 }; - allocs.push(LiveAlloc { offset: node_abs, size: node_size }); + allocs.push(LiveAlloc { + offset: node_abs, + size: node_size, + }); trie_offsets.push(node_abs); let prop_rel = node.prop_offset().load(AO::Acquire); @@ -96,7 +107,10 @@ fn collect( let pi_abs = area.data_offset() + prop_rel as usize; let name_len = strlen_at(area, pi_abs + PROP_INFO_FIXED); let pi_total = (PROP_INFO_FIXED + name_len + 1 + 3) & !3; - allocs.push(LiveAlloc { offset: pi_abs, size: pi_total }); + allocs.push(LiveAlloc { + offset: pi_abs, + size: pi_total, + }); let serial = area.read_u32(pi_abs); if serial & LONG_FLAG != 0 { @@ -104,24 +118,45 @@ fn collect( let long_abs = pi_abs + rel; let val_len = strlen_at(area, long_abs); let aligned = (val_len + 1 + 3) & !3; - allocs.push(LiveAlloc { offset: long_abs, size: aligned }); + allocs.push(LiveAlloc { + offset: long_abs, + size: aligned, + }); long_props.push((pi_abs, long_abs)); } } let left_rel = node.left().load(AO::Acquire); if left_rel != 0 { - collect(area, area.data_offset() + left_rel as usize, allocs, trie_offsets, long_props)?; + collect( + area, + area.data_offset() + left_rel as usize, + allocs, + trie_offsets, + long_props, + )?; } let children_rel = node.children().load(AO::Acquire); if children_rel != 0 { - collect(area, area.data_offset() + children_rel as usize, allocs, trie_offsets, long_props)?; + collect( + area, + area.data_offset() + children_rel as usize, + allocs, + trie_offsets, + long_props, + )?; } let right_rel = node.right().load(AO::Acquire); if right_rel != 0 { - collect(area, area.data_offset() + right_rel as usize, allocs, trie_offsets, long_props)?; + collect( + area, + area.data_offset() + right_rel as usize, + allocs, + trie_offsets, + long_props, + )?; } Ok(()) @@ -163,7 +198,12 @@ fn patch_trie_pointers(area: &PropArea, trie_offsets: &[usize], remap: &HashMap< Err(_) => continue, }; - for field in [node.prop_offset(), node.left(), node.right(), node.children()] { + for field in [ + node.prop_offset(), + node.left(), + node.right(), + node.children(), + ] { let old_rel = field.load(AO::Acquire); if old_rel == 0 { continue; @@ -176,7 +216,11 @@ fn patch_trie_pointers(area: &PropArea, trie_offsets: &[usize], remap: &HashMap< } } -fn patch_long_values(area: &PropArea, long_props: &[(usize, usize)], remap: &HashMap) { +fn patch_long_values( + area: &PropArea, + long_props: &[(usize, usize)], + remap: &HashMap, +) { for &(old_pi, old_lv) in long_props { let new_pi = match remap.get(&old_pi) { Some(&v) => v, @@ -187,7 +231,8 @@ fn patch_long_values(area: &PropArea, long_props: &[(usize, usize)], remap: &Has None => continue, }; let new_rel = (new_lv - new_pi) as u32; - area.atomic_u32(new_pi + 4 + LONG_PROP_ERROR_SIZE).store(new_rel, AO::Release); + area.atomic_u32(new_pi + 4 + LONG_PROP_ERROR_SIZE) + .store(new_rel, AO::Release); } } @@ -201,7 +246,8 @@ fn has_dirty_backup(area: &PropArea) -> bool { return false; } if children == 0 { - return area.bytes_used().load(AO::Acquire) as usize == TRIE_HEADER_SIZE + DIRTY_BACKUP_SIZE; + return area.bytes_used().load(AO::Acquire) as usize + == TRIE_HEADER_SIZE + DIRTY_BACKUP_SIZE; } true } diff --git a/crates/resetprop/src/context.rs b/crates/resetprop/src/context.rs index 455b32c..b4323d1 100644 --- a/crates/resetprop/src/context.rs +++ b/crates/resetprop/src/context.rs @@ -140,7 +140,12 @@ fn child_name(data: &[u8], child_node_off: usize) -> Option<&str> { entry_name(data, &entry) } -fn resolve_binary<'a>(data: &'a [u8], contexts_off: usize, root_off: usize, name: &str) -> Option<&'a str> { +fn resolve_binary<'a>( + data: &'a [u8], + contexts_off: usize, + root_off: usize, + name: &str, +) -> Option<&'a str> { let mut best: Option = None; let mut node_off = root_off; let mut remaining = name; @@ -499,22 +504,22 @@ mod tests { // "build" node (leaf with exact_match) let build_node_off = b.pos(); b.write_u32(entry_build_off); // property_entry - b.write_u32(0); // num_child_nodes - b.write_u32(0); // child_nodes - b.write_u32(0); // num_prefixes - b.write_u32(0); // prefix_entries - b.write_u32(1); // num_exact_matches + b.write_u32(0); // num_child_nodes + b.write_u32(0); // child_nodes + b.write_u32(0); // num_prefixes + b.write_u32(0); // prefix_entries + b.write_u32(1); // num_exact_matches b.write_u32(exact_array_off); // exact_match_entries // "ro" node let ro_node_off = b.pos(); - b.write_u32(entry_ro_off); // property_entry - b.write_u32(1); // num_child_nodes - b.write_u32(ro_children_array_off); // child_nodes - b.write_u32(0); // num_prefixes - b.write_u32(0); // prefix_entries - b.write_u32(0); // num_exact_matches - b.write_u32(0); // exact_match_entries + b.write_u32(entry_ro_off); // property_entry + b.write_u32(1); // num_child_nodes + b.write_u32(ro_children_array_off); // child_nodes + b.write_u32(0); // num_prefixes + b.write_u32(0); // prefix_entries + b.write_u32(0); // num_exact_matches + b.write_u32(0); // exact_match_entries // "persist" node let persist_node_off = b.pos(); @@ -528,13 +533,13 @@ mod tests { // root node let root_off = b.pos(); - b.write_u32(0); // property_entry (none) - b.write_u32(2); // num_child_nodes - b.write_u32(root_children_array_off); // child_nodes - b.write_u32(0); // num_prefixes - b.write_u32(0); // prefix_entries - b.write_u32(0); // num_exact_matches - b.write_u32(0); // exact_match_entries + b.write_u32(0); // property_entry (none) + b.write_u32(2); // num_child_nodes + b.write_u32(root_children_array_off); // child_nodes + b.write_u32(0); // num_prefixes + b.write_u32(0); // prefix_entries + b.write_u32(0); // num_exact_matches + b.write_u32(0); // exact_match_entries // --- Patch child_nodes arrays with actual offsets --- // Root children: sorted by name. "persist" < "ro" alphabetically @@ -597,7 +602,12 @@ mod tests { fn binary_resolve_persist() { let data = build_test_blob(); let header = parse_header(&data).unwrap(); - let result = resolve_binary(&data, header.contexts_off, header.root_off, "persist.sys.timezone"); + let result = resolve_binary( + &data, + header.contexts_off, + header.root_off, + "persist.sys.timezone", + ); assert_eq!(result, Some("u:object_r:persist_prop:s0")); } @@ -631,7 +641,10 @@ mod tests { // Already sorted longest first assert_eq!(resolve_text(&entries, "ro.build.type"), Some("build_ctx")); assert_eq!(resolve_text(&entries, "ro.debuggable"), Some("ro_ctx")); - assert_eq!(resolve_text(&entries, "persist.sys.tz"), Some("persist_ctx")); + assert_eq!( + resolve_text(&entries, "persist.sys.tz"), + Some("persist_ctx") + ); assert_eq!(resolve_text(&entries, "dalvik.vm.heapsize"), None); } diff --git a/crates/resetprop/src/dict.rs b/crates/resetprop/src/dict.rs index 4dc8925..3761bb1 100644 --- a/crates/resetprop/src/dict.rs +++ b/crates/resetprop/src/dict.rs @@ -1,17 +1,58 @@ use std::collections::HashSet; const WORDS_1: &[&[u8]] = &[b"v", b"p", b"a", b"d", b"f", b"m", b"r", b"s", b"t", b"n"]; -const WORDS_2: &[&[u8]] = &[b"bt", b"hw", b"fm", b"ip", b"nv", b"tp", b"sf", b"wl", b"qc", b"pm"]; -const WORDS_3: &[&[u8]] = &[b"sys", b"usb", b"nfc", b"gpu", b"dpi", b"fps", b"vhw", b"cfg", b"dbg"]; -const WORDS_4: &[&[u8]] = &[b"wifi", b"core", b"boot", b"init", b"hdmi", b"dram", b"emmc", b"uart", b"mipi"]; -const WORDS_5: &[&[u8]] = &[b"audio", b"codec", b"power", b"touch", b"panel", b"radio", b"hapti", b"vibra", b"clock"]; -const WORDS_6: &[&[u8]] = &[b"sensor", b"camera", b"modem_", b"memory", b"dalvik", b"vendor", b"kernel", b"source"]; -const WORDS_7: &[&[u8]] = &[b"thermal", b"display", b"network", b"storage", b"battery", b"surface", b"charger", b"encoder"]; -const WORDS_8: &[&[u8]] = &[b"graphics", b"charging", b"firmware", b"recorder", b"platform", b"hardware"]; -const WORDS_9: &[&[u8]] = &[b"telephony", b"accessory", b"proximity", b"touchpads", b"mediacodec"]; -const WORDS_10: &[&[u8]] = &[b"controller", b"configured", b"peripheral", b"background", b"thumbprint"]; -const WORDS_11: &[&[u8]] = &[b"performance", b"temperature", b"sensorhub_t", b"bootanimati"]; -const WORDS_12: &[&[u8]] = &[b"provisioning", b"acceleration", b"notification", b"intermediary"]; +const WORDS_2: &[&[u8]] = &[ + b"bt", b"hw", b"fm", b"ip", b"nv", b"tp", b"sf", b"wl", b"qc", b"pm", +]; +const WORDS_3: &[&[u8]] = &[ + b"sys", b"usb", b"nfc", b"gpu", b"dpi", b"fps", b"vhw", b"cfg", b"dbg", +]; +const WORDS_4: &[&[u8]] = &[ + b"wifi", b"core", b"boot", b"init", b"hdmi", b"dram", b"emmc", b"uart", b"mipi", +]; +const WORDS_5: &[&[u8]] = &[ + b"audio", b"codec", b"power", b"touch", b"panel", b"radio", b"hapti", b"vibra", b"clock", +]; +const WORDS_6: &[&[u8]] = &[ + b"sensor", b"camera", b"modem_", b"memory", b"dalvik", b"vendor", b"kernel", b"source", +]; +const WORDS_7: &[&[u8]] = &[ + b"thermal", b"display", b"network", b"storage", b"battery", b"surface", b"charger", b"encoder", +]; +const WORDS_8: &[&[u8]] = &[ + b"graphics", + b"charging", + b"firmware", + b"recorder", + b"platform", + b"hardware", +]; +const WORDS_9: &[&[u8]] = &[ + b"telephony", + b"accessory", + b"proximity", + b"touchpads", + b"mediacodec", +]; +const WORDS_10: &[&[u8]] = &[ + b"controller", + b"configured", + b"peripheral", + b"background", + b"thumbprint", +]; +const WORDS_11: &[&[u8]] = &[ + b"performance", + b"temperature", + b"sensorhub_t", + b"bootanimati", +]; +const WORDS_12: &[&[u8]] = &[ + b"provisioning", + b"acceleration", + b"notification", + b"intermediary", +]; const WORDS_13: &[&[u8]] = &[b"configuration", b"communication", b"surfaceflinge"]; const WORDS_14: &[&[u8]] = &[b"servicemanager", b"authentication", b"implementation"]; const WORDS_15: &[&[u8]] = &[b"hwservicemanag", b"troubleshooting"]; diff --git a/crates/resetprop/src/error.rs b/crates/resetprop/src/error.rs index 42ce3d6..24659e5 100644 --- a/crates/resetprop/src/error.rs +++ b/crates/resetprop/src/error.rs @@ -9,14 +9,18 @@ pub enum Error { PermissionDenied(std::io::Error), AreaFull, Io(std::io::Error), - ValueTooLong { len: usize }, + ValueTooLong { + len: usize, + }, InvalidKey, PersistCorrupt(String), PtraceAttach(std::io::Error), PtraceOp(std::io::Error), PtraceUnexpectedStatus(i32), PtraceScope, - PtraceTracerBusy { tracer_pid: libc::pid_t }, + PtraceTracerBusy { + tracer_pid: libc::pid_t, + }, ArenaAlreadySealed(PathBuf), ArenaNotMapped(PathBuf), ElfParse(String), diff --git a/crates/resetprop/src/harvest.rs b/crates/resetprop/src/harvest.rs index 75c45cc..a271ab8 100644 --- a/crates/resetprop/src/harvest.rs +++ b/crates/resetprop/src/harvest.rs @@ -6,9 +6,8 @@ use crate::area::PropArea; use crate::dict; const STEMS: &[&[u8]] = &[ - b"hw", b"sv", b"fm", b"nv", b"tp", b"bt", b"qc", b"sf", - b"cfg", b"drv", b"hal", b"dev", b"arm", b"log", b"dsp", - b"svc", b"v8a", b"gpu", b"adb", b"vhw", b"mmc", b"usb", + b"hw", b"sv", b"fm", b"nv", b"tp", b"bt", b"qc", b"sf", b"cfg", b"drv", b"hal", b"dev", b"arm", + b"log", b"dsp", b"svc", b"v8a", b"gpu", b"adb", b"vhw", b"mmc", b"usb", ]; fn rand_index(n: usize) -> usize { @@ -50,11 +49,7 @@ impl SegmentPool { } } -pub(crate) fn replacement( - original: &[u8], - used: &HashSet>, - pool: &SegmentPool, -) -> Vec { +pub(crate) fn replacement(original: &[u8], used: &HashSet>, pool: &SegmentPool) -> Vec { let len = original.len(); if let Some(w) = pool.pick(len, used) { @@ -134,7 +129,9 @@ pub(crate) fn generate_name(area: &PropArea, exclude: &HashSet) -> Strin let fallbacks: &[&str] = &["sys.", "vendor."]; let prefixes: Vec<&str> = match best { - Some(p) => std::iter::once(p).chain(fallbacks.iter().copied()).collect(), + Some(p) => std::iter::once(p) + .chain(fallbacks.iter().copied()) + .collect(), None => fallbacks.to_vec(), }; diff --git a/crates/resetprop/src/info.rs b/crates/resetprop/src/info.rs index 6f1838f..0b01f31 100644 --- a/crates/resetprop/src/info.rs +++ b/crates/resetprop/src/info.rs @@ -230,7 +230,8 @@ impl<'a> PropInfo<'a> { *ptr.add(value.len()) = 0; } - let new_serial = ((serial + 2) & 0x00FFFFFF) | LONG_FLAG | ((value.len() as u32 & 0xFF) << 24); + let new_serial = + ((serial + 2) & 0x00FFFFFF) | LONG_FLAG | ((value.len() as u32 & 0xFF) << 24); std::sync::atomic::fence(Ordering::Release); sa.store(new_serial, Ordering::Release); self.area.futex_wake(self.offset); diff --git a/crates/resetprop/src/inspect.rs b/crates/resetprop/src/inspect.rs index 83251da..5bbf608 100644 --- a/crates/resetprop/src/inspect.rs +++ b/crates/resetprop/src/inspect.rs @@ -35,7 +35,11 @@ impl PropArea { } let value = pi.read_value(); let serial = pi.serial_raw(); - entries.push(PropEntry { name, value, serial }); + entries.push(PropEntry { + name, + value, + serial, + }); } }); entries @@ -46,7 +50,12 @@ impl PropArea { let root = TrieNode::root(self); let children = root.children().load(Ordering::Acquire); if children != 0 { - walk_bst(self, self.data_offset() + children as usize, "", &mut entries); + walk_bst( + self, + self.data_offset() + children as usize, + "", + &mut entries, + ); } entries } diff --git a/crates/resetprop/src/lib.rs b/crates/resetprop/src/lib.rs index 1f446e5..2ffbb60 100644 --- a/crates/resetprop/src/lib.rs +++ b/crates/resetprop/src/lib.rs @@ -18,25 +18,25 @@ //! Use [`PropArea`] for single-file, low-level access. //! Use [`PersistStore`] for the on-disk persistent property store. -mod error; +mod appcompat; mod area; -mod trie; -mod info; -mod dict; -mod harvest; +mod bionic; mod compact; mod context; -mod bionic; -mod persist; -mod appcompat; -mod wait; -pub mod seal; +mod dict; +mod error; +mod harvest; +mod info; pub mod inspect; #[cfg(test)] mod mock; +mod persist; +pub mod seal; +mod trie; +mod wait; -pub use error::{Error, Result}; pub use area::PropArea; +pub use error::{Error, Result}; pub use persist::{PersistStore, Record}; pub use seal::{SealRecord, SealTier}; @@ -196,7 +196,8 @@ impl PropArea { None => { let pi_offset = info::alloc_prop_info(self, name, value)?; let relative = (pi_offset - self.data_offset()) as u32; - self.atomic_u32(node_off + 4).store(relative, Ordering::Release); + self.atomic_u32(node_off + 4) + .store(relative, Ordering::Release); return Ok(()); } } @@ -304,7 +305,8 @@ impl PropArea { return false; } - if let Ok(child) = trie::TrieNode::from_offset(self, self.data_offset() + children as usize) { + if let Ok(child) = trie::TrieNode::from_offset(self, self.data_offset() + children as usize) + { let left = child.left().load(Ordering::Relaxed); let right = child.right().load(Ordering::Relaxed); left != 0 || right != 0 @@ -564,13 +566,17 @@ impl PropSystem { pub fn set(&self, name: &str, value: &str) -> Result<()> { if let Some((idx, area)) = self.find_area(name) { area.set(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set(name, value); + }); self.notify(); return Ok(()); } if let Some((idx, area)) = self.find_writable(name) { area.set(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set(name, value); + }); self.notify(); return Ok(()); } @@ -583,13 +589,17 @@ impl PropSystem { pub fn set_init(&self, name: &str, value: &str) -> Result<()> { if let Some((idx, area)) = self.find_area(name) { area.set_init(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_init(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_init(name, value); + }); self.notify(); return Ok(()); } if let Some((idx, area)) = self.find_writable(name) { area.set_init(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_init(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_init(name, value); + }); self.notify(); return Ok(()); } @@ -602,12 +612,16 @@ impl PropSystem { pub fn set_stealth(&self, name: &str, value: &str) -> Result<()> { if let Some((idx, area)) = self.find_area(name) { area.set_stealth(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_stealth(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_stealth(name, value); + }); return Ok(()); } if let Some((idx, area)) = self.find_writable(name) { area.set_stealth(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_stealth(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_stealth(name, value); + }); return Ok(()); } Err(Error::PermissionDenied(std::io::Error::new( @@ -619,12 +633,16 @@ impl PropSystem { pub fn set_quiet(&self, name: &str, value: &str) -> Result<()> { if let Some((idx, area)) = self.find_area(name) { area.set_quiet(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_quiet(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_quiet(name, value); + }); return Ok(()); } if let Some((idx, area)) = self.find_writable(name) { area.set_quiet(name, value)?; - self.appcompat_write(idx, |m| { let _ = m.set_quiet(name, value); }); + self.appcompat_write(idx, |m| { + let _ = m.set_quiet(name, value); + }); return Ok(()); } Err(Error::PermissionDenied(std::io::Error::new( @@ -637,7 +655,9 @@ impl PropSystem { if let Some((idx, area)) = self.find_area(name) { let deleted = area.delete(name)?; if deleted { - self.appcompat_write(idx, |m| { let _ = m.delete(name); }); + self.appcompat_write(idx, |m| { + let _ = m.delete(name); + }); self.notify(); } return Ok(deleted); @@ -728,11 +748,7 @@ impl PropSystem { self.seal_write(name, value)?; let mirror_path = self.derive_mirror_path(&primary_path, filename); - seal::arena::seal_arena_with_mirror( - seal::INIT_PID, - &primary_path, - mirror_path.as_deref(), - )?; + seal::arena::seal_arena_with_mirror(seal::INIT_PID, &primary_path, mirror_path.as_deref())?; let record = SealRecord { name: name.to_string(), diff --git a/crates/resetprop/src/mock.rs b/crates/resetprop/src/mock.rs index 66f7d96..bcf6a40 100644 --- a/crates/resetprop/src/mock.rs +++ b/crates/resetprop/src/mock.rs @@ -287,8 +287,16 @@ mod tests { let word = pick.unwrap(); assert_eq!(word.len(), 7); // should be one of the 7-char segments from our area: "thermal", "display", "monitor", "config" (6 != 7) - let valid = [b"thermal".to_vec(), b"display".to_vec(), b"monitor".to_vec()]; - assert!(valid.contains(&word), "unexpected pick: {:?}", String::from_utf8_lossy(&word)); + let valid = [ + b"thermal".to_vec(), + b"display".to_vec(), + b"monitor".to_vec(), + ]; + assert!( + valid.contains(&word), + "unexpected pick: {:?}", + String::from_utf8_lossy(&word) + ); } #[test] @@ -337,7 +345,10 @@ mod tests { area.foreach(|_, _| c += 1); c }; - assert_eq!(before_count, after_count, "prop count changed after hexpatch"); + assert_eq!( + before_count, after_count, + "prop count changed after hexpatch" + ); } #[test] @@ -390,7 +401,8 @@ mod tests { let area = mock.open(); area.set("ro.customromverylongsegment.x", "v").unwrap(); - area.hexpatch_delete("ro.customromverylongsegment.x").unwrap(); + area.hexpatch_delete("ro.customromverylongsegment.x") + .unwrap(); let mut props = Vec::new(); area.foreach(|n, _| props.push(n.to_string())); @@ -421,10 +433,15 @@ mod tests { area.delete("a.b.c").unwrap(); let nodes = area.inspect_trie(); - let orphans: Vec<_> = nodes.iter() + let orphans: Vec<_> = nodes + .iter() .filter(|n| n.prop_offset == 0 && !n.has_children) .collect(); - assert!(orphans.is_empty(), "found {} orphan leaves after prune", orphans.len()); + assert!( + orphans.is_empty(), + "found {} orphan leaves after prune", + orphans.len() + ); } #[test] @@ -457,8 +474,10 @@ mod tests { area.delete("c.d").unwrap(); area.compact().unwrap(); - assert!(area.arena_stats().bytes_used < before, - "bytes_used did not decrease after compact"); + assert!( + area.arena_stats().bytes_used < before, + "bytes_used did not decrease after compact" + ); assert_eq!(area.get("a.b").unwrap(), "1"); assert_eq!(area.get("e.f").unwrap(), "3"); assert!(area.get("c.d").is_none()); @@ -470,7 +489,8 @@ mod tests { let area = mock.open(); for i in 0..20 { - area.set(&format!("test.prop{i}"), &format!("value{i}")).unwrap(); + area.set(&format!("test.prop{i}"), &format!("value{i}")) + .unwrap(); } for i in (0..20).step_by(2) { @@ -487,8 +507,10 @@ mod tests { ); } for i in (0..20).step_by(2) { - assert!(area.get(&format!("test.prop{i}")).is_none(), - "even prop {i} still present after compact"); + assert!( + area.get(&format!("test.prop{i}")).is_none(), + "even prop {i} still present after compact" + ); } let mut count = 0; @@ -529,7 +551,11 @@ mod tests { let mut seen = std::collections::HashSet::new(); for seg in &segments[1..] { assert_eq!(seg.len(), 4); - assert!(seen.insert(*seg), "duplicate segment '{}' in mangled name", seg); + assert!( + seen.insert(*seg), + "duplicate segment '{}' in mangled name", + seg + ); } assert!(area.get(&props[0]).is_some()); @@ -544,7 +570,9 @@ mod tests { // read raw serial before hexpatch via a get (serial encodes length in top byte) let (pi_off, _) = crate::trie::find(&area, "ro.serial.test").unwrap(); - let serial_before = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let serial_before = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); // counter bits (1-15, 17-23) should be 0 for a freshly created prop let counter_before = serial_before & 0x00FE_FFFE; assert_eq!(counter_before, 0, "counter non-zero before hexpatch"); @@ -558,7 +586,9 @@ mod tests { let (pi_off_after, _) = crate::trie::find(&area, &mangled).unwrap(); assert_eq!(pi_off, pi_off_after, "prop_info moved after hexpatch"); - let serial_after = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let serial_after = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let counter_after = serial_after & 0x00FE_FFFE; // Bionic compose: (((0|1)+1) & 0xFFFFFF) = 2. Counter zero would be // a propdetect leak (non-init-prefix + counter=0 + value="0"). @@ -580,9 +610,14 @@ mod tests { let area = mock.open(); let siblings = [ - "ro.build.type", "ro.build.tags", "ro.build.date", - "ro.build.host", "ro.build.user", "ro.build.keys", - "ro.lineage.version", "ro.custom.rom", + "ro.build.type", + "ro.build.tags", + "ro.build.date", + "ro.build.host", + "ro.build.user", + "ro.build.keys", + "ro.lineage.version", + "ro.custom.rom", ]; for &prop in &siblings { area.set(prop, "test").unwrap(); @@ -594,8 +629,10 @@ mod tests { // ALL ro.build.* siblings must still be accessible via trie lookup for &prop in &siblings[..6] { assert_eq!( - area.get(prop).unwrap(), "test", - "BST corrupted: {} not found after hexpatch", prop + area.get(prop).unwrap(), + "test", + "BST corrupted: {} not found after hexpatch", + prop ); } @@ -617,7 +654,9 @@ mod tests { for b in name.bytes() { assert!( b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-', - "invalid byte 0x{:02x} in mangled name '{}'", b, name, + "invalid byte 0x{:02x} in mangled name '{}'", + b, + name, ); } assert!(!name.starts_with('.')); @@ -698,8 +737,14 @@ mod tests { assert_eq!(value, "0"); let (pi_off, _) = crate::trie::find(&area, &name).unwrap(); - let serial = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); - assert_eq!(serial, 1u32 << 24, "replacement serial should be (1<<24) for value '0'"); + let serial = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); + assert_eq!( + serial, + 1u32 << 24, + "replacement serial should be (1<<24) for value '0'" + ); } #[test] @@ -708,7 +753,8 @@ mod tests { let area = mock.open(); for i in 0..10 { - area.set(&format!("test.prop.p{i}"), &format!("val{i}")).unwrap(); + area.set(&format!("test.prop.p{i}"), &format!("val{i}")) + .unwrap(); } let before = area.arena_stats().bytes_used; @@ -719,7 +765,13 @@ mod tests { let after = area.arena_stats().bytes_used; let growth = after.saturating_sub(before); - assert!(growth < 512, "bytes_used grew too much: {} -> {} (+{})", before, after, growth); + assert!( + growth < 512, + "bytes_used grew too much: {} -> {} (+{})", + before, + after, + growth + ); let mut count = 0; area.foreach(|n, _| { @@ -754,16 +806,26 @@ mod tests { area.set("ro.test.prop", "hello").unwrap(); let (pi_off, _) = crate::trie::find(&area, "ro.test.prop").unwrap(); - let serial_before = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let serial_before = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let counter_before = serial_before & 0x00FFFFFF; - assert_eq!(counter_before, 2, "counter after two writes via write_value: 0 -> 2"); + assert_eq!( + counter_before, 2, + "counter after two writes via write_value: 0 -> 2" + ); area.set_stealth("ro.test.prop", "world").unwrap(); // Bionic compose advances 2 -> 4: (((2|1)+1) & 0xFFFFFF) = 4 - let serial_after = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let serial_after = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let expected = (5u32 << 24) | 4; - assert_eq!(serial_after, expected, "stealth must bionic-bump counter, not zero it"); + assert_eq!( + serial_after, expected, + "stealth must bionic-bump counter, not zero it" + ); assert_eq!(area.get("ro.test.prop").unwrap(), "world"); } @@ -777,8 +839,14 @@ mod tests { assert_eq!(area.get("vendor.new.prop").unwrap(), "test_val"); let (pi_off, _) = crate::trie::find(&area, "vendor.new.prop").unwrap(); - let serial = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); - assert_eq!(serial, 8u32 << 24, "serial should be (8<<24) for 8-char value"); + let serial = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); + assert_eq!( + serial, + 8u32 << 24, + "serial should be (8<<24) for 8-char value" + ); } #[test] @@ -807,20 +875,33 @@ mod tests { area.set("ro.product.brand", "Pixel").unwrap(); let (pi_off, _) = crate::trie::find(&area, "ro.product.brand").unwrap(); - let before = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let before = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let counter_before = before & 0x00FF_FFFF; - assert_eq!(counter_before, 2, "two `set` calls (alloc + write_value) produce counter=2"); + assert_eq!( + counter_before, 2, + "two `set` calls (alloc + write_value) produce counter=2" + ); let count = area.normalize_serial().unwrap(); assert_eq!(count, 1); - let after = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed); + let after = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed); let counter_after = after & 0x00FF_FFFF; // init-style bump: (((2 | 1) + 1) & 0xFFFFFF) = ((3+1) & 0xFFFFFF) = 4 - assert_eq!(counter_after, 4, "normalize_serial must bionic-bump counter from 2 to 4"); + assert_eq!( + counter_after, 4, + "normalize_serial must bionic-bump counter from 2 to 4" + ); let len_byte = (after >> 24) & 0xFF; - assert_eq!(len_byte, 5, "length byte preserved (value 'Pixel' = 5 bytes)"); + assert_eq!( + len_byte, 5, + "length byte preserved (value 'Pixel' = 5 bytes)" + ); assert_eq!(after & 1, 0, "dirty bit cleared after normalize_serial"); assert_eq!(after & (1 << 16), 0, "long flag not set"); @@ -841,17 +922,29 @@ mod tests { let (vd_off, _) = crate::trie::find(&area, "vendor.display.config").unwrap(); let (ps_off, _) = crate::trie::find(&area, "persist.sys.timezone").unwrap(); let (dv_off, _) = crate::trie::find(&area, "dalvik.vm.heapsize").unwrap(); - let vd_before = area.atomic_u32(vd_off).load(std::sync::atomic::Ordering::Relaxed); - let ps_before = area.atomic_u32(ps_off).load(std::sync::atomic::Ordering::Relaxed); - let dv_before = area.atomic_u32(dv_off).load(std::sync::atomic::Ordering::Relaxed); + let vd_before = area + .atomic_u32(vd_off) + .load(std::sync::atomic::Ordering::Relaxed); + let ps_before = area + .atomic_u32(ps_off) + .load(std::sync::atomic::Ordering::Relaxed); + let dv_before = area + .atomic_u32(dv_off) + .load(std::sync::atomic::Ordering::Relaxed); let count = area.normalize_serial().unwrap(); assert_eq!(count, 0, "no ro.* props, no rewrites"); // serials must be untouched - let vd_after = area.atomic_u32(vd_off).load(std::sync::atomic::Ordering::Relaxed); - let ps_after = area.atomic_u32(ps_off).load(std::sync::atomic::Ordering::Relaxed); - let dv_after = area.atomic_u32(dv_off).load(std::sync::atomic::Ordering::Relaxed); + let vd_after = area + .atomic_u32(vd_off) + .load(std::sync::atomic::Ordering::Relaxed); + let ps_after = area + .atomic_u32(ps_off) + .load(std::sync::atomic::Ordering::Relaxed); + let dv_after = area + .atomic_u32(dv_off) + .load(std::sync::atomic::Ordering::Relaxed); assert_eq!(vd_before, vd_after); assert_eq!(ps_before, ps_after); assert_eq!(dv_before, dv_after); @@ -881,7 +974,10 @@ mod tests { ("ro.product.brand", "Pixel"), ("ro.product.model", "Pixel 8"), ("ro.build.type", "user"), - ("ro.build.fingerprint", "google/shiba/shiba:14/UQ1A.231205.015/11084887:user/release-keys"), + ( + "ro.build.fingerprint", + "google/shiba/shiba:14/UQ1A.231205.015/11084887:user/release-keys", + ), ]; for (name, value) in &pairs { area.set(name, value).unwrap(); @@ -907,22 +1003,37 @@ mod tests { area.set("ro.product.brand", "Pixel").unwrap(); let (pi_off, _) = crate::trie::find(&area, "ro.product.brand").unwrap(); - let counter_initial = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed) & 0x00FF_FFFF; - assert_eq!(counter_initial, 0, "freshly alloc'd prop starts at counter=0"); + let counter_initial = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed) + & 0x00FF_FFFF; + assert_eq!( + counter_initial, 0, + "freshly alloc'd prop starts at counter=0" + ); // first normalize: 0 -> (((0|1)+1) & 0xFFFFFF) = 2 area.normalize_serial().unwrap(); - let c1 = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed) & 0x00FF_FFFF; + let c1 = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed) + & 0x00FF_FFFF; assert_eq!(c1, 2); // second normalize: 2 -> (((2|1)+1) & 0xFFFFFF) = 4 area.normalize_serial().unwrap(); - let c2 = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed) & 0x00FF_FFFF; + let c2 = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed) + & 0x00FF_FFFF; assert_eq!(c2, 4); // third normalize: 4 -> (((4|1)+1) & 0xFFFFFF) = 6 area.normalize_serial().unwrap(); - let c3 = area.atomic_u32(pi_off).load(std::sync::atomic::Ordering::Relaxed) & 0x00FF_FFFF; + let c3 = area + .atomic_u32(pi_off) + .load(std::sync::atomic::Ordering::Relaxed) + & 0x00FF_FFFF; assert_eq!(c3, 6); } @@ -966,7 +1077,10 @@ mod tests { sys.set("vendor.display.config", "auto").unwrap(); let count = sys.normalize_serial().unwrap(); - assert_eq!(count, 2, "PropSystem::normalize_serial counts the 2 ro.* short props"); + assert_eq!( + count, 2, + "PropSystem::normalize_serial counts the 2 ro.* short props" + ); // values intact through PropSystem read path assert_eq!(sys.get("ro.product.brand").unwrap(), "Pixel"); @@ -1033,9 +1147,7 @@ mod tests { #[test] fn set_if_match_skips_when_absent() { let (_dir, sys) = fresh_sys(); - let acted = sys - .set_if_match("ro.absent", "anything", "value") - .unwrap(); + let acted = sys.set_if_match("ro.absent", "anything", "value").unwrap(); assert!(!acted); assert!(sys.get("ro.absent").is_none()); } diff --git a/crates/resetprop/src/persist/io.rs b/crates/resetprop/src/persist/io.rs index 876bcd0..04fcc8f 100644 --- a/crates/resetprop/src/persist/io.rs +++ b/crates/resetprop/src/persist/io.rs @@ -36,7 +36,9 @@ pub(crate) fn atomic_write(dir: &Path, data: &[u8]) -> Result<()> { let result = write_and_sync(fd, data, &selinux_ctx); - unsafe { libc::close(fd); } + unsafe { + libc::close(fd); + } if let Err(e) = result { let _ = std::fs::remove_file(&tmp); @@ -57,7 +59,11 @@ fn write_and_sync(fd: i32, data: &[u8], selinux_ctx: &Option>) -> Result let mut written = 0usize; while written < data.len() { let n = unsafe { - libc::write(fd, data[written..].as_ptr() as *const libc::c_void, data.len() - written) + libc::write( + fd, + data[written..].as_ptr() as *const libc::c_void, + data.len() - written, + ) }; if n < 0 { return Err(std::io::Error::last_os_error().into()); @@ -111,7 +117,9 @@ fn fsync_dir(dir: &Path) -> Result<()> { return Err(std::io::Error::last_os_error().into()); } let rc = unsafe { libc::fsync(fd) }; - unsafe { libc::close(fd); } + unsafe { + libc::close(fd); + } if rc != 0 { return Err(std::io::Error::last_os_error().into()); } diff --git a/crates/resetprop/src/persist/mod.rs b/crates/resetprop/src/persist/mod.rs index 1d45d89..39580dd 100644 --- a/crates/resetprop/src/persist/mod.rs +++ b/crates/resetprop/src/persist/mod.rs @@ -1,5 +1,5 @@ -pub(crate) mod proto; mod io; +pub(crate) mod proto; use std::path::{Path, PathBuf}; @@ -32,11 +32,15 @@ impl PersistStore { } else { load_legacy(dir) }; - Ok(Self { dir: dir.to_path_buf(), records }) + Ok(Self { + dir: dir.to_path_buf(), + records, + }) } pub fn get(&self, name: &str) -> Option<&str> { - self.records.iter() + self.records + .iter() .find(|r| r.name == name) .map(|r| r.value.as_str()) } @@ -45,7 +49,10 @@ impl PersistStore { if let Some(r) = self.records.iter_mut().find(|r| r.name == name) { r.value = value.to_string(); } else { - self.records.push(Record { name: name.to_string(), value: value.to_string() }); + self.records.push(Record { + name: name.to_string(), + value: value.to_string(), + }); } self.flush() } @@ -85,7 +92,10 @@ fn load_legacy(dir: &Path) -> Vec { continue; } if let Ok(value) = std::fs::read_to_string(entry.path()) { - records.push(Record { name, value: value.trim().to_string() }); + records.push(Record { + name, + value: value.trim().to_string(), + }); } } records diff --git a/crates/resetprop/src/persist/proto.rs b/crates/resetprop/src/persist/proto.rs index b5a003f..8d63f83 100644 --- a/crates/resetprop/src/persist/proto.rs +++ b/crates/resetprop/src/persist/proto.rs @@ -148,7 +148,9 @@ fn skip_field(data: &[u8], mut pos: usize, wire_type: u8) -> Result { 2 => { let len = read_varint(data, &mut pos)? as usize; if pos + len > data.len() { - return Err(Error::PersistCorrupt("truncated length-delimited field".into())); + return Err(Error::PersistCorrupt( + "truncated length-delimited field".into(), + )); } Ok(pos + len) } @@ -158,7 +160,9 @@ fn skip_field(data: &[u8], mut pos: usize, wire_type: u8) -> Result { } Ok(pos + 4) } - _ => Err(Error::PersistCorrupt(format!("unknown wire type {wire_type}"))), + _ => Err(Error::PersistCorrupt(format!( + "unknown wire type {wire_type}" + ))), } } @@ -190,9 +194,18 @@ mod tests { #[test] fn round_trip_multiple() { let records = vec![ - Record { name: "persist.sys.timezone".into(), value: "UTC".into() }, - Record { name: "persist.sys.language".into(), value: "en".into() }, - Record { name: "persist.vendor.test".into(), value: "1".into() }, + Record { + name: "persist.sys.timezone".into(), + value: "UTC".into(), + }, + Record { + name: "persist.sys.language".into(), + value: "en".into(), + }, + Record { + name: "persist.vendor.test".into(), + value: "1".into(), + }, ]; let encoded = encode(&records); let decoded = decode(&encoded).unwrap(); @@ -228,7 +241,10 @@ mod tests { #[test] fn wire_format_field_order() { - let records = vec![Record { name: "persist.a".into(), value: "v".into() }]; + let records = vec![Record { + name: "persist.a".into(), + value: "v".into(), + }]; let encoded = encode(&records); assert_eq!(encoded[0], 0x0A); let mut pos = 1; @@ -243,7 +259,10 @@ mod tests { #[test] fn skips_unknown_fields() { - let records = vec![Record { name: "persist.x".into(), value: "y".into() }]; + let records = vec![Record { + name: "persist.x".into(), + value: "y".into(), + }]; let mut encoded = encode(&records); // append an unknown varint field (field 15, wire type 0, value 42) encoded.push(15 << 3); diff --git a/crates/resetprop/src/seal/arena.rs b/crates/resetprop/src/seal/arena.rs index a352bd6..d08e8e7 100644 --- a/crates/resetprop/src/seal/arena.rs +++ b/crates/resetprop/src/seal/arena.rs @@ -354,16 +354,17 @@ pub(crate) unsafe fn remote_remap_private( // is ptrace-stopped for the duration (guarded by `RemoteAttach` above). unsafe { super::ptrace::read_remote(pid, libc_text.start, &mut scan_buf)? }; - let slide_offset = find_scratch_slot(&scan_buf) - .ok_or_else(|| Error::HookInstallFailed("libc.text scan too small for scratch slot".into()))?; + let slide_offset = find_scratch_slot(&scan_buf).ok_or_else(|| { + Error::HookInstallFailed("libc.text scan too small for scratch slot".into()) + })?; let scratch_pc = libc_text.start + slide_offset as u64; // --- Bootstrap: POKEDATA an svc+brk blob at scratch_pc --------------- let saved_bytes = super::ptrace::ptrace_peektext(guard.pid(), scratch_pc)?; // trap (low 4 bytes) ; brk (high 4 bytes), little-endian pack - let trap_brk: u64 = (super::ptrace::TRAP_INSN as u64) - | ((super::ptrace::BRK_INSN as u64) << 32); + let trap_brk: u64 = + (super::ptrace::TRAP_INSN as u64) | ((super::ptrace::BRK_INSN as u64) << 32); super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, trap_brk)?; let bootstrap_page = { @@ -377,12 +378,12 @@ pub(crate) unsafe fn remote_remap_private( scratch_pc, NR_MMAP, [ - 0, // addr = NULL + 0, // addr = NULL BOOTSTRAP_PAGE_SIZE, // len = 4096 - PROT_RW, // prot - MAP_PRIVATE_ANON, // flags - (-1_i64) as u64, // fd = -1 - 0, // offset + PROT_RW, // prot + MAP_PRIVATE_ANON, // flags + (-1_i64) as u64, // fd = -1 + 0, // offset ], ); @@ -752,7 +753,11 @@ mod tests { } fn libc_row() -> MapEntry { - mk("/apex/com.android.runtime/lib64/bionic/libc.so", b"r-xp", 0x2000) + mk( + "/apex/com.android.runtime/lib64/bionic/libc.so", + b"r-xp", + 0x2000, + ) } #[test] diff --git a/crates/resetprop/src/seal/elf.rs b/crates/resetprop/src/seal/elf.rs index 611f655..2857f54 100644 --- a/crates/resetprop/src/seal/elf.rs +++ b/crates/resetprop/src/seal/elf.rs @@ -204,9 +204,9 @@ fn vaddr_to_foff(pt_loads: &[(u64, u64, u64)], vaddr: u64) -> Option { /// here (`Elf64_Ehdr`, `Elf64_Phdr`, `Elf64_Dyn`, `Elf64_Sym`) satisfy this. fn read_struct(bytes: &[u8], off: usize, what: &str) -> Result { let size = mem::size_of::(); - let end = off.checked_add(size).ok_or_else(|| { - Error::ElfParse(format!("offset overflow reading {what}")) - })?; + let end = off + .checked_add(size) + .ok_or_else(|| Error::ElfParse(format!("offset overflow reading {what}")))?; if end > bytes.len() { return Err(Error::ElfParse(format!( "truncated {what} at offset {off} (need {size}, have {})", @@ -270,9 +270,7 @@ pub fn parse_libc_elf(file: &File) -> Result { return Err(Error::ElfParse("e_type != ET_DYN".into())); } if (ehdr.e_phentsize as usize) != mem::size_of::() { - return Err(Error::ElfParse( - "e_phentsize != sizeof(Elf64_Phdr)".into(), - )); + return Err(Error::ElfParse("e_phentsize != sizeof(Elf64_Phdr)".into())); } // ---- Program headers ---- @@ -285,9 +283,10 @@ pub fn parse_libc_elf(file: &File) -> Result { for i in 0..phnum { let off = phoff - .checked_add(i.checked_mul(phentsize).ok_or_else(|| { - Error::ElfParse("phdr index overflow".into()) - })?) + .checked_add( + i.checked_mul(phentsize) + .ok_or_else(|| Error::ElfParse("phdr index overflow".into()))?, + ) .ok_or_else(|| Error::ElfParse("phdr offset overflow".into()))?; let phdr: Elf64_Phdr = read_struct(&bytes, off, "Phdr")?; @@ -302,8 +301,8 @@ pub fn parse_libc_elf(file: &File) -> Result { } } - let (_dyn_vaddr, dyn_off, dyn_filesz) = pt_dynamic - .ok_or_else(|| Error::ElfParse("PT_DYNAMIC absent".into()))?; + let (_dyn_vaddr, dyn_off, dyn_filesz) = + pt_dynamic.ok_or_else(|| Error::ElfParse("PT_DYNAMIC absent".into()))?; // ---- Walk PT_DYNAMIC until DT_NULL or filesz exhausted ---- let mut symtab_va: Option = None; @@ -318,9 +317,10 @@ pub fn parse_libc_elf(file: &File) -> Result { for i in 0..dyn_entries { let off = dyn_off - .checked_add(i.checked_mul(mem::size_of::()).ok_or_else(|| { - Error::ElfParse("dyn index overflow".into()) - })?) + .checked_add( + i.checked_mul(mem::size_of::()) + .ok_or_else(|| Error::ElfParse("dyn index overflow".into()))?, + ) .ok_or_else(|| Error::ElfParse("dyn offset overflow".into()))?; let d: Elf64_Dyn = read_struct(&bytes, off, "Dyn")?; @@ -562,8 +562,7 @@ pub fn linear_lookup(view: &LibcElfView, name: &str) -> Option { if view.strtab_offset <= view.symtab_offset { return None; } - let entries = - (view.strtab_offset - view.symtab_offset) / mem::size_of::(); + let entries = (view.strtab_offset - view.symtab_offset) / mem::size_of::(); for i in 0..entries { let sym_off = view @@ -805,13 +804,7 @@ mod tests { // String table. bytes[strtab_offset..strtab_offset + strtab_size].copy_from_slice(&strtab); - LibcElfView::from_parts( - bytes, - symtab_offset, - strtab_offset, - strtab_size, - Some(0), - ) + LibcElfView::from_parts(bytes, symtab_offset, strtab_offset, strtab_size, Some(0)) } /// A chain-matching symbol whose binding is `STB_LOCAL` must be skipped, diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index da93d21..6e7bcfa 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -343,14 +343,14 @@ pub fn install_init_hook(pid: libc::pid_t) -> Result { let lock_list_page = remote_mmap_anon_rw_page(pid, scratch_pc)?; - let saved_prologue = - match write_sentinel_and_snapshot_prologue(pid, lock_list_page, target_fn) { - Ok(p) => p, - Err(e) => { - best_effort_remote_munmap(pid, scratch_pc, lock_list_page); - return Err(e); - } - }; + let saved_prologue = match write_sentinel_and_snapshot_prologue(pid, lock_list_page, target_fn) + { + Ok(p) => p, + Err(e) => { + best_effort_remote_munmap(pid, scratch_pc, lock_list_page); + return Err(e); + } + }; let body_bytes = build_hook_body_bytes(saved_prologue, lock_list_page, target_fn + 16); @@ -471,9 +471,9 @@ fn write_host_hook_file(pid: libc::pid_t, body_bytes: &[u8]) -> Result /// would overflow the page. fn layout_hook_page_bytes(body_bytes: &[u8]) -> Result> { let body_start = HOOK_BODY_OFFSET as usize; - let body_end = body_start.checked_add(body_bytes.len()).ok_or_else(|| { - Error::HookInstallFailed("stage-B: hook body offset overflow".into()) - })?; + let body_end = body_start + .checked_add(body_bytes.len()) + .ok_or_else(|| Error::HookInstallFailed("stage-B: hook body offset overflow".into()))?; if body_end > HOOK_PAGE_SIZE as usize { return Err(Error::HookInstallFailed(format!( "stage-B: hook body ({} bytes) exceeds page capacity at offset {}", @@ -503,9 +503,7 @@ fn mmap_file_backed_in_tracee( // SAFETY: best-effort close; tracee still stopped. Close errors are // diagnostic-only because the fd leaks to init's fd table at worst, // which self-reclaims on next exec. - let _ = unsafe { - remote_syscall_via_poke(pid, scratch_pc, NR_CLOSE, [fd, 0, 0, 0, 0, 0]) - }; + let _ = unsafe { remote_syscall_via_poke(pid, scratch_pc, NR_CLOSE, [fd, 0, 0, 0, 0, 0]) }; mmap_result } @@ -1211,7 +1209,11 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { NR_MEMBARRIER, [ MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED_SYNC_CORE, - 0, 0, 0, 0, 0, + 0, + 0, + 0, + 0, + 0, ], ) } @@ -1979,8 +1981,10 @@ mod tests { "oracle blob must be 35 words × 4 = 140 bytes; regenerate via oracle/hook_body.s" ); - let template_bytes: Vec = - HOOK_BODY_TEMPLATE.iter().flat_map(|w| w.to_le_bytes()).collect(); + let template_bytes: Vec = HOOK_BODY_TEMPLATE + .iter() + .flat_map(|w| w.to_le_bytes()) + .collect(); for (i, (tpl, gold)) in template_bytes .chunks_exact(4) diff --git a/crates/resetprop/src/seal/maps.rs b/crates/resetprop/src/seal/maps.rs index 058faad..1cc8eae 100644 --- a/crates/resetprop/src/seal/maps.rs +++ b/crates/resetprop/src/seal/maps.rs @@ -54,9 +54,8 @@ pub(super) fn parse_line(line: &str, pid: libc::pid_t) -> Result Error { - Error::AreaCorrupt(format!("/proc/{pid}/maps: {detail}")) - }; + let corrupt = + |detail: &str| -> Error { Error::AreaCorrupt(format!("/proc/{pid}/maps: {detail}")) }; // Columns: ADDR perms offset dev inode [path]. Split on exactly the first // five single-space separators so any spaces inside the path column @@ -79,7 +78,9 @@ pub(super) fn parse_line(line: &str, pid: libc::pid_t) -> Result Result { // SEIZE took; the kernel auto-detaches on thread death, so a later `ESRCH` // needs no explicit detach — just skip the tid. if let Err(e) = ptrace_interrupt(tid) { - return if is_thread_gone(&e) { Ok(false) } else { Err(e) }; + return if is_thread_gone(&e) { + Ok(false) + } else { + Err(e) + }; } match wait_stop(tid, PTRACE_EVENT_STOP) { Ok(_) => Ok(true), @@ -1021,13 +1024,19 @@ mod tests { fn benign_intermediate_stop_then_reaches_expected_event() { let benign = stopped_status(libc::SIGCHLD, 0); assert!( - matches!(classify_stop(benign, PTRACE_EVENT_STOP), StopVerdict::ContAndRetry), + matches!( + classify_stop(benign, PTRACE_EVENT_STOP), + StopVerdict::ContAndRetry + ), "benign SIGCHLD stop must be re-CONTed, not accepted or faulted" ); let awaited = stopped_status(libc::SIGTRAP, PTRACE_EVENT_STOP); assert!( - matches!(classify_stop(awaited, PTRACE_EVENT_STOP), StopVerdict::Awaited), + matches!( + classify_stop(awaited, PTRACE_EVENT_STOP), + StopVerdict::Awaited + ), "the awaited SIGTRAP + matching event must be accepted as the final stop" ); } @@ -1056,7 +1065,11 @@ mod tests { } } assert_eq!(retried, 2, "both benign stops must be re-CONTed"); - assert_eq!(reached, Some(queue[2]), "loop must land on the awaited brk-trap"); + assert_eq!( + reached, + Some(queue[2]), + "loop must land on the awaited brk-trap" + ); } /// The strict final check is intact: a `SIGTRAP` with the WRONG event, and @@ -1068,7 +1081,10 @@ mod tests { // SIGTRAP but event=0 when the caller awaited the group-stop (128). let wrong_event = stopped_status(libc::SIGTRAP, 0); assert!( - matches!(classify_stop(wrong_event, PTRACE_EVENT_STOP), StopVerdict::Unexpected), + matches!( + classify_stop(wrong_event, PTRACE_EVENT_STOP), + StopVerdict::Unexpected + ), "a SIGTRAP with the wrong event must stay PtraceUnexpectedStatus" ); @@ -1157,12 +1173,12 @@ mod tests { #[test] fn thread_gone_detects_exit_and_esrch_only() { - assert!(is_thread_gone(&Error::PtraceAttach(io::Error::from_raw_os_error( - libc::ESRCH - )))); - assert!(is_thread_gone(&Error::PtraceOp(io::Error::from_raw_os_error( - libc::ESRCH - )))); + assert!(is_thread_gone(&Error::PtraceAttach( + io::Error::from_raw_os_error(libc::ESRCH) + ))); + assert!(is_thread_gone(&Error::PtraceOp( + io::Error::from_raw_os_error(libc::ESRCH) + ))); // WIFEXITED(0) is true (low 7 bits == 0): a clean thread exit status. assert!(libc::WIFEXITED(0)); assert!(is_thread_gone(&Error::PtraceUnexpectedStatus(0))); @@ -1170,9 +1186,9 @@ mod tests { let bad_stop = stopped_status(libc::SIGSEGV, 0); assert!(!is_thread_gone(&Error::PtraceUnexpectedStatus(bad_stop))); // A non-ESRCH op failure is a genuine fault, not a vanished thread. - assert!(!is_thread_gone(&Error::PtraceOp(io::Error::from_raw_os_error( - libc::EIO - )))); + assert!(!is_thread_gone(&Error::PtraceOp( + io::Error::from_raw_os_error(libc::EIO) + ))); } #[test] @@ -1199,7 +1215,11 @@ mod tests { ) .expect("a settling group converges to a fixpoint"); assert_eq!(stopped, vec![10, 11, 12]); - assert_eq!(seized, vec![10, 11, 12], "each live tid seized exactly once"); + assert_eq!( + seized, + vec![10, 11, 12], + "each live tid seized exactly once" + ); } #[test] @@ -1315,7 +1335,9 @@ mod tests { } drop(tx); - let worker_tids: Vec = (0..WORKERS).map(|_| rx.recv().expect("worker tid")).collect(); + let worker_tids: Vec = (0..WORKERS) + .map(|_| rx.recv().expect("worker tid")) + .collect(); let listed = enumerate_thread_group(std::process::id() as Pid).expect("enumerate own task dir"); diff --git a/crates/resetprop/src/trie.rs b/crates/resetprop/src/trie.rs index 5809aa9..1ad1552 100644 --- a/crates/resetprop/src/trie.rs +++ b/crates/resetprop/src/trie.rs @@ -51,7 +51,11 @@ impl<'a> TrieNode<'a> { } pub(crate) fn name_bytes(&self) -> &[u8] { - let len = (self.namelen() as usize).min(self.area.len().saturating_sub(self.offset + TRIE_NODE_FIXED)); + let len = (self.namelen() as usize).min( + self.area + .len() + .saturating_sub(self.offset + TRIE_NODE_FIXED), + ); let start = self.offset + TRIE_NODE_FIXED; unsafe { std::slice::from_raw_parts(self.area.base().add(start), len) } } @@ -92,7 +96,11 @@ pub(crate) fn find(area: &PropArea, name: &str) -> Result<(usize, usize)> { return Err(Error::NotFound); } - current = bst_find(area, area.data_offset() + children_off as usize, segment.as_bytes())?; + current = bst_find( + area, + area.data_offset() + children_off as usize, + segment.as_bytes(), + )?; match rest { Some(r) => remaining = r, @@ -179,7 +187,11 @@ where } /// BST insert: returns offset of existing or newly inserted node. -pub(crate) fn bst_insert(area: &PropArea, parent_children: &AtomicU32, name: &[u8]) -> Result { +pub(crate) fn bst_insert( + area: &PropArea, + parent_children: &AtomicU32, + name: &[u8], +) -> Result { let root_off = parent_children.load(AO::Acquire); if root_off == 0 { let off = alloc_trie_node(area, name)?; @@ -256,7 +268,11 @@ pub(crate) fn find_path(area: &PropArea, name: &str) -> Result> { return Err(Error::NotFound); } - let found = bst_find(area, area.data_offset() + children_off as usize, segment.as_bytes())?; + let found = bst_find( + area, + area.data_offset() + children_off as usize, + segment.as_bytes(), + )?; path.push(found.offset()); current = found; diff --git a/crates/resetprop/tests/elf_fixture_smoke.rs b/crates/resetprop/tests/elf_fixture_smoke.rs index ddb1bcd..9eebe52 100644 --- a/crates/resetprop/tests/elf_fixture_smoke.rs +++ b/crates/resetprop/tests/elf_fixture_smoke.rs @@ -71,14 +71,9 @@ fn fixture_symbol_resolves() { workspace_root().join("target/release/libelf_fixture.so") } }; - assert!( - so_path.exists(), - "cdylib missing at {}", - so_path.display() - ); + assert!(so_path.exists(), "cdylib missing at {}", so_path.display()); - let file = File::open(&so_path) - .unwrap_or_else(|e| panic!("open {}: {e}", so_path.display())); + let file = File::open(&so_path).unwrap_or_else(|e| panic!("open {}: {e}", so_path.display())); let view = parse_libc_elf(&file).expect("parse_libc_elf on elf_fixture"); let resolved = resolve_symbol(&view, FIXTURE_SYMBOL) diff --git a/crates/resetprop/tests/ptrace_core_smoke.rs b/crates/resetprop/tests/ptrace_core_smoke.rs index 7867755..b148eac 100644 --- a/crates/resetprop/tests/ptrace_core_smoke.rs +++ b/crates/resetprop/tests/ptrace_core_smoke.rs @@ -30,9 +30,7 @@ #![cfg(target_arch = "aarch64")] use resetprop::seal::ptrace::PTRACE_EVENT_STOP; -use resetprop::seal::{ - ptrace_detach, ptrace_interrupt, ptrace_seize, remote_syscall, wait_stop, -}; +use resetprop::seal::{ptrace_detach, ptrace_interrupt, ptrace_seize, remote_syscall, wait_stop}; /// AArch64 syscall number for `getpid()`; local to this test so the syscall /// table does not leak onto the `resetprop::seal::ptrace` public surface. @@ -52,7 +50,11 @@ const NR_GETPID: u64 = 172; /// narrower bound is strictly sufficient. unsafe fn fork_child(child_body: fn() -> !) -> libc::pid_t { let pid = libc::fork(); - assert!(pid >= 0, "fork() failed: {}", std::io::Error::last_os_error()); + assert!( + pid >= 0, + "fork() failed: {}", + std::io::Error::last_os_error() + ); if pid == 0 { child_body(); } @@ -163,10 +165,8 @@ fn remote_getpid_returns_child_pid() { // names an RWX page with 4096 bytes of room — far more than the 8 bytes // the injector stages at `scratch_pc`. The child is single-threaded and // blocked in pause(), so no other thread races on those 8 bytes. - let ret = unsafe { - remote_syscall(guard.pid(), scratch_pc, NR_GETPID, [0; 6]) - } - .expect("remote_syscall"); + let ret = unsafe { remote_syscall(guard.pid(), scratch_pc, NR_GETPID, [0; 6]) } + .expect("remote_syscall"); assert_eq!( ret, child_pid as i64, diff --git a/crates/resetprop/tests/tier_a_child_smoke.rs b/crates/resetprop/tests/tier_a_child_smoke.rs index ccedb9c..12ee6c5 100644 --- a/crates/resetprop/tests/tier_a_child_smoke.rs +++ b/crates/resetprop/tests/tier_a_child_smoke.rs @@ -74,7 +74,11 @@ const SENTINEL_POST: u8 = 0xBB; /// static initialized by the parent immediately before `fork()`. unsafe fn fork_child(child_body: fn() -> !) -> libc::pid_t { let pid = libc::fork(); - assert!(pid >= 0, "fork() failed: {}", std::io::Error::last_os_error()); + assert!( + pid >= 0, + "fork() failed: {}", + std::io::Error::last_os_error() + ); if pid == 0 { child_body(); } @@ -251,7 +255,8 @@ fn seal_arena_blocks_child_writes_from_reaching_file() { .expect("open tempfile for post-seal reset"); f.seek(SeekFrom::Start(SENTINEL_OFFSET as u64)) .expect("seek SENTINEL_OFFSET post-seal"); - f.write_all(&[SENTINEL_PRE]).expect("write SENTINEL_PRE post-seal"); + f.write_all(&[SENTINEL_PRE]) + .expect("write SENTINEL_PRE post-seal"); f.sync_all().expect("sync_all post-seal"); } From 727ba7ec65db147c304cdcac6536eece78d64575 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Wed, 17 Jun 2026 20:04:50 +0100 Subject: [PATCH 06/21] chore: pin rust toolchain to 1.96.0 Pins rustfmt and clippy so CI and local format identically; a newer rustfmt can no longer reformat the tree out from under the gate. Mirrors the Casting law's rust = 1.96.0 and lists the four Android targets so the cross-builds resolve under the pin. --- rust-toolchain.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 rust-toolchain.toml diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..717779a --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,14 @@ +# Single source of truth for the toolchain: the enforcement wall (rustfmt + clippy), +# the host checks, and the NDK cross-builds all resolve to this exact version, so a +# newer rustfmt can never silently reformat the tree out from under CI. Pinned to the +# Casting law's rust toolchain (casting/gradle/libs.versions.toml: rust = 1.96.0). +[toolchain] +channel = "1.96.0" +profile = "minimal" +components = ["rustfmt", "clippy"] +targets = [ + "aarch64-linux-android", + "armv7-linux-androideabi", + "x86_64-linux-android", + "i686-linux-android", +] From 55f989392aec140b6da35c81c0588ad270d802bf Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Wed, 17 Jun 2026 20:04:50 +0100 Subject: [PATCH 07/21] chore(ci): run the full Rust wall on PRs Replaces the library-only clippy check with the Casting wall: cargo fmt --check, clippy --all-targets --all-features -D warnings, cargo deny (offline supply line), and a comment-hygiene scan mirroring the law's banner rule. Host check, tests, and cross-compile are unchanged. CI gates natively (no JVM); the gradle wall stays the local definition. --- .github/workflows/ci.yml | 39 +++++++++++++------- scripts/comment-hygiene.py | 74 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 scripts/comment-hygiene.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44992d5..c8d0032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,32 @@ jobs: - name: Tests run: cargo test -p resetprop + gates: + name: Casting wall (fmt, clippy, supply, comment-hygiene) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Resolve pinned toolchain (rust-toolchain.toml) + run: rustup show + + - name: Format + run: cargo fmt --all --check + + - name: Clippy (workspace, all targets and features) + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny + + - name: Supply line (offline, deterministic) + run: cargo deny check licenses bans sources + + - name: Comment hygiene + run: python3 scripts/comment-hygiene.py + cross-compile: runs-on: ubuntu-latest strategy: @@ -51,16 +77,3 @@ jobs: - name: Build ${{ matrix.target }} run: cargo build --release --target ${{ matrix.target }} - - clippy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Clippy (library) - run: cargo clippy -p resetprop -- -D warnings diff --git a/scripts/comment-hygiene.py b/scripts/comment-hygiene.py new file mode 100644 index 0000000..fda1854 --- /dev/null +++ b/scripts/comment-hygiene.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Comment-hygiene gate: the cargo-native mirror of the Casting law's +mold.comment-hygiene (banners-only). Fails on decorative box-drawing or divider +comments in tracked first-party sources, kept in lockstep with the law's Kotlin +rule. A file carrying the token comment-hygiene:allow-file is exempt. +""" +import re +import subprocess +import sys +from pathlib import Path + +MARKER = re.compile(r"^(//+!?|/\*+|\*+/?)\s*") +BANNER_PUNCT = set("=-#*_~+") + + +def is_banner(line): + trimmed = line.strip() + m = MARKER.match(trimmed) + if not m: + return False + body = trimmed[m.end():] + if sum(1 for c in body if c != " ") < 4: + return False + return all(c == " " or c in BANNER_PUNCT or "─" <= c <= "▟" for c in body) + + +def is_third_party(path): + return ( + path.startswith(".claude/") + or "/.claude/" in path + or path.startswith("vendor/") + or "/vendor/" in path + ) + + +def tracked_sources(): + try: + out = subprocess.run( + ["git", "ls-files", "--", "*.kt", "*.kts", "*.java", "*.rs"], + capture_output=True, + text=True, + check=True, + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + return None + return [p for p in out.splitlines() if p and not is_third_party(p)] + + +def main(): + files = tracked_sources() + if files is None: + print("comment-hygiene: git unavailable", file=sys.stderr) + return 2 + violations = [] + for rel in files: + text = Path(rel).read_text(encoding="utf-8", errors="replace") + if "comment-hygiene:allow-file" in text: + continue + for i, line in enumerate(text.splitlines(), 1): + if is_banner(line): + violations.append(f"{rel}:{i}: {line.strip()}") + if violations: + print(f"Comment hygiene: {len(violations)} decorative banner comment(s).") + print("Box-drawing and repeated divider runs are banned: write a plain comment,") + print("or add the token comment-hygiene:allow-file to a file that needs one.") + for v in violations: + print(f" {v}") + return 1 + print(f"comment-hygiene: clean ({len(files)} files scanned)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 16cc2d5268488389128daabb38c778edb13b3080 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 00:09:02 +0100 Subject: [PATCH 08/21] fix(ci): drop cross targets from toolchain pin Listing the Android targets in rust-toolchain.toml made every host cargo command (clippy, fmt) try to download their std libs first, which times out offline or on a slow link, and it broke the clippy gate. Drop them from the pin; the cross-compile sites add the target against the pinned toolchain instead (rustup target add), where the network is both available and actually needed. --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 8 ++++---- rust-toolchain.toml | 16 ++++++---------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8d0032..b15ca3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,10 +58,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} + - name: Pinned toolchain + Android target + run: | + rustup show + rustup target add ${{ matrix.target }} - name: Install NDK uses: nttld/setup-ndk@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 684b7f0..6b449b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,10 +28,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} + - name: Pinned toolchain + Android target + run: | + rustup show + rustup target add ${{ matrix.target }} - name: Install NDK uses: nttld/setup-ndk@v1 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 717779a..a647f70 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,14 +1,10 @@ -# Single source of truth for the toolchain: the enforcement wall (rustfmt + clippy), -# the host checks, and the NDK cross-builds all resolve to this exact version, so a -# newer rustfmt can never silently reformat the tree out from under CI. Pinned to the -# Casting law's rust toolchain (casting/gradle/libs.versions.toml: rust = 1.96.0). +# Single source of truth for the toolchain version: the enforcement wall (rustfmt + +# clippy) and the host checks resolve to this exact version, so a newer rustfmt can +# never silently reformat the tree out from under CI. Pinned to the Casting law's rust +# (casting/gradle/libs.versions.toml: rust = 1.96.0). Cross targets are added at the +# cross-compile sites (build.sh, the CI cross jobs), NOT here: listing them forces +# every host cargo command to fetch the Android std libs before it can run. [toolchain] channel = "1.96.0" profile = "minimal" components = ["rustfmt", "clippy"] -targets = [ - "aarch64-linux-android", - "armv7-linux-androideabi", - "x86_64-linux-android", - "i686-linux-android", -] From a1fba84af66c0f02d3c3494d701c28f881311126 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 01:37:15 +0100 Subject: [PATCH 09/21] feat: add --check dry-run to init hook install Add check_init_hook: resolve stage-A facts (maps + libc ELF + symbol) and the scratch slot under one RemoteAttach window, then detach without poking init. The path runs only the no-side-effect sub-ops of install_init_hook -- no remote_syscall_via_poke, ptrace_poketext, or write_remote -- so init's text and registers are left untouched and no trampoline is installed. Results surface as CheckReport. Wire a --check bool flag into the hand-rolled CLI parser. With --seal it dry-runs the Tier B install and reports the resolved facts instead of writing, letting an operator validate a device before the real poke. --- crates/resetprop-cli/src/main.rs | 24 ++++++++++ crates/resetprop/src/seal/hook.rs | 79 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/crates/resetprop-cli/src/main.rs b/crates/resetprop-cli/src/main.rs index 6f83def..23d7076 100644 --- a/crates/resetprop-cli/src/main.rs +++ b/crates/resetprop-cli/src/main.rs @@ -31,6 +31,7 @@ fn run() -> Result<(), String> { let mut wait_value: Option = None; let mut timeout_secs: Option = None; let mut seal: Option = None; + let mut check = false; let mut seal_arena: Option = None; let mut unseal: Option = None; let mut unseal_arena: Option = None; @@ -65,6 +66,7 @@ fn run() -> Result<(), String> { i += 1; seal = Some(arg_val(&args, i, "--seal")?); } + "--check" => check = true, "--seal-arena" | "-sla" => { i += 1; seal_arena = Some(arg_val(&args, i, "--seal-arena")?); @@ -260,6 +262,26 @@ fn run() -> Result<(), String> { } if let Some(ref name) = seal { + if check { + // Dry-run: resolve the Tier B facts in init without poking it. No + // value is written and no trampoline is installed, so VALUE is not + // required here. INIT_PID is `1` (crate-internal in resetprop). + return match resetprop::seal::hook::check_init_hook(1) { + Ok(r) => { + println!( + "check [{name}]: libc_base={:#x} libc_end={:#x} target_fn={:#x} scratch_pc={:#x}", + r.libc_base, r.libc_end, r.target_fn, r.scratch_pc + ); + Ok(()) + } + Err( + e @ (Error::HookInstallFailed(_) + | Error::ElfParse(_) + | Error::SymbolNotFound(_)), + ) => Err(format!("Tier B dry-run failed: {e}")), + Err(e) => Err(format!("check failed: {e}")), + }; + } let value = positional .first() .ok_or_else(|| "--seal requires NAME VALUE (VALUE missing)".to_string())?; @@ -471,6 +493,7 @@ Usage: resetprop --stealth|-st NAME VALUE Set with zeroed serial, no wake signals resetprop --stealth|-st -p NAME VALUE Set stealth + persist to disk resetprop --seal|-sl NAME VALUE Stealth write + Tier B per-prop init hook (default seal) + resetprop --seal|-sl NAME --check Dry-run: resolve Tier B in init, write nothing resetprop --seal-arena|-sla NAME VALUE Stealth write + Tier A arena privatize (fallback) resetprop --unseal NAME Remove NAME from the Tier B lock list resetprop --unseal-arena NAME Revert Tier A arena privatization for NAME @@ -494,6 +517,7 @@ Options: --delete-if-exist NAME Conditional delete: no-op when NAME is absent --stealth, -st Stealth write: bionic compose, no futex wake, no global notify --seal, -sl Tier B seal: stealth write + per-prop hook on __system_property_update in init + --check With --seal: dry-run the Tier B install (resolve only, no ptrace write) --seal-arena, -sla Tier A seal: stealth write + remap init's arena as MAP_PRIVATE|MAP_FIXED --unseal NAME Remove NAME from the in-init Tier B lock list --unseal-arena NAME Revert Tier A privatization for the arena holding NAME diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index 6e7bcfa..c8e675b 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -381,6 +381,65 @@ pub fn install_init_hook(pid: libc::pid_t) -> Result { }) } +/// Result of a [`check_init_hook`] dry-run: the stage-A / scratch-slot facts +/// that the real install would compute, surfaced for an operator to validate +/// on-device before committing to the trampoline patch. +/// +/// Every field is a pure observation — no page was mapped, no prologue byte +/// was written. `target_fn` is `__system_property_update` resolved in init's +/// running libc; `scratch_pc` is the 8-byte-aligned slot the install would +/// reuse for `remote_syscall_via_poke`. +pub struct CheckReport { + pub libc_base: u64, + pub libc_end: u64, + pub target_fn: u64, + pub scratch_pc: u64, +} + +/// Dry-run the per-prop hook install: resolve everything the real path reads, +/// write nothing. +/// +/// Runs ONLY the no-side-effect sub-ops of [`install_init_hook`] — identity +/// check, stage-A (`/proc//maps` + libc ELF + symbol resolution), and +/// scratch-slot derivation — under the same single `RemoteAttach` window, then +/// detaches. The attach is `PTRACE_SEIZE + INTERRUPT` (a group-stop control +/// op, not a memory write); it exists so `derive_libc_scratch_pc`'s +/// `process_vm_readv` snapshot of libc.text cannot race a concurrent +/// APEX/Mainline hot-swap. No `remote_syscall_via_poke`, `ptrace_poketext`, or +/// `write_remote` runs on this path, so init's text and registers are left +/// byte-for-byte unchanged and no trampoline is installed. +/// +/// Lets an operator confirm Tier B will resolve cleanly (no `SymbolNotFound`, +/// no missing libc row, a usable scratch slot) on a given device before the +/// real `--seal` pokes PID 1. +pub fn check_init_hook(pid: libc::pid_t) -> Result { + // M1: reject a non-init PID-1 stand-in before attaching. + seal::arena::verify_init_identity(pid)?; + + let guard = seal::arena::RemoteAttach::new(pid) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: attach: {e}")))?; + + let (libc_base, libc_end, target_fn) = stage_a_locked(pid)?; + + // SAFETY: `guard` holds the tracee ptrace-stopped; `libc_base..libc_end` + // is the `r-xp` row just returned by stage-A on the same process. + let scratch_pc = unsafe { derive_libc_scratch_pc(pid, libc_base, libc_end) }?; + + // No writes happened — detach and report. (Drop would resume the group + // too, but detaching explicitly keeps the no-side-effect contract obvious + // and surfaces a detach error rather than swallowing it.) + guard + .detach() + .map_err(|e| Error::HookInstallFailed(format!("stage-B: detach: {e}")))?; + + Ok(CheckReport { + libc_base, + libc_end, + target_fn, + scratch_pc, + }) +} + /// Allocate a fresh 4 KiB `PROT_READ | PROT_WRITE` anonymous page in the /// tracee. Used for both the runtime lock list and as the staging buffer /// for the hook file path during install. @@ -2241,4 +2300,24 @@ mod tests { "revert must leave init's prologue byte-for-byte intact" ); } + + /// `check_init_hook`'s remote sub-ops (attach + `process_vm_readv`) need a + /// live aarch64 init and are exercised on-device in P05, not from an + /// x86_64 host — same constraint as `install_init_hook` and the Drop body. + /// What we can pin off-device is the public dry-run report surface: every + /// resolved fact is reachable, matching the `hook_handle_size` field-layout + /// check above. + #[test] + fn check_report_fields_reachable() { + let r = CheckReport { + libc_base: 0x7000_0000_0000, + libc_end: 0x7000_0010_0000, + target_fn: 0x7000_0001_2340, + scratch_pc: 0x7000_0000_1000, + }; + assert_eq!(r.libc_base, 0x7000_0000_0000); + assert_eq!(r.libc_end, 0x7000_0010_0000); + assert_eq!(r.target_fn, 0x7000_0001_2340); + assert_eq!(r.scratch_pc, 0x7000_0000_1000); + } } From 652c2a72042ba03ec5b40ccc64f336cda6ba279c Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 01:41:21 +0100 Subject: [PATCH 10/21] test: add per-arch ptrace smoke fixtures --- .../resetprop/tests/ptrace_core_smoke_arm.rs | 89 ++++++++++++++++++ .../resetprop/tests/ptrace_core_smoke_x86.rs | 90 ++++++++++++++++++ .../tests/ptrace_core_smoke_x86_64.rs | 91 +++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 crates/resetprop/tests/ptrace_core_smoke_arm.rs create mode 100644 crates/resetprop/tests/ptrace_core_smoke_x86.rs create mode 100644 crates/resetprop/tests/ptrace_core_smoke_x86_64.rs diff --git a/crates/resetprop/tests/ptrace_core_smoke_arm.rs b/crates/resetprop/tests/ptrace_core_smoke_arm.rs new file mode 100644 index 0000000..7901be5 --- /dev/null +++ b/crates/resetprop/tests/ptrace_core_smoke_arm.rs @@ -0,0 +1,89 @@ +//! Per-arch encoding smoke test for the 32-bit ARM (AArch32) ptrace facade. +//! +//! Sibling of `ptrace_core_smoke.rs` (the aarch64 live SEIZE round-trip). +//! Where that file forks a tracee and round-trips `getpid()`, this file +//! asserts the *static* arch contract the `remote_syscall` stager depends on: +//! the syscall-trap / breakpoint instruction encodings, the NT_PRSTATUS regset +//! byte size, and the Linux EABI syscall-ABI register wiring. These are +//! compile-time-constant assertions plus one pure register-staging round-trip, +//! so the test needs no ptrace, no fork, and no `#[ignore]`. +//! +//! Architecture gate: the entire file is gated behind +//! `#[cfg(target_arch = "arm")]`. The constants under test +//! (`resetprop::seal::ptrace::{TRAP_INSN, BRK_INSN, NT_PRSTATUS_SIZE, +//! UserPtRegs, set_syscall_args, get_syscall_return}`) are re-exported from the +//! `cfg`-selected active arch module, so they hold the AArch32 values only when +//! the crate is built for arm. On any other host this file compiles to an empty +//! test binary, reporting `0 passed; 0 failed; 0 ignored`. +//! +//! Encoding sources (mirrored from +//! `crates/resetprop/src/seal/ptrace/arch/arm.rs`, grounded against injectrc +//! `init_injector/ptrace_utils.hpp:51-64` REG_* macros): +//! - `svc #0` (ARM enc, `00 00 00 ef`) → TRAP_INSN == 0xef00_0000 (ARM ARM A8.8.361) +//! - `bkpt #0` (ARM enc, `70 00 20 e1`) → BRK_INSN == 0xe120_0070 (ARM ARM A8.8.24) +//! - regset = sizeof(struct pt_regs { long uregs[18]; }) == 72 bytes +//! +//! Runner invocation: +//! cargo test -p resetprop --target armv7-linux-androideabi \ +//! --test ptrace_core_smoke_arm + +#![cfg(target_arch = "arm")] + +use resetprop::seal::ptrace::{ + get_syscall_return, set_syscall_args, UserPtRegs, BRK_INSN, NT_PRSTATUS_SIZE, TRAP_INSN, +}; + +/// The AArch32 trap/breakpoint encodings the gadget stages: `svc #0` then +/// `bkpt #0` (ARM-mode encodings). Pinned little-endian per `arch/arm.rs`. +/// Thumb-mode handling is deferred with the rest of the armv7 runtime port. +#[test] +fn arm_trap_brk_encodings() { + assert_eq!( + TRAP_INSN, 0xef00_0000, + "ARM `svc #0` must encode as 00 00 00 ef" + ); + assert_eq!( + BRK_INSN, 0xe120_0070, + "ARM `bkpt #0` must encode as 70 00 20 e1" + ); +} + +/// The NT_PRSTATUS iovec contract: `UserPtRegs` is exactly +/// `sizeof(struct pt_regs)` == 72 bytes (18 * u32), so a GETREGSET iovec staged +/// from this layout reads the full kernel regset without truncation. +#[test] +fn arm_regset_byte_contract() { + assert_eq!(NT_PRSTATUS_SIZE, 72, "ARM NT_PRSTATUS regset is 18 * u32"); + assert_eq!( + core::mem::size_of::(), + NT_PRSTATUS_SIZE, + "UserPtRegs must match the NT_PRSTATUS byte contract", + ); +} + +/// The Linux EABI syscall ABI wiring: pc in `uregs[15]`, number in `r7` +/// (`uregs[7]`), args in `r0..r5` (`uregs[0..6]`), return read back from `r0` +/// (`uregs[0]`). Args are truncated to the 32-bit register width. Staging then +/// reading round-trips the syscall number through `r7` and confirms each arg +/// lands in its ABI register. +#[test] +fn arm_syscall_abi_register_wiring() { + let mut regs = UserPtRegs::default(); + let args = [11, 22, 33, 44, 55, 66]; + set_syscall_args(&mut regs, 0xdead_beef, 20, args); + + assert_eq!(regs.uregs[15], 0xdead_beef, "pc -> uregs[15]"); + assert_eq!(regs.uregs[7], 20, "syscall number -> r7 (uregs[7])"); + for (i, want) in args.iter().enumerate() { + assert_eq!(regs.uregs[i], *want as u32, "arg{i} -> uregs[{i}]"); + } + + // Post-trap, the kernel writes the return value into r0; the helper reads + // it back sign-extended to i64 so `-errno` returns survive. + regs.uregs[0] = (-14_i32) as u32; + assert_eq!( + get_syscall_return(®s), + -14, + "return read from r0, sign-extended" + ); +} diff --git a/crates/resetprop/tests/ptrace_core_smoke_x86.rs b/crates/resetprop/tests/ptrace_core_smoke_x86.rs new file mode 100644 index 0000000..5c1dffc --- /dev/null +++ b/crates/resetprop/tests/ptrace_core_smoke_x86.rs @@ -0,0 +1,90 @@ +//! Per-arch encoding smoke test for the x86 (i386) ptrace facade. +//! +//! Sibling of `ptrace_core_smoke.rs` (the aarch64 live SEIZE round-trip). +//! Where that file forks a tracee and round-trips `getpid()`, this file +//! asserts the *static* arch contract the `remote_syscall` stager depends on: +//! the syscall-trap / breakpoint instruction encodings, the NT_PRSTATUS regset +//! byte size, and the Linux i386 syscall-ABI register wiring. These are +//! compile-time-constant assertions plus one pure register-staging round-trip, +//! so the test needs no ptrace, no fork, and no `#[ignore]`. +//! +//! Architecture gate: the entire file is gated behind +//! `#[cfg(target_arch = "x86")]`. The constants under test +//! (`resetprop::seal::ptrace::{TRAP_INSN, BRK_INSN, NT_PRSTATUS_SIZE, +//! UserPtRegs, set_syscall_args, get_syscall_return}`) are re-exported from the +//! `cfg`-selected active arch module, so they hold the i386 values only when +//! the crate is built for x86. On any other host this file compiles to an empty +//! test binary, reporting `0 passed; 0 failed; 0 ignored`. +//! +//! Encoding sources (mirrored from +//! `crates/resetprop/src/seal/ptrace/arch/x86.rs`, grounded against injectrc +//! `init_injector/ptrace_utils.hpp:35-42` REG_* macros): +//! - `int $0x80` (`cd 80`) → TRAP_INSN == 0x0000_80cd (Intel SDM Vol 2A) +//! - `int3` (`cc`) → BRK_INSN == 0x0000_00cc (Intel SDM Vol 2A) +//! - regset = sizeof(struct user_regs_struct) == 68 bytes +//! +//! Runner invocation: +//! cargo test -p resetprop --target i686-linux-android \ +//! --test ptrace_core_smoke_x86 + +#![cfg(target_arch = "x86")] + +use resetprop::seal::ptrace::{ + get_syscall_return, set_syscall_args, UserPtRegs, BRK_INSN, NT_PRSTATUS_SIZE, TRAP_INSN, +}; + +/// The i386 trap/breakpoint encodings the gadget stages: `int $0x80` (`cd 80`) +/// then `int3` (`cc`). Pinned little-endian into the low half of the `u32` +/// instruction constants per `arch/x86.rs`. +#[test] +fn x86_trap_brk_encodings() { + assert_eq!( + TRAP_INSN, 0x0000_80cd, + "i386 `int $0x80` must encode as cd 80" + ); + assert_eq!(BRK_INSN, 0x0000_00cc, "i386 `int3` must encode as cc"); +} + +/// The NT_PRSTATUS iovec contract: `UserPtRegs` is exactly +/// `sizeof(struct user_regs_struct)` == 68 bytes (17 * u32), so a GETREGSET +/// iovec staged from this layout reads the full kernel regset without +/// truncation. +#[test] +fn x86_regset_byte_contract() { + assert_eq!(NT_PRSTATUS_SIZE, 68, "i386 NT_PRSTATUS regset is 17 * u32"); + assert_eq!( + core::mem::size_of::(), + NT_PRSTATUS_SIZE, + "UserPtRegs must match the NT_PRSTATUS byte contract", + ); +} + +/// The Linux i386 syscall ABI wiring: number in `eax`, args in +/// `ebx, ecx, edx, esi, edi, ebp`, return read back from `eax`. Args are +/// truncated to the 32-bit register width. Staging then reading round-trips the +/// syscall number through `eax` and confirms each arg lands in its ABI +/// register. +#[test] +fn x86_syscall_abi_register_wiring() { + let mut regs = UserPtRegs::default(); + let args = [11, 22, 33, 44, 55, 66]; + set_syscall_args(&mut regs, 0xdead_beef, 20, args); + + assert_eq!(regs.eip, 0xdead_beef, "pc -> eip"); + assert_eq!(regs.eax, 20, "syscall number -> eax"); + assert_eq!(regs.ebx, 11, "arg0 -> ebx"); + assert_eq!(regs.ecx, 22, "arg1 -> ecx"); + assert_eq!(regs.edx, 33, "arg2 -> edx"); + assert_eq!(regs.esi, 44, "arg3 -> esi"); + assert_eq!(regs.edi, 55, "arg4 -> edi"); + assert_eq!(regs.ebp, 66, "arg5 -> ebp"); + + // Post-trap, the kernel writes the return value into eax; the helper reads + // it back sign-extended to i64 so `-errno` returns survive. + regs.eax = (-14_i32) as u32; + assert_eq!( + get_syscall_return(®s), + -14, + "return read from eax, sign-extended" + ); +} diff --git a/crates/resetprop/tests/ptrace_core_smoke_x86_64.rs b/crates/resetprop/tests/ptrace_core_smoke_x86_64.rs new file mode 100644 index 0000000..dffca29 --- /dev/null +++ b/crates/resetprop/tests/ptrace_core_smoke_x86_64.rs @@ -0,0 +1,91 @@ +//! Per-arch encoding smoke test for the x86-64 ptrace facade. +//! +//! Sibling of `ptrace_core_smoke.rs` (the aarch64 live SEIZE round-trip). +//! Where that file forks a tracee and round-trips `getpid()`, this file +//! asserts the *static* arch contract the `remote_syscall` stager depends on: +//! the syscall-trap / breakpoint instruction encodings, the NT_PRSTATUS regset +//! byte size, and the SysV/Linux x86-64 syscall-ABI register wiring. These are +//! compile-time-constant assertions plus one pure register-staging round-trip, +//! so the test needs no ptrace, no fork, and no `#[ignore]`. +//! +//! Architecture gate: the entire file is gated behind +//! `#[cfg(target_arch = "x86_64")]`. The constants under test +//! (`resetprop::seal::ptrace::{TRAP_INSN, BRK_INSN, NT_PRSTATUS_SIZE, +//! UserPtRegs, set_syscall_args, get_syscall_return}`) are re-exported from the +//! `cfg`-selected active arch module, so they hold the x86-64 values only when +//! the crate is built for x86-64. On any other host this file compiles to an +//! empty test binary, reporting `0 passed; 0 failed; 0 ignored`. +//! +//! Encoding sources (mirrored from +//! `crates/resetprop/src/seal/ptrace/arch/x86_64.rs`, grounded against injectrc +//! `init_injector/ptrace_utils.hpp:25-34` REG_* macros): +//! - `syscall` (`0f 05`) → TRAP_INSN == 0x0000_050f (Intel SDM Vol 2B) +//! - `int3` (`cc`) → BRK_INSN == 0x0000_00cc (Intel SDM Vol 2A) +//! - regset = sizeof(struct user_regs_struct) == 216 bytes +//! +//! Runner invocation: +//! cargo test -p resetprop --target x86_64-linux-android \ +//! --test ptrace_core_smoke_x86_64 + +#![cfg(target_arch = "x86_64")] + +use resetprop::seal::ptrace::{ + get_syscall_return, set_syscall_args, UserPtRegs, BRK_INSN, NT_PRSTATUS_SIZE, TRAP_INSN, +}; + +/// The x86-64 trap/breakpoint encodings the gadget stages: `syscall` (`0f 05`) +/// then `int3` (`cc`). Pinned little-endian into the low half of the `u32` +/// instruction constants per `arch/x86_64.rs`. +#[test] +fn x86_64_trap_brk_encodings() { + assert_eq!( + TRAP_INSN, 0x0000_050f, + "x86-64 `syscall` must encode as 0f 05" + ); + assert_eq!(BRK_INSN, 0x0000_00cc, "x86-64 `int3` must encode as cc"); +} + +/// The NT_PRSTATUS iovec contract: `UserPtRegs` is exactly +/// `sizeof(struct user_regs_struct)` == 216 bytes, so a GETREGSET iovec staged +/// from this layout reads the full kernel regset without truncation. +#[test] +fn x86_64_regset_byte_contract() { + assert_eq!( + NT_PRSTATUS_SIZE, 216, + "x86-64 NT_PRSTATUS regset is 27 * u64" + ); + assert_eq!( + core::mem::size_of::(), + NT_PRSTATUS_SIZE, + "UserPtRegs must match the NT_PRSTATUS byte contract", + ); +} + +/// The SysV/Linux x86-64 syscall ABI wiring: number in `rax`, args in +/// `rdi, rsi, rdx, r10, r8, r9`, return read back from `rax`. Staging then +/// reading round-trips the syscall number through `rax` and confirms each arg +/// lands in its ABI register. +#[test] +fn x86_64_syscall_abi_register_wiring() { + let mut regs = UserPtRegs::default(); + let args = [11, 22, 33, 44, 55, 66]; + set_syscall_args(&mut regs, 0xdead_beef, 39, args); + + assert_eq!(regs.rip, 0xdead_beef, "pc -> rip"); + assert_eq!(regs.rax, 39, "syscall number -> rax"); + assert_eq!(regs.rdi, 11, "arg0 -> rdi"); + assert_eq!(regs.rsi, 22, "arg1 -> rsi"); + assert_eq!(regs.rdx, 33, "arg2 -> rdx"); + assert_eq!(regs.r10, 44, "arg3 -> r10"); + assert_eq!(regs.r8, 55, "arg4 -> r8"); + assert_eq!(regs.r9, 66, "arg5 -> r9"); + + // Post-trap, the kernel writes the return value into rax; the helper reads + // it back as a signed i64 so `-errno` returns survive. + regs.rax = (-14_i64) as u64; + assert_eq!( + get_syscall_return(®s), + -14, + "return read from rax as i64" + ); +} From 6004ca227f5d17bca3ff783d0a3b6a37f57cd9de Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 01:51:56 +0100 Subject: [PATCH 11/21] chore(bucket): mark T07, T14 done --- bucket/resetprop-rs-injectrc-ports/progress.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bucket/resetprop-rs-injectrc-ports/progress.yml b/bucket/resetprop-rs-injectrc-ports/progress.yml index e8fdf0a..a3a75b2 100644 --- a/bucket/resetprop-rs-injectrc-ports/progress.yml +++ b/bucket/resetprop-rs-injectrc-ports/progress.yml @@ -1,5 +1,5 @@ plan: resetprop-rs-injectrc-ports -updated: 2026-06-17 +updated: 2026-06-18 tasks: T01: done T02: done @@ -7,14 +7,14 @@ tasks: T04: done T05: done T06: backlog - T07: backlog + T07: done T08: backlog T09: backlog T10: backlog T11: cut T12: done T13: done - T14: backlog + T14: done T15: done T16: done T17: backlog From d5beea8886fae39a741ac02e224bbe485c10862e Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 05:22:29 +0100 Subject: [PATCH 12/21] feat(seal): throttle init-hook install retries A failed install retries on every seal() call while the hook slot is empty, so a load that crashes init can bootloop it. Cap hard failures per process: install_init_hook refuses with HookInstallFailed once a module-static AtomicU32 hits MAX_INSTALL_HARD_FAILURES (5, mirroring ReZygisk's MAX_RETRY_COUNT) before the M1 identity check or any ptrace attach. The counter is module-static because the failures it counts never produce a HookHandle. --- crates/resetprop/src/seal/hook.rs | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index c8e675b..f34072a 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -26,6 +26,7 @@ use std::fs::File; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::error::{Error, Result}; @@ -67,6 +68,20 @@ const PATH_STAGE_OFFSET: u64 = 2048; /// pipelines share identical scan behaviour. const LIBC_SCAN_LIMIT: usize = 64 * 1024; +/// Hard install failures tolerated before [`install_init_hook`] stops +/// attaching for the rest of the process. A seal install pokes init's +/// prologue; a load that keeps failing is a fault that retrying only drives +/// toward a bootloop, so the installer gives up rather than keep hammering +/// PID 1. Mirrors ReZygisk's `MAX_RETRY_COUNT` ptrace-monitor ceiling +/// (loader/src/ptracer/monitor.c). +const MAX_INSTALL_HARD_FAILURES: u32 = 5; + +/// Count of hard [`install_init_hook`] failures this process has seen. Module- +/// static rather than a [`HookHandle`] field because a handle only exists +/// after a *successful* install, so the throttle must outlive the failed +/// attempts that never produce one. +static INSTALL_HARD_FAILURES: AtomicU32 = AtomicU32::new(0); + // P04 T3 — hook-page layout and i-cache sync constants /// Hook page byte offset where [`build_hook_body_bytes`]'s 140-byte body @@ -328,7 +343,28 @@ unsafe fn derive_libc_scratch_pc(pid: libc::pid_t, libc_base: u64, libc_end: u64 /// lock-list mmap + prologue snapshot + host file write + remote /// openat / mmap / close + unlink. Any thread that blocks on init /// for a property write during this window waits out the full stall. +/// +/// # Throttle +/// +/// After [`MAX_INSTALL_HARD_FAILURES`] hard failures in one process the +/// installer refuses further attempts before the M1 identity check or any +/// ptrace attach, so a load that bootloops init cannot be retried into the +/// ground. pub fn install_init_hook(pid: libc::pid_t) -> Result { + if INSTALL_HARD_FAILURES.load(Ordering::Relaxed) >= MAX_INSTALL_HARD_FAILURES { + return Err(Error::HookInstallFailed(format!( + "install throttled after {MAX_INSTALL_HARD_FAILURES} hard failures this boot" + ))); + } + install_init_hook_inner(pid).inspect_err(|_| { + INSTALL_HARD_FAILURES.fetch_add(1, Ordering::Relaxed); + }) +} + +/// Uncounted install body. [`install_init_hook`] wraps this with the per-boot +/// hard-failure throttle; every `Err` it returns advances the counter that +/// eventually trips the guard. +fn install_init_hook_inner(pid: libc::pid_t) -> Result { // M1: reject a non-init PID-1 stand-in before attaching or poking. seal::arena::verify_init_identity(pid)?; @@ -2320,4 +2356,40 @@ mod tests { assert_eq!(r.target_fn, 0x7000_0001_2340); assert_eq!(r.scratch_pc, 0x7000_0000_1000); } + + // T08 / M4 / R3: per-boot install throttle + + /// After [`MAX_INSTALL_HARD_FAILURES`] hard failures the next + /// `install_init_hook` is refused by the throttle guard, returning the + /// reused [`Error::HookInstallFailed`] before the M1 identity check or + /// any ptrace attach. `pid_t::MAX` names no live process, so every + /// pre-threshold call fails in `verify_init_identity`'s `/proc` read with + /// no remote work, forcing the counter to the ceiling off-device. This is + /// the only test that touches `INSTALL_HARD_FAILURES`; it brackets the + /// body with a reset so a parallel run starts and leaves it clean. + #[test] + fn install_init_hook_throttles_after_max_hard_failures() { + INSTALL_HARD_FAILURES.store(0, Ordering::Relaxed); + + let absent_pid = libc::pid_t::MAX; + for _ in 0..MAX_INSTALL_HARD_FAILURES { + let Err(err) = install_init_hook(absent_pid) else { + panic!("install_init_hook on an absent pid must fail without a live init"); + }; + assert!( + !matches!(&err, Error::HookInstallFailed(m) if m.contains("throttled")), + "pre-threshold failure must be a real install fault, not the throttle: {err:?}" + ); + } + + let Err(err) = install_init_hook(absent_pid) else { + panic!("a throttled install must return Err, not a handle"); + }; + assert!( + matches!(&err, Error::HookInstallFailed(m) if m.contains("throttled")), + "after {MAX_INSTALL_HARD_FAILURES} hard failures the next install must be throttled: {err:?}" + ); + + INSTALL_HARD_FAILURES.store(0, Ordering::Relaxed); + } } From 4dacb9906b5e3094d5b7f3036df8ae02dedda17d Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 05:22:47 +0100 Subject: [PATCH 13/21] refactor(seal): consolidate libc-row finder arena and hook carried divergent libc.text predicates; only hook's was hardened (exact r-xp + path ends_with /libc.so). Promote it to a shared find_libc_text_row driving both the stage-A and Tier A remap finders, deleting arena's looser r-x + libc.so.* match that could pick a shared copy or a versioned sibling. Also bind LOCK_LIST_CAPACITY < PATH_STAGE_OFFSET with a const-assert so a future capacity bump fails the build instead of letting the in-init walker read the staged path bytes, and clear doc-rot: the append capacity rationale (the hook body moved off this page in 563e6a3), the empty-list sentinel wording, arena's remote_syscall_via_poke flow doc, and mod.rs's stale "deferred re-exports" note. --- crates/resetprop/src/seal/arena.rs | 21 ++++++--------------- crates/resetprop/src/seal/hook.rs | 28 ++++++++++++++++++++++------ crates/resetprop/src/seal/mod.rs | 5 ----- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/crates/resetprop/src/seal/arena.rs b/crates/resetprop/src/seal/arena.rs index d08e8e7..70119fc 100644 --- a/crates/resetprop/src/seal/arena.rs +++ b/crates/resetprop/src/seal/arena.rs @@ -302,11 +302,11 @@ impl Drop for RemoteAttach { /// — before any error-check can `?` — so libc.text is always left /// pristine. /// 6. Write the NUL-terminated arena path to `bootstrap_page` via -/// `write_remote`; `remote_syscall` openat (scratch_pc=bootstrap_page, -/// the fresh RWX page); `remote_syscall` mmap -/// (`MAP_PRIVATE|MAP_FIXED` or `MAP_SHARED|MAP_FIXED` per `flags`) over -/// the arena VMA — must return exactly `mapping.start`; `remote_syscall` -/// close the fd. +/// `write_remote`; `remote_syscall_via_poke` openat +/// (scratch_pc=bootstrap_page, the fresh RWX page); +/// `remote_syscall_via_poke` mmap (`MAP_PRIVATE|MAP_FIXED` or +/// `MAP_SHARED|MAP_FIXED` per `flags`) over the arena VMA, which must +/// return exactly `mapping.start`; `remote_syscall_via_poke` close the fd. /// 7. munmap the bootstrap page (runs on every post-mmap exit), then /// `guard.detach()` returns cleanly. /// @@ -334,16 +334,7 @@ pub(crate) unsafe fn remote_remap_private( // --- Locate a libc.text NOP slide in the tracee ---------------------- let maps_entries = super::maps::parse_maps(pid)?; - let libc_text = maps_entries - .iter() - .find(|e| { - e.perms.starts_with(b"r-x") - && e.path - .as_deref() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - .is_some_and(|n| n == "libc.so" || n.starts_with("libc.so.")) - }) + let libc_text = super::hook::find_libc_text_row(&maps_entries) .ok_or_else(|| Error::HookInstallFailed("no libc.so r-x mapping in target".into()))?; let scan_len = (libc_text.end - libc_text.start).min(LIBC_SCAN_LIMIT as u64) as usize; diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index f34072a..61add09 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -111,6 +111,12 @@ pub(crate) const HOOK_BODY_OFFSET: u64 = 1024; /// `P04-tier-b-part2.md §Operational Envelope`. pub(crate) const LOCK_LIST_CAPACITY: u64 = 1024; +/// A lock-list entry must never reach the install-time path staged at +/// [`PATH_STAGE_OFFSET`], or the in-init walker would read those path bytes +/// as a lock name. Binding the two constants fails the build on a future +/// capacity bump instead of letting the list silently overrun. +const _: () = assert!(LOCK_LIST_CAPACITY < PATH_STAGE_OFFSET); + /// `MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED_SYNC_CORE` — kernel cmd byte /// that registers the calling task's intent to later issue /// `PRIVATE_EXPEDITED_SYNC_CORE`. Without this registration, the SYNC_CORE @@ -198,6 +204,13 @@ pub(crate) fn is_libc_row(entry: &MapEntry) -> bool { .is_some_and(|s| s.ends_with("/libc.so")) } +/// Locate the executable `libc.so` row in a parsed maps file via the hardened +/// [`is_libc_row`] gate. Stage-A and the Tier A remap share this so both pin +/// libc.text by the identical rule rather than drifting predicates. +pub(crate) fn find_libc_text_row(entries: &[MapEntry]) -> Option<&MapEntry> { + entries.iter().find(|e| is_libc_row(e)) +} + /// Stage-A of the hook install pipeline — RUN UNDER ATTACH. /// /// Returns `(libc_base, libc_end, target_fn)` where `libc_base` / `libc_end` @@ -219,7 +232,7 @@ fn stage_a_locked(pid: libc::pid_t) -> Result<(u64, u64, u64)> { let entries = seal::maps::parse_maps(pid) .map_err(|e| Error::HookInstallFailed(format!("stage-A: parse_maps: {e}")))?; - let libc_row = entries.iter().find(|e| is_libc_row(e)).ok_or_else(|| { + let libc_row = find_libc_text_row(&entries).ok_or_else(|| { Error::HookInstallFailed(format!("stage-A: libc row not found in /proc/{pid}/maps")) })?; @@ -501,8 +514,9 @@ fn remote_mmap_anon_rw_page(pid: libc::pid_t, scratch_pc: u64) -> Result { Ok(ret as u64) } -/// Write the 4-byte empty-list sentinel to `lock_list_page + 0` and -/// snapshot the 16-byte prologue at `target_fn`. +/// Write the empty-list sentinel to `lock_list_page + 0` (one NUL marks the +/// list empty; the write zeroes a 4-byte word) and snapshot the 16-byte +/// prologue at `target_fn`. fn write_sentinel_and_snapshot_prologue( pid: libc::pid_t, lock_list_page: u64, @@ -1396,9 +1410,11 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { /// /// Returns `None` if the new sentinel offset would lie at or past /// `capacity`; callers surface this as [`Error::HookInstallFailed`]. The -/// bounds check uses `>= capacity` rather than `> capacity - 1` because a -/// sentinel at offset `capacity` would be the first byte of the hook body -/// at `HOOK_BODY_OFFSET` and clobber init's trampoline target. +/// bound is `>= capacity` because the sentinel occupies the byte it names, +/// so offset `capacity` is the first byte past the lock list's reserved +/// region. Callers pass [`LOCK_LIST_CAPACITY`], which sits below +/// [`PATH_STAGE_OFFSET`], so a list that respects the bound never lets the +/// in-init walker read the path bytes staged there during install. /// /// Interior-NUL rejection is the caller's responsibility — this helper /// assumes `name` is a validated C-string body. diff --git a/crates/resetprop/src/seal/mod.rs b/crates/resetprop/src/seal/mod.rs index b48fc84..2d4cf0c 100644 --- a/crates/resetprop/src/seal/mod.rs +++ b/crates/resetprop/src/seal/mod.rs @@ -1,9 +1,4 @@ //! Seal feature root — module tree for Tier A (arena-level) and Tier B (per-prop hook) seals. -//! -//! NOTE: Public re-exports of `maps::*` and `ptrace::*` are intentionally deferred. -//! P01 Task 1 ships the submodule declarations and public types only; the -//! `pub use` re-exports will be added incrementally by P01 Task 2 (`maps`) -//! and P01 Task 3/4 (`ptrace`) when the corresponding items land. use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; From 77e76376ecb2b9028b2cd8b8d88dfefa5299e42d Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 05:22:56 +0100 Subject: [PATCH 14/21] chore(bucket): mark T08, T18 done --- bucket/resetprop-rs-injectrc-ports/progress.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bucket/resetprop-rs-injectrc-ports/progress.yml b/bucket/resetprop-rs-injectrc-ports/progress.yml index a3a75b2..20af169 100644 --- a/bucket/resetprop-rs-injectrc-ports/progress.yml +++ b/bucket/resetprop-rs-injectrc-ports/progress.yml @@ -8,7 +8,7 @@ tasks: T05: done T06: backlog T07: done - T08: backlog + T08: done T09: backlog T10: backlog T11: cut @@ -18,5 +18,5 @@ tasks: T15: done T16: done T17: backlog - T18: backlog + T18: done T19: done From a98a5b746ad849edf96c4157d2e559ea6b418a66 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 05:47:34 +0100 Subject: [PATCH 15/21] feat(seal): map the hook page from a memfd Replace the host-file hook-page dance (write under /data/adb/resetprop-rs, remote openat, mmap, unlink) with injectrc's memfd pattern: the tracee creates an anonymous memfd, this process fills it through /proc//fd/ and relabels it to the libc.so SELinux context via setfilecon, then the tracee maps and closes its own fd. The mapping survives the close via the fd's anonymous inode, so nothing reaches disk: no hook-*.bin residue and no adb_data_file:file execute denial to dodge. The libselinux relabel is hand-rolled FFI (no selinux-sys crate). The NDK ships no libselinux to link against, so the Android path resolves getfilecon/setfilecon/freecon at runtime via dlopen rather than #[link]; the host build compiles a shim that reports the op unsupported. The remote syscall sequence (memfd_create, mmap, close) runs behind a closure seam so it is exercised off-device with mocked syscalls. On-device acceptance (zero hook-*.bin residue, no new avc denial) needs real aarch64 init and is not validated here. --- crates/resetprop/src/seal/arena.rs | 6 +- crates/resetprop/src/seal/hook.rs | 349 +++++++++++++++------------ crates/resetprop/src/seal/mod.rs | 1 + crates/resetprop/src/seal/selinux.rs | 115 +++++++++ 4 files changed, 321 insertions(+), 150 deletions(-) create mode 100644 crates/resetprop/src/seal/selinux.rs diff --git a/crates/resetprop/src/seal/arena.rs b/crates/resetprop/src/seal/arena.rs index 70119fc..896187d 100644 --- a/crates/resetprop/src/seal/arena.rs +++ b/crates/resetprop/src/seal/arena.rs @@ -17,10 +17,11 @@ pub(crate) const NR_OPENAT: u64 = 56; pub(crate) const NR_MMAP: u64 = 222; pub(crate) const NR_CLOSE: u64 = 57; pub(crate) const NR_MUNMAP: u64 = 215; +pub(crate) const NR_MEMFD_CREATE: u64 = 279; // fcntl/mman constants (asm-generic/fcntl.h, asm-generic/mman-common.h) pub(crate) const AT_FDCWD: u64 = -100_i64 as u64; // sign-extended to 64 bits -pub(crate) const O_RDONLY: u64 = 0; +pub(crate) const MFD_CLOEXEC: u64 = 0x0001; pub(crate) const O_RDONLY_NOFOLLOW: u64 = 0x20000; pub(crate) const O_RDWR_NOFOLLOW: u64 = 0x20002; pub(crate) const PROT_RW: u64 = 0x3; @@ -725,10 +726,11 @@ mod tests { assert_eq!(NR_MMAP, 222); assert_eq!(NR_CLOSE, 57); assert_eq!(NR_MUNMAP, 215); + assert_eq!(NR_MEMFD_CREATE, 279); // fcntl / mman flags assert_eq!(AT_FDCWD, (-100_i64) as u64); - assert_eq!(O_RDONLY, 0); + assert_eq!(MFD_CLOEXEC, 0x0001); assert_eq!(O_RDONLY_NOFOLLOW, 0x20000); assert_eq!(O_RDWR_NOFOLLOW, 0x20002); assert_eq!(PROT_RW, 0x3); diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index 61add09..5a53a4e 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -23,17 +23,15 @@ //! prefixed message (`"stage-A: : "` / //! `"stage-B: : "`) per the P03 checklist FR-18 / FR-19. +use std::ffi::CString; use std::fs::File; -use std::os::unix::ffi::OsStrExt; -use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; use crate::error::{Error, Result}; use crate::seal; use crate::seal::arena::{ - find_scratch_slot, AT_FDCWD, MAP_PRIVATE, MAP_PRIVATE_ANON, NR_CLOSE, NR_MMAP, NR_MUNMAP, - NR_OPENAT, O_RDONLY, PROT_RW, PROT_RX, + find_scratch_slot, MAP_PRIVATE, MAP_PRIVATE_ANON, MFD_CLOEXEC, NR_CLOSE, NR_MEMFD_CREATE, + NR_MMAP, NR_MUNMAP, PROT_RW, PROT_RX, }; use crate::seal::maps::MapEntry; use crate::seal::ptrace::{ @@ -49,13 +47,6 @@ use crate::seal::ptrace::{ /// the value flows straight into `remote_syscall_via_poke` args. const HOOK_PAGE_SIZE: u64 = 4096; -/// Host directory that receives the stage-B hook body before it is mmap'd -/// file-backed into init. Auto-labels as `u:object_r:adb_data_file:s0` on -/// write, which matches init's `adb_data_file:file { execute map }` allow -/// rule and so avoids the `execmem` denial that anonymous `PROT_READ | -/// PROT_EXEC` would hit on OEMs stripping `process:execmem` from init. -const HOOK_FILE_DIR: &str = "/data/adb/resetprop-rs"; - /// Byte offset inside `lock_list_page` where the host-written hook file /// path is staged before the remote `openat`. Lives past /// [`LOCK_LIST_CAPACITY`] = 1024 so the hook body's in-page walker never @@ -85,7 +76,7 @@ static INSTALL_HARD_FAILURES: AtomicU32 = AtomicU32::new(0); // P04 T3 — hook-page layout and i-cache sync constants /// Hook page byte offset where [`build_hook_body_bytes`]'s 140-byte body -/// lands in the file-backed mapping. Held at 1024 rather than 0 to keep +/// lands in the hook-page mapping. Held at 1024 rather than 0 to keep /// binary compatibility with existing trampoline encoders and tests that /// reference the canonical offset; the preceding 1024 bytes of the hook /// page are zero-padding. @@ -161,11 +152,11 @@ pub(crate) const NR_MEMBARRIER: u64 = 283; #[allow(dead_code)] pub struct HookHandle { pub(crate) pid: libc::pid_t, - /// File-backed `PROT_READ | PROT_EXEC` page in the tracee holding the - /// 140-byte hook body at [`HOOK_BODY_OFFSET`]. Source file is - /// [`HOOK_FILE_DIR`]`/hook--.bin`, unlinked immediately - /// after mmap (kernel retains the mapping via the deleted-inode - /// mechanism, same as bionic's seccomp filter page). + /// `PROT_READ | PROT_EXEC` page in the tracee holding the 140-byte hook + /// body at [`HOOK_BODY_OFFSET`], mapped from an anonymous `memfd` the + /// tracee created and this process filled. The fd is closed after mmap; + /// the mapping survives via the fd's anonymous inode, leaving no on-disk + /// residue. pub(crate) hook_page: u64, /// Anonymous `PROT_READ | PROT_WRITE` page in the tracee holding the /// variable-length lock list at offset 0 (up to [`LOCK_LIST_CAPACITY`] @@ -322,40 +313,37 @@ unsafe fn derive_libc_scratch_pc(pid: libc::pid_t, libc_base: u64, libc_end: u64 /// /// # Page layout /// -/// * `lock_list_page` — anonymous `PROT_READ | PROT_WRITE`. Holds the +/// * `lock_list_page`: anonymous `PROT_READ | PROT_WRITE`. Holds the /// runtime lock list at offset 0 (up to [`LOCK_LIST_CAPACITY`] bytes) -/// and temporarily stages the hook file path at [`PATH_STAGE_OFFSET`] +/// and temporarily stages the memfd name at [`PATH_STAGE_OFFSET`] /// during this install only. -/// * `hook_page` — file-backed `PROT_READ | PROT_EXEC`, sourced from -/// [`HOOK_FILE_DIR`]`/hook--.bin` (written and unlinked -/// within this function). Contains the 140-byte hook body at +/// * `hook_page`: `PROT_READ | PROT_EXEC`, mapped from an anonymous +/// `memfd` the tracee created. Contains the 140-byte hook body at /// [`HOOK_BODY_OFFSET`]; zero padding elsewhere. /// -/// The file-backed page is what lets stage-B run on OEMs that strip -/// `process:execmem` from init's SELinux domain: init keeps the -/// `adb_data_file:file { execute map }` allow rule regardless, so a -/// file written under [`HOOK_FILE_DIR`] (auto-labels as -/// `adb_data_file`) is mmap'able `PROT_R|X` inside init without an -/// SELinux denial. The host file is unlinked immediately after mmap; -/// the kernel retains the mapping via the deleted-inode mechanism. +/// The memfd relabel is what lets stage-B run on OEMs that strip +/// `process:execmem` from init's SELinux domain: the fd is relabelled to +/// the libc.so context init may execute, via `setfilecon` on +/// `/proc//fd/`, so init maps it `PROT_R|X` without an +/// `execmem` or file-execute denial. Nothing reaches disk, so there is no +/// residue to unlink. /// /// # Error cleanup /// /// Any error after `lock_list_page` is mapped issues a best-effort /// remote `munmap` of `lock_list_page` under the same attach window, -/// so the tracee does not leak a 4 KiB page on cold paths. Host file -/// cleanup happens inside the file-backed installer regardless of -/// outcome. +/// so the tracee does not leak a 4 KiB page on cold paths. The memfd is +/// closed in the tracee inside the installer on every path. /// /// # Latency /// /// The install runs inside one `RemoteAttach` window. Observed /// wall-clock on a modern ARM64 handset (Snapdragon-class SoC, bionic /// libc.so ~1.2 MiB, ~5000 `.dynsym` entries): 20-50 ms for the full -/// sequence — `/proc//maps` + libc ELF parse + GNU_HASH walk + -/// lock-list mmap + prologue snapshot + host file write + remote -/// openat / mmap / close + unlink. Any thread that blocks on init -/// for a property write during this window waits out the full stall. +/// sequence of `/proc//maps`, libc ELF parse, GNU_HASH walk, +/// lock-list mmap, prologue snapshot, remote memfd_create, procfs write, +/// relabel, and remote mmap / close. Any thread that blocks on init for +/// a property write during this window waits out the full stall. /// /// # Throttle /// @@ -404,7 +392,7 @@ fn install_init_hook_inner(pid: libc::pid_t) -> Result { let body_bytes = build_hook_body_bytes(saved_prologue, lock_list_page, target_fn + 16); let hook_page = - match install_file_backed_hook_page(pid, scratch_pc, lock_list_page, &body_bytes) { + match install_memfd_hook_page(pid, scratch_pc, lock_list_page, &body_bytes) { Ok(addr) => addr, Err(e) => { best_effort_remote_munmap(pid, scratch_pc, lock_list_page); @@ -533,46 +521,86 @@ fn write_sentinel_and_snapshot_prologue( Ok(saved_prologue) } -/// Materialise the hook body as a file-backed `PROT_READ | PROT_EXEC` -/// mapping in the tracee, using `stage_page + PATH_STAGE_OFFSET` as a -/// scratch buffer for the `openat` pathname. +/// Name attached to the in-init memfd; surfaces only as the basename in +/// `/proc//fd/` and `/proc//maps`. Cosmetic, not a real path. +const MEMFD_NAME: &[u8] = b"resetprop-hook"; + +/// Materialise the hook body as a `PROT_READ | PROT_EXEC` mapping in the +/// tracee, backed by an anonymous in-memory file. No bytes reach disk. /// -/// Host file is always removed before this returns (success or failure); -/// init retains the successful mapping via the deleted-inode mechanism. -fn install_file_backed_hook_page( +/// Ported from injectrc's init_injector: the tracee creates a `memfd`, this +/// process fills it through `/proc//fd/` and relabels it to the +/// libc.so SELinux context, then the tracee maps and closes its own fd. The +/// mapping outlives the close via the fd's anonymous inode, so there is no +/// on-disk residue and no `adb_data_file:file execute` denial to dodge. +fn install_memfd_hook_page( pid: libc::pid_t, scratch_pc: u64, stage_page: u64, body_bytes: &[u8], ) -> Result { - let host_path = write_host_hook_file(pid, body_bytes)?; - let result = mmap_file_backed_in_tracee(pid, scratch_pc, stage_page, &host_path); - let _ = std::fs::remove_file(&host_path); - result + let page = layout_hook_page_bytes(body_bytes)?; + let name_vaddr = stage_cstr_in_tracee(pid, stage_page, MEMFD_NAME)?; + run_memfd_install( + name_vaddr, + // SAFETY: tracee is ptrace-stopped under the caller's attach; + // `scratch_pc` is a libc.text r-xp slot valid for the POKE-driven + // remote syscall ABI. + |syscall_no, args| unsafe { remote_syscall_via_poke(pid, scratch_pc, syscall_no, args) }, + |memfd| publish_page_to_memfd(pid, memfd, &page), + ) } -/// Write the 4 KiB hook page image to disk under [`HOOK_FILE_DIR`]. -/// -/// File layout: zero-padded 4 KiB, with `body_bytes` placed at -/// [`HOOK_BODY_OFFSET`]. Auto-labels as `adb_data_file` so init can -/// open + mmap `PROT_R|X` without an SELinux denial. -fn write_host_hook_file(pid: libc::pid_t, body_bytes: &[u8]) -> Result { - let dir = Path::new(HOOK_FILE_DIR); - std::fs::create_dir_all(dir).map_err(|e| { - Error::HookInstallFailed(format!("stage-B: mkdir_p {}: {e}", dir.display())) - })?; +/// Drive the memfd install sequence: remote `memfd_create`, host-side +/// `publish` (fill + relabel the fd), remote `mmap` `PROT_R|X`, remote +/// `close`. Generic over the remote-syscall executor and `publish` so the +/// sequence is exercised off-device with both mocked. +fn run_memfd_install(name_vaddr: u64, mut remote_syscall: S, publish: P) -> Result +where + S: FnMut(u64, [u64; 6]) -> Result, + P: FnOnce(u64) -> Result<()>, +{ + let memfd = remote_syscall(NR_MEMFD_CREATE, [name_vaddr, MFD_CLOEXEC, 0, 0, 0, 0])?; + if memfd < 0 { + return Err(Error::HookInstallFailed(format!( + "stage-B: memfd_create returned -errno={}", + -memfd + ))); + } + let memfd = memfd as u64; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| Error::HookInstallFailed(format!("stage-B: clock skew: {e}")))? - .as_nanos(); + publish(memfd)?; - let host_path = dir.join(format!("hook-{pid}-{nanos}.bin")); - let file_bytes = layout_hook_page_bytes(body_bytes)?; - std::fs::write(&host_path, &file_bytes).map_err(|e| { - Error::HookInstallFailed(format!("stage-B: write {}: {e}", host_path.display())) - })?; - Ok(host_path) + let mapped = remote_syscall(NR_MMAP, [0, HOOK_PAGE_SIZE, PROT_RX, MAP_PRIVATE, memfd, 0])?; + if (-4095..=-1).contains(&mapped) { + let _ = remote_syscall(NR_CLOSE, [memfd, 0, 0, 0, 0, 0]); + return Err(Error::HookInstallFailed(format!( + "stage-B: memfd mmap returned -errno={}", + -mapped + ))); + } + + let _ = remote_syscall(NR_CLOSE, [memfd, 0, 0, 0, 0, 0]); + Ok(mapped as u64) +} + +/// `/proc//fd/`: the procfs handle this process writes the page +/// image through and relabels. +fn proc_fd_path(pid: libc::pid_t, fd: u64) -> String { + format!("/proc/{pid}/fd/{fd}") +} + +/// Fill init's `memfd` with the 4 KiB page image through `/proc//fd/` +/// and relabel it to the libc.so SELinux context so init can map it `PROT_R|X`. +/// Runs while the tracee is ptrace-stopped under the caller's attach. +fn publish_page_to_memfd(pid: libc::pid_t, memfd: u64, page: &[u8]) -> Result<()> { + let fd_path = proc_fd_path(pid, memfd); + std::fs::write(&fd_path, page) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: write {fd_path}: {e}")))?; + let c_path = CString::new(fd_path.clone()) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: nul in {fd_path}: {e}")))?; + seal::selinux::relabel_to_libc_context(&c_path) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: relabel memfd: {e}"))) } /// Pure helper: assemble a 4 KiB page image with `body_bytes` placed at @@ -595,91 +623,30 @@ fn layout_hook_page_bytes(body_bytes: &[u8]) -> Result> { Ok(bytes) } -/// Open the host file inside the tracee, map it `PROT_R|X`, and close -/// the fd. Caller unlinks the host file. -fn mmap_file_backed_in_tracee( - pid: libc::pid_t, - scratch_pc: u64, - stage_page: u64, - host_path: &Path, -) -> Result { - stage_path_in_tracee(pid, stage_page, host_path)?; - let path_vaddr = stage_page + PATH_STAGE_OFFSET; - - let fd = remote_openat_rdonly(pid, scratch_pc, path_vaddr)?; - let mmap_result = remote_mmap_file_backed_rx(pid, scratch_pc, fd); - - // SAFETY: best-effort close; tracee still stopped. Close errors are - // diagnostic-only because the fd leaks to init's fd table at worst, - // which self-reclaims on next exec. - let _ = unsafe { remote_syscall_via_poke(pid, scratch_pc, NR_CLOSE, [fd, 0, 0, 0, 0, 0]) }; - mmap_result -} - -/// POKE the NUL-terminated host path into the tracee at -/// `stage_page + PATH_STAGE_OFFSET`. -fn stage_path_in_tracee(pid: libc::pid_t, stage_page: u64, host_path: &Path) -> Result<()> { - let path_bytes = host_path.as_os_str().as_bytes(); +/// POKE a NUL-terminated C string into the tracee at `stage_page + +/// PATH_STAGE_OFFSET` and return its tracee vaddr. Stages the memfd name; the +/// bytes are abandoned once `memfd_create` reads them. The slot sits above +/// [`LOCK_LIST_CAPACITY`] (const-asserted below it), so the in-init lock +/// walker never reaches these bytes. +fn stage_cstr_in_tracee(pid: libc::pid_t, stage_page: u64, bytes: &[u8]) -> Result { let max_bytes = HOOK_PAGE_SIZE .saturating_sub(PATH_STAGE_OFFSET) .saturating_sub(1); - if (path_bytes.len() as u64) > max_bytes { + if (bytes.len() as u64) > max_bytes { return Err(Error::HookInstallFailed(format!( - "stage-B: host path too long for stage slot ({} bytes, max {})", - path_bytes.len(), + "stage-B: staged string too long ({} bytes, max {})", + bytes.len(), max_bytes ))); } - let mut nul_path = Vec::with_capacity(path_bytes.len() + 1); - nul_path.extend_from_slice(path_bytes); - nul_path.push(0); + let vaddr = stage_page + PATH_STAGE_OFFSET; + let mut nul = Vec::with_capacity(bytes.len() + 1); + nul.extend_from_slice(bytes); + nul.push(0); // SAFETY: `stage_page` is the fresh PROT_RW anon page; tracee stopped. - unsafe { write_remote(pid, stage_page + PATH_STAGE_OFFSET, &nul_path) } - .map_err(|e| Error::HookInstallFailed(format!("stage-B: stage path: {e}"))) -} - -/// `openat(AT_FDCWD, path_vaddr, O_RDONLY, 0)` in the tracee. -fn remote_openat_rdonly(pid: libc::pid_t, scratch_pc: u64, path_vaddr: u64) -> Result { - // SAFETY: scratch_pc invariants as in `remote_mmap_anon_rw_page`. - let ret = unsafe { - remote_syscall_via_poke( - pid, - scratch_pc, - NR_OPENAT, - [AT_FDCWD, path_vaddr, O_RDONLY, 0, 0, 0], - ) - } - .map_err(|e| Error::HookInstallFailed(format!("stage-B: openat: {e}")))?; - - if ret < 0 { - return Err(Error::HookInstallFailed(format!( - "stage-B: openat returned -errno={}", - -ret - ))); - } - Ok(ret as u64) -} - -/// `mmap(NULL, 4096, PROT_R|X, MAP_PRIVATE, fd, 0)` in the tracee. -fn remote_mmap_file_backed_rx(pid: libc::pid_t, scratch_pc: u64, fd: u64) -> Result { - // SAFETY: scratch_pc invariants as in `remote_mmap_anon_rw_page`. - let ret = unsafe { - remote_syscall_via_poke( - pid, - scratch_pc, - NR_MMAP, - [0, HOOK_PAGE_SIZE, PROT_RX, MAP_PRIVATE, fd, 0], - ) - } - .map_err(|e| Error::HookInstallFailed(format!("stage-B: mmap file-backed: {e}")))?; - - if (-4095..=-1).contains(&ret) { - return Err(Error::HookInstallFailed(format!( - "stage-B: mmap file-backed returned -errno={}", - -ret - ))); - } - Ok(ret as u64) + unsafe { write_remote(pid, vaddr, &nul) } + .map_err(|e| Error::HookInstallFailed(format!("stage-B: stage string: {e}")))?; + Ok(vaddr) } /// Best-effort remote `munmap` for cleanup paths. Errors are swallowed. @@ -1237,9 +1204,9 @@ fn verify_trampoline_readback(word_lo: u64, word_hi: u64, readback: &[u8; 16]) - /// half-written trampoline. pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // Step 1: compute the trampoline's branch target. The 140-byte hook body - // already lives at `hook_page + HOOK_BODY_OFFSET` — `install_init_hook` - // materialised it as a file-backed PROT_R|X mapping, so no runtime - // body write is required here. + // already lives at `hook_page + HOOK_BODY_OFFSET`; `install_init_hook` + // mapped it from init's memfd PROT_R|X, so no runtime body write is + // required here. let hook_body_vaddr = handle.hook_page + HOOK_BODY_OFFSET; // Step 2: acquire attach RAII guard. @@ -2408,4 +2375,90 @@ mod tests { INSTALL_HARD_FAILURES.store(0, Ordering::Relaxed); } + + // T09 / Port 1: memfd install round-trip + + /// `proc_fd_path` formats the procfs handle this process fills and relabels. + #[test] + fn proc_fd_path_formats_procfs_link() { + assert_eq!(proc_fd_path(1, 7), "/proc/1/fd/7"); + } + + /// `run_memfd_install` threads the fd from `memfd_create` into the `mmap` + /// and `close`, runs `publish` against it, and returns the mapped address. + /// Mocked remote syscalls return a non-zero address, exercising the + /// sequence off-device. + #[test] + fn run_memfd_install_threads_fd_and_returns_mapped_addr() { + use std::cell::RefCell; + + let fake_memfd: i64 = 7; + let fake_addr: i64 = 0x0000_7f00_0000; + let calls: RefCell> = RefCell::new(Vec::new()); + let published = RefCell::new(None); + + let addr = run_memfd_install( + 0xdead_beef, + |syscall_no, args| { + calls.borrow_mut().push((syscall_no, args)); + match syscall_no { + NR_MEMFD_CREATE => Ok(fake_memfd), + NR_MMAP => Ok(fake_addr), + NR_CLOSE => Ok(0), + other => panic!("unexpected remote syscall {other}"), + } + }, + |memfd| { + *published.borrow_mut() = Some(memfd); + Ok(()) + }, + ) + .expect("memfd install must succeed with mocked syscalls"); + + assert_eq!(addr, fake_addr as u64, "returns the mmap address as the hook page"); + assert_eq!( + published.borrow().unwrap(), + fake_memfd as u64, + "publish runs against the created fd" + ); + + let calls = calls.borrow(); + assert_eq!(calls[0].0, NR_MEMFD_CREATE); + assert_eq!(calls[0].1[0], 0xdead_beef, "memfd_create reads the staged name vaddr"); + assert_eq!(calls[0].1[1], MFD_CLOEXEC); + assert_eq!(calls[1].0, NR_MMAP); + assert_eq!(calls[1].1[2], PROT_RX, "hook page is mapped PROT_R|X"); + assert_eq!(calls[1].1[4], fake_memfd as u64, "mmap maps the created memfd"); + assert_eq!(calls[2].0, NR_CLOSE); + assert_eq!(calls[2].1[0], fake_memfd as u64, "close releases the memfd"); + } + + /// A `-errno` return from `mmap` surfaces as `HookInstallFailed` and still + /// closes the fd, so a failed install leaves no fd leaked in init. + #[test] + fn run_memfd_install_closes_fd_on_mmap_failure() { + use std::cell::RefCell; + + let closed = RefCell::new(false); + let err = run_memfd_install( + 0, + |syscall_no, _args| match syscall_no { + NR_MEMFD_CREATE => Ok(9), + NR_MMAP => Ok(-12), + NR_CLOSE => { + *closed.borrow_mut() = true; + Ok(0) + } + other => panic!("unexpected remote syscall {other}"), + }, + |_memfd| Ok(()), + ) + .unwrap_err(); + + assert!( + matches!(err, Error::HookInstallFailed(ref m) if m.contains("mmap")), + "mmap failure must surface as HookInstallFailed, got {err:?}" + ); + assert!(*closed.borrow(), "the memfd must be closed even when mmap fails"); + } } diff --git a/crates/resetprop/src/seal/mod.rs b/crates/resetprop/src/seal/mod.rs index 2d4cf0c..7c4d2ec 100644 --- a/crates/resetprop/src/seal/mod.rs +++ b/crates/resetprop/src/seal/mod.rs @@ -9,6 +9,7 @@ pub mod elf; pub mod hook; pub mod maps; pub mod ptrace; +pub mod selinux; /// Process identifier alias matching the libc type used by ptrace/waitpid. pub type Pid = libc::pid_t; diff --git a/crates/resetprop/src/seal/selinux.rs b/crates/resetprop/src/seal/selinux.rs new file mode 100644 index 0000000..72133e3 --- /dev/null +++ b/crates/resetprop/src/seal/selinux.rs @@ -0,0 +1,115 @@ +//! Relabel the in-memory hook fd to the on-device libc.so security context so +//! init can map it `PROT_READ | PROT_EXEC` without an SELinux `execute` denial. +//! +//! Ports injectrc's `set_system_con` (init_injector/ptrace_utils.cpp): take the +//! context of the system libc.so and `setfilecon` it onto the staged fd path. +//! +//! Single-dep law: no `selinux-sys` crate. The Android NDK ships no libselinux +//! to link against, so the Android path resolves `getfilecon` / `setfilecon` / +//! `freecon` at runtime via `dlopen`; on-device the loader always finds +//! `/system/lib*/libselinux.so`. Off-device the relabel is meaningless (there +//! is no real init to satisfy), so the host build compiles a shim that reports +//! the operation unsupported. + +use crate::error::{Error, Result}; +use std::ffi::CStr; + +/// Relabel `path` to the SELinux context init is permitted to execute. +#[cfg(not(target_os = "android"))] +pub(crate) fn relabel_to_libc_context(_path: &CStr) -> Result<()> { + Err(Error::Unsupported( + "SELinux relabel only applies against on-device init".into(), + )) +} + +/// Relabel `path` to the SELinux context init is permitted to execute. +#[cfg(target_os = "android")] +pub(crate) fn relabel_to_libc_context(path: &CStr) -> Result<()> { + android::relabel(path) +} + +#[cfg(target_os = "android")] +mod android { + use super::{CStr, Error, Result}; + use std::ffi::CString; + use std::os::raw::{c_char, c_int, c_void}; + + type GetFileCon = unsafe extern "C" fn(*const c_char, *mut *mut c_char) -> c_int; + type SetFileCon = unsafe extern "C" fn(*const c_char, *const c_char) -> c_int; + type FreeCon = unsafe extern "C" fn(*mut c_char); + + struct Libselinux { + getfilecon: GetFileCon, + setfilecon: SetFileCon, + freecon: FreeCon, + } + + /// libc.so always carries a context init may execute; mirror injectrc's + /// fixed-path probe rather than re-deriving init's exact maps row. + const LIBC_PATH: &CStr = c"/system/lib64/libc.so"; + + /// Used when `getfilecon` on libc.so fails (matches injectrc's fallback). + const FALLBACK_CON: &CStr = c"u:object_r:system_file:s0"; + + pub(super) fn relabel(path: &CStr) -> Result<()> { + let lib = load()?; + let con = libc_context(&lib); + // SAFETY: `path` and `con` are valid NUL-terminated C strings; the fn + // pointer was resolved from libselinux by name. + let rc = unsafe { (lib.setfilecon)(path.as_ptr(), con.as_ptr()) }; + if rc != 0 { + return Err(Error::HookInstallFailed(format!( + "setfilecon({}) failed", + path.to_string_lossy() + ))); + } + Ok(()) + } + + fn load() -> Result { + let name = c"libselinux.so"; + // SAFETY: opening a system library by literal name; RTLD_NOW resolves + // every symbol eagerly so a missing export fails here, not mid-call. + let handle = unsafe { libc::dlopen(name.as_ptr(), libc::RTLD_NOW) }; + if handle.is_null() { + return Err(Error::HookInstallFailed("dlopen libselinux.so failed".into())); + } + // The handle is intentionally never `dlclose`d: libselinux stays + // resident for the single per-boot install and the process is short. + // SAFETY: each symbol is resolved by name then transmuted to its + // libselinux ABI; a null lookup is rejected before transmute. + unsafe { + Ok(Libselinux { + getfilecon: std::mem::transmute::<*mut c_void, GetFileCon>(sym(handle, c"getfilecon")?), + setfilecon: std::mem::transmute::<*mut c_void, SetFileCon>(sym(handle, c"setfilecon")?), + freecon: std::mem::transmute::<*mut c_void, FreeCon>(sym(handle, c"freecon")?), + }) + } + } + + /// SAFETY: `handle` is a live `dlopen` handle. + unsafe fn sym(handle: *mut c_void, name: &CStr) -> Result<*mut c_void> { + let ptr = libc::dlsym(handle, name.as_ptr()); + if ptr.is_null() { + return Err(Error::HookInstallFailed(format!( + "dlsym {} failed", + name.to_string_lossy() + ))); + } + Ok(ptr) + } + + fn libc_context(lib: &Libselinux) -> CString { + let mut con: *mut c_char = std::ptr::null_mut(); + // SAFETY: getfilecon allocates `*con` on success; freed below. + let rc = unsafe { (lib.getfilecon)(LIBC_PATH.as_ptr(), &mut con) }; + if rc < 0 || con.is_null() { + return FALLBACK_CON.to_owned(); + } + // SAFETY: `con` is a NUL-terminated context string owned by libselinux. + let owned = unsafe { CStr::from_ptr(con) }.to_owned(); + // SAFETY: release the libselinux-allocated context. + unsafe { (lib.freecon)(con) }; + owned + } +} From 54494f60245c27d2db83b97e78fa17a4a1ef0dd2 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 05:51:37 +0100 Subject: [PATCH 16/21] chore(bucket): mark T09 done Code + host acceptance complete; on-device residue/avc checks pending hardware (see a98a5b7). --- bucket/resetprop-rs-injectrc-ports/progress.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bucket/resetprop-rs-injectrc-ports/progress.yml b/bucket/resetprop-rs-injectrc-ports/progress.yml index 20af169..3fddd97 100644 --- a/bucket/resetprop-rs-injectrc-ports/progress.yml +++ b/bucket/resetprop-rs-injectrc-ports/progress.yml @@ -9,7 +9,7 @@ tasks: T06: backlog T07: done T08: done - T09: backlog + T09: done T10: backlog T11: cut T12: done From 07740f6fd6a9bface6bca9833ab040680daf5f51 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 14:25:40 +0100 Subject: [PATCH 17/21] feat(seal): observe-init snoop with hook hardening - Add observe-init: a whole-thread-group PTRACE_SYSCALL snoop of init's write(/dev/kmsg) traffic, with per-arch syscall_nr/arg accessors and GET_SYSCALL_INFO entry detection. - Fold the staged-trap injector into one run_remote_payload skeleton shared by the syscall and ISB paths (arena, hook). - Fold first-seal install + trampoline + first append under one thread-group stop; add install_and_seal_first / FirstSeal. - Resolve target_fn against the ELF load bias, not the r-xp segment start, so the trampoline lands on __system_property_update instead of a neighbouring function. --- crates/resetprop/src/lib.rs | 61 ++- crates/resetprop/src/seal/arena.rs | 92 +---- crates/resetprop/src/seal/hook.rs | 376 +++++++++++------ crates/resetprop/src/seal/kmsg_observer.rs | 377 ++++++++++++++++++ crates/resetprop/src/seal/mod.rs | 3 +- crates/resetprop/src/seal/ptrace.rs | 305 +++++++------- .../resetprop/src/seal/ptrace/arch/aarch64.rs | 16 + crates/resetprop/src/seal/ptrace/arch/arm.rs | 16 + .../resetprop/src/seal/ptrace/arch/riscv64.rs | 21 + crates/resetprop/src/seal/ptrace/arch/x86.rs | 24 ++ .../resetprop/src/seal/ptrace/arch/x86_64.rs | 23 ++ crates/resetprop/tests/ptrace_core_smoke.rs | 21 +- 12 files changed, 963 insertions(+), 372 deletions(-) create mode 100644 crates/resetprop/src/seal/kmsg_observer.rs diff --git a/crates/resetprop/src/lib.rs b/crates/resetprop/src/lib.rs index 2ffbb60..5ba8813 100644 --- a/crates/resetprop/src/lib.rs +++ b/crates/resetprop/src/lib.rs @@ -44,7 +44,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::sync::{Mutex, OnceLock}; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; const PROP_DIR: &str = "/dev/__properties__"; @@ -795,13 +795,14 @@ impl PropSystem { /// `set_init` otherwise). Runs BEFORE any ptrace work so a /// misconfigured context fails fast. /// 3. Lazily initialise the shared `HookHandle` under the per-process - /// `OnceLock>>`. `install_init_hook` walks - /// init's `/proc/1/maps` + libc ELF, then `install_trampoline` - /// patches the 16-byte prologue. A failure between the two leaves - /// the slot `None` so the next `seal()` can retry cleanly. - /// 4. `seal::hook::seal_prop(handle, name)` appends the name under the - /// hook's atomic-append invariant (entry bytes → trailing sentinel - /// → length counter). + /// `OnceLock>>`. The first seal runs + /// `install_and_seal_first`, which walks init's `/proc/1/maps` + libc + /// ELF, patches the 16-byte prologue, and appends the name — all under + /// one thread-group stop (audit M5). A failure leaves the slot `None` + /// so the next `seal()` can retry cleanly. + /// 4. Subsequent seals call `seal::hook::seal_prop(handle, name)` to append + /// the name under the hook's atomic-append invariant (entry bytes → + /// trailing sentinel → length counter). /// 5. Record the operation in the process-wide `seals_registry()` as /// `SealTier::Prop`; existing entries with the same `(name, tier)` /// have their timestamp refreshed rather than duplicated. @@ -828,14 +829,24 @@ impl PropSystem { poisoned.into_inner() }); if guard.is_none() { - let mut handle = seal::hook::install_init_hook(seal::INIT_PID)?; - seal::hook::install_trampoline(&mut handle)?; - *guard = Some(handle); + // First seal: install + trampoline + first append under one + // thread-group stop on init (audit M5). The handle is recorded + // whenever the trampoline went live — even if the append failed — + // so a retry appends against the live hook instead of re-installing + // over already-trampolined init. + match seal::hook::install_and_seal_first(seal::INIT_PID, name)? { + seal::hook::FirstSeal::Sealed(handle) => *guard = Some(handle), + seal::hook::FirstSeal::InstalledNotSealed(handle, e) => { + *guard = Some(handle); + return Err(e); + } + } + } else { + let handle = guard + .as_mut() + .expect("hook handle present: slot is Some on this branch"); + seal::hook::seal_prop(handle, name)?; } - let handle = guard - .as_mut() - .expect("hook handle initialised above or was already present"); - seal::hook::seal_prop(handle, name)?; drop(guard); let record = SealRecord { @@ -879,6 +890,26 @@ impl PropSystem { Ok(removed) } + /// Observe-init: trace init (PID 1) for `duration`, mirroring every + /// `write(2)` it makes to a `/dev/kmsg` fd to `sink`. Returns the number + /// of kmsg lines captured. + /// + /// Read-only with respect to init's memory: it single-steps the leader + /// with `PTRACE_SYSCALL` for the window, surfacing the kernel-log lines + /// init emits (e.g. while applying a property set) for diagnostics. + /// AArch64-only, matching the seal subsystem's syscall-number convention; + /// other arches return [`Error::Unsupported`]. + pub fn observe_init(&self, duration: Duration, sink: &mut dyn std::io::Write) -> Result { + require_aarch64()?; + let kmsg_fds = seal::kmsg_observer::discover_kmsg_fds(seal::INIT_PID); + seal::kmsg_observer::snoop_kmsg_writes_for_duration( + seal::INIT_PID, + &kmsg_fds, + duration, + sink, + ) + } + /// Returns a snapshot of the process-wide seal registry. The returned /// `Vec` is an owned clone — callers can iterate without holding the /// internal mutex, and mutations in the registry after the call are diff --git a/crates/resetprop/src/seal/arena.rs b/crates/resetprop/src/seal/arena.rs index 896187d..0fd4a83 100644 --- a/crates/resetprop/src/seal/arena.rs +++ b/crates/resetprop/src/seal/arena.rs @@ -351,80 +351,30 @@ pub(crate) unsafe fn remote_remap_private( })?; let scratch_pc = libc_text.start + slide_offset as u64; - // --- Bootstrap: POKEDATA an svc+brk blob at scratch_pc --------------- - let saved_bytes = super::ptrace::ptrace_peektext(guard.pid(), scratch_pc)?; - - // trap (low 4 bytes) ; brk (high 4 bytes), little-endian pack - let trap_brk: u64 = - (super::ptrace::TRAP_INSN as u64) | ((super::ptrace::BRK_INSN as u64) << 32); - super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, trap_brk)?; - + // --- Bootstrap: mmap a fresh RW scratch page via the shared injector -- + // mmap(NULL, 4096, PROT_RW, MAP_PRIVATE|MAP_ANON, -1, 0) — data-only, no + // execmem requested. Staged through the libc.text `scratch_pc` by the same + // peek/poke injector every other remote syscall in this function uses. + // + // SAFETY: `scratch_pc` is the libc.text NOP slide located above; the tracee + // is ptrace-stopped via `guard`; `remote_syscall_via_poke` saves and + // restores both the scratch word and the register snapshot internally. let bootstrap_page = { - let saved_regs = super::ptrace::getregset(guard.pid())?; - let mut work = saved_regs; - // mmap(NULL, 4096, PROT_RW, MAP_PRIVATE|MAP_ANON, -1, 0) — page is - // data-only, so no execmem is requested. Staged through the arch- - // neutral interface so no raw register index appears here. - super::ptrace::set_syscall_args( - &mut work, - scratch_pc, - NR_MMAP, - [ - 0, // addr = NULL - BOOTSTRAP_PAGE_SIZE, // len = 4096 - PROT_RW, // prot - MAP_PRIVATE_ANON, // flags - (-1_i64) as u64, // fd = -1 - 0, // offset - ], - ); - - super::ptrace::setregset(guard.pid(), &work)?; - - // SAFETY: `libc::ptrace` FFI; tracee is stopped per RemoteAttach's - // post-wait_stop contract; `addr` / `data` are NULL per the - // PTRACE_CONT contract. - let rc = unsafe { - libc::ptrace( - super::ptrace::PTRACE_CONT as _, + let ret = unsafe { + super::ptrace::remote_syscall_via_poke( guard.pid(), - std::ptr::null_mut::(), - std::ptr::null_mut::(), - ) + scratch_pc, + NR_MMAP, + [ + 0, // addr = NULL + BOOTSTRAP_PAGE_SIZE, // len = 4096 + PROT_RW, // prot + MAP_PRIVATE_ANON, // flags + (-1_i64) as u64, // fd = -1 + 0, // offset + ], + )? }; - if rc == -1 { - // Best-effort restore before propagating, so libc.text isn't - // left with the svc+brk bytes in place. - let _ = super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, saved_bytes); - let _ = super::ptrace::setregset(guard.pid(), &saved_regs); - return Err(Error::PtraceOp(std::io::Error::last_os_error())); - } - - // Always restore scratch bytes + registers before inspecting the - // result. A failure in `wait_stop` or `getregset` must not leave - // libc.text containing live svc+brk or the tracee's saved regs in - // work-state — RemoteAttach::drop would release init into that - // poisoned state and the next thread scheduled at scratch_pc would - // trap on brk #0. On the pre-restore failure paths the errors from - // the restore calls are discarded in favor of the original cause. - let wait_result = super::ptrace::wait_stop(guard.pid(), 0); - if wait_result.is_err() { - let _ = super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, saved_bytes); - let _ = super::ptrace::setregset(guard.pid(), &saved_regs); - } - wait_result?; - - let out_result = super::ptrace::getregset(guard.pid()); - if out_result.is_err() { - let _ = super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, saved_bytes); - let _ = super::ptrace::setregset(guard.pid(), &saved_regs); - } - let out = out_result?; - let ret = super::ptrace::get_syscall_return(&out); - - super::ptrace::ptrace_poketext(guard.pid(), scratch_pc, saved_bytes)?; - super::ptrace::setregset(guard.pid(), &saved_regs)?; - if (-4095..=-1).contains(&ret) { return Err(Error::HookInstallFailed(format!( "bootstrap mmap failed: errno={}", diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index 5a53a4e..89de7ef 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -35,8 +35,7 @@ use crate::seal::arena::{ }; use crate::seal::maps::MapEntry; use crate::seal::ptrace::{ - getregset, ptrace_peektext, ptrace_poketext, read_remote, remote_syscall_via_poke, set_pc, - setregset, wait_stop, write_remote, PTRACE_CONT, + ptrace_poketext, read_remote, remote_syscall_via_poke, run_remote_payload, set_pc, write_remote, }; // Stage-B constants (REGISTRY §1 canonical flag values) @@ -178,6 +177,10 @@ pub struct HookHandle { /// on every property update. P04 reverts the trampoline and unmaps /// both pages explicitly before the handle is dropped. pub(crate) trampoline_installed: bool, + /// `/dev/kmsg` file descriptors init held open at install time, discovered + /// from `/proc//fd` (Port 2). The observe-init snoop reads these to + /// recognise `write(kmsg_fd, …)` traffic among init's syscalls. + pub(crate) kmsg_fds: Vec, } /// Predicate picking the executable `libc.so` mapping out of a parsed maps file. @@ -205,9 +208,11 @@ pub(crate) fn find_libc_text_row(entries: &[MapEntry]) -> Option<&MapEntry> { /// Stage-A of the hook install pipeline — RUN UNDER ATTACH. /// /// Returns `(libc_base, libc_end, target_fn)` where `libc_base` / `libc_end` -/// are the `r-xp` row's `start` / `end` addresses and -/// `target_fn = libc_base + st_value("__system_property_update")` -/// (ET_DYN runtime math per references/android-libc-elf.md §7). +/// are the `r-xp` row's `start` / `end` addresses and `target_fn` is +/// `__system_property_update` resolved against the ELF load bias +/// (`libc_base - r-xp file offset`), not against `libc_base` itself — the +/// executable segment is mapped at a non-zero file offset (ET_DYN runtime math +/// per references/android-libc-elf.md §7). /// /// This MUST be called while the caller holds a live `RemoteAttach` on /// `pid`. Every stage-A observation — `/proc//maps`, the libc row, @@ -229,6 +234,7 @@ fn stage_a_locked(pid: libc::pid_t) -> Result<(u64, u64, u64)> { let libc_base = libc_row.start; let libc_end = libc_row.end; + let libc_text_offset = libc_row.offset; // Some Android kernels (observed on Xiaomi 2409BRN2CA, Android 15, kernel // 6.6.58) expose /proc//map_files at VMA granularity while @@ -263,7 +269,17 @@ fn stage_a_locked(pid: libc::pid_t) -> Result<(u64, u64, u64)> { let st_value = seal::elf::resolve_symbol(&view, "__system_property_update") .map_err(|e| Error::HookInstallFailed(format!("stage-A: resolve_symbol: {e}")))?; - let target_fn = libc_base + // A symbol's st_value is a vaddr relative to the ELF load bias, not to the + // executable segment's mapped start. bionic's libc maps its r-xp segment at + // a non-zero file offset (0x44000 on Android 15 bootstrap libc), and for + // these page-aligned PT_LOADs p_vaddr == p_offset, so the load bias is the + // segment's mapped start minus that file offset. Adding st_value to + // libc_base directly overshoots by the file offset and lands the trampoline + // in a neighbouring function. + let load_bias = libc_base + .checked_sub(libc_text_offset) + .ok_or_else(|| Error::HookInstallFailed("stage-A: libc load bias underflow".into()))?; + let target_fn = load_bias .checked_add(st_value) .ok_or_else(|| Error::HookInstallFailed("stage-A: target_fn overflow".into()))?; @@ -369,12 +385,31 @@ fn install_init_hook_inner(pid: libc::pid_t) -> Result { // M1: reject a non-init PID-1 stand-in before attaching or poking. seal::arena::verify_init_identity(pid)?; - let guard = seal::arena::RemoteAttach::new(pid) + let attach = seal::arena::RemoteAttach::new(pid) .map_err(|e| Error::HookInstallFailed(format!("stage-B: attach: {e}")))?; + let handle = install_init_hook_locked(&attach)?; + + attach + .detach() + .map_err(|e| Error::HookInstallFailed(format!("stage-B: detach: {e}")))?; + + Ok(handle) +} + +/// Stage-B install body under a caller-held [`RemoteAttach`]. +/// +/// The attach/detach lives in the caller so the first-seal path +/// ([`install_and_seal_first`]) can run install + trampoline + first append +/// under one thread-group stop (audit M5) instead of three. The identity guard +/// and the per-boot throttle stay in the public wrappers; this body assumes a +/// live, identity-checked attach on init. +fn install_init_hook_locked(attach: &seal::arena::RemoteAttach) -> Result { + let pid = attach.pid(); + let (libc_base, libc_end, target_fn) = stage_a_locked(pid)?; - // SAFETY: `guard` holds the tracee ptrace-stopped; `libc_base..libc_end` + // SAFETY: `attach` holds the tracee ptrace-stopped; `libc_base..libc_end` // is the `r-xp` row just returned by stage-A on the same process. let scratch_pc = unsafe { derive_libc_scratch_pc(pid, libc_base, libc_end) }?; @@ -391,18 +426,13 @@ fn install_init_hook_inner(pid: libc::pid_t) -> Result { let body_bytes = build_hook_body_bytes(saved_prologue, lock_list_page, target_fn + 16); - let hook_page = - match install_memfd_hook_page(pid, scratch_pc, lock_list_page, &body_bytes) { - Ok(addr) => addr, - Err(e) => { - best_effort_remote_munmap(pid, scratch_pc, lock_list_page); - return Err(e); - } - }; - - guard - .detach() - .map_err(|e| Error::HookInstallFailed(format!("stage-B: detach: {e}")))?; + let hook_page = match install_memfd_hook_page(pid, scratch_pc, lock_list_page, &body_bytes) { + Ok(addr) => addr, + Err(e) => { + best_effort_remote_munmap(pid, scratch_pc, lock_list_page); + return Err(e); + } + }; Ok(HookHandle { pid, @@ -415,9 +445,107 @@ fn install_init_hook_inner(pid: libc::pid_t) -> Result { libc_end, scratch_pc, trampoline_installed: false, + kmsg_fds: seal::kmsg_observer::discover_kmsg_fds(pid), + }) +} + +/// Outcome of [`install_and_seal_first`]. +/// +/// Once [`install_trampoline_locked`] flips the trampoline live, init is +/// modified and the handle MUST be recorded by the caller — even if the first +/// append then fails — so the next seal retries the append against the live +/// hook instead of re-installing over already-trampolined init. A re-install +/// would snapshot the trampoline itself as the "original" prologue (see +/// `write_sentinel_and_snapshot_prologue`) and corrupt the restore path. +pub(crate) enum FirstSeal { + /// Hook installed and the first property appended to the lock list. + Sealed(HookHandle), + /// Hook installed (trampoline live) but the first append failed. The caller + /// must still record the handle; the `Error` is the append failure to + /// surface to the user. + InstalledNotSealed(HookHandle, Error), +} + +/// Install the per-prop hook AND seal the first property under one +/// thread-group stop on `pid` (audit M5). +/// +/// The seal orchestrator used to issue three separate SEIZE/INTERRUPT/DETACH +/// cycles on PID 1 for the first seal — one each for [`install_init_hook`], +/// [`install_trampoline`], and [`seal_prop`]. Folding them under a single +/// [`RemoteAttach`] shrinks the interval init's siblings spend frozen and the +/// number of attach/detach races on PID 1. Subsequent seals (hook already +/// installed) keep using [`seal_prop`] with its own short window. +/// +/// The per-boot install throttle counts failures to *install* the hook +/// (identity, attach, stage-A, trampoline) — the steps that can bootloop init — +/// so a load that bootloops the trampoline patch cannot be retried into the +/// ground. A failure of only the first append does NOT count: the hook is +/// installed and returned via [`FirstSeal::InstalledNotSealed`] so the next +/// seal retries the append. +pub(crate) fn install_and_seal_first(pid: libc::pid_t, name: &str) -> Result { + if name.as_bytes().contains(&0) { + return Err(Error::InvalidKey); + } + if INSTALL_HARD_FAILURES.load(Ordering::Relaxed) >= MAX_INSTALL_HARD_FAILURES { + return Err(Error::HookInstallFailed(format!( + "install throttled after {MAX_INSTALL_HARD_FAILURES} hard failures this boot" + ))); + } + install_and_seal_first_inner(pid, name).inspect_err(|_| { + INSTALL_HARD_FAILURES.fetch_add(1, Ordering::Relaxed); }) } +/// Uncounted first-seal body. [`install_and_seal_first`] wraps this with the +/// per-boot throttle; only an `Err` here (an install failure) advances the +/// counter — an installed-but-unsealed outcome is `Ok`. +fn install_and_seal_first_inner(pid: libc::pid_t, name: &str) -> Result { + // M1: reject a non-init PID-1 stand-in before attaching or poking. + seal::arena::verify_init_identity(pid)?; + + let attach = seal::arena::RemoteAttach::new(pid) + .map_err(|e| Error::HookInstallFailed(format!("stage-B: attach: {e}")))?; + + // Phase 1a — install the hook page. On failure `install_init_hook_locked` + // has already freed its own partial mappings; nothing to preserve. + let mut handle = match install_init_hook_locked(&attach) { + Ok(handle) => handle, + Err(e) => { + if let Err(detach_err) = attach.detach() { + eprintln!("resetprop: detach after first-seal install error failed: {detach_err}"); + } + return Err(e); + } + }; + + // Phase 1b — patch the trampoline. On failure it has reverted its own + // partial write, so init is unmodified. Detach FIRST so the dropped + // handle's `drop_best_effort` can re-attach and free the two install pages + // (its nested attach would fail if init were still seized here), then + // propagate the `Err` (which the throttle counts). + if let Err(e) = install_trampoline_locked(&attach, &mut handle) { + if let Err(detach_err) = attach.detach() { + eprintln!("resetprop: detach after first-seal trampoline error failed: {detach_err}"); + } + return Err(e); + } + + // Phase 2 — first append. The trampoline is live now, so the handle MUST + // reach the caller regardless of the append outcome; a lost handle here + // would make the next seal re-install over trampolined init. Detach runs + // unconditionally so init is never left stopped; a detach failure (init + // frozen) outranks the append result and surfaces as a hard `Err`. + let append = seal_prop_locked(&attach, &mut handle, name); + attach + .detach() + .map_err(|e| Error::HookInstallFailed(format!("stage-B: detach: {e}")))?; + + match append { + Ok(()) => Ok(FirstSeal::Sealed(handle)), + Err(e) => Ok(FirstSeal::InstalledNotSealed(handle, e)), + } +} + /// Result of a [`check_init_hook`] dry-run: the stage-A / scratch-slot facts /// that the real install would compute, surfaced for an operator to validate /// on-device before committing to the trampoline patch. @@ -1036,21 +1164,16 @@ pub fn build_hook_body_bytes( // P04 T3 — trampoline installer + i-cache sync /// Execute a single `isb` in the tracee by staging `isb ; brk #0` at -/// `scratch_pc`, flipping `pc`, resuming, waiting for the brk trap, and -/// restoring the original word and registers. -/// -/// Mirrors the structural skeleton of -/// [`crate::seal::ptrace::remote_syscall_via_poke`] (at `ptrace.rs:627-705`) -/// but carries an instruction payload rather than a syscall payload — the -/// tracee never enters the kernel, so there is no `x8`, no args, no `x0` -/// decode. This is the fallback path for `install_trampoline`'s i-cache -/// sync when `membarrier(PRIVATE_EXPEDITED_SYNC_CORE)` returns `EINVAL` -/// (cmd missing) or `EPERM` (registration missing). -/// -/// Errors after the POKE (wait_stop, regs restore) trigger a best-effort -/// restore of both the scratch word and the saved registers before the -/// original cause propagates; this matches the pattern in -/// `remote_syscall_via_poke` so libc.text is never left poisoned. +/// `scratch_pc` through the shared [`run_remote_payload`] skeleton: it flips +/// `pc` to the staged blob, resumes, waits for the brk, and restores the +/// original word and registers (best-effort on every error path). +/// +/// Instruction payload, not a syscall: the tracee never enters the kernel, so +/// there is no syscall-number register, no args, and no return to decode — the +/// post-trap regs are discarded. This is the fallback path for +/// `install_trampoline`'s i-cache sync when +/// `membarrier(PRIVATE_EXPEDITED_SYNC_CORE)` returns `EINVAL` (cmd missing) or +/// `EPERM` (registration missing), and the i-cache resync on the revert path. /// /// # Safety /// @@ -1060,47 +1183,11 @@ pub fn build_hook_body_bytes( unsafe fn execute_remote_isb(pid: libc::pid_t, scratch_pc: u64) -> Result<()> { let payload: u64 = (encoder::ISB_SY as u64) | ((encoder::BRK_0 as u64) << 32); - let saved_word = ptrace_peektext(pid, scratch_pc)?; - ptrace_poketext(pid, scratch_pc, payload)?; - - let saved_regs = getregset(pid)?; - let mut work = saved_regs; - set_pc(&mut work, scratch_pc); - setregset(pid, &work)?; - - // SAFETY: `libc::ptrace` FFI; `addr` / `data` are NULL per PTRACE_CONT - // contract; tracee is ptrace-stopped via the caller's RemoteAttach. - let rc = unsafe { - libc::ptrace( - PTRACE_CONT as _, - pid, - std::ptr::null_mut::(), - std::ptr::null_mut::(), - ) - }; - if rc == -1 { - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - return Err(Error::PtraceOp(std::io::Error::last_os_error())); - } - - let wait_result = wait_stop(pid, 0); - if wait_result.is_err() { - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - } - wait_result?; - - // Success-path restore is symmetric: capture both results before the - // first `?` so that a failure in `setregset` still triggers the - // `ptrace_poketext` scratch-word restore. The P02 fix at commit - // 910ce69 applied the same pattern to `remote_syscall_via_poke`; - // mirroring it here ensures libc.text cannot be left holding the - // `isb; brk` staged payload if the reg-restore FFI fails. - let reg_res = setregset(pid, &saved_regs); - let poke_res = ptrace_poketext(pid, scratch_pc, saved_word); - reg_res?; - poke_res?; + // SAFETY: forwards the caller's RemoteAttach + scratch_pc contract to the + // shared injector; `set_pc` is the only register staged (no syscall ABI). + unsafe { + run_remote_payload(pid, scratch_pc, payload, |work| set_pc(work, scratch_pc))?; + } Ok(()) } @@ -1109,10 +1196,16 @@ unsafe fn execute_remote_isb(pid: libc::pid_t, scratch_pc: u64) -> Result<()> { /// Called only from `install_trampoline`'s error paths after the 16-byte /// trampoline POKE sequence has begun. Restores the original prologue by /// decoding `saved_prologue` as two little-endian `u64` words and issuing -/// two `PTRACE_POKEDATA` writes. Errors are logged via `eprintln!` and -/// never returned — the caller is already propagating the original cause -/// and a second error would only obscure it. -fn revert_trampoline(pid: libc::pid_t, target_fn: u64, saved_prologue: &[u8; 16]) { +/// two `PTRACE_POKEDATA` writes, then re-syncs the i-cache (L1) so a core +/// cannot keep running the half-written trampoline still cached against the +/// just-restored prologue — the same resync `install_trampoline` runs after a +/// successful write. Errors are logged via `eprintln!` and never returned — +/// the caller is already propagating the original cause and a second error +/// would only obscure it. +/// +/// Caller holds the `RemoteAttach` on `pid` (tracee stopped); `scratch_pc` is +/// the cached libc.text slot the install used for its own i-cache sync. +fn revert_trampoline(pid: libc::pid_t, target_fn: u64, scratch_pc: u64, saved_prologue: &[u8; 16]) { let lo = u64::from_le_bytes([ saved_prologue[0], saved_prologue[1], @@ -1139,6 +1232,16 @@ fn revert_trampoline(pid: libc::pid_t, target_fn: u64, saved_prologue: &[u8; 16] if let Err(e) = ptrace_poketext(pid, target_fn + 8, hi) { eprintln!("resetprop: revert_trampoline hi word failed: {e}"); } + // L1: the writes above restore the prologue bytes, but a core may still + // hold the half-written trampoline in its i-cache; resync so it re-fetches + // the restored prologue. Best-effort — the caller propagates the original + // failure. + // SAFETY: `pid` is ptrace-stopped under the caller's RemoteAttach (this fn + // runs only on `install_trampoline`'s in-attach error path); `scratch_pc` + // is the libc.text slot the install used for its own membarrier/ISB sync. + if let Err(e) = unsafe { execute_remote_isb(pid, scratch_pc) } { + eprintln!("resetprop: revert_trampoline i-cache resync failed: {e}"); + } } /// Compare the trampoline bytes read back from the tracee against the two @@ -1203,16 +1306,44 @@ fn verify_trampoline_readback(word_lo: u64, word_hi: u64, readback: &[u8; 16]) - /// before the error propagates, so the tracee is not left running a /// half-written trampoline. pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { + let attach = seal::arena::RemoteAttach::new(handle.pid) + .map_err(|e| Error::HookInstallFailed(format!("install_trampoline: attach: {e}")))?; + + match install_trampoline_locked(&attach, handle) { + Ok(()) => attach + .detach() + .map_err(|e| Error::HookInstallFailed(format!("install_trampoline: detach: {e}"))), + Err(e) => { + // `install_trampoline_locked` already reverted the partial write + // under this attach window; detach and surface the original cause. + if let Err(detach_err) = attach.detach() { + eprintln!("resetprop: detach after install error failed: {detach_err}"); + } + Err(e) + } + } +} + +/// Trampoline-install body under a caller-held [`RemoteAttach`] (audit M5). +/// +/// Writes the 16-byte trampoline and runs the i-cache sync, reverts the partial +/// write on any post-write failure (the caller's attach window stays live for +/// the revert), then flips `handle.trampoline_installed`. The attach/detach is +/// the caller's: [`install_trampoline`] opens one for the standalone path; +/// [`install_and_seal_first`] shares one window across the whole first seal so +/// PID 1 is stopped once, not three times. +fn install_trampoline_locked( + attach: &seal::arena::RemoteAttach, + handle: &mut HookHandle, +) -> Result<()> { + let pid = attach.pid(); + // Step 1: compute the trampoline's branch target. The 140-byte hook body // already lives at `hook_page + HOOK_BODY_OFFSET`; `install_init_hook` // mapped it from init's memfd PROT_R|X, so no runtime body write is // required here. let hook_body_vaddr = handle.hook_page + HOOK_BODY_OFFSET; - // Step 2: acquire attach RAII guard. - let attach = seal::arena::RemoteAttach::new(handle.pid) - .map_err(|e| Error::HookInstallFailed(format!("install_trampoline: attach: {e}")))?; - // Steps 3-5 run under the attach. A failure after the trampoline write // has begun must revert the trampoline before the error unwinds. Using // a closure lets `?` propagate cleanly while a trailing `match` runs @@ -1234,10 +1365,10 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // /proc/1/task tid), so no sibling executes while these words land. let word_lo = (encoder::LDR_X16_PC8 as u64) | ((encoder::BR_X16 as u64) << 32); let word_hi = hook_body_vaddr; - ptrace_poketext(handle.pid, handle.target_fn, word_lo).map_err(|e| { + ptrace_poketext(pid, handle.target_fn, word_lo).map_err(|e| { Error::HookInstallFailed(format!("install_trampoline: poke tramp lo: {e}")) })?; - ptrace_poketext(handle.pid, handle.target_fn + 8, word_hi).map_err(|e| { + ptrace_poketext(pid, handle.target_fn + 8, word_hi).map_err(|e| { Error::HookInstallFailed(format!("install_trampoline: poke tramp hi: {e}")) })?; @@ -1253,7 +1384,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // needs read permission only, and the tracee is ptrace-stopped via // `attach` so the read cannot race a concurrent write. let mut readback = [0u8; 16]; - unsafe { read_remote(handle.pid, handle.target_fn, &mut readback) }.map_err(|e| { + unsafe { read_remote(pid, handle.target_fn, &mut readback) }.map_err(|e| { Error::HookInstallFailed(format!("install_trampoline: trampoline read-back: {e}")) })?; verify_trampoline_readback(word_lo, word_hi, &readback)?; @@ -1280,7 +1411,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { let register_ret = unsafe { remote_syscall_via_poke( - handle.pid, + pid, handle.scratch_pc, NR_MEMBARRIER, [ @@ -1301,7 +1432,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // SAFETY: same invariants as the register call above — `attach` // holds the tracee stopped; `handle.scratch_pc` is the cached // libc.text slot. - return unsafe { execute_remote_isb(handle.pid, handle.scratch_pc) }; + return unsafe { execute_remote_isb(pid, handle.scratch_pc) }; } if register_ret != 0 { return Err(Error::HookInstallFailed(format!( @@ -1315,7 +1446,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { // the cmd) is treated as a hard failure. let sync_ret = unsafe { remote_syscall_via_poke( - handle.pid, + pid, handle.scratch_pc, NR_MEMBARRIER, [MEMBARRIER_CMD_PRIVATE_EXPEDITED_SYNC_CORE, 0, 0, 0, 0, 0], @@ -1327,7 +1458,7 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { if sync_ret == einval_neg { // SAFETY: same invariants as the register call above. - return unsafe { execute_remote_isb(handle.pid, handle.scratch_pc) }; + return unsafe { execute_remote_isb(pid, handle.scratch_pc) }; } if sync_ret != 0 { return Err(Error::HookInstallFailed(format!( @@ -1338,21 +1469,13 @@ pub fn install_trampoline(handle: &mut HookHandle) -> Result<()> { })(); if let Err(e) = trampoline_result { - revert_trampoline(handle.pid, handle.target_fn, &handle.saved_prologue); - if let Err(detach_err) = attach.detach() { - eprintln!("resetprop: detach after install error failed: {detach_err}"); - } + // Revert under the caller's still-live attach window; the caller detaches. + revert_trampoline(pid, handle.target_fn, handle.scratch_pc, &handle.saved_prologue); return Err(e); } - // Step 6: flip typestate so Drop skips the munmap. + // Flip typestate so Drop skips the munmap. handle.trampoline_installed = true; - - // Step 7: explicit detach surfaces any failure here at the install site. - attach - .detach() - .map_err(|e| Error::HookInstallFailed(format!("install_trampoline: detach: {e}")))?; - Ok(()) } @@ -1455,8 +1578,31 @@ pub fn seal_prop(handle: &mut HookHandle, name: &str) -> Result<()> { let attach = seal::arena::RemoteAttach::new(handle.pid) .map_err(|e| Error::HookInstallFailed(format!("seal_prop: attach: {e}")))?; - // L2 (lock-list write-ordering race) is closed here by T15: `RemoteAttach` - // now freezes init's whole thread group, so no init thread reads this lock + seal_prop_locked(&attach, handle, name)?; + + attach + .detach() + .map_err(|e| Error::HookInstallFailed(format!("seal_prop: detach: {e}")))?; + + Ok(()) +} + +/// Append `name` to the lock list under a caller-held [`RemoteAttach`] (M5). +/// +/// The interior-NUL rejection, attach, and detach live in the caller +/// ([`seal_prop`] for the standalone path, [`install_and_seal_first`] for the +/// folded first seal), so `name` is assumed already validated. The tracer-side +/// `lock_list_len` bump stays inside this body, before the caller detaches, so +/// the handle never lies to the next append. +fn seal_prop_locked( + attach: &seal::arena::RemoteAttach, + handle: &mut HookHandle, + name: &str, +) -> Result<()> { + let pid = attach.pid(); + + // L2 (lock-list write-ordering race) is closed by T15: `RemoteAttach` + // freezes init's whole thread group, so no init thread reads this lock // list during the read-modify-write below. seal_prop's byte ordering // therefore needs no lock-free rewrite. let mut buffer = vec![0u8; LOCK_LIST_CAPACITY as usize]; @@ -1466,7 +1612,7 @@ pub fn seal_prop(handle: &mut HookHandle, name: &str) -> Result<()> { // `LOCK_LIST_CAPACITY` bytes at offset 0 lie inside the 4 KiB page. // Tracee is ptrace-stopped via `attach`, so no concurrent mutation // races the read. - unsafe { read_remote(handle.pid, handle.lock_list_page, &mut buffer) } + unsafe { read_remote(pid, handle.lock_list_page, &mut buffer) } .map_err(|e| Error::HookInstallFailed(format!("seal_prop: read_remote: {e}")))?; let new_len = lock_list_append_bytes( @@ -1492,26 +1638,21 @@ pub fn seal_prop(handle: &mut HookHandle, name: &str) -> Result<()> { // page (`write_end <= LOCK_LIST_CAPACITY`). unsafe { write_remote( - handle.pid, + pid, handle.lock_list_page + write_start as u64, &buffer[write_start..write_end], ) } .map_err(|e| Error::HookInstallFailed(format!("seal_prop: write_remote: {e}")))?; - // Counter-before-detach: once `write_remote` succeeds, the tracee - // observes the extended list. Bumping `handle.lock_list_len` before + // Counter-before-detach: once `write_remote` succeeds, the tracee observes + // the extended list. Bumping `handle.lock_list_len` before the caller's // `attach.detach()` keeps the tracer's view and the tracee's view - // consistent — a panic or signal interrupting the tracer between - // detach and the counter bump would otherwise leave the handle - // lying to the next `seal_prop` and producing an off-by-entry - // overwrite. Addresses Gate 2 round-1 critic MAJOR 3. + // consistent — a panic or signal between detach and the counter bump would + // otherwise leave the handle lying to the next `seal_prop` and producing an + // off-by-entry overwrite. Addresses Gate 2 round-1 critic MAJOR 3. handle.lock_list_len = new_len; - attach - .detach() - .map_err(|e| Error::HookInstallFailed(format!("seal_prop: detach: {e}")))?; - Ok(()) } @@ -1596,6 +1737,7 @@ mod tests { libc_end: 0x7000_0010_0000, scratch_pc: 0x7000_0000_1000, trampoline_installed: false, + kmsg_fds: vec![3, 8], }; assert_eq!(h.pid, 42); assert_eq!(h.hook_page, 0xdeadbeef_cafebabe); @@ -1607,6 +1749,7 @@ mod tests { assert_eq!(h.libc_end, 0x7000_0010_0000); assert_eq!(h.scratch_pc, 0x7000_0000_1000); assert!(!h.trampoline_installed); + assert_eq!(h.kmsg_fds, vec![3, 8]); } /// Exercises `is_libc_row` against the tricky cases called out in the @@ -2140,6 +2283,7 @@ mod tests { libc_end: 0, scratch_pc: 0, trampoline_installed: false, + kmsg_fds: Vec::new(), } } diff --git a/crates/resetprop/src/seal/kmsg_observer.rs b/crates/resetprop/src/seal/kmsg_observer.rs new file mode 100644 index 0000000..55c31fc --- /dev/null +++ b/crates/resetprop/src/seal/kmsg_observer.rs @@ -0,0 +1,377 @@ +//! In-init kmsg observability (Port 2). +//! +//! Discovers the `/dev/kmsg` file descriptors init holds open, then snoops +//! init's `write(kmsg_fd, …)` syscalls for a bounded window so the kernel-log +//! lines init emits become visible without touching its memory. Ported from +//! injectrc `init_injector/injector.cpp:213-232` (kmsg fd discovery) and +//! `:254-310` (the syscall-trace loop). + +use std::fs; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use libc::c_int; + +use super::ptrace::{ + detach_thread_group, getregset, is_thread_gone, nth_syscall_arg, ptrace_interrupt, + ptrace_syscall, read_remote, seize_thread_group, syscall_nr, syscall_stop_op, + PTRACE_EVENT_STOP, PTRACE_SYSCALL_INFO_ENTRY, +}; +use super::Pid; +use crate::error::{Error, Result}; + +/// The symlink target every `/dev/kmsg` fd resolves to under `/proc//fd`. +const KMSG_DEVICE: &str = "/dev/kmsg"; + +/// Discover the `/dev/kmsg` file descriptors open in `pid` by resolving every +/// symlink under `/proc//fd`. +pub(crate) fn discover_kmsg_fds(pid: Pid) -> Vec { + let fd_dir = format!("/proc/{pid}/fd"); + discover_kmsg_fds_in(Path::new(&fd_dir)) +} + +/// Testable core of [`discover_kmsg_fds`]: collect the fd numbers in `fd_dir` +/// whose symlink resolves to `/dev/kmsg`, sorted ascending. +/// +/// An unreadable directory yields an empty list — init with no kmsg fd is an +/// unusual but valid state, not an error to propagate. Non-symlink entries and +/// links to other targets are skipped, mirroring injectrc's `DT_LNK` + +/// target-equality filter. +pub(crate) fn discover_kmsg_fds_in(fd_dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(fd_dir) else { + return Vec::new(); + }; + let mut fds: Vec = entries + .flatten() + .filter(|entry| fs::read_link(entry.path()).is_ok_and(|target| target == Path::new(KMSG_DEVICE))) + .filter_map(|entry| entry.file_name().to_str().and_then(|name| name.parse::().ok())) + .collect(); + fds.sort_unstable(); + fds +} + +/// AArch64 `__NR_write` (asm-generic syscall table). The snoop is gated to +/// aarch64 at the `lib.rs` boundary (`require_aarch64`), matching the rest of +/// the seal subsystem's aarch64 syscall-number convention. +const NR_WRITE: u64 = 64; + +/// Upper bound on a single kmsg write the snoop mirrors. Kernel log lines sit +/// well under this; the cap stops a pathological length argument from forcing +/// an unbounded allocation. +const MAX_KMSG_WRITE: usize = 8192; + +/// Decide whether a syscall-entry register read is a `write` to one of +/// `kmsg_fds`, returning the `(buf, capped_len)` to mirror. Pure over the +/// extracted register values so the fd-match and length-cap logic is unit +/// testable without a live tracee. +fn write_target(nr: u64, fd: u64, buf: u64, len: u64, kmsg_fds: &[u64]) -> Option<(u64, usize)> { + if nr != NR_WRITE || !kmsg_fds.contains(&fd) { + return None; + } + Some((buf, (len as usize).min(MAX_KMSG_WRITE))) +} + +/// On a syscall-entry stop, mirror a `write(kmsg_fd, buf, len)` from the +/// tracee: read its buffer through the size-asserted register facade and +/// return the line with a single trailing newline stripped. `None` when the +/// stopped syscall is not a write to one of `kmsg_fds`. +fn capture_kmsg_write(pid: Pid, kmsg_fds: &[u64]) -> Result> { + let regs = getregset(pid)?; + let Some((buf, len)) = write_target( + syscall_nr(®s), + nth_syscall_arg(®s, 0), + nth_syscall_arg(®s, 1), + nth_syscall_arg(®s, 2), + kmsg_fds, + ) else { + return Ok(None); + }; + if len == 0 { + return Ok(Some(String::new())); + } + let mut bytes = vec![0u8; len]; + // SAFETY: the tracee is ptrace-stopped at a syscall-entry stop; `buf..buf + + // len` is the userspace buffer init passed to write(2), readable in its + // address space. + unsafe { read_remote(pid, buf, &mut bytes)? }; + let text = String::from_utf8_lossy(&bytes); + Ok(Some(text.strip_suffix('\n').unwrap_or(&text).to_string())) +} + +/// RAII whole-thread-group ptrace attach for the snoop: freezes every thread in +/// `pid`'s group (SEIZE + INTERRUPT + wait per tid, re-scanned to a fixpoint), +/// then the snoop single-steps each across syscalls. Detaches the whole group on +/// drop. Tracing every thread, not just the leader, is required because Android +/// writes persistent properties on a non-leader init thread, so a leader-only +/// trace misses their kmsg lines. Unlike [`super::arena::RemoteAttach`] the group +/// is resumed (`PTRACE_SYSCALL`) rather than held frozen for an atomic poke. +struct GroupTracer { + tids: Vec, + detached: bool, +} + +impl GroupTracer { + fn seize(pid: Pid) -> Result { + let tids = seize_thread_group(pid)?; + Ok(Self { + tids, + detached: false, + }) + } + + fn detach(mut self) -> Result<()> { + self.detached = true; + detach_thread_group(&self.tids) + } +} + +impl Drop for GroupTracer { + fn drop(&mut self) { + if !self.detached { + // Best-effort resume on the error path; the kernel also auto- + // detaches every tracee when this short-lived CLI exits. + if let Err(e) = detach_thread_group(&self.tids) { + eprintln!("resetprop: observe-init group detach during unwind failed: {e}"); + } + } + } +} + +/// Stop every thread in `running` (each was resumed with `PTRACE_SYSCALL`, so it +/// owes exactly one stop) so the group detach acts on a fully-stopped group. +/// Interrupts each, then reaps one status per interrupted thread; a thread that +/// exits in the window is dropped from `tids`. Threads already held at a stop +/// are not in `running` and need no action. +fn settle_running(tids: &mut Vec, running: &[Pid]) -> Result<()> { + let mut pending: Vec = Vec::with_capacity(running.len()); + for &tid in running { + match ptrace_interrupt(tid) { + Ok(()) => pending.push(tid), + Err(e) if is_thread_gone(&e) => tids.retain(|&t| t != tid), + Err(e) => return Err(e), + } + } + while !pending.is_empty() { + let mut status: c_int = 0; + // SAFETY: `status` is stack-local; `-1` + `__WALL` reaps a stop from any + // interrupted init thread. + let rc = unsafe { libc::waitpid(-1, &mut status, libc::__WALL) }; + if rc == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + return Err(Error::PtraceOp(err)); + } + pending.retain(|&t| t != rc); + if libc::WIFEXITED(status) || libc::WIFSIGNALED(status) { + tids.retain(|&t| t != rc); + } + } + Ok(()) +} + +extern "C" fn handle_alarm(_signum: c_int) {} + +/// RAII SIGALRM arming so a blocking `waitpid` cannot outlast the observe +/// window when init idles in a long syscall. Installs a no-op handler WITHOUT +/// `SA_RESTART` (so the wait returns `EINTR` rather than auto-restarting), +/// restores the previous disposition and cancels the timer on drop. +struct AlarmGuard { + previous: libc::sigaction, +} + +impl AlarmGuard { + fn arm(seconds: u32) -> Result { + // SAFETY: `sigaction` is a C struct that is valid all-zero (empty mask, + // no flags); `mem::zeroed` is the standard way to stage it. + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = handle_alarm as *const () as libc::sighandler_t; + let mut previous: libc::sigaction = unsafe { std::mem::zeroed() }; + // SAFETY: `action`/`previous` are valid sigaction storage for the + // duration of the call; sa_flags is 0 (no SA_RESTART) so the handler + // interrupts a blocked waitpid. + let rc = unsafe { libc::sigaction(libc::SIGALRM, &action, &mut previous) }; + if rc == -1 { + return Err(Error::Io(io::Error::last_os_error())); + } + // SAFETY: arms a one-shot process timer; no memory effect. + unsafe { libc::alarm(seconds) }; + Ok(Self { previous }) + } +} + +impl Drop for AlarmGuard { + fn drop(&mut self) { + // SAFETY: cancel the pending timer and restore the saved disposition. + unsafe { + libc::alarm(0); + libc::sigaction(libc::SIGALRM, &self.previous, std::ptr::null_mut()); + } + } +} + +/// Trace init (`pid`) for `duration`, mirroring every `write(2)` it makes to a +/// `/dev/kmsg` fd in `kmsg_fds` to `sink`. Returns the number of kmsg lines +/// captured. +/// +/// Single-steps each thread across syscall boundaries with `PTRACE_SYSCALL`, +/// dispatching `waitpid(-1, __WALL)` stops by the thread id they report, and +/// uses [`syscall_stop_op`] to act only on entry stops. The entry/exit toggle +/// the injectrc reference uses is unreliable here: the attach interrupts each +/// thread wherever it idles (often inside a blocking syscall), so the first stop +/// is an exit and a toggle would stay inverted for the whole window. A SIGALRM +/// bounds the wait when every thread idles. Ported from injectrc +/// `init_injector/injector.cpp:254-310`. +pub(crate) fn snoop_kmsg_writes_for_duration( + pid: Pid, + kmsg_fds: &[u64], + duration: Duration, + sink: &mut dyn Write, +) -> Result { + if kmsg_fds.is_empty() || duration.is_zero() { + return Ok(0); + } + + let mut tracer = GroupTracer::seize(pid)?; + // Round the alarm up to whole seconds so it never fires before the + // Instant-based deadline, which would risk a wedged wait on an idle init. + let alarm_secs = (duration.as_secs() + u64::from(duration.subsec_nanos() > 0)) + .clamp(1, u64::from(u32::MAX)) as u32; + let _alarm = AlarmGuard::arm(alarm_secs)?; + let deadline = Instant::now() + duration; + let mut captured = 0usize; + + // Begin syscall-stepping every frozen thread; `running` holds the tids we + // have resumed (each owes exactly one stop), which the deadline path settles + // before detaching. A thread that exits between the group-stop and this + // resume is dropped, not an error. + let mut running: Vec = Vec::with_capacity(tracer.tids.len()); + for &tid in &tracer.tids { + match ptrace_syscall(tid, 0) { + Ok(()) => running.push(tid), + Err(e) if is_thread_gone(&e) => {} + Err(e) => return Err(e), + } + } + tracer.tids.retain(|tid| running.contains(tid)); + + while !running.is_empty() { + let mut status: c_int = 0; + // SAFETY: `status` is stack-local; waitpid writes through it only while + // blocked. `-1` + `__WALL` reaps a stop from any seized init thread. + let rc = unsafe { libc::waitpid(-1, &mut status, libc::__WALL) }; + if rc == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + if Instant::now() >= deadline { + break; + } + continue; + } + return Err(Error::PtraceOp(err)); + } + if libc::WIFEXITED(status) || libc::WIFSIGNALED(status) { + running.retain(|&t| t != rc); + tracer.tids.retain(|&t| t != rc); + continue; + } + if !libc::WIFSTOPPED(status) { + // Not a stop we can resume from (no WCONTINUED requested, so this is + // unreachable in practice). Re-wait rather than poke a thread whose + // stop we did not confirm; its `running` membership is unchanged. + continue; + } + // The thread is held at this stop until we resume it below. + running.retain(|&t| t != rc); + let sig = libc::WSTOPSIG(status); + let is_group_stop = ((status >> 16) & 0xff) as u32 == PTRACE_EVENT_STOP; + if sig == (libc::SIGTRAP | 0x80) { + if syscall_stop_op(rc)? == PTRACE_SYSCALL_INFO_ENTRY { + if let Some(line) = capture_kmsg_write(rc, kmsg_fds)? { + writeln!(sink, "{line}").map_err(Error::Io)?; + captured += 1; + } + } + if Instant::now() >= deadline { + break; + } + ptrace_syscall(rc, 0)?; + running.push(rc); + } else { + // Signal-delivery stop: forward the signal so init's own handling is + // preserved. A group-stop event carries a stop signal that must NOT + // be re-injected, only resumed past. + let inject = if is_group_stop { 0 } else { sig }; + ptrace_syscall(rc, inject)?; + running.push(rc); + } + } + + settle_running(&mut tracer.tids, &running)?; + sink.flush().map_err(Error::Io)?; + tracer.detach()?; + Ok(captured) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + /// Only `/dev/kmsg` links are collected; other devices, regular-file links, + /// and the fd numbers are returned sorted. The link targets need not exist: + /// `read_link` reads the link body, not the destination. + #[test] + fn discover_picks_only_kmsg_links_sorted() { + let fd_dir = tempfile::tempdir().expect("tempdir"); + symlink("/dev/kmsg", fd_dir.path().join("7")).unwrap(); + symlink("/dev/null", fd_dir.path().join("4")).unwrap(); + symlink("/dev/kmsg", fd_dir.path().join("3")).unwrap(); + symlink("/data/local/tmp/init.log", fd_dir.path().join("9")).unwrap(); + + assert_eq!(discover_kmsg_fds_in(fd_dir.path()), vec![3, 7]); + } + + /// init holding no kmsg fd yields an empty list rather than an error. + #[test] + fn discover_no_kmsg_links_is_empty() { + let fd_dir = tempfile::tempdir().expect("tempdir"); + symlink("/dev/null", fd_dir.path().join("1")).unwrap(); + + assert!(discover_kmsg_fds_in(fd_dir.path()).is_empty()); + } + + /// A missing fd directory is the empty case, not a panic. + #[test] + fn discover_missing_dir_is_empty() { + assert!(discover_kmsg_fds_in(Path::new("/proc/nonexistent-pid/fd")).is_empty()); + } + + /// A `write` to a tracked kmsg fd yields its `(buf, len)`. + #[test] + fn write_target_matches_kmsg_write() { + assert_eq!( + write_target(NR_WRITE, 7, 0xdead_0000, 64, &[3, 7]), + Some((0xdead_0000, 64)), + ); + } + + /// A non-`write` syscall, or a write to an untracked fd, is ignored. + #[test] + fn write_target_rejects_non_kmsg() { + assert_eq!(write_target(NR_WRITE, 9, 0x1000, 16, &[3, 7]), None); + assert_eq!(write_target(NR_WRITE + 1, 7, 0x1000, 16, &[3, 7]), None); + } + + /// An oversized length argument is capped so the read cannot allocate + /// unboundedly. + #[test] + fn write_target_caps_length() { + assert_eq!( + write_target(NR_WRITE, 3, 0x2000, u64::MAX, &[3]), + Some((0x2000, MAX_KMSG_WRITE)), + ); + } +} diff --git a/crates/resetprop/src/seal/mod.rs b/crates/resetprop/src/seal/mod.rs index 7c4d2ec..7db68b8 100644 --- a/crates/resetprop/src/seal/mod.rs +++ b/crates/resetprop/src/seal/mod.rs @@ -7,6 +7,7 @@ use std::time::SystemTime; pub mod arena; pub mod elf; pub mod hook; +pub mod kmsg_observer; pub mod maps; pub mod ptrace; pub mod selinux; @@ -43,7 +44,7 @@ pub use arena::{seal_arena, seal_arena_with_mirror, unseal_arena, unseal_arena_w pub use maps::{parse_maps, MapEntry}; pub use ptrace::{ getregset, ptrace_detach, ptrace_interrupt, ptrace_peektext, ptrace_poketext, ptrace_seize, - remote_syscall, setregset, wait_stop, UserPtRegs, + remote_syscall_via_poke, setregset, wait_stop, UserPtRegs, }; /// Process-wide in-memory registry of active seals. diff --git a/crates/resetprop/src/seal/ptrace.rs b/crates/resetprop/src/seal/ptrace.rs index faff50f..4f756dd 100644 --- a/crates/resetprop/src/seal/ptrace.rs +++ b/crates/resetprop/src/seal/ptrace.rs @@ -30,7 +30,8 @@ use std::io; use libc::{c_int, c_void, iovec, process_vm_readv, process_vm_writev}; pub use arch::active::{ - get_syscall_return, set_pc, set_syscall_args, UserPtRegs, BRK_INSN, NT_PRSTATUS_SIZE, TRAP_INSN, + get_syscall_return, nth_syscall_arg, set_pc, set_syscall_args, syscall_nr, UserPtRegs, + BRK_INSN, NT_PRSTATUS_SIZE, TRAP_INSN, }; // PTRACE request numbers — from bionic/libc/kernel/uapi/linux/ptrace.h @@ -38,6 +39,11 @@ pub use arch::active::{ /// `PTRACE_CONT` — resume a stopped tracee. source: linux/ptrace.h:17 pub const PTRACE_CONT: c_int = 7; +/// `PTRACE_SYSCALL` — resume a stopped tracee, stopping again at the next +/// syscall entry or exit. Drives the observe-init kmsg snoop's single-step +/// across init's syscall boundaries. source: linux/ptrace.h:18 +pub const PTRACE_SYSCALL: c_int = 24; + /// `PTRACE_DETACH` — detach from tracee. source: linux/ptrace.h:21 pub const PTRACE_DETACH: c_int = 17; @@ -80,6 +86,16 @@ pub const PTRACE_O_TRACESYSGOOD: c_int = 1; /// source: linux/ptrace.h:99 pub const PTRACE_EVENT_STOP: u32 = 128; +/// `PTRACE_GET_SYSCALL_INFO` — report whether the current syscall-stop is the +/// entry or the exit (and, in its tail, the syscall number / args). The snoop +/// consumes only the leading `op` byte, so the variable `union` tail never has +/// to be modelled. source: linux/ptrace.h:104 (Linux 5.3+) +pub const PTRACE_GET_SYSCALL_INFO: c_int = 0x420e; + +/// `PTRACE_SYSCALL_INFO_ENTRY` — the `op` value flagging a syscall-entry stop +/// in [`PTRACE_GET_SYSCALL_INFO`]. source: linux/ptrace.h:108 +pub const PTRACE_SYSCALL_INFO_ENTRY: u8 = 1; + /// `NT_PRSTATUS` — note type selecting general-purpose regs for REGSET ops. /// source: linux/elf.h:301 pub const NT_PRSTATUS: c_int = 1; @@ -365,6 +381,56 @@ pub fn ptrace_detach(pid: Pid) -> Result<()> { Ok(()) } +/// `PTRACE_SYSCALL` — resume `pid` until its next syscall entry/exit stop, +/// delivering `sig` to the tracee (`0` = none). The observe-init snoop passes +/// `0` to step across a syscall boundary and the pending signal number to +/// forward a signal-delivery stop, so init's own signal handling is preserved +/// while it is traced. +pub fn ptrace_syscall(pid: Pid, sig: c_int) -> Result<()> { + // SAFETY: `libc::ptrace` FFI; `addr` is NULL per PTRACE_SYSCALL, `data` + // carries the signal to inject (integer-in-pointer, standard pattern). The + // tracee is ptrace-stopped (caller just observed a stop), so the resume is + // legal. + let rc = unsafe { + libc::ptrace( + PTRACE_SYSCALL as _, + pid, + std::ptr::null_mut::(), + sig as *mut c_void, + ) + }; + if rc == -1 { + return Err(last_ptrace_op_err()); + } + Ok(()) +} + +/// Read the `op` discriminant of [`PTRACE_GET_SYSCALL_INFO`] for the tracee's +/// current syscall-stop: [`PTRACE_SYSCALL_INFO_ENTRY`] vs the exit value. +/// +/// Only the first byte (`op`, offset 0, stable across kernel versions) is +/// requested, so the variable `union` tail — awkward to mirror and not needed +/// here — is never read. The snoop reads register values through the +/// size-asserted [`getregset`] facade instead. +pub fn syscall_stop_op(pid: Pid) -> Result { + let mut op: u8 = 0; + // SAFETY: `libc::ptrace` FFI. For PTRACE_GET_SYSCALL_INFO `addr` is the + // destination buffer size (1 byte) and `data` points at that buffer; the + // kernel writes `min(addr, available)` bytes, so exactly `op` is filled. + let rc = unsafe { + libc::ptrace( + PTRACE_GET_SYSCALL_INFO as _, + pid, + std::ptr::without_provenance_mut::(1), + &mut op as *mut u8 as *mut c_void, + ) + }; + if rc == -1 { + return Err(last_ptrace_op_err()); + } + Ok(op) +} + // Thread-group stop — T15 / Defect B (freeze every init thread before any poke) /// Upper bound on re-scan passes before a thread group is declared @@ -378,7 +444,7 @@ const MAX_GROUP_SCAN_PASSES: u32 = 8; /// `ESRCH` from seize/interrupt/detach and a thread-exit wait status surfaced by /// [`wait_stop`] as [`Error::PtraceUnexpectedStatus`]. Per-tid `ESRCH` tolerance /// (T15): such a tid is skipped, never a hard error. -fn is_thread_gone(err: &Error) -> bool { +pub(crate) fn is_thread_gone(err: &Error) -> bool { match err { Error::PtraceAttach(e) | Error::PtraceOp(e) => e.raw_os_error() == Some(libc::ESRCH), Error::PtraceUnexpectedStatus(status) => { @@ -754,65 +820,54 @@ pub(crate) unsafe fn write_remote(pid: Pid, remote_addr: u64, buf: &[u8]) -> Res Ok(()) } -// remote_syscall — stage `svc #0 ; brk #0` and run one syscall in the tracee +// run_remote_payload — the single staged-trap injector skeleton -/// Execute `syscall_no(args...)` inside `pid` by staging an 8-byte -/// `svc #0 ; brk #0` blob at `scratch_pc`, resuming until the `brk` traps, -/// and reading `x0` back. +/// Run one staged-payload trap in the tracee under a live ptrace stop. /// -/// Caller must have already: -/// - invoked [`ptrace_seize`] + [`ptrace_interrupt`] on `pid`; -/// - consumed the initial SEIZE stop via [`wait_stop`]; -/// - ensured `scratch_pc` is 4-byte aligned and points inside an executable -/// mapping in the tracee with at least 8 bytes of readable+writable+ -/// executable room (typically a bootstrap `mmap` page or a located libc -/// padding region, per linux-arm64-abi.md §8). +/// Saves the word at `scratch_pc`, POKEs `payload` (a two-instruction blob +/// packed low-half | high-half, the high half always a `brk` so the tracee +/// re-traps), snapshots the registers, applies `setup` to a work copy, resumes +/// with `PTRACE_CONT`, waits for the `brk`, then restores the registers and the +/// scratch word. Returns the post-trap register snapshot for the caller to +/// decode (or ignore, for a pure-instruction payload). /// -/// Returns the raw `x0` as `i64`; values in `-4095..=-1` are `-errno`. +/// Every error path after the POKE issues a best-effort restore of both the +/// scratch word and the saved registers before propagating, so libc.text is +/// never left holding a live `…; brk` blob and the tracee never detaches with +/// its registers in work-state. This is the one skeleton behind +/// [`remote_syscall_via_poke`] (syscall payload) and +/// `super::hook::execute_remote_isb` (instruction payload); keeping it single +/// is what closed the Defect-A drift surface where four near-identical copies +/// disagreed on the trap constant. /// /// # Safety /// -/// Caller guarantees (a) the tracee is ptrace-stopped at entry, (b) -/// `scratch_pc` satisfies the alignment/mapping contract above, and (c) no -/// other thread in the tracee is racing on those 8 bytes. -pub unsafe fn remote_syscall( +/// Caller holds the tracee ptrace-stopped (typically a `RemoteAttach`); +/// `scratch_pc` is 4-byte aligned and points inside an executable mapping with +/// at least 8 bytes of room, and no other thread races those 8 bytes. +/// PEEK/POKEDATA bypasses VMA write bits, so an `r-xp` libc.text NOP slide is a +/// legal `scratch_pc`. +pub(crate) unsafe fn run_remote_payload( pid: Pid, scratch_pc: u64, - syscall_no: u64, - args: [u64; 6], -) -> Result { - // Payload: trap-then-breakpoint little-endian. Derived from the two - // arch-neutral instruction constants so the encoding cannot drift from - // the per-arch ISA citations. - let trap_bytes = TRAP_INSN.to_le_bytes(); - let brk_bytes = BRK_INSN.to_le_bytes(); - let mut payload = [0u8; 8]; - payload[..4].copy_from_slice(&trap_bytes); - payload[4..].copy_from_slice(&brk_bytes); - - // (§7 step 2) Save the 8 bytes we are about to clobber. - let mut saved_bytes = [0u8; 8]; - // SAFETY: forwards caller's `scratch_pc` readability guarantee. - unsafe { read_remote(pid, scratch_pc, &mut saved_bytes)? }; - - // (§7 step 3) Stage the trap+brk blob. - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - unsafe { write_remote(pid, scratch_pc, &payload)? }; - - // (§7 step 4) Snapshot registers so we can restore on exit. - let saved_regs = getregset(pid)?; + payload: u64, + setup: impl FnOnce(&mut UserPtRegs), +) -> Result { + // Save the 8 bytes we are about to clobber (one PEEKDATA word on LP64). + let saved_word = ptrace_peektext(pid, scratch_pc)?; - // (§7 step 5) Build the work register set via the arch-neutral interface: - // pc=scratch, syscall-number register, arg registers. Stack/flags/link - // are left untouched — the kernel uses its own stack across the trap. - let mut work = saved_regs; - set_syscall_args(&mut work, scratch_pc, syscall_no, args); + // Stage the payload via POKEDATA — bypasses VMA write bits so the scratch + // may be an `r-xp` libc.text NOP slide. + ptrace_poketext(pid, scratch_pc, payload)?; - // (§7 step 6) Install the work regs, then resume. + // Snapshot registers so we can restore on exit, then build the work set. + let saved_regs = getregset(pid)?; + let mut work = saved_regs; + setup(&mut work); setregset(pid, &work)?; - // SAFETY: `libc::ptrace` FFI. `addr`/`data` are NULL per PTRACE_CONT - // contract. Tracee is guaranteed ptrace-stopped by the function's own + // SAFETY: `libc::ptrace` FFI. `addr`/`data` are NULL per the PTRACE_CONT + // contract. The tracee is guaranteed ptrace-stopped by this function's // safety contract, so a CONT is legal here. let rc = unsafe { libc::ptrace( @@ -823,148 +878,78 @@ pub unsafe fn remote_syscall( ) }; if rc == -1 { - // Best-effort restore before propagating: libc.text (or whichever - // scratch VMA the caller picked) must not retain live svc+brk, and - // the tracee's saved regs must not remain in work-state. Any error - // here is discarded — the original cause is more informative. - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - let _ = unsafe { write_remote(pid, scratch_pc, &saved_bytes) }; + // Best-effort restore before propagating: scratch must not retain the + // live payload, and the saved regs must not remain in work-state, or + // RemoteAttach::drop detaches the tracee into a poisoned state and the + // next thread scheduled at scratch_pc traps on the breakpoint. + let _ = ptrace_poketext(pid, scratch_pc, saved_word); let _ = setregset(pid, &saved_regs); return Err(last_ptrace_op_err()); } - // (§7 step 7) Wait for the brk trap. `wait_stop` verifies - // `WIFSTOPPED && WSTOPSIG == SIGTRAP && event == 0` atomically per its - // contract — group-stops (event=128) and syscall-stops (signal=0x85) are - // rejected as `Error::PtraceUnexpectedStatus`. + // Wait for the brk trap. `wait_stop` verifies + // `WIFSTOPPED && WSTOPSIG == SIGTRAP && event == 0` atomically. let wait_result = wait_stop(pid, 0); if wait_result.is_err() { - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - let _ = unsafe { write_remote(pid, scratch_pc, &saved_bytes) }; + let _ = ptrace_poketext(pid, scratch_pc, saved_word); let _ = setregset(pid, &saved_regs); } wait_result?; - // (§7 step 8) Read the syscall return register from the post-trap state. + // Read the post-trap register state for the caller to decode. let out_result = getregset(pid); if out_result.is_err() { - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - let _ = unsafe { write_remote(pid, scratch_pc, &saved_bytes) }; + let _ = ptrace_poketext(pid, scratch_pc, saved_word); let _ = setregset(pid, &saved_regs); } let out = out_result?; - let ret = get_syscall_return(&out); - // (§7 step 9) Restore in order: regs first (so pc points back at the - // caller's resume address), then the scratch bytes (so a subsequent - // `remote_syscall` invocation sees pristine memory to clobber). - setregset(pid, &saved_regs)?; - // SAFETY: forwards caller's `scratch_pc` writability guarantee. - unsafe { write_remote(pid, scratch_pc, &saved_bytes)? }; + // Restore regs first (so pc points back at the caller's resume address), + // then the scratch word. Capture both results before the first `?` so a + // reg-restore failure still triggers the scratch-word restore. + let reg_res = setregset(pid, &saved_regs); + let poke_res = ptrace_poketext(pid, scratch_pc, saved_word); + reg_res?; + poke_res?; - Ok(ret) + Ok(out) } -// remote_syscall_via_poke — same as remote_syscall, but PEEK/POKEDATA scratch +// remote_syscall_via_poke — syscall specialization of run_remote_payload -/// Execute `syscall_no(args...)` inside `pid` by staging an 8-byte -/// `svc #0 ; brk #0` blob at `scratch_pc` via [`ptrace_peektext`] / -/// [`ptrace_poketext`] (word-granularity PEEK/POKEDATA) rather than -/// [`read_remote`] / [`write_remote`] (process_vm_readv/writev). +/// Execute `syscall_no(args...)` inside `pid` by staging `svc #0 ; brk #0` at +/// `scratch_pc` and decoding the syscall return register from the post-trap +/// snapshot. /// -/// Rationale: `process_vm_writev` respects VMA write bits and EFAULTs on -/// `r-xp` libc.text; `PTRACE_POKEDATA` bypasses the write bit via the -/// `ptrace_access_vm` kernel path. Use this variant when `scratch_pc` lives -/// inside a libc.text NOP slide (i.e. always, in P02's post-bootstrap flow). -/// -/// Behavior and return semantics are otherwise identical to -/// [`remote_syscall`] — caller contract matches verbatim. +/// Returns the raw return register as `i64`; values in `-4095..=-1` are +/// `-errno`. The scratch VMA need not be writable: PEEK/POKEDATA bypass VMA +/// write bits, so an `r-xp` libc.text NOP slide is a legal target. /// /// # Safety /// -/// Caller guarantees (a) the tracee is ptrace-stopped at entry, (b) -/// `scratch_pc` is 4-byte aligned and points inside an executable mapping -/// with at least 8 bytes of readable+executable room, (c) no other thread -/// in the tracee is racing on those 8 bytes. Unlike [`remote_syscall`] the -/// scratch VMA does NOT need to be writable: PEEK/POKEDATA bypass VMA -/// write bits, so an `r-xp` libc.text NOP slide is a legal target. -pub(crate) unsafe fn remote_syscall_via_poke( +/// Same contract as [`run_remote_payload`]: tracee ptrace-stopped at entry, +/// `scratch_pc` 4-byte aligned and inside an executable mapping with at least +/// 8 bytes of room, no other thread racing those 8 bytes. +pub unsafe fn remote_syscall_via_poke( pid: Pid, scratch_pc: u64, syscall_no: u64, args: [u64; 6], ) -> Result { - // Payload: trap-then-breakpoint little-endian packed into one 64-bit word - // (low half = `TRAP_INSN`, high half = `BRK_INSN`). Same byte pattern as - // [`remote_syscall`]; construction differs only in transport. - let trap_brk: u64 = (TRAP_INSN as u64) | ((BRK_INSN as u64) << 32); - - // Save the 8 bytes we are about to clobber (one PEEKDATA word on LP64). - let saved_word = ptrace_peektext(pid, scratch_pc)?; - - // Stage the trap+brk blob via POKEDATA — bypasses VMA write bits so the - // scratch may be an `r-xp` libc.text NOP slide. - ptrace_poketext(pid, scratch_pc, trap_brk)?; - - // Snapshot registers so we can restore on exit. - let saved_regs = getregset(pid)?; - - // Build the work register set via the arch-neutral interface: pc=scratch, - // syscall-number register, arg registers. Stack/flags/link are left - // untouched — the kernel uses its own stack across the trap. - let mut work = saved_regs; - set_syscall_args(&mut work, scratch_pc, syscall_no, args); - - // Install the work regs, then resume. - setregset(pid, &work)?; - - // SAFETY: `libc::ptrace` FFI. `addr`/`data` are NULL per PTRACE_CONT - // contract. Tracee is guaranteed ptrace-stopped by the function's own - // safety contract, so a CONT is legal here. - let rc = unsafe { - libc::ptrace( - PTRACE_CONT as _, - pid, - std::ptr::null_mut::(), - std::ptr::null_mut::(), - ) + // trap (low 4 bytes) ; brk (high 4 bytes), little-endian pack. + let payload: u64 = (TRAP_INSN as u64) | ((BRK_INSN as u64) << 32); + + // SAFETY: forwards this function's caller contract (tracee stopped, + // `scratch_pc` aligned + executable, no racing thread) to the shared + // skeleton; `set_syscall_args` stages pc + syscall-number + arg registers + // through the arch-neutral facade. + let out = unsafe { + run_remote_payload(pid, scratch_pc, payload, |work| { + set_syscall_args(work, scratch_pc, syscall_no, args); + })? }; - if rc == -1 { - // Best-effort restore before propagating: libc.text must not retain - // live trap+brk at scratch_pc, and the tracee's saved regs must not - // remain in work-state (pc=scratch_pc, syscall register set). - // Otherwise RemoteAttach::drop detaches init into a poisoned state and - // the next thread scheduled at scratch_pc traps on the breakpoint. - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - return Err(last_ptrace_op_err()); - } - - // Wait for the brk trap. `wait_stop` verifies - // `WIFSTOPPED && WSTOPSIG == SIGTRAP && event == 0` atomically. - let wait_result = wait_stop(pid, 0); - if wait_result.is_err() { - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - } - wait_result?; - - // Read the syscall return register from the post-trap state. - let out_result = getregset(pid); - if out_result.is_err() { - let _ = ptrace_poketext(pid, scratch_pc, saved_word); - let _ = setregset(pid, &saved_regs); - } - let out = out_result?; - let ret = get_syscall_return(&out); - - // Restore in order: regs first (so pc points back at the caller's resume - // address), then the scratch word (so a subsequent - // `remote_syscall_via_poke` sees pristine memory to clobber). - setregset(pid, &saved_regs)?; - ptrace_poketext(pid, scratch_pc, saved_word)?; - Ok(ret) + Ok(get_syscall_return(&out)) } // Unit tests diff --git a/crates/resetprop/src/seal/ptrace/arch/aarch64.rs b/crates/resetprop/src/seal/ptrace/arch/aarch64.rs index ac10cc7..d4d90c9 100644 --- a/crates/resetprop/src/seal/ptrace/arch/aarch64.rs +++ b/crates/resetprop/src/seal/ptrace/arch/aarch64.rs @@ -75,6 +75,22 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.regs[0] as i64 } +/// Read the syscall number (`x8`) at a syscall-entry stop. `x8` is preserved +/// across the syscall, so it is also valid at the exit stop. +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.regs[8] +} + +/// Read syscall argument `n` (`0..6` → `x0..x5`) at a syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + match n { + 0..=5 => regs.regs[n], + _ => panic!("syscall arg index {n} out of range (0..6)"), + } +} + /// Point the program counter (`pc`) at `pc` without touching the syscall /// registers. Used by the trampoline i-cache-sync path, which resumes the /// tracee at a staged instruction blob and exchanges no syscall arguments. diff --git a/crates/resetprop/src/seal/ptrace/arch/arm.rs b/crates/resetprop/src/seal/ptrace/arch/arm.rs index 57851e6..eebb385 100644 --- a/crates/resetprop/src/seal/ptrace/arch/arm.rs +++ b/crates/resetprop/src/seal/ptrace/arch/arm.rs @@ -61,6 +61,22 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.uregs[0] as i32 as i64 } +/// Read the syscall number (`r7` = `uregs[7]`) at a syscall-entry stop. +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.uregs[7] as u64 +} + +/// Read syscall argument `n` (`0..6` → `r0..r5` = `uregs[0..6]`) at a +/// syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + match n { + 0..=5 => regs.uregs[n] as u64, + _ => panic!("syscall arg index {n} out of range (0..6)"), + } +} + /// Point the program counter (`uregs[15]`) at `pc` (truncated to the 32-bit /// register width) without touching the syscall registers. Used by the /// trampoline i-cache-sync path, which resumes the tracee at a staged diff --git a/crates/resetprop/src/seal/ptrace/arch/riscv64.rs b/crates/resetprop/src/seal/ptrace/arch/riscv64.rs index 2a0f22d..04e454f 100644 --- a/crates/resetprop/src/seal/ptrace/arch/riscv64.rs +++ b/crates/resetprop/src/seal/ptrace/arch/riscv64.rs @@ -90,6 +90,27 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.a0 as i64 } +/// Read the syscall number (`a7`) at a syscall-entry stop. Provided for facade +/// totality; the riscv64 runtime path is deferred (see module note). +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.a7 +} + +/// Read syscall argument `n` (`0..6` → `a0..a5`) at a syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + match n { + 0 => regs.a0, + 1 => regs.a1, + 2 => regs.a2, + 3 => regs.a3, + 4 => regs.a4, + 5 => regs.a5, + _ => panic!("syscall arg index {n} out of range (0..6)"), + } +} + /// Point the program counter (`pc`) at `pc` without touching the syscall /// registers. Provided for facade totality; the riscv64 runtime seal path is /// deferred (see module note), so this is not exercised against a live tracee. diff --git a/crates/resetprop/src/seal/ptrace/arch/x86.rs b/crates/resetprop/src/seal/ptrace/arch/x86.rs index c01cf2f..03fe369 100644 --- a/crates/resetprop/src/seal/ptrace/arch/x86.rs +++ b/crates/resetprop/src/seal/ptrace/arch/x86.rs @@ -75,6 +75,30 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.eax as i32 as i64 } +/// Read the syscall number at a syscall-entry stop. As on x86-64 the kernel +/// clobbers `eax` to `-ENOSYS` on entry and keeps the real number in +/// `orig_eax`, so the snoop reads `orig_eax`. +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.orig_eax as u64 +} + +/// Read syscall argument `n` (`0..6` → `ebx, ecx, edx, esi, edi, ebp`) at a +/// syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + let arg = match n { + 0 => regs.ebx, + 1 => regs.ecx, + 2 => regs.edx, + 3 => regs.esi, + 4 => regs.edi, + 5 => regs.ebp, + _ => panic!("syscall arg index {n} out of range (0..6)"), + }; + arg as u64 +} + /// Point the program counter (`eip`) at `pc` (truncated to the 32-bit /// register width) without touching the syscall registers. Used by the /// trampoline i-cache-sync path, which resumes the tracee at a staged diff --git a/crates/resetprop/src/seal/ptrace/arch/x86_64.rs b/crates/resetprop/src/seal/ptrace/arch/x86_64.rs index 4167e5e..978318a 100644 --- a/crates/resetprop/src/seal/ptrace/arch/x86_64.rs +++ b/crates/resetprop/src/seal/ptrace/arch/x86_64.rs @@ -86,6 +86,29 @@ pub fn get_syscall_return(regs: &UserPtRegs) -> i64 { regs.rax as i64 } +/// Read the syscall number at a syscall-entry stop. The kernel clobbers `rax` +/// to `-ENOSYS` on entry and preserves the real number in `orig_rax`, so the +/// snoop reads `orig_rax` rather than the injection-side `rax`. +#[inline] +pub fn syscall_nr(regs: &UserPtRegs) -> u64 { + regs.orig_rax +} + +/// Read syscall argument `n` (`0..6` → `rdi, rsi, rdx, r10, r8, r9`) at a +/// syscall-entry stop. +#[inline] +pub fn nth_syscall_arg(regs: &UserPtRegs, n: usize) -> u64 { + match n { + 0 => regs.rdi, + 1 => regs.rsi, + 2 => regs.rdx, + 3 => regs.r10, + 4 => regs.r8, + 5 => regs.r9, + _ => panic!("syscall arg index {n} out of range (0..6)"), + } +} + /// Point the program counter (`rip`) at `pc` without touching the syscall /// registers. Used by the trampoline i-cache-sync path, which resumes the /// tracee at a staged instruction blob and exchanges no syscall arguments. diff --git a/crates/resetprop/tests/ptrace_core_smoke.rs b/crates/resetprop/tests/ptrace_core_smoke.rs index b148eac..9fc626c 100644 --- a/crates/resetprop/tests/ptrace_core_smoke.rs +++ b/crates/resetprop/tests/ptrace_core_smoke.rs @@ -1,12 +1,13 @@ -//! Integration smoke test for the T4 remote_syscall injector. +//! Integration smoke test for the consolidated `remote_syscall_via_poke` +//! injector. //! //! Forks a child that installs an anonymous RWX scratch page and //! enters `libc::pause()`, then the parent seizes + interrupts the -//! child and round-trips `remote_syscall(NR_GETPID, [0; 6])`. Asserts +//! child and round-trips `remote_syscall_via_poke(NR_GETPID, [0; 6])`. Asserts //! the returned `x0` equals the child's PID. //! //! Architecture gate: the entire file is gated behind -//! `#[cfg(target_arch = "aarch64")]` because `remote_syscall` stages the +//! `#[cfg(target_arch = "aarch64")]` because `remote_syscall_via_poke` stages the //! ARM64 byte sequence `svc #0 ; brk #0`, expects the AArch64 syscall //! calling convention (`x8` = syscall number, `x0..x5` = args), and reads //! results through a 272-byte `UserPtRegs` whose layout is aarch64-only @@ -18,7 +19,7 @@ //! //! Preconditions (aarch64 hosts only): //! - /proc/sys/kernel/yama/ptrace_scope <= 1, OR CAP_SYS_PTRACE. -//! - Linux with process_vm_readv/writev (kernel 3.2+). +//! - Linux ptrace PEEK/POKEDATA (universally available). //! //! Runner invocation (the test is #[ignore]'d by default): //! cargo test -p resetprop --test ptrace_core_smoke -- \ @@ -30,7 +31,9 @@ #![cfg(target_arch = "aarch64")] use resetprop::seal::ptrace::PTRACE_EVENT_STOP; -use resetprop::seal::{ptrace_detach, ptrace_interrupt, ptrace_seize, remote_syscall, wait_stop}; +use resetprop::seal::{ + ptrace_detach, ptrace_interrupt, ptrace_seize, remote_syscall_via_poke, wait_stop, +}; /// AArch64 syscall number for `getpid()`; local to this test so the syscall /// table does not leak onto the `resetprop::seal::ptrace` public surface. @@ -151,8 +154,8 @@ fn remote_getpid_returns_child_pid() { // (event byte == PTRACE_EVENT_STOP == 128). wait_stop verifies // WIFSTOPPED && WSTOPSIG == SIGTRAP && event == expected_event — so the // expected_event argument is how the caller selects which stop kind is - // legal at this point. remote_syscall passes 0 internally to pin its - // own waitpid to brk-traps only. + // legal at this point. remote_syscall_via_poke passes 0 internally to pin + // its own waitpid to brk-traps only. ptrace_seize(guard.pid()).expect("ptrace_seize"); ptrace_interrupt(guard.pid()).expect("ptrace_interrupt"); wait_stop(guard.pid(), PTRACE_EVENT_STOP).expect("wait_stop (initial SEIZE stop)"); @@ -165,8 +168,8 @@ fn remote_getpid_returns_child_pid() { // names an RWX page with 4096 bytes of room — far more than the 8 bytes // the injector stages at `scratch_pc`. The child is single-threaded and // blocked in pause(), so no other thread races on those 8 bytes. - let ret = unsafe { remote_syscall(guard.pid(), scratch_pc, NR_GETPID, [0; 6]) } - .expect("remote_syscall"); + let ret = unsafe { remote_syscall_via_poke(guard.pid(), scratch_pc, NR_GETPID, [0; 6]) } + .expect("remote_syscall_via_poke"); assert_eq!( ret, child_pid as i64, From d863ab2c8a9d40e3a413c10bd4ddf58a3592696d Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 14:25:47 +0100 Subject: [PATCH 18/21] feat(cli): repeatable --seal and --observe-init - Make --seal repeatable in one run so a single process builds a multi-entry lock list under one shared hook handle, removing the one-seal-per-boot trap. - Add --observe-init [--duration SECS] to dump init's /dev/kmsg writes via PropSystem::observe_init. - Refresh usage and options text. --- crates/resetprop-cli/src/main.rs | 116 +++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/crates/resetprop-cli/src/main.rs b/crates/resetprop-cli/src/main.rs index 23d7076..9f77abf 100644 --- a/crates/resetprop-cli/src/main.rs +++ b/crates/resetprop-cli/src/main.rs @@ -3,6 +3,9 @@ use std::process::ExitCode; use resetprop::{Error, PersistStore, PropSystem}; +/// Default observe-init window when `--duration` is omitted. +const DEFAULT_OBSERVE_SECS: u64 = 5; + fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -30,7 +33,7 @@ fn run() -> Result<(), String> { let mut wait_name: Option = None; let mut wait_value: Option = None; let mut timeout_secs: Option = None; - let mut seal: Option = None; + let mut seals: Vec<(String, Option)> = Vec::new(); let mut check = false; let mut seal_arena: Option = None; let mut unseal: Option = None; @@ -39,6 +42,8 @@ fn run() -> Result<(), String> { let mut if_diff = false; let mut if_match: Option = None; let mut delete_if_exist: Option = None; + let mut observe_init = false; + let mut duration_secs: Option = None; let mut positional = Vec::new(); let mut i = 0; @@ -64,7 +69,17 @@ fn run() -> Result<(), String> { "--stealth" | "-st" => stealth = true, "--seal" | "-sl" => { i += 1; - seal = Some(arg_val(&args, i, "--seal")?); + let name = arg_val(&args, i, "--seal")?; + // VALUE is the next arg unless it is another flag (e.g. --check, + // a dry-run that takes only NAME). Repeating --seal builds a + // multi-entry lock list under one process / one HookHandle. + let value = if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + Some(args[i].clone()) + } else { + None + }; + seals.push((name, value)); } "--check" => check = true, "--seal-arena" | "-sla" => { @@ -114,6 +129,15 @@ fn run() -> Result<(), String> { .map_err(|_| "--timeout requires a number".to_string())?, ); } + "--observe-init" => observe_init = true, + "--duration" => { + i += 1; + let s = arg_val(&args, i, "--duration")?; + duration_secs = Some( + s.parse::() + .map_err(|_| "--duration requires a number".to_string())?, + ); + } "-h" | "--help" => { print_usage(); return Ok(()); @@ -133,11 +157,12 @@ fn run() -> Result<(), String> { || compact || file.is_some() || wait_name.is_some() - || seal.is_some() + || !seals.is_empty() || seal_arena.is_some() || unseal.is_some() || unseal_arena.is_some() - || list_seals; + || list_seals + || observe_init; let any_mode_flag = init || persist || persist_read || stealth || quiet; if if_diff && if_match.is_some() { @@ -177,6 +202,19 @@ fn run() -> Result<(), String> { } .map_err(|e| format!("failed to open property system: {e}"))?; + if observe_init { + let duration = std::time::Duration::from_secs(duration_secs.unwrap_or(DEFAULT_OBSERVE_SECS)); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + let count = sys + .observe_init(duration, &mut out) + .map_err(|e| format!("observe-init failed: {e}"))?; + if verbose { + eprintln!("observe-init: captured {count} kmsg line(s)"); + } + return Ok(()); + } + if let Some(name) = hexpatch { return bool_op(sys.hexpatch_delete(&name), &name, "hexpatch", verbose); } @@ -229,7 +267,7 @@ fn run() -> Result<(), String> { } let seal_flag_count = [ - seal.is_some(), + !seals.is_empty(), seal_arena.is_some(), unseal.is_some(), unseal_arena.is_some(), @@ -261,11 +299,18 @@ fn run() -> Result<(), String> { return bool_op(sys.unseal_arena(name), name, "unsealed(arena)", verbose); } - if let Some(ref name) = seal { + if !seals.is_empty() { if check { // Dry-run: resolve the Tier B facts in init without poking it. No - // value is written and no trampoline is installed, so VALUE is not - // required here. INIT_PID is `1` (crate-internal in resetprop). + // value is written and no trampoline is installed. Single-NAME only; + // the resolution is name-independent. INIT_PID is `1`. + if seals.len() != 1 { + return Err( + "--seal --check is a single-NAME dry-run; pass exactly one --seal NAME" + .to_string(), + ); + } + let (name, _) = &seals[0]; return match resetprop::seal::hook::check_init_hook(1) { Ok(r) => { println!( @@ -282,28 +327,37 @@ fn run() -> Result<(), String> { Err(e) => Err(format!("check failed: {e}")), }; } - let value = positional - .first() - .ok_or_else(|| "--seal requires NAME VALUE (VALUE missing)".to_string())?; - return match sys.seal(name, value) { - Ok(record) => { - if verbose { - eprintln!( - "sealed: [{}] tier={:?} arena={}", - record.name, - record.tier, - record.arena_path.display() - ); + // Seal each prop under one process so the lazily-installed HookHandle is + // shared: the first append installs the trampoline, the rest extend the + // same lock list. The first hard install failure aborts the batch. + for (name, value) in &seals { + let value = value.as_deref().ok_or_else(|| { + format!("--seal requires NAME VALUE (VALUE missing for {name})") + })?; + match sys.seal(name, value) { + Ok(record) => { + if verbose { + eprintln!( + "sealed: [{}] tier={:?} arena={}", + record.name, + record.tier, + record.arena_path.display() + ); + } } - Ok(()) + Err( + e @ (Error::HookInstallFailed(_) + | Error::ElfParse(_) + | Error::SymbolNotFound(_)), + ) => { + return Err(format!( + "Tier B hook install failed for {name}: {e}. Try --seal-arena for Tier A fallback." + )) + } + Err(e) => return Err(format!("seal failed for {name}: {e}")), } - Err( - e @ (Error::HookInstallFailed(_) | Error::ElfParse(_) | Error::SymbolNotFound(_)), - ) => Err(format!( - "Tier B hook install failed: {e}. Try --seal-arena for Tier A fallback." - )), - Err(e) => Err(format!("seal failed: {e}")), - }; + } + return Ok(()); } if let Some(ref name) = seal_arena { @@ -493,6 +547,7 @@ Usage: resetprop --stealth|-st NAME VALUE Set with zeroed serial, no wake signals resetprop --stealth|-st -p NAME VALUE Set stealth + persist to disk resetprop --seal|-sl NAME VALUE Stealth write + Tier B per-prop init hook (default seal) + resetprop --seal A v1 --seal B v2 Seal several props in one run (one multi-entry lock list) resetprop --seal|-sl NAME --check Dry-run: resolve Tier B in init, write nothing resetprop --seal-arena|-sla NAME VALUE Stealth write + Tier A arena privatize (fallback) resetprop --unseal NAME Remove NAME from the Tier B lock list @@ -505,6 +560,7 @@ Usage: resetprop -f FILE Load properties from file (name=value) resetprop --wait NAME [VALUE] Wait for property to exist or equal VALUE resetprop --timeout SECS Timeout for --wait (default: forever) + resetprop --observe-init [--duration SECS] Trace init's /dev/kmsg writes for a window (aarch64) resetprop --dir PATH Use custom property directory Options: @@ -516,12 +572,14 @@ Options: --if-match NEEDLE Conditional set: write NAME=VALUE only when current value equals NEEDLE --delete-if-exist NAME Conditional delete: no-op when NAME is absent --stealth, -st Stealth write: bionic compose, no futex wake, no global notify - --seal, -sl Tier B seal: stealth write + per-prop hook on __system_property_update in init + --seal, -sl Tier B seal: stealth write + per-prop hook on __system_property_update in init (repeatable: one run, one multi-entry lock list) --check With --seal: dry-run the Tier B install (resolve only, no ptrace write) --seal-arena, -sla Tier A seal: stealth write + remap init's arena as MAP_PRIVATE|MAP_FIXED --unseal NAME Remove NAME from the in-init Tier B lock list --unseal-arena NAME Revert Tier A privatization for the arena holding NAME --seals List currently active seals for this session + --observe-init Trace init (PID 1) writes to /dev/kmsg and print them (aarch64 only) + --duration SECS With --observe-init: observe window in seconds (default: 5) --compact Reclaim arena space left by deleted properties -v Verbose output -h, --help Show this help" From fa6fe715238b305b6938648916ed9c70939d0400 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 14:25:47 +0100 Subject: [PATCH 19/21] chore: ignore the serena scratch directory - .serena/ holds codeintel cache and project config, not source. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 405a0e7..94f9585 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # seal: regenerable output + agent scratch (keep execution sessions lean) *.bak +.serena/ graphify-out/ target/ .gradle/ From 3ddd86a5f73c0e360940c516791a7a52917e0bd9 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 14:25:47 +0100 Subject: [PATCH 20/21] chore(bucket): close T06, T10, T17 - T06 Tier B on-device gate, T10 kmsg snoop, and T17 injector consolidation now complete. --- bucket/resetprop-rs-injectrc-ports/progress.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bucket/resetprop-rs-injectrc-ports/progress.yml b/bucket/resetprop-rs-injectrc-ports/progress.yml index 3fddd97..1b8a894 100644 --- a/bucket/resetprop-rs-injectrc-ports/progress.yml +++ b/bucket/resetprop-rs-injectrc-ports/progress.yml @@ -6,17 +6,17 @@ tasks: T03: done T04: done T05: done - T06: backlog + T06: done T07: done T08: done T09: done - T10: backlog + T10: done T11: cut T12: done T13: done T14: done T15: done T16: done - T17: backlog + T17: done T18: done T19: done From 277525d89b3ce7b5ef70bdf3567038d6728cd417 Mon Sep 17 00:00:00 2001 From: Enginex0 Date: Thu, 18 Jun 2026 14:29:29 +0100 Subject: [PATCH 21/21] chore: format the workspace with rustfmt - rustfmt the observe-init/multi-seal additions and the pre-existing selinux row, so the CI fmt gate passes. --- crates/resetprop-cli/src/main.rs | 9 ++++---- crates/resetprop/src/seal/hook.rs | 27 ++++++++++++++++++---- crates/resetprop/src/seal/kmsg_observer.rs | 11 +++++++-- crates/resetprop/src/seal/selinux.rs | 14 ++++++++--- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/crates/resetprop-cli/src/main.rs b/crates/resetprop-cli/src/main.rs index 9f77abf..db3345b 100644 --- a/crates/resetprop-cli/src/main.rs +++ b/crates/resetprop-cli/src/main.rs @@ -203,7 +203,8 @@ fn run() -> Result<(), String> { .map_err(|e| format!("failed to open property system: {e}"))?; if observe_init { - let duration = std::time::Duration::from_secs(duration_secs.unwrap_or(DEFAULT_OBSERVE_SECS)); + let duration = + std::time::Duration::from_secs(duration_secs.unwrap_or(DEFAULT_OBSERVE_SECS)); let stdout = std::io::stdout(); let mut out = stdout.lock(); let count = sys @@ -331,9 +332,9 @@ fn run() -> Result<(), String> { // shared: the first append installs the trampoline, the rest extend the // same lock list. The first hard install failure aborts the batch. for (name, value) in &seals { - let value = value.as_deref().ok_or_else(|| { - format!("--seal requires NAME VALUE (VALUE missing for {name})") - })?; + let value = value + .as_deref() + .ok_or_else(|| format!("--seal requires NAME VALUE (VALUE missing for {name})"))?; match sys.seal(name, value) { Ok(record) => { if verbose { diff --git a/crates/resetprop/src/seal/hook.rs b/crates/resetprop/src/seal/hook.rs index 89de7ef..61fda9a 100644 --- a/crates/resetprop/src/seal/hook.rs +++ b/crates/resetprop/src/seal/hook.rs @@ -1470,7 +1470,12 @@ fn install_trampoline_locked( if let Err(e) = trampoline_result { // Revert under the caller's still-live attach window; the caller detaches. - revert_trampoline(pid, handle.target_fn, handle.scratch_pc, &handle.saved_prologue); + revert_trampoline( + pid, + handle.target_fn, + handle.scratch_pc, + &handle.saved_prologue, + ); return Err(e); } @@ -2559,7 +2564,10 @@ mod tests { ) .expect("memfd install must succeed with mocked syscalls"); - assert_eq!(addr, fake_addr as u64, "returns the mmap address as the hook page"); + assert_eq!( + addr, fake_addr as u64, + "returns the mmap address as the hook page" + ); assert_eq!( published.borrow().unwrap(), fake_memfd as u64, @@ -2568,11 +2576,17 @@ mod tests { let calls = calls.borrow(); assert_eq!(calls[0].0, NR_MEMFD_CREATE); - assert_eq!(calls[0].1[0], 0xdead_beef, "memfd_create reads the staged name vaddr"); + assert_eq!( + calls[0].1[0], 0xdead_beef, + "memfd_create reads the staged name vaddr" + ); assert_eq!(calls[0].1[1], MFD_CLOEXEC); assert_eq!(calls[1].0, NR_MMAP); assert_eq!(calls[1].1[2], PROT_RX, "hook page is mapped PROT_R|X"); - assert_eq!(calls[1].1[4], fake_memfd as u64, "mmap maps the created memfd"); + assert_eq!( + calls[1].1[4], fake_memfd as u64, + "mmap maps the created memfd" + ); assert_eq!(calls[2].0, NR_CLOSE); assert_eq!(calls[2].1[0], fake_memfd as u64, "close releases the memfd"); } @@ -2603,6 +2617,9 @@ mod tests { matches!(err, Error::HookInstallFailed(ref m) if m.contains("mmap")), "mmap failure must surface as HookInstallFailed, got {err:?}" ); - assert!(*closed.borrow(), "the memfd must be closed even when mmap fails"); + assert!( + *closed.borrow(), + "the memfd must be closed even when mmap fails" + ); } } diff --git a/crates/resetprop/src/seal/kmsg_observer.rs b/crates/resetprop/src/seal/kmsg_observer.rs index 55c31fc..2bbab7b 100644 --- a/crates/resetprop/src/seal/kmsg_observer.rs +++ b/crates/resetprop/src/seal/kmsg_observer.rs @@ -44,8 +44,15 @@ pub(crate) fn discover_kmsg_fds_in(fd_dir: &Path) -> Vec { }; let mut fds: Vec = entries .flatten() - .filter(|entry| fs::read_link(entry.path()).is_ok_and(|target| target == Path::new(KMSG_DEVICE))) - .filter_map(|entry| entry.file_name().to_str().and_then(|name| name.parse::().ok())) + .filter(|entry| { + fs::read_link(entry.path()).is_ok_and(|target| target == Path::new(KMSG_DEVICE)) + }) + .filter_map(|entry| { + entry + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()) + }) .collect(); fds.sort_unstable(); fds diff --git a/crates/resetprop/src/seal/selinux.rs b/crates/resetprop/src/seal/selinux.rs index 72133e3..be091d2 100644 --- a/crates/resetprop/src/seal/selinux.rs +++ b/crates/resetprop/src/seal/selinux.rs @@ -72,7 +72,9 @@ mod android { // every symbol eagerly so a missing export fails here, not mid-call. let handle = unsafe { libc::dlopen(name.as_ptr(), libc::RTLD_NOW) }; if handle.is_null() { - return Err(Error::HookInstallFailed("dlopen libselinux.so failed".into())); + return Err(Error::HookInstallFailed( + "dlopen libselinux.so failed".into(), + )); } // The handle is intentionally never `dlclose`d: libselinux stays // resident for the single per-boot install and the process is short. @@ -80,8 +82,14 @@ mod android { // libselinux ABI; a null lookup is rejected before transmute. unsafe { Ok(Libselinux { - getfilecon: std::mem::transmute::<*mut c_void, GetFileCon>(sym(handle, c"getfilecon")?), - setfilecon: std::mem::transmute::<*mut c_void, SetFileCon>(sym(handle, c"setfilecon")?), + getfilecon: std::mem::transmute::<*mut c_void, GetFileCon>(sym( + handle, + c"getfilecon", + )?), + setfilecon: std::mem::transmute::<*mut c_void, SetFileCon>(sym( + handle, + c"setfilecon", + )?), freecon: std::mem::transmute::<*mut c_void, FreeCon>(sym(handle, c"freecon")?), }) }