A Hardened App's One Fatal Flaw: Token Verification Anchored to a Spoofable android_id
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
A Hardened App’s One Fatal Flaw: Token Verification Anchored to a Spoofable android_id
This sample was another gray-market streaming app — and worse than the last one. Every screen was explicit enough that I’m not reproducing any of it; let’s stay on the technical side, because technically it’s the more interesting specimen. The last app of this kind folded to a one-line in-app-purchase hook. This one didn’t. It had a shell, it encrypted its traffic, and — the real difference — it did its entitlement checks server-side through a token.
Most crack writeups stop at “change this boolean.” This app is worth a writeup precisely because that approach fails, and the reason it ultimately still loses is a single, instructive design flaw.
First impressions
The first instinct — the in-app-purchase trick, locally rewriting the view count to infinity — doesn’t work here. Working through it reveals the defenses stack up:
- A packer.
- AES-encrypted traffic.
- Server-side token management and verification.
That last one changes everything. Let me walk through each layer and then the flaw.
Unpacking
The app uses Bangcle’s free shell. frida-unpack handles it; I covered the tool and its errors last time, so here’s just the script:
#-*- coding:utf-8 -*-
import frida
import sys
def on_message(message, data):
if message['type'] == 'send':
base = message['payload']['base']
size = int(message['payload']['size'])
print(hex(base), size)
elif message['type'] == 'error':
for i in message:
if i == "type":
print("[*] %s" % "error:")
continue
if type(message[i]) is str:
print("[*] %s" %
i + ":\n {0}".format(message[i].replace('\t', ' ')))
else:
print("[*] %s" %
i + ":\n {0}".format(message[i]))
else:
print(message)
# print session
# dex_bytes = session.read_bytes(base, size)
# f = open("1.dex","wb")
# f.write(dex_bytes)
# f.close()
#
# Android 9.0 arm needs to hook:
# _ZN3art13DexFileLoader10OpenCommonEPKhjS2_jRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPKNS_10OatDexFileEbbPS9_NS3_10unique_ptrINS_16DexFileContainerENS3_14default_deleteISH_EEEEPNS0_12VerifyResultE
# Android 7.0 arm:
# _ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_
package = "com.hello.qqc"
print( "dex export dir: /data/data/%s"%(package))
device = frida.get_usb_device()
pid = device.spawn(package)
session = device.attach(pid)
src = """
var exports = Module.enumerateExportsSync("libart.so");
for(var i=0;i<exports.length;i++){
if(exports[i].name == "_ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_"){
var openMemory = new NativePointer(exports[i].address);
}
}
Interceptor.attach(openMemory, {
onEnter: function (args) {
var begin = args[1]
console.log("magic : " + Memory.readUtf8String(begin))
var address = parseInt(begin,16) + 0x20
var dex_size = Memory.readInt(ptr(address))
console.log("dex_size :" + dex_size)
var file = new File("/data/data/%s/" + dex_size + ".dex", "wb")
file.write(Memory.readByteArray(begin, dex_size))
file.flush()
file.close()
var send_data = {}
send_data.base = parseInt(begin,16)
send_data.size = dex_size
send(send_data)
},
onLeave: function (retval) {
if (retval.toInt32() > 0) {
}
}
});
"""%(package)
script = session.create_script(src)
script.on("message" , on_message)
script.load()
device.resume(pid)
sys.stdin.read()
My environment was a Nox emulator on Android 7.1.2. Other Android versions need a different hook point — Android 8.1 hooks OpenCommon; Android 9.0’s OpenMemory has different arguments (arg[1] is the dex size, not the address). If you hit those, work out the principle and adapt.
Repackaging
The dump drops four dex files into /data/data/com.hello.qqc:


Rename them classes.dex, classes2.dex, classes3.dex, classes4.dex and replace the packed APK’s classes.dex. Then fix AndroidManifest.xml: the android:name under <application> currently points at com.SecShell.SecShell.ApplicationWrapper (the Bangcle entry), so point it at the real Application subclass instead — search extends Application and you find cn.net.tokyo.ccg.base.App:

Set android:name="cn.net.tokyo.ccg.base.App", sign, install — it runs.
Packet capture
The AES layer
Charles over HTTPS shows encrypted data:

The encrypted blobs start with d. The first one, d:xxxx..., sits in every HTTPS request’s header — a per-request encrypted blob, likely identity-related. The second and third are the request parameters and the response body.
The crypto lives in cn.net.tokyo.ccg.base.encrypt.EncryUtil — AES, with the key and IV right there:



