apk-patchx 7.9.2025.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apk_patchx/__init__.py +14 -0
- apk_patchx/cli.py +273 -0
- apk_patchx/exceptions.py +41 -0
- apk_patchx/services/__init__.py +1 -0
- apk_patchx/services/adb.py +98 -0
- apk_patchx/services/android_sdk.py +145 -0
- apk_patchx/services/apktool.py +150 -0
- apk_patchx/services/frida.py +311 -0
- apk_patchx/services/patch_dex.py +119 -0
- apk_patchx/services/patch_smali.py +145 -0
- apk_patchx/services/signing.py +106 -0
- apk_patchx/services/split_merge.py +146 -0
- apk_patchx/utils/__init__.py +1 -0
- apk_patchx/utils/core.py +77 -0
- apk_patchx/utils/manifest.py +140 -0
- apk_patchx/utils/py.typed +0 -0
- apk_patchx/utils/versions.py +65 -0
- apk_patchx-7.9.2025.1.dist-info/METADATA +110 -0
- apk_patchx-7.9.2025.1.dist-info/RECORD +22 -0
- apk_patchx-7.9.2025.1.dist-info/WHEEL +4 -0
- apk_patchx-7.9.2025.1.dist-info/entry_points.txt +3 -0
- apk_patchx-7.9.2025.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,150 @@
|
|
1
|
+
"""Apktool service for APK operations."""
|
2
|
+
|
3
|
+
import subprocess
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import List, Optional
|
6
|
+
|
7
|
+
from ..exceptions import BuildError, NetworkError, ToolNotFoundError, ValidationError
|
8
|
+
from ..utils.core import run_command, get_apkpatcher_home, download_file
|
9
|
+
from ..utils.versions import get_latest_github_release
|
10
|
+
from .android_sdk import AndroidSDKService
|
11
|
+
|
12
|
+
|
13
|
+
class ApktoolService:
|
14
|
+
"""Service for apktool operations."""
|
15
|
+
|
16
|
+
def __init__(self, verbose: bool = False):
|
17
|
+
self.verbose = verbose
|
18
|
+
self.sdk_service = AndroidSDKService(verbose=verbose)
|
19
|
+
self._apktool_path: Optional[Path] = None
|
20
|
+
|
21
|
+
@property
|
22
|
+
def apktool_path(self) -> Path:
|
23
|
+
"""Get apktool JAR path."""
|
24
|
+
if self._apktool_path is None:
|
25
|
+
self._ensure_apktool()
|
26
|
+
return self._apktool_path
|
27
|
+
|
28
|
+
def _ensure_apktool(self) -> None:
|
29
|
+
"""Ensure apktool is available."""
|
30
|
+
tools_dir = get_apkpatcher_home() / "tools"
|
31
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
32
|
+
|
33
|
+
# Check for existing apktool
|
34
|
+
for jar_file in tools_dir.glob("apktool_*.jar"):
|
35
|
+
self._apktool_path = jar_file
|
36
|
+
return
|
37
|
+
|
38
|
+
# Download latest apktool
|
39
|
+
if self.verbose:
|
40
|
+
print("Downloading latest apktool...")
|
41
|
+
|
42
|
+
try:
|
43
|
+
latest_version = get_latest_github_release("iBotPeaches", "Apktool")
|
44
|
+
jar_name = f"apktool_{latest_version}.jar"
|
45
|
+
jar_url = f"https://github.com/iBotPeaches/Apktool/releases/download/v{latest_version}/{jar_name}"
|
46
|
+
|
47
|
+
jar_path = tools_dir / jar_name
|
48
|
+
download_file(jar_url, jar_path)
|
49
|
+
|
50
|
+
self._apktool_path = jar_path
|
51
|
+
|
52
|
+
if self.verbose:
|
53
|
+
print(f"Downloaded apktool {latest_version}")
|
54
|
+
|
55
|
+
except Exception as e:
|
56
|
+
raise NetworkError(f"Failed to download apktool: {e}")
|
57
|
+
|
58
|
+
def decode(self, apk_path: Path, output_dir: Optional[Path] = None,
|
59
|
+
no_resources: bool = False, no_sources: bool = False,
|
60
|
+
only_main_classes: bool = False, extra_args: Optional[str] = None) -> Path:
|
61
|
+
"""Decode APK file."""
|
62
|
+
if not apk_path.exists():
|
63
|
+
raise ValidationError(f"APK file not found: {apk_path}")
|
64
|
+
|
65
|
+
if output_dir is None:
|
66
|
+
output_dir = apk_path.parent / apk_path.stem
|
67
|
+
|
68
|
+
self.sdk_service.ensure_java()
|
69
|
+
|
70
|
+
cmd = ["java", "-jar", str(self.apktool_path), "d", str(apk_path)]
|
71
|
+
|
72
|
+
if no_resources:
|
73
|
+
cmd.append("-r")
|
74
|
+
if no_sources:
|
75
|
+
cmd.append("-s")
|
76
|
+
if only_main_classes:
|
77
|
+
cmd.append("--only-main-classes")
|
78
|
+
|
79
|
+
cmd.extend(["-o", str(output_dir)])
|
80
|
+
|
81
|
+
if extra_args:
|
82
|
+
cmd.extend(extra_args.split())
|
83
|
+
|
84
|
+
if self.verbose:
|
85
|
+
print(f"Decoding {apk_path} to {output_dir}...")
|
86
|
+
|
87
|
+
result = run_command(cmd)
|
88
|
+
if result.returncode != 0:
|
89
|
+
raise BuildError(f"Failed to decode APK: {apk_path}")
|
90
|
+
|
91
|
+
return output_dir
|
92
|
+
|
93
|
+
def build(self, source_dir: Path, output_path: Path,
|
94
|
+
add_network_config: bool = False, extra_args: Optional[str] = None) -> Path:
|
95
|
+
"""Build APK from source directory."""
|
96
|
+
if not source_dir.exists():
|
97
|
+
raise ValidationError(f"Source directory not found: {source_dir}")
|
98
|
+
|
99
|
+
self.sdk_service.ensure_java()
|
100
|
+
|
101
|
+
# Add network security config if requested
|
102
|
+
if add_network_config:
|
103
|
+
self._add_network_security_config(source_dir)
|
104
|
+
|
105
|
+
cmd = ["java", "-jar", str(self.apktool_path), "b", str(source_dir)]
|
106
|
+
cmd.extend(["-o", str(output_path)])
|
107
|
+
cmd.extend(["--use-aapt2"])
|
108
|
+
|
109
|
+
if extra_args:
|
110
|
+
cmd.extend(extra_args.split())
|
111
|
+
|
112
|
+
if self.verbose:
|
113
|
+
print(f"Building {source_dir} to {output_path}...")
|
114
|
+
|
115
|
+
result = run_command(cmd)
|
116
|
+
if result.returncode != 0:
|
117
|
+
raise BuildError(f"Failed to build APK from: {source_dir}")
|
118
|
+
|
119
|
+
return output_path
|
120
|
+
|
121
|
+
def _add_network_security_config(self, source_dir: Path) -> None:
|
122
|
+
"""Add permissive network security configuration."""
|
123
|
+
from ..utils.manifest import ManifestUtils
|
124
|
+
|
125
|
+
# Create network security config XML
|
126
|
+
xml_dir = source_dir / "res" / "xml"
|
127
|
+
xml_dir.mkdir(parents=True, exist_ok=True)
|
128
|
+
|
129
|
+
network_config = xml_dir / "network_security_config.xml"
|
130
|
+
network_config.write_text("""<?xml version="1.0" encoding="utf-8"?>
|
131
|
+
<network-security-config>
|
132
|
+
<domain-config cleartextTrafficPermitted="true">
|
133
|
+
<domain includeSubdomains="true">localhost</domain>
|
134
|
+
<domain includeSubdomains="true">10.0.0.0/8</domain>
|
135
|
+
<domain includeSubdomains="true">172.16.0.0/12</domain>
|
136
|
+
<domain includeSubdomains="true">192.168.0.0/16</domain>
|
137
|
+
</domain-config>
|
138
|
+
<base-config cleartextTrafficPermitted="true">
|
139
|
+
<trust-anchors>
|
140
|
+
<certificates src="system"/>
|
141
|
+
<certificates src="user"/>
|
142
|
+
</trust-anchors>
|
143
|
+
</base-config>
|
144
|
+
</network-security-config>
|
145
|
+
""")
|
146
|
+
|
147
|
+
# Update AndroidManifest.xml
|
148
|
+
manifest_path = source_dir / "AndroidManifest.xml"
|
149
|
+
if manifest_path.exists():
|
150
|
+
ManifestUtils.add_network_security_config(manifest_path)
|
@@ -0,0 +1,311 @@
|
|
1
|
+
"""Frida gadget injection service."""
|
2
|
+
|
3
|
+
import re
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Optional, Dict
|
6
|
+
|
7
|
+
from ..exceptions import FridaPatchError, NetworkError, ValidationError
|
8
|
+
from ..utils.core import run_command, get_apkpatcher_home, download_file
|
9
|
+
from ..utils.versions import get_latest_github_release
|
10
|
+
from .apktool import ApktoolService
|
11
|
+
from .signing import SigningService
|
12
|
+
|
13
|
+
|
14
|
+
class FridaService:
|
15
|
+
"""Service for Frida gadget operations."""
|
16
|
+
|
17
|
+
ARCH_MAPPING = {
|
18
|
+
"arm": ("armeabi-v7a", "android-arm"),
|
19
|
+
"arm64": ("arm64-v8a", "android-arm64"),
|
20
|
+
"x86": ("x86", "android-x86"),
|
21
|
+
"x86_64": ("x86_64", "android-x86_64")
|
22
|
+
}
|
23
|
+
|
24
|
+
def __init__(self, verbose: bool = False):
|
25
|
+
self.verbose = verbose
|
26
|
+
self.apktool_service = ApktoolService(verbose=verbose)
|
27
|
+
self.signing_service = SigningService(verbose=verbose)
|
28
|
+
|
29
|
+
def patch_apk(self, apk_path: Path, arch: str, gadget_config: Optional[Path] = None,
|
30
|
+
add_network_config: bool = False, no_sources: bool = False,
|
31
|
+
only_main_classes: bool = False, frida_version: Optional[str] = None,
|
32
|
+
decode_args: Optional[str] = None, build_args: Optional[str] = None) -> Path:
|
33
|
+
"""Patch APK with Frida gadget."""
|
34
|
+
if arch not in self.ARCH_MAPPING:
|
35
|
+
raise ValidationError(f"Unsupported architecture: {arch}")
|
36
|
+
|
37
|
+
# Get Frida gadget
|
38
|
+
gadget_path = self._ensure_frida_gadget(arch, frida_version)
|
39
|
+
|
40
|
+
# Decode APK
|
41
|
+
decode_dir = self.apktool_service.decode(
|
42
|
+
apk_path,
|
43
|
+
no_resources=False,
|
44
|
+
no_sources=no_sources,
|
45
|
+
only_main_classes=only_main_classes,
|
46
|
+
extra_args=decode_args
|
47
|
+
)
|
48
|
+
|
49
|
+
# Inject gadget
|
50
|
+
self._inject_gadget(decode_dir, arch, gadget_path, gadget_config, no_sources)
|
51
|
+
|
52
|
+
# Update manifest
|
53
|
+
self._update_manifest(decode_dir, add_network_config)
|
54
|
+
|
55
|
+
# Build patched APK
|
56
|
+
output_path = apk_path.parent / f"{apk_path.stem}.gadget.apk"
|
57
|
+
built_path = self.apktool_service.build(
|
58
|
+
decode_dir,
|
59
|
+
output_path,
|
60
|
+
add_network_config=add_network_config,
|
61
|
+
extra_args=build_args
|
62
|
+
)
|
63
|
+
|
64
|
+
# Sign APK
|
65
|
+
signed_path = self.signing_service.sign_apk(built_path)
|
66
|
+
|
67
|
+
return signed_path
|
68
|
+
|
69
|
+
def _ensure_frida_gadget(self, arch: str, version: Optional[str] = None) -> Path:
|
70
|
+
"""Ensure Frida gadget is available for architecture."""
|
71
|
+
if version is None:
|
72
|
+
version = get_latest_github_release("frida", "frida")
|
73
|
+
|
74
|
+
abi_name, frida_arch = self.ARCH_MAPPING[arch]
|
75
|
+
gadget_name = f"frida-gadget-{version}-{frida_arch}.so"
|
76
|
+
gadget_xz_name = f"{gadget_name}.xz"
|
77
|
+
|
78
|
+
tools_dir = get_apkpatcher_home() / "tools"
|
79
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
80
|
+
|
81
|
+
gadget_path = tools_dir / gadget_name
|
82
|
+
gadget_xz_path = tools_dir / gadget_xz_name
|
83
|
+
|
84
|
+
# Check if already downloaded and extracted
|
85
|
+
if gadget_path.exists():
|
86
|
+
return gadget_path
|
87
|
+
|
88
|
+
# Download if needed
|
89
|
+
if not gadget_xz_path.exists():
|
90
|
+
if self.verbose:
|
91
|
+
print(f"Downloading Frida gadget {version} for {arch}...")
|
92
|
+
|
93
|
+
gadget_url = f"https://github.com/frida/frida/releases/download/{version}/{gadget_xz_name}"
|
94
|
+
download_file(gadget_url, gadget_xz_path)
|
95
|
+
|
96
|
+
# Extract XZ file
|
97
|
+
if self.verbose:
|
98
|
+
print(f"Extracting {gadget_xz_name}...")
|
99
|
+
|
100
|
+
try:
|
101
|
+
import lzma
|
102
|
+
with lzma.open(gadget_xz_path, 'rb') as xz_file:
|
103
|
+
with open(gadget_path, 'wb') as out_file:
|
104
|
+
out_file.write(xz_file.read())
|
105
|
+
except ImportError:
|
106
|
+
# Fallback to system unxz
|
107
|
+
cmd = ["unxz", str(gadget_xz_path)]
|
108
|
+
result = run_command(cmd)
|
109
|
+
if result.returncode != 0:
|
110
|
+
raise FridaPatchError("Failed to extract Frida gadget (unxz not found)")
|
111
|
+
|
112
|
+
return gadget_path
|
113
|
+
|
114
|
+
def _inject_gadget(self, decode_dir: Path, arch: str, gadget_path: Path,
|
115
|
+
gadget_config: Optional[Path] = None, no_sources: bool = False) -> None:
|
116
|
+
"""Inject Frida gadget into decoded APK."""
|
117
|
+
abi_name, _ = self.ARCH_MAPPING[arch]
|
118
|
+
|
119
|
+
# Create lib directory and copy gadget
|
120
|
+
lib_dir = decode_dir / "lib" / abi_name
|
121
|
+
lib_dir.mkdir(parents=True, exist_ok=True)
|
122
|
+
|
123
|
+
import shutil
|
124
|
+
shutil.copy2(gadget_path, lib_dir / "libfrida-gadget.so")
|
125
|
+
|
126
|
+
# Copy gadget config if provided
|
127
|
+
if gadget_config:
|
128
|
+
shutil.copy2(gadget_config, lib_dir / "libfrida-gadget.config.so")
|
129
|
+
|
130
|
+
if not no_sources:
|
131
|
+
# Inject loadLibrary call into smali
|
132
|
+
self._inject_load_library_smali(decode_dir)
|
133
|
+
else:
|
134
|
+
# Use dexpatch for direct DEX patching
|
135
|
+
self._inject_load_library_dex(decode_dir)
|
136
|
+
|
137
|
+
def _inject_load_library_smali(self, decode_dir: Path) -> None:
|
138
|
+
"""Inject System.loadLibrary call into smali code."""
|
139
|
+
from .android_sdk import AndroidSDKService
|
140
|
+
|
141
|
+
# Find main activity
|
142
|
+
main_activity = self._find_main_activity(decode_dir)
|
143
|
+
if not main_activity:
|
144
|
+
raise FridaPatchError("Could not find main activity for injection")
|
145
|
+
|
146
|
+
# Convert activity name to smali path
|
147
|
+
smali_path = self._find_smali_class(decode_dir, main_activity)
|
148
|
+
if not smali_path:
|
149
|
+
raise FridaPatchError(f"Could not find smali file for {main_activity}")
|
150
|
+
|
151
|
+
if self.verbose:
|
152
|
+
print(f"Injecting into {smali_path}")
|
153
|
+
|
154
|
+
# Read smali file
|
155
|
+
lines = smali_path.read_text().splitlines()
|
156
|
+
|
157
|
+
# Find or create static constructor
|
158
|
+
injected = False
|
159
|
+
new_lines = []
|
160
|
+
i = 0
|
161
|
+
|
162
|
+
while i < len(lines):
|
163
|
+
line = lines[i]
|
164
|
+
new_lines.append(line)
|
165
|
+
|
166
|
+
# Look for existing static constructor
|
167
|
+
if line.strip().startswith(".method static constructor"):
|
168
|
+
# Found static constructor, inject after .locals
|
169
|
+
i += 1
|
170
|
+
while i < len(lines) and not lines[i].strip().startswith(".locals"):
|
171
|
+
new_lines.append(lines[i])
|
172
|
+
i += 1
|
173
|
+
|
174
|
+
if i < len(lines): # Found .locals
|
175
|
+
locals_line = lines[i]
|
176
|
+
new_lines.append(locals_line)
|
177
|
+
|
178
|
+
# Extract locals count and increment
|
179
|
+
match = re.search(r"\.locals (\d+)", locals_line)
|
180
|
+
if match:
|
181
|
+
locals_count = int(match.group(1)) + 1
|
182
|
+
new_lines[-1] = f" .locals {locals_count}"
|
183
|
+
|
184
|
+
# Inject loadLibrary call
|
185
|
+
new_lines.extend([
|
186
|
+
"",
|
187
|
+
' const-string v0, "frida-gadget"',
|
188
|
+
" invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V",
|
189
|
+
""
|
190
|
+
])
|
191
|
+
injected = True
|
192
|
+
i += 1
|
193
|
+
|
194
|
+
if not injected:
|
195
|
+
# No static constructor found, create one
|
196
|
+
# Insert before the first method or at the end of the class
|
197
|
+
insert_index = -1
|
198
|
+
for i, line in enumerate(new_lines):
|
199
|
+
if line.strip().startswith(".method") and not line.strip().startswith(".method static constructor"):
|
200
|
+
insert_index = i
|
201
|
+
break
|
202
|
+
|
203
|
+
if insert_index == -1:
|
204
|
+
# Insert before .end class
|
205
|
+
for i in range(len(new_lines) - 1, -1, -1):
|
206
|
+
if new_lines[i].strip() == ".end class":
|
207
|
+
insert_index = i
|
208
|
+
break
|
209
|
+
|
210
|
+
if insert_index != -1:
|
211
|
+
constructor_lines = [
|
212
|
+
"",
|
213
|
+
".method static constructor <clinit>()V",
|
214
|
+
" .locals 1",
|
215
|
+
"",
|
216
|
+
' const-string v0, "frida-gadget"',
|
217
|
+
" invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V",
|
218
|
+
"",
|
219
|
+
" return-void",
|
220
|
+
".end method",
|
221
|
+
""
|
222
|
+
]
|
223
|
+
|
224
|
+
new_lines[insert_index:insert_index] = constructor_lines
|
225
|
+
|
226
|
+
# Write back to file
|
227
|
+
smali_path.write_text("\n".join(new_lines))
|
228
|
+
|
229
|
+
def _inject_load_library_dex(self, decode_dir: Path) -> None:
|
230
|
+
"""Inject using dexpatch for direct DEX manipulation."""
|
231
|
+
# This would use dexpatch.jar - simplified implementation
|
232
|
+
if self.verbose:
|
233
|
+
print("DEX patching not fully implemented - falling back to smali injection")
|
234
|
+
|
235
|
+
self._inject_load_library_smali(decode_dir)
|
236
|
+
|
237
|
+
def _find_main_activity(self, decode_dir: Path) -> Optional[str]:
|
238
|
+
"""Find the main activity class name."""
|
239
|
+
from .android_sdk import AndroidSDKService
|
240
|
+
|
241
|
+
sdk_service = AndroidSDKService(verbose=self.verbose)
|
242
|
+
aapt = sdk_service.get_tool_path("aapt")
|
243
|
+
|
244
|
+
# Find original APK to analyze
|
245
|
+
apktool_yml = decode_dir / "apktool.yml"
|
246
|
+
if not apktool_yml.exists():
|
247
|
+
return None
|
248
|
+
|
249
|
+
# For now, parse AndroidManifest.xml directly
|
250
|
+
manifest_path = decode_dir / "AndroidManifest.xml"
|
251
|
+
if not manifest_path.exists():
|
252
|
+
return None
|
253
|
+
|
254
|
+
try:
|
255
|
+
# Simple XML parsing to find main activity
|
256
|
+
manifest_content = manifest_path.read_text()
|
257
|
+
|
258
|
+
# Look for activity with MAIN action and LAUNCHER category
|
259
|
+
import re
|
260
|
+
pattern = r'<activity[^>]*android:name="([^"]*)"[^>]*>.*?<action android:name="android\.intent\.action\.MAIN".*?<category android:name="android\.intent\.category\.LAUNCHER"'
|
261
|
+
match = re.search(pattern, manifest_content, re.DOTALL)
|
262
|
+
|
263
|
+
if match:
|
264
|
+
activity_name = match.group(1)
|
265
|
+
if activity_name.startswith("."):
|
266
|
+
# Get package name
|
267
|
+
pkg_match = re.search(r'package="([^"]*)"', manifest_content)
|
268
|
+
if pkg_match:
|
269
|
+
package_name = pkg_match.group(1)
|
270
|
+
activity_name = package_name + activity_name
|
271
|
+
return activity_name
|
272
|
+
except Exception:
|
273
|
+
pass
|
274
|
+
|
275
|
+
return None
|
276
|
+
|
277
|
+
def _find_smali_class(self, decode_dir: Path, class_name: str) -> Optional[Path]:
|
278
|
+
"""Find smali file for given class name."""
|
279
|
+
# Convert class name to path
|
280
|
+
class_path = class_name.replace(".", "/") + ".smali"
|
281
|
+
|
282
|
+
# Check in main smali directory
|
283
|
+
smali_file = decode_dir / "smali" / class_path
|
284
|
+
if smali_file.exists():
|
285
|
+
return smali_file
|
286
|
+
|
287
|
+
# Check in smali_classes directories (multidex)
|
288
|
+
for smali_dir in decode_dir.glob("smali_classes*"):
|
289
|
+
smali_file = smali_dir / class_path
|
290
|
+
if smali_file.exists():
|
291
|
+
return smali_file
|
292
|
+
|
293
|
+
return None
|
294
|
+
|
295
|
+
def _update_manifest(self, decode_dir: Path, add_network_config: bool) -> None:
|
296
|
+
"""Update AndroidManifest.xml with required permissions."""
|
297
|
+
from ..utils.manifest import ManifestUtils
|
298
|
+
|
299
|
+
manifest_path = decode_dir / "AndroidManifest.xml"
|
300
|
+
if not manifest_path.exists():
|
301
|
+
return
|
302
|
+
|
303
|
+
# Add INTERNET permission
|
304
|
+
ManifestUtils.add_internet_permission(manifest_path)
|
305
|
+
|
306
|
+
# Set extractNativeLibs to true
|
307
|
+
ManifestUtils.set_extract_native_libs(manifest_path, True)
|
308
|
+
|
309
|
+
# Add network security config if requested
|
310
|
+
if add_network_config:
|
311
|
+
ManifestUtils.add_network_security_config(manifest_path)
|
@@ -0,0 +1,119 @@
|
|
1
|
+
"""Service for direct DEX patching."""
|
2
|
+
|
3
|
+
import subprocess
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Optional
|
6
|
+
|
7
|
+
from ..exceptions import FridaPatchError, ToolNotFoundError
|
8
|
+
from ..utils.core import run_command, get_apkpatcher_home, download_file
|
9
|
+
|
10
|
+
|
11
|
+
class DexPatcher:
|
12
|
+
"""Service for direct DEX file patching using dexpatch."""
|
13
|
+
|
14
|
+
def __init__(self, verbose: bool = False):
|
15
|
+
self.verbose = verbose
|
16
|
+
self._dexpatch_path: Optional[Path] = None
|
17
|
+
|
18
|
+
@property
|
19
|
+
def dexpatch_path(self) -> Path:
|
20
|
+
"""Get dexpatch JAR path."""
|
21
|
+
if self._dexpatch_path is None:
|
22
|
+
self._ensure_dexpatch()
|
23
|
+
return self._dexpatch_path
|
24
|
+
|
25
|
+
def _ensure_dexpatch(self) -> None:
|
26
|
+
"""Ensure dexpatch is available."""
|
27
|
+
tools_dir = get_apkpatcher_home() / "tools"
|
28
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
29
|
+
|
30
|
+
dexpatch_path = tools_dir / "dexpatch-0.1.jar"
|
31
|
+
|
32
|
+
if dexpatch_path.exists():
|
33
|
+
self._dexpatch_path = dexpatch_path
|
34
|
+
return
|
35
|
+
|
36
|
+
# Download dexpatch
|
37
|
+
if self.verbose:
|
38
|
+
print("Downloading dexpatch...")
|
39
|
+
|
40
|
+
dexpatch_url = "https://github.com/ax/DEXPatch/releases/download/v0.1/dexpatch-0.1.jar"
|
41
|
+
download_file(dexpatch_url, dexpatch_path)
|
42
|
+
|
43
|
+
self._dexpatch_path = dexpatch_path
|
44
|
+
|
45
|
+
if self.verbose:
|
46
|
+
print("Downloaded dexpatch")
|
47
|
+
|
48
|
+
def patch_dex_file(self, dex_path: Path, target_class: str,
|
49
|
+
library_name: str = "frida-gadget") -> Path:
|
50
|
+
"""Patch DEX file with loadLibrary call.
|
51
|
+
|
52
|
+
Args:
|
53
|
+
dex_path: Path to DEX file
|
54
|
+
target_class: Target class name (e.g. com/example/MainActivity)
|
55
|
+
library_name: Library name to load
|
56
|
+
|
57
|
+
Returns:
|
58
|
+
Path to patched DEX file
|
59
|
+
"""
|
60
|
+
if not dex_path.exists():
|
61
|
+
raise FridaPatchError(f"DEX file not found: {dex_path}")
|
62
|
+
|
63
|
+
output_path = dex_path.parent / f"{dex_path.stem}.patched{dex_path.suffix}"
|
64
|
+
|
65
|
+
cmd = [
|
66
|
+
"java", "-jar", str(self.dexpatch_path),
|
67
|
+
str(dex_path),
|
68
|
+
str(output_path),
|
69
|
+
target_class
|
70
|
+
]
|
71
|
+
|
72
|
+
if self.verbose:
|
73
|
+
print(f"Patching {dex_path} with dexpatch...")
|
74
|
+
|
75
|
+
result = run_command(cmd)
|
76
|
+
if result.returncode != 0:
|
77
|
+
raise FridaPatchError(f"Failed to patch DEX file: {dex_path}")
|
78
|
+
|
79
|
+
return output_path
|
80
|
+
|
81
|
+
def find_dex_with_class(self, decode_dir: Path, class_name: str) -> Optional[Path]:
|
82
|
+
"""Find DEX file containing the specified class."""
|
83
|
+
# Convert class name to internal format
|
84
|
+
internal_name = class_name.replace(".", "/")
|
85
|
+
|
86
|
+
# Search in classes*.dex files
|
87
|
+
for dex_file in decode_dir.glob("classes*.dex"):
|
88
|
+
try:
|
89
|
+
# Use strings command to search for class in DEX
|
90
|
+
result = subprocess.run(
|
91
|
+
["strings", str(dex_file)],
|
92
|
+
capture_output=True, text=True, check=False
|
93
|
+
)
|
94
|
+
|
95
|
+
if result.returncode == 0 and internal_name in result.stdout:
|
96
|
+
return dex_file
|
97
|
+
except FileNotFoundError:
|
98
|
+
# strings command not available
|
99
|
+
pass
|
100
|
+
|
101
|
+
return None
|
102
|
+
|
103
|
+
def is_dex_patched(self, dex_path: Path, library_name: str = "frida-gadget") -> bool:
|
104
|
+
"""Check if DEX file is already patched."""
|
105
|
+
if not dex_path.exists():
|
106
|
+
return False
|
107
|
+
|
108
|
+
try:
|
109
|
+
result = subprocess.run(
|
110
|
+
["strings", str(dex_path)],
|
111
|
+
capture_output=True, text=True, check=False
|
112
|
+
)
|
113
|
+
|
114
|
+
return (result.returncode == 0 and
|
115
|
+
library_name in result.stdout and
|
116
|
+
"loadLibrary" in result.stdout)
|
117
|
+
except FileNotFoundError:
|
118
|
+
# If strings is not available, assume not patched
|
119
|
+
return False
|