Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@ import com.securevale.rasp.android.api.SecureAppChecker
val shouldCheckForEmulator = true
val shouldCheckForDebugger = true
val shouldCheckForRoot = true
val shouldCheckAppIntegrity = true

val builder = SecureAppChecker.Builder(
this,
checkEmulator = shouldCheckForEmulator,
checkDebugger = shouldCheckForDebugger,
checkRoot = shouldCheckForRoot
checkRoot = shouldCheckForRoot,
checkIntegrity = shouldCheckAppIntegrity
)
```

Expand All @@ -98,6 +100,7 @@ when (checkResult) {
is Result.EmulatorFound -> {} // app is most likely running on emulator
is Result.DebuggerEnabled -> {} // app is in debug mode
is Result.Rooted -> {} // app is most likely rooted
is Result.IntegrityViolation -> {} // app was signed by an untrusted certificate
is Result.Secure -> {} // OK, no threats detected
}
```
Expand Down Expand Up @@ -165,6 +168,30 @@ class documentation.

## Supported Checks

### App Signature / Repackaging Detection

Includes:

- Native verification of the app signing certificate;
- Detection of apps repackaged and signed with an untrusted certificate;
- Support for signing certificate rotation on Android 9+.

To configure the trusted certificate, build a signed APK and run:

```bash
native/update_signature_hash.sh --apk path/to/signed.apk
cd native && ./build.sh
```

Multiple `--apk` arguments can be used to configure more than one trusted certificate.

The signature check helps detect applications that were modified and signed again with an
untrusted certificate. The verification is performed in native code and the trusted certificate
fingerprint is obfuscated to increase the effort required to locate and bypass the check. However,
as with other local security checks, a skilled attacker might still be able to patch or hook the
verification. For this reason, signature verification should be used alongside other RASP checks
and server-side integrity validation whenever possible.

### Debugger Detection

Includes:
Expand Down Expand Up @@ -268,4 +295,4 @@ dependency using exact version as the requirement.
## License

This tool and code is released under Apache License v2.0 with Runtime Library Exception. Please
see [LICENSE](LICENSE) for more information.
see [LICENSE](LICENSE) for more information.
3 changes: 2 additions & 1 deletion native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ log = "0.4.21"
jni = "0.21.1"
android_logger = "0.15.0"
once_cell = "1.19.0"
sha2 = "0.10.9"

[lib]
name = "native"
Expand All @@ -19,4 +20,4 @@ strip = true

#[profile.release-with-debug]
#inherits = "release"
#debug = true
#debug = true
113 changes: 113 additions & 0 deletions native/src/integrity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#![allow(non_snake_case)]

use jni::objects::{JClass, JObject, JObjectArray, JValue};
use jni::sys::jboolean;
use jni::JNIEnv;
use sha2::{Digest, Sha256};

mod trusted_signers;

fn certificate_matches(certificate: &[u8]) -> bool {
let calculated = Sha256::digest(certificate);
trusted_signers::matches(&calculated)
}

fn any_certificate_matches(env: &mut JNIEnv, certificates: JObject) -> jni::errors::Result<bool> {
let certificates = JObjectArray::from(certificates);
for index in 0..env.get_array_length(&certificates)? {
let signature = env.get_object_array_element(&certificates, index)?;
let bytes = env
.call_method(signature, "toByteArray", "()[B", &[])?
.l()?;
let bytes = jni::objects::JByteArray::from(bytes);
if certificate_matches(&env.convert_byte_array(&bytes)?) {
return Ok(true);
}
}
Ok(false)
}

fn signing_certificates<'a>(
env: &mut JNIEnv<'a>,
context: &JObject<'a>,
) -> jni::errors::Result<JObject<'a>> {
let package_manager = env
.call_method(
context,
"getPackageManager",
"()Landroid/content/pm/PackageManager;",
&[],
)?
.l()?;
let package_name = env
.call_method(context, "getPackageName", "()Ljava/lang/String;", &[])?
.l()?;
let sdk = env
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")?
.i()?;

