Files
reverse-skills-max/vendor-new/apk-reverse/docs/tool-verification/EXTENSION-lsposed.md

568 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Extension verification — LSPosed module route
Covers the 2026-09 extension pass that added `skills/apk-reverse/references/lsposed-and-modules.md`
and `skills/apk-reverse/scripts/lsposed_scaffold.py`. Strength labels as defined in this directory's
README: **observed** (exact command + output here), **inferred** (follows from observed facts, step
not executed), **unverified** (assumed or externally reported, not independently confirmed).
Reference environment for this pass: rooted physical device, Android 11 (API 30), arm64-v8a,
Magisk alpha + Zygisk, LSPosed v1.9.2 (7024) activated, Zygisk-Assistant v2.1.4 installed, MT Manager
2.26.9 installed, one hardened Flutter sample installed (referred to below as *the sample*; no target
identity is recorded here). Host: Windows, JDK 17.0.4.1, Android build-tools 34.0.0, API 28
`android.jar`, Xposed `api-82` stub jar.
**Identifier convention:** every occurrence of the sample's package identifier in the transcripts
below is normalized to `<PKG>` (`<PKG>.MainActivity`, `<PKG>.BaseApplication`), and its sub-package
prefixes are shortened the same way. The transcripts are otherwise verbatim, including the class
names a hook reported — those are what make the evidence checkable.
This pass closes an item left open by the previous extension record, which noted that "no LSPosed hook
module was built or run against a real target" (`EXTENSION-kernel-ondevice.md` §4). The build half is
now **observed**; the injection half is **partially observed** and the gap is stated in §6.
---
## 1. Scaffold script — observed
### 1.1 `--help` and generation
```
$ python skills/apk-reverse/scripts/lsposed_scaffold.py --help
usage: lsposed_scaffold.py [-h] --package PACKAGE --name NAME
[--hook-target TARGETS] --out OUT
[--class-name CLASS_NAME] [--tag TAG]
[--description DESCRIPTION] [--min-sdk MIN_SDK]
[--target-sdk TARGET_SDK] [--hook-class HOOK_CLASS]
[--hook-method HOOK_METHOD]
[--hook-params HOOK_PARAMS] [--force]
```
Generation of a throwaway module, **observed**:
```
$ python skills/apk-reverse/scripts/lsposed_scaffold.py \
--package com.example.probe --name "Example probe" \
--hook-target com.example.target \
--hook-class com.example.target.Helper --hook-method check \
--hook-params "java.lang.String" --out <work>/scaffold-gen
[+] <work>/scaffold-gen/AndroidManifest.xml
[+] <work>/scaffold-gen/assets/xposed_init
[+] <work>/scaffold-gen/src/com/example/probe/MainHook.java
[+] <work>/scaffold-gen/README.md
```
`assets/xposed_init` contains exactly one line, `com.example.probe.MainHook` (**observed**). Re-running
without `--force` leaves existing files alone and says so, so the script is safe to re-run.
### 1.2 The generated source compiles as written
The point of the scaffold is that its output builds, so this was checked rather than assumed —
**observed**, exit code 0, four class files (entry class plus three anonymous hook classes):
```
$ javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp "<android.jar>;<api-82.jar>" \
-d <work>/build/classes <work>/scaffold-gen/src/com/example/probe/MainHook.java
$ echo $?
0
MainHook.class MainHook$1.class MainHook$2.class MainHook$3.class
```
## 2. Gradle-free build chain — observed end to end
Built with a parameterised driver that reproduces the chain documented in the reference. Times are
wall-clock on the host above; the module is one class.
| Step | Command shape | Time |
|---|---|---|
| compile | `javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp <android.jar>;<api-82.jar> -d build/classes src/**/*.java` | 850 ms |
| jar | `jar cf build/classes.jar -C build/classes .` | 259 ms |
| dex | `d8.bat --min-api 24 --lib <android.jar> --output build build/classes.jar` | 1143 ms |
| link | `aapt2.exe link -o build/base.apk -I <android.jar> --manifest AndroidManifest.xml --min-sdk-version 24 --target-sdk-version 28 -A assets` | 126 ms |
| add dex | `(cd build && aapt.exe add unsigned.apk classes.dex)` | 97 ms |
| align | `zipalign.exe -f -p 4 build/unsigned.apk build/aligned.apk` | 76 ms |
| sign | `apksigner.bat sign --ks build/test.keystore --ks-pass pass:android --key-pass pass:android --v2-signing-enabled true --out build/module.apk build/aligned.apk` | 1794 ms |
| verify | `apksigner.bat verify --min-sdk-version 24 --print-certs build/module.apk` | 725 ms |
| | **total** | **5.27 s** |
Resulting artifact, **observed**: `module.apk` 12,745 bytes, `classes.dex` 5,024 bytes,
`apksigner verify` reporting v2 signature valid with signer `CN=module, O=local`. APK entry list
(**observed**, read back with zipfile):
```
AndroidManifest.xml 1680
resources.arsc 40
assets/xposed_init 27
classes.dex 5024
META-INF/MANIFEST.MF 375
```
A second, independent build of a hand-written one-class module (different package, different source)
produced the same shape and also verified, so the chain is not specific to the scaffold's output.
### 2.1 Three build-chain traps, all measured rather than assumed
These are the failures that cost time, quoted from the actual runs:
1. **`d8` rejects a directory as program input.** Pointing it at the compiled `classes/` directory
fails with:
```
Error in program input '<work>/build/classes':
com.android.tools.r8.internal.Sb: Unsupported source file type
Compilation failed
```
Jarring the classes first (`jar cf classes.jar -C classes .`) and passing the jar succeeds. This is
the opposite of what several command-line guides imply.
2. **`aapt add` fails with `Unable to add 'classes.dex' to 'unsigned.apk': file not found`** when the
dex is not in the current working directory. It resolves its argument relative to the cwd and stores
the same name, so the dex must be copied out of `dex/` to sit beside the apk.
3. **`jar` and `keytool` are not on `PATH` even where `javac` is.** On this host `javac` resolved to
Oracle's `javapath` shim while `keytool` was absent entirely; both tools had to be called from the
JDK's `bin` directory explicitly. A build script that assumes they are on `PATH` breaks on a
working machine.
Additionally the reference notes that `-bootclasspath` no longer exists on JDK 9+, so the Android API
comes from `-cp` alongside the Xposed stub jar — **observed** (that is the form used above, and it
compiled and dexed cleanly).
## 3. Module state on the device — observed
A previously built probe module was already installed on the device from an earlier pass. Its state
was read, not modified, except where noted:
```
$ adb -s <serial> shell "pm path com.t1.lsphook"
package:/data/app/~~<hash>==/com.t1.lsphook--<hash>==/base.apk
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep -E 'User 0|enabled'"
User 0: ceDataInode=35322 installed=true ... stopped=true notLaunched=true enabled=0 ofs=0 ...
```
`enabled=0` here is the **package manager's** component state, not the LSPosed module switch. Enabling
the package was the one state change this pass made on the device:
```
$ adb -s <serial> shell "pm enable com.t1.lsphook"
Package com.t1.lsphook new state: enabled
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep 'User 0'"
User 0: ... enabled=1 ...
```
LSPosed itself, **observed**:
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/module.prop'"
id=zygisk_lsposed
name=Zygisk - LSPosed
version=v1.9.2 (7024)
$ adb -s <serial> shell "su -c 'ps -A -o PID,NAME | grep lspd'"
744 lspd
$ adb -s <serial> shell "dumpsys package org.lsposed.manager | grep versionName"
versionName=1.9.2
```
### 3.1 The installed module could never have run — observed
Before reusing that module for an injection attempt, its internals were checked. `assets/xposed_init`
declared one entry class while the dex in the same APK contained a different one:
```
$ python -c "... zipfile.ZipFile('installed-module.apk').read('assets/xposed_init') ..."
com.t1.lsphook.MainHook
$ dexdump -f installed.dex | grep 'Class descriptor'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$1;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$2;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$3;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook;'
```
The entry class named in `xposed_init` does not exist in that dex (`MainHook` appears nowhere in the
string table; `ProbeHook` does). LSPosed resolves the class named by `xposed_init`, so this module
could only fail at load — and it would fail **silently** from the outside, which is precisely the
failure mode the reference lists. Recorded because "the package is installed, the `xposedmodule`
meta-data is present and `pm path` returns a path" is still not evidence that the module can run:
the entry class has to exist.
Two consequences measured in the same check:
- A second APK produced by that earlier pass **did** contain the matching class, but was signed with a
different key — signer DN `O=local` against the installed build's `O=tools`, SHA-256
`892c18be…` against `9c86f386…`. Replacing the installed module therefore requires an uninstall
first; `adb install -r` would have failed on a signature mismatch.
- A module rebuilt from the same source with this pass's chain (`build_module.ps1`, the chain in §2)
was verified to contain the entry class, the `T1HOOK` log tag, the `injected: ` literal and the
target package string in its dex, and is the artifact intended for the injection attempt in §6.
Building it took 5.07 s and produced 12,745 bytes.
The **linked** manifest was checked as well, because LSPosed reads the compiled manifest rather than
the source file. `aapt2 dump xmltree <apk> --file AndroidManifest.xml` on the rebuilt APK shows all
three keys intact (`xposedmodule=true`, `xposeddescription="Minimal lifecycle hook probe for route
verification"`, `xposedminversion=82`). The broken APK's manifest was **equally intact** — which is the
point of recording this pair: a correct manifest, an installed package and a valid signature still do
not mean the module can run. Entry-class existence is a separate check, and it is the one that failed
here.
### 3.2 Replacing it, and an install failure that belongs to the ROM — observed
The broken module was removed and the rebuilt one installed. The install path is worth recording
because it failed for a reason that has nothing to do with LSPosed:
```
$ adb -s <serial> uninstall com.t1.lsphook
Success
$ adb -s <serial> install module.apk
[timed out after 240000 ms] # never landed; pm path returned nothing afterwards
$ adb -s <serial> push module.apk /data/local/tmp/<work>/module.apk
$ adb -s <serial> shell "pm install -r /data/local/tmp/<work>/module.apk"
Failure [-99]
$ adb -s <serial> shell "su -c 'pm install -r /data/local/tmp/<work>/module.apk'"
Success
```
Three findings from that sequence:
- `Failure [-99]` from a shell-identity `pm install`, with logcat showing the ROM's own security
centre (`com.coloros.safecenter`) taking the foreground from `com.android.packageinstaller` at the
same moment. The vendor layer is intercepting the install; the framework is not rejecting the APK.
- The same APK installs without complaint as **root**. That is the working path on this ROM, and the
one used here.
- The streaming `adb install` did not merely fail — it **hung for four minutes** and left no package
behind. Retrying as root is cheaper than waiting that out.
Installed state afterwards, **observed**:
```
$ adb -s <serial> shell "pm path com.t1.lsphook"
package:/data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/com.t1.lsphook-<hash>/base.apk
$ adb -s <serial> shell "pm enable com.t1.lsphook"
Package com.t1.lsphook new state: enabled
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep 'User 0'"
User 0: ... installed=true ... enabled=1 ...
```
So the **package-side** half of the deployment was completed in this pass: correct entry class,
correct meta-data, installed, PM-enabled. The scope half did not happen, for the reason in §6.
## 4. Where LSPosed keeps module and scope state — observed
The configuration database was copied off the device (three WAL files) and parsed read-only:
```
$ adb -s <serial> shell "su -c 'cp -f /data/adb/lspd/config/modules_config.db* /data/local/tmp/<work>/'"
$ adb -s <serial> pull /data/local/tmp/<work> ...
```
Schema and contents, **observed** (SQLite):
```
CREATE TABLE modules (mid integer PRIMARY KEY AUTOINCREMENT, module_pkg_name text NOT NULL UNIQUE,
apk_path text NOT NULL, enabled BOOLEAN DEFAULT 0 CHECK (enabled IN (0,1)))
CREATE TABLE scope (mid integer, app_pkg_name text NOT NULL, user_id integer NOT NULL,
PRIMARY KEY (mid, app_pkg_name, user_id), ...)
CREATE TABLE configs (module_pkg_name text NOT NULL, user_id integer NOT NULL, `group` text NOT NULL,
`key` text NOT NULL, data blob NOT NULL, ...)
== table modules ==
(1, 'lspd', '/data/adb/modules/zygisk_lsposed/manager.apk', 0)
(2, 'com.t1.lsphook', '/data/app/~~<hash>==/com.t1.lsphook--<hash>==/base.apk', 0)
== table scope ==
(no rows)
== table configs ==
('lspd', 0, 'config', 'misc_path', <blob>)
```
Three facts worth keeping from this, because each maps to a documented failure mode:
- `enabled` in the LSPosed database is a **separate** bit from the package manager's `enabled` state.
Both were 0 for the probe module when read, and only the package-manager one was changed here.
- The `scope` table was **empty**: an installed module that is not scoped is injected nowhere, which is
why "installed" is not evidence of injection.
- `sqlite_sequence.modules` was 3 while only ids 1 and 2 exist — a module had been registered and
removed in an earlier pass. The ids are not a count of what is loaded.
## 5. lspd lifecycle — observed from its own startup scripts
Read from the installed module directory, **observed**:
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/service.sh'"
...
unshare -m sh -c "$MODDIR/daemon --from-service $@&"
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/daemon'"
...
exec /system/bin/app_process $java_options /system/bin --nice-name=lspd org.lsposed.lspd.Main "$@"
```
`service.sh` is Magisk's late-start service script, which runs once per boot. **Inferred** (not
executed, deliberately): an `lspd` killed from a root shell is not restarted by anything reachable from
that shell, and the cheap-looking "just restart the daemon to pick up my database edit" move would take
the framework's scope decisions away from every subsequently forked process. This pass therefore did
**not** kill `lspd`, and the reference says so explicitly.
Related **inferred** consequence, stated in the reference: hand-editing `modules_config.db` is not a
supported way to change scope, because the daemon serves decisions from its live copy and the file is
its persisted state. The manager path updates both.
## 6. Injection: the framework had to be repaired first, then it worked — observed
With the module installed and PM-enabled, the next step was LSPosed Manager: enable the module there
and add the sample to its scope. That is where the pass stopped — and the reason is a property of the
device's LSPosed state at the time, not of the module.
Manager opened normally but reported the framework as **not installed**. Observed both on screen and in
the dumped node tree:
```
node: "未安装" / "LSPosed 未安装" (overview status card)
node: "API 版本" -> "未安装"
node: "Xposed API 调用保护" -> "未安装"
node: "Dex 优化器包装" -> "未安装"
bottom navigation: 仓库 / 概览 / 设置 # there is no module-list tab in this state
```
The daemon was still alive (`ps -A` → `744 lspd`), so this is not "LSPosed is absent". Two further
observations pin down what it actually is:
- **`lspd` stopped updating its own configuration.** The database still listed the **uninstalled**
module under its old, no-longer-existing path, and never gained an entry for the freshly installed
one. Its size and WAL set were unchanged across the whole pass (156,376 bytes in three files, the
same as before the uninstall):
```
== table modules ==
(1, 'lspd', '/data/adb/modules/zygisk_lsposed/manager.apk', 0)
(2, 'com.t1.lsphook', '/data/app/~~wiGaHYIZn5j3Am2JrJvA5g==/com.t1.lsphook-<hash>/base.apk', 0)
^ that codePath no longer exists: the package was uninstalled this pass
== table scope ==
(still empty)
```
A daemon that had re-scanned packages would have dropped id 2 and registered the new install path.
- **Its log records the framework bridge dying and never recovering.** The verbose capture contains
`system server died`, `am is dead`, then `no response from bridge, retry in 1s` on a loop, and
`Magisk: zygote crashed too many times, rolling-back`.
`[inferred]` The consistent explanation is that the half of the framework that lives in
`system_server`/`zygote` was lost (the same capture shows Magisk rolling a zygote restart back),
leaving `lspd` as a live process with nothing to talk to. Manager asks the framework for its state,
gets nothing, and falls back to its "not installed" onboarding UI — which has no module list, so
**scope cannot be configured through the UI at all** in that state.
Recorded consequences, deliberately not worked around:
- Scope was **not** configured, so no module was injected into any process, so no `T1HOOK` line could
appear. The injection claim therefore remains **unverified on this device**, stated plainly rather
than replaced by a weaker proxy (such as "the module is installed" or "`lspd` is running", both of
which were true the whole time and neither of which means anything about injection).
- The pass did not try to "fix" it by killing `lspd`, hand-editing the database, or restarting
`zygote`: the first two are explained in the reference's §Deploy and the third would have destroyed
concurrent instrumentation work another agent was running on the same device.
- The recovery action is a **device reboot**, which re-initialises Zygisk and `lspd`. It was not taken
because the device was shared and under heavy load. It is the concrete next step, and it is cheap:
after a reboot, open Manager, expect the module list to be present, enable the module, tick the
sample in its scope, then watch `logcat -T <ts> -s T1HOOK` while the sample restarts itself. Per the
reference, the scope change applies to the target's **next** process start, so a self-restarting
target is the ideal case — no spawn, no force-stop, no attach needed.
- Manager was returned to the background afterwards (`mCurrentFocus` back to the launcher). The sample
was never spawned, force-stopped or attached to during this pass.
### 6.1 The repair — observed
A reboot was approved and taken. It is the whole fix, and it is cheap:
```
$ adb -s <serial> reboot
[ 0 s] boot_completed='' ... polled with getprop, no fixed sleep
[ 31 s] boot_completed=1
--- post-boot ---
load average 17.40 4.37 1.46 (was 29.58 31.87 33.61 before)
746 lspd (new pid; was 744)
MoveCertificate zygisk-assistant zygisk_lsposed (all three modules present)
```
Manager then reported the framework normally, and the **module tab appeared** — the recovery
criterion. Observed on the overview page: `已激活 / 1.9.2 (7024) - Zygisk`, `API 版本 100`,
`Xposed API 调用保护 已启用`, `Dex 优化器包装 支持`; bottom navigation went from three tabs to five
(仓库 / 模块 / 概览 / 日志 / 设置). The module package itself survived the reboot
(`pm path` intact, `enabled=1`), so no reinstall was needed — the ROM install workaround in §3.2 only
matters on a fresh install.
### 6.2 Configuring scope through the UI — observed
Done strictly by reading coordinates out of a `uiautomator dump`, never by tapping a guess:
| Step | What was read from the dump | Coordinate tapped | Result |
|---|---|---|---|
| module tab | `[216,2160][432,2293]` | (324, 2260) | module list opened |
| module row | `T1 LSPosed probe`, row `[48,611][1008,793]` | (528, 702) | detail page |
| enable switch | switch node `[804,651][972,795]` | (888, 723) | `checked=true` |
| scope row | `<PKG>`, row `[252,1855][591,1912]` | (528, 1878) | `checked=true` |
The scope list had to be scrolled five screens to reach the target (the list is alphabetical by
display name, not by package). The database then confirmed the change, and — for the first time in
this whole exercise — **`lspd` wrote to it**:
```
== table modules ==
(2, 'com.t1.lsphook', '/data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/com.t1.lsphook-<hash>/base.apk', 1)
enabled ^ = 1
== table scope ==
(2, '<PKG>', 0)
```
The database file grew from 156,376 to 201,696 bytes across the pass. Both facts together are the
proof that the framework, not just its daemon process, came back.
### 6.3 The injection line, and where it actually appears — observed
The target was launched once with `am start`, and the module fired on **every** subsequent process
start. Nine consecutive target starts produced nine full injection blocks:
```
Loading legacy module com.t1.lsphook from /data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/.../base.apk
Loading class com.t1.lsphook.MainHook
T1HOOK injected: <PKG> / <PKG> cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK loaders at-handleLoadPackage:
0: dalvik.system.PathClassLoader c226f2f
1: java.lang.BootClassLoader 8454a72
T1HOOK hook installed: Application.attach
T1HOOK hook installed: Activity.onCreate
T1HOOK hook installed: Application.onCreate
T1HOOK Application.attach #1 this=<PKG>.BaseApplication cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK Application.attach #2 this=com.stub.StubApp cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK Application.onCreate this=com.stub.StubApp
T1HOOK Application.onCreate this=<PKG>.BaseApplication
T1HOOK Activity.onCreate -> <PKG>.MainActivity
T1HOOK injected: com.google.android.webview / <PKG> cl=dalvik.system.PathClassLoader@141f448
T1HOOK Activity.onCreate -> com.qq.e.ads.PortraitADActivity
```
**The verification criterion had to be corrected, and this is the most transferable finding of the
pass.** The reference (and this record's earlier draft, and the scaffold's generated README) said to
watch `logcat -s <TAG>`. On this device that is **empty** — as is `logcat -s LSPosed-Bridge`, and as
is a full-buffer `grep -i T1HOOK`. The module's output exists only in
```
/data/adb/lspd/log/modules_<timestamp>.log
```
which is the same file that earlier appeared to contain nothing but `Logd maybe crashed (err=Socket
operation on non-socket), retrying in 1s...`. The two facts are related: the ROM's `logd` route is
broken, so LSPosed's logcat channel delivers nothing while its file channel works normally. Anyone
debugging a module on such a device with `logcat` alone would conclude "the module never ran" while
looking at nine successful injections. Read **both**: `logcat -s <TAG>` where the platform is healthy,
`/data/adb/lspd/log/modules_*.log` always. The evidence file for this pass is 201 lines, of which 126
are `T1HOOK`.
Three further observations from the same log, none of which needed any extra instrumentation:
- **The packer's double Application is visible in the hook order.** `Application.attach` fires twice —
first for the app's real `BaseApplication`, then for `com.stub.StubApp` — and `onCreate` then runs
for the stub *before* the real Application. Hooks installed at `handleLoadPackage` time survive
this swap, and by `Activity.onCreate` the classes reported are the **unpacked** ones
(`<PKG>.MainActivity`), not the stub's. That is the concrete value of this route on a
packed target: the Java layer is reachable without touching the APK.
- **Scope is per app, not per process.** The module also injected into the app's WebView process
(`com.google.android.webview / <PKG>`) and reported the ad SDK's activity
(`com.qq.e.ads.PortraitADActivity`) — worth knowing before scoping a module widely.
- **The target's self-restart cycle is unaffected by injection.** Nine pids in ~3 minutes, one every
22–23 s (8111 → 9392 → 10557 → 11453 → 12345 → 13236 → 14155 → 15061 → 15934), each run reaching
`MainActivity.onCreate`, the WebView process and an ad activity before ending. The module only
observes, so this says nothing about cause; and because a reboot changes many variables at once, any
comparison with the pre-reboot cycle (a different observer measured 10–20 s) is **inferred at best
and not a controlled result**.
## 7. Log surfaces — observed, and one trap
```
$ adb -s <serial> shell "su -c 'ls -la /data/adb/lspd/log/'"
kmsg.log 993690
modules_2026-09-21T18:44:31.523.log 401
props.txt 46937
verbose_2026-09-21T18:44:31.518.log 178482
```
The `modules_*.log` file is **not** module output. Its entire content, **observed**:
```
----part 1 start----
Logd maybe crashed (err=Socket operation on non-socket), retrying in 1s...
(repeated)
```
`verbose_*.log` is a full logcat snapshot that includes unrelated system events (it contained, for
example, Magisk zygote-restart lines and OEM process deaths from an earlier incident). Useful for
correlating a run with system state; useless as a module log. The injection proof must come from
`logcat -s <TAG>`, which is what the reference and the scaffold's README recommend.
## 8. Root-hiding layer — observed
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk-assistant/module.prop'"
id=zygisk-assistant
name=Zygisk Assistant
version=v2.1.4 (1013f8a-release)
description=A Zygisk module to hide root.
$ adb -s <serial> shell "su -c 'magisk --denylist status'"
Denylist is enforced
$ adb -s <serial> shell "su -c 'magisk --denylist ls'"
(no output)
$ adb -s <serial> shell "su -c 'ls /data/adb/shamiko'"
ls: /data/adb/shamiko: No such file or directory
```
So at pass time the DenyList was **enforced but empty**: no process was being hidden from anything,
while LSPosed and `lspd` were running normally, and Shamiko was not installed. Worth recording because
"the target knows it is on a rooted device" is frequently an environment fact of exactly this shape,
and it is cheaper to check than to escalate (`references/detection-and-anti-analysis.md`).
The Shamiko/Zygisk-Assistant comparison in the reference is **inferred** from their own documentation
plus the on-device module metadata above; the hiding behaviour itself was not exercised against any
detector in this pass.
## 9. Not run / open items
- **Injection is not verified, and the blocker is now identified rather than unknown.** §6 records the
state that stopped it: Manager reporting the framework as not installed, `lspd` alive but no longer
updating its own configuration, and its log showing the framework bridge die and never recover. The
action known to clear that state is a **device reboot**; after it the remaining sequence is short
(enable the module in Manager, tick the sample in its scope, read `logcat -T <ts> -s T1HOOK` on the
target's next self-restart). Until someone does that, every statement in the reference about a module
*actually being injected* rests on framework documentation rather than on this bench.
- Editing `modules_config.db` directly was **not** attempted, on purpose: §5 explains why it would have
been an unreliable experiment, and the unsupported-ness of that path is recorded as inferred rather
than tested by breaking a working setup.
- Killing/restarting `lspd` was **not** attempted, for the same reason.
- The module's effect on a hardened, detection-aware target was not measured, because injection was not
reached. Consequently nothing here validates or contradicts the ClassLoader-count detection reported
externally (see the reference's *What detects you anyway*); that source is cited, not reproduced.
- The vendor install interception (§3.2) was worked around by installing as root; whether it can be
disabled from the ROM's own settings was **not** investigated, and whether `Failure [-99]`
reproduces on an idle device or on other vendor builds is **unverified**.
- `scaffold.py` was exercised on Windows only; the POSIX path in the generated README's command block
is **unverified** (the commands are standard, but not run here).
## 10. Reproduction one-liners
```
python skills/apk-reverse/scripts/lsposed_scaffold.py --help # argparse surface
python skills/apk-reverse/scripts/lsposed_scaffold.py --package <pkg> --name "<name>" \
--hook-target <target.pkg> --out <dir> # writes 4 files
# build (any ABI-neutral host with JDK + build-tools + android.jar + api-82.jar)
javac -source 8 -target 8 -cp "<android.jar>:<api-82.jar>" -d build/classes src/**/*.java
jar cf build/classes.jar -C build/classes . # d8 needs a jar, not a dir
d8 --min-api 24 --lib <android.jar> --output build build/classes.jar
aapt2 link -o build/base.apk -I <android.jar> --manifest AndroidManifest.xml \
--min-sdk-version 24 --target-sdk-version 28 -A assets
(cd build && aapt add unsigned.apk classes.dex) # dex must be in the cwd
zipalign -f -p 4 build/unsigned.apk build/aligned.apk
apksigner sign --ks build/test.keystore --ks-pass pass:android --key-pass pass:android \
--v2-signing-enabled true --out build/module.apk build/aligned.apk
apksigner verify --print-certs build/module.apk
adb -s <serial> shell "pm path <module.pkg>" # installed?
adb -s <serial> shell "dumpsys package <module.pkg> | grep 'User 0'" # pm enabled state
adb -s <serial> shell "su -c 'ps -A -o PID,NAME | grep lspd'" # daemon alive
adb -s <serial> shell "su -c 'magisk --denylist status'" # hiding layer state
```