Skip to content

Commit 1c356a1

Browse files
committed
Add the dispatch guard conformance harness
A two-app, adb-driven test for the guard in the previous commit. Kept because it caught three defects there that a reading of the bytecode had passed, each one only surfacing once something shaped differently was run against it. It asserts five properties of dispatch. The fourth is the one that is easy to lose and hard to see: the dispatch must not dispatch its own internal calls. A dispatch that re-enters itself still returns the right answers, at a multiple of the cost, until a real workload turns that into an ANR - so it is asserted by the nesting cap staying silent on an ordinary run rather than by any result value. Needs a device and a Vector build to run, so it is not wired into any Gradle build; see its README. Separate from the fix so it can be dropped without touching it.
1 parent 72863a6 commit 1c356a1

18 files changed

Lines changed: 891 additions & 0 deletions

File tree

tests/dispatch-guard/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
build/
2+
.gradle/
3+
local.properties

tests/dispatch-guard/README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Dispatch guard conformance harness
2+
3+
Two apps, driven from `adb`, asserting five properties of hook dispatch.
4+
5+
| | Property |
6+
| --- | --- |
7+
| P1 | Hooks fire: instance, static, constructor, class initializer, both `Invoker` types |
8+
| P2 | A hooker sees other hooks — the guard comes down for module code |
9+
| P3 | Hooking a method the dispatch itself calls does not break the dispatch |
10+
| P4 | The dispatch never dispatches its own internal calls |
11+
| P5 | A hooker that calls the method it hooks is bounded, not fatal |
12+
13+
P4 is the one that is easy to lose and hard to see: a dispatch that re-enters itself still returns
14+
the right answers, just at thirty-two times the cost, until some real workload turns that into an
15+
ANR. It is asserted by the nesting cap staying silent, not by any result value.
16+
17+
`Object.getClass()` is the sharpest case for P3 and P4 because the compiler writes calls to it on
18+
everyone's behalf — R8 lowers Kotlin's parameter null checks into it — so the dispatch calls it
19+
whether or not anyone wrote that call.
20+
21+
| Module | Package | Role |
22+
| -------- | --------------------- | ---- |
23+
| `target` | `org.matrix.dgtarget` | The hooked app. `ProbeReceiver` runs each check and logs one `RESULT` line each |
24+
| `module` | `org.matrix.dgmodule` | An API 101 module that installs the hooks |
25+
26+
Scoped to the throwaway target, so the two hostile hooks can only take that app down. Both are armed
27+
on demand, so the safe checks run first.
28+
29+
## Build and install
30+
31+
The module compiles against the framework's own `:legacy` output for the `de.robv` check, so build
32+
that once from the repository root first: `./gradlew :legacy:assembleDebug`.
33+
34+
```sh
35+
./gradlew :target:assembleDebug :module:assembleDebug
36+
adb install -r target/build/outputs/apk/debug/target-debug.apk
37+
adb install -r module/build/outputs/apk/debug/module-debug.apk
38+
39+
V=/data/adb/modules/zygisk_vector/cli
40+
adb shell "su -c '$V modules enable org.matrix.dgmodule'"
41+
adb shell "su -c '$V scope set org.matrix.dgmodule org.matrix.dgtarget/0'"
42+
```
43+
44+
Both APKs set `isDebuggable = false`. A debuggable app makes the zygote turn CheckJNI on, which
45+
aborts the process on any JNI type slip in the framework and hides everything else.
46+
47+
## Run
48+
49+
```sh
50+
P="adb shell am broadcast -a org.matrix.dgtarget.PROBE -n org.matrix.dgtarget/.ProbeReceiver"
51+
$P # P1, P2
52+
$P --es cmd arm-getclass # then $P again, for P3 and P4
53+
$P --es cmd arm-recursion # then --es cmd recurse, for P5
54+
adb logcat -d | grep -E 'DGTarget|DGModule|nested past'
55+
```
56+
57+
## Reading the result
58+
59+
| Check | Expected | Fails as |
60+
| ----- | -------- | -------- |
61+
| `instance` `static` `ctor` | `hooked-…[orig-…]` | P1 |
62+
| `clinit` | `clinit-ran`, plus `clinit hook fired` | P1 — the second line is the one that matters |
63+
| `invoker` | `chain=hooked-… origin=orig-…` | P1 |
64+
| `nested` | `outerHook{outer[hookedHelper]}` | P2 — `orig-helper` means the guard stayed up across module code |
65+
| every check, after `arm-getclass` | unchanged, same pid | P3 |
66+
| `Hook dispatch nested past 32` on a plain run | **absent** | P4 |
67+
| `selfRecurse` | `1`, process alive, cap fires | P5 |
68+
69+
The pid must not change across the whole sequence.
70+
71+
A P3 failure looks like a hang rather than a crash: no `RESULT` line, then `ANR in …` and a kill,
72+
with no `main` thread in the ANR trace because the recursing thread cannot be checkpointed.
73+
74+
A P4 failure passes every check above. That is the point of asserting it separately.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
plugins { id("com.android.application") version "9.3.1" apply false }
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
android.useAndroidX=true
2+
org.gradle.jvmargs=-Xmx2048m
47.3 KB
Binary file not shown.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
distributionBase=GRADLE_USER_HOME
2+
distributionPath=wrapper/dists
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
4+
networkTimeout=10000
5+
retries=0
6+
retryBackOffMs=500
7+
validateDistributionUrl=true
8+
zipStoreBase=GRADLE_USER_HOME
9+
zipStorePath=wrapper/dists

tests/dispatch-guard/gradlew

Lines changed: 248 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
plugins { id("com.android.application") }
2+
3+
android {
4+
namespace = "org.matrix.dgmodule"
5+
compileSdk = 37
6+
buildToolsVersion = "37.0.0"
7+
defaultConfig {
8+
applicationId = "org.matrix.dgmodule"
9+
minSdk = 31
10+
targetSdk = 37
11+
versionCode = 1
12+
versionName = "1.0"
13+
}
14+
buildTypes {
15+
release { isMinifyEnabled = false }
16+
debug { isDebuggable = false }
17+
}
18+
compileOptions {
19+
sourceCompatibility = JavaVersion.VERSION_17
20+
targetCompatibility = JavaVersion.VERSION_17
21+
}
22+
signingConfigs {
23+
getByName("debug") {
24+
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
25+
storePassword = "android"
26+
keyAlias = "androiddebugkey"
27+
keyPassword = "android"
28+
}
29+
}
30+
}
31+
32+
dependencies {
33+
compileOnly("io.github.libxposed:api:101.0.1")
34+
// de.robv is not published anywhere; compile against the framework's own :legacy output.
35+
// Run `../../gradlew :legacy:assembleDebug` from the repository root first.
36+
compileOnly(
37+
files(
38+
"$rootDir/../../legacy/build/intermediates/aar_main_jar/debug/syncDebugLibJars/classes.jar"
39+
)
40+
)
41+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
<application android:label="DG Module" android:allowBackup="false">
4+
<meta-data android:name="xposedmodule" android:value="true" />
5+
<meta-data android:name="xposeddescription" android:value="#798 dispatch guard conformance" />
6+
<meta-data android:name="xposedminversion" android:value="101" />
7+
</application>
8+
</manifest>

0 commit comments

Comments
 (0)