Reading the Defender's Source#

When a mobile app ships an anti-bot SDK, that SDK is the defense. Understanding exactly what it collects, how it signs, and what it reports back is the highest form of anti-bot research. The tools are mature, and the workflow—static analysis, then dynamic instrumentation—is learnable.

Static Analysis#

Start by unpacking the app:

  • Android: APKs are ZIP archives. Use jadx to decompile DEX bytecode to readable Java, and apktool to decode resources and the manifest. Native libraries (.so files) require Ghidra or IDA for disassembly.
  • iOS: IPAs are also archives; a decrypted binary (from a jailbroken device) can be loaded into Ghidra, Hopper, or class-dump to recover Objective-C/Swift class and method names.

Look for the SDK's package name, obfuscated by design (e.g., com.a.b.c). Find the classes that collect device data, the functions that compute signatures, and any network calls that report telemetry. String searches for header names, endpoints, and crypto identifiers quickly reveal the structure.

Dynamic Analysis With Frida#

Static code shows what the SDK does; dynamic instrumentation shows what it does at runtime with real values. Frida injects a JavaScript agent into the running app and lets you hook any function:

// Intercept a signing method and print arguments and return value
Java.perform(function () {
  var Signer = Java.use("com.vendor.security.Signer");
  Signer.sign.overload("java.lang.String").implementation = function (input) {
    console.log("[+] sign() input: " + input);
    var result = this.sign(input);
    console.log("[+] sign() output: " + result);
    return result;
  };
});

Running the app while hooked reveals the exact inputs, outputs, keys, and call order—often reducing a "black box" to a documented algorithm in an afternoon.

Hooking Native Code#

When the logic lives in C/C++ (common for fingerprinting and crypto), hook native functions by address or export name:

Interceptor.attach(Module.findExportByName("libsecurity.so", "compute_token"), {
  onEnter: function (args) { console.log("arg0:", args[0].readCString()); },
  onLeave: function (retval) { console.log("ret:", retval.readCString()); }
});

This exposes the data flowing into the native layer even when the Java layer is a thin wrapper.

Anti-Tampering Countermeasures#

Serious SDKs fight back: root and jailbreak detection, emulator detection, debugger checks, integrity self-checks, and code obfuscation that flattens control flow and encrypts strings. Expect to patch checks (via Frida) or work on rooted devices with detection disabled. Some SDKs detect Frida itself; countering that involves renaming or hiding the agent, or using a gadget embedded in a repackaged app.

From Understanding to Strategy#

Reverse engineering an SDK answers the key questions: Is the fingerprint derived from values you can control, or from hardware you cannot? Is the signature computed client-side (reproducible) or server-side (not)? Does it rely on platform attestation (a wall)? The answers determine your strategy—reimplement, hand off to a real device, or walk away. This work is genuinely educational and widely practiced in security research, but it exists in a legal gray zone: use it to understand systems you are authorized to test, and do not use extracted keys to impersonate users or defeat access controls.

Documenting What You Find#

Treat SDK research like any other reverse-engineering project: keep notes on version numbers, function addresses, and offsets, because every app update shifts them. A short internal write-up that records which functions to hook, which keys are embedded, and how the token is assembled turns a week of analysis into a repeatable process the next time you need it. Without documentation, every app release forces you to rediscover the same facts from scratch.

Entry Points: Where the SDK Touches the World#

Reverse engineering a mobile SDK is not an exercise in reading every line of obfuscated bytecode. It is an exercise in finding the boundaries: where the SDK obtains data, where it signs or attests, where it sends data, and where it decides to fail. Those boundaries are usually a small set of classes, even in heavily obfuscated code.

The useful starting points are:

Entry point Typical classes/APIs What to look for Risk to the defender
Attestation/token mint Play Integrity API, App Attest, or internal attestation endpoints nonce passed in, device signals collected, token returned highest; this is the hardware-bound claim
Network stack OkHttp, Retrofit, native HttpURLConnection, Cronet request headers, canonicalisation, signing high; reveals the exact signed payload
Crypto primitives Mac, MessageDigest, Signature, Cipher keys used, input order, what is hashed high; reveals HMAC inputs covered in signed API requests
Device/sensor signals SensorManager, Build.*, Telephony, Location, BatteryManager which surfaces are actually collected medium; shows fingerprint surface
Root/emulator checks SafetyNet/Integrity checks, Magisk detection, Frida detection order, whether checks are blocking medium; tells you bypass points
Persistence SharedPreferences, encrypted files, keystore key storage, token cache medium; shows replay window
Versioning/config remote config, hardcoded constants thresholds, endpoints, feature flags low but useful

