├── tests
├── __init__.py
└── test_cli.py
├── images
├── carbon.png
└── decompile.png
├── requirements.txt
├── pypi.sh
├── .gitignore
├── scripts
├── __version__.py
├── __init__.py
├── logger.py
├── apk_utils.py
├── frida_github.py
├── uber_apk_signer_github.py
└── cli.py
├── .dockerignore
├── .github
└── workflows
│ └── contributors.yml
├── LICENSE
├── Dockerfile
├── setup.py
└── README.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/images/carbon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahwul/frida-gadget/trunk/images/carbon.png
--------------------------------------------------------------------------------
/images/decompile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hahwul/frida-gadget/trunk/images/decompile.png
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | click
2 | androguard >= 4.0.0
3 | apk-signer
4 | pytest
5 | colorlog
6 | coverage
7 | pylint
8 | requests
9 |
--------------------------------------------------------------------------------
/pypi.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | rm -rf dist/* &&
3 | python setup.py sdist &&
4 | twine check dist/* &&
5 | twine upload -r frida-gadget dist/*
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build/
2 | dist/
3 | *.pyc
4 | venv/
5 | .DS_Store
6 | *.egg-info
7 | clean.sh
8 | test/demo-apk/*
9 | !test/demo-apk/*.apk
10 | .coverage
11 | scripts/files/
12 | .env
13 | /.idea
14 | test_apks
15 | .vscode
16 | docker.sh
17 |
--------------------------------------------------------------------------------
/scripts/__version__.py:
--------------------------------------------------------------------------------
1 | """frida-gadget version information."""
2 |
3 | __title__ = "frida-gadget"
4 | __version__ = "1.6.2"
5 | __description__ = "Automated Frida Gadget injection tool"
6 | __url__ = "https://github.com/ksg97031/frida-gadget"
7 | __author__ = "ksg97031"
8 | __author_email__ = "ksg97031@gmail.com"
9 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .github
3 | images
4 | CONTRIBUTORS.svg
5 | *.apk
6 | .vscode
7 | frida_gadget.egg-info
8 | tests
9 | pypi.sh
10 | .env
11 | dist
12 | scripts/files/*
13 | **/__pycache__/
14 | test_apks
15 | build/
16 | dist/
17 | *.pyc
18 | venv/
19 | .DS_Store
20 | *.egg-info
21 | clean.sh
22 | test/demo-apk/*
23 | !test/demo-apk/*.apk
24 | .coverage
25 | scripts/files/
26 | .env
27 | /.idea
28 | test_apks
29 | .vscode
30 | docker.sh
31 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | """test_cli.py"""
2 | from pathlib import Path
3 | from scripts.cli import run
4 | from click.testing import CliRunner
5 |
6 | p = Path(__file__)
7 | ROOT_DIR = p.parent.resolve()
8 |
9 | def test_run():
10 | """test frida-gadget run
11 | """
12 | runner = CliRunner()
13 | demo_apk_path = str(ROOT_DIR.joinpath('demo-apk/handtrackinggpu.apk').resolve())
14 | result = runner.invoke(run, [demo_apk_path])
15 | assert result.exit_code == 0
16 |
--------------------------------------------------------------------------------
/.github/workflows/contributors.yml:
--------------------------------------------------------------------------------
1 | name: Contributors
2 | on:
3 | schedule:
4 | - cron: '0 1 * * 0' # At 01:00 on Sunday.
5 | push:
6 | branches:
7 | - main
8 | workflow_dispatch:
9 | inputs:
10 | logLevel:
11 | description: 'manual run'
12 | required: false
13 | default: ''
14 | jobs:
15 | contributors:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: bubkoo/contributors-list@v1
19 | with:
20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21 | round: true
22 | permissions:
23 | contents: read
24 | pull-requests: write
25 |
--------------------------------------------------------------------------------
/scripts/__init__.py:
--------------------------------------------------------------------------------
1 | """init file for scripts folder."""
2 | import sys
3 | import subprocess
4 | from .logger import logger
5 |
6 |
7 | def import_or_install(package):
8 | """Function to install missing packages.
9 |
10 | Args:
11 | package (string): Name of the package to install.
12 | """
13 | try:
14 | __import__(package)
15 | except ImportError:
16 | subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'frida', '--user'])
17 | logger.info('Try Again')
18 | sys.exit(0)
19 |
20 |
21 | import_or_install('frida') # Install missing packages
22 | INSTALLED_FRIDA_VERSION: str = __import__('frida').__version__ # Get installed frida version
23 |
--------------------------------------------------------------------------------
/scripts/logger.py:
--------------------------------------------------------------------------------
1 | """logger.py"""
2 | import logging
3 | from colorlog import ColoredFormatter
4 |
5 | ch = logging.StreamHandler()
6 | ch.setLevel(logging.DEBUG)
7 | formatter = ColoredFormatter(
8 | "%(log_color)s[%(levelname)s] %(message)s",
9 | datefmt=None,
10 | reset=True,
11 | log_colors={
12 | 'DEBUG': 'cyan',
13 | 'INFO': 'white,bold',
14 | 'INFOV': 'cyan,bold',
15 | 'WARNING': 'yellow',
16 | 'ERROR': 'red,bold',
17 | 'CRITICAL': 'red,bg_white',
18 | },
19 | secondary_log_colors={},
20 | style='%'
21 | )
22 | ch.setFormatter(formatter)
23 | logger = logging.getLogger('frida-gadget')
24 | logger.setLevel(logging.DEBUG)
25 | logger.addHandler(ch)
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 ksg
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM eclipse-temurin:17-jdk-jammy
2 | LABEL MAINTAINER ksg97031 (ksg97031@gmail.com)
3 |
4 | # Install dependencies
5 | RUN apt-get update && \
6 | apt-get install -y --no-install-recommends \
7 | curl \
8 | python3 \
9 | python3-pip \
10 | python-is-python3 \
11 | && apt-get clean && \
12 | rm -rf /var/lib/apt/lists/*
13 |
14 | # Install Frida
15 | RUN pip3 install --no-cache-dir --upgrade pip && \
16 | pip3 install --no-cache-dir frida
17 |
18 | # Install apktool
19 | ENV APKTOOL_VERSION=2.11.1
20 | WORKDIR /usr/local/bin
21 | RUN curl -sLO https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool && \
22 | chmod +x apktool
23 | RUN curl -sL -o apktool.jar https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_${APKTOOL_VERSION}.jar && \
24 | chmod +x apktool.jar
25 |
26 | # Create a non-root user
27 | RUN useradd -m -s /bin/bash frida-user
28 |
29 | # Install dependencies
30 | WORKDIR /workspace
31 | COPY scripts /workspace/scripts
32 | COPY requirements.txt /workspace/requirements.txt
33 | RUN pip3 install --no-cache-dir -r requirements.txt
34 |
35 | # Set ownership of workspace directory
36 | RUN chown -R frida-user:frida-user /workspace
37 |
38 | # Switch to non-root user
39 | USER frida-user
40 |
41 | ENTRYPOINT ["python3", "-m", "scripts.cli"]
42 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import setuptools
4 |
5 | if sys.version_info < (3, 6):
6 | print("Unfortunately, your python version is not supported!\n" +
7 | "Please upgrade at least to Python 3.6!")
8 | sys.exit(1)
9 |
10 | about = {}
11 | here = os.path.abspath(os.path.dirname(__file__))
12 | with open(os.path.join(here, "scripts", "__version__.py"), "r", encoding="utf-8") as f:
13 | exec(f.read(), about)
14 |
15 | with open("README.rst", "r", encoding="utf-8") as fh:
16 | long_description = fh.read()
17 |
18 | requires = [
19 | 'click',
20 | 'androguard >= 4.0.0',
21 | 'apk-signer',
22 | 'pytest',
23 | 'colorlog',
24 | 'coverage',
25 | 'requests',
26 | ]
27 |
28 | setuptools.setup(
29 | name=about["__title__"],
30 | python_requires='>=3.6',
31 | version=about["__version__"],
32 | author=about["__author__"],
33 | author_email=about["__author_email__"],
34 | description=about["__description__"],
35 | install_requires=requires,
36 | long_description=long_description,
37 | long_description_content_type="text/x-rst",
38 | url=about["__url__"],
39 | packages=setuptools.find_packages(),
40 | package_data={
41 | 'scripts': [
42 | "files/README.md",
43 | ]
44 | },
45 | entry_points={
46 | 'console_scripts': ['frida-gadget = scripts.cli:run'],
47 | },
48 | classifiers=[
49 | "Programming Language :: Python :: 3.6",
50 | "Programming Language :: Python :: 3.7",
51 | "Programming Language :: Python :: 3.8",
52 | "Programming Language :: Python :: 3.9",
53 | "Programming Language :: Python :: 3.10",
54 | "Programming Language :: Python :: 3.11",
55 | "Programming Language :: Python :: 3.12",
56 | "Programming Language :: Python :: 3.13",
57 | "License :: OSI Approved :: MIT License",
58 | "Operating System :: OS Independent",
59 | ],
60 | )
61 |
--------------------------------------------------------------------------------
/scripts/apk_utils.py:
--------------------------------------------------------------------------------
1 | from androguard.core.apk import APK
2 |
3 | from .logger import logger
4 |
5 | def get_main_activity(apk:APK):
6 | x = set()
7 | y = set()
8 |
9 | for i in apk.xml:
10 | if apk.xml[i] is None:
11 | continue
12 | activities_and_aliases = apk.xml[i].findall(".//activity") + \
13 | apk.xml[i].findall(".//activity-alias")
14 |
15 | for item in activities_and_aliases:
16 | # Some applications have more than one MAIN activity.
17 | # For example: paid and free content
18 | activityEnabled = item.get(apk._ns("enabled"))
19 | if activityEnabled == "false":
20 | continue
21 |
22 | for sitem in item.findall(".//action"):
23 | val = sitem.get(apk._ns("name"))
24 | if val == "android.intent.action.MAIN":
25 | activity = item.get(apk._ns("name"))
26 | target_activty = item.get(apk._ns("targetActivity"))
27 | if target_activty is not None:
28 | logger.debug('Target activity found: %s -> %s', activity, target_activty)
29 | activity = target_activty
30 | if activity is not None:
31 | x.add(activity)
32 | else:
33 | logger.warning('Main activity without name')
34 |
35 | for sitem in item.findall(".//category"):
36 | val = sitem.get(apk._ns("name"))
37 | if val == "android.intent.category.LAUNCHER":
38 | activity = item.get(apk._ns("name"))
39 | target_activty = item.get(apk._ns("targetActivity"))
40 | if target_activty is not None:
41 | activity = target_activty
42 | if activity is not None:
43 | y.add(activity)
44 | else:
45 | logger.warning('Launcher activity without name')
46 |
47 | activities = x.intersection(y)
48 | if len(activities) == 0:
49 | return None
50 | elif len(activities) > 1:
51 | logger.error("Multiple main activities found: %s", activities)
52 | logger.error('Please specify one using the --main-activity option.')
53 | return -1
54 |
55 | main_activity = activities.pop()
56 | return main_activity
--------------------------------------------------------------------------------
/scripts/frida_github.py:
--------------------------------------------------------------------------------
1 | """Github module for download frida gadget library"""
2 | # Base code is sourced from the GitHub repository of Objection.
3 | # Source: https://github.com/sensepost/objection/blob/master/objection/utils/patchers/github.py
4 | import lzma
5 | from pathlib import Path
6 | import requests
7 |
8 | class FridaGithub:
9 | """ Interact with Github """
10 |
11 | GITHUB_LATEST_RELEASE = 'https://api.github.com/repos/frida/frida/releases/latest'
12 | GITHUB_TAGGED_RELEASE = 'https://api.github.com/repos/frida/frida/releases/tags/{tag}'
13 |
14 | # the 'context' of this Github instance
15 | gadget_version = None
16 |
17 | def __init__(self, gadget_version: str = None):
18 | """
19 | Init a new instance of Github
20 | """
21 |
22 | if gadget_version:
23 | self.gadget_version = gadget_version
24 |
25 | self.request_cache = {}
26 |
27 | def _call(self, endpoint: str) -> dict:
28 | """
29 | Make a call to Github and cache the response.
30 |
31 | :param endpoint:
32 | :return:
33 | """
34 |
35 | # return a cached response if possible
36 | if endpoint in self.request_cache:
37 | return self.request_cache[endpoint]
38 |
39 | # get a new response
40 | results = requests.get(endpoint, timeout=30).json()
41 |
42 | # cache it
43 | self.request_cache[endpoint] = results
44 |
45 | # and return it
46 | return results
47 |
48 | def get_latest_version(self) -> str:
49 | """
50 | Call Github and get the tag_name of the latest
51 | release.
52 |
53 | :return:
54 | """
55 |
56 | self.gadget_version = self._call(self.GITHUB_LATEST_RELEASE)['tag_name']
57 |
58 | return self.gadget_version
59 |
60 | def get_assets(self) -> dict:
61 | """
62 | Gets the assets for the currently selected gadget_version.
63 |
64 | :return:
65 | """
66 |
67 | assets = self._call(self.GITHUB_TAGGED_RELEASE.format(tag=self.gadget_version))
68 |
69 | if 'assets' not in assets:
70 | raise FileNotFoundError(
71 | f'Unable to determine assets for gadget version \'{self.gadget_version}\'. '
72 | 'Are you sure this version is available on Github?')
73 |
74 | return assets['assets']
75 |
76 | def download_asset(self, url: str, output_file: str) -> None:
77 | """
78 | Download an asset from Github.
79 |
80 | :param url:
81 | :param output_file:
82 | :return:
83 | """
84 |
85 | assert output_file.endswith('.xz')
86 | filepath = Path(output_file)
87 | if filepath.exists() and filepath.stat().st_size > 0:
88 | return
89 |
90 | response = requests.get(url, timeout=600, stream=True)
91 | with open(output_file, 'wb') as asset:
92 | for chunk in response.iter_content(chunk_size=1024):
93 | if chunk:
94 | asset.write(chunk)
95 |
96 | def download_gadget_so(self, url, gadget_fullpath: str) -> str:
97 | """
98 | Download the gadget library from Github.
99 |
100 | :param gadget_path:
101 | :return:
102 | """
103 |
104 | assert gadget_fullpath.endswith('.so')
105 | gadget_path = Path(gadget_fullpath)
106 | download_directory = gadget_path.parent
107 | if gadget_path.exists():
108 | return gadget_fullpath
109 |
110 | if not download_directory.exists():
111 | download_directory.mkdir(parents=True, exist_ok=True)
112 |
113 | xz_gadget_fullpath = gadget_fullpath + ".xz"
114 | self.download_asset(url, xz_gadget_fullpath)
115 | with lzma.open(xz_gadget_fullpath, "rb") as lzma_file:
116 | decompressed_data = lzma_file.read()
117 |
118 | with open(gadget_fullpath, "wb") as gadget_file:
119 | gadget_file.write(decompressed_data)
120 |
121 | return gadget_fullpath
122 |
--------------------------------------------------------------------------------
/scripts/uber_apk_signer_github.py:
--------------------------------------------------------------------------------
1 | """Github module for download frida gadget library"""
2 | # Base code is sourced from the GitHub repository of Objection.
3 | # Source: https://github.com/sensepost/objection/blob/master/objection/utils/patchers/github.py
4 | from pathlib import Path
5 | import hashlib
6 | import os
7 | import requests
8 |
9 | class UberApkSignerGithub:
10 | """ Interact with Github """
11 |
12 | GITHUB_LATEST_RELEASE = 'https://api.github.com/repos/patrickfav/uber-apk-signer/releases/latest'
13 |
14 | # the 'context' of this Github instance
15 | signer_version = None
16 |
17 | def __init__(self, signer_version: str = None):
18 | """
19 | Init a new instance of Github
20 | """
21 |
22 | if signer_version:
23 | self.signer_version = signer_version
24 |
25 | self.request_cache = {}
26 |
27 | def _call(self, endpoint: str) -> dict:
28 | """
29 | Make a call to Github and cache the response.
30 |
31 | :param endpoint:
32 | :return:
33 | """
34 |
35 | # return a cached response if possible
36 | if endpoint in self.request_cache:
37 | return self.request_cache[endpoint]
38 |
39 | # get a new response
40 | results = requests.get(endpoint, timeout=30).json()
41 |
42 | # cache it
43 | self.request_cache[endpoint] = results
44 |
45 | # and return it
46 | return results
47 |
48 | def get_assets(self) -> dict:
49 | """
50 | Gets the assets for the currently selected signer_version.
51 |
52 | :return:
53 | """
54 |
55 | assets = self._call(self.GITHUB_LATEST_RELEASE)
56 |
57 | self.signer_version = assets['tag_name'][1:]
58 |
59 | if 'assets' not in assets:
60 | raise FileNotFoundError(
61 | f'Unable to determine assets for signer version \'{self.signer_version}\'. '
62 | 'Are you sure this version is available on Github?')
63 |
64 | return assets['assets']
65 |
66 | def download_asset(self, url: str, output_file: str) -> None:
67 | """
68 | Download an asset from Github.
69 |
70 | :param url:
71 | :param output_file:
72 | :return:
73 | """
74 | filepath = Path(output_file)
75 | if filepath.exists() and filepath.stat().st_size > 0:
76 | return
77 |
78 | response = requests.get(url, timeout=600, stream=True)
79 | with open(output_file, 'wb') as asset:
80 | for chunk in response.iter_content(chunk_size=1024):
81 | if chunk:
82 | asset.write(chunk)
83 |
84 | def download_signer_jar(self, assets: list, signer_fullpath: str) -> str:
85 | """
86 | Download the signer jar library from Github.
87 |
88 | :param assets:
89 | :param signer_path:
90 | :return:
91 | """
92 | assert len(assets) == 2, 'Unable to determine the correct asset to download.'
93 | assert signer_fullpath.endswith('.jar'), 'Signer path must end with .jar'
94 |
95 | checksum_download_url = assets[0]['browser_download_url']
96 | uber_apk_signer_download_url = assets[1]['browser_download_url']
97 | if assets[1]['name'] == 'checksum-sha256.txt':
98 | checksum_download_url, uber_apk_signer_download_url = \
99 | uber_apk_signer_download_url, checksum_download_url
100 | assert uber_apk_signer_download_url.endswith('.jar'), 'Download URL must end with .jar'
101 |
102 | signer_path = Path(signer_fullpath)
103 | download_directory = signer_path.parent
104 | if signer_path.exists():
105 | return signer_fullpath
106 |
107 | if not download_directory.exists():
108 | download_directory.mkdir(parents=True, exist_ok=True)
109 |
110 | check_sum_fullpath = signer_fullpath[:-4] + '.sha256'
111 | self.download_asset(checksum_download_url, check_sum_fullpath)
112 |
113 | with open(check_sum_fullpath, 'rb') as checksum_file:
114 | checksum = checksum_file.read(64).decode('utf-8')
115 |
116 | self.download_asset(uber_apk_signer_download_url, signer_fullpath)
117 | with open(signer_fullpath, 'rb') as signer_file:
118 | signer_data = signer_file.read()
119 | signer_hash = hashlib.sha256(signer_data).hexdigest()
120 |
121 | if checksum != signer_hash:
122 | os.remove(signer_fullpath)
123 | os.remove(check_sum_fullpath)
124 | raise ValueError('The downloaded uber-apk-signer-*.jar file does not match the checksum.')
125 |
126 | return signer_fullpath
127 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | frida-gadget
2 | ============
3 |
4 | |Codacy-Grade| |Docker| |LICENCE|
5 |
6 |
7 | | ``frida-gadget`` is a tool for patching Android applications to integrate the `Frida Gadget `_.
8 | | This tool automates the process of downloading the Frida gadget library and injecting the ``loadLibrary`` code into the main activity.
9 |
10 |
11 | Installation
12 | ------------
13 |
14 | |Py-Versions| |PyPI-Downloads|
15 |
16 | .. code:: sh
17 |
18 | pip install frida-gadget --upgrade
19 |
20 | Prerequirement
21 | ----------------
22 |
23 | | You should install ``apktool`` and add it to your ``PATH`` environment variable.
24 | |
25 |
26 | .. code:: sh
27 |
28 | # Install Apktool on macOS
29 | brew install apktool
30 |
31 | # Add Apktool to your PATH environment variable
32 | export PATH=$PATH:$HOME/.brew/bin
33 |
34 | | For other operating systems, such as ``Windows``, you can refer to the `Install Guide `_.
35 |
36 |
37 | Usage
38 | ------------
39 |
40 | .. code:: sh
41 |
42 | $ frida-gadget --help
43 | Usage: cli.py [OPTIONS] APK_PATH
44 |
45 | Patch an APK with the Frida gadget library
46 |
47 | Options:
48 | --arch TEXT Specify the target architecture of the device. (options: arm64, x86_64, arm, x86)
49 | --config TEXT Specify the Frida configuration file.
50 | --js TEXT Specify the Frida gadget JavaScript file.
51 | --js-delay INTEGER Specify seconds to wait before executing the JavaScript file.
52 | --force-manifest Force modify AndroidManifest.xml even if it already has required permissions.
53 | --custom-gadget-name TEXT Specify a custom name for the Frida gadget.
54 | --no-res Skip decoding resources.
55 | --main-activity TEXT Specify the main activity if known.
56 | --sign Automatically sign the APK using uber-apk-signer.
57 | --skip-decompile Skip the decompilation step.
58 | --skip-recompile Skip the recompilation step.
59 | --use-aapt2 Use aapt2 instead of aapt for resource processing.
60 | --decompile-opts TEXT Specify additional options for apktool decompile.
61 | --recompile-opts TEXT Specify additional options for apktool recompile.
62 | --apktool-path TEXT Specify the path or command to run apktool.
63 | --frida-version TEXT Specify the Frida version to use.
64 | --ks TEXT The keystore file. If not provided, will use debug keystore.
65 | --ks-alias TEXT The alias of the used key in the keystore.
66 | --ks-key-pass TEXT The password for the key.
67 | --ks-pass TEXT The password for the keystore.
68 | --version Show the version and exit.
69 | --help Show this message and exit.
70 |
71 | How do I begin?
72 | ~~~~~~~~~~~~~~~~~~~~~~
73 | | Simply provide the APK file with the target architecture.
74 | |
75 |
76 | .. code:: sh
77 |
78 | $ frida-gadget target.apk --sign
79 | [INFO] Auto-detected frida version: 16.1.3
80 | [INFO] APK: '[REDACTED]/demo-apk/target.apk'
81 | [INFO] Auto-detected architecture via ADB: arm64-v8a # Alternatively, specify the architecture with --arch arm64
82 | [INFO] Gadget Architecture(--arch): arm64(default)
83 | [DEBUG] Decompiling the target APK using apktool
84 | [DEBUG] Downloading the frida gadget library for arm64
85 | [DEBUG] Checking internet permission and extractNativeLibs settings
86 | [DEBUG] Adding 'android.permission.INTERNET' permission to AndroidManifest.xml
87 | [DEBUG] Searching for the main activity in the smali files
88 | [DEBUG] Found the main activity at '[REDACTED]/frida-gadget/tests/demo-apk/target/smali/com/google/mediap/apps/target/MainActivity.smali'
89 | [DEBUG] Locating the onCreate method and injecting the loadLibrary code
90 | [DEBUG] Recompiling the new APK using apktool
91 | ...
92 | [INFO] APK signing finished: ./target/dist/target-aligned-debugSigned.apk (72.78 MiB)
93 |
94 | With Docker
95 | ~~~~~~~~~~~~~~~~~~
96 | | You can also use this tool with Docker. Here's how to use it:
97 | |
98 | | 1. First, pull the Docker image:
99 | |
100 |
101 | .. code:: sh
102 |
103 | docker pull ksg97031/frida-gadget
104 |
105 | | 2. Mount your local directory containing the APK file to the container:
106 | |
107 |
108 | .. code:: sh
109 |
110 | docker run -v $(pwd):/workspace/mount ksg97031/frida-gadget /workspace/mount/your-app.apk --arch arm64
111 |
112 | | Note: Replace ``your-app.apk`` with your actual APK filename. The patched APK will be created in the same directory as your original APK.
113 | |
114 | | For example, if your APK is named ``example.apk``:
115 | |
116 |
117 | .. code:: sh
118 |
119 | docker run -v $(pwd):/workspace/mount ksg97031/frida-gadget /workspace/mount/example.apk --arch arm64
120 | # The patched APK will be located at ./example/dist/example.apk
121 |
122 | Compatibility
123 | ----------------
124 | Device Architecture
125 | ~~~~~~~~~~~~~~~~~~~~~~~
126 | | The tool automatically detects the device architecture when an ADB device is connected. You can also manually specify the architecture using the ``--arch`` option.
127 | |
128 | | To determine your device's architecture, connect your device and run the following command:
129 | |
130 |
131 | .. code:: sh
132 |
133 | adb shell getprop ro.product.cpu.abi
134 |
135 | | This command will output the architecture of your device, such as ``arm64-v8a``, ``armeabi-v7a``, ``x86``, ``x86_64`` or ``multi-arch``.
136 |
137 | | Example of automatic detection:
138 | |
139 |
140 | .. code:: sh
141 |
142 | $ frida-gadget target.apk --sign
143 | [INFO] Auto-detected architecture via ADB: arm64-v8a
144 |
145 | | Example of manual specification:
146 | |
147 |
148 | .. code:: sh
149 |
150 | $ frida-gadget target.apk --arch arm64 --sign
151 | [INFO] Gadget Architecture(--arch): arm64
152 |
153 | Android Version Support
154 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~
155 | | The following table shows the minimum Frida version required for different Android versions:
156 | | (Note: This information may not be completely accurate)
157 |
158 | .. list-table::
159 | :header-rows: 1
160 |
161 | * - Android Version
162 | - Minimum Frida Version
163 | - Notes
164 | * - Android 5.x ~ 7.x (Lollipop~Nougat)
165 | - Frida 14.2+
166 | - Support for older Android versions was improved in Frida 12.6. Frida 14.2 includes fixes for libc detection errors and restored Houdini (translator) support. Latest Frida (16.x) continues to support Android 5~7.
167 | * - Android 8.0 ~ 8.1 (Oreo)
168 | - Frida 12.6.6+
169 | - Java API issues like Java.choose were resolved in Frida 12.6.3+. Java integration issues on 32-bit ARM devices were fixed in Frida 12.6.6. Frida 14.x and newer versions work stably on Oreo.
170 | * - Android 9.0 (Pie)
171 | - Frida 12.7+
172 | - Frida was extensively tested on Pixel 3 (Android 9). Frida 12.x ~ 15.x versions work stably on AOSP-based Android 9. Latest Frida 16.x also supports Android 9. (For emulators, Google-provided Android 9 images for arm/arm64 are recommended.)
173 | * - Android 10 (Q)
174 | - Frida 14.2+
175 | - While there were no major changes specific to Android 10, Frida 14.2+ is recommended for overall stability. Frida 14.2 includes various compatibility improvements for both pre and post Android 10 versions. Latest Frida 15.x and 16.x versions work without issues on Android 10.
176 | * - Android 11 (R)
177 | - Frida 14.2+
178 | - Frida 14.2 includes modifications to address ART changes and ARM->x86 translation in Android 11. Frida 14.2 or higher is recommended for Android 11. Frida 15.x~16.x fully support Android 11. (May have separate issues on custom ROMs like Samsung.)
179 | * - Android 12 (S)
180 | - Frida 15.0+
181 | - Official support for Android 12 was first added in Frida 15.0. Initial 15.0 version had minor compatibility issues, but Frida 15.1.23 includes several stability improvements for Android 12. Frida 15.1.23 or higher (preferably 15.2 or latest 16.x) is recommended for Android 12 devices.
182 | * - Android 13 (T)
183 | - Frida 15.1.23+
184 | - Preliminary support for Android 13 was introduced in Frida 15.1.23, and support matured in Frida 16.x versions. Minimum Frida 15.1.23 is required for Android 13 devices, but using the latest Frida 16 version is recommended (includes fixes for Android 13's internal behavior changes).
185 | * - Android 14 (UpsideDownCake)
186 | - Frida 16.2.0+
187 | - Due to ART structure changes in Android 14, initial Frida 16.0~16.1 versions had issues with Java hooking, but Frida 16.2.0 improved hooking support for Android 14. Frida 16.2 or higher is recommended for Android 14 (Frida 16.2 added support for Android 14's new ART entrypoints).
188 |
189 | How to Identify the Injection?
190 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
191 | | You can observe the main activity to see the injected ``loadLibrary`` code.
192 | | Additionally, the Frida gadget library will be present in your APK.
193 |
194 | .. code:: sh
195 |
196 | $ unzip -l [REDACTED]/demo-apk/target/dist/target.apk | grep libfrida-gadget
197 | 21133848 09-15-2021 02:28 lib/arm64-v8a/libfrida-gadget-16.1.3-android-arm64.so
198 |
199 | Tips
200 | ------------
201 |
202 | Specifying a Different Main Activity
203 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
204 | | If the main activity is not automatically detected, you can specify it manually using the ``--main-activity`` option:
205 | |
206 |
207 | .. code:: sh
208 |
209 | $ frida-gadget target.apk --main-activity com.example.MainActivity --no-res --sign
210 |
211 | Creating Self-Contained SSL Bypass App with --js
212 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
213 | | 1. Download the `@akabe1/frida-multiple-unpinning `_ script.
214 | | 2. Inject the script into the target application using the ``--js`` flag.
215 |
216 | .. code:: sh
217 |
218 | frida-gadget target.apk --js frida-multiple-unpinning.js --sign --no-res
219 |
220 | | 3. Run the injected application on your device or emulator.
221 | | 4. Observe the network traffic using a proxy tool such as `Burp Suite `_ or `Caido `_.
222 | |
223 | | Note: If the app crashes, try adding ``--js-delay 2`` to delay script execution:
224 |
225 | .. code:: sh
226 |
227 | frida-gadget target.apk --js frida-multiple-unpinning.js --js-delay 2 --sign --no-res
228 |
229 | | This gives the app time to initialize before applying hooks.
230 | |
231 | | You can also specify a custom Frida version using ``--frida-version``:
232 |
233 | .. code:: sh
234 |
235 | frida-gadget target.apk --js frida-multiple-unpinning.js --frida-version 16.1.3 --sign --no-res
236 |
237 | | This is useful when you need to use a specific Frida version for compatibility reasons.
238 |
239 | Using a Custom Apktool
240 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
241 | | You can specify a custom apktool path or command using the ``--apktool-path`` option.
242 | | For example, you can use a script or a specific jar file:
243 | |
244 |
245 | .. code:: sh
246 |
247 | $ frida-gadget target.apk --apktool-path ./tools/apktool.bat --sign # Windows
248 | $ frida-gadget target.apk --apktool-path "java -Xmx16g -jar ~/Download/apktool.jar" --sign # Java with 16GB memory
249 |
250 | Custom Apktool Options
251 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
252 | | You can also specify custom options for apktool decompile and recompile using the ``--decompile-opts`` and ``--recompile-opts`` options.
253 | | For example, you can pass additional flags to apktool:
254 | |
255 |
256 | .. code:: sh
257 |
258 | $ frida-gadget target.apk --decompile-opts "--only-main-classes --no-res" --recompile-opts "--force-all" --sign
259 |
260 | Contributing
261 | -----------------
262 | .. image:: CONTRIBUTORS.svg
263 | :target: ./CONTRIBUTORS.svg
264 |
265 |
266 | .. |Coverage-Status| image:: https://img.shields.io/coveralls/github/ksg97031/frida-gadget/master?logo=coveralls
267 | :target: https://coveralls.io/github/ksg97031/frida-gadget
268 | .. |Branch-Coverage-Status| image:: https://codecov.io/gh/ksg97031/frida-gadget/branch/master/graph/badge.svg
269 | :target: https://codecov.io/gh/ksg97031/frida-gadget
270 | .. |Codacy-Grade| image:: https://app.codacy.com/project/badge/Grade/a1e2ef93fd3842e4b9e92971c135ed3f
271 | :target: https://app.codacy.com/gh/ksg97031/frida-gadget/dashboard
272 | .. |CII Best Practices| image:: https://bestpractices.coreinfrastructure.org/projects/3264/badge
273 | :target: https://bestpractices.coreinfrastructure.org/projects/3264
274 | .. |GitHub-Status| image:: https://img.shields.io/github/tag/ksg97031/frida-gadget.svg?maxAge=86400&logo=github&logoColor=white
275 | :target: https://github.com/ksg97031/frida-gadget/releases
276 | .. |GitHub-Forks| image:: https://img.shields.io/github/forks/ksg97031/frida-gadget.svg?logo=github&logoColor=white
277 | :target: https://github.com/ksg97031/frida-gadget/network
278 | .. |GitHub-Stars| image:: https://img.shields.io/github/stars/ksg97031/frida-gadget.svg?logo=github&logoColor=white
279 | :target: https://github.com/ksg97031/frida-gadget/stargazers
280 | .. |GitHub-Commits| image:: https://img.shields.io/github/commit-activity/y/ksg97031/frida-gadget.svg?logo=git&logoColor=white
281 | :target: https://github.com/ksg97031/frida-gadget/graphs/commit-activity
282 | .. |GitHub-Issues| image:: https://img.shields.io/github/issues-closed/ksg97031/frida-gadget.svg?logo=github&logoColor=white
283 | :target: https://github.com/ksg97031/frida-gadget/issues?q=
284 | .. |GitHub-PRs| image:: https://img.shields.io/github/issues-pr-closed/ksg97031/frida-gadget.svg?logo=github&logoColor=white
285 | :target: https://github.com/ksg97031/frida-gadget/pulls
286 | .. |GitHub-Contributions| image:: https://img.shields.io/github/contributors/ksg97031/frida-gadget.svg?logo=github&logoColor=white
287 | :target: https://github.com/ksg97031/frida-gadget/graphs/contributors
288 | .. |GitHub-Updated| image:: https://img.shields.io/github/last-commit/ksg97031/frida-gadget/master.svg?logo=github&logoColor=white&label=pushed
289 | :target: https://github.com/ksg97031/frida-gadget/pulse
290 | .. |Gift-Casper| image:: https://img.shields.io/badge/dynamic/json.svg?color=ff69b4&label=gifts%20received&prefix=%C2%A3&query=%24..sum&url=https%3A%2F%2Fcaspersci.uk.to%2Fgifts.json
291 | :target: https://cdcl.ml/sponsor
292 | .. |PyPI-Downloads| image:: https://static.pepy.tech/badge/frida-gadget
293 | :target: https://pepy.tech/project/frida-gadget
294 | .. |Py-Versions| image:: https://img.shields.io/pypi/pyversions/frida-gadget
295 | :target: https://pypi.org/project/frida-gadget
296 | .. |Conda-Forge-Status| image:: https://img.shields.io/conda/v/conda-forge/frida-gadget.svg?label=conda-forge&logo=conda-forge
297 | :target: https://anaconda.org/conda-forge/frida-gadget
298 | .. |Docker| image:: https://img.shields.io/badge/docker-pull-blue.svg?logo=docker&logoColor=white
299 | :target: https://github.com/ksg97031/frida-gadget/pkgs/container/frida-gadget
300 | .. |Libraries-Dependents| image:: https://img.shields.io/librariesio/dependent-repos/pypi/frida-gadget.svg?logo=koding&logoColor=white
301 | :target: https://github.com/ksg97031/frida-gadget/network/dependents
302 | .. |OpenHub-Status| image:: https://www.openhub.net/p/frida-gadget/widgets/project_thin_badge?format=gif
303 | :target: https://www.openhub.net/p/frida-gadget?ref=Thin+badge
304 | .. |awesome-python| image:: https://awesome.re/mentioned-badge.svg
305 | :target: https://github.com/vinta/awesome-python
306 | .. |LICENCE| image:: https://img.shields.io/pypi/l/frida-gadget.svg
307 | :target: https://raw.githubusercontent.com/ksg97031/frida-gadget/master/LICENCE
308 | .. |DOI| image:: https://img.shields.io/badge/DOI-10.5281/zenodo.595120-blue.svg
309 | :target: https://doi.org/10.5281/zenodo.595120
310 | .. |binder-demo| image:: https://mybinder.org/badge_logo.svg
311 | :target: https://mybinder.org/v2/gh/ksg97031/frida-gadget/master?filepath=DEMO.ipynb
312 |
--------------------------------------------------------------------------------
/scripts/cli.py:
--------------------------------------------------------------------------------
1 | """
2 | Frida Gadget Injector for Android APK
3 |
4 | This script allows you to inject the Frida gadget library into an Android APK.
5 | It provides various functionalities including:
6 | - Decompiling the APK using apktool
7 | - Downloading the appropriate Frida gadget library based on the device architecture
8 | - Injecting the Frida gadget library into the APK
9 | - Modifying the AndroidManifest.xml to add necessary permissions
10 | - Recompiling the APK
11 | - Optionally signing the APK using uber-apk-signer
12 | """
13 |
14 | import os
15 | import re
16 | import sys
17 | import shutil
18 | import subprocess
19 | import json
20 | import tempfile
21 | import zipfile
22 | from shutil import which
23 | from pathlib import Path
24 | import click
25 | from androguard.core.apk import APK
26 | from .apk_utils import get_main_activity
27 | from .logger import logger
28 | from .__version__ import __version__
29 | from .frida_github import FridaGithub
30 | from .uber_apk_signer_github import UberApkSignerGithub
31 | from . import INSTALLED_FRIDA_VERSION
32 |
33 |
34 | p = Path(__file__)
35 | ROOT_DIR = p.parent.resolve()
36 | TEMP_DIR = ROOT_DIR.joinpath("temp")
37 | FILE_DIR = ROOT_DIR.joinpath("files")
38 |
39 | APKTOOL = which("apktool")
40 |
41 |
42 | def run_apktool(option: list, apk_path: str):
43 | """Run apktool with option
44 |
45 | Args:
46 | option (list|str): option of apktool
47 | apk_path (str): path of apk file
48 |
49 | """
50 |
51 | pipe = subprocess.PIPE
52 | cmd = APKTOOL.split() + option + [apk_path]
53 | with subprocess.Popen(
54 | cmd, stdin=pipe, stdout=sys.stdout, stderr=sys.stderr
55 | ) as process:
56 | process.communicate(b"\n")
57 | if process.returncode != 0:
58 | recommend_options = ["--no-res", "--use-aapt2"]
59 | for opt in recommend_options:
60 | if opt in option:
61 | recommend_options.remove(opt)
62 |
63 | logger.error(
64 | "It looks like you're having trouble with apktool.\n"
65 | "Consider trying the '%s' options, or if you'd prefer more control,\n"
66 | "you can manually specify apktool settings using ['--decompile-opts', '--recompile-opts', '--apktool-path'].",
67 | recommend_options,
68 | )
69 |
70 | raise subprocess.CalledProcessError(
71 | process.returncode, cmd, sys.stdout, sys.stderr
72 | )
73 | return True
74 |
75 |
76 | def download_gadget(arch: str, frida_version: str = None):
77 | """Download the frida gadget library
78 |
79 | Args:
80 | arch (str): architecture of the device
81 | frida_version (str): specific frida version to use
82 | """
83 | if frida_version:
84 | logger.info("Using specified frida version: %s", frida_version)
85 | version = frida_version
86 | else:
87 | logger.info("Auto-detected your frida version: %s", INSTALLED_FRIDA_VERSION)
88 | version = INSTALLED_FRIDA_VERSION
89 |
90 | frida_github = FridaGithub(version)
91 | assets = frida_github.get_assets()
92 | file = f"frida-gadget-{version}-android-{arch}.so.xz"
93 | for asset in assets:
94 | if asset["name"] == file:
95 | logger.debug(
96 | "Downloading the frida gadget library(%s) for %s", version, arch
97 | )
98 | so_gadget_path = str(FILE_DIR.joinpath(file[:-3]))
99 | return frida_github.download_gadget_so(
100 | asset["browser_download_url"], so_gadget_path
101 | )
102 |
103 | raise FileNotFoundError(f"'{file}' not found in the github releases")
104 |
105 |
106 | def download_signer():
107 | """Download the Uber Apk Signer"""
108 | signer_github = UberApkSignerGithub()
109 | assets = signer_github.get_assets()
110 | file = f"uber-apk-signer-{signer_github.signer_version}.jar"
111 | signer_path = str(FILE_DIR.joinpath(file))
112 | if os.path.exists(signer_path):
113 | return signer_path
114 |
115 | logger.debug("Downloading the %s file for signing", file)
116 | return signer_github.download_signer_jar(assets, signer_path)
117 |
118 |
119 | def insert_loadlibary(decompiled_path, main_activity, load_library_name):
120 | """Inject loadlibary code to main activity
121 |
122 | Args:
123 | decompiled_path (str): decomplied path of apk file
124 | main_activity (str): main activity of apk file
125 | load_library_name (str): name of load library
126 | """
127 | logger.debug("Searching for the main activity in the smali files")
128 | target_smali = None
129 | target_smali_class_number = None
130 |
131 | target_relative_path = main_activity.replace(".", os.sep)
132 | for directory in decompiled_path.iterdir():
133 | if directory.is_dir() and directory.name.startswith("smali"):
134 | target_smali = directory.joinpath(target_relative_path + ".smali")
135 | if target_smali.exists():
136 | if directory.name.startswith("smali_classes"):
137 | target_smali_class_number = int(directory.name.split("smali_classes")[1])
138 | break
139 |
140 | if not target_smali or not target_smali.exists():
141 | raise FileNotFoundError(f"The target class file {target_smali} was not found.")
142 |
143 | logger.debug("Found the main activity at '%s'", str(target_smali))
144 | text = target_smali.read_text()
145 |
146 | text = text.replace("invoke-virtual {v0, v1}, Ljava/lang/Runtime;->exit(I)V", "")
147 | text = text.split("\n")
148 |
149 | logger.debug("Locating the entrypoint method and injecting the loadLibrary code")
150 | status = False
151 | entrypoints = [" onCreate(", ""]
152 | for entrypoint in entrypoints:
153 | idx = 0
154 | while idx != len(text):
155 | line = text[idx].strip()
156 | if line.startswith(".method") and entrypoint in line:
157 | if ".locals" not in text[idx + 1]:
158 | idx += 1
159 | continue
160 | else:
161 | # Increase the number of locals 0 to 1
162 | if ".locals 0" in text[idx + 1]:
163 | text[idx + 1] = text[idx + 1].replace(".locals 0", ".locals 1")
164 |
165 | if load_library_name.startswith("lib"):
166 | load_library_name = load_library_name[3:]
167 | text.insert(
168 | idx + 2,
169 | " invoke-static {v0}, "
170 | "Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V",
171 | )
172 | text.insert(idx + 2, f" const-string v0, " f'"{load_library_name}"')
173 | status = True
174 | break
175 | idx += 1
176 |
177 | if status:
178 | break
179 |
180 | if not status:
181 | logger.error("Cannot find the appropriate position in the main activity.")
182 | logger.error(
183 | "Please report the issue at %s with the following information:",
184 | "https://github.com/ksg97031/frida-gadget/issues",
185 | )
186 | logger.error("APK Name: ")
187 | logger.error("APK Version: ")
188 | logger.error("APKTOOL Version: ")
189 | sys.exit(-1)
190 |
191 | # Replace the smali file with the new one
192 | target_smali.write_text("\n".join(text))
193 | return target_smali_class_number
194 |
195 | def modify_manifest(decompiled_path):
196 | """Modify manifest permssions
197 |
198 | Args:
199 | decompiled_path (str): decomplied path of apk file
200 | """
201 | # Add internet permission
202 | logger.debug("Checking internet permission and extractNativeLibs settings")
203 | android_manifest = decompiled_path.joinpath("AndroidManifest.xml")
204 | txt = android_manifest.read_text(encoding="utf-8")
205 | pos = txt.index("")
206 | permission = "android.permission.INTERNET"
207 |
208 | if permission not in txt:
209 | logger.debug(
210 | "Adding 'android.permission.INTERNET' permission to AndroidManifest.xml"
211 | )
212 | permissions_txt = f""
213 | txt = txt[:pos] + permissions_txt + txt[pos:]
214 |
215 | # Set extractNativeLibs to true
216 | if ':extractNativeLibs="false"' in txt:
217 | logger.debug('Editing the extractNativeLibs="true"')
218 | txt = txt.replace(':extractNativeLibs="false"', ':extractNativeLibs="true"')
219 | android_manifest.write_text(txt, encoding="utf-8")
220 |
221 |
222 | def detect_apk_architectures(decompiled_path):
223 | """Detect architectures from the APK's lib directory
224 |
225 | Args:
226 | decompiled_path (str): decompiled path of apk file
227 |
228 | Returns:
229 | list: List of detected architectures
230 | """
231 | lib_dir = decompiled_path.joinpath("lib")
232 | if not lib_dir.exists():
233 | logger.warning(
234 | "No lib directory found in the APK. Returning default architecture (arm64)."
235 | )
236 | return ["arm64"]
237 |
238 | arch_mapping = {
239 | "arm64-v8a": "arm64",
240 | "armeabi-v7a": "arm",
241 | "x86": "x86",
242 | "x86_64": "x86_64",
243 | }
244 |
245 | detected_archs = []
246 | for arch_dir in lib_dir.iterdir():
247 | if arch_dir.is_dir() and arch_dir.name in arch_mapping:
248 | detected_archs.append(arch_mapping[arch_dir.name])
249 |
250 | if not detected_archs:
251 | logger.warning(
252 | "No supported architectures found in the APK. Returning default architecture (arm64)."
253 | )
254 | return ["arm64"]
255 |
256 | logger.info("Detected architectures in APK: %s", ", ".join(detected_archs))
257 | return detected_archs
258 |
259 |
260 | def inject_gadget_into_apk(
261 | apk_path: str,
262 | arch: str,
263 | decompiled_path: str,
264 | no_res,
265 | force_manifest,
266 | main_activity: str = None,
267 | config: str = None,
268 | js: str = None,
269 | custom_gadget_name: str = None,
270 | frida_version: str = None,
271 | ):
272 | """Inject frida gadget into an APK
273 |
274 | Args:
275 | apk (APK): path of apk file
276 | arch (str): architecture of the device
277 | decompiled_path (str): decomplied path of apk file
278 |
279 | Raises:
280 | FileNotFoundError: file not found
281 | NotImplementedError: not implemented
282 | """
283 | apk = APK(apk_path)
284 |
285 | # Handle 'multi-arch' option
286 | if arch == "multi-arch":
287 | archs = detect_apk_architectures(decompiled_path)
288 | logger.info(
289 | "Using multiple architectures detected from APK: %s", ", ".join(archs)
290 | )
291 | else:
292 | archs = [arch]
293 |
294 | # Get main activity if not provided
295 | if not main_activity:
296 | main_activity = get_main_activity(apk)
297 | if main_activity == -1: # multiple main activities
298 | sys.exit(-1)
299 |
300 | if not main_activity:
301 | if len(apk.get_activities()) == 1:
302 | logger.warning(
303 | "The main activity was not found.\n"
304 | "Using the first activity from the manifest file."
305 | )
306 | main_activity = apk.get_activities()[0]
307 | else:
308 | logger.error(
309 | "The main activity was not found.\n"
310 | "Please specify the main activity using the --main-activity option.\n"
311 | "Select the activity from %s",
312 | apk.get_activities(),
313 | )
314 | sys.exit(-1)
315 |
316 | # Apply permission to android manifest
317 | if not no_res or force_manifest:
318 | modify_manifest(decompiled_path)
319 |
320 | for current_arch in archs:
321 | gadget_path = download_gadget(current_arch, frida_version)
322 | gadget_name = Path(gadget_path).name
323 |
324 | # Apply custom gadget name if provided
325 | if custom_gadget_name:
326 | custom_gadget_name_with_ext = custom_gadget_name + ".so"
327 | logger.info("Using custom gadget name: %s", custom_gadget_name_with_ext)
328 | gadget_name = custom_gadget_name_with_ext
329 | if arch == "multi-arch":
330 | # TODO: custom gadget name for multi-arch
331 | gadget_name = "libfrida-gadget.so"
332 | logger.info("Using multi-arch gadget name: %s", gadget_name)
333 |
334 | # Save the first gadget info for loadLibrary
335 | # Copy the frida gadget library to the lib directory
336 | lib = decompiled_path.joinpath("lib")
337 | if not lib.exists():
338 | lib.mkdir()
339 |
340 | arch_dirnames = {
341 | "arm": "armeabi-v7a",
342 | "x86": "x86",
343 | "arm64": "arm64-v8a",
344 | "x86_64": "x86_64",
345 | }
346 | if current_arch not in arch_dirnames:
347 | raise NotImplementedError(
348 | f"The architecture '{current_arch}' is not supported."
349 | )
350 |
351 | arch_dirname = arch_dirnames[current_arch]
352 | lib_arch_dir = lib.joinpath(arch_dirname)
353 | if not lib_arch_dir.exists():
354 | lib_arch_dir.mkdir()
355 |
356 | lib_library_name = gadget_name
357 | if not lib_library_name.startswith("lib"):
358 | lib_library_name = "lib" + gadget_name
359 | shutil.copy(gadget_path, lib_arch_dir.joinpath(lib_library_name))
360 |
361 | # Upload gadget config and js files for each architecture
362 | upload_files = {"config": config, "script": js}
363 | load_library_name = lib_library_name[:-3]
364 |
365 | if js and config:
366 | with open(config, "r") as f:
367 | contents = f.read()
368 | config_data = json.loads(contents)
369 | if "interaction" not in config_data:
370 | logger.error("The config file must contain an 'interaction' key.")
371 | sys.exit(-1)
372 | if "path" in config_data["interaction"]:
373 | logger.debug(
374 | "Updating the script path in '%s' from '%s' to 'lib%s.script.so'",
375 | config,
376 | config_data["interaction"]["path"],
377 | load_library_name,
378 | )
379 | config_data["interaction"]["path"] = f"{load_library_name}.script.so"
380 | with open(
381 | lib_arch_dir.joinpath(f"{load_library_name}.config.so"), "w"
382 | ) as f:
383 | f.write(json.dumps(config_data, indent=4))
384 | del upload_files["config"]
385 | elif js:
386 | config_name = f"{load_library_name}.config.so"
387 | with open(lib_arch_dir.joinpath(config_name), "w") as f:
388 | contents = (
389 | """\
390 | \r{
391 | \r "interaction": {
392 | \r "type": "script",
393 | \r "path": \""""
394 | + load_library_name
395 | + """.script.so"
396 | \r }
397 | \r}
398 | """
399 | )
400 | f.write(contents)
401 | logger.debug("Created the default config file: %s", config_name)
402 | logger.debug(contents)
403 | del upload_files["config"]
404 | elif config:
405 | logger.warning(
406 | "The '%s' config file was provided without the script file.", config
407 | )
408 | logger.warning(
409 | "To upload the script file to the APK, please provide the --js option."
410 | )
411 | with open(config, "r") as f:
412 | contents = f.read()
413 | config_data = json.loads(contents)
414 | if "interaction" not in config_data:
415 | logger.error("The config file must contain an 'interaction' key.")
416 | sys.exit(-1)
417 | if "path" not in config_data["interaction"]:
418 | logger.error("The config file must contain a 'path' key.")
419 | sys.exit(-1)
420 | logger.warning(
421 | "The script file must be located at '%s' on your device",
422 | config_data["interaction"]["path"],
423 | )
424 |
425 | for file_type, file_path in upload_files.items():
426 | if file_path:
427 | file_path = Path(file_path)
428 | if not file_path.exists():
429 | logger.error("Frida %s file not found: %s", file_type, file_path)
430 | sys.exit(-1)
431 | else:
432 | target_name = f"{load_library_name}.{file_type}.so"
433 | if file_path.name == target_name:
434 | logger.debug(
435 | "Uploading Frida %s file: %s", file_type, file_path.name
436 | )
437 | else:
438 | logger.debug(
439 | "Renaming and uploading Frida %s file: %s -> %s",
440 | file_type,
441 | file_path.name,
442 | target_name,
443 | )
444 | shutil.copy(file_path, lib_arch_dir.joinpath(target_name))
445 |
446 | return insert_loadlibary(decompiled_path, main_activity, load_library_name)
447 |
448 |
449 | def sign_apk(apk_path: str, ks: str = None, ks_alias: str = None, ks_key_pass: str = None, ks_pass: str = None):
450 | """Run uber apk signer with option
451 |
452 | Args:
453 | apk_path (str): path of apk file
454 | ks (str): keystore file path
455 | ks_alias (str): keystore alias
456 | ks_key_pass (str): key password
457 | ks_pass (str): keystore password
458 | """
459 | signer_path = download_signer() # Download apk signer
460 |
461 | pipe = subprocess.PIPE
462 | cmd = ["java", "-jar", signer_path, "--apks", apk_path]
463 |
464 | if ks:
465 | cmd.append("--ks")
466 | cmd.append(ks)
467 | if ks_alias:
468 | cmd.append("--ksAlias")
469 | cmd.append(ks_alias)
470 | if ks_key_pass:
471 | cmd.append("--ksKeyPass")
472 | cmd.append(ks_key_pass)
473 | if ks_pass:
474 | cmd.append("--ksPass")
475 | cmd.append(ks_pass)
476 |
477 | with subprocess.Popen(
478 | cmd, stdin=pipe, stdout=subprocess.PIPE, stderr=sys.stderr
479 | ) as process:
480 | stdout, _ = process.communicate(b"\n")
481 | if process.returncode != 0:
482 | logger.error("The APK signing process failed.")
483 | raise subprocess.CalledProcessError(
484 | process.returncode, cmd, sys.stdout, sys.stderr
485 | )
486 |
487 | output = stdout.decode()
488 | print(output)
489 | if "VERIFY" in output:
490 | verify_message = output.split("VERIFY")[1]
491 | if "file:" in verify_message:
492 | apk_path = verify_message.split("file:")[1].split("\n")[0].strip()
493 | logger.info("APK signing finished: %s", apk_path)
494 |
495 |
496 | def detect_adb_arch():
497 | """Detect the architecture of the currently connected device via ADB.
498 |
499 | This function communicates with a connected Android device over ADB
500 | to determine its CPU architecture (e.g., arm64-v8a, armeabi-v7a, x86).
501 |
502 | Returns:
503 | str: The detected architecture of the connected device.
504 | Defaults to 'arm64' if detection fails.
505 | """
506 | pipe = subprocess.PIPE
507 | cmd = ["adb", "shell", "getprop", "ro.product.cpu.abi"]
508 | default_arch = "arm64"
509 |
510 | try:
511 | with subprocess.Popen(
512 | cmd, stdin=pipe, stdout=subprocess.PIPE, stderr=subprocess.PIPE
513 | ) as process:
514 | stdout, stderr = process.communicate()
515 | if process.returncode != 0:
516 | logger.warning(
517 | "Failed to execute ADB command. Error: %s", stderr.decode().strip()
518 | )
519 | logger.warning("Falling back to default architecture: %s", default_arch)
520 | return default_arch
521 |
522 | arch = stdout.decode().strip()
523 | if not arch:
524 | logger.warning(
525 | "Architecture detection failed: no output received. Falling back to default: %s",
526 | default_arch,
527 | )
528 | return default_arch
529 |
530 | if arch == "arm64-v8a":
531 | arch = "arm64"
532 | elif arch == "armeabi-v7a":
533 | arch = "arm"
534 |
535 | logger.info("Auto-detected architecture via ADB: %s", arch)
536 | return arch
537 |
538 | except FileNotFoundError:
539 | logger.warning(
540 | "ADB is not installed or not found in the system PATH. Falling back to default: %s",
541 | default_arch,
542 | )
543 | return default_arch
544 | except Exception as e:
545 | logger.warning(
546 | "An unexpected error occurred during architecture detection: %s. Falling back to default: %s",
547 | str(e),
548 | default_arch,
549 | )
550 | return default_arch
551 |
552 |
553 | def print_version(ctx, _, value):
554 | """Print version and exit"""
555 | if not value or ctx.resilient_parsing:
556 | return
557 | print(f"frida-gadget version {__version__}")
558 | ctx.exit()
559 |
560 |
561 | def wrap_js_with_timeout(js_content: str, delay: int) -> str:
562 | """Wrap JavaScript content with setTimeout
563 |
564 | Args:
565 | js_content (str): Original JavaScript content
566 | delay (int): Seconds to wait before executing
567 |
568 | Returns:
569 | str: Wrapped JavaScript content
570 | """
571 | return f"""setTimeout(function() {{
572 | {js_content}
573 | }}, {delay * 1000});"""
574 |
575 |
576 | # pylint: disable=too-many-arguments
577 | @click.command()
578 | @click.option(
579 | "--arch",
580 | default=None,
581 | help="Specify the target architecture of the device. (options: arm64, x86_64, arm, x86, multi-arch)",
582 | )
583 | @click.option("--config", help="Specify the Frida configuration file.")
584 | @click.option("--js", default=None, help="Specify the Frida gadget JavaScript file.")
585 | @click.option(
586 | "--js-delay",
587 | type=int,
588 | help="Specify seconds to wait before executing the JavaScript file.",
589 | )
590 | @click.option(
591 | "--force-manifest",
592 | is_flag=True,
593 | help="Force modify AndroidManifest.xml even if it already has required permissions.",
594 | )
595 | @click.option(
596 | "--custom-gadget-name",
597 | default=None,
598 | help="Specify a custom name for the Frida gadget.",
599 | )
600 | @click.option("--no-res", is_flag=True, help="Skip decoding resources.")
601 | @click.option(
602 | "--main-activity", default=None, help="Specify the main activity if known."
603 | )
604 | @click.option(
605 | "--sign", is_flag=True, help="Automatically sign the APK using uber-apk-signer."
606 | )
607 | @click.option("--skip-decompile", is_flag=True, help="Skip the decompilation step.")
608 | @click.option("--skip-recompile", is_flag=True, help="Skip the recompilation step.")
609 | @click.option(
610 | "--use-aapt2",
611 | is_flag=True,
612 | help="Use aapt2 instead of aapt for resource processing.",
613 | )
614 | @click.option(
615 | "--decompile-opts",
616 | default=None,
617 | help="Specify additional options for apktool decompile.",
618 | )
619 | @click.option(
620 | "--recompile-opts",
621 | default=None,
622 | help="Specify additional options for apktool recompile.",
623 | )
624 | @click.option(
625 | "--apktool-path", default=None, help="Specify the path or command to run apktool."
626 | )
627 | @click.option("--frida-version", default=None, help="Specify the Frida version to use.")
628 | @click.option("--ks", default=None, help="The keystore file. If not provided, will use debug keystore.")
629 | @click.option("--ks-alias", default=None, help="The alias of the used key in the keystore.")
630 | @click.option("--ks-key-pass", default=None, help="The password for the key.")
631 | @click.option("--ks-pass", default=None, help="The password for the keystore.")
632 | @click.option(
633 | "--version",
634 | is_flag=True,
635 | callback=print_version,
636 | expose_value=False,
637 | is_eager=True,
638 | help="Show the version and exit.",
639 | )
640 | @click.argument("apk_path", type=click.Path(exists=True), required=True)
641 | def run(
642 | apk_path: str,
643 | arch: str,
644 | config: str,
645 | no_res: bool,
646 | main_activity: str,
647 | sign: bool,
648 | custom_gadget_name: str,
649 | js: str,
650 | js_delay: int,
651 | force_manifest: bool,
652 | skip_decompile: bool,
653 | skip_recompile: bool,
654 | use_aapt2: bool,
655 | decompile_opts: str,
656 | recompile_opts: str,
657 | apktool_path: str,
658 | frida_version: str,
659 | ks: str,
660 | ks_alias: str,
661 | ks_key_pass: str,
662 | ks_pass: str,
663 | ):
664 | """Patch an APK with the Frida gadget library"""
665 | apk_path = Path(apk_path)
666 |
667 | logger.info("APK: '%s'", apk_path)
668 | if arch is None:
669 | arch = detect_adb_arch()
670 | elif arch.lower() == "multi-arch":
671 | if skip_decompile:
672 | logger.warning(
673 | "The 'multi-arch' option requires decompiling the APK first to detect architectures"
674 | )
675 | arch = "multi-arch"
676 | elif arch == "arm64-v8a":
677 | arch = "arm64"
678 | elif arch == "armeabi-v7a":
679 | arch = "arm"
680 |
681 | # Validate js-delay option
682 | if js_delay is not None:
683 | if js is None:
684 | logger.error("The --js-delay option requires --js option to be specified.")
685 | sys.exit(-1)
686 | if js_delay < 0:
687 | logger.error("Delay value must be a positive number.")
688 | sys.exit(-1)
689 | logger.info("JavaScript execution will be delayed by %d seconds", js_delay)
690 |
691 | # Process JavaScript file with delay if specified
692 | if js and js_delay is not None:
693 | js_path = Path(js)
694 | if not js_path.exists():
695 | logger.error("The specified JavaScript file does not exist: %s", js)
696 | sys.exit(-1)
697 |
698 | try:
699 | original_content = js_path.read_text()
700 | wrapped_content = wrap_js_with_timeout(original_content, js_delay)
701 |
702 | # Create a temporary file with wrapped content
703 | temp_js = js_path.parent / f"{js_path.stem}_wrapped{js_path.suffix}"
704 | temp_js.write_text(wrapped_content)
705 | js = str(temp_js)
706 | logger.debug("Created wrapped JavaScript file: %s", js)
707 | except Exception as e:
708 | logger.error("Failed to process JavaScript file: %s", str(e))
709 | sys.exit(-1)
710 |
711 | global APKTOOL
712 | if apktool_path:
713 | APKTOOL = apktool_path
714 | apktool_parts = APKTOOL.split()
715 | apktool_binary = apktool_parts[-1]
716 | if not Path(apktool_binary).exists():
717 | logger.error(
718 | "The specified apktool path does not exist: %s", apktool_binary
719 | )
720 | sys.exit(-1)
721 |
722 | if len(apktool_parts) > 1:
723 | logger.info("Using custom apktool command: '%s'", APKTOOL)
724 | else:
725 | logger.info("Using custom apktool path: '%s'", APKTOOL)
726 | else:
727 | if not APKTOOL:
728 | raise FileNotFoundError(
729 | "apktool not found. Please install apktool and add it to your PATH environment.\n"
730 | "For macOS: brew install apktool\n"
731 | "For Windows: Download from https://ibotpeaches.github.io/Apktool/install/\n"
732 | "For Linux: sudo apt-get install apktool\n"
733 | "After installation, you may need to restart your terminal."
734 | )
735 |
736 | if arch != "multi-arch":
737 | logger.info(
738 | "Gadget Architecture(--arch): %s%s",
739 | arch,
740 | "(default)" if arch == "arm64" else "",
741 | )
742 | else:
743 | logger.info(
744 | "Gadget Architecture(--arch): %s (will inject for all architectures found in APK)",
745 | arch,
746 | )
747 |
748 | if js and not Path(js).exists():
749 | logger.error("The specified JavaScript file does not exist: %s", js)
750 | sys.exit(-1)
751 |
752 | if config and not Path(config).exists():
753 | logger.error("The specified configuration file does not exist: %s", config)
754 | sys.exit(-1)
755 | elif config:
756 | try:
757 | with open(config, "r") as f:
758 | json.load(f)
759 | except json.JSONDecodeError:
760 | logger.error(
761 | "The specified configuration file is not a valid JSON: %s", config
762 | )
763 | sys.exit(-1)
764 |
765 | if arch != "multi-arch":
766 | arch = arch.lower()
767 | supported_archs = ["arm", "arm64", "x86", "x86_64"]
768 | if arch not in supported_archs:
769 | logger.error(
770 | "The --arch option only supports the following architectures: %s, multi-arch",
771 | ", ".join(supported_archs),
772 | )
773 | sys.exit(-1)
774 |
775 | # Make temp directory for decompile
776 | decompiled_path = TEMP_DIR.joinpath(str(apk_path.resolve())[:-4])
777 | if not skip_decompile:
778 | logger.debug('Decompiling the target APK using apktool\n"%s"', decompiled_path)
779 | if decompiled_path.exists():
780 | shutil.rmtree(decompiled_path)
781 | decompiled_path.mkdir()
782 |
783 | # APK decompile with apktool
784 | decompile_option = ["d", "-o", str(decompiled_path.resolve()), "--force"]
785 | if force_manifest:
786 | decompile_option += ["--force-manifest"]
787 | if no_res:
788 | decompile_option += ["--no-res"]
789 | if decompile_opts:
790 | if "--no-res" in decompile_opts:
791 | if no_res:
792 | # remove no-res option if it's already in the list
793 | decompile_option.remove("--no-res")
794 | no_res = True
795 | decompile_option += decompile_opts.split()
796 |
797 | run_apktool(decompile_option, str(apk_path.resolve()))
798 | else:
799 | if not decompiled_path.exists():
800 | logger.error("Decompiled directory not found: %s", decompiled_path)
801 | sys.exit(-1)
802 |
803 | # Process if decompile is success
804 | modified_dex_number = inject_gadget_into_apk(
805 | apk_path,
806 | arch,
807 | decompiled_path,
808 | no_res,
809 | force_manifest,
810 | main_activity,
811 | config,
812 | js,
813 | custom_gadget_name,
814 | frida_version,
815 | )
816 |
817 | # Rebuild with apktool, print apk_path if process is success
818 | if not skip_recompile:
819 | logger.debug('Recompiling the new APK using apktool "%s"', decompiled_path)
820 |
821 | recompile_option = ["b"]
822 | if use_aapt2:
823 | recompile_option += ["--use-aapt2"]
824 | if recompile_opts:
825 | recompile_option += recompile_opts.split()
826 |
827 | run_apktool(recompile_option, str(decompiled_path.resolve()))
828 | recompiled_apk_path = decompiled_path.joinpath("dist", apk_path.name)
829 | if not recompiled_apk_path.exists():
830 | logger.error("APK not found: %s", recompiled_apk_path)
831 | else:
832 | logger.info("Frida gadget injected into APK: %s", recompiled_apk_path)
833 |
834 | # Clean up wrapped JavaScript file if it exists
835 | if js and js_delay is not None:
836 | temp_js = Path(js)
837 | if temp_js.exists() and temp_js.name.endswith("_wrapped.js"):
838 | try:
839 | temp_js.unlink()
840 | logger.debug("Cleaned up wrapped JavaScript file: %s", temp_js)
841 | except Exception as e:
842 | logger.warning(
843 | "Failed to clean up wrapped JavaScript file: %s", str(e)
844 | )
845 |
846 | # Copy original dex files except the modified one to the recompiled APK
847 | logger.debug(f"Copying original dex files (except modified one {modified_dex_number}) to the recompiled APK")
848 | try:
849 | # Create a temporary directory for extraction
850 | with tempfile.TemporaryDirectory() as temp_dir:
851 | temp_dir_path = Path(temp_dir)
852 |
853 | # Extract original APK
854 | original_apk_zip = zipfile.ZipFile(apk_path, 'r')
855 | original_apk_zip.extractall(temp_dir_path)
856 | original_apk_zip.close()
857 |
858 | # Create a temporary file for the recompiled APK
859 | with tempfile.NamedTemporaryFile(delete=False) as temp_file:
860 | temp_file_path = Path(temp_file.name)
861 |
862 | # Open recompiled APK for reading
863 | recompiled_apk = zipfile.ZipFile(recompiled_apk_path, 'r')
864 |
865 | # Create a new ZIP file
866 | new_apk = zipfile.ZipFile(temp_file_path, 'w')
867 |
868 | # Copy all files from recompiled APK except dex files
869 | modified_dex_filename = f"classes{modified_dex_number}.dex" \
870 | if modified_dex_number and modified_dex_number > 1 else "classes.dex"
871 |
872 | for item in recompiled_apk.infolist():
873 | if item.filename == modified_dex_filename:
874 | logger.debug(f"Copying {item.filename} from recompiled APK")
875 | new_apk.writestr(item, recompiled_apk.read(item.filename))
876 | elif item.filename.startswith('classes') and item.filename.endswith('.dex'):
877 | dex_file = temp_dir_path.joinpath(item.filename)
878 | new_apk.write(str(dex_file), dex_file.name)
879 | else:
880 | new_apk.writestr(item, recompiled_apk.read(item.filename))
881 |
882 | # Close all zip files
883 | recompiled_apk.close()
884 | new_apk.close()
885 |
886 | # Replace the recompiled APK with the new one
887 | shutil.move(temp_file_path, recompiled_apk_path)
888 | logger.info("Successfully replaced dex files in the recompiled APK")
889 | except Exception as e:
890 | logger.error(f"Failed to copy original dex files: {str(e)}")
891 |
892 | if sign:
893 | logger.debug("Starting APK signing using uber-apk-signer")
894 | sign_apk(str(recompiled_apk_path), ks, ks_alias, ks_key_pass, ks_pass)
895 | return
896 | else:
897 | logger.info(apk_path)
898 | logger.warning(
899 | "The APK is not signed. Use the --sign option to sign it automatically, "
900 | "or sign the APK manually before installing it."
901 | )
902 |
903 |
904 | if __name__ == "__main__":
905 | # pylint: disable=no-value-for-parameter
906 | run()
907 |
--------------------------------------------------------------------------------