Reverse engineer an APK with me
Exposed network traffic is one of the most revealing windows into how a software system actually works. Every API call, every auth token, every URL — it’s all there if you know how to look. On the web, this is trivial: open DevTools, go to the Network tab, and you’re done. On mobile, it’s a different story.
Android APKs are compiled, often obfuscated, and increasingly hardened against inspection. But with the right approach, you can get there.
At JioHotstar, I often use this process to investigate third-party APKs pirating our authenticated video delivery URLs in real time. That investigation is the thread we’ll follow throughout — the target is an APK called Cricfy.
Static Analysis — Before Running Anything
The first step is decompiling the APK using jadx — an open source tool that converts compiled Android APKs back into readable Java source. No emulator, no network setup. Just the raw app pulled apart.
jadx -d cricfy-apk-out/ cricfy.apk You’re not running the app — you’re reading it. A few things become immediately obvious just from the code:
- What endpoints it calls — base URLs and CDN hostnames are almost always hardcoded as string constants.
- Whether it constructs auth tokens itself — if the app is building signed URLs or attaching auth headers, that logic has to live somewhere in the source. It’s often surprisingly readable even after obfuscation.
- What networking library it uses — OkHttp, Retrofit, Volley. This matters because it directly determines how you’ll intercept traffic later and which Frida hook you’ll write.
- Whether certificate pinning is in place — one grep tells you. If it is, you know before touching the emulator that you’ll need to defeat it.
That last one shapes everything that comes after.
Reading the Manifest
Before touching the source, read the manifest.
cat cricfy-apk-out/resources/AndroidManifest.xml The manifest is the app’s declaration of intent — what permissions it requests, what services it runs, and what it’s allowed to do. INTERNET is expected. FOREGROUND_SERVICE means it can keep running when you switch away. Any permission that looks out of place here is worth noting.
Grepping the Source
Rather than reading the entire codebase, three targeted greps get you to the relevant classes fast. The jadx-gui explorer is useful alongside this for navigating quickly between files.
1. Authentication and signing logic
grep -r "token|sign|hmac|secret|key" ./cricfy-apk-out/sources --include="*.java" -l 2. Video delivery endpoints
grep -r ".m3u8|.mpd|cdn|stream" ./cricfy-apk-out/sources --include="*.java" -l .m3u8 and .mpd are the manifest formats for HLS and DASH streaming respectively. Any class referencing these is directly involved in video playback.
3. Certificate pinning
grep -r "CertificatePinner|TrustManager|network_security" ./cricfy-apk-out/sources --include="*.java" -l CertificatePinner would indicate OkHttp’s built-in mechanism. TrustManager means a custom implementation — which is harder to defeat generically and tells you to look closely at what it’s actually doing.
What This Told Us
Three things became clear from static analysis alone, before running the app at all.
The token signing logic was fully readable. Hidden inside androidx/fragment/app/e.java — deliberately namespaced to look like a legitimate AndroidX class — was the complete URL signing algorithm. It takes a path segment from the stream URL, concatenates it with a secret string, the current Unix timestamp, and a stream ID, then SHA-256 hashes the result into a ?token=<hash>-<expiry>-<timestamp> query string. The entire authentication mechanism, in plain decompiled Java.
The certificate pinning is custom, and it specifically blacklists proxy tools by name. Rather than using OkHttp’s built-in CertificatePinner, the app implements its own X509TrustManager in z5/c.java. Inside checkClientTrusted and checkServerTrusted, it decodes three Base64 strings at runtime and checks if any of them appear in the certificate’s subject name:
String[] strArr = {a("SHR0cENhbmFyeQ=="), a("U1NMQ2FwdHVyZQ=="), a("UGFja2V0IENhcHR1cmU=")}; Decoded, these are HttpCanary, SSLCapture, and Packet Capture — all popular Android traffic interception tools. If any of them appear in the intercepting cert’s subject, it throws a CertificateException and kills the connection. Charles Proxy isn’t on this list, which turns out to matter for the next step.
There’s a second, fully permissive TrustManager for their own backend. z5/a.java implements another X509TrustManager where both checkClientTrusted and checkServerTrusted are completely empty — they accept any certificate unconditionally. This client is used for requests to their own stream server (khhjjshv.com), where they don’t bother validating TLS at all. The hardened TrustManager only guards against inbound inspection.
Static analysis answered all the questions we needed before touching an emulator: we knew the signing algorithm, we knew exactly which proxy tools they’d blocked, and we knew Charles would get through where others wouldn’t.
Dynamic Analysis — Running the App Under a Microscope
Static analysis told us what the app was doing in theory. Dynamic analysis is where we confirm it in practice — watching live network traffic as the app runs.
The goal: route the app’s HTTPS traffic through Charles Proxy so we can read it. The obstacle, as we already knew, is the custom X509TrustManager that rejects any intercepting certificate.
Three things need to be in place before any traffic is visible: a rooted emulator, Charles as a MITM proxy, and Frida to neutralise the cert pinning at runtime.
A rooted emulator
Android Studio’s AVD comes close to ready out of the box, but you need a system image without Google Play — those are locked down and can’t be rooted.
In Android Studio’s AVD Manager, create a new device and pick a system image marked “Google APIs” rather than “Google Play”. The distinction matters — Google APIs images ship with a writable system partition.
Boot the emulator with a writable system partition:
emulator -avd RootedPixel -writable-system -no-snapshot-load adb root
adb remount Then install the APK:
adb install cricfy.apk Routing Traffic Through Charles
With the emulator running, configure it to use Charles as a proxy. Find your machine’s IP on the Charles menu bar, then point the emulator at it.
In the emulator: Settings → WiFi → AndroidWifi → Proxy → Manual, set the host to your machine’s IP and port 8888. Or via adb:
adb shell settings put global http_proxy 192.168.0.12:8888 In Charles: Proxy → SSL Proxying Settings, add * to proxy all hosts. Then install the Charles root cert on the device — open chls.pro/ssl in the device browser, download and install it.
At this point Charles will intercept HTTP fine, but HTTPS requests will fail — the custom TrustManager sees Charles’s cert and kills the connection. Exactly what we expected.
Defeating Certificate Pinning with Frida
Frida is a dynamic instrumentation toolkit. When an Android app runs, the ART runtime is the layer that actually executes your compiled code. Frida works by injecting a JavaScript engine directly into this runtime — not at the OS level, not at the network level, but inside the process itself.
This means Frida can intercept any method call before it runs, after it returns, or both. It doesn’t matter if the code is obfuscated — as long as ART has resolved it to execute it, Frida can hook it. You’re not patching a binary or modifying the APK; you’re reaching into a live process and replacing method implementations on the fly.
This is fundamentally different from Charles. Charles sits between the app and the internet. Frida sits inside the app itself.
Setting Up Frida
Install Frida on your machine:
pip install frida-tools Download the Frida server for the emulator’s architecture (x86_64 for most AVDs) from frida.re/releases, then push it to the device:
adb push frida-server /data/local/tmp/
adb shell chmod 755 /data/local/tmp/frida-server
adb shell /data/local/tmp/frida-server & Run the hook against the app’s process:
frida -U -n com.cricfy.app -l unpinner.js The Unpinning Script
Rather than targeting the specific obfuscated class we found in jadx (z5.c), the script hooks at three generic layers — making it robust regardless of how pinning is implemented:
Java.perform(function() {
console.log("[*] Starting SSL unpinning...");
// Layer 1: Replace the TrustManager at SSLContext initialisation
try {
var X509TrustManager = Java.use('javax.net.ssl.X509TrustManager');
var SSLContext = Java.use('javax.net.ssl.SSLContext');
var TrustManager = Java.registerClass({
name: 'com.custom.TrustManager',
implements: [X509TrustManager],
methods: {
checkClientTrusted: function(chain, authType) {},
checkServerTrusted: function(chain, authType) {},
getAcceptedIssuers: function() { return []; }
}
});
var TrustManagers = [TrustManager.$new()];
var SSLContextInit = SSLContext.init.overload(
'[Ljavax.net.ssl.KeyManager;',
'[Ljavax.net.ssl.TrustManager;',
'java.security.SecureRandom'
);
SSLContextInit.implementation = function(keyManager, trustManager, secureRandom) {
SSLContextInit.call(this, keyManager, TrustManagers, secureRandom);
};
console.log("[+] SSLContext unpinned");
} catch(e) { console.log("[-] SSLContext: " + e); }
// Layer 2: Neutralise OkHttp's CertificatePinner directly
try {
var CertificatePinner = Java.use('okhttp3.CertificatePinner');
CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function() {
console.log("[+] OkHttp pin bypassed for: " + arguments[0]);
};
console.log("[+] OkHttp CertificatePinner unpinned");
} catch(e) { console.log("[-] OkHttp: " + e); }
// Layer 3: Bypass Conscrypt's chain verification as a catch-all
try {
var TrustManagerImpl = Java.use('com.android.org.conscrypt.TrustManagerImpl');
TrustManagerImpl.verifyChain.implementation = function() {
console.log("[+] TrustManagerImpl bypassed");
return arguments[0];
};
console.log("[+] TrustManagerImpl unpinned");
} catch(e) { console.log("[-] TrustManagerImpl: " + e); }
console.log("[*] SSL unpinning complete");
}); Why Three Layers
When an app makes an HTTPS request, a TLS handshake happens first. The server presents its certificate. The app checks it. If trusted, the connection proceeds. If not, it’s killed.
When Charles sits in the middle, it presents its own certificate. On a normal device, the OS trusts it because you installed Charles’s root cert. But this app runs its own certificate checks in Java code, independent of the OS. So even though the OS accepts Charles, the app rejects it.
This is certificate pinning. And it’s just Java code. Here’s what happens without the script:
App makes HTTPS request
→ Charles intercepts, presents its own cert
→ App's TrustManager runs checkServerTrusted()
→ Sees "Packet Capture" / "HttpCanary" in cert subject
→ Throws CertificateException
→ Connection killed. Charles sees nothing. Frida injects into the ART runtime of this specific process and replaces the Java methods that do the checking, before any request is made. Frida targets only the process you specify via -n — every other app on the device is unaffected.
By the time the app tries to connect, the methods that would have rejected Charles are already gone:
Frida injected into com.cricfy.app's ART process (before any request)
→ SSLContext.init hooked: app's TrustManager swapped out
→ OkHttp CertificatePinner.check hooked: replaced with no-op
→ Conscrypt TrustManagerImpl.verifyChain hooked: bypassed
App makes HTTPS request
→ Charles intercepts, presents its own cert
→ checkServerTrusted() does nothing, no exception thrown
→ OkHttp pin check: no-op, passes
→ Conscrypt chain verification: returns unverified, passes
→ Connection succeeds. Charles sees everything. The script targets three layers because different apps implement pinning at different levels:
SSLContext.init — the earliest possible intercept. Every SSL connection starts here. SSLContext.init takes a TrustManager as an argument — the object responsible for deciding whether to trust a certificate. The script intercepts this call and swaps in a permissive TrustManager before the app’s own ever gets installed.
okhttp3.CertificatePinner.check — OkHttp’s own pinning layer. OkHttp has a pinning mechanism that runs independently of the system TrustManager. Even if the first layer passes, OkHttp can still reject a certificate by comparing it against a hardcoded list of expected hashes. The script replaces check with a no-op.
TrustManagerImpl.verifyChain — Conscrypt, Android’s TLS layer. Conscrypt is Android’s underlying TLS implementation, sitting below both the app code and OkHttp. If anything slips past the first two layers, this is the final gate. The script bypasses it by returning the chain as-is without verification.
With all three layers hooked, HTTPS traffic flows through Charles without resistance.
The Discovery
With Frida running and Charles intercepting, we opened the app and tapped into a live match. The traffic told the full story.
Three domains were doing all the work.
SofaScore (api.sofascore.com) was serving every piece of match metadata — tournament listings, team images, live scores, fixture times. The app has no sports data infrastructure of its own. It’s a thin wrapper around SofaScore’s public API, scraping it to populate the UI.
poland-help.org was the backend. When a user taps a match to start a stream, the app fires a request like:
GET /data/pro/SW5kaWFulFByZW1pZXlgTGVhZ3Vl...
Host: poland-help.org
User-Agent: okhttp/5.3.2 The path is Base64 encoded — deliberately obscured. The response body is also a long Base64 blob. Decoded, it’s a signed playlist URL or session token handed directly to the player. This is the domain cert pinning was protecting — not the video itself, but the key that unlocks it.
DAZN’s CDN (dck1-fs-live.dtcdn.dazn.com) was serving the actual video bytes. Once the app decoded the response from poland-help.org, it used it to fetch the stream directly from DAZN’s infrastructure.
The full flow:
User taps a match
→ App calls api.sofascore.com for match metadata
→ App calls poland-help.org with Base64 encoded match identifier
→ poland-help.org returns Base64 encoded stream URL or session token
→ App decodes it, constructs a DAZN CDN request
→ Video streams from dck1-fs-live.dtcdn.dazn.com Why the CDN URL is open but the backend is pinned
DAZN’s CDN doesn’t authenticate individual requests. Like most CDNs, it’s optimised purely for delivery — once you have a valid URL, it serves you the content without further checks. Authentication happens upstream, at the origin, before the URL is issued.
The cert pinning wasn’t protecting the video stream. It was protecting poland-help.org — the request that reveals how the app obtains authenticated DAZN access in the first place. That’s where the actual credential lives. Intercepting that request exposes the core of the operation.
The pinning wasn’t a security feature for users. It was the authors protecting their own infrastructure from being reverse engineered. Which, as it turns out, didn’t work.
But the response was still encoded
Getting through the pinning didn’t hand us the answer on a plate. The poland-help.org response was a Base64 blob — and decoding that gave us another encoded payload, likely a further obfuscated URL or token construction that the app resolves at runtime. The network layer was visible, but the meaning of what was flowing through it still required work to interpret.
They had layered their obscurity: pinning at the transport layer, encoding at the application layer, and runtime decoding inside the app’s own logic. Each layer alone is weak. Together, they meaningfully slow down investigation.
The honest take on ROI
At scale, protecting authenticated video streams from this kind of piracy is a genuinely difficult cost-benefit problem. The piracy app works — streams are playing. But every enforcement action — token revocation, CDN-level blocking, legal takedowns — triggers a cat-and-mouse response. The piracy operators rotate domains, re-encode responses, and ship a new APK. The investment to stay one step ahead can outpace the revenue protected, especially for long-tail content.
What they do well, at the network layer specifically, is blend in. DAZN’s CDN traffic looks like legitimate streaming traffic because it is legitimate CDN traffic — the URLs are real, the tokens are valid, just obtained through a channel DAZN didn’t intend. Short of per-session device binding or behavioral anomaly detection on the origin side, the stream itself is indistinguishable from a paying subscriber’s.
The app’s approach isn’t sophisticated. But it’s effective enough, cheap to operate, and hard to shut down permanently. That’s a harder problem than the technical one.
Closing Thoughts
This isn’t a particularly advanced technique. With jadx, a rooted emulator, Charles, and Frida, the entire process is accessible to any Android engineer willing to spend an afternoon setting it up. The toolchain is open source, well documented, and the barrier is lower than most people assume.
What’s surprising is how much a running app reveals about itself once you have the environment in place. The signing algorithm was sitting in decompiled Java. The proxy tool blacklist was Base64 encoded inline in the source. The backend domain showed up the moment traffic started flowing. None of it was truly hidden — it was just inconvenient to look at without the right tools.
A few things worth taking away:
Obfuscation is not security. Renaming classes to z5.c and hiding logic inside androidx.fragment.app adds friction, not protection. jadx and a few grep commands get through it.
Certificate pinning only raises the bar. It stopped casual inspection with a proxy. It didn’t stop Frida. Once an attacker has a rooted environment and some patience, pinning alone isn’t enough.
The real exposure was architectural. The app was entirely dependent on a single unlisted backend — poland-help.org — brokering access to DAZN. That single request, once visible, explained the entire operation. No amount of obfuscation changes that if the architecture itself is the vulnerability.
API security deserves as much attention as the client. Most mobile security thinking goes into the app: pinning, obfuscation, tamper detection. But this investigation is a reminder that the API is the actual attack surface. Once traffic is visible, what matters is whether the backend can stand on its own — proper authentication, short-lived tokens, rate limiting, anomaly detection. A hardened app in front of a weak API is just a locked screen door.