The Java declaration is the handoff point
A method marked native tells you the body lives in a shared library. Record the Java class, method name, argument types and return type, then match them to an export or a registration table in the matching ABI library under lib/. The register or the symbol tells you whether the binding is static or dynamic, which changes how you search.
# static binding exposes a Java_* symbol; dynamic binding does not nm -D lib/arm64-v8a/libapp.so | grep -i 'Java_' readelf -s lib/arm64-v8a/libapp.so | grep -i RegisterNatives # the same library for the other ABIs, because the check may vary per ABI ls lib/arm64-v8a lib/armeabi-v7a lib/arm64-v8b 2>/dev/null
Static registration uses names shaped like Java_package_Class_method. Dynamic registration connects methods through RegisterNatives, so the export name reveals nothing. Follow JNI_OnLoad, the registration arrays and the function pointers instead.
Find the native data path
Load the correct .so for the target ABI into a native-code tool. Strings expose header names, route fragments, cipher labels and error text. Cross-references from those strings usually land closer to the request logic than starting at a large dispatcher, because the app has to name the value somewhere to build the header or the body.
strings lib/arm64-v8a/libapp.so | grep -iE 'x-(timestamp|nonce)|sign|aes|hmac|sha|bearer' | sort -u
Track how JNI converts strings and byte arrays. A Java string is UTF-16; GetStringUTFChars returns Modified UTF-8, which is not the same as UTF-8 for some characters. A Java byte[] crosses JNI as raw bytes. A signature function may receive a method, a path, a body and a timestamp from Kotlin, join them in C++, call a crypto routine and return hex text. Another app may build the whole request body in native code and expose only the final bytes.
Confirm the boundary at runtime
Static analysis tells you where to look; runtime confirms the value. Hook the Java declaration with Frida and log the arguments and the return.
Java.perform(function () {
var C = Java.use('com.example.Api');
C.sign.overload('[B', 'long').implementation = function (body, ts) {
var out = this.sign(body, ts);
console.log('sign input', body.length, ts, 'out', out);
return out;
};
});Then change one input and compare the output, and check whether global state, a device value or an earlier call changes the result. When the same input does not always give the same output, the function depends on something outside its arguments, and that dependency is part of what you must reconstruct.
Port the smallest stable unit
Once the algorithm is understood, reimplement the narrow function the client needs rather than the whole library. Keep test vectors from the app so the port can be rechecked after refactors. Test empty text, ASCII, non-ASCII and a null character where accepted; keep byte arrays as bytes; record lengths because binary data can contain zero; and release JNI pointers through the matching function.
If the native function depends on a device-backed key or live state, the client design must state that dependency instead of pretending the function is pure. A hardware-backed key cannot be copied out of the device, so a server-side reproduction of that value is not possible without the device.
Read the JNI conversion
| JNI call you see | What it does | What you reproduce |
|---|---|---|
| GetStringUTFChars | Returns Modified UTF-8, which differs from UTF-8 for some characters. | Encode with the modified scheme, not standard UTF-8. |
| GetByteArrayElements | Borrows the Java byte array as a C buffer. | Keep it as raw bytes, never a string. |
| RegisterNatives or JNI_OnLoad | Links the Java method to a C function. | Match the registration to the Java declaration. |
| NewStringUTF or NewByteArray | Builds the value that crosses back to Java. | Expect those bytes exactly, including the length. |
Each conversion is a place where the value can change. A method, path and body that sign correctly in Java can produce a different signature in another language if the encoding differs. Record which JNI function is used before you reproduce a signed or encrypted value.
This is why a JNI signature routine is harder to port than a pure Java one: the C++ layer decides the byte representation, and the Java layer only hands over arguments. Match the JNI function to the byte behaviour you see on the wire.
Reviewed 30 August 2026 · SReverse research desk