Files
xieyi-weixin-/wxwork_protocol_probe.py
2026-09-29 15:51:28 +08:00

472 lines
24 KiB
Python
Raw Permalink 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.
"""Read-only inspection of the installed WeCom client; never loads client DLLs."""
from __future__ import annotations
import argparse
from contextlib import contextmanager
import hashlib
import json
import mmap
import os
import platform
from pathlib import Path
import re
import struct
import sys
import tempfile
import time
MARKERS = (
b"SendTextMessage", b"sendTextMessage", b"SendText", b"sendText",
b"SendMessage", b"sendMessage", b"SendMsg", b"sendMsg",
b"SendMsgReq", b"SendMsgRsp", b"SendMsgResp",
b"MessageService", b"MsgService", b"SendMessageService",
b"WM_COPYDATA", b"CreateNamedPipe", b"send_chat_message",
b"WeClawMcpToolManager", b"SendTextToClawConversation",
)
TARGET_MODULES = ("WXWorkCommand.exe", "ilink2.dll", "native.dll",
"tencent.mm.pbc.dll", "ilink_live_lite_wrapper.dll")
MAX_EXPORTS = 20000
def pe_header(path):
with open(path, "rb") as handle:
if handle.read(2) != b"MZ":
raise ValueError("不是 Windows PE 文件")
handle.seek(0x3c)
raw = handle.read(4)
if len(raw) != 4:
raise ValueError("PE 头不完整")
offset = struct.unpack("<I", raw)[0]
if not 64 <= offset <= 16 * 1024 * 1024:
raise ValueError("PE 头偏移无效")
handle.seek(offset)
raw = handle.read(24)
if len(raw) != 24 or raw[:4] != b"PE\x00\x00":
raise ValueError("PE 签名无效")
machine, sections, stamp, _, _, _, characteristics = struct.unpack("<HHIIIHH", raw[4:])
return {
"architecture": {0x14c: "x86", 0x8664: "x64", 0xaa64: "arm64"}.get(machine, "unknown"),
"machine": hex(machine), "sections": sections, "buildTimestamp": stamp,
"isDll": bool(characteristics & 0x2000),
}
def file_version(path):
if os.name != "nt":
return ""
import win32api
info = win32api.GetFileVersionInfo(str(path), "\\")
ms, ls = info["FileVersionMS"], info["FileVersionLS"]
return ".".join(str(value) for value in (ms >> 16, ms & 65535, ls >> 16, ls & 65535))
def running_clients():
"""Read process image paths with PROCESS_QUERY_LIMITED_INFORMATION only."""
if os.name != "nt":
return []
import ctypes
from ctypes import wintypes
from wxwork_key import find_wxwork_pids
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel.OpenProcess.restype = wintypes.HANDLE
kernel.QueryFullProcessImageNameW.argtypes = [
wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)]
kernel.QueryFullProcessImageNameW.restype = wintypes.BOOL
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
kernel.CloseHandle.restype = wintypes.BOOL
result = []
binary_metadata = {}
for pid in find_wxwork_pids(include_web=False, include_extra=False):
process = kernel.OpenProcess(0x1000, False, pid)
if not process:
result.append({"pid": pid, "path": "", "error": "进程信息不可读取"})
continue
try:
buffer = ctypes.create_unicode_buffer(32768)
length = wintypes.DWORD(len(buffer))
if not kernel.QueryFullProcessImageNameW(process, 0, buffer, ctypes.byref(length)):
result.append({"pid": pid, "path": "", "error": "进程路径不可读取"})
continue
path = buffer.value
entry = {"pid": pid, "path": path}
try:
identity = os.path.normcase(os.path.abspath(path))
metadata = binary_metadata.get(identity)
if metadata is None:
metadata = {**pe_header(path), "version": file_version(path), "size": Path(path).stat().st_size}
binary_metadata[identity] = metadata
entry.update(metadata)
except Exception as exc:
entry["error"] = type(exc).__name__
result.append(entry)
finally:
kernel.CloseHandle(process)
return result
def inspect_binary(path, *, deep=False):
path = Path(path)
info = {"name": path.name, "path": str(path), "size": path.stat().st_size,
**pe_header(path)}
try:
info["version"] = file_version(path)
except Exception:
info["version"] = ""
if not deep:
return info
with path.open("rb") as handle:
digest = hashlib.sha256()
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
info["sha256"] = digest.hexdigest()
handle.seek(0)
with mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) as data:
found = []
for marker in MARKERS:
for encoding, needle in (("ascii", marker), ("utf16le", marker.decode("ascii").encode("utf-16le"))):
position, offsets = 0, []
while len(offsets) < 12:
position = data.find(needle, position)
if position < 0:
break
offsets.append(position)
position += len(needle)
if offsets:
found.append({"symbol": marker.decode("ascii"), "encoding": encoding, "fileOffsets": offsets})
info["markers"] = found
try:
import pefile
with pefile.PE(str(path), fast_load=True) as pe:
pe.parse_data_directories(directories=[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_EXPORT"],
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]])
exports = list(getattr(getattr(pe, "DIRECTORY_ENTRY_EXPORT", None), "symbols", []))
info["exportCount"] = len(exports)
relevant = re.compile(r"send|recv|receive|message|task|init|connect|create|ipc|command", re.I)
info["relevantExports"] = [
{"name": item.name.decode("ascii", "replace")[:240], "rva": item.address}
for item in exports[:MAX_EXPORTS]
if item.name and relevant.search(item.name.decode("ascii", "replace"))
][:80]
info["imports"] = [item.dll.decode("ascii", "replace")
for item in getattr(pe, "DIRECTORY_ENTRY_IMPORT", [])]
except ImportError:
info["exportInspection"] = "未安装 pefile,仅完成 PE 头与标记检查"
except Exception as exc:
info["exportInspection"] = "导出表分析失败:" + type(exc).__name__
return info
def bundled_agent_info(executable, version):
"""Inspect packaged SDK files, without executing the agent or importing its code."""
root = Path(executable).parent / version / "WeComAgent"
sdk = root / "python" / "3.10" / "site-packages" / "wecomagent"
info = {
"installed": (root / "wecom-agent.exe").is_file(),
"pythonSdkInstalled": (sdk / "_rpc.py").is_file(),
"rpcMethod": "", "requiresRuntimeAuthorization": False,
"externalChatSendVerified": False,
}
if info["pythonSdkInstalled"]:
try:
# Bound reads; no credential/configuration files are accessed.
with (sdk / "_rpc.py").open(encoding="utf-8") as handle:
source = handle.read(128 * 1024)
info["requiresRuntimeAuthorization"] = all(
marker in source for marker in
("WECOMAGENT_RPC_HOST", "WECOMAGENT_RPC_PORT", "WECOMAGENT_RPC_TOKEN"))
if 'wecom.exec' in source:
info["rpcMethod"] = "wecom.exec"
except (OSError, UnicodeError):
info["inspectionError"] = "内置 SDK 文件不可读"
return info
@contextmanager
def _probe_phase(timings, name):
started=time.perf_counter()
try:yield
finally:timings[name]=round((time.perf_counter()-started)*1000,3)
def probe_installed_client(*, deep=False, initialize_database=False):
"""Run fresh identity checks; static build hashes live for this call only."""
from wecom_native_sender import readonly_build_validation_scope
started=time.perf_counter()
timings={}
with readonly_build_validation_scope() as builds:
result=_probe_installed_client(deep=deep,initialize_database=initialize_database,timings=timings)
timings['total']=round((time.perf_counter()-started)*1000,3)
result['timingsMs']=timings
result['buildValidation']={key:builds[key] for key in ('hashedFiles','reusedFiles','bytesHashed')}
return result
def _probe_installed_client(*, deep=False, initialize_database=False, timings):
"""File/process presence is deliberately not treated as a login or send proof."""
from wecom_native_builds import (
KNOWN_VERSION_FAMILIES, build_for_hash, build_for_version,
classify_version, client_support_text, sha256_file,
)
result = {
"schemaVersion": 3, "checkedAt": time.time(), "readOnly": not initialize_database,
"status": "not_checked", "label": "待检测", "message": "",
"canSend": False, "loginVerified": False, "adapterInstalled": False,
"sendVerified": False, "receiptVerified": False,
"clients": [], "binaries": [], "bundledAgents": [], "errors": [],
"versionDetection": [], "clientVersionFamily": "",
"buildFingerprints": [], "buildMatchMode": "",
"needsAdapterUpdate": False, "autoAdapted": False, "adaptationErrors": [],
"supportedClient": client_support_text(),
"host": {"os": platform.system(), "release": platform.release(),
"machine": platform.machine(), "processBits": struct.calcsize("P") * 8},
"databaseReady": False, "verificationScope": "adapter_version",
"targetSendVerified": False, "localSendVerified": False, "needsSendValidation": False,
}
if os.name != "nt":
result.update(status="unsupported_platform", label="不支持当前平台",
message="原账号客户端协议模式需要 Windows 企业微信。")
return result
try:
with _probe_phase(timings,"processes"):
result["clients"] = running_clients()
except Exception as exc:
result.update(status="probe_failed", label="检测失败", message="无法读取企业微信进程信息。")
result["errors"].append(type(exc).__name__)
return result
if not result["clients"]:
from wecom_environment import installed_executables
for path in installed_executables():
try:
result["binaries"].append(inspect_binary(path))
except (OSError, ValueError) as exc:
result["errors"].append(type(exc).__name__)
result.update(status="client_not_running", label="客户端未运行",
clientInstallPath=" / ".join(item["path"] for item in result["binaries"]),
message="请先启动并登录本机企业微信,再检测。安装路径会自动识别,无需安装在 C 盘。")
return result
paths = sorted({item.get("path") for item in result["clients"] if item.get("path")})
result["clientInstallPath"] = " / ".join(paths)
seen = set()
for path in paths:
version = next((item.get("version") for item in result["clients"] if item.get("path") == path), "")
if version:
result["bundledAgents"].append(bundled_agent_info(path, version))
targets = [Path(path)]
if deep and version:
folder = Path(path).parent / version
targets.extend(folder / name for name in TARGET_MODULES if (folder / name).is_file())
for target in targets:
if str(target) in seen:
continue
seen.add(str(target))
try:
metadata=next((item for item in result['clients'] if item.get('path')==str(target)),{})
if not deep and all(key in metadata for key in ('size','architecture','machine','sections','buildTimestamp','isDll','version')):
result['binaries'].append({'name':target.name,'path':str(target),**{
key:metadata[key] for key in ('size','architecture','machine','sections','buildTimestamp','isDll','version')}})
else:
result["binaries"].append(inspect_binary(target, deep=deep))
except Exception as exc:
result["errors"].append(f"{target.name}: {type(exc).__name__}")
versions = sorted({item.get("version") for item in result["clients"] if item.get("version")})
detections = [classify_version(value) for value in versions]
families = sorted({item["family"] for item in detections if item.get("family")})
result["versionDetection"] = detections
result["clientVersionFamily"] = " / ".join(families)
result["clientVersion"] = " / ".join(versions)
result["architecture"] = " / ".join(sorted({
item.get("architecture") for item in result["clients"] if item.get("architecture")}))
# SDK metadata is optional. This native adapter does not depend on a WeClaw runtime.
if deep:
from wecom_sdk_client import probe_sdk_runtime
try:
result["sdkRuntime"] = probe_sdk_runtime()
except Exception as exc:
result["sdkRuntime"] = {"status": "probe_failed", "rpcVerified": False, "error": type(exc).__name__}
from wecom_native_protocol import load_native_trial
trial = load_native_trial(client_versions=versions)
if trial:
result["nativeTrial"] = trial
if not paths:
result.update(status="probe_failed", label="进程信息不可读",
message="检测到企业微信进程,但无法读取程序路径与版本。请确认本程序与企业微信在同一 Windows 用户会话内、以相同权限运行。")
return result
from wecom_native_sender import discover, NativeUnavailable
version_matches = [item for item in result["clients"] if build_for_version(item.get("version")) is not None]
# The version resource is only a hint. Hash an unregistered image once so
# a localized/malformed version string cannot prevent an exact known build
# from being selected. A matching hash is still checked again by
# NativeClient against the live process before any write is possible.
hash_matches = []
fingerprint_paths = set()
if not version_matches:
for item in result["clients"]:
path = item.get("path")
if not path:
continue
identity = os.path.normcase(os.path.abspath(path))
if identity in fingerprint_paths:
continue
fingerprint_paths.add(identity)
try:
digest = sha256_file(path)
except (OSError, ValueError, TypeError):
continue
match = build_for_hash(digest)
if match is None and item.get("architecture") == "x86":
from wecom_native_adaptation import resolve_build, AdaptationUnavailable
try:
match = resolve_build(path, digest)
except (AdaptationUnavailable, OSError, ValueError) as exc:
result["adaptationErrors"].append(str(exc))
result["buildFingerprints"].append({
"path": str(path), "sha256": digest,
"adapterId": match.adapter_id if match is not None else "",
})
if match is not None:
hash_matches.append(item)
if hash_matches:
version_matches = hash_matches
result["buildMatchMode"] = "sha256"
if not version_matches:
known_families = sorted({
detection.get("family") for detection in detections
if detection.get("state") == "known_family_new_patch"
and detection.get("family") in KNOWN_VERSION_FAMILIES
})
if known_families:
family_text = "、".join(known_families)
result.update(
status="known_client_family_requires_adapter",
label="新版接口自动适配未通过",
needsAdapterUpdate=True,
message=(
f"已自动识别本机企业微信 {result['clientVersion'] or '未知版本'} "
f"属于 {family_text} 系列,已尝试自动定位接口,但接口结构未通过完整校验。"
"请导出此报告以更新适配规则。"
+ ("检测详情:" + ";".join(result["adaptationErrors"]) + "。" if result["adaptationErrors"] else "")
),
)
else:
result.update(status="unsupported_client_version", label="企业微信版本尚未适配",
needsAdapterUpdate=True,
message=f"本机检测到 {result['clientVersion'] or '未知版本'};自动接口适配未通过。请导出报告更新适配规则。" + ";".join(result["adaptationErrors"]))
return result
if not result.get("buildMatchMode"):
result["buildMatchMode"] = "version"
if not any(item.get("architecture") == "x86" for item in version_matches):
result.update(status="unsupported_client_architecture", label="企业微信架构尚未适配",
message=f"本机企业微信为 {result['architecture'] or '未知架构'};协议适配器目前要求 x86 企业微信(可运行在 64 位 Windows 上)。")
return result
try:
with _probe_phase(timings,"clientValidation"):
live = discover(result["clients"])
except (NativeUnavailable, OSError, ValueError) as exc:
error = str(exc)
status, label = "native_unavailable", "原账号协议未就绪"
if any(term in error for term in ("权限", "不可读取", "无法访问")):
status, label = "client_access_denied", "企业微信进程无法读取"
error += "。请确认两者在同一 Windows 用户会话内、以相同权限运行。"
elif any(term in error for term in ("文件未适配", "入口校验", "接口不匹配", "自动适配未通过", "接口与文件不一致")):
status, label = "client_build_mismatch", "企业微信构建尚未适配"
elif "多个企业微信" in error:
status, label = "multiple_clients", "存在多个已登录主客户端"
elif any(term in error for term in ("未登录", "尚未就绪", "状态已变化")):
status, label = "client_not_ready", "等待企业微信登录完成"
result.update(status=status, label=label, message=error, nativeAdapterError=error,
needsAdapterUpdate=status == "client_build_mismatch")
return result
profile = build_for_hash(live.get("clientSha256"))
if profile is None:
result.update(status="client_build_mismatch", label="客户端构建未通过核对",
message="无法核对检测到的客户端构建,请重新检测。", needsAdapterUpdate=True)
return result
result.update(clientVersion=profile.version, autoAdapted=profile.automatic,
buildMatchMode="structural" if profile.automatic else result["buildMatchMode"])
if profile.automatic:
result["buildFingerprints"] = [{"sha256": profile.sha256, "adapterId": profile.adapter_id}]
from wecom_native_evidence import local_send_receipt
local_receipt = local_send_receipt(profile, live["accountId"]) if not profile.verified else None
verified = bool(profile.verified or local_receipt)
result.update(nativeAdapter=profile.adapter_id, nativeAccount=live["accountId"], clientSha256=profile.sha256,
localSendVerified=bool(local_receipt), verificationScope="current_account" if local_receipt else "adapter_version",
loginVerified=True, adapterInstalled=True, sendVerified=verified, receiptVerified=verified)
from wecom_environment import protocol_database_status
with _probe_phase(timings,"databaseValidation"):
database = protocol_database_status(live["accountId"], initialize=initialize_database)
result.update(database=database, databaseReady=database["ready"])
if not database["ready"]:
result.update(status=database["status"], label=database["label"], message=database["message"])
return result
# Recheck identity after a possibly slow first-install initialization.
try:
with _probe_phase(timings,"identityRecheck"):
current = discover()
except (NativeUnavailable, OSError, ValueError) as exc:
result.update(status="client_not_ready", label="企业微信状态已变化", loginVerified=False,
message="初始化后无法核对当前登录账号:" + str(exc))
return result
if any(current.get(key) != live.get(key) for key in ("pid", "accountId", "clientVersion", "clientSha256")):
result.update(status="client_not_ready", label="登录账号已变化", loginVerified=False,
message="初始化期间企业微信进程或登录账号发生变化,请重新检测。")
return result
if not verified:
result.update(status="native_validation_required", label="自动适配完成,待首次发送验证" if profile.automatic else "需要首次发送验证", needsSendValidation=True,
message=f"已自动匹配 {profile.version} 的接口并核对当前账号和消息库。点击本页首次发送验证,取得真实服务器回执后自动启用此账号的协议回复。")
return result
result.update(status="native_ready", label="原账号协议环境已就绪", canSend=True, checkedAt=time.time(),
message=f"已自动选择 {profile.version} 对应接口,当前账号、客户端构建和消息库均已核对,可启动协议自动回复。" + ("本机当前账号已有首次发送及回执实测记录。" if local_receipt else "适配版本已有发送及回执实测记录。") + "本次环境检测未发送消息。")
return result
def export_probe_report(report, path):
"""Export only diagnostics; no account directory, chat text, or login keys."""
target = Path(path)
content = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)
# Atomic export leaves an existing report intact if writing fails.
temporary = None
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=target.resolve().parent,
prefix=".protocol-report-", suffix=".tmp", delete=False,
) as handle:
temporary = Path(handle.name)
handle.write(content + "\n")
os.replace(temporary, target)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return target
def run_packaged_diagnostics(argv):
"""Run before GUI/login initialization; metadata only, never reads chat content."""
parser = argparse.ArgumentParser(description="导出企业微信协议诊断(不启动监听)")
parser.add_argument("--protocol-diagnostics", type=Path, required=True, metavar="REPORT.json")
args = parser.parse_args(argv)
report = probe_installed_client(deep=True)
export_probe_report(report, args.protocol_diagnostics)
return 1 if report["status"] == "probe_failed" else 0
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--deep", action="store_true", help="静态分析已安装程序的导出表和符号标记")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
report = probe_installed_client(deep=args.deep)
if args.output:
export_probe_report(report, args.output)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1 if report["status"] == "probe_failed" else 0
if __name__ == "__main__":
raise SystemExit(main())