diff --git a/.gitignore b/.gitignore index 0a7f648..dcc0ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,223 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +.vscode/* +.vscode +src/.vscode/* +src/.vscode + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml .DS_Store .gadgetCache/ diff --git a/README.md b/README.md index 7029e4d..48bc3fd 100644 --- a/README.md +++ b/README.md @@ -37,22 +37,45 @@ An APK patcher, for use with [objection](https://github.com/sensepost/objection) * **29th March 2020:** Added `--save-apk` parameter to save a copy of the unpatched single APK for use with other tools. * **27th March 2020:** Initial release supporting split APKs and the `--no-enable-user-certs` flag. -## Usage ## -Install the target Android application on your device and connect it to your computer/VM so that `adb devices` can see it, then run: +## Installation & Usage ## + +Installation (development) + +Install the project in editable mode (recommended during development): + +``` +python -m pip install -e . +``` + +Install for production / normal use: + +``` +python -m pip install . +``` + +After installation the CLI entry point `patch-apk` will be available. Alternatively you can run directly from the source tree: + +``` +python -m patch_apk.main {package-name} +``` + +Usage + +Ensure your Android device is connected and visible to adb (`adb devices`). Then run: ``` -python3 patch-apk.py {package-name} +patch-apk {package-name} ``` -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`. +The `{package-name}` parameter can be a fully-qualified package name such as `com.google.android.youtube`, or a partial package name (e.g. `tube`) — the tool will attempt to resolve partial names and ask for confirmation if multiple matches are found. -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. +By default the tool will inject the Frida gadget and enable support for user-installed CA certificates by modifying the app's network security config. To disable the network cert modification, pass `--no-enable-user-certs` on the command line. ### Examples ### -**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`. +**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`. ``` -$ python3 patch-apk.py com.whatsapp +$ patch-apk com.whatsapp Getting APK path(s) for package: com.whatsapp [+] APK path: /data/app/com.whatsapp-NKLgchoExRFTDLkkbDqBGg==/base.apk @@ -70,12 +93,12 @@ Installing the patched APK to the device. Done, cleaning up temporary files. ``` -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. +When `patch-apk` 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. -**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. +**Partial Package Name Matching:** Pass a partial package name to `patch-apk` and it'll automatically grab the correct package name or ask you to confirm from available options. ``` -$ python3 patch-apk.py proxy +$ patch-apk proxy [!] Multiple matching packages installed, select the package to patch. @@ -100,7 +123,7 @@ package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaag 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 org.proxydroid -v +$ patch-apk org.proxydroid -v [+] Retrieving APK path(s) for package: org.proxydroid [+] APK path: /data/app/~~FTVBmscrJiLerJdXIEa5tw==/org.proxydroid-KMq91nU1y9Qz8ZZAGM--RA==/base.apk @@ -127,7 +150,7 @@ $ python3 patch-apk.py org.proxydroid -v 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 + Extracted: /var/folders/t3/vz305z151ng8y2rwvpkx28xw0000gn/T/tmpyw7wl64xk/org.proxydroid-split_config.xxhdpi.apk [+] Rebuilding as a single APK @@ -168,7 +191,7 @@ package:/data/app/org.proxydroid-9NuZnT-lK3qM_IZQEHhTgA==/base.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: ``` -$ python3 patch-apk.py org.proxydroid --extract-only +$ patch-apk org.proxydroid --extract-only [+] Retrieving APK path(s) for package: org.proxydroid @@ -196,7 +219,7 @@ package:/data/app/~~TP7sglBuEoDc3yH0wpZdiA==/org.proxydroid-PCy1JxTMVJT3KmxVqaag 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: +These can be combined into a single APK for use with other tools such as `objection patchapk`. This is done by `patch-apk` as follows: **Step 1 - Extract APKs:** First, the individual APK files are pulled from the device and extracted using `apktool`. diff --git a/README_PROJECT_LAYOUT.md b/README_PROJECT_LAYOUT.md new file mode 100644 index 0000000..d6336a6 --- /dev/null +++ b/README_PROJECT_LAYOUT.md @@ -0,0 +1,50 @@ +## Project layout + +The primary source package lives under `src/patch_apk/`. The following lists the source tree with files inside the `config`, `core`, and `utils` subfolders. + +``` +src/ +├─ .vscode/ +│ └─ PythonImportHelper-v2-Completion.json +├─ patch_apk/ +│ ├─ __init__.py +│ ├─ main.py +│ ├─ config/ +│ │ ├─ __init__.py +│ │ └─ constants.py +│ ├─ core/ +│ │ ├─ __init__.py +│ │ ├─ apk_builder.py +│ │ └─ apk_tool.py +│ └─ utils/ +│ ├─ __init__.py +│ ├─ apk_detect_proguard.py +│ ├─ cli_tools.py +│ ├─ copy_split_apks.py +│ ├─ dependencies.py +│ ├─ disable_apk_split.py +│ ├─ fix_private_resources.py +│ ├─ fix_resource_id.py +│ ├─ frida_objection.py +│ ├─ get_apk_paths.py +│ ├─ get_target_apk.py +│ ├─ raw_re_replace.py +│ ├─ remove_duplicate_style.py +│ ├─ verify_package_name.py +│ └─ data/ +│ └─ patch-apk.keystore +└─ patch_apk.egg-info/ + ├─ dependency_links.txt + ├─ entry_points.txt + ├─ PKG-INFO + ├─ requires.txt + ├─ SOURCES.txt + └─ top_level.txt +``` + +Notes: + +- Files inside `src/patch_apk/core/` are implemented in a class-based style (for example `APKTool`, `APKBuilder`, etc.). +- Files inside `src/patch_apk/utils/` are implemented as modules of helper functions (function-based utilities used across the project). + +The package version is defined in `src/patch_apk/__init__.py` (`__version__`). diff --git a/requirements.txt b/requirements.txt index 2869b37..f80425f 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3c9d56c --- /dev/null +++ b/setup.py @@ -0,0 +1,73 @@ +from pathlib import Path +from setuptools import setup, find_packages +import re + + +HERE = Path(__file__).parent + + +def _safe_read_text(p: Path) -> str: + """Read a text file trying several encodings to avoid UnicodeDecodeError. + + Tries utf-8, utf-8-sig, utf-16, and latin-1 in that order. If the file + doesn't exist returns an empty string. + """ + if not p.exists(): + return "" + encodings = ("utf-8", "utf-8-sig", "utf-16", "latin-1") + data = p.read_bytes() + for enc in encodings: + try: + return data.decode(enc) + except (UnicodeDecodeError, LookupError): + continue + # as a last resort, decode with latin-1 replacing errors to avoid failing the build + return data.decode("latin-1", errors="replace") + + +def read_requirements(req_file: Path): + text = _safe_read_text(req_file) + if not text: + return [] + lines = text.splitlines() + reqs = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#")] + return reqs + + +def get_version(pkg_init: Path): + if not pkg_init.exists(): + return "0.0.0" + text = _safe_read_text(pkg_init) + m = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", text) + return m.group(1) if m else "0.0.0" + + +long_description = "" +readme_file = HERE / "README.md" +if readme_file.exists(): + long_description = _safe_read_text(readme_file) + +install_requires = read_requirements(HERE / "requirements.txt") + +setup( + name="patch-apk", + version=get_version(HERE / "src" / "patch_apk" / "__init__.py"), + description="App Bundle / Split APK aware patcher for objection", + long_description=long_description, + long_description_content_type="text/markdown", + packages=find_packages(where="src"), + package_dir={"": "src"}, + include_package_data=True, + install_requires=install_requires, + entry_points={ + "console_scripts": [ + "patch-apk=patch_apk.main:main", + ] + }, + python_requires=">=3.8", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], +) diff --git a/src/patch_apk/__init__.py b/src/patch_apk/__init__.py new file mode 100644 index 0000000..456325e --- /dev/null +++ b/src/patch_apk/__init__.py @@ -0,0 +1,6 @@ +""" +patch-apk package. +A tool for patching and modifying Android APK files. +""" + +__version__ = '0.1.0' \ No newline at end of file diff --git a/src/patch_apk/config/__init__.py b/src/patch_apk/config/__init__.py new file mode 100644 index 0000000..fba82fe --- /dev/null +++ b/src/patch_apk/config/__init__.py @@ -0,0 +1 @@ +"""Config package for constants and configurations.""" \ No newline at end of file diff --git a/src/patch_apk/config/constants.py b/src/patch_apk/config/constants.py new file mode 100644 index 0000000..26978d1 --- /dev/null +++ b/src/patch_apk/config/constants.py @@ -0,0 +1,7 @@ +""" +Constants and configuration values for the patch-apk tool. +""" + +# Android related constants + +NULL_DECODED_DRAWABLE_COLOR = "#000000ff" \ No newline at end of file diff --git a/src/patch_apk/core/__init__.py b/src/patch_apk/core/__init__.py new file mode 100644 index 0000000..bc576e6 --- /dev/null +++ b/src/patch_apk/core/__init__.py @@ -0,0 +1,6 @@ +"""Core package for APK processing functionality.""" + +from .apk_tool import APKTool +from .apk_builder import APKBuilder + +__all__ = ["APKTool", "APKBuilder"] \ No newline at end of file diff --git a/src/patch_apk/core/apk_builder.py b/src/patch_apk/core/apk_builder.py new file mode 100644 index 0000000..b3e6a22 --- /dev/null +++ b/src/patch_apk/core/apk_builder.py @@ -0,0 +1,41 @@ +""" +APK building module responsible for rebuilding modified APK files. +""" +import os +import shutil + +# core imports +from .apk_tool import APKTool + +# utility imports + +from patch_apk.utils.cli_tools import verbosePrint, abort, assertSubprocessSuccessfulRun +from patch_apk.utils.fix_private_resources import fixPrivateResources + + +class APKBuilder: + """Handles APK building and rebuilding operations.""" + + @staticmethod + def build(baseapkdir): + # Fix private resources preventing builds (apktool wontfix: https://github.com/iBotPeaches/Apktool/issues/2761) + fixPrivateResources(baseapkdir) + + verbosePrint("[+] Rebuilding APK with apktool.") + result = APKTool.runApkTool(["b", baseapkdir]) + if result["returncode"] != 0: + abort("Error: Failed to run 'apktool b " + baseapkdir + "'.\nRun with --debug-output for more information.") + + + @staticmethod + def signAndZipAlign(baseapkdir, baseapkfilename): + # Zip align the new APK + verbosePrint("[+] Zip aligning new APK.") + assertSubprocessSuccessfulRun(["zipalign", "-f", "4", "-p", 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]) \ No newline at end of file diff --git a/src/patch_apk/core/apk_tool.py b/src/patch_apk/core/apk_tool.py new file mode 100644 index 0000000..2bc6005 --- /dev/null +++ b/src/patch_apk/core/apk_tool.py @@ -0,0 +1,142 @@ +''' ApkTool related functions ''' + +import subprocess +import os +from progress.bar import Bar +from packaging.version import parse as parse_version + +# util imports + +from patch_apk.utils.raw_re_replace import rawREReplace +from patch_apk.utils.disable_apk_split import disableApkSplitting +from patch_apk.utils.remove_duplicate_style import hackRemoveDuplicateStyleEntries +from patch_apk.utils.fix_resource_id import fixPublicResourceIDs +from patch_apk.utils.cli_tools import abort, verbosePrint, warningPrint +from patch_apk.utils.apk_detect_proguard import detectProGuard +from patch_apk.utils.copy_split_apks import copySplitApkFiles + + +# core imports + +# from .apk_builder import APKBuilder # removed due to circular import + + +class APKTool: + + ''' + ApkTool class for handling APK files. + + This class provides a way to interface with apktool, a powerful tool for + decompiling and recompiling Android APK files. It also provides helper + functions for common operations when working with APK files. + + Attributes: + None + + Methods: + runApkTool(params): Run apktool with the given parameters. + getAPktoolVersion(): Get the installed version of apktool. + combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack, extract_only): + Combine multiple split APKs into a single APK. + + Examples: + >>> APKTool.runApkTool(["d", "org.proxydroid.apk"]) + ''' + + + @staticmethod + def runApkTool(params): + exe = "apktool.bat" if os.name == "nt" else "apktool" + # Feed "\r\n" so apktool.bat's `pause` won’t block on Windows. + cp = subprocess.run( + [exe, *params], + input="\r\n", # Should be harmless on linux + text=True, + capture_output=True, + check=False, + ) + # Return a simple, uniform dict + return { + "returncode": cp.returncode, + "stdout": cp.stdout, + "stderr": cp.stderr, + "ok": (cp.returncode == 0), + } + + @staticmethod + def getApktoolVersion(): + commands = [["version"], ["v"], ["-version"], ["-v"]] + for cmd in commands: + try: + result = APKTool.runApkTool(cmd) + + if result["returncode"] != 0: + continue + version_output = result["stdout"].strip().split("\n")[0].strip() + version_str = version_output.split("-")[0].strip() + return parse_version(version_str) + except Exception as e: + continue + raise Exception("Error: Failed to get apktool version.") + + + + @staticmethod + def combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack, extract_only): + + from .apk_builder import APKBuilder + + warningPrint("[!] 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 = [] + + bar = Bar('[+] Disassembling split APKs', max=len(localapks)) + verboseOutput = "" + + for apkpath in localapks: + verboseOutput += "\nExtracted: " + apkpath + bar.next() + apkdir = apkpath[:-4] + ret = APKTool.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("[!] 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("[+] 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 + APKBuilder.build(baseapkdir) + + # Return the new APK path + return os.path.join(baseapkdir, "dist", baseapkfilename) + diff --git a/src/patch_apk/main.py b/src/patch_apk/main.py new file mode 100644 index 0000000..f3b0ddb --- /dev/null +++ b/src/patch_apk/main.py @@ -0,0 +1,77 @@ +""" +Main entry point for the patch-apk tool. +""" +import os +import shutil +import subprocess +import tempfile + +# core imports + +from patch_apk.core.apk_tool import APKTool + +# utility imports + +from patch_apk.utils.cli_tools import getArgs, warningPrint, assertSubprocessSuccessfulRun +from patch_apk.utils.dependencies import checkDependencies +from patch_apk.utils.frida_objection import fixAPKBeforeObjection, patchingWithObjection +from patch_apk.utils.get_target_apk import getTargetAPK +from patch_apk.utils.get_apk_paths import getAPKPathsForPackage +from patch_apk.utils.verify_package_name import verifyPackageName + +def main(): + # Grab argz + args = getArgs() + + # Check that dependencies are available + checkDependencies(args.extract_only) + + # Warn for unexpected version + apktoolVersion = APKTool.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" # type: ignore + print("[+] Saving a copy of the APK to " + targetName) + shutil.copy(apkfile, targetName) + + if args.extract_only: + os.remove(apkfile) + return + + # Before patching with objection, add INTERNET permission if not already present, and set extractNativeLibs to true + fixAPKBeforeObjection(apkfile, not args.no_enable_user_certs) + + # Patch the APK with objection + patchingWithObjection(apkfile) + + os.remove(apkfile) + shutil.move(apkfile[:-4] + ".objection.apk", apkfile) + + # Uninstall the original package from the device + print(f"[+] Uninstalling the original package from the device. (user: {current_user})") + assertSubprocessSuccessfulRun(["adb", "uninstall", "--user", current_user, pkgname]) + + # Install the patched APK + print(f"[+] Installing the patched APK to the device. (user: {current_user})") + assertSubprocessSuccessfulRun(["adb", "install", "--user", current_user, apkfile]) + + + # Done + print("[+] Done") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/src/patch_apk/utils/__init__.py b/src/patch_apk/utils/__init__.py new file mode 100644 index 0000000..25cbd79 --- /dev/null +++ b/src/patch_apk/utils/__init__.py @@ -0,0 +1,2 @@ +"""Utils package for helper functions and utilities.""" + diff --git a/src/patch_apk/utils/apk_detect_proguard.py b/src/patch_apk/utils/apk_detect_proguard.py new file mode 100644 index 0000000..1c208c4 --- /dev/null +++ b/src/patch_apk/utils/apk_detect_proguard.py @@ -0,0 +1,15 @@ +import os + +#################### +# Attempt to detect ProGuard/AndResGuard. +#################### +def detectProGuard(extractedPath): + 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 \ No newline at end of file diff --git a/src/patch_apk/utils/cli_tools.py b/src/patch_apk/utils/cli_tools.py new file mode 100644 index 0000000..4ef6598 --- /dev/null +++ b/src/patch_apk/utils/cli_tools.py @@ -0,0 +1,64 @@ +""" +Command-line interface argument handling. +""" + +import argparse +import sys +from termcolor import colored +import subprocess + +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. 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 + +def abort(msg): + print(colored(msg, "red")) + sys.exit(1) + + +def verbosePrint(msg): + if getArgs().verbose: + for line in msg.split("\n"): + print(colored(" " + line, "light_grey")) + + +def dbgPrint(msg): + if getArgs().debug_output: + print(msg) + +#################### +# Warning print +#################### +def warningPrint(msg): + print(colored(msg, "yellow")) + + + +def getStdout(): + if getArgs().debug_output: + return None + else: + return subprocess.DEVNULL + + + +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.") \ No newline at end of file diff --git a/src/patch_apk/utils/copy_split_apks.py b/src/patch_apk/utils/copy_split_apks.py new file mode 100644 index 0000000..bb8768e --- /dev/null +++ b/src/patch_apk/utils/copy_split_apks.py @@ -0,0 +1,33 @@ +import os +from patch_apk.utils.cli_tools import dbgPrint +import shutil + +def copySplitApkFiles(baseapkdir, splitapkpaths): + 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) + + diff --git a/src/patch_apk/utils/data/patch-apk.keystore b/src/patch_apk/utils/data/patch-apk.keystore new file mode 100644 index 0000000..e412b8b Binary files /dev/null and b/src/patch_apk/utils/data/patch-apk.keystore differ diff --git a/src/patch_apk/utils/dependencies.py b/src/patch_apk/utils/dependencies.py new file mode 100644 index 0000000..1d19db3 --- /dev/null +++ b/src/patch_apk/utils/dependencies.py @@ -0,0 +1,29 @@ +import subprocess +import os +import shutil +from patch_apk.utils.cli_tools import abort + +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.") \ No newline at end of file diff --git a/src/patch_apk/utils/disable_apk_split.py b/src/patch_apk/utils/disable_apk_split.py new file mode 100644 index 0000000..7182e60 --- /dev/null +++ b/src/patch_apk/utils/disable_apk_split.py @@ -0,0 +1,46 @@ +from patch_apk.utils.cli_tools import verbosePrint +import os +import xml.etree.ElementTree + +def disableApkSplitting(baseapkdir): + verbosePrint("[+] 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"])]) # pyright: ignore[reportArgumentType] + 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) + + # Clean up tag + root = tree.getroot() + if ns + "isSplitRequired" in root.attrib: + del root.attrib[ns + "isSplitRequired"] + if ns + "requiredSplitTypes" in root.attrib: + del root.attrib[ns + "requiredSplitTypes"] + if ns + "splitTypes" in root.attrib: + del root.attrib[ns + "splitTypes"] + + # Save the updated AndroidManifest.xml + tree.write(os.path.join(baseapkdir, "AndroidManifest.xml"), encoding="utf-8", xml_declaration=True) \ No newline at end of file diff --git a/src/patch_apk/utils/fix_private_resources.py b/src/patch_apk/utils/fix_private_resources.py new file mode 100644 index 0000000..fb7d4cb --- /dev/null +++ b/src/patch_apk/utils/fix_private_resources.py @@ -0,0 +1,19 @@ +import os +from patch_apk.utils.cli_tools import verbosePrint +from patch_apk.utils.raw_re_replace import rawREReplace + + +#################### +# 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.") \ No newline at end of file diff --git a/src/patch_apk/utils/fix_resource_id.py b/src/patch_apk/utils/fix_resource_id.py new file mode 100644 index 0000000..11965b5 --- /dev/null +++ b/src/patch_apk/utils/fix_resource_id.py @@ -0,0 +1,109 @@ +import os +import xml.etree.ElementTree +from patch_apk.utils.cli_tools import verbosePrint, dbgPrint +from patch_apk.config.constants import NULL_DECODED_DRAWABLE_COLOR + + +def fixPublicResourceIDs(baseapkdir, splitapkpaths): + # 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("[+] 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"] + "}" # type: ignore + + # 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.") \ No newline at end of file diff --git a/src/patch_apk/utils/frida_objection.py b/src/patch_apk/utils/frida_objection.py new file mode 100644 index 0000000..b1671a7 --- /dev/null +++ b/src/patch_apk/utils/frida_objection.py @@ -0,0 +1,99 @@ +import os +import tempfile +import shutil +import xml.etree.ElementTree +import subprocess + +# core imports + +from patch_apk.core.apk_tool import APKTool + +# utility imports + +from patch_apk.utils.cli_tools import abort, assertSubprocessSuccessfulRun, warningPrint +from patch_apk.utils.remove_duplicate_class import remove_duplicate_classes + +def fixAPKBeforeObjection(apkfile, fix_network_security_config): + print("[+] Prepping AndroidManifest.xml") + with tempfile.TemporaryDirectory() as tmppath: + apkdir = os.path.join(tmppath, "apk") + ret = APKTool.runApkTool(["d", "--only-main-classes", apkfile, "-o", apkdir,]) + if ret["returncode"] != 0: + abort("Error: Failed to run 'apktool d " + apkfile + " -o " + apkdir + "'.\nRun with --debug-output for more information.") + + # Load AndroidManifest.xml + manifestPath = os.path.join(apkdir, "AndroidManifest.xml") + tree = xml.etree.ElementTree.parse(manifestPath) + + # Register the namespaces and get the prefix for the "android" namespace + namespaces = dict([node for _,node in xml.etree.ElementTree.iterparse(manifestPath, events=["start-ns"])]) + for ns in namespaces: + xml.etree.ElementTree.register_namespace(ns, namespaces[ns]) + ns = "{" + namespaces["android"] + "}" + + # Ensure INTERNET permission is present + hasInternetPermission = False + for el in tree.getroot(): + if el.tag == "uses-permission" and ns + "name" in el.attrib: + if el.attrib[ns + "name"] == "android.permission.INTERNET": + hasInternetPermission = True + break + if not hasInternetPermission: + print("[+] Adding android.permission.INTERNET to AndroidManifest.xml") + usesPermissionEl = xml.etree.ElementTree.Element("uses-permission") + usesPermissionEl.attrib[ns + "name"] = "android.permission.INTERNET" + tree.getroot().insert(0, usesPermissionEl) + + # Set extractNativeLibs to true + appEl = tree.find(".//application") + if appEl is not None: + print("[+] \tSetting extractNativeLibs to true") + appEl.attrib[ns + "extractNativeLibs"] = "true" + + + if fix_network_security_config: + print("[+] \tEnabling support for user-installed CA certificates.") + + # Add networkSecurityConfig + for el in tree.findall("application"): + el.attrib[ns + "networkSecurityConfig"] = "@xml/network_security_config" + + # Create a network security config file + fh = open(os.path.join(apkdir, "res", "xml", "network_security_config.xml"), "wb") + fh.write("".encode("utf-8")) + fh.close() + + # Save the updated AndroidManifest.xml + tree.write(manifestPath, encoding="utf-8", xml_declaration=True) + # Remove problematic duplicate classes + try: + remove_duplicate_classes(apkdir) + except Exception as e: + print(f"[!] Warning: Failed to remove duplicate classes: {e}") + pass + # Rebuild apk file + result = APKTool.runApkTool(["b", apkdir]) + if result["returncode"] != 0: + abort("Error: Failed to run 'apktool b " + apkdir + "'.\nRun with --debug-output for more information.") + + + # Move rebuilt APK back to original location + rebuilt_apk = os.path.join(apkdir, "dist", os.path.basename(apkfile)) + if os.path.exists(rebuilt_apk): + shutil.move(rebuilt_apk, apkfile) + else: + abort("Error: Rebuilt APK not found.") + + +def patchingWithObjection(apkfile): + # Patch the target APK with objection + print("[+] Patching " + apkfile.split(os.sep)[-1] + " with objection.") + warningPrint("[!] The application will be patched with Frida 16.7.19. See https://github.com/sensepost/objection/issues/737 for more information.") + if subprocess.run(["objection", "patchapk", "-V", "16.7.19", "--skip-resources", "--ignore-nativelibs", "-s", apkfile], capture_output=True).returncode != 0: + print("[+] 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 + # Another reason could be a missing INTERNET permission + assertSubprocessSuccessfulRun(["objection", "patchapk","-V", "16.7.19", "--ignore-nativelibs", "-s", apkfile]) \ No newline at end of file diff --git a/src/patch_apk/utils/get_apk_paths.py b/src/patch_apk/utils/get_apk_paths.py new file mode 100644 index 0000000..b04b451 --- /dev/null +++ b/src/patch_apk/utils/get_apk_paths.py @@ -0,0 +1,35 @@ +import subprocess +import os +import re +from patch_apk.utils.cli_tools import abort, verbosePrint, warningPrint + +def getAPKPathsForPackage(pkgname, current_user = "0", users_to_try = None): + print(f"[+] 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 \ No newline at end of file diff --git a/src/patch_apk/utils/get_target_apk.py b/src/patch_apk/utils/get_target_apk.py new file mode 100644 index 0000000..99df08c --- /dev/null +++ b/src/patch_apk/utils/get_target_apk.py @@ -0,0 +1,29 @@ +import os +from patch_apk.utils.cli_tools import verbosePrint, assertSubprocessSuccessfulRun +from progress.bar import Bar +from patch_apk.core.apk_tool import APKTool + +def getTargetAPK(pkgname, apkpaths, tmppath, disableStylesHack, extract_only): + # Pull the APKs from the device + + 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}" + 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 APKTool.combineSplitAPKs(pkgname, localapks, tmppath, disableStylesHack, extract_only) \ No newline at end of file diff --git a/src/patch_apk/utils/raw_re_replace.py b/src/patch_apk/utils/raw_re_replace.py new file mode 100644 index 0000000..55c901e --- /dev/null +++ b/src/patch_apk/utils/raw_re_replace.py @@ -0,0 +1,22 @@ +""" +File operation utilities. +""" +from patch_apk.utils.cli_tools import abort, dbgPrint +import os +import re +import xml.etree.ElementTree +from patch_apk.utils.cli_tools import verbosePrint + + +def rawREReplace(path, pattern, replacement): + if os.path.exists(path): + contents = "" + with open(path, 'r') as file: + contents = file.read() + newContents = re.sub(pattern, replacement, contents) + if (contents != newContents): + dbgPrint("[~] Patching " + path) + with open(path, 'w') as file: + file.write(newContents) + else: + abort("\nError: Failed to find file at " + path + " for pattern replacement") \ No newline at end of file diff --git a/src/patch_apk/utils/remove_duplicate_class.py b/src/patch_apk/utils/remove_duplicate_class.py new file mode 100644 index 0000000..7edb07d --- /dev/null +++ b/src/patch_apk/utils/remove_duplicate_class.py @@ -0,0 +1,69 @@ +import os +import patch_apk.utils.remove_duplicate_class +import shutil + +def remove_duplicate_classes(apkdir): + """ + Remove duplicate/conflicting smali classes from smali_assets directory. + This prevents 'has already been interned' errors during APK rebuild. + """ + smali_assets = os.path.join(apkdir, "smali_assets") + + if not os.path.exists(smali_assets): + print("[+] No smali_assets directory found, skipping duplicate check") + return + + print("[+] Scanning for conflicting smali classes...") + + # Get all smali directories except smali_assets + main_smali_dirs = [os.path.join(apkdir, d) for d in os.listdir(apkdir) + if d.startswith("smali") and d != "smali_assets" + and os.path.isdir(os.path.join(apkdir, d))] + + if not main_smali_dirs: + print("[+] No main smali directories found") + return + + # Build a set of all class paths in main smali directories + main_classes = set() + for smali_dir in main_smali_dirs: + for root, dirs, files in os.walk(smali_dir): + for file in files: + if file.endswith(".smali"): + # Get relative path from smali_dir root + rel_path = os.path.relpath(os.path.join(root, file), smali_dir) + main_classes.add(rel_path) + + print(f"[+] Found {len(main_classes)} classes in main smali directories") + + # Check smali_assets for duplicates + duplicates_removed = 0 + for root, dirs, files in os.walk(smali_assets): + for file in files: + if file.endswith(".smali"): + full_path = os.path.join(root, file) + rel_path = os.path.relpath(full_path, smali_assets) + + # If this class exists in main smali dirs, it's a duplicate + if rel_path in main_classes: + try: + os.remove(full_path) + duplicates_removed += 1 + print(f"[+] Removed duplicate: {rel_path}") + except Exception as e: + print(f"[!] Failed to remove {rel_path}: {e}") + + # Clean up empty directories + for root, dirs, files in os.walk(smali_assets, topdown=False): + if not os.listdir(root): + try: + os.rmdir(root) + except Exception: + pass + + # If smali_assets is now empty, remove it entirely + if os.path.exists(smali_assets) and not os.listdir(smali_assets): + shutil.rmtree(smali_assets) + print("[+] Removed empty smali_assets directory") + + print(f"[+] Removed {duplicates_removed} conflicting smali classes") \ No newline at end of file diff --git a/src/patch_apk/utils/remove_duplicate_style.py b/src/patch_apk/utils/remove_duplicate_style.py new file mode 100644 index 0000000..6787160 --- /dev/null +++ b/src/patch_apk/utils/remove_duplicate_style.py @@ -0,0 +1,9 @@ +from patch_apk.utils.cli_tools import abort, getStdout, verbosePrint +from patch_apk.utils.raw_re_replace import rawREReplace +import os + + +def hackRemoveDuplicateStyleEntries(baseapkdir): + # Bail if there is no styles.xml + if not os.path.exists(os.path.join(baseapkdir, "res", "values", "styles.xml")): + return \ No newline at end of file diff --git a/src/patch_apk/utils/verify_package_name.py b/src/patch_apk/utils/verify_package_name.py new file mode 100644 index 0000000..567679a --- /dev/null +++ b/src/patch_apk/utils/verify_package_name.py @@ -0,0 +1,36 @@ +import subprocess +from patch_apk.utils.cli_tools import abort, warningPrint +import os + +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: + 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("[!] 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("\nChoice: ") + if not choice.isnumeric() or int(choice) < 1 or int(choice) > len(packages): + print("\nInvalid choice.\n") + choice = -1 + print("") + return packages[int(choice) - 1] \ No newline at end of file