Frigida/frigida/__init__.py

326 lines
12 KiB
Python

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,
)
)