A Java decryptor for the header blob:
package AesDecrypt;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.security.Key;
import java.util.Base64;
public class EncryUtil {
public static void main(String []args) throws Exception {
String str ="suh8B7nUE/BRq3WsTT1R7YpRKA8PfI7xAnwisnYUT2I/Agp8iMWbc7ihFNosWg7oqn/g5Zw/fPqxTUjRvcO5CigHWtsR2KRL6pOTxu9IXufRGafuHOLLFU1bLOpAqW2Y0lPG5L2PjyW1tXM5ZaXs9m3wfV5nTbRWe+hcNom+1Ly2yQwiXFBBEnl5QOH78j3ow2Ho7qx5i2Gfr5fJTgb3jtmJT7qtep7FMeJoqzdotNVdiRPrG/yShHe1blD2KmK6+3CfxdzqNyvd6OcxpnS+n52LvAerjDwVA5I0Nlgxj94qZ4I0rpl99vr97Yc2lmszjICIJUPx0+/XyjjonCAZO99AklJCtgguCeQxwDKP4x86mNLB498ckcLPxFD+gARWujdeG9K3t1cGpm35rz+1SgOc0qnZA59SHmssxn50MKsYeY1t7JM8gopFO+e8EFHiqjZ19EPVq+RdnKIWrBG2DzWg7WX25d3MKg8IiqU7ZUseWwg1u/SW6ufAuuDhJva2MunqhBJNVky4E4NyzTe5jg==";
System.out.println(decryptFromBase64(str));
}
public static String decryptFromBase64(String arg1) {
String v0 = "UTF-8";
try {
Base64.Decoder decoder = Base64.getDecoder();
return new String(decrypt( decoder.decode(arg1.getBytes())), v0);
}
catch(UnsupportedEncodingException v1) {
throw new RuntimeException("decrypt fail!", ((Throwable)v1));
}
}
public static byte[] decrypt(byte[] arg3) {
String v0 = "AES";
byte[] arg4 = "xPxo2S5uGPhKHx5g".getBytes();
byte[] arg5 = "0a1b2c3d4e5f6789".getBytes();
if(arg4.length == 16) {
try {
SecretKeySpec v1 = new SecretKeySpec(new SecretKeySpec(arg4, v0).getEncoded(), v0);
Cipher v4 = Cipher.getInstance("AES/CBC/PKCS5Padding");
v4.init(2, ((Key)v1), new IvParameterSpec(arg5));
return v4.doFinal(arg3);
}
catch(Exception v3) {
v3.printStackTrace();
throw new RuntimeException("decrypt fail!", ((Throwable)v3));
}
}
throw new RuntimeException("Invalid AES key length (must be 16 bytes)");
}
}
The decrypted header:
{"Connection":"close","app":"3","platform":"1","s":"cd551ab25dc5c0e93def7b9bd42df473","t":"22430220186106","token":"LzF2SHp6NldrVmVBaHFobmVMVys3RFZ3VkxVRnFMYSs4akRYUGlrRnlLL05lTXBPblp6YUJVekxxTjBERG9xNktDVzRackxhY3hCeTdyaWdsVDMyejMxa05oQmVLa2l0UDU4NjZLSWlzN0YzRzZ0V2dDWnM1UmFsbUxGSnNVUW1XWlYvMnp0SldvWUNJKzFjdHlvbFo0c2VYcHMyMERreWFOMUt6b1d0enVpWno1QnIxcXI4TWY2NGQ4NVZpdlVF","version":"1.2.5"}
There’s a token. Remember it.
frida packet capture
This app’s request/response code is also more layered than the last one. Requests are neatly wrapped in an interface, cn.net.tokyo.ccg.dagger.Apis:

The HTTPS wrapper lives in cn.net.tokyo.ccg.dagger.module.ApiModule:

There’s the familiar string — the per-request header blob, with the token front and center. Hook static Response a(Interceptor$Chain arg1) and call arg1.request().url().toString() to print the request URL, plus a second hook at the AES site for the plaintext:
var ApiModule = Java.use("cn.net.tokyo.ccg.dagger.module.ApiModule");
ApiModule.a.overload('okhttp3.Interceptor$Chain').implementation = function(arg1){
send("send request **********************************************")
send("request_url:"+arg1.request().url().toString());
return this.a(arg1);
}
var EncryUtil = Java.use("cn.net.tokyo.ccg.base.encrypt.EncryUtil")
var GsonTools = Java.use("cn.net.tokyo.ccg.base.helper.GsonTools")
EncryUtil.encryptAes.overload("java.util.Map").implementation = function(arg1){
send("request encrypted data:")
send(GsonTools.createGsonString(arg1));
return this.encryptAes(arg1);
}
For the response, hook cn.net.tokyo.ccg.dagger.MoreBaseUrlInterceptor’s intercept:
var MoreBaseUrlInterceptor = Java.use("cn.net.tokyo.ccg.dagger.MoreBaseUrlInterceptor");
MoreBaseUrlInterceptor.getResponseString.implementation = function(arg1){
var result = this.getResponseString(arg1);
send("receive data **********************************************");
send(""+ arg1);
send(result)
return result;
}
Registering an account and capturing looks like:


The token verification
Why the local hook fails
The usual play: find the user model cn.net.tokyo.ccg.bean.User, with its getXxx()/setXxx() accessors:

Hook is_vip and view_limit_today… and nothing happens. Watching still pops “you’ve reached today’s limit, buy VIP”:

The string isn’t in the APK, so it’s server-sent. Capture the popup moment:



The popup text is indeed the server’s response. How does the server know the view count is exhausted? The nearby request parameters answer it: the token. The token encodes the user’s state — view count, VIP status, everything — and the server validates it on every request. That’s why the local boolean rewrite does nothing.
Where the token lives
Start from one read site, e.g. cn.net.tokyo.ccg.dagger.module.ApiModule, which reads it via SPHelper.getString():

Which bottoms out in getSharedPreferences() — local storage. SPHelper holds the other persisted values too:

Confirming on disk, /data/data/com.hello.qqc/shared_prefs/config.xml holds the token and a lot more. The two to focus on: token and key_uuid:

Where the token comes from
The token is server-issued, so go back to the capture:



The server returns the token from https://[redacted]/api/v2/bootstrap. Search those parameters in the app to locate the code:

Which parameter drives the token? android_id, deviceid, and mac_address are all the same value, and device_uuid is ultimately derived from android_id too. So: the token is issued against the device’s android_id.
Defeating it: the “infinite token”
Server-side token verification sounds like a dead end — VIP only comes from paying, and the token is what proves you paid. But the analysis above cracks it open: the token is bound to the device’s android_id, each device has one android_id, and each android_id gets one free watch per day. So: rotate the android_id, get a fresh token, watch again. Modify the app to mint a new random android_id after every video and you’ve turned one free view into unlimited.
Minting a random android_id
Add a small class that generates a random 16-char hex string. In Java:
package fingerprint.me;
public class getRandomDeviceId {
public static String getString(){
String str = "";
for (int i = 0; i < 16; i++) {
char temp = 0;
int key = (int) (Math.random() * 2);
switch (key) {
case 0:
temp = (char) (Math.random() * 10 + 48); // produce a random digit
break;
case 1:
temp = (char) (Math.random() * 6 + 'a'); // produce a-f
break;
default:
break;
}
str = str + temp;
}
return str;
}
}
Compiled to smali:
.class public Lfingerprint/me/getRandomDeviceId;
.super Ljava/lang/Object;
.source "getRandomDeviceId.java"
# direct methods
.method public constructor <init>()V
.registers 1
.line 3
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
.method public static getString()Ljava/lang/String;
.registers 8
.line 5
const-string v0, ""
.line 6
.local v0, "str":Ljava/lang/String;
const/4 v1, 0x0
.local v1, "i":I
:goto_3
const/16 v2, 0x10
if-ge v1, v2, :cond_48
.line 7
const/4 v2, 0x0
.line 8
.local v2, "temp":C
invoke-static {}, Ljava/lang/Math;->random()D
move-result-wide v3
const-wide/high16 v5, 0x4000000000000000L # 2.0
mul-double v3, v3, v5
double-to-int v3, v3
.line 9
.local v3, "key":I
if-eqz v3, :cond_28
const/4 v4, 0x1
if-eq v3, v4, :cond_17
goto :goto_36
.line 14
:cond_17
invoke-static {}, Ljava/lang/Math;->random()D
move-result-wide v4
const-wide/high16 v6, 0x4018000000000000L # 6.0
mul-double v4, v4, v6
const-wide v6, 0x4058400000000000L # 97.0
add-double/2addr v4, v6
double-to-int v4, v4
int-to-char v2, v4
.line 15
goto :goto_36
.line 11
:cond_28
invoke-static {}, Ljava/lang/Math;->random()D
move-result-wide v4
const-wide/high16 v6, 0x4024000000000000L # 10.0
mul-double v4, v4, v6
const-wide/high16 v6, 0x4048000000000000L # 48.0
add-double/2addr v4, v6
double-to-int v4, v4
int-to-char v2, v4
.line 12
nop
.line 19
:goto_36
new-instance v4, Ljava/lang/StringBuilder;
invoke-direct {v4}, Ljava/lang/StringBuilder;-><init>()V
invoke-virtual {v4, v0}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
invoke-virtual {v4, v2}, Ljava/lang/StringBuilder;->append(C)Ljava/lang/StringBuilder;
invoke-virtual {v4}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
move-result-object v0
.line 6
.end local v2 # "temp":C
.end local v3 # "key":I
add-int/lit8 v1, v1, 0x1
goto :goto_3
.line 21
.end local v1 # "i":I
:cond_48
return-object v0
.end method
Then inject the class into the dex with the real logic:

And rewrite DeviceHelper.getDeviceID to call our generator:
.method public static getDeviceID(Landroid/content/Context;)Ljava/lang/String;
.registers 2
invoke-static {}, Lfingerprint/me/getRandomDeviceId;->getString()Ljava/lang/String;
move-result-object p0
return-object p0
.end method

Requesting a fresh token
Now trigger the bootstrap logic on demand. We already found the bootstrap code — cn.net.tokyo.ccg.util.f’s a method. Tracing its callers back: b.a.a.a.d.a.m0’s h method, and finally cn.net.tokyo.ccg.ui.activity.MainActivity’s onEventReceived:


So the token request fires when onEventReceived receives a TaskEvent with action == 401. Cross-reference TaskEvent to find how it’s dispatched — via org.greenrobot.eventbus.c.c().b(). The smali to send one:
new-instance v1, Lcn/net/tokyo/ccg/base/Event$TaskEvent;
invoke-direct {v1}, Lcn/net/tokyo/ccg/base/Event$TaskEvent;-><init>()V
const/16 v2, 401
iput v2, v1, Lcn/net/tokyo/ccg/base/Event$TaskEvent;->action:I
invoke-static {}, Lorg/greenrobot/eventbus/c;->c()Lorg/greenrobot/eventbus/c;
move-result-object v2
invoke-virtual {v2, v1}, Lorg/greenrobot/eventbus/c;->b(Ljava/lang/Object;)V
Wiring it into the activity lifecycle
The last piece: on entering/leaving a video, clear the stored token, write a random key_uuid, and fire the TaskEvent to fetch a new token. I dropped this into the start of onPause() and onDestroy() in VideoDetailActivity:
const-string v0, "token"
const-string v1, ""
invoke-static {p0, v0, v1}, Lcn/net/tokyo/ccg/base/helper/SPHelper;->saveString(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V
const-string v0, "key_uuid"
invoke-static {}, Lfingerprint/me/getRandomDeviceId;->getString()Ljava/lang/String;
move-result-object v1
invoke-static {p0, v0, v1}, Lcn/net/tokyo/ccg/base/helper/SPHelper;->saveString(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V
new-instance v1, Lcn/net/tokyo/ccg/base/Event$TaskEvent;
invoke-direct {v1}, Lcn/net/tokyo/ccg/base/Event$TaskEvent;-><init>()V
const/16 v2, 0x191
iput v2, v1, Lcn/net/tokyo/ccg/base/Event$TaskEvent;->action:I
invoke-static {}, Lorg/greenrobot/eventbus/c;->c()Lorg/greenrobot/eventbus/c;
move-result-object v2
invoke-virtual {v2, v1}, Lorg/greenrobot/eventbus/c;->b(Ljava/lang/Object;)V
Two gotchas worth repeating: clear token and rotate key_uuid before requesting the new token, and after adding smali, bump the method’s .registers count — too few and the repacked APK crashes at runtime. Recompile, sign, install — and the daily limit is gone, along with the ads and popups if you strip them.
What the defender should take away
This is a near-miss that’s more instructive than a total collapse. The app did almost everything right:
- It packed its dex.
- It AES-encrypted its traffic, including a per-request identity header.
- It moved entitlement checks server-side, into a token, so no local boolean rewrite works.
And it still lost, because the token’s entire trust chain bottoms out in android_id — a value the client provides, that any rooted device spoofs in one line. Once the token is bound to a spoofable client value, “server-side verification” is just theater: the server is diligently verifying something the attacker fully controls.
The fix isn’t subtle. A device-bound token needs to bind to something the client can’t trivially fabricate — hardware-backed attestation, a key protected by the Secure Enclave/StrongBox, or at minimum a server-side device registry that issues non-transferable credentials. The moment you anchor trust to android_id (or MAC, or device_uuid derived from it), you’ve anchored it to the weakest thing in the room.
One more note
The AV engines flag this sample as Riskware.Cloud.Generic — “dangerous behavior, may download and install software, steal private info.” I didn’t find anything overtly trojan-like, but if you reproduce this, do it in an emulator or sandbox.

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch