Skip to content

Defeating Anti-Tamper Protections

Everything you have built so far — injection hooks, frame replacement, location spoofing, sensor manipulation — assumes a cooperative target. An APK that does not fight back. You decode it, inject your classes, rebuild, re-sign, sideload, and it runs. No complaints.

Production targets are not cooperative. The APK you download from a live environment will check its own signature at startup and kill itself when it finds your debug key instead of the release key. It will hash its own DEX files and abort when the checksums do not match. It will ask the Play Store whether it was the installer and exit when the answer is no. It will pin its TLS certificates and refuse to talk to any server you can intercept. And it will layer these defenses — not one check, but three or four, triggering at different points in the application lifecycle.

This chapter teaches you to systematically find every integrity check in a hardened APK and neutralize each one without breaking the app’s functionality. The techniques here are mechanical. Once you can read the patterns, you can defeat them. The defenses are predictable because they all rely on the same Android APIs, and those APIs are all hookable.


Before you start grepping smali, you need a mental model of what you are walking into. Defense layering is not random. Different app categories follow recognizable patterns, and understanding those patterns tells you what to look for and where.

Banking apps are the most heavily defended targets you will encounter. A typical production banking APK runs three to four defense layers:

  1. Signature verification in Application.onCreate() or a dedicated SecurityManager class. This fires first, before any UI renders. The app extracts its own signing certificate, hashes it, and compares against a hardcoded value. Mismatch means immediate System.exit(1) or android.os.Process.killProcess().

  2. Play Integrity or SafetyNet attestation during or immediately after splash screen. The app calls Google’s attestation API, sends the token to its backend, and the backend decides whether the device is trustworthy. This checks device integrity, app integrity, and account licensing in a single server round-trip.

  3. Certificate pinning on every API call. OkHttp’s CertificatePinner or a custom TrustManager implementation. The app will not talk to its backend through a proxy unless you strip the pins.

  4. Root and emulator detection either at startup or at the point of sensitive transactions. Libraries like those from commercial anti-tamper vendors check for su binaries, Magisk artifacts, Xposed framework files, known emulator fingerprints, and debugging indicators.

The critical insight: these checks are sequenced. Signature verification fires in onCreate(). Attestation fires during splash. Certificate pinning fires on first network call. Root detection may fire later, at login or transaction time. You must neutralize them in order, because if the app dies at step 1, you never reach steps 2 through 4.

KYC apps are moderately defended. They typically run two to three layers:

  1. Signature verification — same pattern as banking apps, usually in Application.onCreate() or the launcher activity.

  2. DEX integrity checking — more common here than in banking apps because KYC apps integrate commercial liveness SDKs that include their own integrity validation. The SDK checks that its own classes have not been modified by computing a checksum over its DEX file entries.

  3. Installer verification — checks that the APK was installed from the Play Store (com.android.vending). This prevents sideloading, which is exactly what you need to do.

KYC apps are less likely to use Play Integrity attestation because many are deployed in markets where Google Play Services are unreliable. They compensate with local checks instead of server-side attestation.

Fintech onboarding flows often combine a standard defense layer with a commercial anti-tamper SDK. The app itself might only have signature verification, but the integrated SDK adds its own root detection, debugger detection, emulator detection, and integrity validation. These commercial SDKs are initialized in Application.onCreate() or in the activity that hosts the verification flow. They report to a backend dashboard, and the backend can reject the session before your hooks ever fire.

The pattern to watch for: an invoke-static call to an unfamiliar SDK class in the application’s onCreate() method that passes a context and a license key. That is the SDK initialization. Neutralizing that single call often disables the entire commercial protection layer.

Across all categories, the structure is predictable:

  • Early checks (signature, DEX integrity, installer): Application.onCreate(), SplashActivity.onCreate(), or a static initializer that runs before any UI.
  • Mid-flow checks (attestation, root detection): splash screen completion handler, login activity, or session initialization.
  • Late checks (certificate pinning, transaction-time root checks): network layer configuration, specific sensitive activities like payment or identity verification screens.

Map the lifecycle. Find the checks. Neutralize in order. Then inject.


What hardened apps check for, in order of frequency:

DefenseWhat it detectsHow common
Signature verificationAPK re-signed with different keyVery common
Certificate pinningMITM on SDK API callsCommon
Root/emulator detectionRooted device or emulator environmentCommon
Installer verificationSideloaded (not from Play Store)Moderate
DEX integrity checkModified classes.dex (CRC/hash mismatch)Moderate
Debuggable flagandroid:debuggable=true in manifestModerate
Frida/Xposed detectionRuntime hooking frameworksCommon (but irrelevant to us)

That last row is worth emphasizing. Frida detection, Xposed detection, hooking framework detection — these are irrelevant to the approach taught in this book. We do not inject a runtime framework. We do not attach an agent. The hooks are baked into the APK as smali patches. They are the app’s own bytecode. There is no external artifact to detect. This is a fundamental advantage over dynamic instrumentation approaches.


Before you can neutralize defenses, you need to find them. Extend your standard recon with these additional grep patterns:

Terminal window
grep -rn "getPackageInfo" decoded/smali*/
grep -rn "GET_SIGNATURES\|GET_SIGNING_CERTIFICATES" decoded/smali*/
grep -rn "Signature;->toByteArray\|Signature;->hashCode" decoded/smali*/
grep -rn "MessageDigest" decoded/smali*/

The typical pattern: the app calls PackageManager.getPackageInfo() with the GET_SIGNATURES flag, extracts the signature bytes, hashes them (usually SHA-256), and compares against a hardcoded string. If they don’t match, the app kills itself.

Terminal window
grep -rn "classes\.dex" decoded/smali*/
grep -rn "getCrc\|getChecksum\|ZipEntry" decoded/smali*/

The app opens its own APK as a ZipFile, reads classes.dex, and computes a CRC or hash. Since you have injected over a thousand classes into a new DEX file, the hash will not match.

