Recipe — hooking an AIDL stub¶
A lucky special case. Most classes you map are anchored generically — by
a stable string literal or a
stable framework parent — because that
is all the obfuscator leaves you. AIDL stubs are the exception: they hand
you an unusually strong anchor for free (a stable DESCRIPTOR string and
transaction codes), so when a class is a stub, lean into it while
authoring the signatures. Just don't assume a class is AIDL — most are not.
The AIDL evidence lives in the signatures source, not the map. As of
schema_version: 5the published map is a pure real→obfuscated mapping: a stub is justkind: classand a callbackkind: interface, with noaidl_descriptor/aidl_txn/anchorsfields. You still hook the stub by its real name exactly as below — the descriptor and transaction codes simply guided sigmatcher to the class; they are no longer carried in the emitted artifact.
AIDL stubs are the binder dispatch surface between processes — when one
Android process wants to call into a service running in another, that
call lands on a Foo$Stub descendant in the service process. Hooking the
stub captures every IPC into the service.
This recipe walks through the canonical example from
examples/sample-hook/hook.ts, annotated.
The target¶
In the sample map at maps/com.example.app/30405.json:
"com.example.app.IRemoteService$Stub": {
"obfuscated": "aaaa",
"extends": "android.os.Binder",
"kind": "class",
"methods": {
"requestTicket": [
{
"obfuscated": "c",
"signature": "(Landroid/os/Bundle;Lbbbb;)V"
},
{
"obfuscated": "d",
"signature": "(Landroid/os/Bundle;Ljava/lang/String;Lbbbb;)V"
}
]
}
}
Two overloads of requestTicket — one with two args, one with three.
The two-arg overload is the common call path; the three-arg form takes an
opaque tag string. (The AIDL transaction codes that distinguish them are
authoring evidence in the signatures source, not map fields — at hook time
you disambiguate by argument types, below.)
The hook¶
import sampleMap from '../../maps/com.example.app/30405.json' with { type: 'json' };
import { rosetta, type RosettaMap } from 'rosetta-frida';
const map = sampleMap as unknown as RosettaMap;
Java.perform(() => {
rosetta.session({
map,
trace: true,
failurePolicy: 'warn',
});
rosetta.hook(
{
class: 'com.example.app.IRemoteService$Stub',
method: 'requestTicket',
args: ['android.os.Bundle', 'com.example.app.IServiceCallback'],
},
function (bundle: unknown, callback: unknown) {
send({
channel: 'sample',
stage: 'requestTicket',
bundleKeys: bundleKeys(bundle),
});
return rosetta.proceed(bundle, callback);
},
);
});
function bundleKeys(bundle: unknown): string[] {
try {
const b = bundle as { keySet?: () => { toArray?: () => string[] } | null } | null;
const keySet = b?.keySet?.();
const arr = keySet?.toArray?.();
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
Why the object form¶
requestTicket has two overloads in the map. If you wrote:
— the string form — rosetta would throw
AmbiguousOverloadError
because it doesn't know which overload you mean. The error message
tells you to use the object form and points at the
disambiguating-args trick.
The object form names the args by real name:
The resolver translates each entry:
'android.os.Bundle'→ framework type, pass-through.'com.example.app.IServiceCallback'→ resolves to the map's obfuscated name (bbbb).
Internally Frida ends up calling
.overload('android.os.Bundle', 'bbbb') and selects the 2-arg
overload. The 3-arg form (which takes
'android.os.Bundle', 'java.lang.String', 'com.example.app.IServiceCallback')
is not selected.
Why rosetta.proceed¶
Inside the hook body, we call rosetta.proceed(bundle, callback).
This invokes whatever implementation was on the overload before this
hook layered on top — which, since this is the only hook, is the
original method body.
This is Frida's standard this.foo.apply(this, arguments) semantics
expressed as a function. See
Tier 1 — rosetta.proceed for the
full semantics.
We could mutate the args before forwarding:
return rosetta.proceed(bundle, callback);
// versus
return rosetta.proceed(modifyBundle(bundle), callback);
Or short-circuit by returning a fabricated value:
For an AIDL stub, the return value is usually void ()V in the
signature) so short-circuiting just suppresses the call. For methods
returning data, return a fabricated RosettaMap instance or a Frida
wrapper.
What you see at runtime¶
With trace: true, attaching prints:
[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.IRemoteService$Stub ← aaaa (map)
[rosetta] com.example.app.IRemoteService$Stub.requestTicket ← c (map) (Landroid/os/Bundle;Lbbbb;)V
[rosetta] com.example.app.IServiceCallback ← bbbb (map)
The first three lines are the session setup. The next three are the hook installation — class resolved, method overload picked, callback type translated.
When the app makes its first call to requestTicket, the hook fires
and you receive a send({ ... }) message on the host side:
Variants¶
Hook every overload¶
If you want both 2-arg and 3-arg requestTicket, install two hooks:
rosetta.hook({
class: 'com.example.app.IRemoteService$Stub',
method: 'requestTicket',
args: ['android.os.Bundle', 'com.example.app.IServiceCallback'],
}, function (b, cb) {
send({ overload: 2, keys: bundleKeys(b) });
return rosetta.proceed(b, cb);
});
rosetta.hook({
class: 'com.example.app.IRemoteService$Stub',
method: 'requestTicket',
args: ['android.os.Bundle', 'java.lang.String', 'com.example.app.IServiceCallback'],
}, function (b, tag, cb) {
send({ overload: 3, keys: bundleKeys(b), tag });
return rosetta.proceed(b, tag, cb);
});
Hook the callback too¶
To capture the response (what the service returns to the caller):
rosetta.hook(
'com.example.app.IServiceCallback.onResult',
function (bundle: unknown) {
send({ stage: 'onResult', keys: bundleKeys(bundle) });
return rosetta.proceed(bundle);
},
);
onResult has a single overload in the map (a, signature
(Landroid/os/Bundle;)V), so the string form works directly — no
disambiguation needed.
Hook onTransact directly (tier 3)¶
For low-level inspection — hooking onTransact directly and logging
every transaction code the binder dispatches — drop to tier 3:
const stub = rosetta.map.resolveClass('com.example.app.IRemoteService$Stub');
const stubWrapper = Java.use(stub.obfName) as { onTransact: unknown };
(stubWrapper as { onTransact: { overload: (...a: string[]) => { implementation: unknown } } })
.onTransact
.overload('int', 'android.os.Parcel', 'android.os.Parcel', 'int')
.implementation = function (txn: number, data: unknown, reply: unknown, flags: number) {
send({ stage: 'onTransact', class: 'com.example.app.IRemoteService$Stub', txn });
return (this as { onTransact: (...a: unknown[]) => unknown }).onTransact(txn, data, reply, flags);
};
The map no longer carries aidl_txn codes (they were authoring evidence,
not a resolver input), so map → transaction-code dispatch is no longer a
built-in. If you need it, keep a small txn→method table in your hook
script. This is rarely necessary — hooking the method directly by real
name is cleaner — but illustrates how tier 3 stays available for advanced
needs.
Common gotchas¶
- Forgetting
Java.perform. Frida requires Java APIs insideJava.perform(...). rosetta's resolver doesn't callJava.useuntil you reach for a class, but you still need the wrapping for the call to succeed at runtime. - Hooking the wrong end. The
$Stubclass runs inside the service process; the corresponding$Stub$Proxyruns in the client process. Hook the stub if you want to see incoming calls on the service side; hook the proxy if you want to see outgoing calls on the client side. They have different obfuscated names — both should be in your map. - Expecting AIDL metadata in the map. As of
schema_version: 5the map carries noaidl_descriptor/aidl_txn/aidl_stubfields — a stub iskind: class. Anchor on the descriptor while authoring the signatures; the map only records the resolved names.
See also¶
- Recipe — string-anchored class — the default anchor when a class has no AIDL surface, just an embedded string literal.
- Recipe — superclass-anchored method — the default anchor when a class is pinned by a framework parent.
- Concepts — anchoring.