記録 · Entry

Reverse engineering an Android app, end to end

Pull the APK off your own phone, read the native library with binutils you already have, decompile the Java with jadx, and use the JNI boundary as a map through heavily obfuscated code — then verify every finding against the open source.

9 min read

The fastest way to understand an app you did not write is to start where it talks to itself. Not the UI, not the manifest — the boundary where Java calls into native code. That boundary is named, exported, and impossible to obfuscate away, and it hands you a map into everything else.

I ran the whole thing against Telegram 13.0.2, pulled from a Samsung Galaxy SM-M366B on Android 16. Telegram is the right teaching target precisely because it is open source: every finding can be checked against DrKLO/Telegram, so you learn whether your technique is sound rather than guessing. The tools are all boring and mostly preinstalled: adb, jadx, and the binutils trio nm / readelf / strings. gh for the cross-checking.

#Getting the APK off the device

A modern Android app is not one APK. It is an App Bundle, split at install time into the pieces a given device actually needs:

adb shell pm list packages -3        # third-party packages only
adb shell pm path org.telegram.messenger

pm path returns several lines, not one:

package:/data/app/~~.../base.apk
package:/data/app/~~.../split_config.arm64_v8a.apk
package:/data/app/~~.../split_config.xxhdpi.apk

base.apk is the Java and the manifest. split_config.arm64_v8a.apk carries the native libraries for this phone's CPU ABI. split_config.xxhdpi.apk is density-specific resources — irrelevant here. You want base plus the arm64 split; that pair contains everything that runs.

adb pull /data/app/~~.../base.apk .
adb pull /data/app/~~.../split_config.arm64_v8a.apk .

One real-world wrinkle: on this device pm list packages threw a SecurityException complaining about a secondary user profile. If you hit that, it is not a dead end — pm path <pkg> with the package name typed out still works, because you are no longer asking the system to enumerate across profiles. Knowing the package name in advance routes around the thing that fails.

#Reading the native library with nothing but binutils

Unzip the arm64 split and the payload falls out:

unzip -o split_config.arm64_v8a.apk -d arm64
ls -la arm64/lib/arm64-v8a/
# libtmessages.49.so   ~22 MB

You do not need a disassembler to learn an enormous amount from this file. Start by confirming what it even is:

readelf -h arm64/lib/arm64-v8a/libtmessages.49.so
# Class:  ELF64
# Machine: AArch64

ELF64, AArch64 — a 64-bit ARM shared object, exactly what the arm64 split should contain. Now the part that does the real work. List the exported dynamic symbols:

nm -D --defined-only arm64/lib/arm64-v8a/libtmessages.49.so | grep ' Java_'

Every JNI entry point is exported under a name the JVM mangles from the fully-qualified Java method: Java_org_telegram_messenger_Utilities_.... Telegram exports 383 of them. Grouped by their class prefix, the native half of the application draws its own architecture diagram:

nm -D --defined-only arm64/lib/arm64-v8a/libtmessages.49.so \
  | grep ' Java_' | sed 's/.* Java_//; s/_[^_]*$//' | sort | uniq -c | sort -rn
  • WebRTC / voip_NativeInstance — voice and video calls run natively.
  • Utilities — the crypto helpers.
  • SQLite_* — the database layer is native, not the Android SQLiteDatabase you would expect.
  • RLottieNative / AnimatedFileNative — sticker and media rendering.
  • WalletEngine2 — a TON crypto wallet, which I did not expect to find sitting in a messenger's .so.

strings fills in the constants the symbols only hint at:

strings arm64/lib/arm64-v8a/libtmessages.49.so | grep -i 'sqlite'      # bundled version
strings arm64/lib/arm64-v8a/libtmessages.49.so | grep 'org/telegram'   # RegisterNatives targets
strings arm64/lib/arm64-v8a/libtmessages.49.so | grep -i 'wamr\|wasm'  # a WASM runtime path

That last one is the surprise: embedded in the binary is WAMR, a WebAssembly runtime, apparently used for call logic. And grep 'org/telegram' surfaces the class names the library registers with RegisterNatives at load time — org/telegram/tgnet/ConnectionsManager among them. Those are not exported as Java_ symbols because they are wired up dynamically, but the string constants give them away. The binary is telling you which Java classes it reaches into before you have opened a single .java file.

