Implementing Global SDK Privacy Monitoring and Hooking in Android via ASM Transform and Custom Annotations
This article explains how QuNar’s front‑end team built a comprehensive Android SDK privacy monitoring system using a custom Transform, ASM bytecode manipulation, and annotation‑driven hook configurations, detailing both basic and advanced solutions for globally intercepting sensitive API calls and ensuring extensible, version‑independent protection.
1 Background
In 2019, a CCTV 3.15 report exposed personal data leaks through mobile apps, highlighting the risk of third‑party SDKs that are delivered as binaries without source code. Such SDKs may misuse or contain vulnerabilities that collect user privacy information or execute malicious code.
This article shares how QuNar’s front‑end team built a technical solution to control both internal and third‑party SDKs from accessing privacy data.
2 Features and Advantages
1. Global Monitoring
Network requests
Permission requests
Reading installed app list
Reading Android SN (Serial)
Accessing contacts, call logs, calendar, device number
Location and base‑station info
MAC and IP addresses
Reading IMEI, MEID, IMSI, ADID
2. Comprehensive and Efficient
No need to upgrade SDK versions or modify business logic; a single configuration enables monitoring of existing and future SDKs.
3. Simple Development
Adding a hook requires only a custom method and one annotation line; the change takes effect after compilation.
4. No Library Version Dependency
The tool hooks only the APIs that exist in the utility library, avoiding complex version checks.
5. Strong Extensibility
Custom tool methods can record call stacks, return empty data, or apply other custom controls.
3 Initial Solution
The first approach uses a custom Android Transform to globally hook sensitive API calls by replacing them with custom methods, handling both Java classes and JARs.
TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
telephonyManager.getDeviceId();After transformation the call becomes:
TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
// Call our utility class
QTelephonyManager.getDeviceId(telephonyManager);ASM bytecode manipulation replaces the original INVOKEVIRTUAL instruction with INVOKESTATIC pointing to the custom class.
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "android/telephony/TelephonyManager", "getDeviceId", "()Ljava/lang/String;", false);
// becomes
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/mqunar/goldcicada/lib/QTelephonyManager", "getDeviceId", "(Landroid/telephony/TelephonyManager;)Ljava/lang/String;", false);The custom utility method:
public static String getDeviceId(TelephonyManager telephonyManager) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return "";
}
return telephonyManager.getDeviceId();
}The Transform implementation reads class bytes, modifies the ClassNode , and writes back the transformed bytecode.
public static byte[] transform(byte[] bytes) {
def classNode = new ClassNode()
new ClassReader(bytes).accept(classNode, 0)
classNode = transform(classNode)
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS)
classNode.accept(cw)
return cw.toByteArray()
}Limitations of this approach include hard‑coded logic, library version dependencies, and the need for deep ASM knowledge for each new hook.
4 Advanced Solution
To overcome the drawbacks, a generic configuration driven by custom annotations is introduced. The workflow:
Create an annotation library ( apt-annotation ) defining @AsmField with parameters for the original class, method, and access type.
Tool library methods are annotated with @AsmField to declare which SDK method they replace.
The main project depends on the tool library.
A custom Android plugin AnnotationParserTransform scans bytecode for these annotations, builds a hook configuration list, and passes it to a second Transform.
The second Transform ( AsmInjectProxy ) performs bytecode replacement based on the generated configuration, eliminating hard‑coded logic.
Example annotation:
@AsmField(oriClass = TelephonyManager.class, oriAccess = MethodAccess.INVOKEVIRTUAL)
public static String getDeviceId(TelephonyManager telephonyManager) { ... }Parsing code extracts annotation values and creates AsmItem objects that store original and target method descriptors.
static void parseAsmAnnotation(byte[] bytes) {
def klass = new ClassNode()
new ClassReader(bytes).accept(klass, 0)
klass.methods.each { method ->
method.invisibleAnnotations?.each { node ->
if (node.desc == 'Lcom/mqunar/qannotation/AsmField;') {
asmConfigs << new AsmItem(klass.name, method, node)
}
}
}
}The injection Transform iterates over instructions, matches them against the configuration, and rewrites the opcode, owner, name, and descriptor accordingly.
if (asmItem.oriDesc == insnNode.desc && asmItem.oriMethod == insnNode.name &&
insnNode.opcode == asmItem.oriAccess && insnNode.owner == asmItem.oriClass) {
insnNode.opcode = asmItem.targetAccess
insnNode.name = asmItem.targetMethod
insnNode.desc = asmItem.targetDesc
insnNode.owner = asmItem.targetClass
}This configuration‑driven approach handles most hook scenarios (≈80%). For more complex cases such as constructor hooks, super‑method calls, and object creation, additional logic is added.
5 Super Hook
Examples include:
Hooking Activity.requestPermissions via a static method annotated with @AsmField(oriClass = Activity.class, oriAccess = MethodAccess.INVOKESPECIAL) and using reflection to invoke the original super method.
Hooking instance methods on Object to cover both Activity and non‑Activity callers.
Replacing new OkHttpClient.Builder() with a custom builder method, requiring removal of the original NEW and DUP instructions to keep the stack balanced.
Constructor hook example:
@AsmField(oriClass = OkHttpClient.Builder.class, oriMehod = "
", oriAccess = MethodAccess.INVOKESPECIAL)
public static OkHttpClient.Builder getOkHttpClientBuilder() {
OkHttpClient.Builder builder = new OkHttpClient.Builder();
builder.addInterceptor(chain -> {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
return new Response.Builder().code(404).protocol(Protocol.HTTP_2)
.message("Can`t use network by GoldCicada")
.body(ResponseBody.create(MediaType.get("text/html; charset=utf-8"), ""))
.request(chain.request()).build();
}
return chain.proceed(chain.request());
});
return builder;
}The Transform locates the <init> invocation, replaces it with a static call, and removes the preceding NEW and DUP nodes to avoid stack errors.
6 Conclusion
The project now provides a unified, annotation‑driven mechanism to monitor and control privacy‑related API calls across the app and third‑party SDKs without modifying source code or SDK versions. It also supports hooking reflective calls and dynamic dex loading, offering a comprehensive security layer for Android clients.
END
Qunar Tech Salon
Qunar Tech Salon is a learning and exchange platform for Qunar engineers and industry peers. We share cutting-edge technology trends and topics, providing a free platform for mid-to-senior technical professionals to exchange and learn.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.