├── .gitignore ├── LICENSE ├── README.md ├── data └── patch-apk.keystore └── patch-apk.py /.gitignore: -------------------------------------------------------------------------------- 1 | patch-apk-dbg.py 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nicky Bloor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # patch-apk - App Bundle/Split APK Aware Patcher for Objection # 2 | An APK patcher, for use with [objection](https://github.com/sensepost/objection), that supports Android app bundles/split APKs. It automates the following: 3 | 4 | 1. Finding the full package name of an Android app. 5 | 2. Finding the APK path(s) and pulling them from the device. 6 | 3. Patching the APK(s) using `objection patchapk`. 7 | - Combining split APKs into a single APK where necessary. 8 | 4. Enabling support for user-installed CA certificates (e.g. Burp Suite's CA Cert). 9 | 5. Uninstalling the original app from the device. 10 | 6. Installing the patched app to the device, ready for use with objection. 11 | 12 | ### Changelog ### 13 | 14 | * **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)). 15 | * **28th April 2021:** Fixed a bug with `objection` version detection when the `objection version` command output an update notice. 16 | * **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. 17 | * **30th March 2020:** Fixed a bug where dummy resource IDs were assumed to all have true names. Added a hack to resolve an issue with duplicate entries in res/values/styles.xml after decompiling with apktool. 18 | * **29th March 2020:** Added `--save-apk` parameter to save a copy of the unpatched single APK for use with other tools. 19 | * **27th March 2020:** Initial release supporting split APKs and the `--no-enable-user-certs` flag. 20 | 21 | ## Usage ## 22 | Install the target Android application on your device and connect it to your computer/VM so that `adb devices` can see it, then run: 23 | 24 | ``` 25 | python3 patch-apk.py {package-name} 26 | ``` 27 | 28 | The package-name parameter can be the fully-qualified package name of the Android app, such as `com.google.android.youtube`, or a partial package name, such as `tube`. 29 | 30 | Along with injecting an instrumentation gadget, the script also automatically enables support for user-installed CA certificates by injecting a network security configuration file into the APK. To disable this functionality, pass the `--no-enable-user-certs` parameter on the command line. 31 | 32 | ### Examples ### 33 | **Basic usage:** Simply install the target Android app on your device, make sure `adb devices` can see your device, then pass the package name to `patch-apk.py`. 34 | 35 | ``` 36 | $ python3 patch-apk.py com.whatsapp 37 | Getting APK path(s) for package: com.whatsapp 38 | [+] APK path: /data/app/com.whatsapp-NKLgchoExRFTDLkkbDqBGg==/base.apk 39 | 40 | Pulling APK file(s) from device. 41 | [+] Pulling: com.whatsapp-base.apk 42 | 43 | Patching com.whatsapp-base.apk with objection. 44 | 45 | Patching APK to enable support for user-installed CA certificates. 46 | 47 | Uninstalling the original package from the device. 48 | 49 | Installing the patched APK to the device. 50 | 51 | Done, cleaning up temporary files. 52 | ``` 53 | 54 | When `patch-apk.py` is done, the installed app should be patched with objection and have support for user-installed CA certificates enabled. Launch the app on the device and run `objection explore` as you normally would to connect to the agent. 55 | 56 | **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. 57 | 58 | ``` 59 | $ python3 patch-apk.py ovid 60 | Multiple matching packages installed, select the package to patch. 61 | [1] com.android.providers.telephony 62 | [2] com.android.providers.calendar 63 | [3] com.android.providers.media 64 | [4] com.android.providers.downloads 65 | [5] com.android.providers.downloads.ui 66 | [6] com.android.providers.settings 67 | [7] com.android.providers.partnerbookmarks 68 | [8] com.android.bookmarkprovider 69 | [9] com.android.providers.blockednumber 70 | [10] com.android.providers.userdictionary 71 | [11] com.joinzoe.covid_zoe 72 | [12] com.android.providers.contacts 73 | Choice: 74 | ``` 75 | 76 | **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. 77 | 78 | ``` 79 | $ adb shell pm path com.joinzoe.covid_zoe 80 | package:/data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/base.apk 81 | package:/data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.arm64_v8a.apk 82 | package:/data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.en.apk 83 | package:/data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.xxhdpi.apk 84 | ``` 85 | 86 | The following shows `patch-apk.py` detecting, rebuilding, and patching a split APK. Some output has been snipped for brevity. 87 | 88 | ``` 89 | $ python3 patch-apk.py covid 90 | Getting APK path(s) for package: com.joinzoe.covid_zoe 91 | [+] APK path: /data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/base.apk 92 | [+] APK path: /data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.arm64_v8a.apk 93 | [+] APK path: /data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.en.apk 94 | [+] APK path: /data/app/com.joinzoe.covid_zoe-vck7Y7NlVGutCaaAbonakw==/split_config.xxhdpi.apk 95 | 96 | Pulling APK file(s) from device. 97 | [+] Pulling: com.joinzoe.covid_zoe-base.apk 98 | [+] Pulling: com.joinzoe.covid_zoe-split_config.arm64_v8a.apk 99 | [+] Pulling: com.joinzoe.covid_zoe-split_config.en.apk 100 | [+] Pulling: com.joinzoe.covid_zoe-split_config.xxhdpi.apk 101 | 102 | App bundle/split APK detected, rebuilding as a single APK. 103 | 104 | Extracting individual APKs with apktool. 105 | [+] Extracting: /tmp/tmp1kir74u_/com.joinzoe.covid_zoe-base.apk 106 | [+] Extracting: /tmp/tmp1kir74u_/com.joinzoe.covid_zoe-split_config.arm64_v8a.apk 107 | [+] Extracting: /tmp/tmp1kir74u_/com.joinzoe.covid_zoe-split_config.en.apk 108 | [+] Extracting: /tmp/tmp1kir74u_/com.joinzoe.covid_zoe-split_config.xxhdpi.apk 109 | 110 | Copying files and directories from split APKs into base APK. 111 | [+] Creating directory in base APK: /lib 112 | [+] Creating directory in base APK: /lib/arm64-v8a 113 | [+] Moving file to base APK: /lib/arm64-v8a/libfb.so 114 | ... 115 | [+] Moving file to base APK: /res/drawable-xxxhdpi/shell_launch_background_image.png 116 | 117 | Found public.xml in the base APK, fixing resource identifiers across split APKs. 118 | [+] Resolving 83 resource identifiers. 119 | [+] Located 83 true resource names. 120 | [+] Updated 83 dummy resource names with true names in the base APK. 121 | [+] Updated 164 references to dummy resource names in the base APK. 122 | 123 | Disabling APK splitting in AndroidManifest.xml of base APK. 124 | 125 | Rebuilding as a single APK. 126 | [+] Building APK with apktool. 127 | [+] Signing new APK. 128 | [+] Zip aligning new APK. 129 | 130 | Patching com.joinzoe.covid_zoe-base.apk with objection. 131 | 132 | Patching APK to enable support for user-installed CA certificates. 133 | 134 | Uninstalling the original package from the device. 135 | 136 | Installing the patched APK to the device. 137 | 138 | Done, cleaning up temporary files. 139 | ``` 140 | 141 | 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. 142 | 143 | ``` 144 | $ adb shell pm path com.joinzoe.covid_zoe 145 | package:/data/app/com.joinzoe.covid_zoe-9NuZnT-lK3qM_IZQEHhTgA==/base.apk 146 | ``` 147 | 148 | ## Combining Split APKs ## 149 | 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: 150 | 151 | ``` 152 | $ adb shell pm path com.joinzoe.covid_zoe 153 | package:/data/app/com.joinzoe.covid_zoe-NW8ZbgI5VPzvSZ1NgMa4CQ==/base.apk 154 | package:/data/app/com.joinzoe.covid_zoe-NW8ZbgI5VPzvSZ1NgMa4CQ==/split_config.arm64_v8a.apk 155 | package:/data/app/com.joinzoe.covid_zoe-NW8ZbgI5VPzvSZ1NgMa4CQ==/split_config.en.apk 156 | package:/data/app/com.joinzoe.covid_zoe-NW8ZbgI5VPzvSZ1NgMa4CQ==/split_config.xxhdpi.apk 157 | ``` 158 | 159 | 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: 160 | 161 | **Step 1 - Extract APKs:** First, the individual APK files are pulled from the device and extracted using `apktool`. 162 | 163 | **Step 2 - Combine Files:** Next, we walk the directory trees of all but `base.apk`, and move files and directories from the split APKs into the base APK. 164 | 165 | **Step 3 - Fix Resource Identifiers:** Some resource names might only be defined in one of the split APKs, so we need to gather these up and update `base.apk` with the correct resource names. 166 | 167 | **Step 4 - Disable Splitting:** The `AndroidManifest.xml` in `base.apk` is updated to disable support for splitting before rebuilding, signing, and zip aligning the APK. 168 | 169 | More details can be found on [my blog](https://nickbloor.co.uk/2020/03/29/patching-android-split-apks/). 170 | -------------------------------------------------------------------------------- /data/patch-apk.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NickstaDB/patch-apk/24c64576afaedca7b10846d132a7d1ecd5b632b1/data/patch-apk.keystore -------------------------------------------------------------------------------- /patch-apk.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse 3 | import os 4 | import pkg_resources 5 | import shutil 6 | import subprocess 7 | import sys 8 | import tempfile 9 | import xml.etree.ElementTree 10 | 11 | #################### 12 | # Main() 13 | #################### 14 | def main(): 15 | #Check that dependencies are available 16 | checkDependencies() 17 | 18 | #Grab argz 19 | args = getArgs() 20 | 21 | #Verify the package name and ensure it's installed (also supports partial package names) 22 | pkgname = verifyPackageName(args.pkgname) 23 | 24 | #Get the APK path(s) from the device 25 | apkpaths = getAPKPathsForPackage(pkgname) 26 | 27 | #Create a temp directory to work from 28 | with tempfile.TemporaryDirectory() as tmppath: 29 | #Get the APK to patch. Combine app bundles/split APKs into a single APK. 30 | apkfile = getTargetAPK(pkgname, apkpaths, tmppath, args.disable_styles_hack) 31 | 32 | #Save the APK if requested 33 | if args.save_apk is not None: 34 | print("Saving a copy of the APK to " + args.save_apk) 35 | print("") 36 | shutil.copy(apkfile, args.save_apk) 37 | 38 | #Patch the target APK with objection 39 | print("Patching " + apkfile.split(os.sep)[-1] + " with objection.") 40 | ret = None 41 | if getObjectionVersion() >= pkg_resources.parse_version("1.9.3"): 42 | ret = subprocess.run(["objection", "patchapk", "--skip-resources", "--ignore-nativelibs", "-s", apkfile], stdout=getStdout()) 43 | else: 44 | ret = subprocess.run(["objection", "patchapk", "--skip-resources", "-s", apkfile], stdout=getStdout()) 45 | if ret.returncode != 0: 46 | print("Error: Failed to run 'objection patchapk --skip-resources -s " + apkfile + "'.\nRun with --debug-output for more information.") 47 | sys.exit(1) 48 | os.remove(apkfile) 49 | shutil.move(apkfile[:-4] + ".objection.apk", apkfile) 50 | print("") 51 | 52 | #Enable support for user-installed CA certs (e.g. Burp Suite CA installed on device by user) 53 | if args.no_enable_user_certs == False: 54 | enableUserCerts(apkfile) 55 | 56 | #Uninstall the original package from the device 57 | print("Uninstalling the original package from the device.") 58 | ret = subprocess.run(["adb", "uninstall", pkgname], stdout=getStdout()) 59 | if ret.returncode != 0: 60 | print("Error: Failed to run 'adb uninstall " + pkgname + "'.\nRun with --debug-output for more information.") 61 | sys.exit(1) 62 | print("") 63 | 64 | #Install the patched APK 65 | print("Installing the patched APK to the device.") 66 | ret = subprocess.run(["adb", "install", apkfile], stdout=getStdout()) 67 | if ret.returncode != 0: 68 | print("Error: Failed to run 'adb install " + apkfile + "'.\nRun with --debug-output for more information.") 69 | sys.exit(1) 70 | print("") 71 | 72 | #Done 73 | print("Done, cleaning up temporary files.") 74 | 75 | #################### 76 | # Check that required dependencies are present: 77 | # -> Tools used 78 | # -> Android device connected 79 | # -> Keystore 80 | #################### 81 | def checkDependencies(): 82 | deps = ["adb", "apktool", "jarsigner", "objection", "zipalign"] 83 | missing = [] 84 | for dep in deps: 85 | if shutil.which(dep) is None: 86 | missing.append(dep) 87 | if len(missing) > 0: 88 | print("Error, missing dependencies, ensure the following commands are available on the PATH: " + (", ".join(missing))) 89 | sys.exit(1) 90 | 91 | #Verify that an Android device is connected 92 | proc = subprocess.run(["adb", "devices"], stdout=subprocess.PIPE) 93 | if proc.returncode != 0: 94 | print("Error: Failed to run 'adb devices'.") 95 | sys.exit(1) 96 | deviceOut = proc.stdout.decode("utf-8") 97 | if len(deviceOut.strip().split(os.linesep)) == 1: 98 | print("Error, no Android device connected (\"adb devices\"), connect a device first.") 99 | sys.exit(1) 100 | 101 | #Check that the included keystore exists 102 | if os.path.exists(os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore"))) == False: 103 | 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.") 104 | sys.exit(1) 105 | 106 | #################### 107 | # Grab command line parameters 108 | #################### 109 | def getArgs(): 110 | #Only parse args once 111 | if not hasattr(getArgs, "parsed_args"): 112 | #Parse the command line 113 | parser = argparse.ArgumentParser( 114 | description="patch-apk - Pull and patch Android apps for use with objection/frida." 115 | ) 116 | 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") 117 | parser.add_argument("--save-apk", help="Save a copy of the APK (or single APK) prior to patching for use with other tools.") 118 | parser.add_argument("--disable-styles-hack", help="Disable the styles hack that removes duplicate entries from res/values/styles.xml.", action="store_true") 119 | parser.add_argument("--debug-output", help="Enable debug output.", action="store_true") 120 | parser.add_argument("pkgname", help="The name, or partial name, of the package to patch (e.g. com.foo.bar).") 121 | 122 | #Store the parsed args 123 | getArgs.parsed_args = parser.parse_args() 124 | 125 | #Return the parsed command line args 126 | return getArgs.parsed_args 127 | 128 | #################### 129 | # Debug print 130 | #################### 131 | def dbgPrint(msg): 132 | if getArgs().debug_output == True: 133 | print(msg) 134 | 135 | #################### 136 | # Get the stdout target for subprocess calls. Set to DEVNULL unless debug output is enabled. 137 | #################### 138 | def getStdout(): 139 | if getArgs().debug_output == True: 140 | return None 141 | else: 142 | return subprocess.DEVNULL 143 | 144 | #################### 145 | # Get objection version 146 | #################### 147 | def getObjectionVersion(): 148 | proc = subprocess.run(["objection", "version"], stdout=subprocess.PIPE) 149 | return pkg_resources.parse_version(proc.stdout.decode("utf-8").strip().split(": ")[-1].strip()) 150 | 151 | #################### 152 | # Get apktool version 153 | #################### 154 | def getApktoolVersion(): 155 | output = "" 156 | if os.name == "nt": 157 | proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 158 | proc.communicate(b"\r\n") 159 | output = proc.stdout.decode("utf-8").strip() 160 | else: 161 | proc = subprocess.run(["apktool", "-version"], stdout=subprocess.PIPE) 162 | output = proc.stdout.decode("utf-8").strip() 163 | return pkg_resources.parse_version(output.split("-")[0].strip()) 164 | 165 | #################### 166 | # Wrapper to run apktool platform-independently, complete with a dirty hack to fix apktool's dirty hack. 167 | #################### 168 | def runApkTool(params): 169 | if os.name == "nt": 170 | args = ["apktool.bat"] 171 | args.extend(params) 172 | 173 | #apktool.bat has a dirty hack that execute "pause", so we need a dirty hack to kill the pause command... 174 | proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=getStdout()) 175 | proc.communicate(b"\r\n") 176 | return proc 177 | else: 178 | args = ["apktool"] 179 | args.extend(params) 180 | return subprocess.run(args, stdout=getStdout()) 181 | 182 | #################### 183 | # Verify the package name - checks whether the target package is installed 184 | # on the device or if an exact match is not found presents the options to 185 | # the user for selection. 186 | #################### 187 | def verifyPackageName(pkgname): 188 | #Get a list of installed packages matching the given name 189 | packages = [] 190 | proc = subprocess.run(["adb", "shell", "pm", "list", "packages"], stdout=subprocess.PIPE) 191 | if proc.returncode != 0: 192 | print("Error: Failed to run 'adb shell pm list packages'.") 193 | sys.exit(1) 194 | out = proc.stdout.decode("utf-8") 195 | for line in out.split(os.linesep): 196 | if line.startswith("package:"): 197 | line = line[8:].strip() 198 | if pkgname.lower() in line.lower(): 199 | packages.append(line) 200 | 201 | #Bail out if no matching packages were found 202 | if len(packages) == 0: 203 | print("Error, no packages found on the device matching the search term '" + pkgname + "'.") 204 | print("Run 'adb shell pm list packages' to verify installed package names.") 205 | sys.exit(1) 206 | 207 | #Return the target package name, offering a choice to the user if necessary 208 | if len(packages) == 1: 209 | return packages[0] 210 | else: 211 | print("Multiple matching packages installed, select the package to patch.") 212 | choice = -1 213 | while choice == -1: 214 | for i in range(len(packages)): 215 | print("[" + str(i + 1) + "] " + packages[i]) 216 | choice = input("Choice: ") 217 | if choice.isnumeric() == False or int(choice) < 1 or int(choice) > len(packages): 218 | print("Invalid choice.\n") 219 | choice = -1 220 | print("") 221 | return packages[int(choice) - 1] 222 | 223 | #################### 224 | # Get the APK path(s) on the device for the given package name. 225 | #################### 226 | def getAPKPathsForPackage(pkgname): 227 | print("Getting APK path(s) for package: " + pkgname) 228 | paths = [] 229 | proc = subprocess.run(["adb", "shell", "pm", "path", pkgname], stdout=subprocess.PIPE) 230 | if proc.returncode != 0: 231 | print("Error: Failed to run 'adb shell pm path " + pkgname + "'.") 232 | sys.exit(1) 233 | out = proc.stdout.decode("utf-8") 234 | for line in out.split(os.linesep): 235 | if line.startswith("package:"): 236 | line = line[8:].strip() 237 | print("[+] APK path: " + line) 238 | paths.append(line) 239 | print("") 240 | return paths 241 | 242 | #################### 243 | # Pull the APK file(s) for the package and return the local file path to work with. 244 | # If the package is an app bundle/split APK, combine the APKs into a single APK. 245 | #################### 246 | def getTargetAPK(pkgname, apkpaths, tmppath, disableStylesHack): 247 | #Pull the APKs from the device 248 | print("Pulling APK file(s) from device.") 249 | localapks = [] 250 | for remotepath in apkpaths: 251 | baseapkname = remotepath.split('/')[-1] 252 | localapks.append(os.path.join(tmppath, pkgname + "-" + baseapkname)) 253 | print("[+] Pulling: " + pkgname + "-" + baseapkname) 254 | ret = subprocess.run(["adb", "pull", remotepath, localapks[-1]], stdout=getStdout()) 255 | if ret.returncode != 0: 256 | print("Error: Failed to run 'adb pull " + remotepath + " " + localapks[-1] + "'.\nRun with --debug-output for more information.") 257 | sys.exit(1) 258 | print("") 259 | 260 | #Return the target APK path 261 | if len(localapks) == 1: 262 | return localapks[0] 263 | else: 264 | #Combine split APKs 265 | return combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack) 266 | 267 | #################### 268 | # Combine app bundles/split APKs into a single APK for patching. 269 | #################### 270 | def combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack): 271 | print("App bundle/split APK detected, rebuilding as a single APK.") 272 | print("") 273 | 274 | #Extract the individual APKs 275 | print("Extracting individual APKs with apktool.") 276 | baseapkdir = os.path.join(tmppath, pkgname + "-base") 277 | baseapkfilename = pkgname + "-base.apk" 278 | splitapkpaths = [] 279 | for apkpath in localapks: 280 | print("[+] Extracting: " + apkpath) 281 | apkdir = apkpath[:-4] 282 | ret = runApkTool(["d", apkpath, "-o", apkdir]) 283 | if ret.returncode != 0: 284 | print("Error: Failed to run 'apktool d " + apkpath + " -o " + apkdir + "'.\nRun with --debug-output for more information.") 285 | sys.exit(1) 286 | 287 | #Record the destination paths of all but the base APK 288 | if apkpath.endswith("base.apk") == False: 289 | splitapkpaths.append(apkdir) 290 | 291 | #Check for ProGuard/AndResGuard - this might b0rk decompile/recompile 292 | if detectProGuard(apkdir): 293 | print("\n[~] WARNING: Detected ProGuard/AndResGuard, decompile/recompile may not succeed.\n") 294 | print("") 295 | 296 | #Walk the extracted APK directories and copy files and directories to the base APK 297 | copySplitApkFiles(baseapkdir, splitapkpaths) 298 | 299 | #Fix public resource identifiers 300 | fixPublicResourceIDs(baseapkdir, splitapkpaths) 301 | 302 | #Hack: Delete duplicate style resource entries. 303 | if disableStylesHack == False: 304 | hackRemoveDuplicateStyleEntries(baseapkdir) 305 | 306 | #Disable APK splitting in the base AndroidManifest.xml file 307 | disableApkSplitting(baseapkdir) 308 | 309 | #Rebuild the base APK 310 | print("Rebuilding as a single APK.") 311 | if os.path.exists(os.path.join(baseapkdir, "res", "navigation")) == True: 312 | print("[+] Found res/navigation directory, rebuilding with 'apktool --use-aapt2'.") 313 | ret = runApkTool(["--use-aapt2", "b", baseapkdir]) 314 | if ret.returncode != 0: 315 | print("Error: Failed to run 'apktool b " + baseapkdir + "'.\nRun with --debug-output for more information.") 316 | sys.exit(1) 317 | elif getApktoolVersion() > pkg_resources.parse_version("2.4.2"): 318 | print("[+] Found apktool version > 2.4.2, rebuilding with 'apktool --use-aapt2'.") 319 | ret = runApkTool(["--use-aapt2", "b", baseapkdir]) 320 | if ret.returncode != 0: 321 | print("Error: Failed to run 'apktool b " + baseapkdir + "'.\nRun with --debug-output for more information.") 322 | sys.exit(1) 323 | else: 324 | print("[+] Building APK with apktool.") 325 | ret = runApkTool(["b", baseapkdir]) 326 | if ret.returncode != 0: 327 | print("Error: Failed to run 'apktool b " + baseapkdir + "'.\nRun with --debug-output for more information.") 328 | sys.exit(1) 329 | 330 | 331 | #Sign the new APK 332 | print("[+] Signing new APK.") 333 | ret = subprocess.run([ 334 | "jarsigner", "-sigalg", "SHA1withRSA", "-digestalg", "SHA1", "-keystore", 335 | os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore")), 336 | "-storepass", "patch-apk", os.path.join(baseapkdir, "dist", baseapkfilename), "patch-apk-key"], 337 | stdout=getStdout() 338 | ) 339 | if ret.returncode != 0: 340 | print("Error: Failed to run 'jarsigner -sigalg SHA1withRSA -digestalg SHA1 -keystore " + 341 | os.path.realpath(os.path.join(os.path.realpath(__file__), "..", "data", "patch-apk.keystore")) + 342 | "-storepass patch-apk " + os.path.join(baseapkdir, "dist", baseapkfilename) + " patch-apk-key'.\nRun with --debug-output for more information.") 343 | sys.exit(1) 344 | 345 | 346 | #Zip align the new APK 347 | print("[+] Zip aligning new APK.") 348 | ret = subprocess.run([ 349 | "zipalign", "-f", "4", os.path.join(baseapkdir, "dist", baseapkfilename), 350 | os.path.join(baseapkdir, "dist", baseapkfilename[:-4] + "-aligned.apk") 351 | ], 352 | stdout=getStdout() 353 | ) 354 | if ret.returncode != 0: 355 | print("Error: Failed to run 'zipalign -f 4 " + os.path.join(baseapkdir, "dist", baseapkfilename) + 356 | " " + os.path.join(baseapkdir, "dist", baseapkfilename[:-4] + "-aligned.apk") + "'.\nRun with --debug-output for more information.") 357 | sys.exit(1) 358 | shutil.move(os.path.join(baseapkdir, "dist", baseapkfilename[:-4] + "-aligned.apk"), os.path.join(baseapkdir, "dist", baseapkfilename)) 359 | print("") 360 | 361 | #Return the new APK path 362 | return os.path.join(baseapkdir, "dist", baseapkfilename) 363 | 364 | #################### 365 | # Attempt to detect ProGuard/AndResGuard. 366 | #################### 367 | def detectProGuard(extractedPath): 368 | if os.path.exists(os.path.join(extractedPath, "original", "META-INF", "proguard")) == True: 369 | return True 370 | if os.path.exists(os.path.join(extractedPath, "original", "META-INF", "MANIFEST.MF")) == True: 371 | fh = open(os.path.join(extractedPath, "original", "META-INF", "MANIFEST.MF")) 372 | d = fh.read() 373 | fh.close() 374 | if "proguard" in d.lower(): 375 | return True 376 | return False 377 | 378 | #################### 379 | # Copy files and directories from split APKs into the base APK directory. 380 | #################### 381 | def copySplitApkFiles(baseapkdir, splitapkpaths): 382 | print("Copying files and directories from split APKs into base APK.") 383 | for apkdir in splitapkpaths: 384 | for (root, dirs, files) in os.walk(apkdir): 385 | #Skip the original files directory 386 | if root.startswith(os.path.join(apkdir, "original")) == False: 387 | #Create any missing directories 388 | for d in dirs: 389 | #Translate directory path to base APK path and create the directory if it doesn't exist 390 | p = baseapkdir + os.path.join(root, d)[len(apkdir):] 391 | if os.path.exists(p) == False: 392 | dbgPrint("[+] Creating directory in base APK: " + p[len(baseapkdir):]) 393 | os.mkdir(p) 394 | 395 | #Copy files into the base APK 396 | for f in files: 397 | #Skip the AndroidManifest.xml and apktool.yml in the APK root directory 398 | if apkdir == root and (f == "AndroidManifest.xml" or f == "apktool.yml"): 399 | continue 400 | 401 | #Translate path to base APK 402 | p = baseapkdir + os.path.join(root, f)[len(apkdir):] 403 | 404 | #Copy files into the base APK, except for XML files in the res directory 405 | if f.lower().endswith(".xml") and p.startswith(os.path.join(baseapkdir, "res")): 406 | continue 407 | dbgPrint("[+] Moving file to base APK: " + p[len(baseapkdir):]) 408 | shutil.move(os.path.join(root, f), p) 409 | print("") 410 | 411 | #################### 412 | # Fix public resource identifiers that are shared across split APKs. 413 | # Maps all APKTOOL_DUMMY_ resource IDs in the base APK to the proper resource names from the 414 | # split APKs, then updates references in other resource files in the base APK to use proper 415 | # resource names. 416 | #################### 417 | def fixPublicResourceIDs(baseapkdir, splitapkpaths): 418 | #Bail if the base APK does not have a public.xml 419 | if os.path.exists(os.path.join(baseapkdir, "res", "values", "public.xml")) == False: 420 | return 421 | print("Found public.xml in the base APK, fixing resource identifiers across split APKs.") 422 | 423 | #Mappings of resource IDs and names 424 | idToDummyName = {} 425 | dummyNameToRealName = {} 426 | 427 | #Step 1) Find all resource IDs that apktool has assigned a name of APKTOOL_DUMMY_XXX to. 428 | # Load these into the lookup tables ready to resolve the real resource names from 429 | # the split APKs in step 2 below. 430 | baseXmlTree = xml.etree.ElementTree.parse(os.path.join(baseapkdir, "res", "values", "public.xml")) 431 | for el in baseXmlTree.getroot(): 432 | if "name" in el.attrib and "id" in el.attrib: 433 | if el.attrib["name"].startswith("APKTOOL_DUMMY_") and el.attrib["name"] not in idToDummyName: 434 | idToDummyName[el.attrib["id"]] = el.attrib["name"] 435 | dummyNameToRealName[el.attrib["name"]] = None 436 | print("[+] Resolving " + str(len(idToDummyName)) + " resource identifiers.") 437 | 438 | #Step 2) Parse the public.xml file from each split APK in search of resource IDs matching 439 | # those loaded during step 1. Each match gives the true resource name allowing us to 440 | # replace all APKTOOL_DUMMY_XXX resource names with the true resource names back in 441 | # the base APK. 442 | found = 0 443 | for splitdir in splitapkpaths: 444 | if os.path.exists(os.path.join(splitdir, "res", "values", "public.xml")): 445 | tree = xml.etree.ElementTree.parse(os.path.join(splitdir, "res", "values", "public.xml")) 446 | for el in tree.getroot(): 447 | if "name" in el.attrib and "id" in el.attrib: 448 | if el.attrib["id"] in idToDummyName: 449 | dummyNameToRealName[idToDummyName[el.attrib["id"]]] = el.attrib["name"] 450 | found += 1 451 | print("[+] Located " + str(found) + " true resource names.") 452 | 453 | #Step 3) Update the base APK to replace all APKTOOL_DUMMY_XXX resource names with the true 454 | # resource name. 455 | updated = 0 456 | for el in baseXmlTree.getroot(): 457 | if "name" in el.attrib and "id" in el.attrib: 458 | if el.attrib["name"] in dummyNameToRealName and dummyNameToRealName[el.attrib["name"]] is not None: 459 | el.attrib["name"] = dummyNameToRealName[el.attrib["name"]] 460 | updated += 1 461 | baseXmlTree.write(os.path.join(baseapkdir, "res", "values", "public.xml"), encoding="utf-8", xml_declaration=True) 462 | print("[+] Updated " + str(updated) + " dummy resource names with true names in the base APK.") 463 | 464 | #Step 4) Find all references to APKTOOL_DUMMY_XXX resources within other XML resource files 465 | # in the base APK and update them to refer to the true resource name. 466 | updated = 0 467 | for (root, dirs, files) in os.walk(os.path.join(baseapkdir, "res")): 468 | for f in files: 469 | if f.lower().endswith(".xml"): 470 | try: 471 | #Load the XML 472 | dbgPrint("[~] Parsing " + os.path.join(root, f)) 473 | tree = xml.etree.ElementTree.parse(os.path.join(root, f)) 474 | 475 | #Register the namespaces and get the prefix for the "android" namespace 476 | namespaces = dict([node for _,node in xml.etree.ElementTree.iterparse(os.path.join(baseapkdir, "AndroidManifest.xml"), events=["start-ns"])]) 477 | for ns in namespaces: 478 | xml.etree.ElementTree.register_namespace(ns, namespaces[ns]) 479 | ns = "{" + namespaces["android"] + "}" 480 | 481 | #Update references to APKTOOL_DUMMY_XXX resources 482 | changed = False 483 | for el in tree.iter(): 484 | #Check for references to APKTOOL_DUMMY_XXX resources in attributes of this element 485 | for attr in el.attrib: 486 | val = el.attrib[attr] 487 | if val.startswith("@") and "/" in val and val.split("/")[1].startswith("APKTOOL_DUMMY_") and dummyNameToRealName[val.split("/")[1]] is not None: 488 | el.attrib[attr] = val.split("/")[0] + "/" + dummyNameToRealName[val.split("/")[1]] 489 | updated += 1 490 | changed = True 491 | elif val.startswith("APKTOOL_DUMMY_") and dummyNameToRealName[val] is not None: 492 | el.attrib[attr] = dummyNameToRealName[val] 493 | updated += 1 494 | changed = True 495 | 496 | #Check for references to APKTOOL_DUMMY_XXX resources in the element text 497 | val = el.text 498 | 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: 499 | el.text = val.split("/")[0] + "/" + dummyNameToRealName[val.split("/")[1]] 500 | updated += 1 501 | changed = True 502 | 503 | #Save the file if it was updated 504 | if changed == True: 505 | tree.write(os.path.join(root, f), encoding="utf-8", xml_declaration=True) 506 | except xml.etree.ElementTree.ParseError: 507 | print("[-] XML parse error in " + os.path.join(root, f) + ", skipping.") 508 | print("[+] Updated " + str(updated) + " references to dummy resource names in the base APK.") 509 | print("") 510 | 511 | #################### 512 | # Hack to remove duplicate style resource entries before rebuilding. 513 | # 514 | # Possibly a bug in apktool affecting the Uber app (com.ubercab) 515 | # -> res/values/styles.xml has