Skip to content

Recipe — hooking a string-anchored class

The default anchoring pattern. Most classes in a real app are deep, internal, and have no exposed API surface — no AIDL descriptor, no public interface, nothing the obfuscator is forced to leave readable. They get the full rotation treatment: the class name becomes aaaa in one release and something unrelated the next.

What the obfuscator cannot touch is the data a class embeds. A string literal the developer wrote — an algorithm name, a log tag, a JSON key, a URL path — is a constant in the constant pool, not a name in the symbol table. It rides through every rotation untouched. That makes a distinctive string the single most broadly applicable anchor, and the one to reach for by default.

The target

A crypto helper, buried somewhere internal, that calls Cipher.getInstance("AES/GCM/NoPadding"). There is no public API on it and the class name rotates every release — but the literal "AES/GCM/NoPadding" is right there in the class, every version.

You pin the class on that literal in the sigmatcher signatures source (a regex-over-smali match for AES/GCM/NoPadding). The signatures resolve the class, and the emitted schema_version: 5 map records only the resolved real→obfuscated names — a pure mapping, no anchor field:

"com.example.app.crypto.GcmCipherHelper": {
    "obfuscated": "f0a",
    "kind": "class",
    "methods": {
        "encrypt": {
            "obfuscated": "a",
            "signature": "([B[B)[B"
        }
    }
}

The real fully-qualified name (com.example.app.crypto.GcmCipherHelper) is the name you type in hooks; obfuscated: "f0a" is what the class is called in this version. The stable string "AES/GCM/NoPadding" is what located f0a during signature authoring — it is finding evidence that lives in the signatures YAML, not a field in the map. (Earlier schema versions carried an anchors array on the entry; schema_version: 5 dropped it, because no resolver ever read it.)

Finding the class

In jadx (or any dex grep), search the dex for the literal "AES/GCM/NoPadding". The hit lands you inside the obfuscated class (f0a here); its method that calls Cipher.getInstance(...) is your encrypt. Copy the obfuscated class and method names into the entry above. When the app rotates, the class name changes but the string search finds it again — the anchor is what makes the re-discovery mechanical.

The hook

You write against the real name; rosetta translates it through the loaded map at runtime.

import appMap from '../../maps/com.example.app/30405.json' with { type: 'json' };
import { rosetta, type RosettaMap } from 'rosetta-frida';

const map = appMap as unknown as RosettaMap;

Java.perform(() => {
    rosetta.session({
        map,
        trace: true,
        failurePolicy: 'warn',
    });

    // Real name in, obfuscated name resolved under the hood.
    rosetta.hook(
        'com.example.app.crypto.GcmCipherHelper.encrypt',
        function (plaintext: unknown, key: unknown) {
            send({ stage: 'encrypt', len: (plaintext as { length: number }).length });
            return rosetta.proceed(plaintext, key);
        },
    );
});

encrypt has a single overload in the map, so the string form ('Class.method') resolves without disambiguation. If the class had several encrypt overloads you would switch to the object form and name the arg types by real name — see Overloaded methods.

If you would rather hold the class and reach into it yourself, the tier-2 rosetta.use proxy works the same way:

const Helper = rosetta.use('com.example.app.crypto.GcmCipherHelper');
Helper.encrypt.implementation = function (pt: unknown, key: unknown) {
    send({ stage: 'encrypt', len: (pt as { length: number }).length });
    return this.encrypt(pt, key);
};

Why the literal survives obfuscation

Obfuscators (R8 / ProGuard / DexGuard) rename symbols — classes, methods, fields — because those are identifiers the runtime resolves by name, and shortening them shrinks the dex and frustrates a reader. A string constant is data: it sits in the dex string pool and is loaded by index, so renaming it would change program behavior. The obfuscator therefore leaves "AES/GCM/NoPadding" exactly as written.

That is the whole trick: anchor on what the obfuscator is structurally unable to rewrite. A name (the class's identity) is fragile; a literal the class carries (its data) is durable.

A few practical notes:

  • Pick a distinctive string. "", "true", or "0" appear in thousands of classes and won't pin anything. Algorithm names, full URL paths, unusual error messages, and developer-written log tags are good — distinctive enough that the containing class is unambiguous.
  • Anchor on several strings in the signatures source. A signature can require more than one stable literal for a tighter match and lower false-positive rate during re-discovery. That tightening happens in the sigmatcher YAML; the map itself stays a pure name mapping.
  • String encryption defeats this. Some hardened apps encrypt their string constants and decrypt at runtime, so the literal isn't in the dex as plaintext. Then fall back to the superclass anchor or a runtime trace.

What you see at runtime

With trace: true, attaching prints the session setup and the resolved entry:

[rosetta] detect auto: [email protected]
[rosetta] map-load [email protected] schema=4 classes=15
[rosetta] health-check PASS rate=100.0% threshold=80.0% failures=0
[rosetta] com.example.app.crypto.GcmCipherHelper ← f0a (map)
[rosetta] com.example.app.crypto.GcmCipherHelper.encrypt ← a (map) ([B[B)[B

The health check confirmed f0a carries the "AES/GCM/NoPadding" anchor, so you know the entry resolved to the real cipher helper rather than a same-named impostor from a rotation.

See also