wechat-cli 0.1.0__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.
- wechat_cli/__init__.py +3 -0
- wechat_cli/__main__.py +3 -0
- wechat_cli/assets/app.js +79 -0
- wechat_cli/assets/bridge.cpp +643 -0
- wechat_cli/assets/controls.swift +218 -0
- wechat_cli/assets/entitlements.plist +38 -0
- wechat_cli/assets/favicon.svg +1 -0
- wechat_cli/assets/index.html +10 -0
- wechat_cli/assets/native-agent.js +334 -0
- wechat_cli/assets/pdf-thumbnail.swift +13 -0
- wechat_cli/assets/profile.json +232 -0
- wechat_cli/assets/profiles/4.1.13-269631.json +182 -0
- wechat_cli/assets/profiles/4.1.13.34-269602.json +232 -0
- wechat_cli/assets/profiles/4.1.15-270135.json +194 -0
- wechat_cli/assets/startup-notice.js +78 -0
- wechat_cli/assets/style.css +1 -0
- wechat_cli/cli.py +238 -0
- wechat_cli/client.py +59 -0
- wechat_cli/codec.py +14 -0
- wechat_cli/config.py +84 -0
- wechat_cli/controls.py +223 -0
- wechat_cli/errors.py +7 -0
- wechat_cli/idempotency.py +82 -0
- wechat_cli/lifecycle.py +141 -0
- wechat_cli/media.py +341 -0
- wechat_cli/models.py +260 -0
- wechat_cli/native.py +229 -0
- wechat_cli/network.py +9 -0
- wechat_cli/server.py +518 -0
- wechat_cli/setup.py +342 -0
- wechat_cli/worker.py +227 -0
- wechat_cli-0.1.0.dist-info/METADATA +178 -0
- wechat_cli-0.1.0.dist-info/RECORD +36 -0
- wechat_cli-0.1.0.dist-info/WHEEL +4 -0
- wechat_cli-0.1.0.dist-info/entry_points.txt +2 -0
- wechat_cli-0.1.0.dist-info/licenses/LICENSE +7 -0
wechat_cli/setup.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Download, validate, copy and instrument WeChat without modifying the installed app."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import lzma
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import secrets
|
|
9
|
+
import shutil
|
|
10
|
+
import socket
|
|
11
|
+
import struct
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .config import ASSETS, load_runtime, private_directory, save_json
|
|
20
|
+
from .errors import AppError
|
|
21
|
+
from .network import ssl_context
|
|
22
|
+
|
|
23
|
+
WECHAT_URL = "https://dldir1v6.qq.com/weixin/Universal/Mac/WeChatMac_4.1.15.dmg"
|
|
24
|
+
GADGET_URL = (
|
|
25
|
+
"https://github.com/frida/frida/releases/download/17.8.0/frida-gadget-17.8.0-macos-universal.dylib.xz"
|
|
26
|
+
)
|
|
27
|
+
GADGET_SHA256 = "4cf5433452e745e8b283c7a2e79518c2cd6295e85869589dc3c171d5689e6b8c"
|
|
28
|
+
GADGET_LOAD = b"@executable_path/../Frameworks/FridaGadget.dylib"
|
|
29
|
+
PROFILE = json.loads((ASSETS / "profile.json").read_text())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def require_macos():
|
|
33
|
+
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
|
34
|
+
raise AppError("unsupported_platform", "Native setup requires Apple Silicon macOS", 424)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def execute(args, **kwargs):
|
|
38
|
+
try:
|
|
39
|
+
return subprocess.run(
|
|
40
|
+
[str(x) for x in args],
|
|
41
|
+
check=True,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
timeout=kwargs.pop("timeout", 120),
|
|
44
|
+
**kwargs,
|
|
45
|
+
).stdout
|
|
46
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
47
|
+
# Do not include command outputs; they may contain private paths or tokens.
|
|
48
|
+
raise AppError(
|
|
49
|
+
"setup_command_failed",
|
|
50
|
+
f"{Path(str(args[0])).name} failed; check Xcode command-line tools and app write permissions",
|
|
51
|
+
424,
|
|
52
|
+
) from error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fingerprint(path):
|
|
56
|
+
with path.open("rb") as source:
|
|
57
|
+
return hashlib.file_digest(source, "sha256").hexdigest()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_payload(app):
|
|
61
|
+
try:
|
|
62
|
+
actual = fingerprint(app / "Contents/Resources/wechat.dylib")
|
|
63
|
+
except OSError:
|
|
64
|
+
raise AppError("invalid_wechat_app", "Missing Contents/Resources/wechat.dylib", 424) from None
|
|
65
|
+
for path in sorted((ASSETS / "profiles").glob("*.json")):
|
|
66
|
+
profile = json.loads(path.read_text())
|
|
67
|
+
if actual == profile["payloadSha256"]:
|
|
68
|
+
return profile
|
|
69
|
+
raise AppError(
|
|
70
|
+
"unsupported_wechat_version",
|
|
71
|
+
"This WeChat payload has no validated native profile. Update wechat-cli, "
|
|
72
|
+
"or select a supported installer with --wechat-url. Stock WeChat was not modified.",
|
|
73
|
+
424,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_app(app):
|
|
78
|
+
app = Path(app).expanduser().resolve()
|
|
79
|
+
if app == Path("/Applications/WeChat.app").resolve():
|
|
80
|
+
raise AppError(
|
|
81
|
+
"stock_app_protected", "Use --source to copy stock WeChat, or --app with a separate patched copy"
|
|
82
|
+
)
|
|
83
|
+
check_payload(app)
|
|
84
|
+
try:
|
|
85
|
+
config = json.loads((app / "Contents/Frameworks/FridaGadget.config").read_text())["interaction"]
|
|
86
|
+
if (
|
|
87
|
+
config["type"] != "listen"
|
|
88
|
+
or config.get("address", "127.0.0.1") != "127.0.0.1"
|
|
89
|
+
or type(config["port"]) is not int
|
|
90
|
+
or not 1 <= config["port"] <= 65535
|
|
91
|
+
or not isinstance(config["token"], str)
|
|
92
|
+
or len(config["token"]) < 16
|
|
93
|
+
):
|
|
94
|
+
raise ValueError()
|
|
95
|
+
if not (app / "Contents/Frameworks/FridaGadget.dylib").is_file():
|
|
96
|
+
raise ValueError()
|
|
97
|
+
except (OSError, KeyError, TypeError, ValueError):
|
|
98
|
+
raise AppError(
|
|
99
|
+
"invalid_gadget_config", "A loopback-only authenticated Frida Gadget is required", 424
|
|
100
|
+
) from None
|
|
101
|
+
return app
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def download(url, target, maximum, expected=None):
|
|
105
|
+
"""Bounded, atomic HTTPS download; a failed download never replaces a cached file."""
|
|
106
|
+
if not url.startswith("https://"):
|
|
107
|
+
raise AppError("invalid_download_url", "Installer downloads require HTTPS")
|
|
108
|
+
if target.is_file() and expected and fingerprint(target) == expected:
|
|
109
|
+
return target
|
|
110
|
+
temporary = target.with_suffix(target.suffix + ".part")
|
|
111
|
+
try:
|
|
112
|
+
request = urllib.request.Request(url, headers={"User-Agent": "wechat-cli/0.1.0"})
|
|
113
|
+
with (
|
|
114
|
+
urllib.request.urlopen(request, timeout=60, context=ssl_context()) as response,
|
|
115
|
+
temporary.open("wb") as output,
|
|
116
|
+
):
|
|
117
|
+
if not response.url.startswith("https://"):
|
|
118
|
+
raise AppError("invalid_download_url", "Download redirected away from HTTPS")
|
|
119
|
+
size = 0
|
|
120
|
+
while chunk := response.read(1024 * 1024):
|
|
121
|
+
size += len(chunk)
|
|
122
|
+
if size > maximum:
|
|
123
|
+
raise AppError("download_too_large", "Download exceeds the configured size limit", 413)
|
|
124
|
+
output.write(chunk)
|
|
125
|
+
if expected and fingerprint(temporary) != expected:
|
|
126
|
+
raise AppError(
|
|
127
|
+
"download_checksum_mismatch", "Downloaded Frida Gadget failed SHA-256 verification", 424
|
|
128
|
+
)
|
|
129
|
+
os.replace(temporary, target)
|
|
130
|
+
return target
|
|
131
|
+
except (OSError, urllib.error.URLError) as error:
|
|
132
|
+
raise AppError(
|
|
133
|
+
"download_failed",
|
|
134
|
+
"Cannot download the installer; check connectivity or supply --source /path/to/WeChat.app",
|
|
135
|
+
424,
|
|
136
|
+
) from error
|
|
137
|
+
finally:
|
|
138
|
+
temporary.unlink(missing_ok=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@contextmanager
|
|
142
|
+
def mounted_app(dmg, directory):
|
|
143
|
+
mount = Path(tempfile.mkdtemp(prefix="mount-", dir=directory))
|
|
144
|
+
attached = False
|
|
145
|
+
try:
|
|
146
|
+
execute(
|
|
147
|
+
["/usr/bin/hdiutil", "attach", "-readonly", "-nobrowse", "-mountpoint", mount, dmg], timeout=180
|
|
148
|
+
)
|
|
149
|
+
attached = True
|
|
150
|
+
apps = [p for p in mount.glob("*.app") if p.name in {"WeChat.app", "微信.app"}]
|
|
151
|
+
if len(apps) != 1:
|
|
152
|
+
raise AppError("invalid_installer", "Installer must contain one WeChat.app", 424)
|
|
153
|
+
yield apps[0]
|
|
154
|
+
finally:
|
|
155
|
+
if attached:
|
|
156
|
+
execute(["/usr/bin/hdiutil", "detach", mount], timeout=30)
|
|
157
|
+
mount.rmdir()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def inject_gadget(binary):
|
|
161
|
+
"""Add LC_LOAD_DYLIB only in verified empty Mach-O header padding.
|
|
162
|
+
|
|
163
|
+
No relocation or vendor payload modification. Support thin 64-bit and
|
|
164
|
+
universal 32-bit fat headers; fail closed for other layouts.
|
|
165
|
+
"""
|
|
166
|
+
data = bytearray(binary.read_bytes())
|
|
167
|
+
if data[:4] == b"\xca\xfe\xba\xbe":
|
|
168
|
+
count = struct.unpack_from(">I", data, 4)[0]
|
|
169
|
+
if not 1 <= count <= 8 or len(data) < 8 + count * 20:
|
|
170
|
+
raise AppError("invalid_macho", "Invalid universal Mach-O header", 424)
|
|
171
|
+
slices = [struct.unpack_from(">IIIII", data, 8 + i * 20)[2:4] for i in range(count)]
|
|
172
|
+
else:
|
|
173
|
+
slices = [(0, len(data))]
|
|
174
|
+
for base, size in slices:
|
|
175
|
+
if base + size > len(data) or size < 32 or data[base : base + 4] != b"\xcf\xfa\xed\xfe":
|
|
176
|
+
raise AppError("invalid_macho", "Expected a 64-bit Mach-O executable", 424)
|
|
177
|
+
ncmds, cmdsize = struct.unpack_from("<II", data, base + 16)
|
|
178
|
+
end = base + 32 + cmdsize
|
|
179
|
+
if end > base + size:
|
|
180
|
+
raise AppError("invalid_macho", "Load commands exceed the Mach-O slice", 424)
|
|
181
|
+
cursor, found = base + 32, False
|
|
182
|
+
first_section = base + size
|
|
183
|
+
for _ in range(ncmds):
|
|
184
|
+
if cursor + 8 > end:
|
|
185
|
+
raise AppError("invalid_macho", "Truncated load command", 424)
|
|
186
|
+
cmd, length = struct.unpack_from("<II", data, cursor)
|
|
187
|
+
if length < 8 or cursor + length > end:
|
|
188
|
+
raise AppError("invalid_macho", "Invalid load command length", 424)
|
|
189
|
+
if cmd == 0x19:
|
|
190
|
+
if length < 72:
|
|
191
|
+
raise AppError("invalid_macho", "Truncated segment command", 424)
|
|
192
|
+
section_count = struct.unpack_from("<I", data, cursor + 64)[0]
|
|
193
|
+
if length < 72 + section_count * 80:
|
|
194
|
+
raise AppError("invalid_macho", "Truncated section table", 424)
|
|
195
|
+
for section in range(section_count):
|
|
196
|
+
section_offset = struct.unpack_from("<I", data, cursor + 72 + section * 80 + 48)[0]
|
|
197
|
+
if section_offset:
|
|
198
|
+
first_section = min(first_section, base + section_offset)
|
|
199
|
+
if cmd in {0xC, 0x80000018} and GADGET_LOAD in data[cursor : cursor + length]:
|
|
200
|
+
found = True
|
|
201
|
+
cursor += length
|
|
202
|
+
if cursor != end:
|
|
203
|
+
raise AppError("invalid_macho", "Inconsistent load command size", 424)
|
|
204
|
+
if found:
|
|
205
|
+
continue
|
|
206
|
+
length = (24 + len(GADGET_LOAD) + 1 + 7) & ~7
|
|
207
|
+
if end + length > first_section or any(data[end : end + length]):
|
|
208
|
+
raise AppError("macho_padding_unavailable", "No empty header padding for Frida Gadget", 424)
|
|
209
|
+
command = struct.pack("<IIIIII", 0xC, length, 24, 0, 0, 0) + GADGET_LOAD + b"\0"
|
|
210
|
+
data[end : end + length] = command.ljust(length, b"\0")
|
|
211
|
+
struct.pack_into("<II", data, base + 16, ncmds + 1, cmdsize + length)
|
|
212
|
+
binary.write_bytes(data)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def build_helper(app):
|
|
216
|
+
# Gadget configuration lives in Frameworks, so codesign treats it as nested code.
|
|
217
|
+
# A generic file signature is stored in extended attributes by macOS.
|
|
218
|
+
execute(["/usr/bin/codesign", "--force", "--sign", "-", app / "Contents/Frameworks/FridaGadget.config"])
|
|
219
|
+
compiler = execute(["/usr/bin/clang++", "--version"])
|
|
220
|
+
flags = ["-arch", "arm64", "-std=c++17", "-dynamiclib", "-O2", "-Wall", "-Wextra", "-Werror"]
|
|
221
|
+
source = ASSETS / "bridge.cpp"
|
|
222
|
+
digest = hashlib.sha256(source.read_bytes() + compiler + json.dumps(flags).encode()).hexdigest()[:16]
|
|
223
|
+
library = f"native-reader-{digest}.dylib"
|
|
224
|
+
output = app / "Contents/Frameworks" / library
|
|
225
|
+
if not output.exists():
|
|
226
|
+
temporary = output.with_suffix(f".{os.getpid()}.tmp")
|
|
227
|
+
try:
|
|
228
|
+
execute(["/usr/bin/clang++", *flags, source, "-o", temporary])
|
|
229
|
+
os.replace(temporary, output)
|
|
230
|
+
finally:
|
|
231
|
+
temporary.unlink(missing_ok=True)
|
|
232
|
+
execute(
|
|
233
|
+
["/usr/bin/codesign", "--force", "--sign", "-", "--entitlements", ASSETS / "entitlements.plist", app]
|
|
234
|
+
)
|
|
235
|
+
return library
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def register(app, directory):
|
|
239
|
+
app = check_app(app)
|
|
240
|
+
library = build_helper(app)
|
|
241
|
+
save_json(directory / "native-runtime.json", {"app": str(app), "supportLibrary": library})
|
|
242
|
+
return {
|
|
243
|
+
"configured": True,
|
|
244
|
+
"app": str(app),
|
|
245
|
+
"version": check_payload(app)["version"],
|
|
246
|
+
"build": check_payload(app)["build"],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def prepare(source, destination, gadget, directory):
|
|
251
|
+
source, destination = Path(source).resolve(), Path(destination).resolve()
|
|
252
|
+
if destination == Path("/Applications/WeChat.app").resolve() or destination == source:
|
|
253
|
+
raise AppError("stock_app_protected", "Destination must be a separate app copy")
|
|
254
|
+
if source in destination.parents:
|
|
255
|
+
raise AppError("invalid_destination", "Destination cannot be inside the source app")
|
|
256
|
+
if destination.exists():
|
|
257
|
+
raise AppError("destination_exists", "Destination already exists; use setup --app to register it")
|
|
258
|
+
check_payload(source) # Reject before copying hundreds of megabytes.
|
|
259
|
+
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
260
|
+
with tempfile.TemporaryDirectory(prefix="prepare-", dir=destination.parent) as staging:
|
|
261
|
+
app = Path(staging) / "WeChatBridge.app"
|
|
262
|
+
execute(["/usr/bin/ditto", source, app], timeout=180)
|
|
263
|
+
frameworks = app / "Contents/Frameworks"
|
|
264
|
+
shutil.copyfile(gadget, frameworks / "FridaGadget.dylib")
|
|
265
|
+
with socket.socket() as sock:
|
|
266
|
+
sock.bind(("127.0.0.1", 0))
|
|
267
|
+
port = sock.getsockname()[1]
|
|
268
|
+
save_json(
|
|
269
|
+
frameworks / "FridaGadget.config",
|
|
270
|
+
{
|
|
271
|
+
"interaction": {
|
|
272
|
+
"type": "listen",
|
|
273
|
+
"address": "127.0.0.1",
|
|
274
|
+
"port": port,
|
|
275
|
+
"token": secrets.token_hex(32),
|
|
276
|
+
"on_load": "wait",
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
inject_gadget(app / "Contents/MacOS/WeChat")
|
|
281
|
+
execute(["/usr/bin/codesign", "--force", "--sign", "-", frameworks / "FridaGadget.dylib"])
|
|
282
|
+
library = build_helper(app)
|
|
283
|
+
# Ensure no partially prepared app is exposed as the configured runtime.
|
|
284
|
+
os.rename(app, destination)
|
|
285
|
+
save_json(directory / "native-runtime.json", {"app": str(destination), "supportLibrary": library})
|
|
286
|
+
return {
|
|
287
|
+
"configured": True,
|
|
288
|
+
"app": str(destination),
|
|
289
|
+
"version": check_payload(destination)["version"],
|
|
290
|
+
"build": check_payload(destination)["build"],
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def setup(directory, *, app=None, source=None, destination=None, gadget=None, wechat_url=WECHAT_URL):
|
|
295
|
+
require_macos()
|
|
296
|
+
private_directory(directory)
|
|
297
|
+
if app:
|
|
298
|
+
return register(app, directory)
|
|
299
|
+
destination = Path(destination or directory / "apps/WeChatBridge.app").expanduser().resolve()
|
|
300
|
+
if not source and destination.exists():
|
|
301
|
+
return register(destination, directory)
|
|
302
|
+
downloads = private_directory(directory / "downloads")
|
|
303
|
+
if gadget:
|
|
304
|
+
gadget_path = Path(gadget).expanduser().resolve()
|
|
305
|
+
else:
|
|
306
|
+
compressed = download(GADGET_URL, downloads / "gadget-17.8.0.dylib.xz", 64 * 1024**2, GADGET_SHA256)
|
|
307
|
+
gadget_path = downloads / "FridaGadget.dylib"
|
|
308
|
+
with lzma.open(compressed) as input_file, gadget_path.open("wb") as output:
|
|
309
|
+
# The compressed archive is pinned to a verified hash.
|
|
310
|
+
shutil.copyfileobj(input_file, output)
|
|
311
|
+
if source:
|
|
312
|
+
return prepare(source, destination, gadget_path, directory)
|
|
313
|
+
dmg = download(wechat_url, downloads / "WeChat.dmg", 1024**3)
|
|
314
|
+
with mounted_app(dmg, downloads) as stock:
|
|
315
|
+
# Verify Tencent's signature before trusting any downloaded executable.
|
|
316
|
+
execute(["/usr/bin/codesign", "--verify", "--deep", "--strict", stock])
|
|
317
|
+
details = subprocess.run(
|
|
318
|
+
["/usr/bin/codesign", "-dv", "--verbose=4", str(stock)], capture_output=True, timeout=30
|
|
319
|
+
)
|
|
320
|
+
if b"TeamIdentifier=5A4RE8SF68" not in details.stderr:
|
|
321
|
+
raise AppError(
|
|
322
|
+
"invalid_vendor_signature",
|
|
323
|
+
"Downloaded app is not signed by the expected WeChat developer",
|
|
324
|
+
424,
|
|
325
|
+
)
|
|
326
|
+
return prepare(stock, destination, gadget_path, directory)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def doctor(directory):
|
|
330
|
+
require_macos()
|
|
331
|
+
runtime = load_runtime(directory)
|
|
332
|
+
check_app(runtime["app"])
|
|
333
|
+
import frida
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
"ready": True,
|
|
337
|
+
"app": runtime["app"],
|
|
338
|
+
"version": check_payload(Path(runtime["app"]))["version"],
|
|
339
|
+
"build": check_payload(Path(runtime["app"]))["build"],
|
|
340
|
+
"fridaVersion": frida.__version__,
|
|
341
|
+
"dataDir": str(directory),
|
|
342
|
+
}
|
wechat_cli/worker.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Authenticated local Gadget RPC transport. No UI automation; native chat reads and text sending."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import frida
|
|
12
|
+
|
|
13
|
+
from .setup import check_payload
|
|
14
|
+
|
|
15
|
+
package = pathlib.Path(__file__).resolve().parent / "assets"
|
|
16
|
+
app = pathlib.Path(os.environ["WECHAT_NATIVE_APP"]).resolve()
|
|
17
|
+
data_dir = pathlib.Path(os.environ["WECHAT_DATA_DIR"])
|
|
18
|
+
config = json.loads((app / "Contents/Frameworks/FridaGadget.config").read_text())
|
|
19
|
+
|
|
20
|
+
profile = check_payload(app)
|
|
21
|
+
source = (package / "startup-notice.js").read_text() + "\n"
|
|
22
|
+
source += "globalThis.WECHAT_NATIVE_PROFILE=" + json.dumps(profile) + ";\n"
|
|
23
|
+
support = os.environ["WECHAT_NATIVE_SUPPORT"]
|
|
24
|
+
if (
|
|
25
|
+
not support.startswith("native-reader-")
|
|
26
|
+
or not support.endswith(".dylib")
|
|
27
|
+
or pathlib.Path(support).name != support
|
|
28
|
+
):
|
|
29
|
+
raise RuntimeError("invalid_native_runtime")
|
|
30
|
+
source += "globalThis.WECHAT_NATIVE_SUPPORT=" + json.dumps(str(app / "Contents/Frameworks" / support)) + ";\n"
|
|
31
|
+
source += (package / "native-agent.js").read_text()
|
|
32
|
+
endpoint = "127.0.0.1:" + str(config["interaction"]["port"])
|
|
33
|
+
session = device = script = child = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def emit(value):
|
|
37
|
+
print(json.dumps(value), flush=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
digest = hashlib.sha256()
|
|
42
|
+
with (app / "Contents/Resources/wechat.dylib").open("rb") as binary:
|
|
43
|
+
for block in iter(lambda: binary.read(1048576), b""):
|
|
44
|
+
digest.update(block)
|
|
45
|
+
if digest.hexdigest() != profile["payloadSha256"]:
|
|
46
|
+
raise RuntimeError("native_payload_mismatch")
|
|
47
|
+
device = frida.get_device_manager().add_remote_device(endpoint, token=config["interaction"]["token"])
|
|
48
|
+
try:
|
|
49
|
+
session = device.attach("Gadget")
|
|
50
|
+
except frida.ServerNotRunningError:
|
|
51
|
+
snapshot = subprocess.run(
|
|
52
|
+
["/bin/ps", "-axo", "pid=,comm="], check=True, capture_output=True, text=True, timeout=5
|
|
53
|
+
)
|
|
54
|
+
if any(
|
|
55
|
+
line.strip().split(None, 1)[-1] == "/Applications/WeChat.app/Contents/MacOS/WeChat"
|
|
56
|
+
for line in snapshot.stdout.splitlines()
|
|
57
|
+
):
|
|
58
|
+
raise RuntimeError("stock_app_running")
|
|
59
|
+
child = subprocess.Popen(
|
|
60
|
+
[str(app / "Contents/MacOS/WeChat")],
|
|
61
|
+
stdin=subprocess.DEVNULL,
|
|
62
|
+
stdout=subprocess.DEVNULL,
|
|
63
|
+
stderr=subprocess.DEVNULL,
|
|
64
|
+
start_new_session=True,
|
|
65
|
+
)
|
|
66
|
+
for attempt in range(250):
|
|
67
|
+
if child.poll() is not None:
|
|
68
|
+
raise RuntimeError("native_copy_exited")
|
|
69
|
+
try:
|
|
70
|
+
session = device.attach("Gadget")
|
|
71
|
+
break
|
|
72
|
+
except (frida.TransportError, frida.ServerNotRunningError, frida.TimedOutError):
|
|
73
|
+
time.sleep(0.2)
|
|
74
|
+
else:
|
|
75
|
+
raise RuntimeError("native_bridge_timeout")
|
|
76
|
+
script = session.create_script(source)
|
|
77
|
+
script.load()
|
|
78
|
+
identity = script.exports_sync.startup_status()
|
|
79
|
+
if pathlib.Path(identity["executable"]).resolve() != (app / "Contents/MacOS/WeChat").resolve():
|
|
80
|
+
raise RuntimeError("native_process_identity_mismatch")
|
|
81
|
+
# Resume a Gadget-waiting app even after an earlier gateway startup timed out.
|
|
82
|
+
if child is not None or not identity.get("applicationRunning"):
|
|
83
|
+
try:
|
|
84
|
+
device.resume(identity["pid"])
|
|
85
|
+
except frida.InvalidOperationError:
|
|
86
|
+
pass
|
|
87
|
+
emit({"event": "ready"})
|
|
88
|
+
for line in sys.stdin:
|
|
89
|
+
request = json.loads(line)
|
|
90
|
+
try:
|
|
91
|
+
if request["method"] == "status":
|
|
92
|
+
result = script.exports_sync.status()
|
|
93
|
+
elif request["method"] == "probeTextProcessor":
|
|
94
|
+
result = script.exports_sync.probe_text_processor()
|
|
95
|
+
elif request["method"] == "prepareText":
|
|
96
|
+
result = script.exports_sync.prepare_text(
|
|
97
|
+
request["params"]["chatId"], request["params"]["text"]
|
|
98
|
+
)
|
|
99
|
+
elif request["method"] == "sendText":
|
|
100
|
+
result = script.exports_sync.send_text(request["params"]["chatId"], request["params"]["text"])
|
|
101
|
+
elif request["method"] == "selfId":
|
|
102
|
+
result = script.exports_sync.self_id()
|
|
103
|
+
elif request["method"] == "importControl":
|
|
104
|
+
result = script.exports_sync.import_control(request["params"]["action"])
|
|
105
|
+
elif request["method"] == "migrationStatus":
|
|
106
|
+
result = script.exports_sync.migration_status()
|
|
107
|
+
elif request["method"] == "microphoneStatus":
|
|
108
|
+
result = script.exports_sync.microphone_status()
|
|
109
|
+
elif request["method"] == "armRecording":
|
|
110
|
+
result = script.exports_sync.arm_recording(request["params"]["chatId"])
|
|
111
|
+
elif request["method"] == "recordingResult":
|
|
112
|
+
result = script.exports_sync.recording_result()
|
|
113
|
+
elif request["method"] == "discardRecording":
|
|
114
|
+
result = script.exports_sync.discard_recording()
|
|
115
|
+
elif request["method"] in ["sendVoice", "prepareVoice"]:
|
|
116
|
+
result = script.exports_sync.send_voice(
|
|
117
|
+
request["params"]["chatId"],
|
|
118
|
+
request["params"]["dataBase64"],
|
|
119
|
+
request["params"]["durationMs"],
|
|
120
|
+
request["method"] == "prepareVoice",
|
|
121
|
+
)
|
|
122
|
+
elif request["method"] == "attachmentData":
|
|
123
|
+
result = script.exports_sync.attachment_data(
|
|
124
|
+
request["params"]["chatId"], request["params"]["id"]
|
|
125
|
+
)
|
|
126
|
+
elif request["method"] == "voiceData":
|
|
127
|
+
result = script.exports_sync.voice_data(request["params"]["chatId"], request["params"]["id"])
|
|
128
|
+
elif request["method"] == "transcribeVoice":
|
|
129
|
+
result = script.exports_sync.transcribe_voice(
|
|
130
|
+
request["params"]["chatId"], request["params"]["id"]
|
|
131
|
+
)
|
|
132
|
+
elif request["method"] == "extractImageText":
|
|
133
|
+
result = script.exports_sync.extract_image_text(request["params"]["dataBase64"])
|
|
134
|
+
elif request["method"] == "voiceTranscriptionResult":
|
|
135
|
+
result = script.exports_sync.voice_transcription_result(
|
|
136
|
+
request["params"]["chatId"], request["params"]["id"]
|
|
137
|
+
)
|
|
138
|
+
elif request["method"] == "cachedMedia":
|
|
139
|
+
result = script.exports_sync.cached_media(request["params"]["hash"])
|
|
140
|
+
elif request["method"] == "listChats":
|
|
141
|
+
result = script.exports_sync.list_chats()
|
|
142
|
+
elif request["method"] in ["listMessages", "rawMessages"]:
|
|
143
|
+
result = script.exports_sync.list_messages(
|
|
144
|
+
request["params"]["chatId"], request["params"]["limit"]
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
raise RuntimeError("native_method_unimplemented")
|
|
148
|
+
emit({"id": request["id"], "result": result})
|
|
149
|
+
except Exception as error:
|
|
150
|
+
# Private diagnostic for native call failures. Never sent to callers
|
|
151
|
+
# and never logged with chat/secret values.
|
|
152
|
+
if isinstance(error, frida.core.RPCException):
|
|
153
|
+
description = str(error).split("\n")[0]
|
|
154
|
+
if request["method"] == "prepareVoice" or description.startswith(
|
|
155
|
+
(
|
|
156
|
+
"Error: access violation",
|
|
157
|
+
"Error: invalid address",
|
|
158
|
+
"TypeError:",
|
|
159
|
+
"Error: native_",
|
|
160
|
+
"Error: recursive_mutex",
|
|
161
|
+
)
|
|
162
|
+
):
|
|
163
|
+
diagnostic = data_dir / "native-rpc-error.json"
|
|
164
|
+
diagnostic.write_text(
|
|
165
|
+
json.dumps({"method": request["method"], "description": description}) + "\n"
|
|
166
|
+
)
|
|
167
|
+
diagnostic.chmod(0o600)
|
|
168
|
+
code = "native_read_failed"
|
|
169
|
+
if isinstance(error, frida.core.RPCException):
|
|
170
|
+
for known in [
|
|
171
|
+
"native_feature_unavailable",
|
|
172
|
+
"native_invalid_media_id",
|
|
173
|
+
"native_migration_busy",
|
|
174
|
+
"native_migration_failed",
|
|
175
|
+
"native_recording_unavailable",
|
|
176
|
+
"native_recording_invalid",
|
|
177
|
+
"native_recording_recipient_changed",
|
|
178
|
+
"native_send_outcome_unknown",
|
|
179
|
+
"native_voice_layout_unverified",
|
|
180
|
+
"invalid_voice",
|
|
181
|
+
"native_voice_prepare_failed",
|
|
182
|
+
"native_ocr_not_ready",
|
|
183
|
+
"native_ocr_failed",
|
|
184
|
+
"native_invalid_ocr_image",
|
|
185
|
+
"native_ocr_layout_unverified",
|
|
186
|
+
"native_message_not_found",
|
|
187
|
+
"invalid_media_type",
|
|
188
|
+
"native_transcription_failed",
|
|
189
|
+
"native_conversations_not_observed",
|
|
190
|
+
"native_account_not_ready",
|
|
191
|
+
"native_signature_mismatch",
|
|
192
|
+
"native_module_missing",
|
|
193
|
+
"native_chat_not_found",
|
|
194
|
+
"native_message_layout_unverified",
|
|
195
|
+
"native_services_not_ready",
|
|
196
|
+
"native_message_service_not_ready",
|
|
197
|
+
"native_task_context_unavailable",
|
|
198
|
+
"native_send_failed",
|
|
199
|
+
"native_invalid_recipient",
|
|
200
|
+
"invalid_chat_id",
|
|
201
|
+
"invalid_text",
|
|
202
|
+
"native_bridge_busy",
|
|
203
|
+
"native_session_service_not_ready",
|
|
204
|
+
"native_session_layout_unverified",
|
|
205
|
+
]:
|
|
206
|
+
if known in str(error):
|
|
207
|
+
code = known
|
|
208
|
+
break
|
|
209
|
+
emit({"id": request["id"], "error": code})
|
|
210
|
+
except Exception as error:
|
|
211
|
+
code = str(error) if isinstance(error, RuntimeError) else "native_bridge_unavailable"
|
|
212
|
+
emit({"event": "failure", "error": code})
|
|
213
|
+
finally:
|
|
214
|
+
if script is not None:
|
|
215
|
+
try:
|
|
216
|
+
script.unload()
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
if session is not None:
|
|
220
|
+
try:
|
|
221
|
+
session.detach()
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
if device is not None:
|
|
225
|
+
frida.get_device_manager().remove_remote_device(endpoint)
|
|
226
|
+
# Keep the native app running for its normal login/session lifecycle.
|
|
227
|
+
# This transport never logs out or terminates the user's app on API close.
|