commit 6c12a11ef62bdf455874f4fbaa6469bc6a991488 Author: Phyks (Lucas Verney) Date: Fri Nov 11 16:45:06 2022 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fd3853 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.js +__pycache__ +*.jar diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a067ce3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) 2022 Phyks + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..30c1948 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +Frigida +======= + +Inject frida-gadget into an existing APK file. + + +## Requirements + +* Java +* Android SDK tools in `$PATH` (`aapt`, `keytool`, etc.). +* [`apktool`](https://github.com/iBotPeaches/Apktool) +* `openssl` / `xxd` / `tr` commands + + +## Installation + +``` +python3 -m venv .venv +./.venv/bin/pip install -r requirements.txt +``` + + +## Usage + +``` +./.venv/bin/python -m frigida $PATH_TO_APK_FILE $TARGET_ARCHITECTURE +``` + +Current working directory will be used for extracting APK. At the end of the +process: + +* `frida-gadget-script.js` in the working directory contains a base Frida Gadget + script to load. +* `$APK_NAME/dist/$APK_NAME-aligned-debugSigned.apk` in the working directory + contains the APK with frida-gadget injected and ready to be installed. + + +## License + +Released under an MIT license. diff --git a/frigida/__init__.py b/frigida/__init__.py new file mode 100644 index 0000000..0954994 --- /dev/null +++ b/frigida/__init__.py @@ -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, + ) + ) diff --git a/frigida/__main__.py b/frigida/__main__.py new file mode 100644 index 0000000..e824ac5 --- /dev/null +++ b/frigida/__main__.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import argparse +import logging +import os +from pathlib import Path + +from frigida import ( + get_package_name, decompress_apk, inject_frida_gadget, rebuild_apk, + resign_apk, prepare_gadget_script +) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) + + parser = argparse.ArgumentParser() + parser.add_argument("apk", help="Path to the original APK file.") + parser.add_argument("arch", help="Architecture to target (e.g. arm64).") + args = parser.parse_args() + + package_name = get_package_name(args.apk) + + uncompressed_apk_path = os.path.splitext(os.path.basename(args.apk))[0] + + decompress_apk(args.apk) + inject_frida_gadget(uncompressed_apk_path, args.arch) + rebuild_apk(uncompressed_apk_path) + + rebuilt_apk_path = Path(uncompressed_apk_path) / 'dist' / os.path.basename(args.apk) + resign_apk(rebuilt_apk_path) + + prepare_gadget_script(args.apk, package_name) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..06ca4c3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +apksigcopier +jinja2 +lief +requests