Back to blog

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_register per framework getter would silently transform the wrong register on any ROM whose allocation differs. SetLocalObject on 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.