Back to blog

“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:

armstub installeddebug.rutken.throw_overridesobserved result
Ayes0FATAL EXCEPTION: java.lang.ArithmeticException: divide by zero in MainActivity.onCreate; “Radare2 Pay keeps stopping”
Byes1app runs fully (PIN / amount / card UI reachable); events=62 sites=1 refused_sites=0
A’no0no 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.