304 lines
15 KiB
Python
304 lines
15 KiB
Python
"""版本锁定的原生桥接,只开放当前用户的文件传输助手。"""
|
||
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)
|