forked from NickstaDB/patch-apk
-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathpatch-apk.py
More file actions
executable file
·217 lines (172 loc) · 8.61 KB
/
patch-apk.py
File metadata and controls
executable file
·217 lines (172 loc) · 8.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
#!/usr/bin/env python3
import argparse, os, sys, tempfile, shutil, subprocess
from pathlib import Path
from APK import APK
from ADBHelper import ADBHelper, ADBError
from Log import Log
from FridaGadget import FridaGadget
from termcolor import colored # pip3 install termcolor
from packaging.version import parse as parse_version
def sign_with_apksigner(apk_path: str):
DEBUG_KS_NAME = "patchapk.jks"
DEBUG_KS_ALIAS = "patchapk"
DEBUG_KS_PASS = "patchapk"
script_dir = os.path.dirname(os.path.realpath(__file__))
ks_path = os.path.join(script_dir, DEBUG_KS_NAME)
cmd = [
"apksigner", "sign",
"--ks", ks_path,
"--ks-key-alias", DEBUG_KS_ALIAS,
"--ks-pass", f"pass:{DEBUG_KS_PASS}",
"--key-pass", f"pass:{DEBUG_KS_PASS}",
"--ks-type", "JKS",
"--v1-signing-enabled=false",
"--v2-signing-enabled=true",
"--v3-signing-enabled=true",
"--v4-signing-enabled=false",
apk_path,
]
Log.verbose("[apksigner] " + " ".join(cmd))
cp = subprocess.run(cmd, check=True)
def choose_package(adb: ADBHelper, pattern: str) -> str:
matches = adb.get_packages(pattern)
if not matches:
Log.abort(f"No packages found matching '{pattern}'")
if len(matches) == 1:
return matches[0]
# Multiple matches: show menu, ask user to choose by number
Log.info("Multiple matching packages found. Select the package to patch:")
for i, name in enumerate(matches, start=1):
Log.info(f"[{i}] {name}", "")
while True:
try:
choice = input("\nChoice (number, or 'q' to cancel): ").strip().lower()
except (EOFError, KeyboardInterrupt):
Log.abort("Selection cancelled.")
if choice in ("q", "quit", "exit"):
Log.abort("Selection cancelled by user.")
if choice.isdigit():
idx = int(choice)
if 1 <= idx <= len(matches):
selected = matches[idx - 1]
return selected
Log.warn("Invalid choice. Please enter a number from the list, or 'q' to cancel.")
def main():
ap = argparse.ArgumentParser(description="Pull, merge/patch, add gadget, build, align, sign, install.")
ap.add_argument("pkg_pattern", help="Package name or substring")
ap.add_argument("--serial", help="adb -s <serial>")
ap.add_argument("--user", default="0", help="Preferred user id (fallback to others if not found)")
ap.add_argument("--gadget-version", default=None, help="Frida Gadget version (None = latest)")
ap.add_argument("--no-user-certs", action="store_true", default=False,
help="Do not enable user-installed CA certs via networkSecurityConfig")
ap.add_argument("--no-gadget", action="store_true", default=False,
help="Do not add Frida Gadget")
ap.add_argument("--extract-only", action="store_true", default=False,
help="Only extract, merge and rebuild. Alias for --no-gadget --no-user-certs --no-install")
ap.add_argument("--disable-styles-hack", action="store_true", default=False,
help="Skip duplicate <style><item> removal (merge step)")
ap.add_argument("--no-install", action="store_true", help="Do not install to device at the end")
ap.add_argument("--keep-splits", action="store_true", help="Keep split APKs when extracting")
ap.add_argument("--save-apk", help="Copy final APK to this path")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
Log.verbose_enabled = args.verbose
if args.gadget_version and args.no_gadget:
Log.abort("Cannot specify --gadget-version when --no-gadget is set.")
adb = ADBHelper(serial=args.serial)
pkg = choose_package(adb, args.pkg_pattern)
Log.info(f"Using package: {colored(pkg, 'green')}")
resolved_user, apk_paths = adb.get_apk_paths(pkg, user=args.user)
if not apk_paths:
Log.abort(f"No APK paths found for {pkg}")
if resolved_user != args.user:
Log.warn(f"Requested user '{args.user}' not found for package; using user '{resolved_user}' instead.")
if not args.extract_only:
Log.info("Fetching Frida gadgets")
gadget_version = FridaGadget().obtain_gadgets(args.gadget_version)
if not args.gadget_version:
Log.warn(f"No Frida Gadget version specified; using latest available ({gadget_version}).")
Log.warn("Specify --gadget-version 16.7.19 for compatibility with objection")
if args.gadget_version and gadget_version != args.gadget_version:
Log.warn(f"Requested Frida Gadget version '{args.gadget_version}' not found; using '{gadget_version}' instead.")
Log.verbose(f"Resolved user: {resolved_user}")
Log.verbose(f"APK paths: {apk_paths}")
with tempfile.TemporaryDirectory(prefix="patchapk_") as tmp:
# Pull split(s) via ADBHelper
local_apks = adb.pull_files(apk_paths, tmp, pkg)
Log.info(f"Pulled {len(local_apks)} APK(s)")
# Keep splits if requested
if args.keep_splits:
target = f"{pkg}_splits"
Path(target).mkdir(parents=True, exist_ok=True)
for p in local_apks:
shutil.copyfile(p, Path(target) / (os.path.basename(p).replace(pkg + "-", "")))
Log.info(f"Saved split APKs to: {colored(target, 'green')}")
for p in local_apks:
Log.info(f" - {os.path.basename(p)}")
# Test if apktool is version 3.0.2 or newer
apktool_version = subprocess.run(["apktool", "-version"], capture_output=True, text=True).stdout.strip()
if parse_version(apktool_version) < parse_version("3.0.2"):
Log.abort(f"apktool version 3.0.2 or newer is required, found {apktool_version}")
return
if len(local_apks) == 1:
Log.info("Single APK detected")
base = APK(local_apks[0])
else:
Log.info(f"Split APK set detected ({len(local_apks)})")
apks = [APK(p) for p in local_apks]
# Find base APK (heuristic: filename containing "base", else first)
base = next((p for p in apks if "base.apk" in p.apk_path), apks[0])
others = [p for p in apks if p != base]
base.merge_with(others, disable_styles_hack=args.disable_styles_hack)
if len(local_apks) == 1:
# If there's only one APK, and extract-only is requested, just copy it and exit
if args.extract_only:
target = args.save_apk if args.save_apk else f"{pkg}.apk"
Path(os.path.dirname(target) or ".").mkdir(parents=True, exist_ok=True)
shutil.copyfile(local_apks[0], target)
Log.info(f"Saved APK: {colored(target, 'green')}")
return
# Otherwise, disassemble it for patching
base.disassemble()
# Apply patches
if not args.extract_only:
base.apply_patches(version=gadget_version,
enable_user_certs=not args.no_user_certs,
frida_gadget=not args.no_gadget)
# Build final APK
base.assemble()
# If extract-only, save and exit
if args.extract_only:
target = args.save_apk if args.save_apk else f"{pkg}.apk"
Path(os.path.dirname(target) or ".").mkdir(parents=True, exist_ok=True)
shutil.copyfile(base.apk_path, target)
Log.info(f"Saved APK: {colored(target, 'green')}")
return
# Prep apk for installation
base.zipalign(in_place=True)
final_apk = base.apk_path
# Sign
Log.info("Signing with apksigner")
sign_with_apksigner(final_apk)
# Save copy if requested
if args.save_apk or args.no_install:
target = args.save_apk if args.save_apk else f"{pkg}.apk"
Path(os.path.dirname(target) or ".").mkdir(parents=True, exist_ok=True)
shutil.copyfile(final_apk, target)
Log.info(f"Saved APK: {colored(target, 'green')}")
# Install via ADBHelper
if not args.no_install:
Log.info(f"Uninstalling original (user {resolved_user})")
adb.uninstall_pkg(pkg, user=resolved_user)
Log.info(f"Installing patched version (user {resolved_user})")
adb.install_apk(final_apk, user=resolved_user, replace=True)
if __name__ == "__main__":
try:
main()
except ADBError as e:
Log.abort(f"[ADB ERROR] {e}", file=sys.stderr)
except subprocess.CalledProcessError as e:
Log.abort(f"[PROC ERROR] {e}", file=sys.stderr)
except KeyboardInterrupt:
sys.exit(130)