if sdk >= 28 {
// GET_SIGNING_CERTIFICATES. Use the complete signing lineage for rotated keys.
let package_info = env
.call_method(
&package_manager,
"getPackageInfo",
"(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;",
&[JValue::Object(&package_name), JValue::Int(0x0800_0000)],
)?
.l()?;
let signing_info = env
.get_field(
package_info,
"signingInfo",
"Landroid/content/pm/SigningInfo;",
)?
.l()?;
let has_multiple = env
.call_method(&signing_info, "hasMultipleSigners", "()Z", &[])?
.z()?;
let method = if has_multiple {
"getApkContentsSigners"
} else {
"getSigningCertificateHistory"
};
env.call_method(
signing_info,
method,
"()[Landroid/content/pm/Signature;",
&[],
)?
.l()
} else {
// GET_SIGNATURES for the library's API 24-27 range.
let package_info = env
.call_method(
package_manager,
"getPackageInfo",
"(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;",
&[JValue::Object(&package_name), JValue::Int(0x40)],
)?
.l()?;
env.get_field(
package_info,
"signatures",
"[Landroid/content/pm/Signature;",
)?
.l()
}
}

/// Returns true when the installed package is not signed by a configured trusted signer.
#[no_mangle]
pub unsafe extern "C" fn Java_com_securevale_rasp_android_integrity_checks_SignatureChecks_a<'a>(
mut env: JNIEnv<'a>,
_class: JClass,
context: JObject<'a>,
) -> jboolean {
let trusted = signing_certificates(&mut env, &context)
.and_then(|certificates| any_certificate_matches(&mut env, certificates))
.unwrap_or(false);

// Fail closed: inability to obtain or inspect the signing certificate is a violation.
u8::from(!trusted)
}
32 changes: 32 additions & 0 deletions native/src/integrity/trusted_signers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
include!("trusted_signers_data.rs");

#[inline(always)]
fn concealed(value: &u8) -> u8 {
// Volatile reads stop LTO from folding the arrays back into a plaintext digest.
// SAFETY: `value` is always a valid reference to an initialized byte in one of
// the fixed-size static arrays generated in `trusted_signers_data.rs`.
unsafe { std::ptr::read_volatile(value) }
}

/// Compares a SHA-256 digest against every configured trusted signer.
///
/// The complete expected digest is reconstructed only while each byte is compared.
#[inline(never)]
pub fn matches(candidate: &[u8]) -> bool {
if candidate.len() != 32 {
return false;
}

let mut matched = 0u8;
for encoded in &ENCODED {
let mut difference = 0u8;
for index in 0..32 {
difference |= candidate[index]
^ concealed(&encoded[index])
^ concealed(&MASK_A[index])
^ concealed(&MASK_B[index]);
}
matched |= u8::from(difference == 0);
}
matched != 0
}
22 changes: 22 additions & 0 deletions native/src/integrity/trusted_signers_data.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Generated by native/update_signature_hash.sh. Do not edit.

const MASK_A: [u8; 32] = [
0x77, 0x36, 0x9a, 0x7e, 0xcc, 0x76, 0xb0, 0x2e,
0x81, 0xe0, 0x91, 0xfe, 0x75, 0xf8, 0x1d, 0x9b,
0xf3, 0x56, 0x0a, 0xd8, 0x76, 0xac, 0x42, 0xfd,
0xd4, 0x57, 0x31, 0xf2, 0xff, 0x19, 0xc9, 0xb9,
];
const MASK_B: [u8; 32] = [
0x98, 0xca, 0xd2, 0x73, 0x86, 0xa9, 0xcd, 0xf6,
0xc3, 0xfd, 0x2a, 0x63, 0x80, 0x56, 0x6d, 0x69,
0xb6, 0x5a, 0xc1, 0x08, 0x11, 0x4d, 0x58, 0xe6,
0x75, 0x90, 0xd8, 0xa0, 0xb2, 0x23, 0xd7, 0x71,
];
const ENCODED: [[u8; 32]; 1] = [
[
0x6a, 0x19, 0x0b, 0x5f, 0xf8, 0x01, 0xb8, 0x94,
0x36, 0x75, 0xf2, 0x58, 0xa3, 0x4c, 0x0b, 0x3f,
0x22, 0x1c, 0x5b, 0x00, 0xfb, 0xf7, 0x0c, 0x9a,
0xc2, 0xfb, 0xcb, 0x32, 0x28, 0x36, 0x69, 0xad,
],
];
1 change: 1 addition & 0 deletions native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use jni::JNIEnv;