Terminal window
grep -rn "FLAG_DEBUGGABLE\|0x2.*ApplicationInfo" decoded/smali*/
Terminal window
grep -rn "getInstallingPackageName\|getInstallSourceInfo" decoded/smali*/
grep -rn "com\.android\.vending" decoded/smali*/

Checks whether the APK was installed from Google Play (com.android.vending). Sideloaded APKs fail this check.

Terminal window
grep -rn "su\b\|/system/xbin\|Superuser\|magisk" decoded/smali*/
grep -rn "Build\.FINGERPRINT\|Build\.MODEL\|goldfish\|sdk_gphone" decoded/smali*/
Terminal window
grep -rn "CertificatePinner" decoded/smali*/
grep -rn "network_security_config" decoded/AndroidManifest.xml
ls decoded/res/xml/network_security_config.xml 2>/dev/null

The simplest approach. Find the conditional branch that acts on the check result and disable it.

What is nop? In assembly-level code, nop (no operation) is an instruction that does nothing — the processor simply moves to the next instruction. By replacing a conditional branch (if-nez, meaning “if not zero, jump to…”) with nop instructions, the check still runs but the app never jumps to the failure handler.

# BEFORE: if signature doesn't match, call killApp()
if-nez v0, :signature_invalid
...
:signature_invalid
invoke-virtual {p0}, Lcom/example/SecurityCheck;->killApp()V
# AFTER: nop the branch -- check never triggers
nop
nop

When to use: When the check is a simple if/else and you can identify the failure branch.

Replace the entire check method body with a constant return.

# BEFORE: 50 lines of signature checking code
.method public static isSignatureValid(Landroid/content/Context;)Z
.registers 8
# ... complex checking logic ...
return v5
# AFTER: always returns true
.method public static isSignatureValid(Landroid/content/Context;)Z
.registers 1
const/4 v0, 0x1
return v0
.end method

When to use: When the check is isolated in its own method. The nuclear option — clean and reliable.

Replace the hardcoded expected hash with the hash of YOUR signing key.

Terminal window
# Get your debug keystore's signature hash:
keytool -exportcert -keystore ~/.android/debug.keystore \
-alias androiddebugkey | openssl dgst -sha256 -hex

Then find the hardcoded hash in the smali:

# Find this:
const-string v3, "aB3x...originalHash..."
# Replace with:
const-string v3, "yZ9w...yourDebugHash..."

When to use: The cleanest approach. The check still runs, it still validates the signature — but it validates YOUR signature. No functionality is removed.

Some apps use third-party anti-tamper SDKs (SafetyNet, Play Integrity, AppCheck). Find the SDK’s initialization call and nop it:

# Find:
invoke-static {p0}, Lcom/security/sdk/IntegrityCheck;->init(Landroid/content/Context;)V
# Replace with:
nop

When to use: When the defense is a third-party SDK with a clear entry point.

Option A: Patch network_security_config.xml

Edit decoded/res/xml/network_security_config.xml:

<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

Option B: Nop the CertificatePinner

Find CertificatePinner.check() calls and nop them, or find CertificatePinner$Builder.add() calls and nop them (removes all pins).


Not every bypass requires smali patching. Many apps ship configuration files, ML models, threshold values, and business rules as plain files inside the APK — in assets/, res/raw/, or embedded in resource XML. These files control verification behavior directly: a liveness threshold, a geofence radius, a feature flag that enables or disables a check, a TensorFlow Lite model that decides whether a face is real. If these assets have no integrity protection — and most do not — you can modify them during the decode/rebuild cycle without touching a single line of smali.

This is the lowest-effort, highest-reliability attack class. You are not patching bytecode. You are not navigating control flow. You are editing a JSON file or swapping a binary blob. The app loads the modified asset at runtime and trusts it completely.

After decoding with apktool, inventory the assets:

Terminal window
# List everything in assets/
find decoded/assets/ -type f | head -30
# List raw resources
ls decoded/res/raw/ 2>/dev/null
# Find JSON config files
find decoded/assets/ decoded/res/raw/ -name "*.json" 2>/dev/null
# Find ML models
find decoded/assets/ decoded/res/raw/ \
-name "*.tflite" -o -name "*.onnx" -o -name "*.pt" -o -name "*.mlmodel" \
2>/dev/null
# Find XML config files that might contain thresholds
find decoded/assets/ decoded/res/raw/ -name "*.xml" 2>/dev/null
# Find properties/config files
find decoded/assets/ decoded/res/raw/ \
-name "*.properties" -o -name "*.cfg" -o -name "*.conf" -o -name "*.yaml" \
2>/dev/null

Common findings in KYC and biometric apps:

File TypeTypical LocationWhat It Controls
.tfliteassets/face_detection.tfliteOn-device ML model for face detection, liveness, or anti-spoofing
.jsonassets/sdk_config.jsonSDK initialization parameters, thresholds, feature flags
.jsonassets/liveness_config.jsonLiveness challenge sequence, timeout values, score thresholds
.xmlres/xml/remote_config_defaults.xmlFirebase Remote Config defaults — feature flags and A/B test values
.propertiesassets/app.propertiesAPI endpoints, environment toggles, debug flags
.jsonassets/geofence.jsonGeofence coordinates, allowed regions, radius values
.dat / .binassets/model.datEncrypted or proprietary model data

The most common and most impactful target. Many liveness SDKs ship a JSON config that controls their behavior:

Terminal window
# Read the SDK config
cat decoded/assets/sdk_config.json

A typical config might look like:

{
"liveness_threshold": 0.85,
"face_quality_min": 0.6,
"max_retries": 3,
"timeout_seconds": 30,
"require_blink": true,
"require_head_turn": true,
"anti_spoof_enabled": true,
"debug_mode": false,
"geofence_radius_km": 50,
"mock_detection_enabled": true
}

Every one of these values is an attack surface:

ModificationEffect
"liveness_threshold": 0.01Liveness check passes with almost any input
"face_quality_min": 0.01Accepts blurry, dark, or partial face frames
"max_retries": 999Unlimited attempts to pass verification
"timeout_seconds": 9999Effectively disables the session timeout
"require_blink": falseRemoves the blink challenge from active liveness
"require_head_turn": falseRemoves the head turn challenge
"anti_spoof_enabled": falseDisables the anti-spoofing model entirely
"debug_mode": trueMay enable verbose logging, skip checks, or show internal state
"mock_detection_enabled": falseDisables mock location detection at the config level

Edit the JSON, rebuild, and the SDK runs with your parameters. No smali patching needed.

Some apps do not use external config files — they hardcode threshold values directly in the source. These appear as constants in smali:

Terminal window
# Find float constants that look like thresholds (0.0 to 1.0 range)
grep -rn "const.*0\.\[0-9\]" decoded/smali*/ | grep -iE "threshold|confidence|score|quality|min"
# Find hardcoded geofence values
grep -rn "const.*40\.\|const.*-73\.\|const.*37\." decoded/smali*/
# Find string constants with config-like names
grep -rn "const-string.*threshold\|const-string.*config\|const-string.*enable" decoded/smali*/

When thresholds are hardcoded, you change them with a simple const replacement in smali — still simpler than full control-flow patching.

Apps that run on-device ML for face detection, liveness, or document verification ship model files (usually .tflite for TensorFlow Lite). These models are loaded at runtime from assets/:

Terminal window
# Find model loading code
grep -rn "loadModel\|Interpreter\|tflite\|tensorflow\|onnx" decoded/smali*/
# Find the model file reference
grep -rn "const-string.*\.tflite\|const-string.*\.onnx" decoded/smali*/

Three attack vectors on unprotected models:

1. Replace with a permissive model. Train or obtain a model that accepts all inputs as valid. Replace the .tflite file in assets/. The SDK loads your model and every face passes liveness, every document passes authenticity checks.

2. Replace with a no-op model. Create a minimal TFLite model that returns a constant “pass” output regardless of input. This requires matching the expected input/output tensor shapes — inspect the original model with:

Terminal window
python3 -c "
import tensorflow as tf
interpreter = tf.lite.Interpreter(model_path='decoded/assets/face_detection.tflite')
interpreter.allocate_tensors()
print('Input:', interpreter.get_input_details())
print('Output:', interpreter.get_output_details())
"

3. Downgrade the model. Some SDKs ship multiple model variants (e.g., model_v3.tflite and model_v1.tflite). Older models are typically less accurate at detecting spoofing. If the config references a specific model filename, point it at the weaker variant.

Many apps use Firebase Remote Config for feature flags. The defaults are shipped in the APK at res/xml/remote_config_defaults.xml:

Terminal window
cat decoded/res/xml/remote_config_defaults.xml
<defaultsMap>
<entry>
<key>liveness_enabled</key>
<value>true</value>
</entry>
<entry>
<key>nfc_required</key>
<value>true</value>
</entry>
<entry>
<key>geofence_check</key>
<value>true</value>
</entry>
<entry>
<key>min_face_score</key>
<value>0.85</value>
</entry>
</defaultsMap>

These defaults apply when the app cannot reach Firebase (offline, first launch before fetch completes, or network issues). Edit them to disable checks or lower thresholds. If the app has a connectivity issue during your test, it falls back to your modified defaults.

Note: if the app successfully fetches remote config from Firebase, the server values override these defaults. This attack is most effective when the device is offline or when you also block Firebase connectivity.

Not every asset is plain text. Commercial liveness SDKs frequently encrypt their config files or ship them as proprietary binary formats. When you open a file and see binary data or base64-encoded content instead of readable JSON, the SDK decrypts it at runtime.

To find the decryption logic:

Terminal window
# Find where the app reads the asset file
grep -rn "const-string.*sdk_config\|const-string.*liveness\|const-string.*model" decoded/smali*/
# Find decryption operations near asset loading
grep -rn "Cipher\|SecretKey\|AES\|decrypt\|Base64\.decode" decoded/smali*/
# Find the class that opens the asset and trace forward
grep -rn "AssetManager\|openRawResource\|getAssets" decoded/smali*/

The typical pattern: the app opens the asset file, reads the bytes, passes them through a decryption method, then parses the result as JSON or feeds it to a model loader. The decryption key is usually hardcoded in the same class or in a companion constants class — it has to be, because the app needs it at runtime without any server round-trip.

Once you find the key and algorithm (usually AES-256-CBC or AES-128-GCM), you have three options:

  1. Decrypt, modify, re-encrypt. Write a small script that uses the same key and algorithm to decrypt the asset, edit the plaintext, and re-encrypt. Replace the file in decoded/assets/.
  2. Replace the encrypted file with plaintext and patch the loader. Remove the decryption call in smali (nop it or bypass it) so the app reads the file directly. Then replace the encrypted asset with your plaintext version.
  3. Hook the decryption output. If the decryption is complex or uses multiple layers, it may be easier to let it run and patch the code that consumes the decrypted output — forcing the threshold value after parsing rather than before.

Option 2 is usually the cleanest. The decryption is typically a single invoke-static or invoke-virtual call that you can nop, then the downstream code parses plaintext JSON from the raw bytes.

Some apps ship default preference values in res/xml/ that get loaded the first time the app runs. These can contain feature flags, threshold values, or toggle switches:

Terminal window
# Find preference XML files
find decoded/res/xml/ -name "*prefer*" -o -name "*settings*" -o -name "*config*" 2>/dev/null
# Search for preference references in smali
grep -rn "getDefaultSharedPreferences\|PreferenceManager" decoded/smali*/

A preferences defaults file might look like:

<PreferenceScreen>
<CheckBoxPreference
android:key="enable_liveness"
android:defaultValue="true" />
<EditTextPreference
android:key="face_score_threshold"
android:defaultValue="0.85" />
<CheckBoxPreference
android:key="require_location"
android:defaultValue="true" />
</PreferenceScreen>

Change android:defaultValue="true" to "false" for checks you want to disable, or lower the numeric defaults. These values apply on first launch or when the app calls PreferenceManager.setDefaultValues(). If the app has already been installed and populated its SharedPreferences, you may need to clear its data first (adb shell pm clear <package>) for the new defaults to take effect.

res/values/strings.xml is often overlooked as an attack surface. It can contain:

Terminal window
cat decoded/res/values/strings.xml | grep -iE "api|key|url|endpoint|secret|threshold|token"

Common findings:

<!-- API endpoints -- useful for understanding backend communication -->
<string name="base_url">https://api.target.com/v2/</string>
<!-- API keys shipped in the APK (yes, this happens) -->
<string name="sdk_api_key">sk_live_abc123xyz789</string>
<!-- Hardcoded geofence parameters -->
<string name="allowed_country_code">US</string>
<string name="geofence_center_lat">40.7580</string>
<string name="geofence_center_lng">-73.9855</string>

Modifying string resources follows the same decode-edit-rebuild cycle. Change the API endpoint to point to your proxy server. Change the country code to match your spoofed location. Change the geofence center to coordinates you control.

Also check for locale-specific overrides in res/values-*/strings.xml — some apps define different endpoints or parameters per language or region.

Worked Example: Lowering a Liveness Threshold

Section titled “Worked Example: Lowering a Liveness Threshold”

Here is a concrete end-to-end example against a target that ships a liveness config in its assets.

Step 1: Decode and inventory

Terminal window
apktool d target-kyc.apk -o decoded/
find decoded/assets/ -type f

Output includes decoded/assets/verification_config.json.

Step 2: Read the config

Terminal window
cat decoded/assets/verification_config.json
{
"version": 2,
"face_detection": {
"model": "face_detect_v3.tflite",
"min_confidence": 0.80
},
"liveness": {
"enabled": true,
"threshold": 0.85,
"challenges": ["blink", "turn_left", "turn_right"],
"timeout_ms": 15000
},
"anti_spoof": {
"enabled": true,
"model": "spoof_detect_v2.tflite",
"threshold": 0.70
}
}

Step 3: Modify

Terminal window
python3 -c "
import json
with open('decoded/assets/verification_config.json', 'r') as f:
c = json.load(f)
c['liveness']['threshold'] = 0.01
c['liveness']['challenges'] = []
c['liveness']['timeout_ms'] = 999000
c['anti_spoof']['enabled'] = False
c['face_detection']['min_confidence'] = 0.10
with open('decoded/assets/verification_config.json', 'w') as f:
json.dump(c, f, indent=2)
"

What changed:

  • Liveness threshold dropped from 0.85 to 0.01 — almost anything passes
  • Active challenges removed — no blink or head turn required
  • Timeout extended to 999 seconds — effectively no time pressure
  • Anti-spoof model disabled entirely
  • Face detection confidence lowered to 0.10 — accepts partial or blurry faces

Step 4: Rebuild, align, sign, install

Terminal window
apktool b decoded/ -o modified.apk
zipalign -f 4 modified.apk aligned.apk
apksigner sign --ks ~/.android/debug.keystore \
--ks-key-alias androiddebugkey --ks-pass pass:android aligned.apk
adb uninstall com.target.kyc 2>/dev/null
adb install aligned.apk

Step 5: Verify

Terminal window
adb shell am start -n com.target.kyc/.LauncherActivity
adb logcat -s LivenessSDK FaceDetection

Watch logcat. If the config was loaded successfully, you should see the SDK initializing with your modified values. A log line like LivenessSDK: threshold=0.01, challenges=0 confirms the modified config is active. Now even a gray rectangle passes face detection, and liveness requires no user interaction.

Step 6: Layer injection on top

The asset modification made the SDK permissive. Now add the injection hooks for full control:

Terminal window
java -jar patch-tool.jar aligned.apk --out final.apk --work-dir ./work
adb install -r final.apk

You now have a target with lowered thresholds AND active frame injection. The lowered thresholds mean your injected frames face a much easier bar to clear. This is the belt-and-suspenders approach: even if frame injection alone would have passed the original thresholds, the lowered thresholds eliminate any margin of error.

Asset Integrity: Why Most Apps Don’t Check

Section titled “Asset Integrity: Why Most Apps Don’t Check”

Most apps do not verify the integrity of their own asset files. The assumption is: the APK was signed, so the contents are authentic. But after you decode with apktool, modify assets, and rebuild, the new APK is signed with YOUR key. The signing is valid — it is just a different signer. Unless the app also performs signature verification (covered earlier in this chapter), modified assets are trusted.

Even apps that check their signing certificate rarely extend that check to individual asset files. Signature verification confirms the APK was signed by a specific key — it does not verify that every file inside is unmodified since original build time. A few commercial anti-tamper SDKs do compute checksums over specific asset files, but this is uncommon.

When you do encounter asset integrity checking, the recon patterns from the DEX integrity section apply — look for file reading and hashing operations targeting assets/ paths. Neutralize them with the same techniques.

Add these to your standard recon workflow:

[ ] Inventory assets/ directory -- list all JSON, XML, model, and config files
[ ] Read every JSON config file -- note thresholds, flags, and toggleable features
[ ] Identify ML model files -- note format (.tflite, .onnx) and filename
[ ] Check res/xml/remote_config_defaults.xml for feature flags
[ ] Search smali for asset loading code -- which files does the app read at runtime?
[ ] Check for asset integrity verification -- does the app hash its own assets?
[ ] Document modifiable values in your recon report under a new "Asset Attack Surface" section

Asset modifications use the same apktool decode/rebuild pipeline as smali patching. Here is the complete cycle for asset-only changes:

Step 1: Decode

Terminal window
apktool d target.apk -o decoded/

Step 2: Edit the assets directly

The decoded directory mirrors the APK structure. Edit files in place:

Terminal window
# Edit a JSON config -- lower the liveness threshold
# Use any text editor or sed for simple changes
vi decoded/assets/sdk_config.json
# Or use a one-liner for targeted edits
python3 -c "
import json
with open('decoded/assets/sdk_config.json', 'r') as f:
config = json.load(f)
config['liveness_threshold'] = 0.01
config['anti_spoof_enabled'] = False
with open('decoded/assets/sdk_config.json', 'w') as f:
json.dump(config, f, indent=2)
"
# Replace an ML model with your modified version
cp my_permissive_model.tflite decoded/assets/face_detection.tflite
# Edit Firebase Remote Config defaults
vi decoded/res/xml/remote_config_defaults.xml
# Edit network security config (for cert pinning bypass)
vi decoded/res/xml/network_security_config.xml

There is no special syntax or tooling needed. The files are plain text (JSON, XML) or binary blobs (models) sitting in a regular directory. Edit them however you want.

Step 3: Rebuild

Terminal window
apktool b decoded/ -o modified.apk

apktool repackages everything — your modified assets, the original smali (unless you also patched that), the manifest, the resources — into a new APK. If the build fails, apktool will tell you which resource has a syntax error. JSON files are not validated by apktool, so malformed JSON will build fine but crash the app at runtime — always test.

Step 4: Align

Terminal window
zipalign -f 4 modified.apk aligned.apk

zipalign ensures uncompressed data in the APK is aligned to 4-byte boundaries. This is required for efficient memory-mapped access on the device. Skip this step and the install may fail or the app may run slowly.

Step 5: Sign

Terminal window
apksigner sign \
--ks ~/.android/debug.keystore \
--ks-key-alias androiddebugkey \
--ks-pass pass:android \
aligned.apk

This signs the APK with your debug key. The signing is cryptographically valid — it just uses a different key than the original developer’s. Android accepts this for sideloaded installs. If the app was previously installed with a different signature, uninstall it first (adb uninstall <package>).

If you do not have a debug keystore, create one:

Terminal window
keytool -genkeypair -v -keystore debug.keystore \
-alias androiddebugkey -keyalg RSA -keysize 2048 \
-validity 10000 -storepass android -keypass android \
-dname "CN=Debug,O=Android,C=US"

Step 6: Install and test

Terminal window
adb uninstall com.target.package 2>/dev/null
adb install aligned.apk
adb shell am start -n com.target.package/.LauncherActivity

Watch logcat for crashes. If the app reads your modified JSON and hits a missing key or wrong type, you will see a JSONException or NullPointerException in the log. Fix the asset and repeat from Step 3 — no need to re-decode.

Combining with smali patches and injection hooks. If you are also modifying smali or running the patch-tool, the order matters:

  1. Decode with apktool
  2. Edit assets (configs, models, XML)
  3. Edit smali (if doing manual evasion patches)
  4. Rebuild with apktool
  5. Optionally run the patch-tool against the rebuilt APK (it re-decodes and adds injection hooks)
  6. Align and sign the final APK

Asset edits and smali edits happen in the same decoded directory during the same cycle. There is no need for separate passes. The patch-tool in step 5 preserves your asset modifications — it adds classes to a new DEX file but does not touch assets/ or res/.


Choosing a Technique: The Decision Flowchart

Section titled “Choosing a Technique: The Decision Flowchart”

Selecting the right evasion technique is not guesswork. It follows a decision tree based on what you find during recon. Here is the expanded flowchart:

Step 1: Can you identify the check method by name?
(e.g., verifySignature(), checkIntegrity(), isRooted())
|
+-- YES --> Is the method's return value used as a boolean gate?
| |
| +-- YES --> Technique 2: Force Return Value
| | (Replace method body with const/4 v0, 0x1; return v0)
| | This is the fastest, cleanest neutralization.
| |
| +-- NO --> Does the method call System.exit() or killProcess() internally?
| |
| +-- YES --> Technique 1: Nop the kill call
| +-- NO --> Technique 1: Nop the branch that routes to failure
|
+-- NO --> Can you find a conditional branch (if-eqz / if-nez) that
gates access to a failure label?
|
+-- YES --> Technique 1: Nop the Branch
| Follow the failure label. Make sure it terminates
| (calls finish(), exit(), or shows an error dialog).
| Nop the branch, not the failure code itself.
|
+-- NO --> Is the check comparing a hardcoded hash or string?
|
+-- YES --> Technique 3: Patch the Expected Value
| Compute YOUR key's hash, swap the const-string.
| Preserves all validation logic. Hardest to detect.
|
+-- NO --> Is the check an SDK initialization call?
|
+-- YES --> Technique 4: Nop the init() call
+-- NO --> Is it certificate pinning?
|
+-- YES --> Technique 5: Patch config or nop pinner
+-- NO --> Trace the call chain manually.
Find where the result is consumed
and apply Technique 1 or 2 at that point.

A few practical notes on this flowchart:

Technique 2 is your default. If the defense is in its own method with a boolean return, force it. Do not overthink it. This handles 60-70% of real-world checks.

Technique 3 is your stealth option. When the target has server-side telemetry that might flag “integrity check disabled,” patching the expected value keeps the check fully functional — it just validates your key instead of the release key. The server sees a passing check. Use this when you suspect the backend monitors check results.

Technique 1 is your fallback. When the check logic is inlined into a larger method (not isolated into its own method), you cannot force the return without breaking the surrounding code. Nop the branch instead.

Always trace the failure path. Before you nop anything, follow the failure label to confirm it is actually the kill path. You do not want to nop a branch that leads to a legitimate feature gate.


Worked Example: Defeating Signature Verification and Certificate Pinning

Section titled “Worked Example: Defeating Signature Verification and Certificate Pinning”

This walkthrough demonstrates the full evasion workflow against a target with two defense layers: signature verification in a SecurityManager class and OkHttp certificate pinning. These are the two most common defenses and the combination you will encounter most frequently.

Decode the target and run the standard integrity check grep patterns:

Terminal window
apktool d target-hardened.apk -o decoded
grep -rn "getPackageInfo" decoded/smali*/
grep -rn "GET_SIGNATURES\|GET_SIGNING_CERTIFICATES" decoded/smali*/
grep -rn "CertificatePinner" decoded/smali*/
grep -rn "network_security_config" decoded/AndroidManifest.xml

Results:

decoded/smali/com/target/security/SecurityManager.smali:42: invoke-virtual ... getPackageInfo
decoded/smali/com/target/security/SecurityManager.smali:58: sget ... GET_SIGNATURES
decoded/smali/com/target/security/SecurityManager.smali:87: const-string v3, "a1b2c3d4..."
decoded/smali/com/target/network/ApiClient.smali:23: new-instance ... CertificatePinner$Builder
decoded/smali/com/target/network/ApiClient.smali:31: invoke-virtual ... ->add(
decoded/AndroidManifest.xml:8: android:networkSecurityConfig="@xml/network_security_config"

Two defenses confirmed. Signature verification in SecurityManager, certificate pinning in ApiClient.

Open decoded/smali/com/target/security/SecurityManager.smali and find the verification method:

.method public static verifyIntegrity(Landroid/content/Context;)Z
.registers 8
# Get PackageManager
invoke-virtual {p0}, Landroid/content/Context;->getPackageManager()Landroid/content/pm/PackageManager;
move-result-object v0
# Get package info with signatures
invoke-virtual {p0}, Landroid/content/Context;->getPackageName()Ljava/lang/String;
move-result-object v1
const/16 v2, 0x40 # GET_SIGNATURES flag
invoke-virtual {v0, v1, v2}, Landroid/content/pm/PackageManager;->getPackageInfo(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;
move-result-object v0
# Extract first signature, compute SHA-256
iget-object v0, v0, Landroid/content/pm/PackageInfo;->signatures:[Landroid/content/pm/Signature;
const/4 v1, 0x0
aget-object v0, v0, v1
invoke-virtual {v0}, Landroid/content/pm/Signature;->toByteArray()[B
move-result-object v0
const-string v1, "SHA-256"
invoke-static {v1}, Ljava/security/MessageDigest;->getInstance(Ljava/lang/String;)Ljava/security/MessageDigest;
move-result-object v1
invoke-virtual {v1, v0}, Ljava/security/MessageDigest;->digest([B)[B
move-result-object v0
# Convert to hex string (helper method)
invoke-static {v0}, Lcom/target/security/SecurityManager;->bytesToHex([B)Ljava/lang/String;
move-result-object v4
# Compare against hardcoded expected hash
const-string v3, "a1b2c3d4e5f6..."
invoke-virtual {v4, v3}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
move-result v5
return v5
.end method

The logic is clear: extract signing certificate, SHA-256 hash it, compare against a hardcoded hex string, return boolean. This is a textbook case for Technique 2.

Now find where verifyIntegrity() is called. It is typically in Application.onCreate() or the launcher activity:

Terminal window
grep -rn "verifyIntegrity" decoded/smali*/
decoded/smali/com/target/app/TargetApp.smali:35: invoke-static {p0}, Lcom/target/security/SecurityManager;->verifyIntegrity(Landroid/content/Context;)Z
decoded/smali/com/target/app/TargetApp.smali:37: if-eqz v0, :integrity_failed

The caller checks the boolean. If false (if-eqz), it jumps to a failure label that calls finish() and System.exit(). Apply Technique 2 — replace the entire method body:

# AFTER: always returns true
.method public static verifyIntegrity(Landroid/content/Context;)Z
.registers 1
const/4 v0, 0x1
return v0
.end method

Three lines. The entire 30-line check is gone. The caller receives true, the if-eqz branch is not taken, and the app proceeds normally.

Two options. The fastest for this target: patch network_security_config.xml to trust user-installed certificates and remove the programmatic pinner.

Edit decoded/res/xml/network_security_config.xml:

<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

Then nop the CertificatePinner.Builder.add() calls in ApiClient.smali so the programmatic pins are stripped:

# BEFORE:
invoke-virtual {v0, v1, v2}, Lokhttp3/CertificatePinner$Builder;->add(Ljava/lang/String;[Ljava/lang/String;)Lokhttp3/CertificatePinner$Builder;
# AFTER:
nop

This removes both the XML-declared and programmatic certificate pins. The app will trust any certificate in the system or user trust store.

Terminal window
apktool b decoded -o target-evaded.apk
# Sign with your debug key
apksigner sign --ks ~/.android/debug.keystore \
--ks-key-alias androiddebugkey \
--ks-pass pass:android \
target-evaded.apk
# Install and launch
adb install -r target-evaded.apk
adb shell monkey -p com.target.app -c android.intent.category.LAUNCHER 1

Watch logcat. No SecurityException. No “Integrity check failed” toast. No SSL handshake errors. The app launches, connects to its backend, and functions normally.

Now that the target is cooperative, run the injection pipeline:

Terminal window
java -jar patch-tool.jar target-evaded.apk --out target-final.apk
adb install -r target-final.apk

The patch-tool decodes the already-evaded APK, adds injection hooks, rebuilds, and re-signs. Your evasion patches survive because they are in the app’s own smali — the patch-tool adds new classes but does not modify existing ones.

Verify both evasion and injection are working:

Terminal window
adb logcat -s FrameInterceptor HookEngine SecurityManager

You should see hook initialization messages from HookEngine and no integrity failure messages from SecurityManager. The target is fully operational: defenses neutralized, injection hooks active, ready for engagement.


Critical: Evasion patches must be applied BEFORE or ALONGSIDE injection hooks.

Option A: Manual evasion + automated injection (recommended)

  1. Decode APK with apktool
  2. Manually neutralize integrity checks in smali
  3. Rebuild with apktool
  4. Run patch-tool against the rebuilt APK (it re-decodes, adds hooks, rebuilds again)

Option B: Extend the patch-tool

  1. Write an evasion hook module (Chapter 14 technique)
  2. Add it to the patch-tool alongside injection hooks
  3. Single pass: decode, neutralize checks, inject hooks, rebuild

Option B is cleaner long-term but requires more upfront work. Option A gets you to a working result faster.

Why two cycles? Yes, the APK gets decoded and rebuilt twice in Option A. The first cycle is your manual evasion work; the second is the patch-tool adding injection hooks. The patch-tool expects an intact APK as input and handles its own decode/rebuild. Option B eliminates the double cycle by integrating evasion into the patch-tool itself.


[ ] Decode APK
[ ] Grep for all integrity check patterns (see Recon section above)
[ ] Map each check: which class, which method, what happens on failure
[ ] Neutralize signature verification
[ ] Neutralize DEX integrity checks
[ ] Neutralize debuggable flag detection
[ ] Neutralize installer verification
[ ] Bypass certificate pinning (if SDK makes API calls)
[ ] Rebuild, sign with YOUR keystore
[ ] If using Technique 3: compute hash from YOUR keystore first
[ ] Test: app launches and functions normally without integrity failures
[ ] THEN apply injection hooks (via patch-tool)
[ ] Test: injection works on top of the evasion patches

Even with the right technique, evasion patches can fail in predictable ways. Here are the ones you will hit:

App crashes immediately after launch. You nop’d the wrong branch or forced a return in a method that does more than just the integrity check. Trace the method more carefully. Look for other logic in the same method that the app depends on. If the check is interleaved with initialization code, use Technique 1 (nop the branch) instead of Technique 2 (force return).

App launches but shows a blank screen or error dialog. The integrity check result is consumed by the UI layer, not just a kill switch. The failure path shows an error fragment instead of the main content. Find the UI routing logic and ensure the success path is taken.

App launches but network calls fail with SSL errors. You patched the XML config but missed programmatic pins. Or the app uses a custom TrustManager implementation instead of (or in addition to) CertificatePinner. Grep for X509TrustManager, SSLSocketFactory, and HostnameVerifier in addition to the standard pinning patterns.

App launches, works for a while, then crashes. A delayed integrity check. Some apps re-verify at intervals or when specific activities are opened. Search for additional call sites of the verification method. Some apps call the same check from multiple entry points — the splash activity, the login activity, and the sensitive transaction activity.

App launches but the backend rejects requests. Server-side attestation. The app sends an integrity token to its backend, and the backend validates it. Client-side patching cannot defeat server-side validation. You need to either intercept and forge the attestation response (complex) or find that the backend has a fallback path when attestation is unavailable (common in apps that support devices without Google Play Services).


Everything above operates in the Dalvik/ART layer — smali bytecode you can read, modify, and rebuild. Some targets push critical logic into native code: compiled C/C++ in .so files inside the APK’s lib/ directory. Commercial liveness SDKs, anti-tamper frameworks, and high-security biometric processors frequently move their core algorithms and integrity checks into native libraries. When they do, smali patching alone is not enough.

During recon, detect JNI usage with these patterns:

Terminal window
# Find native method declarations in smali
grep -rn "\.method.*native" decoded/smali*/
# Find System.loadLibrary calls (loading .so files)
grep -rn "loadLibrary\|System\.load" decoded/smali*/
# List all native libraries in the APK
ls decoded/lib/*/
# Find JNI_OnLoad (library initialization entry point)
strings decoded/lib/arm64-v8a/*.so | grep -i "JNI_OnLoad\|integrity\|verify\|signature"

Common patterns that indicate native defenses:

PatternWhat It Means
native checkIntegrity()Z in a SecurityManager classThe integrity check runs in C, not Java
.so files from a commercial anti-tamper vendorEntire protection suite in native code
JNI_OnLoad with string references to classes.dexThe library verifies DEX integrity at load time
Native method that accepts Context and returns booleanClassic JNI integrity check pattern

A native integrity check has two parts: the Java/Kotlin declaration and the C implementation.

Java side (visible in smali):

.method public static native checkNativeIntegrity(Landroid/content/Context;)Z
.end method

The native keyword means the method body is not in the DEX file — it is in a .so library. When the app calls this method, the JVM looks up the corresponding C function in the loaded library and executes it.

C side (compiled into the .so):

JNIEXPORT jboolean JNICALL
Java_com_target_security_NativeCheck_checkNativeIntegrity(
JNIEnv *env, jclass clazz, jobject context) {
// Read APK, hash DEX, verify signature -- all in native code
// Return JNI_TRUE or JNI_FALSE
}

The smali for the call site looks like any other method call:

invoke-static {v0}, Lcom/target/security/NativeCheck;->checkNativeIntegrity(Landroid/content/Context;)Z
move-result v1
if-eqz v1, :native_check_failed
Section titled “Approach 1: Cut at the JNI Bridge (Recommended)”

The native method is called from Java code. The result is consumed by Java code. You do not need to touch the .so at all — intercept at the boundary.

Option A: Force the return at the call site. Find the invoke-static/invoke-virtual that calls the native method and the if-eqz/if-nez that branches on the result. Apply the same Technique 1 (nop the branch) or Technique 2 (force the return) you use for Java checks.

# Original:
invoke-static {v0}, Lcom/target/security/NativeCheck;->checkNativeIntegrity(Landroid/content/Context;)Z
move-result v1
if-eqz v1, :native_check_failed
# Neutralized -- force v1 to true before the branch:
invoke-static {v0}, Lcom/target/security/NativeCheck;->checkNativeIntegrity(Landroid/content/Context;)Z
move-result v1
const/4 v1, 0x1
if-eqz v1, :native_check_failed

The native code still runs — but its result is overwritten before the branch evaluates it.

Option B: Replace the native declaration with a Java implementation. Remove the native keyword and provide a method body:

# Original:
.method public static native checkNativeIntegrity(Landroid/content/Context;)Z
.end method
# Replaced:
.method public static checkNativeIntegrity(Landroid/content/Context;)Z
.locals 1
const/4 v0, 0x1
return v0
.end method

The native function in the .so is never called because the method is no longer declared as native. The JVM executes your Java implementation instead. The .so file can stay untouched in the APK.

Option C: Prevent the library from loading. If the entire .so is an anti-tamper SDK you want to disable, nop the System.loadLibrary() call:

# Original:
const-string v0, "security_native"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
# Neutralized:
nop
nop

Without loadLibrary, the native methods remain declared but have no implementation. If the app calls them, it crashes with UnsatisfiedLinkError. Make sure you also neutralize or replace every call site (Option A or B above) so the native methods are never invoked.

When cutting at the JNI bridge is not feasible — for example, when the native library performs continuous validation that the Java layer queries repeatedly, or when the SDK’s Java code is heavily obfuscated and hard to trace — you may need to modify the .so directly.

Tools:

  • Ghidra (free, open source from NSA) — disassembler and decompiler for ARM/ARM64 binaries. Handles Android .so files natively. The decompiler produces readable C-like pseudocode.
  • IDA Pro (commercial) — the industry standard for binary reverse engineering. More polished decompiler, better handling of complex optimizations.
  • Binary Ninja (commercial) — modern alternative with strong scripting support.
  • radare2/rizin (free) — command-line focused, steep learning curve, but highly scriptable.

The workflow:

  1. Extract the .so from decoded/lib/arm64-v8a/ (or the appropriate architecture).
  2. Open in Ghidra. The auto-analysis takes a few minutes on large libraries.
  3. Find the JNI function. Search for the mangled name: Java_com_target_security_NativeCheck_checkNativeIntegrity. JNI function names follow a predictable pattern derived from the Java package, class, and method name.
  4. Read the decompiled output. Identify the return path — the instruction that sets the return value.
  5. Patch: change the function to immediately return the desired value.

ARM64 patch example:

; Original function epilogue:
; ... (validation logic) ...
; mov w0, w19 ; w0 = result of validation
; ret
; Patched:
mov w0, #1 ; force return true
ret

In Ghidra: right-click the instruction, “Patch Instruction,” change mov w0, w19 to mov w0, #1. Then export the patched binary (“File > Export Program > ELF”).

  1. Replace the .so in decoded/lib/arm64-v8a/ with your patched version.
  2. If the APK supports multiple architectures (armeabi-v7a, x86_64), you must patch each architecture’s .so or remove the directories you do not need (and ensure the target device matches the remaining architecture).

When to use this approach: Only when Approach 1 fails. Binary patching is more fragile — it depends on the exact binary layout, which changes with every SDK update. Prefer cutting at the JNI bridge when possible.

The nuclear option. If the .so is a standalone anti-tamper SDK with no other functionality the app depends on:

  1. Delete the .so from all architecture directories.
  2. Nop the System.loadLibrary() call.
  3. Replace all native method declarations with Java stubs that return safe defaults.
  4. Nop or replace the SDK initialization call in Application.onCreate().

This is appropriate for third-party anti-tamper SDKs that exist solely for protection. It is not appropriate for SDKs where the .so contains functionality the app needs (biometric processing, cryptographic operations, ML inference).

Not all .so files contain defenses. Most are runtime dependencies. Focus your analysis:

Terminal window
# List all .so files by size (large files = more logic)
find decoded/lib/ -name "*.so" -exec ls -lhS {} \;
# Look for security-related strings in each .so
for so in decoded/lib/arm64-v8a/*.so; do
echo "=== $(basename $so) ==="
strings "$so" | grep -iE "integrity|signature|verify|tamper|root|debug|frida|xposed|mock" | head -5
done
# Look for RASP / anti-tamper SDK markers
strings decoded/lib/arm64-v8a/*.so | grep -iE "rasp|shield|protect|guard|tamper|integrity_check"

Libraries with names like libsecurity.so, libprotect.so, libguard.so, or libantitamper.so are obvious defense components. Commercial anti-tamper SDKs often use generic names like libapp.so or obfuscated names to avoid easy identification — the string search helps reveal their purpose.

Found a native defense method?
|
+-- Is the result consumed by Java code via a simple boolean check?
| |
| +-- YES --> Approach 1: Cut at the JNI bridge
| | (Force return value or nop the branch at the call site)
| | This is your default. Do not touch the .so.
| |
| +-- NO --> Is the .so a standalone anti-tamper SDK?
| |
| +-- YES --> Approach 3: Delete the library + stub the methods
| |
| +-- NO --> Does the native code perform continuous
| validation that is hard to intercept from Java?
| |
| +-- YES --> Approach 2: Patch the .so binary
| +-- NO --> Re-examine. There is usually a
| Java-level interception point
| you missed. Trace the call chain.

Approach 1 handles 80-90% of real-world native defenses. The key insight: native code must communicate its results back to Java code through the JNI bridge. That bridge is always visible in smali. You control the smali. You control the bridge.


Anti-tamper defenses are speed bumps, not walls. They slow you down, they force you to do recon before injection, and they add steps to your workflow. But they share a fundamental limitation: they run on a device you control, in bytecode you can read and modify. The app cannot hide its own defense logic from someone willing to read smali — and even native code must cross the JNI bridge back into the managed layer, where you control the outcome. Every check has a call site, every call site has a branch, and every branch can be neutralized.

The real question is not whether you can defeat the defenses. It is how quickly you can identify and neutralize all of them without breaking the app. That is a skill you build through practice. The recon patterns in this chapter will find the checks. The decision flowchart will tell you which technique to apply. The worked example shows the full workflow from recon to verified evasion. Do it ten times and it becomes mechanical. Do it fifty times and you will identify defenses from the grep output alone, without even opening the smali file.

A note on RASP-protected targets: Commercial RASP SDKs bundle many of the individual checks described in this chapter into a single obfuscated, native-backed package. Instead of 3-5 findable check methods, RASP sprays 50-200 integrity checks across the entire codebase, inserts decoy control flows to waste your analysis time, and couples integrity state to processing outputs so tampered builds fail silently rather than crashing. When you encounter a RASP-protected target during an authorized assessment, expect the recon and evasion effort to be 5-10x higher than an unprotected app. See the RASP deep dive for the full technical breakdown of RASP techniques and their limitations.

Practice: Lab 10 (Anti-Tamper Evasion) provides hands-on exercises assessing signature checks, DEX integrity validation, and certificate pinning on authorized targets.

The next chapter covers automation — building pipelines that handle the mechanical steps so you can focus on the parts that require judgment.