|
|
|
@ -0,0 +1,325 @@
|
|
|
|
|
import logging
|
|
|
|
|
import lzma
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import subprocess
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import apksigcopier
|
|
|
|
|
import jinja2
|
|
|
|
|
import lief
|
|
|
|
|
import requests
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GADGET_SCRIPT_TEMPLATE = """
|
|
|
|
|
Java.perform(function () {
|
|
|
|
|
/**
|
|
|
|
|
* Convert an hex-string to an array of int.
|
|
|
|
|
*
|
|
|
|
|
* Based on an implementation from crypto-js.
|
|
|
|
|
* Taken from https://stackoverflow.com/a/34356351
|
|
|
|
|
*/
|
|
|
|
|
function hexToBytes(hex) {
|
|
|
|
|
for (var bytes = [], c = 0; c < hex.length; c += 2) {
|
|
|
|
|
bytes.push(parseInt(hex.substr(c, 2), 16));
|
|
|
|
|
}
|
|
|
|
|
return bytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Bypass package certificate hash.
|
|
|
|
|
*
|
|
|
|
|
* Altering the APK (embedding frida-gadget + resigning) does change the
|
|
|
|
|
* SHA-1 of the signing certificate.
|
|
|
|
|
*
|
|
|
|
|
* GCM/Firebase sends this to the server to check the APK is authorized in
|
|
|
|
|
* developer account (they have to declare their signing certificate for
|
|
|
|
|
* distributed APK embedding GCM/Firebase).
|
|
|
|
|
*
|
|
|
|
|
* Hence, GCM/Firebase is no longer working in altered APKs, often
|
|
|
|
|
* materializing through an "Play Store / Play Services are not available"
|
|
|
|
|
* error message.
|
|
|
|
|
*
|
|
|
|
|
* See https://github.com/firebase/firebase-android-sdk/blob/dcf82a5296b86f04c0b8ce162e0437c7aeb42734/firebase-installations/src/main/java/com/google/firebase/installations/remote/FirebaseInstallationServiceClient.java
|
|
|
|
|
* and https://github.com/firebase/firebase-android-sdk/blob/dcf82a5296b86f04c0b8ce162e0437c7aeb42734/firebase-installations/src/main/java/com/google/firebase/installations/FirebaseInstallations.java.
|
|
|
|
|
*
|
|
|
|
|
* This hook bypasses the certificate check by force-returning the original one.
|
|
|
|
|
*/
|
|
|
|
|
// Output of:
|
|
|
|
|
// keytool -printcert -jarfile "ORIGINAL_UNALTERED_APK.apk" | grep "SHA 1:"
|
|
|
|
|
var INITIAL_CERTIFICATE_HASH = "{{ initial_certificate_hash }}";
|
|
|
|
|
// Output of:
|
|
|
|
|
// mkdir sigs && apksigcopier extract base.apk sigs
|
|
|
|
|
// openssl pkcs7 -in sigs/BNDLTOOL.RSA -inform DER -print_certs | openssl x509 -outform DER | xxd -p | tr -d \\n
|
|
|
|
|
var RAW_SIGNING_CERTIFICATE = "{{ raw_signing_certificate }}";
|
|
|
|
|
|
|
|
|
|
// Bypass through GMS API
|
|
|
|
|
var AndroidUtilsLight = Java.use('com.google.android.gms.common.util.AndroidUtilsLight');
|
|
|
|
|
AndroidUtilsLight.getPackageCertificateHashBytes.implementation = function (context, packageName) {
|
|
|
|
|
var value = Java.array("byte", hexToBytes(INITIAL_CERTIFICATE_HASH.replaceAll(':', '')))
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bypass accessing certs through Android package manager API
|
|
|
|
|
var Signature = Java.use('android.content.pm.Signature');
|
|
|
|
|
Java.use('android.app.ApplicationPackageManager').getPackageInfo.overload('java.lang.String', 'int').implementation = function (packageName, flags) {
|
|
|
|
|
// See https://developer.android.com/reference/android/content/pm/PackageManager#GET_SIGNING_CERTIFICATES
|
|
|
|
|
// Android API level >= 28
|
|
|
|
|
if (packageName == '{{ package_name }}' && flags == 134217728) {
|
|
|
|
|
var value = this.getPackageInfo(packageName, flags);
|
|
|
|
|
// Overload the returned signatures
|
|
|
|
|
value.signingInfo.value.getApkContentsSigners.implementation = function () {
|
|
|
|
|
// TODO: Handle multiple signers / history
|
|
|
|
|
return Java.array(
|
|
|
|
|
'android.content.pm.Signature',
|
|
|
|
|
[Signature.$new(RAW_SIGNING_CERTIFICATE)]
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
value.signingInfo.value.getSigningCertificateHistory.implementation = function () {
|
|
|
|
|
return Java.array(
|
|
|
|
|
'android.content.pm.Signature',
|
|
|
|
|
[Signature.$new(RAW_SIGNING_CERTIFICATE)]
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
return this.getPackageInfo(packageName, flags);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Also unblock FCM requests.
|
|
|
|
|
var d = Java.use('com.google.firebase.installations.remote.RequestLimiter');
|
|
|
|
|
d.isRequestAllowed.implementation = function () {
|
|
|
|
|
var ret = this.isRequestAllowed();
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Log SSL/TLS keys.
|
|
|
|
|
*
|
|
|
|
|
* See https://github.com/PiRogueToolSuite/pirogue-cli/blob/main/pirogue_cli/frida-scripts/log_ssl_keys.js
|
|
|
|
|
*/
|
|
|
|
|
function _log_ssl_keys(SSL_CTX_new, SSL_CTX_set_keylog_callback) {
|
|
|
|
|
function log_key(ssl, line) {
|
|
|
|
|
const s_line = new NativePointer(line).readCString();
|
|
|
|
|
console.log(s_line);
|
|
|
|
|
}
|
|
|
|
|
const keylogCallback = new NativeCallback(log_key, 'void', ['pointer', 'pointer'])
|
|
|
|
|
|
|
|
|
|
Interceptor.attach(SSL_CTX_new, {
|
|
|
|
|
onLeave: function(retval) {
|
|
|
|
|
const ssl = new NativePointer(retval);
|
|
|
|
|
if (!ssl.isNull()) {
|
|
|
|
|
const SSL_CTX_set_keylog_callbackFn = new NativeFunction(SSL_CTX_set_keylog_callback, 'void', ['pointer', 'pointer']);
|
|
|
|
|
SSL_CTX_set_keylog_callbackFn(ssl, keylogCallback);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
_log_ssl_keys(
|
|
|
|
|
Module.findExportByName('libssl.so', 'SSL_CTX_new'),
|
|
|
|
|
Module.findExportByName('libssl.so', 'SSL_CTX_set_keylog_callback')
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def get_package_name(apk_path):
|
|
|
|
|
"""
|
|
|
|
|
Get the package name (com.example) from an APK.
|
|
|
|
|
|
|
|
|
|
:param apk_path: Path to the APK file.
|
|
|
|
|
:returns: Package name.
|
|
|
|
|
"""
|
|
|
|
|
aapt_dump_out = subprocess.check_output(
|
|
|
|
|
['aapt', 'dump', 'badging', apk_path]
|
|
|
|
|
).decode()
|
|
|
|
|
return re.search(r"name='(.*?)'", aapt_dump_out).group(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decompress_apk(
|
|
|
|
|
apk_path,
|
|
|
|
|
should_not_decode_res=False, should_not_decode_src=True
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Decompress APK file using apktool
|
|
|
|
|
|
|
|
|
|
APK is uncompressed in a folder named without the .apk extension in the
|
|
|
|
|
working directory.
|
|
|
|
|
|
|
|
|
|
:param apk_path: Path to the APK file.
|
|
|
|
|
:param should_not_decode_res: Do not decode resources. (default False)
|
|
|
|
|
:param should_not_decode_src: Do not decode sources. Speeds up
|
|
|
|
|
unpacking/repacking. (default True)
|
|
|
|
|
"""
|
|
|
|
|
logging.info('Uncompressing apk with apktool...')
|
|
|
|
|
optional_args = []
|
|
|
|
|
if should_not_decode_res:
|
|
|
|
|
optional_args.append('-r')
|
|
|
|
|
if should_not_decode_src:
|
|
|
|
|
optional_args.append('-s')
|
|
|
|
|
|
|
|
|
|
return subprocess.run(
|
|
|
|
|
['apktool', 'd'] + optional_args + [apk_path]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inject_frida_gadget(uncompressed_apk_path, target_architecture):
|
|
|
|
|
"""
|
|
|
|
|
Inject frida gadget in the libs from the APK.
|
|
|
|
|
|
|
|
|
|
See https://fadeevab.com/frida-gadget-injection-on-android-no-root-2-methods/.
|
|
|
|
|
|
|
|
|
|
:param uncompressed_apk_path: Folder of the output from apktool.
|
|
|
|
|
:param target_architecture: Architecture to target for injection.
|
|
|
|
|
"""
|
|
|
|
|
libs_path = Path(uncompressed_apk_path) / 'lib'
|
|
|
|
|
for dir in os.listdir(libs_path):
|
|
|
|
|
# Find correct subdirectory based on target architecture
|
|
|
|
|
if dir.startswith(target_architecture):
|
|
|
|
|
libs_path = libs_path / dir
|
|
|
|
|
break
|
|
|
|
|
libfridagadget_name = 'libgadget.so'
|
|
|
|
|
|
|
|
|
|
if not os.path.isfile(libs_path / libfridagadget_name):
|
|
|
|
|
logging.info('Download Frida-gadget into apk libs...')
|
|
|
|
|
latest_release = requests.get(
|
|
|
|
|
'https://api.github.com/repos/frida/frida/releases/latest'
|
|
|
|
|
).json()
|
|
|
|
|
download_url = next(
|
|
|
|
|
asset['browser_download_url']
|
|
|
|
|
for asset in latest_release['assets']
|
|
|
|
|
if (
|
|
|
|
|
asset['name'].startswith('frida-gadget-')
|
|
|
|
|
and asset['name'].endswith(f'-android-{target_architecture}.so.xz')
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
req = requests.get(
|
|
|
|
|
download_url
|
|
|
|
|
)
|
|
|
|
|
with open(libs_path / libfridagadget_name, 'wb') as fh:
|
|
|
|
|
fh.write(lzma.LZMADecompressor().decompress(req.content))
|
|
|
|
|
|
|
|
|
|
logging.info('Injecting Frida-gadget lib in apk libs...')
|
|
|
|
|
apk_libs = [item for item in os.listdir(libs_path)]
|
|
|
|
|
if not apk_libs:
|
|
|
|
|
logging.error('APK does not have any lib for injection!')
|
|
|
|
|
raise Exception('APK does not have any lib for injection!')
|
|
|
|
|
for lib in apk_libs:
|
|
|
|
|
libnative = lief.parse(str(libs_path / lib))
|
|
|
|
|
libnative.add_library(libfridagadget_name) # Injection!
|
|
|
|
|
libnative.write(str(libs_path / lib))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rebuild_apk(uncompressed_apk_path, optional_args=['--use-aapt2']):
|
|
|
|
|
"""
|
|
|
|
|
Rebuild APK
|
|
|
|
|
|
|
|
|
|
New (rebuilt) APK is generated in ``dist/`` folder under the unpacked APK
|
|
|
|
|
tree.
|
|
|
|
|
|
|
|
|
|
:param uncompressed_apk_path: Path to the apk ressources.
|
|
|
|
|
:param optional_args: Extra arguments to pass to apktool.
|
|
|
|
|
"""
|
|
|
|
|
logging.info('Ensure android:extractNativeLibs is true in the AndroidManifest.xml...')
|
|
|
|
|
# https://stackoverflow.com/questions/42998083/setting-androidextractnativelibs-false-to-reduce-app-size
|
|
|
|
|
with open(Path(uncompressed_apk_path) / 'AndroidManifest.xml', 'r') as fh:
|
|
|
|
|
android_manifest = fh.read()
|
|
|
|
|
if 'android:extractNativeLibs="false"' in android_manifest:
|
|
|
|
|
with open(Path(uncompressed_apk_path) / 'AndroidManifest.xml', 'w') as fh:
|
|
|
|
|
fh.write(
|
|
|
|
|
android_manifest.replace(
|
|
|
|
|
'android:extractNativeLibs="false"',
|
|
|
|
|
'android:extractNativeLibs="true"',
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logging.info('Rebuilding APK...')
|
|
|
|
|
return subprocess.run(
|
|
|
|
|
['apktool', 'b'] + optional_args + [uncompressed_apk_path]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resign_apk(apk_path):
|
|
|
|
|
"""
|
|
|
|
|
Resign the APK with uber apk signer.
|
|
|
|
|
|
|
|
|
|
APK is generated beside the source APK file with a ``-aligned-debugSigned``
|
|
|
|
|
suffix.
|
|
|
|
|
|
|
|
|
|
:param apk_path: Path to the APK to sign.
|
|
|
|
|
"""
|
|
|
|
|
latest_release = requests.get(
|
|
|
|
|
'https://api.github.com/repos/patrickfav/uber-apk-signer/releases/latest'
|
|
|
|
|
).json()
|
|
|
|
|
download_url, jar_name = next(
|
|
|
|
|
(asset['browser_download_url'], asset['name'])
|
|
|
|
|
for asset in latest_release['assets']
|
|
|
|
|
if asset['name'].endswith('.jar')
|
|
|
|
|
)
|
|
|
|
|
if not os.path.isfile(jar_name):
|
|
|
|
|
with open(jar_name, 'wb') as fh:
|
|
|
|
|
fh.write(
|
|
|
|
|
requests.get(download_url).content
|
|
|
|
|
)
|
|
|
|
|
return subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
'java', '-jar', jar_name,
|
|
|
|
|
'-a', apk_path
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def prepare_gadget_script(apk_path, package_name):
|
|
|
|
|
"""
|
|
|
|
|
Prepare the Frida Gadget script to be loaded upon APK loading to ensure
|
|
|
|
|
correct behavior of the APK:
|
|
|
|
|
* Masking re-signing by overloading Android APIs and presenting
|
|
|
|
|
original certificate hash.
|
|
|
|
|
* ...
|
|
|
|
|
|
|
|
|
|
Script is generated as ``frida-gadget-script.js`` in the working directory.
|
|
|
|
|
|
|
|
|
|
:param apk_path: Path to the original APK.
|
|
|
|
|
:param package_name: Name of the package (e.g. `com.example`).
|
|
|
|
|
"""
|
|
|
|
|
# Get certificate hash (SHA-1) from the initial APK
|
|
|
|
|
initial_certificate_hash = (
|
|
|
|
|
next(
|
|
|
|
|
line
|
|
|
|
|
for line in subprocess.check_output(
|
|
|
|
|
['keytool', '-printcert', '-jarfile', apk_path]
|
|
|
|
|
).decode().splitlines()
|
|
|
|
|
if line.strip().startswith('SHA 1')
|
|
|
|
|
).strip().replace('SHA 1: ', '')
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get the signing certificate from the original APK
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_sig_out_path:
|
|
|
|
|
# Get certificates from original APK
|
|
|
|
|
apksigcopier.do_extract(apk_path, tmp_sig_out_path)
|
|
|
|
|
# Get the hexdump of the certificate
|
|
|
|
|
cert_file = os.path.join(
|
|
|
|
|
tmp_sig_out_path,
|
|
|
|
|
next(
|
|
|
|
|
file for file in os.listdir(tmp_sig_out_path) if file.endswith('.RSA')
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
raw_signing_certificate = subprocess.check_output(
|
|
|
|
|
f'openssl pkcs7 -in {cert_file} -inform DER -print_certs | openssl x509 -outform DER | xxd -p | tr -d \\n',
|
|
|
|
|
shell=True
|
|
|
|
|
).decode()
|
|
|
|
|
|
|
|
|
|
with open('./frida-gadget-script.js', 'w') as fh:
|
|
|
|
|
template = jinja2.Environment(
|
|
|
|
|
loader=jinja2.BaseLoader
|
|
|
|
|
).from_string(GADGET_SCRIPT_TEMPLATE)
|
|
|
|
|
fh.write(
|
|
|
|
|
template.render(
|
|
|
|
|
initial_certificate_hash=initial_certificate_hash,
|
|
|
|
|
raw_signing_certificate=raw_signing_certificate,
|
|
|
|
|
package_name=package_name,
|
|
|
|
|
)
|
|
|
|
|
)
|