-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Port MASTG-TEST-0050: Testing Runtime Integrity Checks (android) #3692
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 29 commits
05f3e3c
b44f8f4
0177d94
6783f22
296b1e4
7afdb80
ae571d0
6d1e5d0
1e8b982
ac48167
561a677
562cbd0
a519993
4593cda
7ff16cb
d3868e3
411ee2a
dd68825
eb14e51
8acd5e6
25129d6
0745792
f90b55c
6e3373c
eca2026
5d42595
e21c4f5
3c38385
a795d81
fbafc6f
358631f
6058aee
4cf1f15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| --- | ||
| title: Hardening Against Runtime Hooking | ||
| alias: hardening-against-runtime-hooking | ||
| id: MASTG-BEST-0029 | ||
| platform: android | ||
| knowledge: [MASTG-KNOW-0027, MASTG-KNOW-0030, MASTG-KNOW-0032, MASTG-KNOW-00kw] | ||
| --- | ||
|
|
||
| Defending against runtime hooking requires a layered approach that combines several types of security controls: | ||
|
|
||
| - **Preventive controls**: Implement root detection (@MASTG-KNOW-0027) and device/app attestation (https://github.com/OWASP/mastg/issues/3505) as the first line of defense, since most hooking frameworks (e.g., Frida server, Xposed) require rooted devices. | ||
|
Check failure on line 11 in best-practices/MASTG-BEST-0029.md
|
||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| - **Detective controls**: Scan for tool signatures using artifact-based detection (@MASTG-KNOW-0030) and verify the app's code and memory integrity at runtime (@MASTG-KNOW-0032) to detect hooking attempts. | ||
| - **Deterrent controls**: Obfuscate detection logic, scatter checks throughout the app, and vary their timing to increase the cost and effort required to bypass protections. | ||
| - **Responsive controls**: Terminate the session, clear sensitive data from memory, or even alert the backend server when a threat is detected. | ||
|
|
||
| Because hooking can also occur on non-rooted devices (e.g., by repackaging the app with an embedded frida-gadget, see @MASTG-TECH-0026), do not rely solely on preventive controls. Apply the detective, deterrent, and responsive controls described below to protect against hooking regardless of the device's root status. | ||
|
|
||
| ## Detective Controls | ||
|
|
||
| ### Combine Artifact-Based and Integrity-Based Detection | ||
|
|
||
| Implement both artifact-based detection (@MASTG-KNOW-0030) and runtime integrity verification (@MASTG-KNOW-0032). Use artifact-based detection to scan for known tool signatures (e.g., Frida server processes, libraries, open ports) and runtime integrity verification to detect the _modifications_ these tools make to the app's code and memory (e.g., GOT hooks, inline trampolines, ART method entry point changes). Do not rely on only one approach, as each has blind spots the other covers. | ||
|
|
||
| ### Apply Multiple Detection Techniques | ||
|
|
||
| Layer several techniques to maximize detection coverage: | ||
|
|
||
| - **Memory scanning**: Scan `/proc/self/maps` and process memory for known artifacts (e.g., "LIBFRIDA", frida-agent libraries, Xposed bridge classes). | ||
| - **Integrity checksums**: Compute checksums of critical code sections at build/load time and verify them periodically at runtime to detect patches and inline hooks. | ||
| - **GOT/PLT verification**: Verify that Global Offset Table entries point to addresses within their expected libraries. | ||
| - **Function prologue inspection**: Compare the first bytes of security-critical functions against their expected values to detect trampoline patterns (e.g., `LDR X16, .+8; BR X16` on ARM64). | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also consider: #3692 (comment) |
||
| - **ART method verification**: Use JNI's `FromReflectedMethod` to confirm that Java method entry points fall within legitimate regions (OAT file, interpreter, or JIT code cache). | ||
| - **Network-based checks**: Probe for D-Bus responses on open ports to reveal frida-server even when renamed. | ||
|
|
||
| ## Deterrent Controls | ||
|
|
||
| ### Implement Detection in Native Code | ||
|
|
||
| Consider writing detection checks in native (C/C++) code rather than Java/Kotlin. Native code is significantly harder to hook and reverse engineer than Java bytecode, which can be easily intercepted via Frida's Java API or Xposed modules. Use JNI to bridge results back to the application layer. | ||
|
|
||
| ### Obfuscate Detection Logic | ||
|
|
||
| Apply [code obfuscation](../Document/0x04c-Tampering-and-Reverse-Engineering.md#obfuscation) to all detection routines. Scatter checks throughout the app rather than centralizing them in a single function, and vary their timing (e.g., periodic, event-driven, or randomized) to prevent systematic bypassing. | ||
|
|
||
| ## Responsive Controls | ||
|
|
||
| Trigger the following response actions when hooks are detected: | ||
|
|
||
| - Terminate the app session immediately. | ||
| - Clear sensitive data from memory before exiting. | ||
| - Alert the backend server to flag the compromised session. | ||
|
|
||
| Do not allow the app to continue running in a compromised state. Protect the response mechanism itself against hooking by implementing it in native code and obfuscating its control flow. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| --- | ||
| platform: android | ||
| title: Extracting Sensitive Data from Cipher.doFinal via Frida Hooking | ||
| id: MASTG-DEMO-0087 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| code: [kotlin] | ||
| test: MASTG-TEST-03te | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| kind: fail | ||
| --- | ||
|
|
||
| ## Sample | ||
|
|
||
| This sample encrypts and decrypts a sensitive API key using AES/GCM via the Android KeyStore. The app does not implement any runtime hook detection mechanisms. On the contrary, @MASTG-DEMO-0088 demonstrates a runtime hook detection mechanism. | ||
|
|
||
| {{ MastgTest.kt }} | ||
|
|
||
| ## Steps | ||
|
|
||
| 1. Install the app on a device (@MASTG-TECH-0005) | ||
| 2. Use @MASTG-TECH-0033 to target `Cipher.dofinal()` API | ||
| 3. Run `run.sh` to spawn the app with Frida | ||
| 4. Click the **Start** button | ||
| 5. Stop the script by pressing `Ctrl+C` and/or `q` to quit the Frida CLI | ||
|
|
||
| {{ script.js # run.sh }} | ||
|
|
||
| ## Observation | ||
|
|
||
| The output contains all instances of `Cipher` method calls found at runtime. A backtrace is also provided to help identify the location in the code. The `doFinal` calls reveal the sensitive API key in plaintext: as the input parameter during encryption and as the return value during decryption. | ||
|
|
||
| {{ output.txt }} | ||
|
|
||
| ## Evaluation | ||
|
|
||
| The test fails because the hook executes successfully and the sensitive API key `sk-OWASP-MAS-SuperSecretKey-1234567890` is extracted in plaintext from the `Cipher.doFinal()` calls. The app lacks runtime integrity verification, allowing instrumentation tools to intercept cryptographic operations without any defensive response. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package org.owasp.mastestapp | ||
|
|
||
| import android.content.Context | ||
| import android.security.keystore.KeyGenParameterSpec | ||
| import android.security.keystore.KeyProperties | ||
| import android.util.Base64 | ||
| import java.security.KeyStore | ||
| import javax.crypto.Cipher | ||
| import javax.crypto.KeyGenerator | ||
| import javax.crypto.SecretKey | ||
| import javax.crypto.spec.GCMParameterSpec | ||
|
|
||
| class MastgTest(private val context: Context) { | ||
|
|
||
| private val sensitiveApiKey = "sk-OWASP-MAS-SuperSecretKey-1234567890" | ||
| private val keyAlias = "mastgCipherKey" | ||
|
|
||
| private fun getOrCreateSecretKey(): SecretKey { | ||
| val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } | ||
| return if (keyStore.containsAlias(keyAlias)) { | ||
| (keyStore.getEntry(keyAlias, null) as KeyStore.SecretKeyEntry).secretKey | ||
| } else { | ||
| KeyGenerator.getInstance( | ||
| KeyProperties.KEY_ALGORITHM_AES, | ||
| "AndroidKeyStore" | ||
| ).apply { | ||
| init( | ||
| KeyGenParameterSpec.Builder( | ||
| keyAlias, | ||
| KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT | ||
| ) | ||
| .setBlockModes(KeyProperties.BLOCK_MODE_GCM) | ||
| .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) | ||
| .build() | ||
| ) | ||
| }.generateKey() | ||
| } | ||
| } | ||
|
|
||
| fun mastgTest(): String { | ||
| return try { | ||
| val key = getOrCreateSecretKey() | ||
|
|
||
| // Encrypt the sensitive API key | ||
| val encryptCipher = Cipher.getInstance("AES/GCM/NoPadding") | ||
| encryptCipher.init(Cipher.ENCRYPT_MODE, key) | ||
| val iv = encryptCipher.iv | ||
| val encryptedBytes = encryptCipher.doFinal(sensitiveApiKey.toByteArray(Charsets.UTF_8)) | ||
| val encryptedData = Base64.encodeToString(iv + encryptedBytes, Base64.DEFAULT) | ||
|
|
||
| // Decrypt to verify | ||
| val decodedData = Base64.decode(encryptedData, Base64.DEFAULT) | ||
| val ivFromData = decodedData.copyOfRange(0, 12) | ||
| val ciphertext = decodedData.copyOfRange(12, decodedData.size) | ||
|
|
||
| val decryptCipher = Cipher.getInstance("AES/GCM/NoPadding") | ||
| decryptCipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, ivFromData)) | ||
| val decryptedBytes = decryptCipher.doFinal(ciphertext) | ||
| val decryptedString = String(decryptedBytes, Charsets.UTF_8) | ||
|
|
||
| "Encryption and decryption successful.\n" + | ||
| "Encrypted: $encryptedData\n" + | ||
| "Decrypted: $decryptedString" | ||
| } catch (e: Exception) { | ||
| "Error: ${e.message}" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
|
|
||
| [+] Frida script loaded. Hooking Cipher.doFinal() to extract sensitive data. | ||
|
|
||
|
|
||
|
|
||
| [*] Cipher.doFinal(byte[]) called | ||
| Algorithm: AES/GCM/NoPadding | ||
| Mode: ENCRYPT_MODE | ||
| Input (plaintext): sk-OWASP-MAS-SuperSecretKey-1234567890 | ||
| Output (ciphertext hex): b1e23e01082d0b660385a200566d1912f0be8e4bb843ae509b1752557da3a2586b9105bebcea2bbef1bba39e32a5ee98455aec67f7fe | ||
|
|
||
| Backtrace: | ||
| javax.crypto.Cipher.doFinal(Native Method) | ||
| org.owasp.mastestapp.MastgTest.mastgTest(MastgTest.kt:48) | ||
| org.owasp.mastestapp.MainActivityKt.MainScreen$lambda$12$lambda$11(MainActivity.kt:101) | ||
| org.owasp.mastestapp.MainActivityKt.$r8$lambda$Pm6AsbKBmypP53K-UABM21E_Xxk(Unknown Source:0) | ||
| org.owasp.mastestapp.MainActivityKt$$ExternalSyntheticLambda3.run(D8$$SyntheticClass:0) | ||
| java.lang.Thread.run(Thread.java:1119) | ||
|
|
||
|
|
||
| [*] Cipher.doFinal(byte[]) called | ||
| Algorithm: AES/GCM/NoPadding | ||
| Mode: DECRYPT_MODE | ||
| Input (ciphertext hex): b1e23e01082d0b660385a200566d1912f0be8e4bb843ae509b1752557da3a2586b9105bebcea2bbef1bba39e32a5ee98455aec67f7fe | ||
| Output (plaintext): sk-OWASP-MAS-SuperSecretKey-1234567890 | ||
|
|
||
| Backtrace: | ||
| javax.crypto.Cipher.doFinal(Native Method) | ||
| org.owasp.mastestapp.MastgTest.mastgTest(MastgTest.kt:58) | ||
| org.owasp.mastestapp.MainActivityKt.MainScreen$lambda$12$lambda$11(MainActivity.kt:101) | ||
| org.owasp.mastestapp.MainActivityKt.$r8$lambda$Pm6AsbKBmypP53K-UABM21E_Xxk(Unknown Source:0) | ||
| org.owasp.mastestapp.MainActivityKt$$ExternalSyntheticLambda3.run(D8$$SyntheticClass:0) | ||
| java.lang.Thread.run(Thread.java:1119) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| #!/bin/bash | ||
| frida -U -f org.owasp.mastestapp -l script.js -o output.txt |
Diolor marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| Java.perform(() => { | ||
|
|
||
| function printBacktrace(maxLines = 8) { | ||
| let Exception = Java.use("java.lang.Exception"); | ||
| let stackTrace = Exception.$new().getStackTrace().toString().split(","); | ||
|
|
||
| console.log("\nBacktrace:"); | ||
| for (let i = 0; i < Math.min(maxLines, stackTrace.length); i++) { | ||
| console.log(stackTrace[i]); | ||
| } | ||
| } | ||
|
|
||
| function bytesToHex(bytes) { | ||
| let hex = []; | ||
| for (let i = 0; i < bytes.length; i++) { | ||
| hex.push(("0" + (bytes[i] & 0xFF).toString(16)).slice(-2)); | ||
| } | ||
| return hex.join(""); | ||
| } | ||
|
|
||
| let Cipher = Java.use("javax.crypto.Cipher"); | ||
| let StringClass = Java.use("java.lang.String"); | ||
|
|
||
| // Hook Cipher.doFinal(byte[]) | ||
| Cipher["doFinal"].overload("[B").implementation = function (input) { | ||
| let result = this["doFinal"](input); | ||
| let algorithm = this.getAlgorithm(); | ||
|
|
||
| // Cipher.ENCRYPT_MODE = 1, Cipher.DECRYPT_MODE = 2 | ||
| let opmode = this.opmode.value; | ||
| let modeStr = opmode === 1 ? "ENCRYPT_MODE" : opmode === 2 ? "DECRYPT_MODE" : "UNKNOWN(" + opmode + ")"; | ||
|
|
||
| console.log("\n\n[*] Cipher.doFinal(byte[]) called"); | ||
| console.log(" Algorithm: " + algorithm); | ||
| console.log(" Mode: " + modeStr); | ||
|
|
||
| if (opmode === 1) { | ||
| try { | ||
| console.log(" Input (plaintext): " + StringClass.$new(input)); | ||
| } catch (e) { | ||
| console.log(" Input (hex): " + bytesToHex(input)); | ||
| } | ||
| console.log(" Output (ciphertext hex): " + bytesToHex(result)); | ||
| } | ||
|
|
||
| if (opmode === 2) { | ||
| console.log(" Input (ciphertext hex): " + bytesToHex(input)); | ||
| try { | ||
| console.log(" Output (plaintext): " + StringClass.$new(result)); | ||
| } catch (e) { | ||
| console.log(" Output (hex): " + bytesToHex(result)); | ||
| } | ||
| } | ||
|
|
||
| printBacktrace(); | ||
|
|
||
| return result; | ||
| }; | ||
|
|
||
| console.log("\n[+] Frida script loaded. Hooking Cipher.doFinal() to extract sensitive data.\n"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| --- | ||
| platform: android | ||
| title: App Terminating on Frida Hook Detection Before Cipher.doFinal Execution | ||
| id: MASTG-DEMO-0088 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| code: [kotlin] | ||
| test: MASTG-TEST-03te | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| kind: pass | ||
| --- | ||
|
|
||
| ## Sample | ||
|
|
||
| This sample encrypts and decrypts a sensitive API key using AES/GCM via the Android KeyStore. Unlike the unprotected variant in @MASTG-DEMO-0087, this version includes a runtime hook detection mechanism that reads `/proc/self/maps` to check for the presence of Frida-related libraries (e.g., `frida-agent`, `frida-gadget`). If detected, the app terminates the process immediately via `Process.killProcess()` before any cryptographic operations are performed. | ||
|
|
||
| {{ MastgTest.kt }} | ||
|
|
||
| ## Steps | ||
|
|
||
| 1. Install the app on a device (@MASTG-TECH-0005) | ||
| 2. Use @MASTG-TECH-0033 to target `Cipher.dofinal()` API | ||
| 3. Run `run.sh` to spawn the app with Frida | ||
| 4. Click the **Start** button | ||
| 5. Observe that the app terminates before the hooks can capture any data | ||
|
|
||
| {{ script.js # run.sh }} | ||
|
|
||
| ## Observation | ||
|
|
||
| The output contains no instances of `Cipher` method calls found at runtime. The terminal output from the `run.sh` command is `Process terminated`. | ||
|
|
||
| {{ output.txt }} | ||
|
|
||
| ## Evaluation | ||
|
|
||
| The test passes because the hooking attempt fails due to the app's defensive response. The app detects the Frida agent by scanning `/proc/self/maps` for entries containing "frida" or "gadget" and terminates the process via `Process.killProcess()`. The process terminates before `Cipher.doFinal()` hooks execute, so no sensitive data is extracted. | ||
|
|
||
| !!! note | ||
| Even if the test case passes, it might still be possible to bypass the app's defensive response. For example, an attacker could hook the `detectHooking()` method itself or lower level APIs such as the file reading APIs to hide Frida from the process memory map. @MASTG-DEMO-0089 demonstrates such a bypass. @MASTG-KNOW-0030 and @MASTG-KNOW-0032 describe such challenges. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| package org.owasp.mastestapp | ||
|
|
||
| import android.content.Context | ||
| import android.security.keystore.KeyGenParameterSpec | ||
| import android.security.keystore.KeyProperties | ||
| import android.util.Base64 | ||
| import java.io.BufferedReader | ||
| import java.io.FileReader | ||
| import java.security.KeyStore | ||
| import javax.crypto.Cipher | ||
| import javax.crypto.KeyGenerator | ||
| import javax.crypto.SecretKey | ||
| import javax.crypto.spec.GCMParameterSpec | ||
|
|
||
| class MastgTest(private val context: Context) { | ||
|
|
||
| private val sensitiveApiKey = "sk-OWASP-MAS-SuperSecretKey-1234567890" | ||
| private val keyAlias = "mastgCipherKey" | ||
|
|
||
| init { | ||
| if (detectHooking()) { | ||
| android.os.Process.killProcess(android.os.Process.myPid()) | ||
| } | ||
| } | ||
|
|
||
| private fun detectHooking(): Boolean { | ||
| try { | ||
| BufferedReader(FileReader("/proc/self/maps")).use { reader -> | ||
| var line: String? | ||
| while (reader.readLine().also { line = it } != null) { | ||
| val l = line!!.lowercase() | ||
| if (l.contains("frida") || l.contains("gadget")) { | ||
| return true | ||
| } | ||
| } | ||
| } | ||
| } catch (_: Exception) { | ||
| // Unable to read maps | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| private fun getOrCreateSecretKey(): SecretKey { | ||
| val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } | ||
| return if (keyStore.containsAlias(keyAlias)) { | ||
| (keyStore.getEntry(keyAlias, null) as KeyStore.SecretKeyEntry).secretKey | ||
| } else { | ||
| KeyGenerator.getInstance( | ||
| KeyProperties.KEY_ALGORITHM_AES, | ||
| "AndroidKeyStore" | ||
| ).apply { | ||
| init( | ||
| KeyGenParameterSpec.Builder( | ||
| keyAlias, | ||
| KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT | ||
| ) | ||
| .setBlockModes(KeyProperties.BLOCK_MODE_GCM) | ||
| .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) | ||
| .build() | ||
| ) | ||
| }.generateKey() | ||
| } | ||
| } | ||
|
|
||
| fun mastgTest(): String { | ||
| // Check for hooking before performing cryptographic operations | ||
| if (detectHooking()) { | ||
| android.os.Process.killProcess(android.os.Process.myPid()) | ||
| return "" | ||
| } | ||
|
|
||
| return try { | ||
| val key = getOrCreateSecretKey() | ||
|
|
||
| // Encrypt the sensitive API key | ||
| val encryptCipher = Cipher.getInstance("AES/GCM/NoPadding") | ||
| encryptCipher.init(Cipher.ENCRYPT_MODE, key) | ||
| val iv = encryptCipher.iv | ||
| val encryptedBytes = encryptCipher.doFinal(sensitiveApiKey.toByteArray(Charsets.UTF_8)) | ||
| val encryptedData = Base64.encodeToString(iv + encryptedBytes, Base64.DEFAULT) | ||
|
|
||
| // Decrypt to verify | ||
| val decodedData = Base64.decode(encryptedData, Base64.DEFAULT) | ||
| val ivFromData = decodedData.copyOfRange(0, 12) | ||
| val ciphertext = decodedData.copyOfRange(12, decodedData.size) | ||
|
|
||
| val decryptCipher = Cipher.getInstance("AES/GCM/NoPadding") | ||
| decryptCipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, ivFromData)) | ||
| val decryptedBytes = decryptCipher.doFinal(ciphertext) | ||
| val decryptedString = String(decryptedBytes, Charsets.UTF_8) | ||
|
|
||
| "Encryption and decryption successful.\n" + | ||
| "Encrypted: $encryptedData\n" + | ||
| "Decrypted: $decryptedString" | ||
| } catch (e: Exception) { | ||
| "Error: ${e.message}" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
|
|
||
| [+] Frida script loaded. Hooking Cipher.doFinal() to extract sensitive data. | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.