Files
helper/native/run_selftest.py
T
2026-10-15 18:27:46 +08:00

304 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""版本锁定的原生桥接,只开放当前用户的文件传输助手。"""
from __future__ import annotations
import ctypes as c
from ctypes import wintypes as w
import json
import argparse
import hashlib
from pathlib import Path
import sys
import time
import uuid
import pefile
ROOT = Path(__file__).resolve().parent.parent
DLL = ROOT / "native/build/selftest_bridge.dll"
TEXT_DLL = ROOT / "native/build_text/text_bridge.dll"
K = c.WinDLL("kernel32", use_last_error=True)
PTR = c.c_void_p
K.OpenProcess.argtypes = [w.DWORD, w.BOOL, w.DWORD]; K.OpenProcess.restype = w.HANDLE
K.CloseHandle.argtypes = [w.HANDLE]; K.CloseHandle.restype = w.BOOL
K.QueryFullProcessImageNameW.argtypes = [w.HANDLE, w.DWORD, w.LPWSTR, c.POINTER(w.DWORD)]
K.CreateToolhelp32Snapshot.argtypes = [w.DWORD, w.DWORD]; K.CreateToolhelp32Snapshot.restype = w.HANDLE
K.GetModuleHandleW.argtypes = [w.LPCWSTR]; K.GetModuleHandleW.restype = w.HMODULE
K.GetProcAddress.argtypes = [w.HMODULE, c.c_char_p]; K.GetProcAddress.restype = PTR
K.GetModuleHandleExW.argtypes = [w.DWORD, PTR, c.POINTER(w.HMODULE)]
K.GetModuleFileNameW.argtypes = [w.HMODULE, w.LPWSTR, w.DWORD]
K.VirtualAllocEx.argtypes = [w.HANDLE, PTR, c.c_size_t, w.DWORD, w.DWORD]; K.VirtualAllocEx.restype = PTR
K.VirtualFreeEx.argtypes = [w.HANDLE, PTR, c.c_size_t, w.DWORD]
K.WriteProcessMemory.argtypes = [w.HANDLE, PTR, PTR, c.c_size_t, c.POINTER(c.c_size_t)]
K.ReadProcessMemory.argtypes = [w.HANDLE, PTR, PTR, c.c_size_t, c.POINTER(c.c_size_t)]
K.CreateRemoteThread.argtypes = [w.HANDLE, PTR, c.c_size_t, PTR, PTR, w.DWORD, c.POINTER(w.DWORD)]
K.CreateRemoteThread.restype = w.HANDLE
K.WaitForSingleObject.argtypes = [w.HANDLE, w.DWORD]; K.WaitForSingleObject.restype = w.DWORD
K.GetExitCodeThread.argtypes = [w.HANDLE, c.POINTER(w.DWORD)]
class Module(c.Structure):
_fields_ = [("dwSize", w.DWORD), ("th32ModuleID", w.DWORD), ("th32ProcessID", w.DWORD),
("GlblcntUsage", w.DWORD), ("ProccntUsage", w.DWORD), ("modBaseAddr", PTR),
("modBaseSize", w.DWORD), ("hModule", w.HMODULE),
("szModule", w.WCHAR * 256), ("szExePath", w.WCHAR * 260)]
class Config(c.Structure):
_fields_ = [("size", w.DWORD), ("pid", w.DWORD), ("manager", c.c_uint64),
("log_path", w.WCHAR * 260), ("text", c.c_char * 1024)]
class TextConfig(Config):
_fields_ = [("target", c.c_char * 128)]
class AtTextConfig(TextConfig):
_fields_ = [("members", c.c_char * 1024)]
assert c.sizeof(Config) == 1560 and c.sizeof(TextConfig) == 1688 and c.sizeof(AtTextConfig) == 2712
class ProcessEntry(c.Structure):
_fields_ = [("dwSize", w.DWORD), ("cntUsage", w.DWORD), ("pid", w.DWORD),
("heap", c.c_size_t), ("module", w.DWORD), ("threads", w.DWORD),
("parent", w.DWORD), ("priority", w.LONG), ("flags", w.DWORD),
("exe", w.WCHAR * 260)]
K.Module32FirstW.argtypes = [w.HANDLE, c.POINTER(Module)]
K.Module32NextW.argtypes = [w.HANDLE, c.POINTER(Module)]
K.Process32FirstW.argtypes = [w.HANDLE, c.POINTER(ProcessEntry)]
K.Process32NextW.argtypes = [w.HANDLE, c.POINTER(ProcessEntry)]
def check(result, label):
if not result:
raise c.WinError(c.get_last_error(), f"{label} 失败")
return result
def modules(pid):
snapshot = K.CreateToolhelp32Snapshot(0x8 | 0x10, pid)
if snapshot == c.c_void_p(-1).value:
raise c.WinError(c.get_last_error())
result = {}
try:
entry = Module(); entry.dwSize = c.sizeof(entry)
check(K.Module32FirstW(snapshot, c.byref(entry)), "读取模块")
while True:
result[entry.szModule.lower()] = (int(entry.modBaseAddr), entry.szExePath)
if not K.Module32NextW(snapshot, c.byref(entry)): break
finally:
K.CloseHandle(snapshot)
return result
def remote_system_function(pid, name):
address = check(K.GetProcAddress(K.GetModuleHandleW("kernel32.dll"), name), "查找系统函数")
owner = w.HMODULE()
check(K.GetModuleHandleExW(0x4 | 0x2, address, c.byref(owner)), "读取函数所属模块")
path = c.create_unicode_buffer(32768)
check(K.GetModuleFileNameW(owner, path, len(path)), "读取系统模块名")
remote_base = modules(pid)[Path(path.value).name.lower()][0]
return remote_base + address - owner.value
def remote_call(process, address, parameter, timeout=30000):
thread = check(K.CreateRemoteThread(process, None, 0, address, parameter, 0, None), "创建自测线程")
try:
status = K.WaitForSingleObject(thread, timeout)
if status != 0:
raise TimeoutError(f"自测线程未完成,等待结果 {status};不要终止该线程")
result = w.DWORD()
check(K.GetExitCodeThread(thread, c.byref(result)), "读取自测结果")
return result.value
finally:
K.CloseHandle(thread)
def wechat_candidates():
snapshot = K.CreateToolhelp32Snapshot(2, 0)
if snapshot == c.c_void_p(-1).value: raise c.WinError(c.get_last_error())
candidates = []
try:
entry = ProcessEntry(); entry.dwSize = c.sizeof(entry)
check(K.Process32FirstW(snapshot, c.byref(entry)), "枚举微信进程")
while True:
if entry.exe.lower() == "weixin.exe":
try:
if "weixin.dll" in modules(entry.pid): candidates.append(entry.pid)
except OSError:
pass
if not K.Process32NextW(snapshot, c.byref(entry)): break
finally:
K.CloseHandle(snapshot)
return candidates
def find_wechat():
candidates = wechat_candidates()
if len(candidates) != 1:
raise RuntimeError(f"需要唯一的微信主进程,当前候选为 {candidates};可指定 --pid")
return candidates[0]
def run(mode, *, pid=None, text="WXNATIVE_TEST_3", keep_loaded=False, preserve_report=True,
bridge_path=None, target="filehelper", mentions=None):
dll = Path(bridge_path or DLL).resolve()
if dll not in (DLL.resolve(), TEXT_DLL.resolve()):
raise ValueError("不支持的桥接 DLL 路径")
generic = dll == TEXT_DLL.resolve()
if generic and mode not in ("--dispatch", "--listen", "--remove", "--probe-at"):
raise ValueError("通用桥接只支持文本发送、监听和卸载")
if not generic and target != "filehelper":
raise ValueError("旧自测桥接仅支持文件传输助手")
if generic:
from wxbot_framework.text import validate_target
validate_target(target)
if mentions is not None:
if not generic or mode != "--dispatch":
raise ValueError("艾特成员列表仅用于通用文本发送")
from wxbot_framework.text import validate_mentions
mentions = validate_mentions(target, mentions)
if mode not in ("--arm", "--dispatch", "--receive", "--listen", "--remove", "--probe-at"):
raise RuntimeError("不支持的原生桥接操作")
if mode == "--probe-at" and not generic:
raise ValueError("艾特探针仅用于通用桥接")
if keep_loaded and mode != "--dispatch":
raise ValueError("keep_loaded 只用于接收服务内的发送")
if c.sizeof(PTR) != 8: raise RuntimeError("需要 64 位 Python")
encoded = text.encode("utf-8")
if not encoded or len(encoded) > 1023 or b"\0" in encoded:
raise ValueError("文本应为 1–1023 个 UTF-8 字节,且不能包含 NUL")
pid = pid or find_wechat()
process = check(K.OpenProcess(0x43a, False, pid), "打开已验证的微信进程")
allocations = []
pending_thread = False
try:
path = c.create_unicode_buffer(32768); length = w.DWORD(len(path))
check(K.QueryFullProcessImageNameW(process, 0, path, c.byref(length)), "核对客户端路径")
client = Path(path.value)
if client.name.lower() != "weixin.exe": raise RuntimeError("目标不是微信")
version = pefile.PE(str(client)).VS_FIXEDFILEINFO[0]
if (version.FileVersionMS, version.FileVersionLS) != (0x40001, 0xf000d):
raise RuntimeError("客户端版本已改变")
loaded_modules = modules(pid)
core = loaded_modules.get("weixin.dll")
if not core: raise RuntimeError("微信核心模块尚未加载")
baseline = json.loads((ROOT / "native/compatibility.json").read_text(encoding="utf-8"))
if hashlib.sha256(Path(core[1]).read_bytes()).hexdigest() != baseline["sha256"]:
raise RuntimeError("微信核心 DLL 指纹不匹配,拒绝加载")
other = DLL if generic else TEXT_DLL
if mode != "--remove" and other.name.lower() in loaded_modules:
raise RuntimeError("另一版本的桥接仍在运行,请先在旧 main.py 中输入 quit")
pe = pefile.PE(str(dll))
name = b"WxArmMentionProbe" if mode == "--probe-at" else {
"--arm":b"WxArmSelfTest", "--dispatch":b"WxDispatchSelfTest", "--receive":b"WxArmReceiveTest",
"--listen":b"WxStartFileHelperReceiver", "--remove":b"WxRemoveSelfTest"}[mode]
if generic:
name = {"--dispatch": b"WxSendText", "--listen": b"WxStartTextReceiver",
"--remove": b"WxRemoveSelfTest", "--probe-at": b"WxArmMentionProbe"}[mode]
if mentions is not None:
name = b"WxSendAtText"
export = next(item.address for item in pe.DIRECTORY_ENTRY_EXPORT.symbols if item.name == name)
def allocate(data):
pointer = check(K.VirtualAllocEx(process, None, len(data), 0x3000, 0x04), "分配自测参数")
allocations.append(pointer)
buffer = c.create_string_buffer(data); written = c.c_size_t()
check(K.WriteProcessMemory(process, pointer, buffer, len(data), c.byref(written)), "写入自测参数")
if written.value != len(data): raise RuntimeError("参数写入不完整")
return pointer
if mode in ("--arm", "--dispatch", "--receive", "--listen", "--probe-at"):
existing = dll.name.lower() in loaded_modules
if existing and not keep_loaded: raise RuntimeError("桥接 DLL 已加载,请先清理")
if not existing:
if keep_loaded: raise RuntimeError("接收服务的桥接 DLL 已卸载")
dll_parameter = allocate((str(dll) + "\0").encode("utf-16-le"))
remote_call(process, remote_system_function(pid, b"LoadLibraryW"), dll_parameter)
loaded = modules(pid).get(dll.name.lower())
if not loaded or Path(loaded[1]).resolve() != dll: raise RuntimeError("桥接 DLL 未正确加载")
report_path = ROOT / f".runtime/native_requests/{uuid.uuid4().hex}.json"
report_path.parent.mkdir(parents=True, exist_ok=True)
if len(str(report_path)) >= 260: raise RuntimeError("报告路径超过原生接口长度")
config = AtTextConfig() if mentions is not None else (TextConfig() if generic else Config())
config.size, config.pid, config.manager = c.sizeof(config), pid, 0
config.log_path, config.text = str(report_path), encoded
if generic:
config.target = target.encode("ascii")
if mentions is not None:
config.members = ",".join(mentions).encode("ascii")
config_parameter = allocate(bytes(config))
exit_code = remote_call(process, loaded[0] + export, config_parameter)
if mode == "--remove" and exit_code == 0:
remote_call(process, remote_system_function(pid, b"FreeLibrary"), loaded[0])
# Failed initialization or a partially scheduled job must still pass
# the DLL's cleanup guard before FreeLibrary is allowed.
result = {"mode":mode,"pid":pid,"code":exit_code,"target":target,"report_path":str(report_path)}
if mode == "--remove":
result["dll_unloaded"] = dll.name.lower() not in modules(pid)
if mode == "--dispatch":
deadline = time.monotonic() + 15
while exit_code == 0 and not report_path.exists() and time.monotonic() < deadline:
time.sleep(0.05)
if report_path.exists():
# The completion closes the file before its callback lease is
# released. Cleanup below waits for that lease before unload.
for attempt in range(20):
try:
result["report"] = json.loads(report_path.read_text(encoding="utf-8")); break
except (json.JSONDecodeError, PermissionError): time.sleep(0.05)
result["report_path"] = str(report_path)
cleanup_name = b"WxWaitForTasks" if keep_loaded else b"WxRemoveSelfTest"
cleanup_export = next(item.address for item in pe.DIRECTORY_ENTRY_EXPORT.symbols if item.name == cleanup_name)
cleanup_code = remote_call(process, loaded[0] + cleanup_export, None)
result["cleanup_code"] = cleanup_code
if cleanup_code == 0 and not keep_loaded:
remote_call(process, remote_system_function(pid, b"FreeLibrary"), loaded[0])
result["dll_unloaded"] = dll.name.lower() not in modules(pid)
else:
result["dll_unloaded"] = False
result["success"] = (exit_code == 0 and result.get("report", {}).get("completion_success") is True
and result["report"].get("target") == target and result["report"].get("text") == text
and cleanup_code == 0 and (keep_loaded or result.get("dll_unloaded") is True))
if mentions is not None:
result["success"] = result["success"] and result["report"].get("at_members") == ",".join(mentions)
if not preserve_report and result["success"]:
# 长期运行只保留内存中的完成结果;删除本次已结束的临时报告。
try:
report_path.unlink(missing_ok=True)
result["report_retained"] = False
except OSError as error:
# 临时文件被占用不应把已经成功的发送变成失败。
result["report_retained"] = True
result["report_cleanup_error"] = str(error)
return result
except TimeoutError:
# A timed-out remote thread may still be using these buffers. Keep them
# allocated in the client until it exits instead of freeing live input.
pending_thread = True
raise
finally:
if not pending_thread:
for pointer in allocations: K.VirtualFreeEx(process, pointer, 0, 0x8000)
K.CloseHandle(process)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="通过微信原生任务队列给文件传输助手发送文本")
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument("--arm", action="store_true")
action.add_argument("--dispatch", action="store_true")
action.add_argument("--remove", action="store_true")
action.add_argument("--receive", action="store_true")
action.add_argument("--listen", action="store_true", help="启用文件传输助手接收队列,结束后需 --remove")
parser.add_argument("--pid", type=int)
parser.add_argument("--text", default="WXNATIVE_TEST_3")
options = parser.parse_args()
mode = "--arm" if options.arm else "--dispatch" if options.dispatch else "--receive" if options.receive else "--listen" if options.listen else "--remove"
outcome = run(mode, pid=options.pid, text=options.text)
print(json.dumps(outcome, ensure_ascii=False), flush=True)
sys.exit(0 if outcome.get("success", outcome["code"] == 0) else 1)