first commit

This commit is contained in:
Prometheus-first
2026-10-15 18:27:46 +08:00
commit 68eb19ea35
88 changed files with 10319 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
"""版本锁定的原生桥接,只开放当前用户的文件传输助手。"""
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)