Reverse engineering workflow: device to split APKs to two analysis branches to source cross-check Device (phone) adb pull split APKs base.apk arm64_v8a density unzip → .so nm / readelf strings jadx obfuscated Java grep by strings, start at the JNI boundary cross-check public source (GitHub)
The pipeline: the device yields split APKs, which fork into native inspection and Java decompilation, both converging on a grep-driven read of the JNI boundary that is verified against the upstream source.

#Decompiling the Java

sudo pacman -S jadx
JAVA_OPTS=-Xmx6g jadx -j 6 --show-bad-code -d out base.apk

-Xmx6g because the default heap will not survive the size of this app, -j 6 to use six threads, --show-bad-code so jadx emits its best-effort output instead of silently dropping methods it cannot fully resolve. Telegram decompiles to roughly 11,885 classes across about 16,000 Java files, and it reports 235 decompile errors along the way. That is not a failure. On a codebase this size a few hundred methods that jadx cannot perfectly reconstruct is expected, and --show-bad-code means you still get something to read for each of them.

Then you open the tree and immediately hit the wall every real app puts up: the class names are obfuscated. Not Telegram's own org.telegram.* packages, which ship with real names, but the dependencies and a great deal else — single-letter and short mangled names like oh0.java. There is no navigating this by class name. The name tells you nothing.

#The JNI boundary is the way in

This is where the native work pays off. Those 383 Java_... exports name the exact Java classes and methods that cross into native code — in plain, un-obfuscated form, because the JVM has to find them by their real mangled signature at runtime. They cannot be renamed without breaking the link. So the native library is a directory of true names pointing straight into the Java you are trying to read.

From there the technique is always the same: never search for a class, search for a string that a class must contain. A distinctive SQL fragment, a log message, a getString(R.string...) key, a pragma — something stable that obfuscation cannot touch because it is data, not an identifier.

grep -rl 'PRAGMA secure_delete' out/sources/
grep -rl 'ConnectionsManager' out/sources/
grep -rln 'CREATE TABLE messages' out/sources/

Want the class behind a native call? Take the method name out of the Java_ symbol, grep for it. Want the code that builds the message database? Grep for the CREATE TABLE statement. The obfuscated filename it lands in does not matter — you arrived by content, and the content is what you were after.

#Cross-verifying against the source

Decompilers lie in small ways. A reconstructed loop, a synthesized local, an inlined constant — none of it is guaranteed to match what the author wrote. With a closed app you simply live with that uncertainty. With Telegram you can erase it, and in doing so turn the exercise into a miniature reproducible-builds check: does the "it's open source, here's the code" claim actually hold for the binary on my phone?

gh api -X GET search/code -f q='WalletEngine2 repo:DrKLO/Telegram'
gh api repos/DrKLO/Telegram/contents/TMessagesProj/jni/tgnet/ConnectionsManager.cpp \
  -H 'Accept: application/vnd.github.raw'

The search finds the file that defines a symbol; the raw fetch pulls it down so you can read it beside your decompiled output. Checked against the 13.0.2 tag, the shipped library matched the public source — same version, same code paths. That is the whole payoff: not "I found something hidden," but "I confirmed the thing they publish is the thing they ship." For an app that stakes its reputation on that claim, being able to verify it from your own device is the point.

#Ethics and legality, briefly

Analyse your own device and your own copy of the app, and nothing else. Open-source apps are the ideal subject exactly because you can check your findings — you are learning a method, not prying into a secret. Do not redistribute decompiled code. Do not point this at authentication, payment, or crypto-wallet apps. Do not try to defeat anti-tamper or integrity checks; that is where legitimate study ends and attack begins.

The security posture is itself interesting to observe, as an observation rather than a recipe. Telegram computes a SHA-256 of its own signing certificate at startup and sends it to the server, so tamper detection is handled server-side rather than by a client check you could patch. It uses Google Play Integrity during phone-number login. And notably, the binary contains no client-side root, Frida, or emulator detection at all. Seeing what an app does and does not defend tells you how it thinks about trust — and you can read all of it with tools that came with your machine.

Related entries