“The hook fired” proves nothing: a 700-byte APK that made an anti-tamper bypass real
On one unrooted phone, the target’s own divide-by-zero
kill fired without our intervention and was suppressed with it.
An app’s verdict flipped as a function of the bypass alone,
confirmed by an A/B/A with a no-stub control arm. The throw arm fired
hits=11 with pending_exception_cleared=0, so
every firing was armed on purpose, not a cleanup side effect. The
caveat that keeps it honest: one app, one run, on a training target
built to be defeated, not evidence against hardened commercial
anti-tamper. Here is the 700-byte APK that made the experiment mean
anything.
Here is the most results-invalidating limit on any runtime-override tier, stated as our own docs state it: when a forced answer equals the natural one, the run proves the MECHANISM and nothing about behaviour.
Concretely. You are on an unrooted phone. The target calls
RootBeerNative.checkForRoot() and you hook it to force
the return to 0. The hook fires, the hit counter
increments, the app proceeds. Looks like a win. It is not: the
device was going to answer 0 (“not rooted”)
anyway. You forced the answer it was already going to give. An
uninstrumented run behaves identically. Read as a bypass, that is an
overclaim, and it was the shape of every override
result on our ART tier up to 2026-09-21, including the
NativeMethodBind C1 result and the raw throw hit
counts.
The fix is to manufacture a difference
The experiment is only valid if the app’s natural answer differs from the forced one. So make it differ. The target scans for the Magisk package. Give it one: an empty stub APK whose entire purpose is to claim the package name the target checks for, so the natural answer becomes “rooted / Magisk present” and the forced answer (“not present”) is now distinguishable in the app’s own behaviour.
The stub is about 700 bytes, the smallest fixture we ship,
deliberately so. It carries
android:hasCode="false" and no
classes.dex, and it ships no Java sources, so the
fixture build compiles no code for it at all. The manifest is the
whole thing:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.topjohnwu.magisk">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="28" />
<application android:label="RutkenAntiTamperStub"
android:hasCode="false"
android:allowBackup="false" />
</manifest>
The package name is not a guess:
com.topjohnwu.magisk is lifted verbatim from the
ROOT_PACKAGES allowlist (the same name the
root-detection pack itself scans for), so the stub needs no
companion change anywhere else.
Built with aapt2 link + zipalign +
apksigner and signed with a throwaway test keystore
(password android). It installs as a real package
named com.topjohnwu.magisk with zero executable
content. The fixture’s own header states what it is for: it
“encodes nothing to be FOUND by static analysis. Its
only job is to make
PackageManager.getPackageInfo("com.topjohnwu.magisk", ...)
succeed NATURALLY on a device that has neither root nor Magisk
installed.”
Why this flips the natural answer: root checks of this family don’t
look for working Magisk, they look for its package,
a PackageManager.getPackageInfo("com.topjohnwu.magisk", 0)
that returns instead of throwing NameNotFoundException.
With the stub installed, that call succeeds: the app now
genuinely believes Magisk is present. On a clean unrooted phone it
throws, and the app believes nothing is there. That single bit is
the independent variable the whole experiment was missing.
The override mechanism: throw, not “return false”
Our ART tier has an install_throw_override primitive:
a matched predicate makes the call site
throw instead of returning. It is the sibling of
install_return_override (the vocabulary names
them one family,
JavaOverrideAction { Return(JavaReturnValue), Throw(JavaThrowKind) })
but they stay separate trait entry points because a throw is
valid for every signature, including void,
while a forced return must be type-checked against the signature.
JavaThrowKind is a two-variant, fieldless
allowlist of (call site, exception type) pairs,
never an arbitrary class name, because ART performs
no runtime check that a propagating exception matches the
frame’s declared throws clause, so an
ill-matched throw would corrupt the frame:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JavaThrowKind {
// java.io.IOException("No such file or directory")
// declaring site: Runtime.exec(String) throws IOException
ExecNoSuchFileOrDirectory,
// android.content.pm.PackageManager$NameNotFoundException
// declaring site: getPackageInfo(String, int) throws NameNotFoundException
PackageNameNotFound,
}
Both exception classes are on the boot classpath. The second one is
the one that matters here: force
PackageManager.getPackageInfo to throw
NameNotFoundException, and the app’s
Magisk-package probe is told “not installed”,
even though the stub is installed. Mechanically, an entry
breakpoint on getPackageInfo deoptimizes the method; on
a predicate match the agent plants a freshly constructed
boot-classpath NameNotFoundException as the
thread’s pending exception and unwinds the frame, so it
propagates exactly as a real miss would. The throw takes no budget,
like the return override.
Two details keep it auditable rather than magical. The call site
is narrowed, never blanket: the pack installs
JavaReturnPredicate::ArgumentStringIn { arg_index: 0, allowed: ROOT_PACKAGES },
so only a getPackageInfo whose first argument is a
known root package throws. A lookup of the app’s own
package passes straight through. An open-ended “throw on
every call” is refused at install time with
ArtError::ThrowPredicateTooBroad; on
getPackageInfo, which a live UI reaches from
everywhere, blanket throwing would be a guaranteed process kill,
not a bypass. And the exception jclass is resolved
through FindClass once, at install time.
The per-firing callback makes the single
ThrowNew call and then returns, with no JNI call in
between, because with an exception already pending anything else
trips ART’s CheckJNI and aborts the process loudly.
Now the two answers diverge: natural (stub installed) = “Magisk present,” forced (throw armed) = “not present.” An A/B that toggles the override is finally measuring behaviour, not confirming the device agrees with itself.
The A/B/A on re.pwnme
Target: re.pwnme (MASTG-r2pay, the Radare2
Pay training app), agent-embedded throw packs. The toggle is a
system property,
debug.rutken.throw_overrides. r2pay’s own
anti-tamper, when it decides it has been tampered with, routes into
a deliberate self-destruct, a divide-by-zero it triggers on
purpose to bring the process down. Three arms:
| arm | stub installed | debug.rutken.throw_overrides | observed result |
|---|---|---|---|
| A | yes | 0 | FATAL EXCEPTION: java.lang.ArithmeticException: divide by zero in MainActivity.onCreate; “Radare2 Pay keeps stopping” |
| B | yes | 1 | app runs fully (PIN / amount / card UI reachable); events=62 sites=1 refused_sites=0 |
| A’ | no | 0 | no fatal exception (control) |
Read it as a controlled experiment. Arm A: stub present,
override off. The app sees Magisk, hits its anti-tamper path,
and kills itself with its own ArithmeticException.
Arm B: stub still present, override on.
getPackageInfo throws, the app believes Magisk is gone,
and runs to the PIN / card UI;
events=62 sites=1 refused_sites=0. Arm A’ is
the control that makes the whole thing legible: no stub,
override off, and there is no crash, proving the stub, not
the instrumentation, is what caused the kill in arm A, and that
the intervention in arm B is what suppressed it. The divide-by-zero
is r2pay’s own kill, fired on cue in A and suppressed in B.
This is the first result on this tier where an app’s own
verdict visibly flipped as a function of our intervention alone.
One number guards the attribution. The throw arm reported
java.root.packagemanager_getpackageinfo hits=11 with
diag.breakpoint.pending_exception_cleared = 0. That
second counter is the point: a breakpoint callback that fails
partway through (a failed string read, say) can
leave a stray exception pending that ART would then throw into the
app by accident, indistinguishable from a deliberate
throw. A zero there means every one of the 11 was armed on
purpose, not a side effect of a cleanup bug.
JavaThrowEvent itself still reports only
“armed,” never “delivered”, so it
is that counter and the A/B/A, not the raw hit count, that carry
the weight.
And the A/B/A is not a one-off anecdote: it is wired as an
automated device-gate arm, whose pass criterion is
the difference and never any single arm: arm A
must crash, arm B must not crash while its throw hook
demonstrably fired (hits > 0), and
arm A’ must not crash. The gate reads the crash
specifically from logcat -b crash, because on this
tier a dead agent socket could just as easily mean the app process
died as that a protocol broke. The crash buffer is the only
honest witness. As the gate’s own comment puts it, any arm
alone (“the hook fired” or “the app didn’t
crash”) is “exactly the forced-equals-natural
overclaim” this post opens with; only the A-versus-B flip
together with the A’ control rules it out.
The mandatory caveat, in the doc’s own words:
this “remains one app, one run, on a deliberately vulnerable
training target that this project did not write but that is built
to be defeated.” It is a demonstration of the throw/override
mechanism and the A/B/A measurement methodology, not
evidence against a hardened commercial anti-tamper implementation,
and it corroborated no detection rule (our
[OBSERVED] count is unchanged at 11). A post that
dropped this line would be lying by
omission.
Why the methodology is the point, not the bypass
The transferable result here is not “we beat r2pay.” r2pay is designed to be beaten. The transferable result is the discipline: an override result is worthless unless the forced answer can differ from the natural one, and proving that difference took a 700-byte APK with no code in it and a third control arm. The same logic indicts a lot of “bypass” demos you’ll see: a hook firing on a check whose honest answer already matched the forced one proves the tool can hook, nothing more.
This is also why the throw primitive is a two-entry fieldless
allowlist rather than “throw any exception”: ART
won’t police the throws clause for you, so an
open-ended version is a frame-corruption bug waiting to happen. The
constraint buys a clean evidence trail: every armed throw is
a typed, named (call-site, exception) pair, and
JavaThrowEvent honestly reports “armed,”
never “delivered.” The app’s own behaviour, in the
A/B/A, is the only thing allowed to say “delivered.”
The engine is the part of Rutken that gets onto a hardened target without repacking it, and then lets you prove what you changed. The A/B/A above is what “prove” means. Rutken is available here if you want to try it.