Reverse engineer a private Android API with mitmproxy and Frida
I have been taking automating declutter to a next level as I realized with current state of LLM models I could just write a simple CLI for my agents - so I don’t have to work with clunky interfaces, annoying mobile apps in platform like vinted, olx or allegrolokalnie (local ebay, kinda).
In most cases just spinning mitmweb and running your app just works. LLM are brilliant at analyzing logs and can quickly re-create API and write a client for it based on quick session in your app. One app was particularlly annoying because of SSL pinning and few other prevention techniques.
Below I’m gonna dump mostly “AI notes” based on few attempts so other LLMs or maybe some lost human can save some tokens in the future…
Dislaimer: the goal was not to work around captchas etc. - I stored real session ids from either real devices or simulator and just used that in my CLI.
Problemz
The app:
- Ignores user-installed CA certificates, so a plain proxy sees only TLS errors.
- Pins its own certificates on top of the system check, so even a trusted system CA is not enough.
This is the setup that worked (minus the dead ends).
The shape of it
App (signed in normally)
-> Frida hook drops only the app's extra certificate pin check
-> Android system trust validates the proxy's certificate
-> emulator proxy 10.0.2.2:8080
-> mitmweb on the host (proxy 8080, UI 8081)
-> real servers
Nothing here defeats login, 2FA, or bot protection. I log in by hand in the app; the proxy only makes the already-authenticated traffic readable.
What you need
- A Google APIs emulator image (not Google Play), so the system partition is writable.
- mitmproxy on the host.
- Frida host tools and a version-matched
frida-serverfor Android. - JADX, to find the class and field to hook.
- The app itself, installed and signed in normally.
1. Use a rootable emulator
Production Google Play images (user build, ro.debuggable=0) don’t give you adb root, which you need to drop a CA into the system store. Create a fresh AVD on a Google APIs image (not Google Play) so it is rooted and writable:
export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk
SDK="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin"
"$SDK/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" \
'system-images;android-33;google_apis;arm64-v8a'
"$SDK/avdmanager" create avd --name capture \
--package 'system-images;android-33;google_apis;arm64-v8a' --device pixel_6
"$ANDROID_SDK_ROOT/emulator/emulator" -avd capture -port 5556 \
-writable-system -no-snapshot -no-boot-anim
Keep -writable-system every time you start it, and address the device as emulator-5556 in every adb command below.
A Google APIs image has no real Play Store, so apps that demand one won’t install. On my capture AVD I had to pull the genuine
Phonesky.apkand its privileged-permission allowlist out of a Play-enabled image and drop them into/product/priv-app/. If your app installs from a sideloaded APK, you can skip that whole detour.
2. Put mitmproxy’s CA in the system store
A user CA gets accepted by Chrome but ignored by native apps. The system store is keyed by a hash of the certificate’s subject, so you compute that hash, rename the certificate to <hash>.0, and push it:
ADB="$ANDROID_SDK_ROOT/platform-tools/adb"
HASH=$(openssl x509 -in ~/.mitmproxy/mitmproxy-ca-cert.pem -subject_hash_old -noout)
"$ADB" -s emulator-5556 root
"$ADB" -s emulator-5556 remount # reboot if it asks, then root + remount again
cp ~/.mitmproxy/mitmproxy-ca-cert.pem "$HASH.0"
"$ADB" -s emulator-5556 push "$HASH.0" /system/etc/security/cacerts/
"$ADB" -s emulator-5556 shell chmod 644 /system/etc/security/cacerts/$HASH.0
"$ADB" -s emulator-5556 shell restorecon /system/etc/security/cacerts/$HASH.0
The remount often needs a reboot before the overlay sticks. Watch adb’s output and redo root and remount after it reboots. Only the public certificate goes on the device; the CA private key never leaves the host.
To prove the system trusts it independently of any app, run a tiny HttpsURLConnection request to https://example.com through the proxy. A 200 response with an issuer of mitmproxy means native trust is sorted.
3. Match Frida host and device versions
Frida loads JavaScript into a running process so you can replace individual methods. Host (Python) and device (frida-server) versions must match exactly. Install the host tools in a venv and push the matching server:
python3 -m venv frida-venv
frida-venv/bin/pip install 'frida==17.24.1' 'frida-tools==14.11.0'
# download frida-server-17.24.1-android-arm64.xz from Frida's GitHub releases, unpack
"$ADB" -s emulator-5556 root
"$ADB" -s emulator-5556 push frida-server /data/local/tmp/frida-server
"$ADB" -s emulator-5556 shell chmod 755 /data/local/tmp/frida-server
"$ADB" -s emulator-5556 shell /data/local/tmp/frida-server # leave running, as root
adb root has to be repeated after every reboot, and the server needs root or it fails with permission errors.
4. Hook only the extra pin check
The app uses a custom X509TrustManager that first delegates to Android’s default trust manager and then applies its own public-key pinning. Decompile the app with JADX to find that class and the field holding the delegate, then replace the method so it calls the delegate and skips the pin check:
Java.perform(function () {
const Pinning = Java.use('vf.a'); // app's trust-manager class
const check = Pinning.checkServerTrusted.overload(
'[Ljava.security.cert.X509Certificate;', 'java.lang.String');
check.implementation = function (chain, authType) {
this.a.value.checkServerTrusted(chain, authType); // real Android validation
// extra pin check intentionally not called
};
});
The class and field names (vf.a, a) are the runtime names. JADX shows you prettified aliases, so cross-check them against the runtime. This hook still rejects an untrusted chain; it only removes the pin that would otherwise refuse mitmproxy’s (now system-trusted) certificate.
5. Attach to the running app, don’t spawn it
This was the one non-obvious lifecycle lesson. Spawning the app under Frida crashed it in its native library before the hook even loaded. What survived was:
- Start the app normally with the proxy off and let it settle.
- Attach to the existing PID (not spawn), then load the bridge and the hook.
- Wait for the hook’s “ready” signal, then sit idle for about 15 seconds to confirm it is stable.
- Only then enable the proxy and do your thing.
- On any error or detach, flip the proxy back off.
pid = int(run("shell", "pidof", "pl.allegro.sale"))
session = frida.get_device("emulator-5556").attach(pid)
script = session.create_script(bridge + hook)
script.load()
# after the hook reports ready and an idle warmup:
run("shell", "settings", "put", "global", "http_proxy", "10.0.2.2:8080")
Flip the proxy with adb shell settings put global http_proxy 10.0.2.2:8080 to turn it on, and :0 to turn it off. Keeping it off until the hook is confirmed stable means you never half-capture a login with the hook already dead.
The payoff
With all of that in place, mitmweb decrypted the app’s native GraphQL calls: real operation names, 200 responses, and the token-exchange flow during login. From there I could reconstruct the request shapes in a small client and verify them against my own account.
What this is and isn’t
- It reads your own authenticated traffic. It is not a way around login, 2FA, CAPTCHA, or device and bot checks; all of those stay fully intact.
- The hook lives in memory for one process. The APK on disk is untouched: no trust-all, no signature or integrity patching.
- Version-matched Frida and attach-don’t-spawn were the two things that turned “it crashes” into “it works.” I changed several details at once, so I won’t claim I pinned down the exact crash cause, only that this combination held through repeated captures and a full login.
Useful references
- Frida docs and Android setup
- mitmproxy certificates
- Android network security config (why system vs. user CA trust and app pinning are separate)
- JADX for finding the class and field to hook