├── .gitignore ├── MANIFEST.in ├── README.md ├── android_unpinner ├── __init__.py ├── __main__.py ├── jdwplib.py ├── scripts │ ├── hide-debugger.js │ └── httptoolkit-unpinner.js └── vendor │ ├── __init__.py │ ├── build_tools │ ├── NOTICE.txt │ ├── __init__.py │ ├── android-unpinner.jks │ ├── darwin │ │ ├── aapt2 │ │ ├── apksigner │ │ ├── lib │ │ │ └── apksigner.jar │ │ └── zipalign │ ├── linux │ │ ├── aapt2 │ │ ├── apksigner │ │ ├── lib │ │ │ └── apksigner.jar │ │ ├── lib64 │ │ │ └── libc++.so │ │ └── zipalign │ └── win32 │ │ ├── aapt2.exe │ │ ├── apksigner.bat │ │ ├── lib │ │ └── apksigner.jar │ │ └── zipalign.exe │ ├── frida │ ├── COPYING │ ├── frida-gadget-16.0.10-android-arm.so │ ├── frida-gadget-16.0.10-android-arm64.so │ ├── frida-gadget-16.0.10-android-x86.so │ ├── frida-gadget-16.0.10-android-x86_64.so │ ├── gadget-config-listen.json │ └── gadget-config-script-directory.json │ ├── frida_tools │ ├── COPYING │ ├── __init__.py │ └── apk.py │ └── platform_tools │ ├── NOTICE.txt │ ├── __init__.py │ ├── darwin │ └── adb │ ├── linux │ └── adb │ └── win32 │ ├── AdbWinApi.dll │ ├── AdbWinUsbApi.dll │ └── adb.exe ├── httptoolkit-pinning-demo.apk └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | __pycache__/ 3 | *.unpinned.apk 4 | *.idsig 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft android_unpinner/vendor 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Unpinner 2 | 3 | This tool removes certificate pinning from APKs. 4 | 5 | - Does not require root. 6 | - Uses [`frida-apk`](https://github.com/frida/frida-tools/blob/main/frida_tools/apk.py) to mark app as debuggable. 7 | This is much less invasive than other approaches, only `AndroidManifest.xml` is touched within the APK. 8 | - Includes a custom Java Debug Wire Protocol implementation to inject the Frida Gadget via ADB. 9 | - Uses [HTTPToolkit's excellent unpinning script](https://github.com/httptoolkit/frida-android-unpinning) to defeat certificate pinning. 10 | - Already includes all native dependencies for Windows/Linux/macOS (`adb`, `apksigner`, `zipalign`, `aapt2`). 11 | 12 | The goal was not to build yet another unpinning tool, but to explore some newer avenues for non-rooted devices. 13 | Please shamelessly copy whatever idea you like into other tools. :-) 14 | 15 | ## Installation 16 | 17 | ```console 18 | $ git clone https://github.com/mitmproxy/android-unpinner.git 19 | $ cd android-unpinner 20 | $ pip install -e . 21 | ``` 22 | 23 | ## Usage 24 | 25 | Connect your device via USB and run the following command. 26 | 27 | ```console 28 | $ android-unpinner all httptoolkit-pinning-demo.apk 29 | ``` 30 | 31 | ![screenshot](https://uploads.hi.ls/2022-03/2022-03-08_09-09-36.png) 32 | 33 | See `android-unpinner --help` for usage details. 34 | 35 | You can pull APKs from your device using `android-unpinner list-packages` and `android-unpinner get-apks`. 36 | Alternatively, you can download APKs from the internet, for example manually from [apkpure.com](https://apkpure.com/) or automatically 37 | using [apkeep](https://github.com/EFForg/apkeep). 38 | 39 | ## Comparison 40 | 41 | **Compared to using a rooted device, android-unpinner...** 42 | 43 | 🟥 requires APK patching. 44 | 🟩 does not need to hide from root detection. 45 | 46 | **Compared to [`apk-mitm`](https://github.com/shroudedcode/apk-mitm), android-unpinner...** 47 | 48 | 🟥 requires active instrumentation from a desktop machine when launching the app. 49 | 🟩 allows more dynamic patching at runtime (thanks to Frida). 50 | 🟩 does less invasive APK patching, e.g. `classes.dex` stays as-is. 51 | 52 | **Compared to [`objection`](https://github.com/sensepost/objection), android-unpinner...** 53 | 54 | 🟥 supports only one feature (disable pinning) and no interactive analysis shell. 55 | 🟩 is easier to get started with, does not require additional dependencies. 56 | 🟩 does less invasive APK patching, e.g. `classes.dex` stays as-is. 57 | 58 | **Compared to [`frida`](https://frida.re/) + [`LIEF`](https://lief-project.github.io/doc/latest/tutorials/09_frida_lief.html), 59 | android-unpinner...** 60 | 61 | 🟥 modifies `AndroidManifest.xml` 62 | 🟩 is easier to get started with, does not require additional dependencies. 63 | 🟩 Does not require that the application includes a native library. 64 | 65 | ## Licensing 66 | 67 | This tool stands on the shoulders of giants. 68 | 69 | - `httptoolkit-pinning-demo.apk` is a copy of HTTP Toolkit's neat demo app available 70 | at https://github.com/httptoolkit/android-ssl-pinning-demo 71 | (Apache-2.0 License). 72 | - `scripts/httptoolkit-unpinner.js` is a copy of HTTP Toolkit's excellent unpinning script available at 73 | https://github.com/httptoolkit/frida-android-unpinning/ 74 | (AGPL License, Version 3.0 or later). 75 | - `android_unpinner/vendor/frida/` contains the fantastic Frida gadgets available at https://frida.re/ 76 | (wxWindows Library Licence, Version 3.1). 77 | - `android_unpinner/vendor/frida-tools/` is adapted from https://github.com/frida/frida-tools 78 | (wxWindows Library Licence, Version 3.1). 79 | - `android_unpinner/vendor/build_tools/` is a copy of some of Android's build tools 80 | (see `NOTICE.txt` therein for license). 81 | - `android_unpinner/vendor/platform_tools/` is a copy of some of Android's platform tools 82 | (see `NOTICE.txt` therein for license). 83 | - Code written here is licensed under the MIT license 84 | (https://github.com/mitmproxy/mitmproxy/blob/main/LICENSE). 85 | -------------------------------------------------------------------------------- /android_unpinner/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0" 2 | -------------------------------------------------------------------------------- /android_unpinner/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | import subprocess 6 | from pathlib import Path 7 | from time import sleep 8 | 9 | import rich.traceback 10 | import rich_click as click 11 | from rich.logging import RichHandler 12 | 13 | from . import jdwplib 14 | from .vendor import build_tools 15 | from .vendor import frida_tools 16 | from .vendor import gadget_config_file_listen, gadget_config_file_script_directory 17 | from .vendor import gadget_files 18 | from .vendor.platform_tools import adb 19 | 20 | here = Path(__file__).absolute().parent 21 | LIBGADGET = "libgadget.so" 22 | LIBGADGET_CONF = "libgadget.config.so" 23 | 24 | force = False 25 | gadget_config_file = gadget_config_file_script_directory 26 | 27 | 28 | def patch_apk_file(infile: Path, outfile: Path) -> None: 29 | """ 30 | Patch the APK to be debuggable. 31 | """ 32 | if outfile.exists(): 33 | if force or click.confirm( 34 | f"Overwrite existing file: {outfile.absolute()}?", abort=True 35 | ): 36 | outfile.unlink() 37 | 38 | logging.info(f"Make APK debuggable...") 39 | frida_tools.apk.make_debuggable( 40 | str(infile), 41 | str(outfile), 42 | ) 43 | 44 | logging.info(f"Zipalign & re-sign APK...") 45 | build_tools.zipalign(outfile) 46 | build_tools.sign(outfile) 47 | 48 | logging.info(f"Created patched APK: {outfile}") 49 | 50 | 51 | def patch_apk_files(apks: list[Path]) -> list[Path]: 52 | """ 53 | Patch multiple APK files and return the list of patched filenames. 54 | """ 55 | patched: list[Path] = [] 56 | for apk in apks: 57 | if apk.stem.endswith(".unpinned"): 58 | logging.warning( 59 | f"Skipping {apk} (filename indicates it is already patched)." 60 | ) 61 | continue 62 | 63 | outfile = apk.with_suffix(".unpinned" + apk.suffix) 64 | if outfile.exists(): 65 | logging.warning(f"Reusing existing file: {outfile}") 66 | else: 67 | logging.info(f"Patching {apk}...") 68 | patch_apk_file(apk, outfile) 69 | patched.append(outfile) 70 | return patched 71 | 72 | 73 | def ensure_device_connected() -> None: 74 | try: 75 | adb("get-state") 76 | except subprocess.CalledProcessError: 77 | raise RuntimeError("No device connected via ADB.") 78 | 79 | 80 | def install_apk(apk_files: list[Path]) -> None: 81 | """ 82 | Install the APK on the device, replacing any existing installation. 83 | """ 84 | ensure_device_connected() 85 | 86 | package_name = build_tools.package_name(apk_files[0]) 87 | 88 | if package_name in get_packages(): 89 | if not force: 90 | click.confirm( 91 | f"About to install patched APK. This removes the existing app with all its data. Continue?", 92 | abort=True, 93 | ) 94 | 95 | logging.info("Uninstall existing app...") 96 | adb(f"uninstall {package_name}") 97 | 98 | logging.info(f"Installing {package_name}...") 99 | if len(apk_files) > 1: 100 | adb(f"install-multiple --no-incremental {' '.join(str(x) for x in apk_files)}") 101 | else: 102 | adb(f"install --no-incremental {apk_files[0]}") 103 | 104 | 105 | def copy_files() -> None: 106 | """ 107 | Copy the Frida Gadget and unpinning scripts. 108 | """ 109 | # TODO: We could later provide the option to use a custom script dir. 110 | ensure_device_connected() 111 | logging.info(f"Detect architecture...") 112 | abi = adb("shell getprop ro.product.cpu.abi").stdout.strip() 113 | if abi == "armeabi-v7a": 114 | abi = "arm" 115 | gadget_file = gadget_files.get(abi, gadget_files["arm64"]) 116 | logging.info(f"Copying matching gadget: {gadget_file.name}...") 117 | adb(f"push {gadget_file} /data/local/tmp/{LIBGADGET}") 118 | adb(f"push {gadget_config_file} /data/local/tmp/{LIBGADGET_CONF}") 119 | 120 | logging.info( 121 | f"Copying builtin Frida scripts to /data/local/tmp/android-unpinner..." 122 | ) 123 | adb(f"push {here / 'scripts'}/. /data/local/tmp/android-unpinner/") 124 | active_scripts = adb( 125 | f"shell ls /data/local/tmp/android-unpinner" 126 | ).stdout.splitlines(keepends=False) 127 | logging.info(f"Active frida scripts: {active_scripts}") 128 | 129 | 130 | def start_app_on_device(package_name: str) -> None: 131 | ensure_device_connected() 132 | logging.info("Start app (suspended)...") 133 | adb(f"shell am set-debug-app -w {package_name}") 134 | activity = adb(f"shell cmd \"package resolve-activity --brief {package_name} | tail -n 1\"").stdout.strip() 135 | adb(f"shell am start -n {activity}") 136 | 137 | logging.info("Obtain process id...") 138 | pid = None 139 | for i in range(5): 140 | try: 141 | pid = adb(f"shell pidof {package_name}").stdout.strip() 142 | break 143 | except subprocess.CalledProcessError: 144 | if i: 145 | logging.info("Timeout...") 146 | if i == 4: 147 | raise 148 | sleep(1) 149 | logging.debug(f"{pid=}") 150 | local_port = int(adb(f"forward tcp:0 jdwp:{pid}").stdout) 151 | logging.debug(f"{local_port=}") 152 | 153 | async def inject_frida(): 154 | logging.info("Establish Java Debug Wire Protocol Connection over ADB...") 155 | async with jdwplib.JDWPClient("127.0.0.1", local_port) as client: 156 | logging.info("Advance until android.app.Activity.onCreate...") 157 | thread_id = await client.advance_to_breakpoint( 158 | "Landroid/app/Activity;", "onCreate" 159 | ) 160 | logging.info("Copy Frida gadget into app...") 161 | await client.exec( 162 | thread_id, 163 | f"cp /data/local/tmp/{LIBGADGET} /data/data/{package_name}/{LIBGADGET}", 164 | ) 165 | await client.exec( 166 | thread_id, 167 | f"cp /data/local/tmp/{LIBGADGET_CONF} /data/data/{package_name}/{LIBGADGET_CONF}", 168 | ) 169 | logging.info("Inject Frida gadget...") 170 | await client.load(thread_id, f"/data/data/{package_name}/{LIBGADGET}") 171 | logging.info("Continue app execution...") 172 | await client.send_command(jdwplib.Commands.RESUME_VM) 173 | 174 | asyncio.run(inject_frida()) 175 | 176 | 177 | def get_packages() -> list[str]: 178 | packages = adb("shell pm list packages").stdout.strip().splitlines() 179 | return [p.removeprefix("package:") for p in sorted(packages)] 180 | 181 | 182 | @click.group() 183 | def cli(): 184 | rich.traceback.install(suppress=[click, click.core]) 185 | 186 | 187 | def _verbosity(ctx, param, verbose): 188 | logging.basicConfig( 189 | format="%(message)s", 190 | datefmt="[%X]", 191 | handlers=[ 192 | RichHandler( 193 | show_path=False, show_level=verbose > 0, omit_repeated_times=False 194 | ) 195 | ], 196 | ) 197 | if verbose == 0: 198 | logging.getLogger().setLevel("INFO") 199 | logging.getLogger("jdwplib").setLevel("WARNING") 200 | elif verbose == 1: 201 | logging.getLogger().setLevel("INFO") 202 | else: 203 | logging.getLogger().setLevel("DEBUG") 204 | 205 | 206 | verbosity_option = click.option( 207 | "-v", 208 | "--verbose", 209 | count=True, 210 | metavar="", 211 | help="Log verbosity. Can be passed twice.", 212 | callback=_verbosity, 213 | expose_value=False, 214 | ) 215 | 216 | 217 | def _force(ctx, param, val): 218 | global force 219 | force = val 220 | 221 | 222 | force_option = click.option( 223 | "-f", 224 | "--force", 225 | help="Affirmatively answer all safety prompts.", 226 | is_flag=True, 227 | callback=_force, 228 | expose_value=False, 229 | ) 230 | 231 | 232 | def _listen(ctx, param, val): 233 | global gadget_config_file 234 | if val: 235 | gadget_config_file = gadget_config_file_listen 236 | 237 | 238 | listen_option = click.option( 239 | "-l", 240 | "--listen", 241 | help="Configure the Frida gadget to expose a server instead of running unpinning scripts.", 242 | is_flag=True, 243 | callback=_listen, 244 | expose_value=False, 245 | ) 246 | 247 | 248 | @cli.command("all") 249 | @verbosity_option 250 | @force_option 251 | @listen_option 252 | @click.argument( 253 | "apk-files", 254 | type=click.Path(path_type=Path, exists=True), 255 | nargs=-1, 256 | required=True, 257 | ) 258 | def all_cmd(apk_files: list[Path]) -> None: 259 | """ 260 | Patch a local APK, then install and start it. 261 | 262 | You may pass multiple files for the same package in case of split APKs. 263 | """ 264 | package_names = {build_tools.package_name(apk) for apk in apk_files} 265 | if len(package_names) > 1: 266 | raise RuntimeError( 267 | "Detected multiple APKs with different package names, aborting." 268 | ) 269 | package_name = next(iter(package_names)) 270 | logging.info(f"Target: {package_name}") 271 | apk_patched = patch_apk_files(apk_files) 272 | install_apk(apk_patched) 273 | copy_files() 274 | start_app_on_device(package_name) 275 | logging.info("All done! 🎉") 276 | 277 | 278 | @cli.command("install") 279 | @verbosity_option 280 | @force_option 281 | @click.argument( 282 | "apk-files", 283 | type=click.Path(path_type=Path, exists=True), 284 | nargs=-1, 285 | required=True, 286 | ) 287 | def install_cmd(apk_files: list[Path]) -> None: 288 | """ 289 | Install a package on the device. 290 | 291 | You may pass multiple files for the same package in case of split APKs. 292 | """ 293 | install_apk(apk_files) 294 | logging.info("All done! 🎉") 295 | 296 | 297 | @cli.command() 298 | @verbosity_option 299 | @force_option 300 | @click.argument( 301 | "apks", 302 | type=click.Path(path_type=Path, exists=True), 303 | nargs=-1, 304 | required=True, 305 | ) 306 | def patch_apks(apks: list[Path]) -> None: 307 | """Patch an APK file to be debuggable.""" 308 | patch_apk_files(apks) 309 | logging.info("All done! 🎉") 310 | 311 | 312 | @cli.command() 313 | @verbosity_option 314 | @force_option 315 | @listen_option 316 | def push_resources() -> None: 317 | """Copy Frida gadget and scripts to device.""" 318 | copy_files() 319 | logging.info("All done! 🎉") 320 | 321 | 322 | @cli.command() 323 | @verbosity_option 324 | @force_option 325 | @click.argument("package-name") 326 | def start_app(package_name: str) -> None: 327 | """Start app on device and inject Frida gadget.""" 328 | start_app_on_device(package_name) 329 | logging.info("All done! 🎉") 330 | 331 | 332 | @cli.command() 333 | @verbosity_option 334 | def list_packages() -> None: 335 | """List all packages installed on the device.""" 336 | ensure_device_connected() 337 | logging.info(f"Enumerating packages...") 338 | print("\n".join(get_packages())) 339 | logging.info("All done! 🎉") 340 | 341 | 342 | @cli.command() 343 | @click.argument("apk-file", type=click.Path(path_type=Path, exists=True)) 344 | def package_name(apk_file: Path) -> None: 345 | """Get the package name for a local APK file.""" 346 | print(build_tools.package_name(apk_file)) 347 | 348 | 349 | @cli.command() 350 | @verbosity_option 351 | @force_option 352 | @click.argument("package", type=str) 353 | @click.argument("outdir", type=click.Path(path_type=Path, file_okay=False)) 354 | def get_apks(package: str, outdir: Path) -> None: 355 | """Get all APKs for a specific package from the device.""" 356 | ensure_device_connected() 357 | 358 | logging.info("Getting package info...") 359 | if package not in get_packages(): 360 | raise RuntimeError(f"Could not find package: {package}") 361 | 362 | package_info = adb(f"shell pm path {package}").stdout 363 | if not package_info.startswith("package:"): 364 | raise RuntimeError(f"Unxepected output from pm path: {package_info!r}") 365 | apks = [p.removeprefix("package:") for p in package_info.splitlines()] 366 | if not outdir.exists(): 367 | outdir.mkdir() 368 | for apk in apks: 369 | logging.info(f"Getting {apk}...") 370 | outfile = outdir / Path(apk).name 371 | if outfile.exists(): 372 | if force or click.confirm( 373 | f"Overwrite existing file: {outfile.absolute()}?", abort=True 374 | ): 375 | outfile.unlink() 376 | adb(f"pull {apk} {outfile.absolute()}") 377 | 378 | logging.info("All done! 🎉") 379 | 380 | 381 | if __name__ == "__main__": 382 | cli() 383 | -------------------------------------------------------------------------------- /android_unpinner/jdwplib.py: -------------------------------------------------------------------------------- 1 | """ 2 | A minimal, modern, asyncio-based Python 3 implementation of the Java Debug Wire Protocol. 3 | The implemented functionality is just enough to execute commands and load libraries. 4 | 5 | When working with JDWP, make sure to enable debug logging: 6 | 7 | ```python 8 | logging.getLogger("jdwplib").setLevel("DEBUG") 9 | ``` 10 | 11 | References: 12 | - 13 | - 14 | """ 15 | from __future__ import annotations 16 | 17 | import asyncio 18 | import enum 19 | import io 20 | import logging 21 | import struct 22 | from dataclasses import dataclass 23 | from pathlib import Path 24 | 25 | log = logging.getLogger("jdwplib") 26 | 27 | REPLY_PACKET = 0x80 28 | HANDSHAKE = b"JDWP-Handshake" 29 | 30 | 31 | class JDWPClient: 32 | """ 33 | A Java Debug Wire Protocol Client. Usage example: 34 | 35 | ```python 36 | import asyncio 37 | 38 | async def run(): 39 | async with jdwplib.JDWPClient("127.0.0.1", 1234) as client: 40 | thread_id = await client.advance_to_breakpoint("Landroid/app/Activity;", "onCreate") 41 | response = await client.exec(thread_id, "sleep 5") 42 | 43 | asyncio.run(run()) 44 | ``` 45 | ADB shortcut: 46 | ```python 47 | async with await jdwplib.JDWPClient.connect_adb() as client: 48 | ... 49 | ``` 50 | """ 51 | 52 | sizes: IDSizes 53 | 54 | def __init__( 55 | self, 56 | host: str, 57 | port: int, 58 | ): 59 | self.host: str = host 60 | self.port: int = port 61 | self._reply_waiter: dict[int, asyncio.Event] = {} 62 | self._replies: dict[int, Packet] = {} 63 | self.current_id: int = 0 64 | self.server_commands: asyncio.Queue[Packet] = asyncio.Queue() 65 | self._classes_cache: dict[str, bytes] = {} 66 | self._methods_cache: dict[bytes, bytes] = {} 67 | 68 | @classmethod 69 | async def connect_adb(cls, adb_binary: Path | None = None) -> JDWPClient: 70 | """Take the first (!) debuggable PID found via ADB, forward it via TCP, and connect to it.""" 71 | log.info("Obtaining jdwp pid from adb...") 72 | if adb_binary is None: 73 | adb_binary = Path("adb") 74 | 75 | async def try_read_pid() -> int: 76 | proc = await asyncio.create_subprocess_shell( 77 | f"{adb_binary} jdwp", 78 | stdout=asyncio.subprocess.PIPE, 79 | ) 80 | try: 81 | assert proc.stdout 82 | pid = int(await proc.stdout.readline()) 83 | finally: 84 | proc.kill() 85 | proc._transport.close() # https://bugs.python.org/issue43884 86 | return pid 87 | 88 | pid = None 89 | for i in range(1, 4): 90 | try: 91 | pid = await asyncio.wait_for(try_read_pid(), i) 92 | break 93 | except asyncio.TimeoutError: 94 | log.info("Timeout...") 95 | if pid is None: 96 | raise RuntimeError("`adb jdwp` did not return a process id.") 97 | log.info(f"{pid=}") 98 | 99 | log.info("Forwarding to local port...") 100 | proc = await asyncio.create_subprocess_shell( 101 | f"{adb_binary} forward tcp:0 jdwp:{pid}", 102 | stdout=asyncio.subprocess.PIPE, 103 | ) 104 | assert proc.stdout 105 | local_port = int(await proc.stdout.readline()) 106 | await proc.wait() 107 | log.info(f"{local_port=}") 108 | return JDWPClient("127.0.0.1", local_port) 109 | 110 | async def __aenter__(self) -> JDWPClient: 111 | log.info("Establishing connection...") 112 | self.reader, self.writer = await asyncio.open_connection(self.host, self.port) 113 | 114 | log.info("Starting handshake...") 115 | self.writer.write(HANDSHAKE) 116 | reply = await self.reader.readexactly(len(HANDSHAKE)) 117 | if reply != HANDSHAKE: 118 | raise RuntimeError(f"Handshake failed: {reply=}") 119 | self._reader_task_instance = asyncio.create_task(self._reader_task()) 120 | 121 | log.info("Obtaining Java id sizes...") 122 | sizes = await self.send_command(Commands.GET_ID_SIZES) 123 | assert not sizes.message 124 | self.sizes = IDSizes(sizes.data) 125 | 126 | log.info("Getting version info...") 127 | version_info = await self.send_command(Commands.VERSION) 128 | buf = io.BytesIO(version_info.data) 129 | description = _read_str(buf) 130 | versions_ = buf.read(8) 131 | vm_version_ = _read_str(buf) 132 | vm_name_ = _read_str(buf) 133 | log.info(f"JDWP Version: {description}") 134 | return self 135 | 136 | async def __aexit__(self, exc_type, exc_val, exc_tb): 137 | self.writer.close() 138 | self._reader_task_instance.cancel("connection closed") 139 | 140 | async def _reader_task(self): 141 | while True: 142 | header = await self.reader.readexactly(11) 143 | length, id, flags, message = struct.unpack_from("!IIBH", header) 144 | data = await self.reader.readexactly(length - 11) 145 | packet = Packet(id, flags, message, data) 146 | 147 | if packet.is_reply: 148 | self._replies[packet.id] = packet 149 | self._reply_waiter[packet.id].set() 150 | else: 151 | await self.server_commands.put(packet) 152 | 153 | log.debug(f"<< {packet}") 154 | if packet.is_reply and packet.message: 155 | log.error(f"Command errored: {packet}") 156 | 157 | async def send_command(self, command: Commands, data: bytes = b"") -> Packet: 158 | """ 159 | Send a generic request to the VM, wait for the response, and return it. 160 | """ 161 | cmd = Packet(self.current_id, 0, command.value, data) 162 | log.debug(f">> {cmd}") 163 | self.writer.write(bytes(cmd)) 164 | self._reply_waiter[cmd.id] = asyncio.Event() 165 | self.current_id += 1 166 | 167 | await self._reply_waiter[cmd.id].wait() 168 | del self._reply_waiter[cmd.id] 169 | return self._replies.pop(cmd.id) 170 | 171 | async def get_first_class_id(self, cls_sig: str) -> bytes | None: 172 | """ 173 | Get the class id for the first class matching the signature, 174 | e.g. "Ljava/lang/Runtime;". 175 | """ 176 | if cls_sig not in self._classes_cache: 177 | resp = await self.send_command( 178 | Commands.CLASSES_BY_SIGNATURE, _encode_jdwp_str(cls_sig) 179 | ) 180 | (classes,) = struct.unpack_from("!I", resp.data) 181 | if not classes: 182 | raise ValueError(f"Class not found: {cls_sig}") 183 | else: 184 | self._classes_cache[cls_sig] = resp.data[5 : 5 + self.sizes.reference] 185 | 186 | return self._classes_cache[cls_sig] 187 | 188 | async def get_first_method_id( 189 | self, 190 | cls_id: bytes, 191 | method_sig: str, 192 | ) -> bytes: 193 | """ 194 | Get the method id for the first method matching the signature in the given class, 195 | e.g. "getRuntime". If multiple implementation are available, you can additionally specify the signature, 196 | e.g. "getRuntime()Ljava/lang/Runtime;". 197 | """ 198 | i = method_sig.find("(") 199 | if i != -1: 200 | name = method_sig[:i] 201 | signature = method_sig[i:] 202 | else: 203 | name = method_sig 204 | signature = None 205 | 206 | if cls_id not in self._methods_cache: 207 | resp = await self.send_command(Commands.METHODS, cls_id) 208 | self._methods_cache[cls_id] = resp.data 209 | 210 | buf = io.BytesIO(self._methods_cache[cls_id]) 211 | (methods,) = struct.unpack("!I", buf.read(4)) 212 | for _ in range(methods): 213 | id = buf.read(self.sizes.method) 214 | n = _read_str(buf) 215 | sig = _read_str(buf) 216 | (mod_bits,) = struct.unpack("!I", buf.read(4)) 217 | is_a_match = name == n and (signature is None or signature == sig) 218 | if is_a_match: 219 | return id 220 | 221 | raise ValueError(f"Method not found: {method_sig}") 222 | 223 | async def advance_to_breakpoint(self, cls_sig: str, method_name: str) -> bytes: 224 | """ 225 | Set a breakpoint at a given location, and then resume the VM until the breakpoint is hit. 226 | This dance yields a correct thread id. 227 | """ 228 | cls_id = await self.get_first_class_id(cls_sig) 229 | assert cls_id 230 | meth_id = await self.get_first_method_id(cls_id, method_name) 231 | assert meth_id 232 | 233 | # set breakpoint 234 | resp = await self.send_command( 235 | Commands.SET_BREAKPOINT, 236 | b"\x02" # EventKind: Breakpoint 237 | b"\x02" # SuspendPolicy: all 238 | b"\x00\x00\x00\x01" # one modifier 239 | b"\x07" # location only 240 | b"\x01" + cls_id + meth_id + b"\x00" * 8, 241 | ) 242 | 243 | # resume vm 244 | await self.send_command(Commands.RESUME_VM) 245 | 246 | # wait for breakpoint event 247 | while True: 248 | command = await self.server_commands.get() 249 | if command.message == Commands.EVENT_COMPOSITE.value: 250 | buf = io.BytesIO(command.data) 251 | suspend_policy_ = buf.read(1) 252 | events = buf.read(4) 253 | kind = buf.read(1) 254 | request_id = buf.read(4) 255 | if ( 256 | events == b"\x00\x00\x00\x01" 257 | and kind == b"\x02" 258 | and request_id == resp.data 259 | ): 260 | thread_id = buf.read(self.sizes.object) 261 | break 262 | log.debug(f"Command did not match expected event and got discarded.") 263 | 264 | return thread_id 265 | 266 | async def get_runtime(self, thread_id: bytes) -> bytes: 267 | """ 268 | Get the instance id of the current runtime. 269 | """ 270 | runtime_class_id = await self.get_first_class_id("Ljava/lang/Runtime;") 271 | assert runtime_class_id 272 | get_runtime = await self.get_first_method_id( 273 | runtime_class_id, "getRuntime()Ljava/lang/Runtime;" 274 | ) 275 | assert get_runtime 276 | 277 | resp = await self.send_command( 278 | Commands.INVOKE_STATIC_METHOD, 279 | runtime_class_id 280 | + thread_id 281 | + get_runtime 282 | + b"\x00\x00\x00\x00" 283 | + b"\x00\x00\x00\x00", 284 | ) 285 | runtime_id = resp.data[1 : 1 + self.sizes.object] 286 | return runtime_id 287 | 288 | async def create_string(self, s: str) -> bytes: 289 | """ 290 | Create a string on the VM, get the string id in return. 291 | """ 292 | resp = await self.send_command(Commands.CREATE_STRING, _encode_jdwp_str(s)) 293 | assert resp.data 294 | return resp.data 295 | 296 | async def invoke_method( 297 | self, 298 | object_id: bytes, 299 | thread_id: bytes, 300 | class_sig: str, 301 | method_sig: str, 302 | arguments: bytes = b"\x00\x00\x00\x00", 303 | ) -> bytes: 304 | class_id = await self.get_first_class_id(class_sig) 305 | assert class_id 306 | method_id = await self.get_first_method_id(class_id, method_sig) 307 | assert method_id 308 | 309 | resp = await self.send_command( 310 | Commands.INVOKE_METHOD, 311 | object_id 312 | + thread_id 313 | + class_id 314 | + method_id 315 | + arguments 316 | + b"\x00\x00\x00\x00", 317 | ) 318 | assert resp.message == 0 319 | 320 | exception = resp.data[-self.sizes.object :] 321 | if exception != b"\x00\x00\x00\x00\x00\x00\x00\x00": 322 | throwable = await self.get_first_class_id("Ljava/lang/Throwable;") 323 | assert throwable 324 | get_message = await self.get_first_method_id( 325 | throwable, "toString()Ljava/lang/String;" 326 | ) 327 | assert get_message 328 | resp = await self.send_command( 329 | Commands.INVOKE_METHOD, 330 | exception 331 | + thread_id 332 | + throwable 333 | + get_message 334 | + b"\x00\x00\x00\x00" 335 | + b"\x00\x00\x00\x00", 336 | ) 337 | assert resp.message == 0 338 | assert ( 339 | resp.data[-self.sizes.object :] == b"\x00\x00\x00\x00\x00\x00\x00\x00" 340 | ) 341 | resp = await self.send_command( 342 | Commands.STRING_VALUE, resp.data[1 : self.sizes.reference + 1] 343 | ) 344 | val = _read_str(io.BytesIO(resp.data)) 345 | raise RuntimeError( 346 | f"Method invocation of {class_sig}.{method_sig} failed: {val}" 347 | ) 348 | 349 | return resp.data[: -(self.sizes.object + 1)] 350 | 351 | async def exec(self, thread_id: bytes, cmd: str) -> int: 352 | """ 353 | Execute a command using `Runtime.getRuntime().exec(cmd)`. 354 | """ 355 | runtime = await self.get_runtime(thread_id) 356 | cmd_str = await self.create_string(cmd) 357 | 358 | resp = await self.invoke_method( 359 | runtime, 360 | thread_id, 361 | "Ljava/lang/Runtime;", 362 | "exec(Ljava/lang/String;)Ljava/lang/Process;", 363 | b"\x00\x00\x00\x01L" + cmd_str, 364 | ) 365 | process = resp[1:] 366 | 367 | # wait for process to exit 368 | resp = await self.invoke_method( 369 | process, thread_id, "Ljava/lang/Process;", "waitFor()I" 370 | ) 371 | (exit_code,) = struct.unpack_from("!I", resp, 1) 372 | 373 | if exit_code: 374 | logging.error(f"Command {cmd!r} return exit code {exit_code}") 375 | return exit_code 376 | 377 | async def load(self, thread_id: bytes, path: str): 378 | """ 379 | Load a library using `Runtime.getRuntime().load(cmd)`. 380 | """ 381 | runtime_id = await self.get_runtime(thread_id) 382 | assert runtime_id 383 | 384 | cmd_str = await self.create_string(path) 385 | args = b"\x00\x00\x00\x01L" + cmd_str 386 | resp = await self.invoke_method( 387 | runtime_id, 388 | thread_id, 389 | "Ljava/lang/Runtime;", 390 | "load(Ljava/lang/String;)V", 391 | args, 392 | ) 393 | assert resp == b"V" 394 | 395 | 396 | @dataclass 397 | class Packet: 398 | """ 399 | A packet sent over the connection, can be either a command or a reply. 400 | 401 | 402 | """ 403 | 404 | id: int 405 | flags: int 406 | message: int 407 | """ 408 | Bytes 10-11 of the packet as a big-endian integer. 409 | For commands, this is the command set and the command id. 410 | For replies, this is the error code. 411 | """ 412 | data: bytes 413 | 414 | @property 415 | def is_reply(self) -> bool: 416 | return bool(self.flags & REPLY_PACKET) 417 | 418 | def __repr__(self): 419 | if self.is_reply: 420 | typ = "Reply" 421 | message = f"0x{self.message:04x}" 422 | else: 423 | typ = "Commd" 424 | try: 425 | message = Commands(self.message).name 426 | except ValueError: 427 | message = f"0x{self.message:04x}" 428 | return f"{typ}(0x{self.id:04x}, {message}, {self.data!r})" 429 | 430 | def __bytes__(self): 431 | total_len = 11 + len(self.data) 432 | return ( 433 | struct.pack("!IIBH", total_len, self.id, self.flags, self.message) 434 | + self.data 435 | ) 436 | 437 | 438 | @dataclass 439 | class IDSizes: 440 | """ 441 | Container type holding the size information of various data type on the VM. 442 | 443 | 444 | """ 445 | 446 | field: int 447 | method: int 448 | object: int 449 | reference: int 450 | frame: int 451 | 452 | def __init__(self, data: bytes): 453 | ( 454 | self.field, 455 | self.method, 456 | self.object, 457 | self.reference, 458 | self.frame, 459 | ) = struct.unpack("!IIIII", data) 460 | 461 | 462 | class Commands(enum.IntEnum): 463 | """ 464 | Incomplete enumeration of command constants taken from 465 | . 466 | 467 | For example, the IDSizes command is command set 1 and command 7. We represent it as 468 | `0x0107`. 469 | """ 470 | 471 | VERSION = 0x0101 472 | CLASSES_BY_SIGNATURE = 0x0102 473 | GET_ID_SIZES = 0x0107 474 | RESUME_VM = 0x0109 475 | CREATE_STRING = 0x010B 476 | METHODS = 0x0205 477 | INVOKE_STATIC_METHOD = 0x0303 478 | INVOKE_METHOD = 0x0906 479 | SET_BREAKPOINT = 0x0F01 480 | EVENT_COMPOSITE = 0x4064 481 | STRING_VALUE = 0x0A01 482 | 483 | 484 | def _read_str(buf: io.BytesIO) -> str: 485 | """Read a length-prefixed UTF8 string from a buffer.""" 486 | (l,) = struct.unpack("!I", buf.read(4)) 487 | return buf.read(l).decode() 488 | 489 | 490 | def _encode_jdwp_str(x: str) -> bytes: 491 | """Encode a string as length-prefixed UTF8.""" 492 | xb = x.encode() 493 | return len(xb).to_bytes(4, "big") + xb 494 | -------------------------------------------------------------------------------- /android_unpinner/scripts/hide-debugger.js: -------------------------------------------------------------------------------- 1 | const android_log_write = new NativeFunction(Module.getExportByName(null, '__android_log_write'), 'int', ['int', 'pointer', 'pointer']); 2 | const log = (message) => { 3 | const tag = Memory.allocUtf8String("frida"); 4 | const str = Memory.allocUtf8String(message); 5 | android_log_write(3, tag, str); 6 | }; 7 | 8 | Java.perform(function () { 9 | 10 | var SystemProperties = Java.use('android.os.SystemProperties'); 11 | var get = SystemProperties.get.overload('java.lang.String'); 12 | 13 | get.implementation = function (name) { 14 | if (name === "re.debuggable") { 15 | log("Fake Debuggable"); 16 | return "0"; 17 | } 18 | return this.get.call(this, name); 19 | }; 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /android_unpinner/scripts/httptoolkit-unpinner.js: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | // Vendored from the fantastic HTTP Toolkit: 3 | // https://github.com/httptoolkit/frida-interception-and-unpinning 4 | /************************************************************************************************** 5 | * 6 | * This script defines a large set of targeted certificate unpinning hooks: matching specific 7 | * methods in certain classes, and transforming their behaviour to ensure that restrictions to 8 | * TLS trust are disabled. 9 | * 10 | * This does not disable TLS protections completely - each hook is designed to disable only 11 | * *additional* restrictions, and to explicitly trust the certificate provided as CERT_PEM in the 12 | * config.js configuration file, preserving normal TLS protections wherever possible, even while 13 | * allowing for controlled MitM of local traffic. 14 | * 15 | * The file consists of a few general-purpose methods, then a data structure declaratively 16 | * defining the classes & methods to match, and how to transform them, and then logic at the end 17 | * which uses this data structure, applying the transformation for each found match to the 18 | * target process. 19 | * 20 | * For more details on what was matched, and log output when each hooked method is actually used, 21 | * enable DEBUG_MODE in config.js, and watch the Frida output after running this script. 22 | * 23 | * Source available at https://github.com/httptoolkit/frida-interception-and-unpinning/ 24 | * SPDX-License-Identifier: AGPL-3.0-or-later 25 | * SPDX-FileCopyrightText: Tim Perry 26 | * 27 | *************************************************************************************************/ 28 | 29 | const DEBUG_MODE = false; 30 | 31 | function buildX509CertificateFromBytes(certBytes) { 32 | const ByteArrayInputStream = Java.use('java.io.ByteArrayInputStream'); 33 | const CertFactory = Java.use('java.security.cert.CertificateFactory'); 34 | const certFactory = CertFactory.getInstance("X.509"); 35 | return certFactory.generateCertificate(ByteArrayInputStream.$new(certBytes)); 36 | } 37 | 38 | function getCustomTrustManagerFactory() { 39 | // This is the one X509Certificate that we want to trust. No need to trust others (we should capture 40 | // _all_ TLS traffic) and risky to trust _everything_ (risks interception between device & proxy, or 41 | // worse: some traffic being unintercepted & sent as HTTPS with TLS effectively disabled over the 42 | // real web - potentially exposing auth keys, private data and all sorts). 43 | const certBytes = Java.use("java.lang.String").$new(CERT_PEM).getBytes(); 44 | const trustedCACert = buildX509CertificateFromBytes(certBytes); 45 | 46 | // Build a custom TrustManagerFactory with a KeyStore that trusts only this certificate: 47 | 48 | const KeyStore = Java.use("java.security.KeyStore"); 49 | const keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); 50 | keyStore.load(null); 51 | keyStore.setCertificateEntry("ca", trustedCACert); 52 | 53 | const TrustManagerFactory = Java.use("javax.net.ssl.TrustManagerFactory"); 54 | const customTrustManagerFactory = TrustManagerFactory.getInstance( 55 | TrustManagerFactory.getDefaultAlgorithm() 56 | ); 57 | customTrustManagerFactory.init(keyStore); 58 | 59 | return customTrustManagerFactory; 60 | } 61 | 62 | function getCustomX509TrustManager() { 63 | const customTrustManagerFactory = getCustomTrustManagerFactory(); 64 | const trustManagers = customTrustManagerFactory.getTrustManagers(); 65 | 66 | const X509TrustManager = Java.use('javax.net.ssl.X509TrustManager'); 67 | 68 | const x509TrustManager = trustManagers.find((trustManager) => { 69 | return trustManager.class.isAssignableFrom(X509TrustManager.class); 70 | }); 71 | 72 | // We have to cast it explicitly before Frida will allow us to use the X509 methods: 73 | return Java.cast(x509TrustManager, X509TrustManager); 74 | } 75 | 76 | // Some standard hook replacements for various cases: 77 | const NO_OP = () => {}; 78 | const RETURN_TRUE = () => true; 79 | const CHECK_OUR_TRUST_MANAGER_ONLY = () => { 80 | const trustManager = getCustomX509TrustManager(); 81 | return (certs, authType) => { 82 | trustManager.checkServerTrusted(certs, authType); 83 | }; 84 | }; 85 | 86 | const PINNING_FIXES = { 87 | // --- Native HttpsURLConnection 88 | 89 | 'javax.net.ssl.HttpsURLConnection': [ 90 | { 91 | methodName: 'setDefaultHostnameVerifier', 92 | replacement: () => NO_OP 93 | }, 94 | { 95 | methodName: 'setSSLSocketFactory', 96 | replacement: () => NO_OP 97 | }, 98 | { 99 | methodName: 'setHostnameVerifier', 100 | replacement: () => NO_OP 101 | }, 102 | ], 103 | 104 | // --- Native SSLContext 105 | 106 | 'javax.net.ssl.SSLContext': [ 107 | { 108 | methodName: 'init', 109 | overload: ['[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom'], 110 | replacement: (targetMethod) => { 111 | const customTrustManagerFactory = getCustomTrustManagerFactory(); 112 | 113 | // When constructor is called, replace the trust managers argument: 114 | return function (keyManager, _providedTrustManagers, secureRandom) { 115 | return targetMethod.call(this, 116 | keyManager, 117 | customTrustManagerFactory.getTrustManagers(), // Override their trust managers 118 | secureRandom 119 | ); 120 | } 121 | } 122 | } 123 | ], 124 | 125 | // --- Native Conscrypt CertPinManager 126 | 127 | 'com.android.org.conscrypt.CertPinManager': [ 128 | { 129 | methodName: 'isChainValid', 130 | replacement: () => RETURN_TRUE 131 | }, 132 | { 133 | methodName: 'checkChainPinning', 134 | replacement: () => NO_OP 135 | } 136 | ], 137 | 138 | // --- Native pinning configuration loading (used for configuration by many libraries) 139 | 140 | 'android.security.net.config.NetworkSecurityConfig': [ 141 | { 142 | methodName: '$init', 143 | overload: '*', 144 | replacement: (targetMethod) => { 145 | const PinSet = Java.use('android.security.net.config.PinSet'); 146 | const EMPTY_PINSET = PinSet.EMPTY_PINSET.value; 147 | return function () { 148 | // Always ignore the 2nd 'pins' PinSet argument entirely: 149 | arguments[2] = EMPTY_PINSET; 150 | targetMethod.call(this, ...arguments); 151 | } 152 | } 153 | } 154 | ], 155 | 156 | // --- Native HostnameVerification override (n.b. Android contains its own vendored OkHttp v2!) 157 | 158 | 'com.android.okhttp.internal.tls.OkHostnameVerifier': [ 159 | { 160 | methodName: 'verify', 161 | overload: [ 162 | 'java.lang.String', 163 | 'javax.net.ssl.SSLSession' 164 | ], 165 | replacement: (targetMethod) => { 166 | // Our trust manager - this trusts *only* our extra CA 167 | const trustManager = getCustomX509TrustManager(); 168 | 169 | return function (hostname, sslSession) { 170 | try { 171 | const certs = sslSession.getPeerCertificates(); 172 | 173 | // https://stackoverflow.com/a/70469741/68051 174 | const authType = "RSA"; 175 | 176 | // This throws if the certificate isn't trusted (i.e. if it's 177 | // not signed by our extra CA specifically): 178 | trustManager.checkServerTrusted(certs, authType); 179 | 180 | // If the cert is from our CA, great! Skip hostname checks entirely. 181 | return true; 182 | } catch (e) {} // Ignore errors and fallback to default behaviour 183 | 184 | // We fallback to ensure that connections with other CAs (e.g. direct 185 | // connections allowed past the proxy) validate as normal. 186 | return targetMethod.call(this, ...arguments); 187 | } 188 | } 189 | } 190 | ], 191 | 192 | 'com.android.okhttp.Address': [ 193 | { 194 | methodName: '$init', 195 | overload: [ 196 | 'java.lang.String', 197 | 'int', 198 | 'com.android.okhttp.Dns', 199 | 'javax.net.SocketFactory', 200 | 'javax.net.ssl.SSLSocketFactory', 201 | 'javax.net.ssl.HostnameVerifier', 202 | 'com.android.okhttp.CertificatePinner', 203 | 'com.android.okhttp.Authenticator', 204 | 'java.net.Proxy', 205 | 'java.util.List', 206 | 'java.util.List', 207 | 'java.net.ProxySelector' 208 | ], 209 | replacement: (targetMethod) => { 210 | const defaultHostnameVerifier = Java.use("com.android.okhttp.internal.tls.OkHostnameVerifier") 211 | .INSTANCE.value; 212 | const defaultCertPinner = Java.use("com.android.okhttp.CertificatePinner") 213 | .DEFAULT.value; 214 | 215 | return function () { 216 | // Override arguments, to swap any custom check params (widely used 217 | // to add stricter rules to TLS verification) with the defaults instead: 218 | arguments[5] = defaultHostnameVerifier; 219 | arguments[6] = defaultCertPinner; 220 | 221 | targetMethod.call(this, ...arguments); 222 | } 223 | } 224 | }, 225 | // Almost identical patch, but for Nougat and older. In these versions, the DNS argument 226 | // isn't passed here, so the arguments to patch changes slightly: 227 | { 228 | methodName: '$init', 229 | overload: [ 230 | 'java.lang.String', 231 | 'int', 232 | // No DNS param 233 | 'javax.net.SocketFactory', 234 | 'javax.net.ssl.SSLSocketFactory', 235 | 'javax.net.ssl.HostnameVerifier', 236 | 'com.android.okhttp.CertificatePinner', 237 | 'com.android.okhttp.Authenticator', 238 | 'java.net.Proxy', 239 | 'java.util.List', 240 | 'java.util.List', 241 | 'java.net.ProxySelector' 242 | ], 243 | replacement: (targetMethod) => { 244 | const defaultHostnameVerifier = Java.use("com.android.okhttp.internal.tls.OkHostnameVerifier") 245 | .INSTANCE.value; 246 | const defaultCertPinner = Java.use("com.android.okhttp.CertificatePinner") 247 | .DEFAULT.value; 248 | 249 | return function () { 250 | // Override arguments, to swap any custom check params (widely used 251 | // to add stricter rules to TLS verification) with the defaults instead: 252 | arguments[4] = defaultHostnameVerifier; 253 | arguments[5] = defaultCertPinner; 254 | 255 | targetMethod.call(this, ...arguments); 256 | } 257 | } 258 | } 259 | ], 260 | 261 | // --- OkHttp v3 262 | 263 | 'okhttp3.CertificatePinner': [ 264 | { 265 | methodName: 'check', 266 | overload: ['java.lang.String', 'java.util.List'], 267 | replacement: () => NO_OP 268 | }, 269 | { 270 | methodName: 'check', 271 | overload: ['java.lang.String', 'java.security.cert.Certificate'], 272 | replacement: () => NO_OP 273 | }, 274 | { 275 | methodName: 'check', 276 | overload: ['java.lang.String', '[Ljava.security.cert.Certificate;'], 277 | replacement: () => NO_OP 278 | }, 279 | { 280 | methodName: 'check$okhttp', 281 | replacement: () => NO_OP 282 | }, 283 | ], 284 | 285 | // --- SquareUp OkHttp (< v3) 286 | 287 | 'com.squareup.okhttp.CertificatePinner': [ 288 | { 289 | methodName: 'check', 290 | overload: ['java.lang.String', 'java.security.cert.Certificate'], 291 | replacement: () => NO_OP 292 | }, 293 | { 294 | methodName: 'check', 295 | overload: ['java.lang.String', 'java.util.List'], 296 | replacement: () => NO_OP 297 | } 298 | ], 299 | 300 | // --- Trustkit (https://github.com/datatheorem/TrustKit-Android/) 301 | 302 | 'com.datatheorem.android.trustkit.pinning.PinningTrustManager': [ 303 | { 304 | methodName: 'checkServerTrusted', 305 | replacement: CHECK_OUR_TRUST_MANAGER_ONLY 306 | } 307 | ], 308 | 309 | // --- Appcelerator (https://github.com/tidev/appcelerator.https) 310 | 311 | 'appcelerator.https.PinningTrustManager': [ 312 | { 313 | methodName: 'checkServerTrusted', 314 | replacement: CHECK_OUR_TRUST_MANAGER_ONLY 315 | } 316 | ], 317 | 318 | // --- PhoneGap sslCertificateChecker (https://github.com/EddyVerbruggen/SSLCertificateChecker-PhoneGap-Plugin) 319 | 320 | 'nl.xservices.plugins.sslCertificateChecker': [ 321 | { 322 | methodName: 'execute', 323 | overload: ['java.lang.String', 'org.json.JSONArray', 'org.apache.cordova.CallbackContext'], 324 | replacement: () => (_action, _args, context) => { 325 | context.success("CONNECTION_SECURE"); 326 | return true; 327 | } 328 | // This trusts _all_ certs, but that's fine - this is used for checks of independent test 329 | // connections, rather than being a primary mechanism to secure the app's TLS connections. 330 | } 331 | ], 332 | 333 | // --- IBM WorkLight 334 | 335 | 'com.worklight.wlclient.api.WLClient': [ 336 | { 337 | methodName: 'pinTrustedCertificatePublicKey', 338 | getMethod: (WLClientCls) => WLClientCls.getInstance().pinTrustedCertificatePublicKey, 339 | overload: '*' 340 | } 341 | ], 342 | 343 | 'com.worklight.wlclient.certificatepinning.HostNameVerifierWithCertificatePinning': [ 344 | { 345 | methodName: 'verify', 346 | overload: '*', 347 | replacement: () => NO_OP 348 | } 349 | // This covers at least 4 commonly used WorkLight patches. Oddly, most sets of hooks seem 350 | // to return true for 1/4 cases, which must be wrong (overloads must all have the same 351 | // return type) but also it's very hard to find any modern (since 2017) references to this 352 | // class anywhere including WorkLight docs, so it may no longer be relevant anyway. 353 | ], 354 | 355 | 'com.worklight.androidgap.plugin.WLCertificatePinningPlugin': [ 356 | { 357 | methodName: 'execute', 358 | overload: '*', 359 | replacement: () => RETURN_TRUE 360 | } 361 | ], 362 | 363 | // --- CWAC-Netsecurity (unofficial back-port pinner for Android<4.2) CertPinManager 364 | 365 | 'com.commonsware.cwac.netsecurity.conscrypt.CertPinManager': [ 366 | { 367 | methodName: 'isChainValid', 368 | overload: '*', 369 | replacement: () => RETURN_TRUE 370 | } 371 | ], 372 | 373 | // --- Netty 374 | 375 | 'io.netty.handler.ssl.util.FingerprintTrustManagerFactory': [ 376 | { 377 | methodName: 'checkTrusted', 378 | replacement: () => NO_OP 379 | } 380 | ], 381 | 382 | // --- Cordova / PhoneGap Advanced HTTP Plugin (https://github.com/silkimen/cordova-plugin-advanced-http) 383 | 384 | // Modern version: 385 | 'com.silkimen.cordovahttp.CordovaServerTrust': [ 386 | { 387 | methodName: '$init', 388 | replacement: (targetMethod) => function () { 389 | // Ignore any attempts to set trust to 'pinned'. Default settings will trust 390 | // our cert because of the separate system-certificate injection step. 391 | if (arguments[0] === 'pinned') { 392 | arguments[0] = 'default'; 393 | } 394 | 395 | return targetMethod.call(this, ...arguments); 396 | } 397 | } 398 | ], 399 | 400 | // --- Appmattus Cert Transparency (https://github.com/appmattus/certificatetransparency/) 401 | 402 | 'com.appmattus.certificatetransparency.internal.verifier.CertificateTransparencyHostnameVerifier': [ 403 | { 404 | methodName: 'verify', 405 | replacement: () => RETURN_TRUE 406 | // This is not called unless the cert passes basic trust checks, so it's safe to blindly accept. 407 | } 408 | ], 409 | 410 | 'com.appmattus.certificatetransparency.internal.verifier.CertificateTransparencyInterceptor': [ 411 | { 412 | methodName: 'intercept', 413 | replacement: () => (a) => a.proceed(a.request()) 414 | // This is not called unless the cert passes basic trust checks, so it's safe to blindly accept. 415 | } 416 | ], 417 | 418 | 'com.appmattus.certificatetransparency.internal.verifier.CertificateTransparencyTrustManager': [ 419 | { 420 | methodName: 'checkServerTrusted', 421 | overload: ['[Ljava.security.cert.X509Certificate;', 'java.lang.String'], 422 | replacement: CHECK_OUR_TRUST_MANAGER_ONLY, 423 | methodName: 'checkServerTrusted', 424 | overload: ['[Ljava.security.cert.X509Certificate;', 'java.lang.String', 'java.lang.String'], 425 | replacement: () => { 426 | const trustManager = getCustomX509TrustManager(); 427 | return (certs, authType, _hostname) => { 428 | // We ignore the hostname - if the certs are good (i.e they're ours), then the 429 | // whole chain is good to go. 430 | trustManager.checkServerTrusted(certs, authType); 431 | return Java.use('java.util.Arrays').asList(certs); 432 | }; 433 | } 434 | } 435 | ], 436 | 437 | 'com.android.org.conscrypt.TrustManagerImpl': [ 438 | { 439 | methodName: 'checkTrustedRecursive', 440 | replacement: () => { 441 | const arrayList = Java.use("java.util.ArrayList") 442 | return function ( 443 | certs, 444 | host, 445 | clientAuth, 446 | untrustedChain, 447 | trustAnchorChain, 448 | used 449 | ) { 450 | return arrayList.$new(); 451 | } 452 | } 453 | } 454 | ] 455 | }; 456 | 457 | const getJavaClassIfExists = (clsName) => { 458 | try { 459 | return Java.use(clsName); 460 | } catch { 461 | return undefined; 462 | } 463 | } 464 | 465 | Java.perform(function () { 466 | if (DEBUG_MODE) console.log('\n === Disabling all recognized unpinning libraries ==='); 467 | 468 | const classesToPatch = Object.keys(PINNING_FIXES); 469 | 470 | classesToPatch.forEach((targetClassName) => { 471 | const TargetClass = getJavaClassIfExists(targetClassName); 472 | if (!TargetClass) { 473 | // We skip patches for any classes that don't seem to be present. This is common 474 | // as not all libraries we handle are necessarily used. 475 | if (DEBUG_MODE) console.log(`[ ] ${targetClassName} *`); 476 | return; 477 | } 478 | 479 | const patches = PINNING_FIXES[targetClassName]; 480 | 481 | let patchApplied = false; 482 | 483 | patches.forEach(({ methodName, getMethod, overload, replacement }) => { 484 | const namedTargetMethod = getMethod 485 | ? getMethod(TargetClass) 486 | : TargetClass[methodName]; 487 | 488 | const methodDescription = `${methodName}${ 489 | overload === '*' 490 | ? '(*)' 491 | : overload 492 | ? '(' + overload.map((argType) => { 493 | // Simplify arg names to just the class name for simpler logs: 494 | const argClassName = argType.split('.').slice(-1)[0]; 495 | if (argType.startsWith('[L')) return `${argClassName}[]`; 496 | else return argClassName; 497 | }).join(', ') + ')' 498 | // No overload: 499 | : '' 500 | }` 501 | 502 | let targetMethodImplementations = []; 503 | try { 504 | if (namedTargetMethod) { 505 | if (!overload) { 506 | // No overload specified 507 | targetMethodImplementations = [namedTargetMethod]; 508 | } else if (overload === '*') { 509 | // Targetting _all_ overloads 510 | targetMethodImplementations = namedTargetMethod.overloads; 511 | } else { 512 | // Or targetting a specific overload: 513 | targetMethodImplementations = [namedTargetMethod.overload(...overload)]; 514 | } 515 | } 516 | } catch (e) { 517 | // Overload not present 518 | } 519 | 520 | 521 | // We skip patches for any methods that don't seem to be present. This is rarer, but does 522 | // happen due to methods that only appear in certain library versions or whose signatures 523 | // have changed over time. 524 | if (targetMethodImplementations.length === 0) { 525 | if (DEBUG_MODE) console.log(`[ ] ${targetClassName} ${methodDescription}`); 526 | return; 527 | } 528 | 529 | targetMethodImplementations.forEach((targetMethod, i) => { 530 | const patchName = `${targetClassName} ${methodDescription}${ 531 | targetMethodImplementations.length > 1 ? ` (${i})` : '' 532 | }`; 533 | 534 | try { 535 | const newImplementation = replacement(targetMethod); 536 | if (DEBUG_MODE) { 537 | // Log each hooked method as it's called: 538 | targetMethod.implementation = function () { 539 | console.log(` => ${patchName}`); 540 | return newImplementation.apply(this, arguments); 541 | } 542 | } else { 543 | targetMethod.implementation = newImplementation; 544 | } 545 | 546 | if (DEBUG_MODE) console.log(`[+] ${patchName}`); 547 | patchApplied = true; 548 | } catch (e) { 549 | // In theory, errors like this should never happen - it means the patch is broken 550 | // (e.g. some dynamic patch building fails completely) 551 | console.error(`[!] ERROR: ${patchName} failed: ${e}`); 552 | } 553 | }) 554 | }); 555 | 556 | if (!patchApplied) { 557 | console.warn(`[!] Matched class ${targetClassName} but could not patch any methods`); 558 | } 559 | }); 560 | 561 | console.log('== Certificate unpinning completed =='); 562 | }); 563 | -------------------------------------------------------------------------------- /android_unpinner/vendor/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | here = Path(__file__).absolute().parent 4 | 5 | frida_version = "16.0.10" 6 | gadget_files = { 7 | "arm": here / f"frida/frida-gadget-{frida_version}-android-arm.so", 8 | "arm64": here / f"frida/frida-gadget-{frida_version}-android-arm64.so", 9 | "x86": here / f"frida/frida-gadget-{frida_version}-android-x86.so", 10 | "x86_64": here / f"frida/frida-gadget-{frida_version}-android-x86_64.so", 11 | } 12 | gadget_config_file_script_directory = here / "frida/gadget-config-script-directory.json" 13 | gadget_config_file_listen = here / "frida/gadget-config-listen.json" 14 | -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/NOTICE.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/NOTICE.txt -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/__init__.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | from functools import cache 4 | from pathlib import Path 5 | 6 | here = Path(__file__).absolute().parent 7 | 8 | if sys.platform == "win32": 9 | aapt2_binary = here / "win32" / "aapt2.exe" 10 | apksigner_binary = here / "win32" / "apksigner.bat" 11 | zipalign_binary = here / "win32" / "zipalign.exe" 12 | elif sys.platform == "darwin": 13 | aapt2_binary = here / "darwin" / "aapt2" 14 | apksigner_binary = here / "darwin" / "apksigner" 15 | zipalign_binary = here / "darwin" / "zipalign" 16 | else: 17 | aapt2_binary = here / "linux" / "aapt2" 18 | apksigner_binary = here / "linux" / "apksigner" 19 | zipalign_binary = here / "linux" / "zipalign" 20 | 21 | 22 | def zipalign(apk_file: Path) -> None: 23 | apk_aligned = apk_file.with_suffix(".aligned.apk") 24 | subprocess.run([ 25 | zipalign_binary, 26 | "-p", "4", 27 | apk_file, 28 | apk_aligned 29 | ], check=True) 30 | apk_file.unlink() 31 | apk_aligned.rename(apk_file) 32 | 33 | 34 | def sign(apk_file: Path) -> None: 35 | # android-unpinner.jks was generated as follows: 36 | # keytool -genkey -v -keystore android-unpinner.jks -alias android-unpinner -keyalg RSA -keysize 2048 -validity 3650 37 | subprocess.run([ 38 | apksigner_binary, 39 | "sign", 40 | "--v4-signing-enabled", 41 | "false", 42 | '--ks', 43 | here / "android-unpinner.jks", 44 | '--ks-pass', 45 | 'pass:correcthorsebatterystaple', 46 | '--ks-key-alias', 47 | 'android-unpinner', 48 | apk_file 49 | ], check=True) 50 | 51 | 52 | @cache 53 | def package_name(apk_file: Path) -> str: 54 | return subprocess.run([ 55 | aapt2_binary, 56 | "dump", 57 | "packagename", 58 | apk_file, 59 | ], check=True, capture_output=True, text=True).stdout.strip() 60 | -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/android-unpinner.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/android-unpinner.jks -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/darwin/aapt2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/darwin/aapt2 -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/darwin/apksigner: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright (C) 2016 The Android Open Source Project 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Set up prog to be the path of this script, including following symlinks, 18 | # and set up progdir to be the fully-qualified pathname of its directory. 19 | prog="$0" 20 | while [ -h "${prog}" ]; do 21 | newProg=`/bin/ls -ld "${prog}"` 22 | newProg=`expr "${newProg}" : ".* -> \(.*\)$"` 23 | if expr "x${newProg}" : 'x/' >/dev/null; then 24 | prog="${newProg}" 25 | else 26 | progdir=`dirname "${prog}"` 27 | prog="${progdir}/${newProg}" 28 | fi 29 | done 30 | oldwd=`pwd` 31 | progdir=`dirname "${prog}"` 32 | cd "${progdir}" 33 | progdir=`pwd` 34 | prog="${progdir}"/`basename "${prog}"` 35 | cd "${oldwd}" 36 | 37 | jarfile=apksigner.jar 38 | libdir="$progdir" 39 | 40 | if [ ! -r "$libdir/$jarfile" ]; then 41 | # set apksigner.jar location for the SDK case 42 | libdir="$libdir/lib" 43 | fi 44 | 45 | 46 | if [ ! -r "$libdir/$jarfile" ]; then 47 | # set apksigner.jar location for the Android tree case 48 | libdir=`dirname "$progdir"`/framework 49 | # also include the library directory for any provider native libraries 50 | providerLibdir=`dirname "$progdir"`/lib64 51 | fi 52 | 53 | if [ ! -r "$libdir/$jarfile" ]; then 54 | echo `basename "$prog"`": can't find $jarfile" 55 | exit 1 56 | fi 57 | 58 | # By default, give apksigner a max heap size of 1 gig. This can be overridden 59 | # by using a "-J" option (see below). 60 | defaultMx="-Xmx1024M" 61 | 62 | # The following will extract any initial parameters of the form 63 | # "-J" from the command line and pass them to the Java 64 | # invocation (instead of to apksigner). This makes it possible for you to add 65 | # a command-line parameter such as "-JXmx256M" in your scripts, for 66 | # example. "java" (with no args) and "java -X" give a summary of 67 | # available options. 68 | 69 | javaOpts="" 70 | 71 | while expr "x$1" : 'x-J' >/dev/null; do 72 | opt=`expr "x$1" : 'x-J\(.*\)'` 73 | javaOpts="${javaOpts} -${opt}" 74 | if expr "x${opt}" : "xXmx[0-9]" >/dev/null; then 75 | defaultMx="no" 76 | elif expr "x${opt}" : "xDjava.library.path=" >/dev/null; then 77 | defaultLibdir="no" 78 | fi 79 | shift 80 | done 81 | 82 | if [ "${defaultMx}" != "no" ]; then 83 | javaOpts="${javaOpts} ${defaultMx}" 84 | fi 85 | 86 | if [ "${defaultLibdir}" != "no" ] && [ -n $providerLibdir ]; then 87 | javaOpts="${javaOpts} -Djava.library.path=$providerLibdir" 88 | fi 89 | 90 | if [ "$OSTYPE" = "cygwin" ]; then 91 | # For Cygwin, convert the jarfile path into native Windows style. 92 | jarpath=`cygpath -w "$libdir/$jarfile"` 93 | else 94 | jarpath="$libdir/$jarfile" 95 | fi 96 | 97 | exec java $javaOpts -jar "$jarpath" "$@" 98 | -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/darwin/lib/apksigner.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/darwin/lib/apksigner.jar -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/darwin/zipalign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/darwin/zipalign -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/linux/aapt2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/linux/aapt2 -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/linux/apksigner: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright (C) 2016 The Android Open Source Project 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Set up prog to be the path of this script, including following symlinks, 18 | # and set up progdir to be the fully-qualified pathname of its directory. 19 | prog="$0" 20 | while [ -h "${prog}" ]; do 21 | newProg=`/bin/ls -ld "${prog}"` 22 | newProg=`expr "${newProg}" : ".* -> \(.*\)$"` 23 | if expr "x${newProg}" : 'x/' >/dev/null; then 24 | prog="${newProg}" 25 | else 26 | progdir=`dirname "${prog}"` 27 | prog="${progdir}/${newProg}" 28 | fi 29 | done 30 | oldwd=`pwd` 31 | progdir=`dirname "${prog}"` 32 | cd "${progdir}" 33 | progdir=`pwd` 34 | prog="${progdir}"/`basename "${prog}"` 35 | cd "${oldwd}" 36 | 37 | jarfile=apksigner.jar 38 | libdir="$progdir" 39 | 40 | if [ ! -r "$libdir/$jarfile" ]; then 41 | # set apksigner.jar location for the SDK case 42 | libdir="$libdir/lib" 43 | fi 44 | 45 | 46 | if [ ! -r "$libdir/$jarfile" ]; then 47 | # set apksigner.jar location for the Android tree case 48 | libdir=`dirname "$progdir"`/framework 49 | # also include the library directory for any provider native libraries 50 | providerLibdir=`dirname "$progdir"`/lib64 51 | fi 52 | 53 | if [ ! -r "$libdir/$jarfile" ]; then 54 | echo `basename "$prog"`": can't find $jarfile" 55 | exit 1 56 | fi 57 | 58 | # By default, give apksigner a max heap size of 1 gig. This can be overridden 59 | # by using a "-J" option (see below). 60 | defaultMx="-Xmx1024M" 61 | 62 | # The following will extract any initial parameters of the form 63 | # "-J" from the command line and pass them to the Java 64 | # invocation (instead of to apksigner). This makes it possible for you to add 65 | # a command-line parameter such as "-JXmx256M" in your scripts, for 66 | # example. "java" (with no args) and "java -X" give a summary of 67 | # available options. 68 | 69 | javaOpts="" 70 | 71 | while expr "x$1" : 'x-J' >/dev/null; do 72 | opt=`expr "x$1" : 'x-J\(.*\)'` 73 | javaOpts="${javaOpts} -${opt}" 74 | if expr "x${opt}" : "xXmx[0-9]" >/dev/null; then 75 | defaultMx="no" 76 | elif expr "x${opt}" : "xDjava.library.path=" >/dev/null; then 77 | defaultLibdir="no" 78 | fi 79 | shift 80 | done 81 | 82 | if [ "${defaultMx}" != "no" ]; then 83 | javaOpts="${javaOpts} ${defaultMx}" 84 | fi 85 | 86 | if [ "${defaultLibdir}" != "no" ] && [ -n $providerLibdir ]; then 87 | javaOpts="${javaOpts} -Djava.library.path=$providerLibdir" 88 | fi 89 | 90 | if [ "$OSTYPE" = "cygwin" ]; then 91 | # For Cygwin, convert the jarfile path into native Windows style. 92 | jarpath=`cygpath -w "$libdir/$jarfile"` 93 | else 94 | jarpath="$libdir/$jarfile" 95 | fi 96 | 97 | exec java $javaOpts -jar "$jarpath" "$@" 98 | -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/linux/lib/apksigner.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/linux/lib/apksigner.jar -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/linux/lib64/libc++.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/linux/lib64/libc++.so -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/linux/zipalign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/linux/zipalign -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/win32/aapt2.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/win32/aapt2.exe -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/win32/apksigner.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | REM Copyright (C) 2016 The Android Open Source Project 3 | REM 4 | REM Licensed under the Apache License, Version 2.0 (the "License"); 5 | REM you may not use this file except in compliance with the License. 6 | REM You may obtain a copy of the License at 7 | REM 8 | REM http://www.apache.org/licenses/LICENSE-2.0 9 | REM 10 | REM Unless required by applicable law or agreed to in writing, software 11 | REM distributed under the License is distributed on an "AS IS" BASIS, 12 | REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | REM See the License for the specific language governing permissions and 14 | REM limitations under the License. 15 | 16 | REM don't modify the caller's environment 17 | setlocal 18 | 19 | REM Locate apksigner.jar in the directory where apksigner.bat was found and start it. 20 | 21 | REM Set up prog to be the path of this script, including following symlinks, 22 | REM and set up progdir to be the fully-qualified pathname of its directory. 23 | set prog=%~f0 24 | 25 | @rem Find java.exe 26 | if defined JAVA_HOME goto findJavaFromJavaHome 27 | 28 | set JAVA_EXE=java.exe 29 | %JAVA_EXE% -version >NUL 2>&1 30 | if "%ERRORLEVEL%" == "0" goto init 31 | 32 | echo. 33 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 34 | echo. 35 | echo Please set the JAVA_HOME variable in your environment to match the 36 | echo location of your Java installation. 37 | exit /b 1 38 | 39 | :findJavaFromJavaHome 40 | set JAVA_HOME=%JAVA_HOME:"=% 41 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 42 | 43 | if exist "%JAVA_EXE%" goto init 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | exit /b 1 51 | 52 | :init 53 | set jarfile=apksigner.jar 54 | set "frameworkdir=%~dp0" 55 | rem frameworkdir must not end with a dir sep. 56 | set "frameworkdir=%frameworkdir:~0,-1%" 57 | 58 | if exist "%frameworkdir%\%jarfile%" goto JarFileOk 59 | set "frameworkdir=%~dp0lib" 60 | 61 | if exist "%frameworkdir%\%jarfile%" goto JarFileOk 62 | set "frameworkdir=%~dp0..\framework" 63 | 64 | :JarFileOk 65 | 66 | set "jarpath=%frameworkdir%\%jarfile%" 67 | 68 | set javaOpts= 69 | set args= 70 | 71 | REM By default, give apksigner a max heap size of 1 gig and a stack size of 1meg. 72 | rem This can be overridden by using "-JXmx..." and "-JXss..." options below. 73 | set defaultXmx=-Xmx1024M 74 | set defaultXss=-Xss1m 75 | 76 | REM Capture all arguments that are not -J options. 77 | REM Note that when reading the input arguments with %1, the cmd.exe 78 | REM automagically converts --name=value arguments into 2 arguments "--name" 79 | REM followed by "value". apksigner has been changed to know how to deal with that. 80 | set params= 81 | 82 | :firstArg 83 | if [%1]==[] goto endArgs 84 | set "a=%~1" 85 | 86 | if [%defaultXmx%]==[] goto notXmx 87 | if "%a:~0,5%" NEQ "-JXmx" goto notXmx 88 | set defaultXmx= 89 | :notXmx 90 | 91 | if [%defaultXss%]==[] goto notXss 92 | if "%a:~0,5%" NEQ "-JXss" goto notXss 93 | set defaultXss= 94 | :notXss 95 | 96 | if "%a:~0,2%" NEQ "-J" goto notJ 97 | set javaOpts=%javaOpts% -%a:~2% 98 | shift /1 99 | goto firstArg 100 | 101 | :notJ 102 | set params=%params% %1 103 | shift /1 104 | goto firstArg 105 | 106 | :endArgs 107 | 108 | set javaOpts=%javaOpts% %defaultXmx% %defaultXss% 109 | call "%java_exe%" %javaOpts% -jar "%jarpath%" %params% 110 | 111 | -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/win32/lib/apksigner.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/win32/lib/apksigner.jar -------------------------------------------------------------------------------- /android_unpinner/vendor/build_tools/win32/zipalign.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/build_tools/win32/zipalign.exe -------------------------------------------------------------------------------- /android_unpinner/vendor/frida/COPYING: -------------------------------------------------------------------------------- 1 | wxWindows Library Licence, Version 3.1 2 | ====================================== 3 | 4 | Copyright (c) 1998-2005 Julian Smart, Robert Roebling et al 5 | 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this licence document, but changing it is not allowed. 8 | 9 | WXWINDOWS LIBRARY LICENCE 10 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 11 | 12 | This library is free software; you can redistribute it and/or modify it 13 | under the terms of the GNU Library General Public Licence as published by 14 | the Free Software Foundation; either version 2 of the Licence, or (at your 15 | option) any later version. 16 | 17 | This library is distributed in the hope that it will be useful, but WITHOUT 18 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 19 | FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public 20 | Licence for more details. 21 | 22 | You should have received a copy of the GNU Library General Public Licence 23 | along with this software, usually in a file named COPYING.LIB. If not, 24 | write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth 25 | Floor, Boston, MA 02110-1301 USA. 26 | 27 | EXCEPTION NOTICE 28 | 29 | 1. As a special exception, the copyright holders of this library give 30 | permission for additional uses of the text contained in this release of the 31 | library as licenced under the wxWindows Library Licence, applying either 32 | version 3.1 of the Licence, or (at your option) any later version of the 33 | Licence as published by the copyright holders of version 3.1 of the Licence 34 | document. 35 | 36 | 2. The exception is that you may use, copy, link, modify and distribute 37 | under your own terms, binary object code versions of works based on the 38 | Library. 39 | 40 | 3. If you copy code from files distributed under the terms of the GNU 41 | General Public Licence or the GNU Library General Public Licence into a 42 | copy of this library, as this licence permits, the exception does not apply 43 | to the code that you add in this way. To avoid misleading anyone as to the 44 | status of such modified files, you must delete this exception notice from 45 | such code and/or adjust the licensing conditions notice accordingly. 46 | 47 | 4. If you write modifications of your own for this library, it is your 48 | choice whether to permit this exception to apply to your modifications. If 49 | you do not wish that, you must delete the exception notice from such code 50 | and/or adjust the licensing conditions notice accordingly. 51 | -------------------------------------------------------------------------------- /android_unpinner/vendor/frida/frida-gadget-16.0.10-android-arm.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/frida/frida-gadget-16.0.10-android-arm.so -------------------------------------------------------------------------------- /android_unpinner/vendor/frida/frida-gadget-16.0.10-android-arm64.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/frida/frida-gadget-16.0.10-android-arm64.so -------------------------------------------------------------------------------- /android_unpinner/vendor/frida/frida-gadget-16.0.10-android-x86.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/frida/frida-gadget-16.0.10-android-x86.so -------------------------------------------------------------------------------- /android_unpinner/vendor/frida/frida-gadget-16.0.10-android-x86_64.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/frida/frida-gadget-16.0.10-android-x86_64.so -------------------------------------------------------------------------------- /android_unpinner/vendor/frida/gadget-config-listen.json: -------------------------------------------------------------------------------- 1 | { 2 | "interaction": { 3 | "type": "listen" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /android_unpinner/vendor/frida/gadget-config-script-directory.json: -------------------------------------------------------------------------------- 1 | { 2 | "interaction": { 3 | "type": "script-directory", 4 | "path": "/data/local/tmp/android-unpinner", 5 | "on_change": "reload" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /android_unpinner/vendor/frida_tools/COPYING: -------------------------------------------------------------------------------- 1 | wxWindows Library Licence, Version 3.1 2 | ====================================== 3 | 4 | Copyright (c) 1998-2005 Julian Smart, Robert Roebling et al 5 | 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this licence document, but changing it is not allowed. 8 | 9 | WXWINDOWS LIBRARY LICENCE 10 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 11 | 12 | This library is free software; you can redistribute it and/or modify it 13 | under the terms of the GNU Library General Public Licence as published by 14 | the Free Software Foundation; either version 2 of the Licence, or (at your 15 | option) any later version. 16 | 17 | This library is distributed in the hope that it will be useful, but WITHOUT 18 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 19 | FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public 20 | Licence for more details. 21 | 22 | You should have received a copy of the GNU Library General Public Licence 23 | along with this software, usually in a file named COPYING.LIB. If not, 24 | write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth 25 | Floor, Boston, MA 02110-1301 USA. 26 | 27 | EXCEPTION NOTICE 28 | 29 | 1. As a special exception, the copyright holders of this library give 30 | permission for additional uses of the text contained in this release of the 31 | library as licenced under the wxWindows Library Licence, applying either 32 | version 3.1 of the Licence, or (at your option) any later version of the 33 | Licence as published by the copyright holders of version 3.1 of the Licence 34 | document. 35 | 36 | 2. The exception is that you may use, copy, link, modify and distribute 37 | under your own terms, binary object code versions of works based on the 38 | Library. 39 | 40 | 3. If you copy code from files distributed under the terms of the GNU 41 | General Public Licence or the GNU Library General Public Licence into a 42 | copy of this library, as this licence permits, the exception does not apply 43 | to the code that you add in this way. To avoid misleading anyone as to the 44 | status of such modified files, you must delete this exception notice from 45 | such code and/or adjust the licensing conditions notice accordingly. 46 | 47 | 4. If you write modifications of your own for this library, it is your 48 | choice whether to permit this exception to apply to your modifications. If 49 | you do not wish that, you must delete the exception notice from such code 50 | and/or adjust the licensing conditions notice accordingly. 51 | -------------------------------------------------------------------------------- /android_unpinner/vendor/frida_tools/__init__.py: -------------------------------------------------------------------------------- 1 | from . import apk 2 | -------------------------------------------------------------------------------- /android_unpinner/vendor/frida_tools/apk.py: -------------------------------------------------------------------------------- 1 | # vendored from https://github.com/frida/frida-tools/blob/63cd957ae2dbe93df5c5670afafaedd5d54304af/frida_tools/apk.py 2 | from __future__ import annotations 3 | 4 | import os 5 | import struct 6 | from enum import IntEnum 7 | from io import BufferedReader 8 | from typing import List 9 | from zipfile import ZipFile 10 | 11 | 12 | def make_debuggable(path: str, output_path: str) -> None: 13 | with ZipFile(path, "r") as iz, ZipFile(output_path, "w") as oz: 14 | for info in iz.infolist(): 15 | with iz.open(info) as f: 16 | if info.filename == "AndroidManifest.xml": 17 | manifest = BinaryXML(f) 18 | 19 | pool = None 20 | debuggable_index = None 21 | 22 | size = 8 23 | for header in manifest.chunk_headers[1:]: 24 | if header.type == ChunkType.STRING_POOL: 25 | pool = StringPool(header) 26 | debuggable_index = pool.append_str("debuggable") 27 | 28 | if header.type == ChunkType.RESOURCE_MAP: 29 | # The "debuggable" attribute name is not only a reference to the string pool, but 30 | # also to the resource map. We need to extend the resource map with a valid entry. 31 | # refs https://justanapplication.wordpress.com/category/android/android-binary-xml/android-xml-startelement-chunk/ 32 | resource_map = ResourceMap(header) 33 | resource_map.add_debuggable(debuggable_index) 34 | 35 | if header.type == ChunkType.START_ELEMENT: 36 | start = StartElement(header) 37 | name = pool.get_string(start.name) 38 | if name == "application": 39 | start.insert_debuggable(debuggable_index, resource_map) 40 | 41 | size += header.size 42 | 43 | header = manifest.chunk_headers[0] 44 | header_data = bytearray(header.chunk_data) 45 | header_data[4 : 4 + 4] = struct.pack(" None: 65 | self.stream = stream 66 | self.chunk_headers = [] 67 | self.parse() 68 | 69 | def parse(self) -> None: 70 | chunk_header = ChunkHeader(self.stream, False) 71 | if chunk_header.type != ChunkType.XML: 72 | raise BadHeader() 73 | self.chunk_headers.append(chunk_header) 74 | 75 | size = chunk_header.size 76 | 77 | while self.stream.tell() < size: 78 | chunk_header = ChunkHeader(self.stream) 79 | self.chunk_headers.append(chunk_header) 80 | 81 | 82 | class ChunkType(IntEnum): 83 | STRING_POOL = 0x001 84 | XML = 0x003 85 | START_ELEMENT = 0x102 86 | RESOURCE_MAP = 0x180 87 | 88 | 89 | class ResourceType(IntEnum): 90 | BOOL = 0x12 91 | 92 | 93 | class StringType(IntEnum): 94 | UTF8 = 1 << 8 95 | 96 | 97 | class BadHeader(Exception): 98 | pass 99 | 100 | 101 | class ChunkHeader: 102 | FORMAT = " None: 105 | self.stream = stream 106 | data = self.stream.peek(struct.calcsize(self.FORMAT)) 107 | (self.type, self.header_size, self.size) = struct.unpack_from(self.FORMAT, data) 108 | if consume_data: 109 | self.chunk_data = self.stream.read(self.size) 110 | else: 111 | self.chunk_data = self.stream.read(struct.calcsize(self.FORMAT)) 112 | 113 | 114 | class StartElement: 115 | FORMAT = " None: 119 | self.header = header 120 | self.stream = self.header.stream 121 | self.header_size = struct.calcsize(self.FORMAT) 122 | 123 | data = struct.unpack_from(self.FORMAT, self.header.chunk_data) 124 | if data[0] != ChunkType.START_ELEMENT: 125 | raise BadHeader() 126 | 127 | self.name = data[6] 128 | self.attribute_count = data[8] 129 | 130 | attributes_data = self.header.chunk_data[self.header_size :] 131 | if len(attributes_data[-20:]) == 20: 132 | previous_attribute = struct.unpack(self.ATTRIBUTE_FORMAT, attributes_data[-20:]) 133 | self.namespace = previous_attribute[0] 134 | else: 135 | # There are no other attributes in the application tag 136 | self.namespace = -1 137 | 138 | def insert_debuggable(self, name: int, resource_map: ResourceMap) -> None: 139 | # TODO: Instead of using the previous attribute to determine the probable 140 | # namespace for the debuggable tag we could scan the strings section 141 | # for the AndroidManifest schema tag 142 | if self.namespace == -1: 143 | raise BadHeader() 144 | 145 | chunk_data = bytearray(self.header.chunk_data) 146 | 147 | resource_size = 8 148 | resource_type = ResourceType.BOOL 149 | # Denotes a True value in AXML, 0 is used for False 150 | resource_data = -1 151 | 152 | debuggable = struct.pack( 153 | self.ATTRIBUTE_FORMAT, self.namespace, name, -1, resource_size, 0, resource_type, resource_data 154 | ) 155 | 156 | # Some parts of Android expect this to be sorted by resource ID. 157 | attr_offset = None 158 | for insert_pos in range(self.attribute_count + 1): 159 | attr_offset = 0x24 + 20 * insert_pos 160 | idx = int.from_bytes(chunk_data[attr_offset + 4 : attr_offset + 8], "little") 161 | if resource_map.get_resource(idx) > ResourceMap.DEBUGGING_RESOURCE: 162 | break 163 | chunk_data[attr_offset:attr_offset] = debuggable 164 | 165 | self.header.size = len(chunk_data) 166 | chunk_data[4 : 4 + 4] = struct.pack(" None: 178 | self.header = header 179 | 180 | def add_debuggable(self, idx: int) -> None: 181 | assert idx is not None 182 | data_size = len(self.header.chunk_data) - 8 183 | target = (idx + 1) * 4 184 | self.header.chunk_data += b"\x00" * (target - data_size - 4) + self.DEBUGGING_RESOURCE.to_bytes(4, "little") 185 | 186 | self.header.size = len(self.header.chunk_data) 187 | self.header.chunk_data = ( 188 | self.header.chunk_data[:4] + struct.pack(" int: 192 | offset = index * 4 + 8 193 | return int.from_bytes(self.header.chunk_data[offset : offset + 4], "little") 194 | 195 | 196 | class StringPool: 197 | FORMAT = " str: 219 | offset = self.offsets[index] 220 | 221 | # HACK: We subtract 4 because we insert a string offset during append_str 222 | # but we do not update the original stream and thus it reads stale data. 223 | if self.dirty: 224 | offset -= 4 225 | 226 | position = self.stream.tell() 227 | self.stream.seek(self.strings_offset + 8 + offset, os.SEEK_SET) 228 | 229 | string = None 230 | if self.utf8: 231 | # Ignore number of characters 232 | n = struct.unpack(" int: 253 | data_size = len(self.header.chunk_data) 254 | # Reserve data for our new offset 255 | data_size += 4 256 | 257 | chunk_data = bytearray(data_size) 258 | end = self.header_size + self.string_count * 4 259 | chunk_data[:end] = self.header.chunk_data[:end] 260 | chunk_data[end + 4 :] = self.header.chunk_data[end:] 261 | 262 | # Add 4 since we have added a string offset 263 | offset = len(chunk_data) - 8 - self.strings_offset + 4 264 | 265 | if self.utf8: 266 | assert len(add.encode("utf-8")) < 128 # multi-byte len strings not supported yet 267 | # the length of the string in characters 268 | chunk_data.extend(struct.pack(" subprocess.CompletedProcess[str]: 17 | """Helper function to call adb and capture stdout.""" 18 | cmd = f"{adb_binary} {cmd}" 19 | try: 20 | proc = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True) 21 | except subprocess.CalledProcessError as e: 22 | logging.debug(f"cmd='{cmd}'\n" 23 | f"{e.stdout=}\n" 24 | f"{e.stderr=}") 25 | raise 26 | logging.debug(f"cmd='{cmd}'\n" 27 | f"{proc.stdout=}\n" 28 | f"{proc.stderr=}") 29 | return proc 30 | -------------------------------------------------------------------------------- /android_unpinner/vendor/platform_tools/darwin/adb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/platform_tools/darwin/adb -------------------------------------------------------------------------------- /android_unpinner/vendor/platform_tools/linux/adb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/platform_tools/linux/adb -------------------------------------------------------------------------------- /android_unpinner/vendor/platform_tools/win32/AdbWinApi.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/platform_tools/win32/AdbWinApi.dll -------------------------------------------------------------------------------- /android_unpinner/vendor/platform_tools/win32/AdbWinUsbApi.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/platform_tools/win32/AdbWinUsbApi.dll -------------------------------------------------------------------------------- /android_unpinner/vendor/platform_tools/win32/adb.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/android_unpinner/vendor/platform_tools/win32/adb.exe -------------------------------------------------------------------------------- /httptoolkit-pinning-demo.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitmproxy/android-unpinner/0570cd70f108b8d234cbd073cf958a8fe61cfbd6/httptoolkit-pinning-demo.apk -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | here = Path(__file__).parent 7 | 8 | long_description = (here / "README.md").read_text("utf8") 9 | 10 | VERSION = re.search( 11 | r'__version__ = "(.+?)"', 12 | (here / "android_unpinner" / "__init__.py").read_text("utf8"), 13 | ).group(1) 14 | 15 | setup( 16 | name="android-unpinner", 17 | author="Maximilian Hils", 18 | author_email="android-unpinner@maximilianhils.com", 19 | version=VERSION, 20 | description="Android Certificate Pinning Unpinner", 21 | long_description=long_description, 22 | long_description_content_type="text/markdown", 23 | url="https://github.com/mitmproxy/android-unpinner", 24 | project_urls={ 25 | "Source": "https://github.com/mitmproxy/android-unpinner", 26 | "Documentation": "https://github.com/mitmproxy/android-unpinner", 27 | "Issues": "https://github.com/mitmproxy/android-unpinner/issues", 28 | }, 29 | classifiers=[ 30 | "Development Status :: 5 - Production/Stable", 31 | "Environment :: Console", 32 | "Operating System :: MacOS", 33 | "Operating System :: POSIX", 34 | "Operating System :: Microsoft :: Windows", 35 | "Intended Audience :: Developers", 36 | "Programming Language :: Python :: 3.10", 37 | "Topic :: Security", 38 | "Topic :: Internet :: WWW/HTTP", 39 | "Topic :: Internet :: Proxy Servers", 40 | "Topic :: System :: Networking :: Monitoring", 41 | "Topic :: Software Development :: Testing", 42 | "Typing :: Typed", 43 | ], 44 | packages=find_packages( 45 | include=[ 46 | "android_unpinner", 47 | "android_unpinner.*", 48 | ] 49 | ), 50 | include_package_data=True, 51 | entry_points={ 52 | "console_scripts": [ 53 | "android-unpinner = android_unpinner.__main__:cli", 54 | "aup = android_unpinner.__main__:cli", 55 | ] 56 | }, 57 | python_requires=">=3.10", 58 | install_requires=[ 59 | "rich_click", 60 | ], 61 | ) 62 | --------------------------------------------------------------------------------