The mistake is to start statically decompiling the entire DEX/AAR. Start dynamically: attach a debugger or a tracing tool, run the app in a clean state, and trigger the sensitive action. The first network call after a login or a checkout will usually pass through the code path you care about, and stack traces will point to the SDK's classes. From there you can work backwards to the data sources.

String Classification Tells You What the SDK Is Doing#

Strings survive obfuscation longer than you might expect, and they are often the fastest way to map behaviour. Classify strings by what they imply:

Class Examples Action
network_endpoint full URLs, path prefixes, CDN hosts map which endpoints require attestation or signatures
header_or_cookie_name X-Device-Integrity, x-sdk-signature, cf-ray see what is being sent and received
attestation "IntegrityManager", "AttestationResponse", "PlayIntegrity" find token minting code
crypto_primitive algorithm names (SHA256withRSA, HmacSHA256) reconstruct signing
sensor_surface "accelerometer", "light", "telephony", "battery" inventory collected signals
root_and_emulator "su", "Magisk", "Frida", "ro.debuggable" identify detection checks
obfuscation_marker random class names, ProGuard/R8 rules know how much you must undo
version_pin SDK version strings, build hashes track changes across releases

Entropy is a useful triage: very high-entropy random-looking strings are often encrypted constants; low-entropy, human-readable strings are often config or error messages. The snippet below classifies a small inventory of extracted strings and reports the surfaces with evidence. It also produces a recommended "order of work" -- the hook order that maximises information gain while minimising wasted effort.

import math
from collections import Counter, defaultdict

# String literals recovered from one decoded APK: the DEX string table, the
# resource table, and whatever survived a partial decode. This is the first
# thing you get, and it is usually enough to place the SDK.
STRINGS = [
    "https://telemetry.vendor-sdk.net/v2/events",
    "https://collector.vendor-sdk.net/collect",
    "wss://stream.vendor-sdk.net/live",
    "api.vendor-sdk.net",
    "/v1/session",
    "X-Vendor-Token", "X-Vendor-Device", "x-sdk-session", "X-Request-Id",
    "Content-Type", "User-Agent",
    "vd_token", "_vdsid", "vd_sess", "sessionid",
    "HmacSHA256", "SHA-256", "AES/CBC/PKCS5Padding", "SecretKeySpec",
    "javax.crypto.Mac", "java.security.MessageDigest", "PBKDF2WithHmacSHA1",
    "com.google.android.gms.security.api.IntegrityManager",
    "IntegrityTokenRequest", "StandardIntegrityTokenRequest",
    "AppIntegrityToken", "com.apple.devicecheck.DCAppAttestService",
    "getInstalledPackages", "Build.FINGERPRINT", "ro.debuggable",
    "/proc/cpuinfo", "android_id", "SensorManager", "AudioRecord",
    "canOpenURL", "getDeviceId",
    "/system/xbin/su", "/system/bin/superuser", "test-keys", "goldfish",
    "qemu.hw.mainkeys", "Magisk", "/sbin/.magisk", "ro.hardware",
    "DexGuard", "StringRepository", "com.a.b.c", "a.b.c.d",
    "aHR0cHM6Ly9jb2xsZWN0b3Iu",
    "kZ8s1QxW3nR7pL2mV9tB4yH6",
    "minSdkVersion", "targetSdkVersion", "6.4.1",
    "activity_main", "androidx.core.app.AppCompatActivity",
    "res/values/strings.xml",
]

RULES = [
    ("attestation", ("IntegrityManager", "IntegrityTokenRequest", "StandardIntegrityTokenRequest",
                     "AppIntegrityToken", "DCAppAttestService")),
    ("root_and_emulator", ("/su", "/superuser", "test-keys", "goldfish", "qemu.hw",
                           "Magisk", "ro.hardware")),
    ("crypto_primitive", ("HmacSHA", "SHA-256", "AES/", "SecretKeySpec", "crypto.Mac",
                          "MessageDigest", "PBKDF2")),
    ("sensor_surface", ("getInstalledPackages", "FINGERPRINT", "ro.debuggable",
                        "/proc/cpuinfo", "android_id", "SensorManager",
                        "AudioRecord", "canOpenURL", "getDeviceId")),
    ("network_endpoint", ("http://", "https://", "wss://", "api.", "telemetry",
                          "collector", "/v1/", "/collect")),
    ("header_or_cookie_name", ("X-Vendor", "x-sdk", "X-Request-Id", "Content-Type",
                               "User-Agent", "vd_token", "_vdsid", "vd_sess", "sessionid")),
    ("obfuscation_marker", ("DexGuard", "StringRepository", "a.b.c", "a.b.c.d")),
    ("version_pin", ("SdkVersion", "6.4.1")),
]


