Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This tool removes certificate pinning from APKs.
- Includes a custom Java Debug Wire Protocol implementation to inject the Frida Gadget via ADB.
- Uses [HTTPToolkit's excellent unpinning script](https://github.com/httptoolkit/frida-android-unpinning) to defeat certificate pinning.
- Already includes all native dependencies for Windows/Linux/macOS (`adb`, `apksigner`, `zipalign`, `aapt2`).
- Handles XAPKs by extracting the split APKs, unpinning them and installing them with `adb install-multiple`.
- Handles XAPKs by extracting the split APKs, unpinning them and installing them with `adb install-multiple`.

The goal was not to build yet another unpinning tool, but to explore some newer avenues for non-rooted devices.
Please shamelessly copy whatever idea you like into other tools. :-)
Expand Down Expand Up @@ -39,12 +39,19 @@ $ android-unpinner all httptoolkit-pinning-demo.apk

![screenshot](https://uploads.hi.ls/2022-03/2022-03-08_09-09-36.png)

See `android-unpinner --help` for usage details.
See `android-unpinner --help` for further usage details.

You can pull APKs from your device using `android-unpinner list-packages` and `android-unpinner get-apks`.
Alternatively, you can download APKs from the internet, for example manually from [apkpure.com](https://apkpure.com/) or automatically
using [apkeep](https://github.com/EFForg/apkeep).

Please keep in mind that for most unpinning hooks to function properly, it is
strongly advised to specify a custom script directory that includes your Frida
unpinning scripts using the `--custom-script-dir` option. Currently, some of
the built-in unpinning hooks may not work, as they rely on hooking most pinning
methods by accepting a specific SSL certificate, which is stored in the
`CERT_PEM` variable inside the builtin unpinning scripts.

## Comparison

**Compared to using a rooted device, android-unpinner...**
Expand Down
74 changes: 58 additions & 16 deletions android_unpinner/__main__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from __future__ import annotations

import os
import zipfile
import asyncio
import logging
import os
import subprocess
import zipfile
from pathlib import Path
from time import sleep

Expand All @@ -13,10 +13,13 @@
from rich.logging import RichHandler

from . import jdwplib
from .vendor import build_tools
from .vendor import frida_tools
from .vendor import gadget_config_file_listen, gadget_config_file_script_directory
from .vendor import gadget_files
from .vendor import (
build_tools,
frida_tools,
gadget_config_file_listen,
gadget_config_file_script_directory,
gadget_files,
)
from .vendor.platform_tools import adb, set_device

here = Path(__file__).absolute().parent
Expand All @@ -25,6 +28,8 @@

force = False
gadget_config_file = gadget_config_file_script_directory
builtin_script_dir = here / "scripts"
script_dir = builtin_script_dir


def patch_apk_file(infile: Path, outfile: Path) -> None:
Expand Down Expand Up @@ -104,7 +109,7 @@ def install_apk(apk_files: list[Path]) -> None:
adb(f"install --no-incremental {apk_files[0]}")


def find_apks_in_xapk(xapk_path: Path, output_dir = None) -> list[Path] | None:
def find_apks_in_xapk(xapk_path: Path, output_dir=None) -> list[Path] | None:
"""
Extracts APK files from an XAPK file to a folder and returns their paths.
"""
Expand All @@ -114,24 +119,26 @@ def find_apks_in_xapk(xapk_path: Path, output_dir = None) -> list[Path] | None:

if not os.path.exists(xapk_path):
return None

logging.info(f"Processing XAPK: {os.path.basename(xapk_path)}")

if output_dir is None:
extraction_dir = xapk_path.parent / f"{xapk_path.stem}_extracted"
else:
extraction_dir = Path(output_dir).resolve()

if os.path.exists(extraction_dir):
logging.warning(f"Directory '{extraction_dir}' already exists. New files will be merged/overwritten.")
logging.warning(
f"Directory '{extraction_dir}' already exists. New files will be merged/overwritten."
)
else:
os.makedirs(extraction_dir)
logging.info(f"Created extraction directory: {extraction_dir}")

apk_files = []

try:
with zipfile.ZipFile(xapk_path, 'r') as zip_ref:
try:
with zipfile.ZipFile(xapk_path, "r") as zip_ref:
zip_ref.extractall(extraction_dir)
logging.info("XAPK extraction complete.")

Expand All @@ -142,7 +149,7 @@ def find_apks_in_xapk(xapk_path: Path, output_dir = None) -> list[Path] | None:
full_path = os.path.join(root, file_name)
apk_files.append(Path(full_path))
logging.info(f"Found APK: {full_path}")

return apk_files

except zipfile.BadZipFile:
Expand Down Expand Up @@ -171,7 +178,6 @@ def copy_files() -> None:
"""
Copy the Frida Gadget and unpinning scripts.
"""
# TODO: We could later provide the option to use a custom script dir.
ensure_device_connected()
logging.info("Detect architecture...")
abi = adb("shell getprop ro.product.cpu.abi").stdout.strip()
Expand All @@ -182,8 +188,16 @@ def copy_files() -> None:
adb(f"push {gadget_file} /data/local/tmp/{LIBGADGET}")
adb(f"push {gadget_config_file} /data/local/tmp/{LIBGADGET_CONF}")

logging.info("Copying builtin Frida scripts to /data/local/tmp/android-unpinner...")
adb(f"push {here / 'scripts'}/. /data/local/tmp/android-unpinner/")
if script_dir == builtin_script_dir:
logging.info(
"Copying builtin Frida scripts to /data/local/tmp/android-unpinner..."
)
adb(f"push {script_dir}/. /data/local/tmp/android-unpinner/")
else:
logging.info(
f"Copying Frida scripts within {script_dir} to /data/local/tmp/android-unpinner..."
)
adb(f"push {script_dir}/*.js /data/local/tmp/android-unpinner/")
active_scripts = adb("shell ls /data/local/tmp/android-unpinner").stdout.splitlines(
keepends=False
)
Expand Down Expand Up @@ -324,11 +338,38 @@ def _device(ctx, param, val):
)


def _custom_script_dir(ctx, param, val):
global script_dir
if val:
path = Path(val)
if not path.is_dir():
raise click.BadOptionUsage(
custom_script_dir_option,
"The provided custom script directory is not a directory.",
)
if path.is_dir() and not any(path.glob("*.js")):
raise click.BadOptionUsage(
custom_script_dir_option,
"The provided custom script directory doesn't contain any frida scripts",
)
script_dir = path


custom_script_dir_option = click.option(
"-s",
"--custom-script-dir",
help="Custom script directory containing the frida scripts to use instead of the builtin ones.",
callback=_custom_script_dir,
expose_value=False,
)


@cli.command("all")
@verbosity_option
@force_option
@listen_option
@device_option
@custom_script_dir_option
@click.argument(
"apk-files",
type=click.Path(path_type=Path, exists=True),
Expand Down Expand Up @@ -397,6 +438,7 @@ def patch_apks(apks: list[Path]) -> None:
@verbosity_option
@force_option
@listen_option
@custom_script_dir_option
@device_option
def push_resources() -> None:
"""Copy Frida gadget and scripts to device."""
Expand Down