mod debuggable;
mod emulator;
mod integrity;

mod common;
mod root;
Expand Down
109 changes: 109 additions & 0 deletions native/tools/generate_trusted_signers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
use std::env;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::Path;

fn parse_digest(value: &str) -> Result<[u8; 32], String> {
let hex: String = value
.chars()
.filter(|character| character.is_ascii_hexdigit())
.collect();
if hex.len() != 64 {
return Err(format!(
"expected a SHA-256 digest (64 hex digits), got {}",
hex.len()
));
}
let mut digest = [0u8; 32];
for (index, byte) in digest.iter_mut().enumerate() {
*byte = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16)
.map_err(|error| error.to_string())?;
}
Ok(digest)
}

fn random_bytes() -> Result<[u8; 32], String> {
let mut result = [0u8; 32];
File::open("/dev/urandom")
.and_then(|mut source| source.read_exact(&mut result))
.map_err(|error| format!("could not read /dev/urandom: {error}"))?;
Ok(result)
}

fn array(name: &str, value: &[u8; 32]) -> String {
let rows = value
.chunks(8)
.map(|row| {
format!(
" {},",
row.iter()
.map(|byte| format!("0x{byte:02x}"))
.collect::<Vec<_>>()
.join(", ")
)
})
.collect::<Vec<_>>()
.join("\n");
format!("const {name}: [u8; 32] = [\n{rows}\n];\n")
}

fn main() -> Result<(), String> {
let output = env::args().nth(1).ok_or("missing output file")?;
let digests = env::args()
.skip(2)
.map(|value| parse_digest(&value))
.collect::<Result<Vec<_>, _>>()?;
if digests.is_empty() {
return Err("provide at least one trusted SHA-256 fingerprint".into());
}

let mask_a = random_bytes()?;
let mask_b = random_bytes()?;
let encoded = digests
.iter()
.map(|digest| {
let mut value = [0u8; 32];
for index in 0..32 {
value[index] = digest[index] ^ mask_a[index] ^ mask_b[index];
}
value
})
.collect::<Vec<_>>();

let encoded_rows = encoded
.iter()
.map(|value| {
let rows = value
.chunks(8)
.map(|row| {
format!(
" {},",
row.iter()
.map(|byte| format!("0x{byte:02x}"))
.collect::<Vec<_>>()
.join(", ")
)
})
.collect::<Vec<_>>()
.join("\n");
format!(" [\n{rows}\n ],")
})
.collect::<Vec<_>>()
.join("\n");

let source = format!(
"// Generated by native/update_signature_hash.sh. Do not edit.\n\n{}{}const ENCODED: [[u8; 32]; {}] = [\n{}\n];\n",
array("MASK_A", &mask_a),
array("MASK_B", &mask_b),
encoded.len(),
encoded_rows
);

let output_path = Path::new(&output);
let temporary = output_path.with_extension("rs.tmp");
File::create(&temporary)
.and_then(|mut file| file.write_all(source.as_bytes()))
.map_err(|error| error.to_string())?;
fs::rename(temporary, output_path).map_err(|error| error.to_string())?;
Ok(())
}
Loading