def entropy(s):
    if not s:
        return 0.0
    counts = Counter(s)
    n = float(len(s))
    return -sum((c / n) * math.log2(c / n) for c in counts.values())


def classify(s):
    low = s.lower()
    for name, needles in RULES:
        if any(n.lower() in low for n in needles):
            return name
    return "unclassified"


NEXT_HOOK = {
    "attestation": "hook the token mint, then log the request it is bound to",
    "root_and_emulator": "hook the checks before patching them; the result tells you nothing yet",
    "crypto_primitive": "hook Mac.doFinal and MessageDigest.digest for real values",
    "sensor_surface": "hook each getter separately; a wrapper hides which ones run",
    "network_endpoint": "hook the HTTP client, not the collector, for the full request",
    "header_or_cookie_name": "grep the manifest for the SDK service and start there",
    "obfuscation_marker": "find the string table first; every other hook is premature",
    "version_pin": "note the build; this whole inventory moves on the next release",
    "unclassified": "ignore, or use it as a search key in the decompiled source",
}


def main():
    buckets = defaultdict(list)
    for s in STRINGS:
        buckets[classify(s)].append(s)

    print("{} literals recovered from the DEX and resource string tables".format(len(STRINGS)))
    print()
    print("{:<24} {:>6}  {}".format("SURFACE", "COUNT", "EXAMPLES"))
    print("-" * 100)
    for name in sorted(buckets, key=lambda k: (-len(buckets[k]), k)):
        shown = ", ".join(buckets[name][:2])
        print("{:<24} {:>6}  {}".format(name, len(buckets[name]), shown[:66]))
    print()

    bands = [("under 3.0", 0.0, 3.0, "readable prose or identifiers"),
             ("3.0 - 4.0", 3.0, 4.0, "mixed: names, URLs, paths"),
             ("4.0 - 4.4", 4.0, 4.4, "encoded, likely base64 of something readable"),
             ("4.4 and over", 4.4, 99.0, "encrypted or random: the string table is closed")]
    print("{:<22} {:>6}  {}".format("ENTROPY BITS/CHAR", "COUNT", "VERDICT"))
    print("-" * 88)
    for label, lo, hi, verdict in bands:
        print("{:<22} {:>6}  {}".format(label, sum(1 for s in STRINGS if lo <= entropy(s) < hi),
                                        verdict))
    print()
    print("highest-entropy literals, and what each one probably is")
    for s in sorted(STRINGS, key=entropy, reverse=True)[:4]:
        print("  {:.2f}  {}".format(entropy(s), s))
    print()

    print("order of work: what to hook first")
    print("-" * 88)
    order = ["obfuscation_marker", "attestation", "crypto_primitive", "sensor_surface",
             "network_endpoint", "root_and_emulator", "header_or_cookie_name", "version_pin"]
    for i, name in enumerate(order, 1):
        if name in buckets:
            print("{}. {:<22} {}".format(i, name, NEXT_HOOK[name]))
    print()
    print("surfaces with no evidence yet: {}".format(
        sorted(set(NEXT_HOOK) - set(buckets)) or "none"))


main()
56 literals recovered from the DEX and resource string tables

SURFACE                   COUNT  EXAMPLES
----------------------------------------------------------------------------------------------------
header_or_cookie_name        10  X-Vendor-Token, X-Vendor-Device
sensor_surface                9  getInstalledPackages, Build.FINGERPRINT
root_and_emulator             8  /system/xbin/su, /system/bin/superuser
crypto_primitive              7  HmacSHA256, SHA-256
attestation                   5  com.google.android.gms.security.api.IntegrityManager, IntegrityTok
network_endpoint              5  https://telemetry.vendor-sdk.net/v2/events, https://collector.vend
unclassified                  5  aHR0cHM6Ly9jb2xsZWN0b3Iu, kZ8s1QxW3nR7pL2mV9tB4yH6
obfuscation_marker            4  DexGuard, StringRepository
version_pin                   3  minSdkVersion, targetSdkVersion

ENTROPY BITS/CHAR       COUNT  VERDICT
----------------------------------------------------------------------------------------
under 3.0                  14  readable prose or identifiers
3.0 - 4.0                  38  mixed: names, URLs, paths
4.0 - 4.4                   3  encoded, likely base64 of something readable
4.4 and over                1  encrypted or random: the string table is closed

