Initial commit

This commit is contained in:
Lucas Verney 2022-11-11 16:45:06 +01:00
commit 6c12a11ef6
6 changed files with 423 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.js
__pycache__
*.jar

19
LICENSE.md Normal file
View File

@ -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.

40
README.md Normal file
View File

@ -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.

325
frigida/__init__.py Normal file
View File

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

32
frigida/__main__.py Normal file
View File

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

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
apksigcopier
jinja2
lief
requests