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.
@@ -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