highest-entropy literals, and what each one probably is
  4.58  kZ8s1QxW3nR7pL2mV9tB4yH6
  4.33  aHR0cHM6Ly9jb2xsZWN0b3Iu
  4.06  PBKDF2WithHmacSHA1
  4.03  com.google.android.gms.security.api.IntegrityManager

order of work: what to hook first
----------------------------------------------------------------------------------------
1. obfuscation_marker     find the string table first; every other hook is premature
2. attestation            hook the token mint, then log the request it is bound to
3. crypto_primitive       hook Mac.doFinal and MessageDigest.digest for real values
4. sensor_surface         hook each getter separately; a wrapper hides which ones run
5. network_endpoint       hook the HTTP client, not the collector, for the full request
6. root_and_emulator      hook the checks before patching them; the result tells you nothing yet
7. header_or_cookie_name  grep the manifest for the SDK service and start there
8. version_pin            note the build; this whole inventory moves on the next release

surfaces with no evidence yet: none

Notice the order: find obfuscation markers first, because hooking attestation before understanding the class mapping means you will log obfuscated class names that are impossible to interpret. Hook the attestation boundary second, because the token and the context it is bound to (nonce, endpoint, body hash) tell you what must be verified server-side. Hook crypto primitives third to see the exact byte strings being signed. The rest follow in order of dependency.

Static Versus Dynamic Tradeoffs#

Approach Strengths Weaknesses When to use
Static (APK decompile, smali, jadx) fast to scan, can see all code paths breaks on heavy obfuscation, string encryption, reflection initial mapping, find constants
Dynamic (Frida, Xposed, LSPosed, network proxy) sees real runtime values (keys, tokens, nonces) only covers paths you execute, can be detected extract live tokens/signatures, verify canonicalisation
Hybrid map statically, verify dynamically more setup almost always the correct approach

For SDKs that use runtime checks (root/frida detection), dynamic analysis usually requires patching those checks first, which in turn requires understanding where they run. The hook order above reflects this dependency loop.

Obfuscation, Packing, and Anti-Tamper#

Modern SDKs employ multiple layers. The techniques to recognise them are procedural:

Technique How it appears Practical response
ProGuard/R8 renaming short single-letter class names, no readable package structure map by entry points (activities/services, manifest entries) and by strings
String encryption strings loaded via getString from encrypted blobs hook the decryption function at runtime, not statically
Reflection heavy use of Class.forName, Method.invoke trace dynamically; static call graphs are incomplete
Native packing native libraries decrypt DEX at runtime dump memory maps, extract decrypted DEX from memory
Anti-debug/anti-hook detects Frida, ptrace, debuggers patch checks early or use a rooted environment with hardened tooling
Integrity checks checks APK signature, app hash may require resigning and patching; see mobile attestation and app integrity for server-side enforcement

A critical point: anti-tamper checks are often ordered. If you patch the final "integrity OK" flag but not the check that sets it, the app may crash, or it may set a different flag. Hook the inputs and the check function itself, not just the output. And never assume the SDK runs checks once at startup; many run them before each sensitive API call.

This is not a technical note but a necessary boundary. Reverse engineering mobile SDKs can violate terms of service, end-user licence agreements, and in some jurisdictions, laws governing circumvention of technological protection measures. The technical details above describe what exists, not what you should do. Any work in this area must be done only in environments you control, with apps you own or have explicit permission to analyse, and in compliance with applicable laws and policies. The purpose here is defensive understanding (hardening your own app, verifying your own verifier) rather than circumventing protections on third-party apps.

The Practical Takeaway#

The value of this work for an anti-bot engineer is defensive: by understanding how your own SDK is extracted, you know where to harden. Hardening that actually works is usually server-side:

  • Enforce request binding. The attestation token must be bound to the specific request (endpoint, method, body hash, nonce). See signed API requests.
  • Require freshness. Do not accept cached tokens for sensitive actions; mint immediately before use.
  • Validate everything. Check app identity, package/signature, key ID, algorithm, timestamp, nonce reuse, and verdicts. See mobile attestation and app integrity.
  • Assume extraction. Treat embedded secrets as compromised; design rotation and revocation accordingly.
  • Detect abuse server-side. Behavioural signals (risk score and session profiles) catch patterns even if a token is valid but used abnormally.

The SDK is the client; your verification logic is the only part you can reliably control. Spend your effort there first.