diff --git a/.gitignore b/.gitignore index cfac947..f7ae478 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ patch-apk-dbg.py +.DS_Store diff --git a/README.md b/README.md index 3710c88..7029e4d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +# :warning: Active fork for NickstaDB/patch-apk + +As the original projects has been set to read-only, I've created an active fork which combines most interesting modifications. If you identify any issues, please open a ticket. + +Original credit goes to @NickstaDB and significant modifications were made by myself and @jseigelis + # patch-apk - App Bundle/Split APK Aware Patcher for Objection # An APK patcher, for use with [objection](https://github.com/sensepost/objection), that supports Android app bundles/split APKs. It automates the following: @@ -11,6 +17,19 @@ An APK patcher, for use with [objection](https://github.com/sensepost/objection) ### Changelog ### +* **10th October 2024:** + * Added mult-user support. If the apk file path can't be found for the default user, it will try other users + * Remove split-apk tags from . Doesn't seem to be documented, but Android verifies these too. + +* **22nd February 2023:** Took over project and added various features: + * Merged modifications from @jseigelis's fork + * Removed support for outdated objection versions + * Fixed bug for `--debug-output` + * Added `--verbose` flag + * Fixed bug with objection AndroidManifest extraction + * Updated output format + * Remove dependency on apksigner by using objection's signapk command + * **29th April 2021:** Implemented a fix for an issue with `apktool` where the handling of some resource XML elements changed and the `--use-aapt2` flag is required ([https://github.com/iBotPeaches/Apktool/issues/2462](https://github.com/iBotPeaches/Apktool/issues/2462)). * **28th April 2021:** Fixed a bug with `objection` version detection when the `objection version` command output an update notice. * **1st August 2020:** Updated for compatibility with `objection` version 1.9.3 and above and fixed a bug with line endings when retrieving package names from the Android device/emulator. @@ -56,104 +75,125 @@ When `patch-apk.py` is done, the installed app should be patched with objection **Partial Package Name Matching:** Pass a partial package name to `patch-apk.py` and it'll automatically grab the correct package name or ask you to confirm from available options. ``` -$ python3 patch-apk.py ovid -Multiple matching packages installed, select the package to patch. -[1] com.android.providers.telephony -[2] com.android.providers.calendar -[3] com.android.providers.media -[4] com.android.providers.downloads -[5] com.android.providers.downloads.ui -[6] com.android.providers.settings -[7] com.android.providers.partnerbookmarks -[8] com.android.bookmarkprovider -[9] com.android.providers.blockednumber -[10] com.android.providers.userdictionary -[11] com.joinzoe.covid_zoe -[12] com.android.providers.contacts -Choice: +$ python3 patch-apk.py proxy + +[!] Multiple matching packages installed, select the package to patch. + +[1] org.proxydroid +[2] com.android.proxyhandler +Choice: + ``` **Patching Split APKs:** Split APKs are automatically detected and combined into a single APK before patching. Split APKs can be identified by multiple APK paths being returned by the `adb shell pm path` command as shown below. ``` -$ adb shell pm path com.joinzoe.covid_zoe -package:/data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/base.apk -package:/data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.arm64_v8a.apk -package:/data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.en.apk -package:/data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.xxhdpi.apk +$ adb shell pm path org.proxydroid +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/base.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.arm64_v8a.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.en.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.fr.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.nl.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.xxhdpi.apk ``` -The following shows `patch-apk.py` detecting, rebuilding, and patching a split APK. Some output has been snipped for brevity. +The following shows `patch-apk.py` detecting, rebuilding, and patching a split APK. Some output has been snipped for brevity. The `-v` flag has been set to show additional info. ``` -$ python3 patch-apk.py covid -Getting APK path(s) for package: com.joinzoe.covid_zoe -[+] APK path: /data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/base.apk -[+] APK path: /data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.arm64_v8a.apk -[+] APK path: /data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.en.apk -[+] APK path: /data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.xxhdpi.apk - -Pulling APK file(s) from device. -[+] Pulling: com.joinzoe.covid_zoe-base.apk -[+] Pulling: com.joinzoe.covid_zoe-split_config.arm64_v8a.apk -[+] Pulling: com.joinzoe.covid_zoe-split_config.en.apk -[+] Pulling: com.joinzoe.covid_zoe-split_config.xxhdpi.apk - -App bundle/split APK detected, rebuilding as a single APK. - -Extracting individual APKs with apktool. -[+] Extracting: /tmp/tmp1kir74u_/com.joinzoe.covid_zoe-base.apk -[+] Extracting: /tmp/tmp1kir74u_/com.joinzoe.covid_zoe-split_config.arm64_v8a.apk -[+] Extracting: /tmp/tmp1kir74u_/com.joinzoe.covid_zoe-split_config.en.apk -[+] Extracting: /tmp/tmp1kir74u_/com.joinzoe.covid_zoe-split_config.xxhdpi.apk - -Copying files and directories from split APKs into base APK. -[+] Creating directory in base APK: /lib -[+] Creating directory in base APK: /lib/arm64-v8a -[+] Moving file to base APK: /lib/arm64-v8a/libfb.so -... -[+] Moving file to base APK: /res/drawable-xxxhdpi/shell_launch_background_image.png +$ python3 patch-apk.py org.proxydroid -v + +[+] Retrieving APK path(s) for package: org.proxydroid + [+] APK path: /data/app/~~FTVBmscrJiLerJdXIEa5tw==/org.proxydroid-KMq91nU1y9Qz8ZZAGM--RA==/base.apk + [+] APK path: /data/app/~~FTVBmscrJiLerJdXIEa5tw==/org.proxydroid-KMq91nU1y9Qz8ZZAGM--RA==/split_config.arm64_v8a.apk + [+] APK path: /data/app/~~FTVBmscrJiLerJdXIEa5tw==/org.proxydroid-KMq91nU1y9Qz8ZZAGM--RA==/split_config.en.apk + [+] APK path: /data/app/~~FTVBmscrJiLerJdXIEa5tw==/org.proxydroid-KMq91nU1y9Qz8ZZAGM--RA==/split_config.fr.apk + [+] APK path: /data/app/~~FTVBmscrJiLerJdXIEa5tw==/org.proxydroid-KMq91nU1y9Qz8ZZAGM--RA==/split_config.nl.apk + [+] APK path: /data/app/~~FTVBmscrJiLerJdXIEa5tw==/org.proxydroid-KMq91nU1y9Qz8ZZAGM--RA==/split_config.xxhdpi.apk + +[+] Pulling APK file(s) from device |################################| 6/6 + [+] Pulled: org.proxydroid-base.apk + [+] Pulled: org.proxydroid-split_config.arm64_v8a.apk + [+] Pulled: org.proxydroid-split_config.en.apk + [+] Pulled: org.proxydroid-split_config.fr.apk + [+] Pulled: org.proxydroid-split_config.nl.apk + [+] Pulled: org.proxydroid-split_config.xxhdpi.apk + +[!] App bundle/split APK detected, rebuilding as a single APK. + +[+] Disassembling split APKs |################################| 6/6 + + Extracted: /var/folders/t3/vz305z151ng8y2rwvpkx28xw0000gn/T/tmpyw7wl64i/org.proxydroid-base.apk + Extracted: /var/folders/t3/vz305z151ng8y2rwvpkx28xw0000gn/T/tmpyw7wl64i/org.proxydroid-split_config.arm64_v8a.apk + Extracted: /var/folders/t3/vz305z151ng8y2rwvpkx28xw0000gn/T/tmpyw7wl64i/org.proxydroid-split_config.en.apk + Extracted: /var/folders/t3/vz305z151ng8y2rwvpkx28xw0000gn/T/tmpyw7wl64i/org.proxydroid-split_config.fr.apk + Extracted: /var/folders/t3/vz305z151ng8y2rwvpkx28xw0000gn/T/tmpyw7wl64i/org.proxydroid-split_config.nl.apk + Extracted: /var/folders/t3/vz305z151ng8y2rwvpkx28xw0000gn/T/tmpyw7wl64i/org.proxydroid-split_config.xxhdpi.apk + +[+] Rebuilding as a single APK + + [+] Found public.xml in the base APK, fixing resource identifiers across split APKs. + [+] Resolving 21 resource identifiers. + [+] Located 21 true resource names. + [+] Updated 21 dummy resource names with true names in the base APK. + [+] Updated 47 references to dummy resource names in the base APK. + [+] Disabling APK splitting in AndroidManifest.xml of base APK. + [+] Fixing any improperly escaped ampersands. + [+] Forcing all private resources to be public + [+] Updated 350 private resources before building APK. + [+] Rebuilding with 'apktool --use-aapt2'. + +[+] Patching org.proxydroid-base.apk with objection. + +[+] Patching APK to enable support for user-installed CA certificates. + [+] Forcing all private resources to be public + [+] Updated 351 private resources before building APK. + [+] Rebuilding with 'apktool --use-aapt2'. + [+] Zip aligning new APK. + [+] Signing new APK. + +[+] Uninstalling the original package from the device. + +[+] Installing the patched APK to the device. + +[+] Done +``` -Found public.xml in the base APK, fixing resource identifiers across split APKs. -[+] Resolving 83 resource identifiers. -[+] Located 83 true resource names. -[+] Updated 83 dummy resource names with true names in the base APK. -[+] Updated 164 references to dummy resource names in the base APK. +After `patch-apk.py` completes, we can run `adb shell pm path` again to verify that there is now a single patched APK installed on the device. -Disabling APK splitting in AndroidManifest.xml of base APK. +``` +$ adb shell pm path org.proxydroid +package:/data/app/org.proxydroid-9NuZnT-lK3qM_IZQEHhTgA==/base.apk +``` -Rebuilding as a single APK. -[+] Building APK with apktool. -[+] Signing new APK. -[+] Zip aligning new APK. +By default, patch-apk will inject the frida gadget and modify the network security config. It is also possible to only perform an extraction by providing the `--extract-only` flag. Any split apks will still be merged and a local copy of the APK will be produced: -Patching com.joinzoe.covid_zoe-base.apk with objection. +``` +$ python3 patch-apk.py org.proxydroid --extract-only -Patching APK to enable support for user-installed CA certificates. +[+] Retrieving APK path(s) for package: org.proxydroid -Uninstalling the original package from the device. +[+] Pulling APK file(s) from device |################################| 6/6 -Installing the patched APK to the device. +[!] App bundle/split APK detected, rebuilding as a single APK. -Done, cleaning up temporary files. -``` +[+] Disassembling split APKs |################################| 6/6 -After `patch-apk.py` completes, we can run `adb shell pm path` again to verify that there is now a single patched APK installed on the device. +[+] Rebuilding as a single APK -``` -$ adb shell pm path com.joinzoe.covid_zoe -package:/data/app/com.joinzoe.covid_zoe-9NuZnT-lK3qM_IZQEHhTgA==/base.apk +[+] Saving a copy of the APK to org.proxydroid.apk ``` ## Combining Split APKs ## Split APKs have been supported since Android 5/Lollipop (June 2014, API level 21). Essentially this allows an app to be split across multiple APK files, for example one might contain the main code and another might contain image resources for a given screen resolution. We can identify whether an app uses split APKs with the `adb shell pm path` command like so: ``` -$ adb shell pm path com.joinzoe.covid_zoe -package:/data/app/com.joinzoe.covid_zoe-NW8ZbgI5VPzvSZ1NgMa4CQ==/base.apk -package:/data/app/com.joinzoe.covid_zoe-NW8ZbgI5VPzvSZ1NgMa4CQ==/split_config.arm64_v8a.apk -package:/data/app/com.joinzoe.covid_zoe-NW8ZbgI5VPzvSZ1NgMa4CQ==/split_config.en.apk -package:/data/app/com.joinzoe.covid_zoe-NW8ZbgI5VPzvSZ1NgMa4CQ==/split_config.xxhdpi.apk +$ adb shell pm path org.proxydroid +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/base.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.arm64_v8a.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.en.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.fr.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.nl.apk +package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaagGQ==/split_config.xxhdpi.apk ``` These can be combined into a single APK for use with other tools such as `objection patchapk`. This is done by `patch-apk.py` as follows: diff --git a/patch-apk.py b/patch-apk.py index f666cd7..922a7bc 100755 --- a/patch-apk.py +++ b/patch-apk.py @@ -1,176 +1,244 @@ #!/usr/bin/python3 import argparse import os -import pkg_resources +from packaging.version import parse as parse_version import shutil import subprocess import sys import tempfile import xml.etree.ElementTree +import re +from progress.bar import Bar # pip3 install progress +from termcolor import colored # pip3 install termcolor + +NULL_DECODED_DRAWABLE_COLOR = "#000000ff" #################### # Main() #################### def main(): - #Check that dependencies are available - checkDependencies() - - #Grab argz - args = getArgs() - - #Verify the package name and ensure it's installed (also supports partial package names) - pkgname = verifyPackageName(args.pkgname) - - #Get the APK path(s) from the device - apkpaths = getAPKPathsForPackage(pkgname) - - #Create a temp directory to work from - with tempfile.TemporaryDirectory() as tmppath: - #Get the APK to patch. Combine app bundles/split APKs into a single APK. - apkfile = getTargetAPK(pkgname, apkpaths, tmppath, args.disable_styles_hack) - - #Save the APK if requested - if args.save_apk is not None: - print("Saving a copy of the APK to " + args.save_apk) - print("") - shutil.copy(apkfile, args.save_apk) - - #Patch the target APK with objection - print("Patching " + apkfile.split(os.sep)[-1] + " with objection.") - ret = None - if getObjectionVersion() >= pkg_resources.parse_version("1.9.3"): - ret = subprocess.run(["objection", "patchapk", "--skip-resources", "--ignore-nativelibs", "-s", apkfile], stdout=getStdout()) - else: - ret = subprocess.run(["objection", "patchapk", "--skip-resources", "-s", apkfile], stdout=getStdout()) - if ret.returncode != 0: - print("Error: Failed to run 'objection patchapk --skip-resources -s " + apkfile + "'.\nRun with --debug-output for more information.") - sys.exit(1) - os.remove(apkfile) - shutil.move(apkfile[:-4] + ".objection.apk", apkfile) - print("") - - #Enable support for user-installed CA certs (e.g. Burp Suite CA installed on device by user) - if args.no_enable_user_certs == False: - enableUserCerts(apkfile) - - #Uninstall the original package from the device - print("Uninstalling the original package from the device.") - ret = subprocess.run(["adb", "uninstall", pkgname], stdout=getStdout()) - if ret.returncode != 0: - print("Error: Failed to run 'adb uninstall " + pkgname + "'.\nRun with --debug-output for more information.") - sys.exit(1) - print("") - - #Install the patched APK - print("Installing the patched APK to the device.") - ret = subprocess.run(["adb", "install", apkfile], stdout=getStdout()) - if ret.returncode != 0: - print("Error: Failed to run 'adb install " + apkfile + "'.\nRun with --debug-output for more information.") - sys.exit(1) - print("") - - #Done - print("Done, cleaning up temporary files.") + # Grab argz + args = getArgs() + + # Check that dependencies are available + checkDependencies(args.extract_only) + + # Warn for unexpected version + apktoolVersion = getApktoolVersion() + print(f"Using apktool v{apktoolVersion}") + + # Verify the package name and ensure it's installed (also supports partial package names) + pkgname = verifyPackageName(args.pkgname) + + # Get the APK path(s) from the device + current_user, apkpaths = getAPKPathsForPackage(pkgname) + + # Create a temp directory to work from + with tempfile.TemporaryDirectory() as tmppath: + # Get the APK to patch. Combine app bundles/split APKs into a single APK. + apkfile = getTargetAPK(pkgname, apkpaths, tmppath, args.disable_styles_hack, args.extract_only) + + # Save the APK if requested + if args.save_apk is not None or args.extract_only: + targetName = args.save_apk if args.save_apk is not None else pkgname + ".apk" + print("\n[+] Saving a copy of the APK to " + targetName) + shutil.copy(apkfile, targetName) + if args.extract_only: + os.remove(apkfile) + return + + # Patch the target APK with objection + print("\n[+] Patching " + apkfile.split(os.sep)[-1] + " with objection.") + if subprocess.run(["objection", "patchapk", "--skip-resources", "--ignore-nativelibs", "-s", apkfile], stdout=getStdout(), stderr=getStdout()).returncode != 0: + print("\n[+] Objection patching failed, trying alternative approach") + warningPrint("[!] If you get an error, the application might not have a launchable activity") + + # Try without --skip-resources, since objection potentially wasn't able to identify the starting activity + # There could have been another reason for the failure, but it's a sensible fallback + assertSubprocessSuccessfulRun(["objection", "patchapk", "--ignore-nativelibs", "-s", apkfile]) + + os.remove(apkfile) + shutil.move(apkfile[:-4] + ".objection.apk", apkfile) + + # Enable support for user-installed CA certs (e.g. Burp Suite CA installed on device by user) + if not args.no_enable_user_certs: + enableUserCerts(apkfile) + + # Uninstall the original package from the device + print(f"\n[+] Uninstalling the original package from the device. (user: {current_user})") + assertSubprocessSuccessfulRun(["adb", "uninstall", "--user", current_user, pkgname]) + + # Install the patched APK + print(f"\n[+] Installing the patched APK to the device. (user: {current_user})") + assertSubprocessSuccessfulRun(["adb", "install", "--user", current_user, apkfile]) + + # Done + print("\n[+] Done") + +def assertSubprocessSuccessfulRun(args): + if subprocess.run(args, stdout=getStdout(), stderr=getStdout()).returncode != 0: + abort(f"Error: Failed to run {' '.join(args)}.\nRun with --debug-output for more information.") + #################### # Check that required dependencies are present: # -> Tools used # -> Android device connected # -> Keystore #################### -def checkDependencies(): - deps = ["adb", "apktool", "jarsigner", "objection", "zipalign"] - missing = [] - for dep in deps: - if shutil.which(dep) is None: - missing.append(dep) - if len(missing) > 0: - print("Error, missing dependencies, ensure the following commands are available on the PATH: " + (", ".join(missing))) - sys.exit(1) - - #Verify that an Android device is connected - proc = subprocess.run(["adb", "devices"], stdout=subprocess.PIPE) - if proc.returncode != 0: - print("Error: Failed to run 'adb devices'.") - sys.exit(1) - deviceOut = proc.stdout.decode("utf-8") - if len(deviceOut.strip().split(os.linesep)) == 1: - print("Error, no Android device connected (\"adb devices\"), connect a device first.") - sys.exit(1) - - #Check that the included keystore exists - if os.path.exists(os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore"))) == False: - print("Error, the keystore was not found at " + os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore")) + ", please clone the repository or get the keystore file and place it at this location.") - sys.exit(1) +def checkDependencies(extract_only): + deps = ["adb", "apktool", "aapt"] + + if not extract_only: + deps += ["objection", "zipalign", "apksigner"] + + missing = [] + for dep in deps: + if shutil.which(dep) is None: + missing.append(dep) + if len(missing) > 0: + abort("Error, missing dependencies, ensure the following commands are available on the PATH: " + (", ".join(missing))) + + # Verify that an Android device is connected + proc = subprocess.run(["adb", "devices"], stdout=subprocess.PIPE) + if proc.returncode != 0: + abort("Error: Failed to run 'adb devices'.") + deviceOut = proc.stdout.decode("utf-8") + if len(deviceOut.strip().split(os.linesep)) == 1: + abort("Error, no Android device connected (\"adb devices\"), connect a device first.") + + # Check that the included keystore exists + if not os.path.exists(os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore"))): + abort("Error, the keystore was not found at " + os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore")) + ", please clone the repository or get the keystore file and place it at this location.") #################### # Grab command line parameters #################### def getArgs(): - #Only parse args once - if not hasattr(getArgs, "parsed_args"): - #Parse the command line - parser = argparse.ArgumentParser( - description="patch-apk - Pull and patch Android apps for use with objection/frida." - ) - parser.add_argument("--no-enable-user-certs", help="Prevent patch-apk from enabling user-installed certificate support via network security config in the patched APK.", action="store_true") - parser.add_argument("--save-apk", help="Save a copy of the APK (or single APK) prior to patching for use with other tools.") - parser.add_argument("--disable-styles-hack", help="Disable the styles hack that removes duplicate entries from res/values/styles.xml.", action="store_true") - parser.add_argument("--debug-output", help="Enable debug output.", action="store_true") - parser.add_argument("pkgname", help="The name, or partial name, of the package to patch (e.g. com.foo.bar).") - - #Store the parsed args - getArgs.parsed_args = parser.parse_args() - - #Return the parsed command line args - return getArgs.parsed_args + # Only parse args once + if not hasattr(getArgs, "parsed_args"): + # Parse the command line + parser = argparse.ArgumentParser( + description="patch-apk - Pull and patch Android apps for use with objection/frida. Supports split APKs." + ) + parser.add_argument("--no-enable-user-certs", help="Prevent patch-apk from enabling user-installed certificate support via network security config in the patched APK.", action="store_true") + parser.add_argument("--save-apk", help="Save a copy of the APK (or single APK) prior to patching for use with other tools. APK will be saved under the given name.") + parser.add_argument("--extract-only", help="Disable including objection and pushing modified APK to device.", action="store_true") + parser.add_argument("--disable-styles-hack", help="Disable the styles hack that removes duplicate entries from res/values/styles.xml.", action="store_true") + parser.add_argument("--debug-output", help="Enable debug output.", action="store_true") + parser.add_argument("-v", "--verbose", help="Enable verbose output.", action="store_true") + parser.add_argument("pkgname", help="The name, or partial name, of the package to patch (e.g. com.foo.bar).") + + # Store the parsed args + getArgs.parsed_args = parser.parse_args() + + # Return the parsed command line args + return getArgs.parsed_args #################### # Debug print #################### def dbgPrint(msg): - if getArgs().debug_output == True: - print(msg) + if getArgs().debug_output: + print(msg) #################### -# Get the stdout target for subprocess calls. Set to DEVNULL unless debug output is enabled. +# Warning print #################### -def getStdout(): - if getArgs().debug_output == True: - return None - else: - return subprocess.DEVNULL +def warningPrint(msg): + print(colored(msg, "yellow")) + + +#################### +# Abort will print given error message and exit the app +#################### +def abort(msg): + print(colored(msg, "red")) + sys.exit(1) #################### -# Get objection version +# Get the stdout target for subprocess calls. Set to DEVNULL unless debug output is enabled. #################### -def getObjectionVersion(): - proc = subprocess.run(["objection", "version"], stdout=subprocess.PIPE) - return pkg_resources.parse_version(proc.stdout.decode("utf-8").strip().split(": ")[-1].strip()) +def getStdout(): + if getArgs().debug_output: + return None + else: + return subprocess.DEVNULL #################### # Get apktool version #################### def getApktoolVersion(): - proc = subprocess.run(["apktool", "-version"], stdout=subprocess.PIPE) - return pkg_resources.parse_version(proc.stdout.decode("utf-8").strip().split("-")[0].strip()) + commands = [["apktool", "version"], ["apktool", "-version"]] + + for cmd in commands: + try: + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + output = proc.stdout.decode("utf-8").strip() + version_str = output.split("-")[0].strip() + return parse_version(version_str) + except (Exception): + continue + raise Exception("Error: Failed to get apktool version.") #################### # Wrapper to run apktool platform-independently, complete with a dirty hack to fix apktool's dirty hack. #################### def runApkTool(params): - if os.name == "nt": - args = ["apktool.bat"] - args.extend(params) - - #apktool.bat has a dirty hack that execute "pause", so we need a dirty hack to kill the pause command... - proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=getStdout()) - proc.communicate(b"\r\n") - return proc - else: - args = ["apktool"] - args.extend(params) - return subprocess.run(args, stdout=getStdout()) + if os.name == "nt": + args = ["apktool.bat"] + args.extend(params) + + # apktool.bat has a dirty hack that execute "pause", so we need a dirty hack to kill the pause command... + proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=getStdout()) + proc.communicate(b"\r\n") + return proc + else: + args = ["apktool"] + args.extend(params) + return subprocess.run(args, stdout=getStdout()) + +#################### +# Fix private resources preventing builds (apktool wontfix: https://github.com/iBotPeaches/Apktool/issues/2761) +#################### +def fixPrivateResources(baseapkdir): + verbosePrint("[+] Forcing all private resources to be public") + updated = 0 + for (root, dirs, files) in os.walk(os.path.join(baseapkdir, "res")): + for f in files: + if f.lower().endswith(".xml"): + rawREReplace(os.path.join(root, f), '@android', '@*android') + updated += 1 + if updated > 0: + verbosePrint("[+] Updated " + str(updated) + " private resources before building APK.") + +#################### +# Build the APK +#################### +def build(baseapkdir): + # Fix private resources preventing builds (apktool wontfix: https://github.com/iBotPeaches/Apktool/issues/2761) + fixPrivateResources(baseapkdir) + + verbosePrint("[+] Rebuilding APK with apktool.") + ret = runApkTool(["b", baseapkdir]) + if ret.returncode != 0: + abort("Error: Failed to run 'apktool b " + baseapkdir + "'.\nRun with --debug-output for more information.") + +#################### +# Sign the APK with apksigner and zip align +# Fixes https://github.com/NickstaDB/patch-apk/issues/31 by no longer using jarsigner (V1 APK signatures) +#################### +def signAndZipAlign(baseapkdir, baseapkfilename): + # Zip align the new APK + verbosePrint("[+] Zip aligning new APK.") + assertSubprocessSuccessfulRun(["zipalign", "-f", "4", os.path.join(baseapkdir, "dist", baseapkfilename), + os.path.join(baseapkdir, "dist", baseapkfilename[:-4] + "-aligned.apk")]) + shutil.move(os.path.join(baseapkdir, "dist", baseapkfilename[:-4] + "-aligned.apk"), os.path.join(baseapkdir, "dist", baseapkfilename)) + + # Sign the new APK + verbosePrint("[+] Signing new APK.") + apkpath = os.path.join(baseapkdir, "dist", baseapkfilename) + assertSubprocessSuccessfulRun(["objection", "signapk", apkpath]) #################### # Verify the package name - checks whether the target package is installed @@ -178,228 +246,210 @@ def runApkTool(params): # the user for selection. #################### def verifyPackageName(pkgname): - #Get a list of installed packages matching the given name - packages = [] - proc = subprocess.run(["adb", "shell", "pm", "list", "packages"], stdout=subprocess.PIPE) - if proc.returncode != 0: - print("Error: Failed to run 'adb shell pm list packages'.") - sys.exit(1) - out = proc.stdout.decode("utf-8") - for line in out.split(os.linesep): - if line.startswith("package:"): - line = line[8:].strip() - if pkgname.lower() in line.lower(): - packages.append(line) - - #Bail out if no matching packages were found - if len(packages) == 0: - print("Error, no packages found on the device matching the search term '" + pkgname + "'.") - print("Run 'adb shell pm list packages' to verify installed package names.") - sys.exit(1) - - #Return the target package name, offering a choice to the user if necessary - if len(packages) == 1: - return packages[0] - else: - print("Multiple matching packages installed, select the package to patch.") - choice = -1 - while choice == -1: - for i in range(len(packages)): - print("[" + str(i + 1) + "] " + packages[i]) - choice = input("Choice: ") - if choice.isnumeric() == False or int(choice) < 1 or int(choice) > len(packages): - print("Invalid choice.\n") - choice = -1 - print("") - return packages[int(choice) - 1] + # Get a list of installed packages matching the given name + packages = [] + proc = subprocess.run(["adb", "shell", "pm", "list", "packages"], stdout=subprocess.PIPE) + if proc.returncode != 0: + abort("Error: Failed to run 'adb shell pm list packages'.") + out = proc.stdout.decode("utf-8") + for line in out.split(os.linesep): + if line.startswith("package:"): + line = line[8:].strip() + if pkgname.lower() in line.lower(): + packages.append(line) + + # Bail out if no matching packages were found + if len(packages) == 0: + abort("Error, no packages found on the device matching the search term '" + pkgname + "'.\nRun 'adb shell pm list packages' to verify installed package names.") + + # Return the target package name, offering a choice to the user if necessary + if len(packages) == 1: + return packages[0] + else: + warningPrint("\n[!] Multiple matching packages installed, select the package to patch.\n") + choice = -1 + while choice == -1: + for i in range(len(packages)): + print("[" + str(i + 1) + "] " + packages[i]) + choice = input("Choice: ") + if not choice.isnumeric() or int(choice) < 1 or int(choice) > len(packages): + print("Invalid choice.\n") + choice = -1 + return packages[int(choice) - 1] #################### # Get the APK path(s) on the device for the given package name. #################### -def getAPKPathsForPackage(pkgname): - print("Getting APK path(s) for package: " + pkgname) - paths = [] - proc = subprocess.run(["adb", "shell", "pm", "path", pkgname], stdout=subprocess.PIPE) - if proc.returncode != 0: - print("Error: Failed to run 'adb shell pm path " + pkgname + "'.") - sys.exit(1) - out = proc.stdout.decode("utf-8") - for line in out.split(os.linesep): - if line.startswith("package:"): - line = line[8:].strip() - print("[+] APK path: " + line) - paths.append(line) - print("") - return paths +def getAPKPathsForPackage(pkgname, current_user = "0", users_to_try = None): + print(f"\n[+] Retrieving APK path(s) for package: {pkgname} for user {current_user}") + paths = [] + proc = subprocess.run(["adb", "shell", "pm", "path", "--user", current_user, pkgname], stdout=subprocess.PIPE) + if proc.returncode != 0: + if not users_to_try: + proc = subprocess.run(["adb", "shell", "pm", "list", "users"], stdout=subprocess.PIPE) + out = proc.stdout.decode("utf-8") + + pattern = r'UserInfo{(\d+):' + users_to_try = re.findall(pattern, out) + + if current_user in users_to_try: + users_to_try.remove(current_user) + + if len(users_to_try) > 0: + warningPrint(f"[!] Package not found for user {current_user}, trying next user") + return getAPKPathsForPackage(pkgname, users_to_try[0], users_to_try) + else: + abort("Error: Failed to run 'adb shell pm path " + pkgname + "'.") + + out = proc.stdout.decode("utf-8") + + for line in out.split(os.linesep): + if line.startswith("package:"): + line = line[8:].strip() + verbosePrint("[+] APK path: " + line) + paths.append(line) + + return current_user, paths #################### # Pull the APK file(s) for the package and return the local file path to work with. # If the package is an app bundle/split APK, combine the APKs into a single APK. #################### -def getTargetAPK(pkgname, apkpaths, tmppath, disableStylesHack): - #Pull the APKs from the device - print("Pulling APK file(s) from device.") - localapks = [] - for remotepath in apkpaths: - baseapkname = remotepath.split('/')[-1] - localapks.append(os.path.join(tmppath, pkgname + "-" + baseapkname)) - print("[+] Pulling: " + pkgname + "-" + baseapkname) - ret = subprocess.run(["adb", "pull", remotepath, localapks[-1]], stdout=getStdout()) - if ret.returncode != 0: - print("Error: Failed to run 'adb pull " + remotepath + " " + localapks[-1] + "'.\nRun with --debug-output for more information.") - sys.exit(1) - print("") - - #Return the target APK path - if len(localapks) == 1: - return localapks[0] - else: - #Combine split APKs - return combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack) +def getTargetAPK(pkgname, apkpaths, tmppath, disableStylesHack, extract_only): + # Pull the APKs from the device + print("") + bar = Bar('[+] Pulling APK file(s) from device', max=len(apkpaths)) + verboseOutput = "" + + localapks = [] + for remotepath in apkpaths: + baseapkname = remotepath.split('/')[-1] + localapks.append(os.path.join(tmppath, pkgname + "-" + baseapkname)) + verboseOutput += f"[+] Pulled: {pkgname}-{baseapkname}\n" + bar.next() + # assertSubprocessSuccessfulRun(["adb", "pull", remotepath, localapks[-1]]) + assertSubprocessSuccessfulRun(["adb", "pull", remotepath, localapks[-1]] ) + + bar.finish() + verbosePrint(verboseOutput.rstrip()) + + # Return the target APK path + if len(localapks) == 1: + return localapks[0] + else: + # Combine split APKs + return combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack, extract_only) + +def verbosePrint(msg): + if getArgs().verbose: + for line in msg.split("\n"): + print(colored(" " + line, "light_grey")) #################### # Combine app bundles/split APKs into a single APK for patching. #################### -def combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack): - print("App bundle/split APK detected, rebuilding as a single APK.") - print("") - - #Extract the individual APKs - print("Extracting individual APKs with apktool.") - baseapkdir = os.path.join(tmppath, pkgname + "-base") - baseapkfilename = pkgname + "-base.apk" - splitapkpaths = [] - for apkpath in localapks: - print("[+] Extracting: " + apkpath) - apkdir = apkpath[:-4] - ret = runApkTool(["d", apkpath, "-o", apkdir]) - if ret.returncode != 0: - print("Error: Failed to run 'apktool d " + apkpath + " -o " + apkdir + "'.\nRun with --debug-output for more information.") - sys.exit(1) - - #Record the destination paths of all but the base APK - if apkpath.endswith("base.apk") == False: - splitapkpaths.append(apkdir) - - #Check for ProGuard/AndResGuard - this might b0rk decompile/recompile - if detectProGuard(apkdir): - print("\n[~] WARNING: Detected ProGuard/AndResGuard, decompile/recompile may not succeed.\n") - print("") - - #Walk the extracted APK directories and copy files and directories to the base APK - copySplitApkFiles(baseapkdir, splitapkpaths) - - #Fix public resource identifiers - fixPublicResourceIDs(baseapkdir, splitapkpaths) - - #Hack: Delete duplicate style resource entries. - if disableStylesHack == False: - hackRemoveDuplicateStyleEntries(baseapkdir) - - #Disable APK splitting in the base AndroidManifest.xml file - disableApkSplitting(baseapkdir) - - #Rebuild the base APK - print("Rebuilding as a single APK.") - if os.path.exists(os.path.join(baseapkdir, "res", "navigation")) == True: - print("[+] Found res/navigation directory, rebuilding with 'apktool --use-aapt2'.") - ret = runApkTool(["--use-aapt2", "b", baseapkdir]) - if ret.returncode != 0: - print("Error: Failed to run 'apktool b " + baseapkdir + "'.\nRun with --debug-output for more information.") - sys.exit(1) - elif getApktoolVersion() > pkg_resources.parse_version("2.4.2"): - print("[+] Found apktool version > 2.4.2, rebuilding with 'apktool --use-aapt2'.") - ret = runApkTool(["--use-aapt2", "b", baseapkdir]) - if ret.returncode != 0: - print("Error: Failed to run 'apktool b " + baseapkdir + "'.\nRun with --debug-output for more information.") - sys.exit(1) - else: - print("[+] Building APK with apktool.") - ret = runApkTool(["b", baseapkdir]) - if ret.returncode != 0: - print("Error: Failed to run 'apktool b " + baseapkdir + "'.\nRun with --debug-output for more information.") - sys.exit(1) - - - #Sign the new APK - print("[+] Signing new APK.") - ret = subprocess.run([ - "jarsigner", "-sigalg", "SHA1withRSA", "-digestalg", "SHA1", "-keystore", - os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore")), - "-storepass", "patch-apk", os.path.join(baseapkdir, "dist", baseapkfilename), "patch-apk-key"], - stdout=getStdout() - ) - if ret.returncode != 0: - print("Error: Failed to run 'jarsigner -sigalg SHA1withRSA -digestalg SHA1 -keystore " + - os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore")) + - "-storepass patch-apk " + os.path.join(baseapkdir, "dist", baseapkfilename) + " patch-apk-key'.\nRun with --debug-output for more information.") - sys.exit(1) - - - #Zip align the new APK - print("[+] Zip aligning new APK.") - ret = subprocess.run([ - "zipalign", "-f", "4", os.path.join(baseapkdir, "dist", baseapkfilename), - os.path.join(baseapkdir, "dist", baseapkfilename[:-4] + "-aligned.apk") - ], - stdout=getStdout() - ) - if ret.returncode != 0: - print("Error: Failed to run 'zipalign -f 4 " + os.path.join(baseapkdir, "dist", baseapkfilename) + - " " + os.path.join(baseapkdir, "dist", baseapkfilename[:-4] + "-aligned.apk") + "'.\nRun with --debug-output for more information.") - sys.exit(1) - shutil.move(os.path.join(baseapkdir, "dist", baseapkfilename[:-4] + "-aligned.apk"), os.path.join(baseapkdir, "dist", baseapkfilename)) - print("") - - #Return the new APK path - return os.path.join(baseapkdir, "dist", baseapkfilename) +def combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack, extract_only): + warningPrint("\n[!] App bundle/split APK detected, rebuilding as a single APK.") + + # Extract the individual APKs + baseapkdir = os.path.join(tmppath, pkgname + "-base") + baseapkfilename = pkgname + "-base.apk" + splitapkpaths = [] + + print("") + bar = Bar('[+] Disassembling split APKs', max=len(localapks)) + verboseOutput = "" + + for apkpath in localapks: + verboseOutput += "\nExtracted: " + apkpath + bar.next() + apkdir = apkpath[:-4] + ret = runApkTool(["d", apkpath, "-o", apkdir]) + if ret.returncode != 0: + abort("\nError: Failed to run 'apktool d " + apkpath + " -o " + apkdir + "'.\nRun with --debug-output for more information.") + + # Record the destination paths of all but the base APK + if not apkpath.endswith("base.apk"): + splitapkpaths.append(apkdir) + + # Check for ProGuard/AndResGuard - this might b0rk decompile/recompile + if detectProGuard(apkdir): + warningPrint("\n[!] WARNING: Detected ProGuard/AndResGuard, decompile/recompile may not succeed.\n") + + bar.finish() + + verbosePrint(verboseOutput) + + # Walk the extracted APK directories and copy files and directories to the base APK + print("\n[+] Rebuilding as a single APK") + copySplitApkFiles(baseapkdir, splitapkpaths) + + # Fix public resource identifiers + fixPublicResourceIDs(baseapkdir, splitapkpaths) + + # Hack: Delete duplicate style resource entries. + if not disableStylesHack: + hackRemoveDuplicateStyleEntries(baseapkdir) + + #Disable APK splitting in the base AndroidManifest.xml file + disableApkSplitting(baseapkdir) + + # Fix apktool bug where ampersands are improperly escaped: https://github.com/iBotPeaches/Apktool/issues/2703 + verbosePrint("[+] Fixing any improperly escaped ampersands.") + rawREReplace(os.path.join(baseapkdir, "res", "values", "strings.xml"), r'(&)([^;])', r'\1;\2') + + # Rebuild the base APK + build(baseapkdir) + + # Return the new APK path + return os.path.join(baseapkdir, "dist", baseapkfilename) + + #################### # Attempt to detect ProGuard/AndResGuard. #################### def detectProGuard(extractedPath): - if os.path.exists(os.path.join(extractedPath, "original", "META-INF", "proguard")) == True: - return True - if os.path.exists(os.path.join(extractedPath, "original", "META-INF", "MANIFEST.MF")) == True: - fh = open(os.path.join(extractedPath, "original", "META-INF", "MANIFEST.MF")) - d = fh.read() - fh.close() - if "proguard" in d.lower(): - return True - return False + if os.path.exists(os.path.join(extractedPath, "original", "META-INF", "proguard")): + return True + if os.path.exists(os.path.join(extractedPath, "original", "META-INF", "MANIFEST.MF")): + fh = open(os.path.join(extractedPath, "original", "META-INF", "MANIFEST.MF")) + d = fh.read() + fh.close() + if "proguard" in d.lower(): + return True + return False #################### # Copy files and directories from split APKs into the base APK directory. #################### def copySplitApkFiles(baseapkdir, splitapkpaths): - print("Copying files and directories from split APKs into base APK.") - for apkdir in splitapkpaths: - for (root, dirs, files) in os.walk(apkdir): - #Skip the original files directory - if root.startswith(os.path.join(apkdir, "original")) == False: - #Create any missing directories - for d in dirs: - #Translate directory path to base APK path and create the directory if it doesn't exist - p = baseapkdir + os.path.join(root, d)[len(apkdir):] - if os.path.exists(p) == False: - dbgPrint("[+] Creating directory in base APK: " + p[len(baseapkdir):]) - os.mkdir(p) - - #Copy files into the base APK - for f in files: - #Skip the AndroidManifest.xml and apktool.yml in the APK root directory - if apkdir == root and (f == "AndroidManifest.xml" or f == "apktool.yml"): - continue - - #Translate path to base APK - p = baseapkdir + os.path.join(root, f)[len(apkdir):] - - #Copy files into the base APK, except for XML files in the res directory - if f.lower().endswith(".xml") and p.startswith(os.path.join(baseapkdir, "res")): - continue - dbgPrint("[+] Moving file to base APK: " + p[len(baseapkdir):]) - shutil.move(os.path.join(root, f), p) - print("") + for apkdir in splitapkpaths: + for (root, dirs, files) in os.walk(apkdir): + # Skip the original files directory + if not root.startswith(os.path.join(apkdir, "original")): + # Create any missing directories + for d in dirs: + # Translate directory path to base APK path and create the directory if it doesn't exist + p = baseapkdir + os.path.join(root, d)[len(apkdir):] + if not os.path.exists(p): + dbgPrint("[+] Creating directory in base APK: " + p[len(baseapkdir):]) + os.mkdir(p) + + # Copy files into the base APK + for f in files: + # Skip the AndroidManifest.xml and apktool.yml in the APK root directory + if apkdir == root and (f == "AndroidManifest.xml" or f == "apktool.yml"): + continue + + # Translate path to base APK + p = baseapkdir + os.path.join(root, f)[len(apkdir):] + + # Copy files into the base APK, except for XML files in the res directory + if f.lower().endswith(".xml") and p.startswith(os.path.join(baseapkdir, "res")): + continue + dbgPrint("[+] Moving file to base APK: " + p[len(baseapkdir):]) + shutil.move(os.path.join(root, f), p) #################### # Fix public resource identifiers that are shared across split APKs. @@ -408,98 +458,108 @@ def copySplitApkFiles(baseapkdir, splitapkpaths): # resource names. #################### def fixPublicResourceIDs(baseapkdir, splitapkpaths): - #Bail if the base APK does not have a public.xml - if os.path.exists(os.path.join(baseapkdir, "res", "values", "public.xml")) == False: - return - print("Found public.xml in the base APK, fixing resource identifiers across split APKs.") - - #Mappings of resource IDs and names - idToDummyName = {} - dummyNameToRealName = {} - - #Step 1) Find all resource IDs that apktool has assigned a name of APKTOOL_DUMMY_XXX to. - # Load these into the lookup tables ready to resolve the real resource names from - # the split APKs in step 2 below. - baseXmlTree = xml.etree.ElementTree.parse(os.path.join(baseapkdir, "res", "values", "public.xml")) - for el in baseXmlTree.getroot(): - if "name" in el.attrib and "id" in el.attrib: - if el.attrib["name"].startswith("APKTOOL_DUMMY_") and el.attrib["name"] not in idToDummyName: - idToDummyName[el.attrib["id"]] = el.attrib["name"] - dummyNameToRealName[el.attrib["name"]] = None - print("[+] Resolving " + str(len(idToDummyName)) + " resource identifiers.") - - #Step 2) Parse the public.xml file from each split APK in search of resource IDs matching - # those loaded during step 1. Each match gives the true resource name allowing us to - # replace all APKTOOL_DUMMY_XXX resource names with the true resource names back in - # the base APK. - found = 0 - for splitdir in splitapkpaths: - if os.path.exists(os.path.join(splitdir, "res", "values", "public.xml")): - tree = xml.etree.ElementTree.parse(os.path.join(splitdir, "res", "values", "public.xml")) - for el in tree.getroot(): - if "name" in el.attrib and "id" in el.attrib: - if el.attrib["id"] in idToDummyName: - dummyNameToRealName[idToDummyName[el.attrib["id"]]] = el.attrib["name"] - found += 1 - print("[+] Located " + str(found) + " true resource names.") - - #Step 3) Update the base APK to replace all APKTOOL_DUMMY_XXX resource names with the true - # resource name. - updated = 0 - for el in baseXmlTree.getroot(): - if "name" in el.attrib and "id" in el.attrib: - if el.attrib["name"] in dummyNameToRealName and dummyNameToRealName[el.attrib["name"]] is not None: - el.attrib["name"] = dummyNameToRealName[el.attrib["name"]] - updated += 1 - baseXmlTree.write(os.path.join(baseapkdir, "res", "values", "public.xml"), encoding="utf-8", xml_declaration=True) - print("[+] Updated " + str(updated) + " dummy resource names with true names in the base APK.") - - #Step 4) Find all references to APKTOOL_DUMMY_XXX resources within other XML resource files - # in the base APK and update them to refer to the true resource name. - updated = 0 - for (root, dirs, files) in os.walk(os.path.join(baseapkdir, "res")): - for f in files: - if f.lower().endswith(".xml"): - try: - #Load the XML - dbgPrint("[~] Parsing " + os.path.join(root, f)) - tree = xml.etree.ElementTree.parse(os.path.join(root, f)) - - #Register the namespaces and get the prefix for the "android" namespace - namespaces = dict([node for _,node in xml.etree.ElementTree.iterparse(os.path.join(baseapkdir, "AndroidManifest.xml"), events=["start-ns"])]) - for ns in namespaces: - xml.etree.ElementTree.register_namespace(ns, namespaces[ns]) - ns = "{" + namespaces["android"] + "}" - - #Update references to APKTOOL_DUMMY_XXX resources - changed = False - for el in tree.iter(): - #Check for references to APKTOOL_DUMMY_XXX resources in attributes of this element - for attr in el.attrib: - val = el.attrib[attr] - if val.startswith("@") and "/" in val and val.split("/")[1].startswith("APKTOOL_DUMMY_") and dummyNameToRealName[val.split("/")[1]] is not None: - el.attrib[attr] = val.split("/")[0] + "/" + dummyNameToRealName[val.split("/")[1]] - updated += 1 - changed = True - elif val.startswith("APKTOOL_DUMMY_") and dummyNameToRealName[val] is not None: - el.attrib[attr] = dummyNameToRealName[val] - updated += 1 - changed = True - - #Check for references to APKTOOL_DUMMY_XXX resources in the element text - val = el.text - if val is not None and val.startswith("@") and "/" in val and val.split("/")[1].startswith("APKTOOL_DUMMY_") and dummyNameToRealName[val.split("/")[1]] is not None: - el.text = val.split("/")[0] + "/" + dummyNameToRealName[val.split("/")[1]] - updated += 1 - changed = True - - #Save the file if it was updated - if changed == True: - tree.write(os.path.join(root, f), encoding="utf-8", xml_declaration=True) - except xml.etree.ElementTree.ParseError: - print("[-] XML parse error in " + os.path.join(root, f) + ", skipping.") - print("[+] Updated " + str(updated) + " references to dummy resource names in the base APK.") - print("") + # Bail if the base APK does not have a public.xml + if not os.path.exists(os.path.join(baseapkdir, "res", "values", "public.xml")): + return + verbosePrint("\n[+] Found public.xml in the base APK, fixing resource identifiers across split APKs.") + + # Mappings of resource IDs and names + idToDummyName = {} + dummyNameToRealName = {} + + # Step 1) Find all resource IDs that apktool has assigned a name of APKTOOL_DUMMY_XXX to. + # Load these into the lookup tables ready to resolve the real resource names from + # the split APKs in step 2 below. + baseXmlTree = xml.etree.ElementTree.parse(os.path.join(baseapkdir, "res", "values", "public.xml")) + for el in baseXmlTree.getroot(): + if "name" in el.attrib and "id" in el.attrib: + if el.attrib["name"].startswith("APKTOOL_DUMMY_") and el.attrib["name"] not in idToDummyName: + idToDummyName[el.attrib["id"]] = el.attrib["name"] + dummyNameToRealName[el.attrib["name"]] = None + verbosePrint("[+] Resolving " + str(len(idToDummyName)) + " resource identifiers.") + + # Step 2) Parse the public.xml file from each split APK in search of resource IDs matching + # those loaded during step 1. Each match gives the true resource name allowing us to + # replace all APKTOOL_DUMMY_XXX resource names with the true resource names back in + # the base APK. + found = 0 + for splitdir in splitapkpaths: + if os.path.exists(os.path.join(splitdir, "res", "values", "public.xml")): + tree = xml.etree.ElementTree.parse(os.path.join(splitdir, "res", "values", "public.xml")) + for el in tree.getroot(): + if "name" in el.attrib and "id" in el.attrib: + if el.attrib["id"] in idToDummyName: + dummyNameToRealName[idToDummyName[el.attrib["id"]]] = el.attrib["name"] + found += 1 + verbosePrint("[+] Located " + str(found) + " true resource names.") + + # Step 3) Update the base APK to replace all APKTOOL_DUMMY_XXX resource names with the true + # resource name. + updated = 0 + for el in baseXmlTree.getroot(): + if "name" in el.attrib and "id" in el.attrib: + if el.attrib["name"] in dummyNameToRealName and dummyNameToRealName[el.attrib["name"]] is not None: + el.attrib["name"] = dummyNameToRealName[el.attrib["name"]] + updated += 1 + baseXmlTree.write(os.path.join(baseapkdir, "res", "values", "public.xml"), encoding="utf-8", xml_declaration=True) + verbosePrint("[+] Updated " + str(updated) + " dummy resource names with true names in the base APK.") + + # Step 4) Find all references to APKTOOL_DUMMY_XXX resources within other XML resource files + # in the base APK and update them to refer to the true resource name. + updated = 0 + for (root, dirs, files) in os.walk(os.path.join(baseapkdir, "res")): + for f in files: + if f.lower().endswith(".xml"): + try: + # Load the XML + xmlPath = os.path.join(root, f) + dbgPrint("[~] Parsing " + xmlPath) + tree = xml.etree.ElementTree.parse(xmlPath) + + # Register the namespaces and get the prefix for the "android" namespace + namespaces = dict([node for _,node in xml.etree.ElementTree.iterparse(os.path.join(baseapkdir, "AndroidManifest.xml"), events=["start-ns"])]) + for ns in namespaces: + xml.etree.ElementTree.register_namespace(ns, namespaces[ns]) + ns = "{" + namespaces["android"] + "}" + + # Update references to APKTOOL_DUMMY_XXX resources + changed = False + for el in tree.iter(): + # Check for references to APKTOOL_DUMMY_XXX resources in attributes of this element + for attr in el.attrib: + val = el.attrib[attr] + if val.startswith("@") and "/" in val and val.split("/")[1].startswith("APKTOOL_DUMMY_") and dummyNameToRealName[val.split("/")[1]] is not None: + el.attrib[attr] = val.split("/")[0] + "/" + dummyNameToRealName[val.split("/")[1]] + updated += 1 + changed = True + elif val.startswith("APKTOOL_DUMMY_") and dummyNameToRealName[val] is not None: + el.attrib[attr] = dummyNameToRealName[val] + updated += 1 + changed = True + + if changed: + dbgPrint("[~] Patching dummy apktool attribute \"" + attr + "\" value \"" + val + "\"" + (" -> \"" + el.attrib[attr] + "\"" if val != el.attrib[attr] else "") + " (" + str(updated) + ")") + + # Fix for untracked bug where drawables are decoded without drawable values (@null) + if f == "drawables.xml" and attr == "name" and el.text is None: + dbgPrint("[~] Patching null decoded drawable \"" + el.attrib[attr] + "\" (" + str(updated) + ")") + el.text = NULL_DECODED_DRAWABLE_COLOR + + # Check for references to APKTOOL_DUMMY_XXX resources in the element text + val = el.text + if val is not None and val.startswith("@") and "/" in val and val.split("/")[1].startswith("APKTOOL_DUMMY_") and dummyNameToRealName[val.split("/")[1]] is not None: + el.text = val.split("/")[0] + "/" + dummyNameToRealName[val.split("/")[1]] + updated += 1 + changed = True + dbgPrint("[~] Patching dummy apktool element \"" + el.get('name', el.tag) + "\" value \"" + val + (" -> \"" + el.text + "\"" if val != el.text else "") + str(updated) + ")") + + # Save the file if it was updated + if changed: + dbgPrint("[+] Writing patched " + f) + tree.write(os.path.join(root, f), encoding="utf-8", xml_declaration=True) + except xml.etree.ElementTree.ParseError: + print("[-] XML parse error in " + os.path.join(root, f) + ", skipping.") + verbosePrint("[+] Updated " + str(updated) + " references to dummy resource names in the base APK.") #################### # Hack to remove duplicate style resource entries before rebuilding. @@ -514,34 +574,33 @@ def fixPublicResourceIDs(baseapkdir, splitapkpaths): # This hack parses res/values/styles.xml, finds all offending elements, removes them, then saves the result. #################### def hackRemoveDuplicateStyleEntries(baseapkdir): - #Bail if there is no styles.xml - if os.path.exists(os.path.join(baseapkdir, "res", "values", "styles.xml")) == False: - return - print("Found styles.xml in the base APK, checking for duplicate