Implementation¶
Implementing integrity
requires, on top of a KevlarAntipiracy
and the attestation infrastructure, a bit of information about your application's metadata, which will be hardcoded inside it.
Hardcoded data is necessary to provide kevlar a truth value (essentially, that should be the description of all the scenarios that the application is allowed to run in) to match the runtime values of your binary (which may have been tampered or altered by an attacker) against.
That's because, if done well, we can detect almost every kind of attack/tampering attempt through checking various APK metadata, such as the package name, the signature, the installer and debug flags.
The obfuscation is necessary because we need to conceal the truth values, since they will be looked for by the attacker (software or human), and make it as hard as possible to automatically find and patch them.
A working example for the integrity module can be found in the github repository under the :showcase
module.
Dependency¶
Maven
Hardcoded metadata¶
Understanding which metadata you need¶
The first step is choosing which checks to run, and thus which data to provide kevlar.
The good news is that they all are stable metadata. This means that once you make the effort of searching the right strings and implementing everything, you are ideally good to go forever.
Kevlar has 4 kinds of checks: a package name check, a signature check, an installer check and a debug check.
Check Type | Required parameters | Parameter type |
---|---|---|
Package Name | package name | String |
Signature Check | app signature | String (base64-encoded) |
Installer Check | N/A | N/A |
Debug Check | N/A | N/A |
The hardcoded metadata you need to find is the following:
- Your application package name: Will be used to check that the running binary's package name matches the hardcoded package name;
- Your application signature: Will be used to check that the running binary's signature is the same as the hardcoded signature.
Additionally, you should consider enabling the other two kinds of checks:
- Debug checks: Is enabled, kevlar autonomously checks for debug flags and bits in your binary;
- Installer checks: If enabled, kevlar will report any application which has not been installed through an allowed installer (by default it only supports the Play Store, but you can add custom stores if you distribute your software elsewhere).
Once you choose which kind of checks you want to run, you get the required metadata
// Holds the package name
private val packageNameData = HardcodedPackageName(
packageName = "com.kevlar.showcase"
)
// Holds the signature
private val signatureData = HardcodedBase64EncodedSignature(
base64EncodedSignature = "J+nqXLfuIO8B2AmhkMYHGE4jDyw="
)
// Combines all the metadata and configuration
private val integrity = KevlarIntegrity {
checks {
packageName {
hardcodedPackageName(packageNameData)
}
signature {
hardcodedSignatures(signatureData)
}
installer()
debug()
}
}
// Runs the checks on the current executing app
integrity.attestate(context)
Do not
Maybe, just maybe, you may be tempted to so something like this:
private val notAtAllHardcodedMetadata = HardcodedMetadata(
packageName = context.getPackageName(),
signature = getRuntimeSignature()
)
The sole purpose of HardcodedMetadata
is hardcoding truth values inside your app, which don't depend on the context or application, which may have been tampered with. This snippet single handedly kills the whole library (because kevlar will check that the (supposedly) hardcoded package name, in this case context.getPackageName()
, matches the runtime package name, which is always true since it is gathered via context.getPackageName()
too) and is like shooting yourself in the foot with a cannon. Don't.
Finding metadata¶
Finding the package name is easy, since you are the one choosing it for your application.
For the signature, it's not so straightforward to extract because it depends on your keystore. You have two different ways to get your keystore signature. In the examples we will find the debug signature, but you need to find the signature of the keystore you use to sign your application when publishing on google play.
Direct application extraction¶
The most practical way to read your keystore it is to put the following line of code in your app (it is a courtesy method provided by kevlar to do just that), to then sign the application with the key you are interested in acquiring the signature string of, and run it.
// This returns the signature of the current running application.
val signature: String = KevlarIntegrity.obtainCurrentAppSignature(context)
// Log it to the console for extracting the signature
Log.d("SIGNATURE", signature)
This will output the current app signature. That's the reference string you need to give to kevlar through hardcodedSignatures()
(which, once an attestation is requested, will extract the runtime signature of your app (which may be tampered with, if someone recompiled your application) and match it against that string you just extracted).
Android debug signature
Every android application is signed with some key. When an application is signed as "debug", it simply means that it is signed with a special key, which is known to be the debug key.
Signature extraction & Google Play App Signing API
If you are using Google Play App Signing, the key you sign your application with is not the one your app is distributed with (See the official docs regarding the matter, and a relevant issue in kevlar).
In this case the easiest way to get your actual signature would be to upload a dummy version of your app (which logs the runtime signature) through google play store, let the backend process and sign it, download it (through the archive manager on the play console), install & run it locally on an emulator/device, and save the runtime signature. Once you have done this (quite tedious, but once-in-a-lifetime) procedure, you have your signature and can pass it to kevlar.
Android studio extraction¶
Running ./gradlew signingReport
will spit out all the details for all the different keystores in your project.
The signature we are interested in is the SHA-1 entry. In this case, 27:E9:EA:5C:B7:EE:20:EF:01:D8:09:A1:90:C6:07:18:4E:23:0F:2C
.
> Task :showcase:signingReport
Variant: debug
Config: debug
Store: /Users/cioccarellia/.android/debug.keystore
Alias: AndroidDebugKey
MD5: 1B:AF:39:46:4E:13:83:F3:45:E9:0A:5A:53:64:9C:CB
SHA1: 27:E9:EA:5C:B7:EE:20:EF:01:D8:09:A1:90:C6:07:18:4E:23:0F:2C
SHA-256: 36:C8:C0:A1:8A:DD:6D:0E:34:F9:6E:7E:98:DC:1F:89:08:BC:CD:2E:EF:88:ED:45:DF:79:85:D2:39:BD:E1:54
Valid until: Tuesday, June 25, 2052
----------
Variant: release
Config: null
Store: null
Alias: null
----------
We then have to convert it in a string form (like that we have the raw hex bytes, we want a base64 encoding of the binary signature). In my case the conversion (you can use sha1_to_base64 online tools to do this) yields J+nqXLfuIO8B2AmhkMYHGE4jDyw=
.
Incompatible With Play Signing
Since we don't have access to the "real" keystore file if we're using use Play Signing, this method is not viable in that case, and you have to resort to uploading a dummy version of the app, download its play-signed version through the releases page, and extract the signature from that APK file.
Obfuscating metadata¶
The second step (optional but recommended) is obfuscating the metadata you just gathered, so that it is stored in an obfuscated form (in your bytecode, so that automatic tools / unskilled attackers can't easily find it), but passed to kevlar deobfuscated (obviously kevlar has to receive the real, intended value, so that we have the original truth values. The idea is to perform the deobfuscation/decryption just at run time, not leaving a trace of the actual plaintext signature in the application code).
This means that we'll ship with our app the obfuscated data, and then at runtime (when kevlar is invoked) we will convert that obfuscated data back to plaintext to feed kevlar.
There are a few different ways to do it, all of them are fully implemented in the :showcase
module for you check out:
No obfuscation (not recommended)¶
In this case you just save the values as they are, and pass them in HardcodedMetadata
:
private const val packageName = HardcodedPackageName("com.kevlar.showcase")
private const val signature = HardcodedBase64EncodedSignature("J+nqXLfuIO8B2AmhkMYHGE4jDyw=")
Bytecode
The produced kotlin bytecode clearly exposes the raw values:
Base64 obfuscation¶
You store the package name and signature values as Base64-encoded byte arrays, and they go through the Base64.decode()
function when creating HardcodedMetadata
.
private val packageName = """Y29tLmtldmxhci5zaG93Y2FzZQ==""".toByteArray()
private val signature = """SitucVhMZnVJTzhCMkFtaGtNWUhHRTRqRHl3PQ==""".toByteArray()
private val base64ObfuscatedHardcodedPackageName = HardcodedPackageName(
packageName = Base64.decode(base64PackageName, Base64.DEFAULT).toString(Charsets.UTF_8)
)
private val base64ObfuscatedHardcodedSignature = HardcodedBase64EncodedSignature(
Base64.decode(base64Signature, Base64.DEFAULT).toString(Charsets.UTF_8)
)
Where Y29tLmtldmxhci5zaG93Y2FzZQ==
is the base64 encoding of com.kevlar.showcase
, and SitucVhMZnVJTzhCMkFtaGtNWUhHRTRqRHl3PQ==
of J+nqXLfuIO8B2AmhkMYHGE4jDyw=
(the signature).
You can look them up online, grab them from your app or use openssl base64
in the terminal.
Base64 flags & charset
The flag field and charset don't necessarily need to be Base64.DEFAULT
and UTF_8
. Even though they are the most popular, you may choose something else if you prefer, as long as you preserve consistency.
Bytecode
Here the metadata is hidden and not targetable with basic find-and-replace techniques
Encryption (+base64)¶
A better alternative is to encrypt the hardcoded metadata, store them in an encrypted form, and send them through a decryption function when creating HardcodedMetadata
.
// Our arbitrary 256-bit encryption key
private val aesKey256 = """4t7w!z%C*F-JaNcRfUjXn2r5u8x/A?Ds"""
// This is "com.kevlar.showcase", encrypted using AES256 with the previous key
private val encryptedPackageName = """s3wf/AOYtr9BEMVFrweeLnkmerryUykMA8O77S5tMlI=""".toByteArray()
// This is "J+nqXLfuIO8B2AmhkMYHGE4jDyw=", encrypted using AES256 with the previous key
private val encryptedSignature = """tqMJquO3D+EKx1rx4R7/qzmsuEgpp1bKwxXe9AeB/WU=""".toByteArray()
private val aes256EncryptedHardcodedPackageName = HardcodedPackageName(
packageName = EncryptionUtil.decrypt(
encryptedPackageName,
EncryptionUtil.generateKey(aesKey256)
)
)
private val aes256EncryptedHardcodedSignatures = HardcodedBase64EncodedSignature(
EncryptionUtil.decrypt(encryptedSignature, EncryptionUtil.generateKey(aesKey256))
)
Where 7KAa2CFkhPQOUouDu32KZJLqOzGFbTTnJA3rGxMlAg4=
is the encrypted value of com.kevlar.showcase
, and +ylMx63kwFRmXKHQU0cbzyb8MJ1iiGW1g8+MjDRcS/o=
of J+nqXLfuIO8B2AmhkMYHGE4jDyw=
.
This ensures that there is no possibility that an automatic attack picks up the string as a package name or signature, and trivial string substitutions or encodings like base64 won't give any information away (the ciphertext is encoded in base64).
AES128 or AES256 is recommended as the encryption algorithm (it's a little overkill but it does the job).
The ciphertext may also be stored as a byte array.
Encryption utility
This tiny class (from the showcase module in the repository) is a basic AES encryption/decryption utility.
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
object EncryptionUtil {
private const val algorithm = "AES"
private const val transformation = "AES/ECB/PKCS5Padding"
fun generateKey(key: String): SecretKey = SecretKeySpec(key.toByteArray(), algorithm)
fun encrypt(text: ByteArray, secret: SecretKey): String {
val cipher: Cipher = Cipher.getInstance(transformation).apply {
init(Cipher.ENCRYPT_MODE, secret)
}
return Base64.encodeToString(cipher.doFinal(text), Base64.NO_WRAP) ?: ""
}
fun decrypt(ciphertext: ByteArray, secret: SecretKey): String {
val cipher: Cipher = Cipher.getInstance(transformation).apply {
init(Cipher.DECRYPT_MODE, secret)
}
return String(cipher.doFinal(Base64.decode(ciphertext, Base64.NO_WRAP)), Charsets.UTF_8)
}
}
Bytecode
Here detecting and reconstructing the original package name automatically is basically impossible
Hashing¶
This hasn't been developed yet, but it may be possible to let kevlar know only the hash of your hardcoded data, and let it match directly on the runtime signatures and package names hashes. This would require multiple options of composite hash functions to be secure enough. It is not implemented.
Initialization & Attestations¶
You need to create a KevlarIntegrity
instance (which is the way you will be requesting attestations), along with your desired parameters (either global, local or in your repository layer, if you are using MVVM/MVC).
Once you have that, you just go ahead and call integrity.attestate()
in a coroutine and your application running metadata will be checked, according to the provided parameters.
IntegrityAttestation
will be returned from the call (it's a sealed class), containing the checks which failed, if any.
Note that we will be initializing KevlarIntegrity
with custom scan settings, but you could leave it as default.
In-Place¶
This is the most concise (and complete) way to implement this package.
/**
* Base64 obfuscated package name and signature
* */
// Original value is "com.kevlar.showcase", the package name hardcoded value
private val base64EncodedPackageName = """Y29tLmtldmxhci5zaG93Y2FzZQ==""".toByteArray()
private val base64ObfuscatedHardcodedPackageName = HardcodedPackageName(
packageName = Base64.decode(base64EncodedPackageName, Base64.DEFAULT).toString(Charsets.UTF_8)
)
// Original value is "J+nqXLfuIO8B2AmhkMYHGE4jDyw=", the signature hardcoded value
private val base64EncodedSignature = """SitucVhMZnVJTzhCMkFtaGtNWUhHRTRqRHl3PQ==""".toByteArray()
private val base64ObfuscatedHardcodedSignatures = HardcodedBase64EncodedSignature(
Base64.decode(base64EncodedSignature, Base64.DEFAULT).toString(Charsets.UTF_8)
)
/**
* Integrity package
* */
private val integrity = KevlarIntegrity {
checks {
packageName {
hardcodedPackageName(base64ObfuscatedHardcodedPackageName)
}
signature {
hardcodedSignatures(base64ObfuscatedHardcodedSignatures)
}
installer()
debug()
}
}
/**
* Assestation request & callback
* +/
CoroutineScope(Dispatchers.Default).launch {
// Attestation request
when (val attestation = integrity.attestate(context)) {
is IntegrityAttestation.Blank -> {
// Pending attestation, no information yet.
// Don't do anything.
}
is IntegrityAttestation.Clear -> {
// Good to go.
}
is IntegrityAttestation.Failed -> {
// One or more checks have failed.
}
}
}
This packs everything in one file. It is not excellent when writing a modern applications but it does its job.
ViewModel + Repository + SharedFlow + DI with Hilt¶
Activity:¶
@AndroidEntryPoint
class IntegrityActivity : AppCompatActivity() {
private val vm: ActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.attestation.collectLatest {
when (it) {
is IntegrityAttestation.Blank -> {
// Pending attestation, no information yet.
// Don't do anything.
}
is IntegrityAttestation.Clear -> {
// Good to go.
}
is IntegrityAttestation.Failed -> {
// Pirate software detected.
}
}
}
}
}
CoroutineScope(Dispatchers.Main).launch {
vm.requestAttestation()
}
}
}
View model:¶
@HiltViewModel
class ActivityViewModel @Inject constructor(
private val integrityRepository: IntegrityRepository
) : ViewModel() {
private val _attestation = MutableStateFlow(KevlarIntegrity.blankAttestation())
internal val attestation: SharedFlow<IntegrityAttestation> = _attestation.stateIn(
viewModelScope,
SharingStarted.Eagerly,
initialValue = KevlarIntegrity.blankAttestation()
)
fun requestAttestation() {
viewModelScope.launch {
_attestation.value = integrityRepository.attestate()
}
}
}
Repository¶
class IntegrityRepository @Inject constructor(
@ApplicationContext val context: Context,
@IoDispatcher val externalDispatcher: CoroutineDispatcher
) {
/**
* Base64 obfuscated package name
* */
private val base64PackageName = """Y29tLmtldmxhci5zaG93Y2FzZQ==""".toByteArray()
private val base64Signature = """SitucVhMZnVJTzhCMkFtaGtNWUhHRTRqRHl3PQ==""".toByteArray()
private val base64ObfuscatedHardcodedPackageName = HardcodedPackageName(
packageName = Base64.decode(base64PackageName, Base64.DEFAULT).toString(Charsets.UTF_8)
)
private val base64ObfuscatedHardcodedSignatures = HardcodedBase64EncodedSignature(
Base64.decode(base64Signature, Base64.DEFAULT).toString(Charsets.UTF_8)
)
/**
* Integrity package
* */
private val integrity = KevlarIntegrity {
checks {
packageName {
hardcodedPackageName(base64ObfuscatedHardcodedPackageName)
}
signature {
hardcodedSignatures(base64ObfuscatedHardcodedSignatures)
}
installer()
debug()
}
}
suspend fun attestate(): IntegrityAttestation = withContext(externalDispatcher) {
integrity.attestate(context)
}
}