You can't hardcode where a getter's return value lives
On a moto_e40 (API 30, production ROM) we turned an
app’s real android_id afb03358ed32f459 into
00000000deadbeef (hits=1, no crash, no
repackaging) by deriving the getter’s (dex_pc,
return_register) from that ROM’s own compiled bytecode at
runtime, and refusing to install if the derivation failed. There is
no hardcoded offset anywhere in that path. Here is why you can’t
hardcode where a getter’s return value lives.
Say you want to spoof what Settings.Secure.getString(cr,
ANDROID_ID) returns to an app, without repackaging it. The
clean way to do that under ART is a return transform: set a
breakpoint at the method's return site and overwrite the register
that holds the return object just before the frame pops. To install
that, you need two coordinates: the dex_pc of the
return-object instruction, and the vAA
register it names. Our existing primitive takes them as parameters:
install_return_value_transform(
spec,
return_location: i64, // the dex pc of the return-object instruction
return_register: i32, // the vAA it names
transform, predicate, budget, ...
)
For an app method, fine: we dexdump the APK
host-side and read both off the disassembly. For a framework
getter, that falls apart, and the way it falls apart is a trap.
The coordinate is a property of the artifact, not the source
Settings.Secure.getString, getImei,
getSerial: their bytecode lives in
framework.jar / the boot image, not in any app's
classes.dex. No disassembly a hardcoded pack has access
to yields these facts. And here is the part that makes
"just ship a constant per getter" actively dangerous: register
allocation is a property of the compiled artifact, not of
the source shape, so the (dex_pc, vAA) pair varies
per Android version and per OEM ROM. The reason we settled
on deriving it:
Hardcoding a
return_location/return_registerper framework getter would silently transform the wrong register on any ROM whose allocation differs.SetLocalObjecton a valid-but-wrong slot does not fail, it writes something else.
That is the whole problem in one line. A wrong coordinate is not a crash you can catch. It is a successful write to the wrong local: the method returns a corrupted-but-plausible value, the app keeps running, and you have silently changed program state you never intended to touch. A hardcoded constant does not fail, it writes something else.
So read this ROM's own bytecode, at runtime
The fix is to stop guessing and ask the VM. JVMTI exposes
GetBytecodes(method), which hands back the method's own
Dalvik instruction stream, the actual compiled bytes on
this device. We bind the slot, copy the buffer out, and free
it exactly once:
pub(crate) fn get_bytecodes(env, method) -> Result<Vec<u8>, ArtError> {
let rc = unsafe { get_bc(env, method, &mut count, &mut buf) };
check(env, op, rc)?;
if buf.is_null() || count <= 0 {
/* free if non-null */
return Err(ArtError::BytecodeStreamMalformed { offset: 0 });
}
let len = count as usize;
// SAFETY: GetBytecodes reported `count` (> 0) readable bytes at `buf`.
let bytes = unsafe { std::slice::from_raw_parts(buf, len) }.to_vec();
/* deallocate(env, buf) exactly once */
Ok(bytes)
}
Then a pure, total byte walk over that stream finds the single
return-object (opcode 0x11, format
11x) and reads its vAA out of the high byte
of the code unit. dex_pc is the code-unit offset of that
instruction, the exact unit a JVMTI jlocation uses
for a Dalvik frame. The decoder takes no dex dependency,
because what GetBytecodes returns is a single method's
instruction stream, not a classes.dex container: no
header, no ULEB128 string/type/proto tables, no code_off.
Decoding it is a self-contained walk, not the DEX parsing our layering
rules keep out of runtime tooling.
// JVMTI hands back whole 16-bit code units; an odd byte length means a
// unit cannot be read -- refuse, never walk a truncated stream.
if code.len() & 1 != 0 { return Err(Malformed { offset: (code.len() >> 1) as u64 }); }
while cursor < total {
let unit = read_unit(code, cursor).ok_or_else(malformed)?; // little-endian unit
let width = match unit {
// The three variable-length payloads, sized exactly as our disassembler does:
0x0100 => 4 + size * 2, // packed-switch-payload
0x0200 => 2 + size * 4, // sparse-switch-payload
0x0300 => 4 + (ew * size).div_ceil(2), // fill-array-data-payload
_ => {
let opcode = (unit & 0x00ff) as u8; // the low byte is the opcode
match opcode {
0x0f => saw_narrow = true, // return -> NarrowNonObjectReturn
0x10 => saw_wide = true, // return-wide -> WideReturn
// return-object (11x): vAA is the high byte of the unit.
0x11 => return_object_sites.push((cursor as u64, unit >> 8)),
_ => {}
}
width_for_opcode(opcode).ok_or_else(malformed)? // 256-entry table
}
};
let next = cursor.checked_add(width).ok_or_else(malformed)?;
if width == 0 || next > total { return Err(malformed()); } // would spin / truncated
cursor = next;
}
read_unit is a bounds-checked little-endian 16-bit read:
out of range returns None, which becomes a
Malformed refusal, never a panic and never a partial
read. width_for_opcode is a 256-entry opcode→code-unit-width
table transcribed from our host-side opcode_width,
with one deliberate difference: a reserved or unassigned opcode
(which that host-side table treats as a width-1 10x)
returns None here instead, so an unexpected byte in a
stream that should never contain one refuses the whole derivation
rather than advancing a guessed single unit. Every arithmetic step
(checked_add, div_ceil) is checked, and a
zero or past-the-end width is caught before the cursor moves.
There is no indexing or arithmetic in the walk that can panic.
Derive, or refuse. Never guess.
The single most dangerous bug in this whole mechanism is a desynced
length walk: if the width table is wrong for one opcode on the path,
or a variable-length payload size (packed-switch, sparse-switch,
fill-array-data) is miscomputed, the cursor lands mid-instruction and
may read a data byte as 0x11, deriving a
plausible-but-wrong (dex_pc, vAA). That is the exact
silent-wrong-register bug the whole phase exists to kill, reintroduced
one layer down. So the walker is fail-closed and total. Any
out-of-bounds read, unknown opcode, or payload overrun returns a named
refusal, never a guess, never a panic, and it is
singular-or-refuse at the end:
// singular-or-refuse
match return_object_sites.len() {
1 => Ok((dex_pc, vAA)), // exactly one site -> derive
0 if saw_wide => Err(WideReturn), // only return is long/double
0 if saw_narrow => Err(NarrowNonObjectReturn),
0 => Err(NoReturnObject),
_ => Err(MultipleReturnObjects), // branchy getter -> refuse, never pick
}
A getter with two return-object sites (branches) is
refused by name, not resolved to a guess, because the
install takes one return_location and picking a branch is
the "confident wrong evidence" failure. None of these is a silent
None: each ReturnSiteDecodeRefusal maps
through a From impl onto a named ArtError the
caller can match, so the derivation path surfaces each outcome by
variant rather than as an opaque failure:
impl From<ReturnSiteDecodeRefusal> for ArtError {
// NoReturnObject -> ReturnSiteNoReturnObject
// MultipleReturnObjects -> ReturnSiteMultipleReturnObjects
// WideReturn -> ReturnSiteUnsupportedShape { why: "return-wide (long/double)" }
// NarrowNonObjectReturn -> ReturnSiteUnsupportedShape { why: "narrow non-object return" }
// Malformed { offset } -> BytecodeStreamMalformed { offset }
}
Two earlier refusals never even reach the decoder. If this ART build
never granted the GetBytecodes capability, the call
refuses with GetBytecodesUnavailable before any JVMTI
work; and a native or abstract method (no bytecode to read) is caught
by a single GetMethodModifiers check and refused as
MethodHasNoBytecode { why }, naming which. The
derive-then-install entry point wires all of this in front of the
existing, fully-gated install and weakens none of it:
fn install_return_value_transform_derived(&self, spec, transform, predicate, budget, ...) {
// refuse by name if this ART build never granted GetBytecodes (bit 3)
if !GET_BYTECODES_CAPABILITY_GRANTED.load(Ordering::Relaxed) {
return Err(ArtError::GetBytecodesUnavailable);
}
// derivation needs the LOADED method's bytecode. Unlike the caller-
// supplied path, this one does NOT defer on ClassNotFound: no loaded
// method -> no bytecode to read -> fail closed, never guess a coordinate.
let class = resolve_class(&thread, spec.class, spec.class_dotted)?;
let method_id = get_method_id(class, spec)?;
// native/abstract have no bytecode -- one GetMethodModifiers check first
let modifiers = get_method_modifiers(env, method_id)?;
if is_abstract || is_native { return Err(ArtError::MethodHasNoBytecode { .. }); }
let bytecode = get_bytecodes(env, method_id)?; // THIS ROM
let (dex_pc, vreg) = decode_return_object_site(&bytecode)?; // derive or refuse
// delegate to the EXISTING install with the derived pair
self.install_return_value_transform(spec, dex_pc as i64, i32::from(vreg),
transform, predicate, budget, ...)
}
Note the signature: it is
install_return_value_transform minus
return_location and return_register,
the two values it derives. Everything downstream (type-mismatch,
predicate-too-broad, location-zero, budget, the three consent-gate
property checks) is unchanged. Derivation is a wall placed in front
of the existing wall, not a hole in it. The one refusal it adds of its
own is that fail-closed class resolution: the caller-supplied install
can defer until the class loads, but with no loaded method there is no
bytecode to disassemble and so no coordinate to defer toward.
This path refuses rather than wait on a derivation it can never do.
The capability request itself
is one extra read-only JVMTI bit, in a separate atomic
AddCapabilities so its refusal can be non-fatal
(GetBytecodesUnavailable) rather than forfeiting every
other Java hook; GetBytecodes patches nothing and binds
nothing.
Device proof: afb03358ed32f459 → 00000000deadbeef
We built a minimal debuggable fixture,
com.rutken.fixture.idread: one activity that reads
Settings.Secure.getString(cr, Settings.Secure.ANDROID_ID)
in onCreate and logs it under tag
RUTKEN_IDREAD. Installed on a moto_e40 (API 30,
production ROM), attached via no-repack agent injection. Unarmed, then
armed with the active ack and the three transform property gates set:
unarmed: onCreate android_id=afb03358ed32f459 # the device's real id
armed: onCreate android_id=00000000deadbeef # the canonical fake
site java.framework.settings_secure_getstring_spoof_android_id
hits=1 crashed=false
That proves the whole chain on a real framework method, not a fixture
method: GetBytecodes derived
Settings.Secure.getString's (dex_pc,
return_register) off this ROM's own compiled bytecode
(a derivation failure would have refused, not installed),
and the transform spoofed the app's actual android_id
read. The narrowing predicate
ArgumentStringIn{arg_index:1, allowed:["android_id"]}
scopes it to exactly that setting name, so a different setting read
through the same getter still returns its real value. The mechanism
shipped slightly earlier, deliberately left unmeasured, and was then
proven on device through the CLI path: a device run on one phone
recorded android_id afb03358ed32f459 → 00000000deadbeef, hits=1,
no crash.
What is proven, and what is not. This is the
mechanism, device-proven on our own debuggable fixture reading a
real framework method, through the CLI path, on one API-30 ROM. At
the time of writing, the GUI auto-arming path (the service
setting those three transform properties for a GUI-initiated run)
was still pending, so this is explicitly not a
claim of full GUI end-to-end. It also removes exactly one gap:
getters with a String argument we can narrow on. Zero-argument
telephony getters (getImei, getSubscriberId)
still can't satisfy any narrowing predicate and are not targeted
here. One ROM proven is not "every ROM". It is precisely the
reason the coordinate is derived on-device per run instead of
shipped as a constant.
The general point outlives the one getter: anything that depends on where a value sits in a compiled method (a register, a return site, a stack slot) cannot be a constant in your tool, because it is not a constant in the world. You either read it off the artifact in front of you, or you refuse. If you want to see the runtime engine this is part of, the docs cover the no-repack attach path it rides on.