lqg 2 years ago
parent
commit
beeb3751c5
5 changed files with 658 additions and 0 deletions
  1. 1 0
      .gitignore
  2. 12 0
      main.py
  3. 0 0
      patch_apk/__init__.py
  4. 645 0
      patch_apk/patch_apk.py
  5. 0 0
      requirements.txt

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+patch_apk/

+ 12 - 0
main.py

@@ -0,0 +1,12 @@
+#!/usr/bin/env python
+# -*- encoding: utf-8 -*-
+'''
+@Contact :   liuyuqi.gov@msn.cn
+@Time    :   2022/11/23 05:27:23
+@License :   Copyright © 2017-2022 liuyuqi. All Rights Reserved.
+@Desc    :   
+'''
+from patch_apk.patch_apk import main
+
+if __name__=='__main__':
+    main()

+ 0 - 0
patch_apk/__init__.py


+ 645 - 0
patch_apk/patch_apk.py

@@ -0,0 +1,645 @@
+#!/usr/bin/env python
+# -*- encoding: utf-8 -*-
+'''
+@Contact :   liuyuqi.gov@msn.cn
+@Time    :   2022/11/23 05:24:06
+@License :   Copyright © 2017-2022 liuyuqi. All Rights Reserved.
+@Desc    :   
+'''
+import argparse
+import os
+import pkg_resources
+import shutil
+import subprocess
+import sys
+import tempfile
+import xml.etree.ElementTree
+
+def main():
+	checkDependencies()
+	args = getArgs()
+	pkgname = verifyPackageName(args.pkgname)
+	apkpaths = getAPKPathsForPackage(pkgname)
+	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.")
+
+####################
+# 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)
+
+####################
+# 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
+
+####################
+# Debug print
+####################
+def dbgPrint(msg):
+	if getArgs().debug_output == True:
+		print(msg)
+
+####################
+# Get the stdout target for subprocess calls. Set to DEVNULL unless debug output is enabled.
+####################
+def getStdout():
+	if getArgs().debug_output == True:
+		return None
+	else:
+		return subprocess.DEVNULL
+
+####################
+# Get objection version
+####################
+def getObjectionVersion():
+	proc = subprocess.run(["objection", "version"], stdout=subprocess.PIPE)
+	return pkg_resources.parse_version(proc.stdout.decode("utf-8").strip().split(": ")[-1].strip())
+
+####################
+# Get apktool version
+####################
+def getApktoolVersion():
+	output = ""
+	if os.name == "nt":
+		proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
+		proc.communicate(b"\r\n")
+		output = proc.stdout.decode("utf-8").strip()
+	else:
+		proc = subprocess.run(["apktool", "-version"], stdout=subprocess.PIPE)
+		output = proc.stdout.decode("utf-8").strip()
+	return pkg_resources.parse_version(output.split("-")[0].strip())
+
+####################
+# 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())
+
+####################
+# Verify the package name - checks whether the target package is installed
+# on the device or if an exact match is not found presents the options to
+# 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 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
+
+####################
+# 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)
+
+####################
+# 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)
+
+####################
+# 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
+
+####################
+# 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("")
+
+####################
+# Fix public resource identifiers that are shared across split APKs.
+# Maps all APKTOOL_DUMMY_ resource IDs in the base APK to the proper resource names from the
+# split APKs, then updates references in other resource files in the base APK to use proper
+# 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("")
+
+####################
+# Hack to remove duplicate style resource entries before rebuilding.
+# 
+# Possibly a bug in apktool affecting the Uber app (com.ubercab)
+# -> res/values/styles.xml has <style> elements where two child <item> elements had the same name e.g.
+#        <item name="borderWarning">@color/ub__ui_core_v2_orange200</item>
+#        <item name="borderWarning">@color/ub__ui_core_v2_orange400</item>
+# --> Doing an "apktool d com.ubercab.apk" then "apktool b com.ubercab" fails, so not a bug with patch-apk.py.
+# --> See: https://github.com/iBotPeaches/Apktool/issues/2240
+# 
+# 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 <style> -> <item> elements and removing.")
+	print("[~] Warning: this is a complete hack and may impact the visuals of the app, disable with --disable-styles-hack.")
+	
+	#Duplicates
+	dupes = []
+	
+	#Parse styles.xml and find all <item> elements with duplicate names
+	tree = xml.etree.ElementTree.parse(os.path.join(baseapkdir, "res", "values", "styles.xml"))
+	for styleEl in tree.getroot().findall("style"):
+		itemNames = []
+		for itemEl in styleEl:
+			if "name" in itemEl.attrib and itemEl.attrib["name"] in itemNames:
+				dupes.append([styleEl, itemEl])
+			else:
+				itemNames.append(itemEl.attrib["name"])
+	
+	#Delete all duplicates from the tree
+	for dupe in dupes:
+		dupe[0].remove(dupe[1])
+	
+	#Save the result if any duplicates were found and removed
+	if len(dupes) > 0:
+		tree.write(os.path.join(baseapkdir, "res", "values", "styles.xml"), encoding="utf-8", xml_declaration=True)
+		print("[+] Removed " + str(len(dupes)) + " duplicate entries from styles.xml.")
+	print("")
+
+####################
+# Update AndroidManifest.xml to disable APK splitting.
+# -> Removes the "isSplitRequired" attribute of the "application" element.
+# -> Sets the "extractNativeLibs" attribute of the "application" element.
+# -> Removes meta-data elements with the name "com.android.vending.splits" or "com.android.vending.splits.required"
+####################
+def disableApkSplitting(baseapkdir):
+	print("Disabling APK splitting in AndroidManifest.xml of base APK.")
+	
+	#Load AndroidManifest.xml
+	tree = xml.etree.ElementTree.parse(os.path.join(baseapkdir, "AndroidManifest.xml"))
+	
+	#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"] + "}"
+	
+	#Disable APK splitting
+	appEl = None
+	elsToRemove = []
+	for el in tree.iter():
+		if el.tag == "application":
+			appEl = el
+			if ns + "isSplitRequired" in el.attrib:
+				del el.attrib[ns + "isSplitRequired"]
+			if ns + "extractNativeLibs" in el.attrib:
+				el.attrib[ns + "extractNativeLibs"] = "true"
+		elif appEl is not None and el.tag == "meta-data":
+			if ns + "name" in el.attrib:
+				if el.attrib[ns + "name"] == "com.android.vending.splits.required":
+					elsToRemove.append(el)
+				elif el.attrib[ns + "name"] == "com.android.vending.splits":
+					elsToRemove.append(el)
+	for el in elsToRemove:
+		appEl.remove(el)
+	
+	#Save the updated AndroidManifest.xml
+	tree.write(os.path.join(baseapkdir, "AndroidManifest.xml"), encoding="utf-8", xml_declaration=True)
+	print("")
+
+####################
+# Patch an APK to enable support for user-installed CA certs (e.g. Burp Suite CA cert).
+####################
+def enableUserCerts(apkfile):
+	#Create a separate temp directory to work from
+	print("Patching APK to enable support for user-installed CA certificates.")
+	with tempfile.TemporaryDirectory() as tmppath:
+		#Extract the APK
+		apkdir = os.path.join(tmppath, apkfile.split(os.sep)[-1][:-4])
+		apkname = apkdir.split(os.sep)[-1] + ".apk"
+		ret = runApkTool(["d", apkfile, "-o", apkdir])
+		if ret.returncode != 0:
+			print("Error: Failed to run 'apktool d " + apkfile + " -o " + apkdir + "'.\nRun with --debug-output for more information.")
+			sys.exit(1)
+		
+		#Load AndroidManifest.xml and check for or create the networkSecurityConfig attribute
+		tree = xml.etree.ElementTree.parse(os.path.join(apkdir, "AndroidManifest.xml"))
+		namespaces = dict([node for _,node in xml.etree.ElementTree.iterparse(os.path.join(apkdir, "AndroidManifest.xml"), events=["start-ns"])])
+		for ns in namespaces:
+			xml.etree.ElementTree.register_namespace(ns, namespaces[ns])
+		ns = "{" + namespaces["android"] + "}"
+		for el in tree.findall("application"):
+			el.attrib[ns + "networkSecurityConfig"] = "@xml/network_security_config"
+		tree.write(os.path.join(apkdir, "AndroidManifest.xml"), encoding="utf-8", xml_declaration=True)
+		
+		#Create a network security config file
+		fh = open(os.path.join(apkdir, "res", "xml", "network_security_config.xml"), "wb")
+		fh.write("<?xml version=\"1.0\" encoding=\"utf-8\" ?><network-security-config><base-config><trust-anchors><certificates src=\"system\" /><certificates src=\"user\" /></trust-anchors></base-config></network-security-config>".encode("utf-8"))
+		fh.close()
+		
+		#Rebuild and sign the APK
+		ret = runApkTool(["b", apkdir])
+		if ret.returncode != 0:
+			print("Error: Failed to run 'apktool b " + apkdir + "'.\nRun with --debug-output for more information.")
+			sys.exit(1)
+		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(apkdir, "dist", apkname), "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(apkdir, "dist", apkname) + "patch-apk-key'.\nRun with --debug-output for more information.")
+			sys.exit(1)
+		
+		#Zip align the new APK
+		os.remove(apkfile)
+		ret = subprocess.run(["zipalign", "4", os.path.join(apkdir, "dist", apkname), apkfile], stdout=getStdout())
+		if ret.returncode != 0:
+			print("Error: Failed to run 'zipalign 4 " + os.path.join(apkdir, "dist", apkname) + " " + apkfile + "'.\nRun with --debug-output for more information.")
+			sys.exit(1)
+	print("")
+
+if __name__ == "__main__":
+	main()

+ 0 - 0
requirements.txt