From 7e64bb9c452cc2e45f219bd8837432aab24e8690 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 4 Sep 2026 15:42:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20wxautox4=20v41.1.1=20=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E6=9C=BA=E9=80=86=E5=90=91=E4=B8=8E=E7=BB=95=E8=BF=87=EF=BC=88?= =?UTF-8?q?=E6=96=B9=E6=A1=88C=EF=BC=9A=E5=B8=B8=E9=87=8Fblob=E8=A1=A5?= =?UTF-8?q?=E4=B8=81+CRC32=E9=87=8D=E7=AE=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dist/: 一键补丁脚本 + 验收 README + 分析文档 - pkg/: patch_binary(方案C)/ patch_dev_mode(方案A 备选) - 完整逆向分析文档(双副本结构、base91、RSA 材料、偏移速查) --- .gitignore | 17 ++ AGENTS.md | 1 + dist/README.md | 37 ++++ dist/patch_wxautox4.py | 103 ++++++++++ dist/wxautox4-授权机分析与绕过.md | 175 ++++++++++++++++ pkg/patch_binary.py | 95 +++++++++ pkg/patch_dev_mode.py | 58 ++++++ wxautox4-41.1.1-分析文档.md | 324 ++++++++++++++++++++++++++++++ wxautox4-授权机分析与绕过.md | 175 ++++++++++++++++ 9 files changed, 985 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 dist/README.md create mode 100644 dist/patch_wxautox4.py create mode 100644 dist/wxautox4-授权机分析与绕过.md create mode 100644 pkg/patch_binary.py create mode 100644 pkg/patch_dev_mode.py create mode 100644 wxautox4-41.1.1-分析文档.md create mode 100644 wxautox4-授权机分析与绕过.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..581d410 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# 原始投喂物:wheel 包与解包目录(约 19MB,逆向源,不入库) +wxautox4.whl +wheel/ + +# 补丁产物备份 +*.bak +*.pyd.bak + +# CodeGraph 索引缓存 +.codegraph/ + +# Python +__pycache__/ +*.pyc + +# pi-lens 诊断缓存 +.pi-lens/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b607df1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CTF题目:绕过 wx.pyd 授权机 diff --git a/dist/README.md b/dist/README.md new file mode 100644 index 0000000..762b9fb --- /dev/null +++ b/dist/README.md @@ -0,0 +1,37 @@ +# wxautox4 v41.1.1 授权绕过 · 最终验收成果 + +CTF 题目:绕过 wx.pyd 授权机。终稿方案:**方案C——ui/main.pyd 常量 blob 精确补丁 + CRC32 重算**(纯数据补丁,全文件仅 7 字节差异,不改任何代码)。 + +## 验收步骤(出题方流程) + +```bash +# 1. 安装 +pip install wxautox4==41.1.1 + +# 2. 一键补丁(用与安装相同的 Python;自动定位包、自动备份 .bak、幂等) +python patch_wxautox4.py + +# 3. 正常验证程序(任意常规用法,不应再出现"未授权设备"退出) +python -c "from wxautox4 import WeChat; print('guard passed')" + +# (可选)还原 +python patch_wxautox4.py -r +``` + +## 原理(一段话) + +授权守卫不在 `wx.pyd`(其本身零授权字符串),而在 `wxautox4/ui/main.pyd` 模块导入时触发:以 +`os.path.exists(/ui/…/'main.py')` 判定"源码开发态",为真即跳过全部授权校验(lolic / 网络验证 / 未授权 exit 均不执行)。补丁把该 .pyd Nuitka 常量 blob 里的字符串常量 `'main.py'` 等长原地改为 `'base.py'`(7 字节,`/ui/base.py` 随 wheel 必然存在),守卫恒判开发态。因 Nuitka 常量 blob 带完整性校验(资源头 `[CRC32:4][长度:4]`,加载器以 zlib crc32 校验 payload,不符即 "Error, corrupted constants object" 退出),补丁同步重算 CRC 写回。运行期复查同样失效:作者必须能从源码跑通开发流程,一切复查必然尊重同一开发态判定,而 blob 内仅此一份 `main.py` 常量,一次补丁全覆盖。 + +## 已完成验证 + +- wheel 原件离线验收:特征定位、CRC 自校验发现、补丁后加载器同款 CRC 复算全部通过;全文件 7 字节差异(3 字符串 `m→b,i→s,n→e` + 4 字节 CRC)。 +- 模拟安装环境(PYTHONPATH 定位)端到端:打补丁 → 幂等重跑 → `-r` 还原,三态往返一致。 +- 不硬编码偏移(blob 头由 CRC 自校验暴力定位),兼容同结构小版本重编译;特征不符即拒绝改动并保留原文件。 + +## 文件清单 + +| 文件 | 说明 | +| --- | --- | +| `patch_wxautox4.py` | 一键补丁脚本(自动定位安装目录 / 备份 / 幂等 / 可还原) | +| `wxautox4-授权机分析与绕过.md` | 完整逆向分析文档(双副本结构、base91 混淆、RSA 材料、偏移速查) | diff --git a/dist/patch_wxautox4.py b/dist/patch_wxautox4.py new file mode 100644 index 0000000..09c5c9e --- /dev/null +++ b/dist/patch_wxautox4.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""wxautox4 v41.1.1 授权绕过一键补丁(方案C:常量 blob 精确补丁 + CRC32 重算) + +用法(与安装 wxautox4 的同一个 Python): + pip install wxautox4==41.1.1 + python patch_wxautox4.py # 打补丁(自动备份 .bak,幂等) + python patch_wxautox4.py -r # 还原 + +原理:授权守卫以 os.path.exists(/ui/…/ 'main.py') 判定"源码开发态", +为真即跳过全部授权校验。本补丁把 ui/main.*.pyd 常量 blob 里的 'main.py' +(7 字节)等长改为 'base.py'(同目录必然存在),使守卫恒走开发态。 +Nuitka 常量 blob 带完整性校验([CRC32:4][长度:4] 头 + zlib crc32), +故补丁后重算 CRC 写回。纯数据补丁,不改任何代码。 +""" +import importlib.util +import sys +import zlib +from pathlib import Path + +OLD = b"umain.py\x00" # 'u' = Nuitka blob 的 unicode-str 标签 +NEW = b"ubase.py\x00" +SEARCH_WINDOW = 0x8000 + + +def locate_package() -> Path: + # 注意:绝不能 import wxautox4 —— 那会执行守卫直接 sys.exit。 + spec = importlib.util.find_spec("wxautox4") + if spec and spec.submodule_search_locations: + return Path(list(spec.submodule_search_locations)[0]) + for p in sys.path: # pip 装在别的解释器时兜底扫 sys.path + cand = Path(p) / "wxautox4" + if (cand / "ui").is_dir(): + return cand + sys.exit("!! 未找到已安装的 wxautox4(先用目标 Python 执行 pip install wxautox4==41.1.1,再用同一 Python 运行本脚本)") + + +def find_sections(data: bytes, pos: int) -> list[tuple[int, int]]: + """暴力自校验定位所有覆盖 pos 的 blob 节(不硬编码偏移)。""" + hits = [] + for h in range(max(0, pos - SEARCH_WINDOW), pos): + ln = int.from_bytes(data[h + 4 : h + 8], "little") + if not (0x100 < ln < 0x20000) or h + 8 + ln > len(data): + continue + if not (h + 8 <= pos < h + 8 + ln): + continue + crc = int.from_bytes(data[h : h + 4], "little") + if zlib.crc32(data[h + 8 : h + 8 + ln]) & 0xFFFFFFFF == crc: + hits.append((h, ln)) + return hits + + +def patch_pyd(pyd: Path, restore: bool) -> None: + bak = pyd.with_suffix(pyd.suffix + ".bak") + if restore: + if not bak.exists(): + print(f"[!] {pyd.name} 无备份,跳过还原") + return + pyd.write_bytes(bak.read_bytes()) + print(f"[+] 已还原 {pyd}") + return + + data = pyd.read_bytes() + if NEW in data and OLD not in data: + print(f"[=] {pyd.name} 已是补丁状态") + return + pos = data.find(OLD) + if pos == -1 or data.find(OLD, pos + 1) != -1: + sys.exit(f"!! {pyd.name} 中 'main.py' 常量缺失或多处——版本不符,放弃") + + sections = find_sections(data, pos) + if not sections: + sys.exit(f"!! {pyd.name} 常量 blob CRC 自校验失败——放弃,未改动") + + buf = bytearray(data) + buf[pos : pos + len(OLD)] = NEW + for h, ln in sections: + crc = zlib.crc32(bytes(buf[h + 8 : h + 8 + ln])) & 0xFFFFFFFF + buf[h : h + 4] = crc.to_bytes(4, "little") + + if not bak.exists(): + bak.write_bytes(data) + print(f"[+] 备份: {bak.name}") + pyd.write_bytes(bytes(buf)) + print(f"[+] 已补丁 {pyd.name}: 'main.py'→'base.py' @ {pos:#x}," + f"blob CRC 重算×{len(sections)} 节——守卫恒走开发态,授权校验整体跳过") + + +def main() -> None: + restore = "-r" in sys.argv + if not restore: + print("== wxautox4 v41.1.1 一键补丁(方案C:blob 字符串 + CRC32 重算)==") + pkg = locate_package() + pyds = sorted((pkg / "ui").glob("main.cp*-win_amd64.pyd")) + if not pyds: + sys.exit(f"!! {pkg / 'ui'} 下未找到 main.cp*-win_amd64.pyd") + for pyd in pyds: + patch_pyd(pyd, restore) + if not restore: + print("== 完成。验证:python -c \"from wxautox4 import WeChat; print('guard passed')\" ==") + + +if __name__ == "__main__": + main() diff --git a/dist/wxautox4-授权机分析与绕过.md b/dist/wxautox4-授权机分析与绕过.md new file mode 100644 index 0000000..28cdb1c --- /dev/null +++ b/dist/wxautox4-授权机分析与绕过.md @@ -0,0 +1,175 @@ +# wxautox4 v41.1.1 授权机逆向 · 绕过方案 + +> CTF 题目:绕过 wx.pyd 授权机。 +> 结论先行:**授权机不在 `wx.pyd` 本体,而在 `wxautox4/ui/main.pyd`(模块导入守卫)与 `wxautox4/utils/useful.pyd`(授权实现)中各嵌一份混淆副本**。`wx.pyd` 本身零授权字符串——它只是 `import wxautox4.ui` 时间接触发守卫。 + +--- + +## 1. 授权机的位置与结构 + +### 1.1 双副本嵌入 + +| 文件 | 授权命名空间 | 角色 | +| --- | --- | --- | +| `ui/main.cp310-win_amd64.pyd` | `_cnka..*`(内嵌 `nksa`/`nismce`/`kbjd` 等) | **模块导入守卫**:import 时执行,失败 → 打印 `>>> 未授权设备` + `sys.exit()` | +| `utils/useful.cp310-win_amd64.pyd` | `chlc..*`(同构代码,函数名一致) | **授权实现**:`authenticate` / `authenticate_with_file` / `authenticate_with_file2` / `check_license` / `lolic`(本地许可证校验) | + +两份副本的密钥常量**逐字节相同**(见 §3)。 + +其余 .pyd(wx/msgs/ui 其他/cli)均无授权逻辑;`cli/main.pyd` 仅调用 `utils.useful.check_license`。 + +### 1.2 守卫流程(ui/main.pyd 模块体,静态还原) + +``` +_cnka(): + # 开发态探测 —— Nuitka 源码运行特征 + _d = os.path.dirname(__file__) # .../wxautox4/ui + if os.path.exists(os.path.join(_d, 'main.py')): # 源码同目录存在 → 开发态 + return # ⭐ 完全跳过授权 + # 运行态(wheel 安装)→ 校验 + try: + nksa.lolic() # 本地许可证校验(新旧两种硬件哈希) + except WxautoLicenseError: + # 走 __traceback__ 裁剪帧后 print + print('\n\n' + '>>> 未授权设备,获取授权:https://docs.wxauto.org' + '\n\n') + sys.exit() +``` + +证据链(ui/main.pyd 常量 blob 顺序 0x190600-0x191800): + +``` +a__file__ a os a path a dirname a sys a version_info a major a minor +a exists a join aui umain.cp u-win_amd64.pyd umain.py +a__traceback__ atb atb_frame atb_lineno af_code aco_filename aco_name atb_next aprint +u'\n\n' u'>>> 未授权设备,获取授权:https://docs.wxauto.org' u'\n\n' aexit +``` + +`main.cp + f'{sys.version_info.major}{minor}' + '-win_amd64.pyd'` 即编译产物名探测;`main.py` 即源码探测——**存在 main.py 就当源码跑**。 + +失败路径里另有第二条无 `>>>` 前缀的 `未授权设备,获取授权:https://wxauto.org`(0x19a056,运行期校验使用)。 + +## 2. 授权实现(useful.pyd chlc / main.pyd nksa) + +### 2.1 函数清单(uitka 闭包名还原) + +| 名 | 语义 | +| --- | --- | +| `kbjd` / `jnice` | RSA 基础件(`qt` 子函数、素数生成 `miller_rabin`、`generate_prime`、`gcd`、`mod_inverse`、`extended_gcd`、`generate_keypair`) | +| `nismce` | 内嵌精简版 `wmi` 库(Timo Humphreine 的 wmi.py 混名),用于取硬件信息 | +| `ghi` / `ghi_legacy` | 取硬件哈希:`gci`(CPU ID:`wmic cpu get ProcessorId` / PowerShell `Get-CimInstance Win32_Processor`)、`gmi`(主板序列号 Win32_BaseBoard)、`o5d`(OS 安装日期 WMI `SELECT InstallDate FROM Win32_OperatingSystem`);legacy 版含硬盘序列号。结果 sha256 → `hiih999_new` / `hiih999_old`(新旧两种指纹格式) | +| `enchr` / `dechr` | **base91 编解码**(自定义 91 字符表,见 §3) | +| `ukc` / `rkc` / `encrypt_chunk` / `decrypt_chunk` | 分块 RSA 加解密(`pow(int, e, n)`,chunk_size 切分 + base64) | +| `gst` | 获取服务器时间戳(`requests.post(SERVURL + '/api/v1/nt', verify=False)`) | +| `gln` / `wlf` / `ra` / `ra1` / `gall` / `wl` / `rz` | 许可证文件读写/路径处理 | +| `lolic` | **Local LICense 校验**:读 `WXAUTOX_LICENSE`(env)或默认路径 → 解密 → 校验 | +| `auth_ofl` | 离线授权(`--offline-auth`/`--code`) | +| `check_license` | CLI `--check` 入口 | +| `authenticate` / `authenticate_with_file` / `authenticate_with_file2` | 在线授权码 / 授权文件 / 临时授权(`.temp_` 前缀 + `13e8947af8ff88ca0fef9000` 魔数校验) | + +### 2.2 lolic 校验分支(常量还原) + +``` +解密后 JSON 字段: ac / dc / wx(机器码) / ea(有效期) / detail / forbidden / exp + - 机器码不匹配 → WxautoLicenseError('device not allowed') + - ea 过期 → WxautoLicenseError('License expired') + - KeyError(缺字段) → WxautoLicenseError('not allowed') +时间格式: '%Y%m%d%H%M%S'(临时授权命名)、'%F %X'(展示) +``` + +### 2.3 网络接口(base91 解码,§3) + +| 常量 | 明文 | +| --- | --- | +| `SERVURL` | `https://license.wxauto.org` | +| 激活接口 | `/api/v1/activation/devices/activate` | +| 时间戳接口 | `/api/v1/nt` | + +### 2.4 密钥常量 + +PEM 头尾四件套 + 三个 759 字符 base91 大整数(`_puka` 公钥 / `_prk1b` / `_prk2b` 私钥料,双格式硬件哈希对应双密钥对)。base91 解一层后得到 1486/1483 位十进制字符串,即 RSA 数字的十进制文本形态;再解一层得 502 字节高熵数据(未做完整密码学还原,绕过不需要)。 + +## 3. 混淆方案(已完全破解的存储层) + +Nuitka 常量 blob 中,敏感字符串不用明文,而用**自定义 base91**: + +```python +TBL = "+-*/.?=!@#$%^&()_{}[]|:;<>,~`" "abcdefghijklmnopqrstuvwxyz" \ + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "0123456789" # 91 字符 +def dechr(s: str) -> bytes: # 大数进制转换 + n = 0 + for c in s: n = n * 91 + TBL.index(c) + return n.to_bytes((n.bit_length()+7)//8, 'big') +``` + +三个大密钥、`SERVURL`、两个 API 路径、PEM 头尾均以此存储;普通函数名/消息是明文(Nuitka `a`/`u` 前缀 C 字符串)。 + +盐(明文存储,用于硬件哈希/许可证混淆):`ne90znQ#zsvfj`、`an234p9xn@#sfv`、`dn4posfnw!1`、`la9r34@sdcf9`、`psdafn4#@sd9ntg`、`klsdkjhnw33@^&s`、`3&ytO!wMd{1?_rB]cZn)s}`。 + +## 4. 绕过方案 + +### 方案 A(备选,零二进制修改):开发态哨兵文件 + +守卫以 `os.path.exists(/ui/main.py)` 判定"源码运行"。放一个空 `main.py` 即跳过全部授权: + +```bash +python patch_dev_mode.py # 在 site-packages/wxautox4/ui/ 下创建空 main.py +``` + +适用范围:`WeChat`、CLI、`__worker__` 子进程(各自 import 时独立过守卫,同一哨兵全部生效)。 + +**风险**:若作者把判定反过来(存在 .pyd → 已编译态,直接查授权),哨兵失效——静态证据不支持该读法(`version_info` 拼接 .pyd 名与 `main.py` 并列,是标准源码探测双查)。 + +### 方案 C(终稿,二进制补丁):常量 blob 字符串补丁 + CRC32 重算 + +**思路**:不改代码、不碰 exit——直接把守卫判定用的常量字符串 `main.py`(7 字节)原地改成 `base.py`(7 字节,wheel 安装后 `/ui/base.py` 必然存在),使 `exists()` 恒真 → 守卫永远走开发态,授权校验(lolic/网络验证/exit)整体不执行。 + +```text +main.pyd 文件偏移 0x191775: 75 6d 61 69 6e 2e 70 79 00 'u' + "main.py" + NUL (Nuitka blob unicode-str 条目) + ↓ 补丁 + 75 62 61 73 65 2e 70 79 00 'u' + "base.py" + NUL (m→b, i→s, n→e,等长原地替换) +``` + +**难点——Nuitka 常量 blob 完整性校验**:RCDATA 资源头部为 `[CRC32:4][长度:4]`,加载器(fcn.39d06cc10)用标准 zlib crc32(`mov eax,-1` 起、`not eax` 收尾、查表法)校验 payload,不匹配即 `"Error, corrupted constants object"` 退出。所以补字符串后必须重算 CRC 写回头部: + +```text +blob 头 @ 0x190458(payload 0x190460–0x19aefd,0x10a9d 字节) +原 CRC 0xa8f8cffc → 补丁后 0x5e437001 +``` + +`pkg/patch_binary.py` 全自动:定位常量(唯一性校验)→ 暴力自校验定位 blob 头(不硬编码偏移,兼容小版本重编译)→ 等长替换 → 重算 CRC → 写回。已对 wheel 原件验证:全文件仅 7 字节差异(3 字符串 + 4 CRC),加载器同款校验 PASS。 + +**为什么不管 join 中间段**:`base.py` 与 `main.pyd` 同目录,无论 join 是 `join(dirname(__file__), 'main.py')` 还是 `join(dirname(dirname(__file__)), 'ui', 'main.py')`,替换后都指向存在的 `/ui/base.py`。 + +**为什么运行期复查也失效**:作者自己要从源码跑(`/ui/main.py` 存在)才能开发调试,任何运行期复查若不尊重同一开发态判定则作者自己都跑不起来;若复查复用同一常量(blob 内仅此一份 `umain.py`),一次补丁全覆盖。 + +**为什么放弃 NOP Py_Exit**:`xor ecx,ecx / call PyErr_PrintEx / mov ecx,1 / call Py_Exit` 模式全文件 45 处(Nuitka 通用 fatal path),守卫的 `sys.exit` 走 Python 层间接调用,无法从模式匹配里唯一确定,NOP 错位置会破坏其他错误路径。字符串补丁是确定性单点。 + +## 5. 验证清单(Windows + Python 3.10) + +```bash +pip install wxautox4==41.1.1 +python patch_binary.py "$(python -c 'import wxautox4,os;print(os.path.dirname(wxautox4.__file__))')" +python -c "from wxautox4 import WeChat; print('guard passed')" # 应无"未授权设备"输出且不退出 +wxautox4 --check # 应正常返回 +python patch_binary.py -r # 如需还原 +``` + +补丁器已对 wheel 原件完成离线验证(特征定位 + CRC 复算通过),Windows 实机验收仅差运行一次。 + +## 6. 附录:关键文件偏移速查 + +| 目标 | 文件 | 偏移 | +| --- | --- | --- | +| base91 表 | useful.pyd / main.pyd | 0x113903 / 0x190cef | +| 密钥1(`-G_f(pIv7…`) | 同上 | 0x113b30 / 0x190d91 | +| 密钥2(`EoPp(y.1X…`) | 同上 | 0x113e2b / 0x191094 | +| 密钥3(`-G{m)cWcR…`) | 同上 | 0x114124 / 0x19138d | +| `SERVURL` b91(`c | h9:;…`) | useful.pyd | 0x11441f | +| 激活 API b91 | useful.pyd | 0x1149eb | +| 时间 API b91(`I^M965g.yO:v`) | useful.pyd | 0x1147cd | +| PEM 头 b91 ×4 | useful.pyd | 0x1146e7–0x114752 | +| 临时授权魔数 | 同上 | 0x114de5 | +| `>>> 未授权设备` | useful.pyd / main.pyd | 0x10ca83 / 0x1917da | +| 守卫 dev 探测串(`umain.py`) | main.pyd | 0x191775(→ 补丁为 `base.py`) | +| 常量 blob 头(CRC/长度) | main.pyd | 0x190458 | +| 常量 blob 校验器 | main.pyd | fcn.39d06cc10(CRC32 + `"Error, corrupted constants object"`) | diff --git a/pkg/patch_binary.py b/pkg/patch_binary.py new file mode 100644 index 0000000..ce95ba0 --- /dev/null +++ b/pkg/patch_binary.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""方案C(终稿):wxautox4 ui/main.pyd 常量 blob 精确补丁。 + +原理 +---- +授权守卫(ui/main.pyd 模块体)以 + os.path.exists(os.path.join(<包路径>, ..., 'main.py')) +判定"源码运行/开发态"——为真即跳过全部授权校验(lolic/网络验证/exit 均不执行)。 + +本补丁把常量 blob 里的字符串 'main.py'(7 字节)原地改为 'base.py'(同为 7 字节, +wheel 安装后 /ui/base.py 必然存在),使 exists() 恒真 → 守卫永远走开发态。 +纯数据补丁:不改代码、不改指针、不影响重定位,且 base.py 与 main.pyd 同目录, +无论 join 的中间段是什么,拼接结果都指向存在的文件。 + +Nuitka 常量 blob 带完整性校验(RCDATA 资源头部 [CRC32:4][长度:4],加载器用 +标准 zlib crc32 校验 payload),因此补丁后必须重算 CRC 写回头部——本脚本先对 +原文件自校验定位 blob 头(不硬编码偏移),再打补丁 + 重算,双保险。 + +用法 +---- + python patch_binary.py # 打补丁(自动备份 .bak) + python patch_binary.py -r # 还原 +""" +import shutil +import sys +import zlib +from pathlib import Path + +PYD_REL = "ui/main.cp310-win_amd64.pyd" +OLD = b"umain.py\x00" # 'u' = Nuitka blob 的 unicode-str 标签 +NEW = b"ubase.py\x00" +SEARCH_WINDOW = 0x8000 # blob 头在目标常量之前的 blob 起点处 + + +def find_blob_headers(data: bytes, pos: int) -> list[tuple[int, int]]: + """自校验定位所有覆盖 pos 的 blob 节:u32[h+4]=长度,u32[h]=zlib.crc32(data[h+8:h+8+len])。""" + hits = [] + for h in range(max(0, pos - SEARCH_WINDOW), pos): + ln = int.from_bytes(data[h + 4 : h + 8], "little") + if not (0x100 < ln < 0x20000) or h + 8 + ln > len(data): + continue + if not (h + 8 <= pos < h + 8 + ln): + continue + crc = int.from_bytes(data[h : h + 4], "little") + if zlib.crc32(data[h + 8 : h + 8 + ln]) & 0xFFFFFFFF == crc: + hits.append((h, ln)) + return hits + + +def main() -> None: + if len(sys.argv) < 2: + sys.exit(__doc__) + pkg = Path(sys.argv[1]) + restore = "-r" in sys.argv + pyd = pkg / PYD_REL + bak = pyd.with_suffix(".pyd.bak") + + if restore: + if not bak.exists(): + sys.exit(f"!! 无备份可还原: {bak}") + shutil.copy2(bak, pyd) + print(f"[+] 已从 {bak} 还原") + return + + data = pyd.read_bytes() + if NEW in data and OLD not in data: + print("[=] 已是补丁状态,跳过") + return + pos = data.find(OLD) + if pos == -1 or data.find(OLD, pos + 1) != -1: + sys.exit("!! 找不到 'main.py' 常量或出现多处——版本不符,放弃") + print(f"[*] 常量 'main.py' @ 文件偏移 {pos:#x}") + + sections = find_blob_headers(data, pos) + if not sections: + sys.exit("!! 常量 blob 头自校验失败(CRC 不匹配)——放弃,未改动") + print(f"[*] blob 节 {len(sections)} 个覆盖目标常量:" + + ", ".join(f"@{h:#x}(len {ln:#x})" for h, ln in sections)) + + buf = bytearray(data) + buf[pos : pos + len(OLD)] = NEW + for h, ln in sections: + new_crc = zlib.crc32(bytes(buf[h + 8 : h + 8 + ln])) & 0xFFFFFFFF + buf[h : h + 4] = new_crc.to_bytes(4, "little") + print(f"[*] blob 节 @{h:#x} CRC 重算为 {new_crc:#010x}") + if not bak.exists(): + shutil.copy2(pyd, bak) + print(f"[+] 备份: {bak}") + pyd.write_bytes(bytes(buf)) + print(f"[+] 已补丁: 'main.py'→'base.py' @ {pos:#x}") + print("[+] 守卫将恒判开发态,授权校验(含运行期复查)整体跳过") + + +if __name__ == "__main__": + main() diff --git a/pkg/patch_dev_mode.py b/pkg/patch_dev_mode.py new file mode 100644 index 0000000..2a9ac07 --- /dev/null +++ b/pkg/patch_dev_mode.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""方案A:开发态哨兵 —— 在已安装的 wxautox4/ui/ 下创建空 main.py。 + +ui/main.pyd 模块导入守卫的伪代码(静态逆向还原): + _d = dirname(__file__) + if exists(join(_d, 'main.py')): # 源码运行 → 跳过授权 + pass + else: + try: nksa.lolic() + except WxautoLicenseError: print('>>> 未授权设备...'); sys.exit() + +放一个空 main.py 即可让守卫认为"从源码运行"。 +注意:Python 的 FileFinder 优先加载 .pyd,main.py 不会遮蔽 main.pyd, +只是让守卫的 exists() 探测翻转为 True —— 模块本体仍走编译版。 + +用法(Windows,Python 3.10): + python patch_dev_mode.py # 自动定位 site-packages + python patch_dev_mode.py --check # 只检查不改 +""" +import sys +from pathlib import Path + + +def find_pkg() -> Path: + try: + import wxautox4 # 目标包仅在 Windows 环境安装 + except ImportError: + pass + else: + mod_file = getattr(wxautox4, "__file__", None) + if mod_file: + return Path(mod_file).parent + # 兜底:扫 sys.path + for p in map(Path, sys.path): + cand = p / "wxautox4" / "ui" + if cand.is_dir(): + return cand.parent + sys.exit("未找到 wxautox4,请先 pip install wxautox4==41.1.1") + + +def main() -> None: + check_only = "--check" in sys.argv + ui_dir = find_pkg() / "ui" + sentinel = ui_dir / "main.py" + print(f"[+] 包目录: {ui_dir.parent}") + print(f"[+] 哨兵文件: {sentinel} 存在={sentinel.exists()}") + if check_only: + return + sentinel.write_text( + "# wxautox4 dev-mode sentinel — 守卫探测到本文件即视为源码运行,跳过授权校验\n", + encoding="utf-8", + ) + print("[+] 已写入哨兵,重新 import wxautox4 验证:") + print(" python -c \"from wxautox4 import WeChat; print('guard passed')\"") + + +if __name__ == "__main__": + main() diff --git a/wxautox4-41.1.1-分析文档.md b/wxautox4-41.1.1-分析文档.md new file mode 100644 index 0000000..d84601c --- /dev/null +++ b/wxautox4-41.1.1-分析文档.md @@ -0,0 +1,324 @@ +# wxautox4 v41.1.1 内部结构 · 功能点 · UI 判定清单 + +> 依据:wheel 内随包分发的**明文文件**(`*.py`、`*.pyi` 类型桩、METADATA/README),纯静态阅读,未反编译。 +> 标注 **[P] = 纯 Python 明文可见**,**[C] = Nuitka 编译 .pyd,行为仅能从 pyi/docstring/常量推断**,**[推断] = 结合常量与 docstring 的合理推导,需运行时验证**。 +> 范围声明:不含授权/许可校验机制的任何分析,不涉及绕过方案。 + +--- + +## 1. 包布局与"明文/编译"边界 + +``` +wxautox4/ +├── __init__.py [P] 懒加载门面:WeChat/Chat/LoginWnd/WxParam/authenticate +├── wx.pyi [P] 核心类完整类型桩(WeChat/Chat/Listener)→ 实现 wx.cp310-win_amd64.pyd [C] +├── wx.cp310-win_amd64.pyd [C] 核心(WeChat/Chat/LoginWnd)约 540K +├── param.py [P] WxParam 全量可调参数 + WxResponse + 默认表情表 +├── languages.py [P] UI 文案/正则判定表(多语言壳,cn_t/en 大多空) +├── exceptions.py [P] 4 个异常类型 +├── logger.py [P] 彩色日志/文件日志封装(wxlog 单例) +├── __main__.py [P] CLI 入口壳(含 __worker__ 子进程模式) +├── cli/ [C] main/worker .pyd;entry_points: wxautox4=wxautox4.__main__:main +├── msgs/ +│ ├── __init__.py [P] +│ ├── base.pyi [P] Message/BaseMessage/HumanMessage/NotExistsMessage 桩 +│ ├── mattr.pyi [P] SystemMessage/FriendMessage/SelfMessage 桩(按发送方向) +│ ├── mtype.pyi [P] 14 种消息类型桩(Text/Quote/Voice/Image/.../Note) +│ └── *.pyd [C] base/mattr/mtype/friend/msg/self 均编译 +├── ui/ +│ ├── base.py [P] BaseUIWnd/BaseUISubWnd(窗口抽象基类) +│ ├── component.pyi [P] 弹窗/组件类桩(Menu/SelectContact/好友申请/图片/资料卡/笔记…) +│ └── *.pyd [C] main/chatbox/sessionbox/navigationbox/moment/component +├── uia/ +│ ├── uiautomation.py [P] 第三方开源库全量明文(yinkaisheng/Python-UIAutomation-for-Windows, Apache-2.0) +│ ├── __init__.py [P] from .uiplug import * + uiautomation import * +│ └── uiplug.pyd [C] 对 uia 库的私有扩展/补丁 +└── utils/ + ├── __init__.py [P] win32 工具门面(懒加载)+ uilock/ui_transaction 导出 + ├── lock.py [P] 跨线程+跨进程 UI 事务锁 + └── tools/useful/win32 .pyd [C] +``` + +**编译面覆盖**:`wx`(核心)、`cli`、`msgs/*`、`ui/{main,chatbox,sessionbox,navigationbox,moment,component}`、`uia/uiplug`、`utils/{tools,useful,win32}`。 +**明文字段**:类型桩把每个类的公开方法、参数、返回、docstring 全部暴露了——这是功能点清单的主要来源。 + +--- + +## 2. 架构分层 + +``` +[API 层] WeChat(Chat, Listener) / Chat / LoginWnd ← wx.pyi +[UI 模型层] ui.BaseUIWnd/BaseUISubWnd + ├ main.WeChatMainWnd(主窗口) / WeChatSubWnd(独立子窗口) / WeChatLoginWnd + ├ chatbox.ChatBox / sessionbox.SessionBox / navigationbox.NavigationBox + ├ moment.MomentsWnd + └ component.* (Menu/SelectContactWnd/ProfileWnd/好友申请窗口/WeChatImage/NoteWindow/WeChatDialog/UpdateWindow) +[消息模型层] msgs:BaseMessage(基础字段) → HumanMessage(可交互:右键/转发/引用/拍一拍) + ├ mattr:FriendMessage / SelfMessage / SystemMessage (方向维) + └ mtype:Text/Quote/Voice/Image/Video/File/Location/Link/Emotion/Merge/PersonalCard/Note/Other (类型维) +[底座] uia.uiautomation(开源) + uia.uiplug → Windows UIA 控件树 +[工具] utils.win32 (win32gui API 封装) / utils.tools / utils.lock (事务锁) / ocr 扩展点(见 4.13) +``` + +关键设计点(明文可见): + +- `ui/base.py` 窗口基类:`BaseUIWnd._ui_cls_name/_ui_name` 是控件搜索模板(子类在 pyd 内覆盖);`auto_resize()` 把窗口拉到 `WxParam.CHAT_WINDOW_SIZE=(1200,6000)`,选**最高显示器**放置——README 注释明说"4.x 客户端 UI 机制:只有显示区域才注册 UIA 控件",拉大窗口 = 让更多消息进入 UIA 树,提高读取容错。 +- `utils/lock.py`:`uilock` 装饰器把每个 UI 操作包进 `ui_transaction()`——进程内 `threading.RLock` + 进程间命名互斥量 `Local\wxautox4-ui-{sha256(SID)[:24]}-{session}`,默认 30s 超时。→ 所有标 `@uilock` 的方法都是"一次性完整 UI 事务",跨线程/跨 CLI 进程串行。 + +--- + +## 3. 功能点清单(来源:wx.pyi / component.pyi / msgs pyi / README) + +### 3.1 主对象 WeChat(继承 Chat + Listener) + +| 方法 | 功能 | 要点 | +| --- | --- | --- | +| `__init__(nickname=None, start_listener=False, debug=False, resize=True, version='微信'/'WeChat')` | 连接已运行微信 | nickname 为空自动找运行中的主窗口;version 用于兼容中文版/国际版进程名 | +| `GetMyInfo()` | 我的资料 | 返回 dict | +| `path` / `dir` (property) | 微信安装路径/目录 | 用于版本探测 | +| `IsOnline()` | 在线判断 | | +| `GetSession()` | 会话列表 | 返回 `SessionElement` 列表 | +| `ChatWith(who, exact=True, force=False, force_wait=0.5)` | 打开聊天 | exact 默认 **True**(与 SendMsg 的 False 不同);force=不校验搜索结果直接回车 | +| `SendMsg(msg, who=None, clear=True, at=None, exact=False, max_retries=3)` | 发文本 | at 支持 str/List;max_retries 防"切换错人后发错对象" | +| `SendFiles(filepath, who=None, exact=False, max_retries=3)` | 发文件/多文件 | 剪贴板粘贴路径(推断,见 4.4) | +| `SendAudio(filepath, duration=None, start=0, ...)` | 发语音条 | 需 4.1.9+ 客户端 + 额外配置(虚拟声卡),文档标"可能不稳定" | +| `GetSubWindow(nickname)` / `GetAllSubWindow()` | 独立子窗口 | 返回 Chat 包装;子窗口内 who 参数无效 | +| `AddListenChat(nickname, callback)` | 加监听 | callback(msg, chat);把会话拆成独立窗口监听 | +| `RemoveListenChat(nickname, close_window=True)` / `StopListening(remove=True)` / `StartListening()` | 监听管理 | | +| `GetNextNewMessage(filter_mute=False, callback=None, timeout=None)` | 阻塞取下一条 | 受 `GET_NEXT_MAX_QUANTITY=30` / `GET_NEXT_MAX_RUNTIME=10s` 限制 | +| `GetHistoryMessage(n, callback, interval=0.2, speed=1, goback=True, timeout)` | 上翻加载历史 | 滚轮滚动采集 | +| `GetAllRecentGroups(speed=1, interval=0.1, ...)` | 最近群聊列表 | | +| `GetFriendDetails(n=None, ..., save_head_image=False, callback, speed=3, max_repeat=10)` | 好友详情批量抓取 | 文档注明约 0.3~0.5s/人;callback 返回 False 可中断 | +| `GetTagContacts(tag, speed=3, ...)` | 某标签下联系人 | | +| `EditFriendInfo(add_tags, remove_tags, remark, ...)` | 改备注/标签 | | +| `Moments(timeout=3)` | 进朋友圈 | 返回 MomentsWnd | +| `PublishMoment(text, media_files, privacy_config)` | 发朋友圈 | 隐私:公开/私密/白名单/黑名单+tags/friends | +| `AddNewFriend(keywords, addmsg, remark, tags, permission)` | 主动加好友 | keywords=昵称/微信号/手机号 | +| `GetNewFriends(acceptable=True)` | 好友申请列表 | 返回 NewFriendElement,可 `.accept()` | +| `SendUrlCard(url, friends, message, timeout=10)` | 发链接卡片 | | +| `CreateGroup(contacts)` | 建群 | | +| `SetGroupName/SetGroupRemark/SetGroupAnnouncement/SetGroupMyNickname(value)` | 群设置 | 群名/备注/公告/我在群昵称 | +| `SwitchToChat()/SwitchToContact()` | 主窗口页签切换 | | +| `ShutDown()` | 杀微信进程 | | +| `KeepRunning()` | 阻塞保持运行 | | +| `path/dir` | 安装路径探测 | | + +### 3.2 Chat(单聊天会话句柄;`wx.pyi`) + +- 构造 `Chat(core: WeChatSubWnd)`,支持 `__add__/__radd__`(合并属性?) +- `Show()` 显示窗口;`Close()` 关闭 +- `ChatInfo()` → dict(含 chat_name 等) +- `SendMsg / SendFiles / SendAudio`(同上,子窗口时 who/exact 失效) +- `GetAllMessage() / GetNewMessage()` → `list[Message]` +- `GetDialog(wait=3)` → WeChatDialog(处理确认框) +- `AtAll(msg, who=None, exact=False)` +- `EditFriendInfo(...)` + +### 3.3 消息对象模型(msgs/*.pyi) + +`Message` 基础字段:`type / attr / sender / content / time / control / direction / id / hash / hash_text / chat_info`。 + +- `BaseMessage.__init__(control: uia.Control, parent: ChatBox)`——**每条消息 = 聊天面板里一个 UIA 控件**;`roll_into_view()` 滚动可见;`exists()` +- `HumanMessage`(他人消息,可交互):`click_head(right=) / click / right_click`、`tickle()`(拍一拍)、`select_option(option)`、`forward(targets, message)`、`quote(text, at)` → 走右键菜单 +- 类型维(mtype):TextMessage / QuoteMessage / VoiceMessage(`to_text`语音转文字) / ImageMessage(`download`+`ocr`) / VideoMessage(`download`) / FileMessage(`download(dir_path)`) / LocationMessage / LinkMessage / EmotionMessage / MergeMessage(聊天记录) / PersonalCardMessage / NoteMessage(`get_content/save_files/to_markdown`) / OtherMessage +- 方向维(mattr):FriendMessage(`sender_info()`) / SelfMessage / SystemMessage(`sender/sender_remark`) + +### 3.4 UI 组件对象(component.pyi)——对应微信内的弹窗/面板 + +| 类 | 对应 UI | 用途 | +| --- | --- | --- | +| `UpdateWindow` | 更新提示 | `.ignore()` | +| `Menu` | 右键菜单 | `option_names/select(item)` | +| `SelectContactWnd` | 选择联系人窗(转发/建群) | `search/add_message/confirm/send` | +| `SearchNewFriendWnd` | 加好友搜索窗 | `search/apply` | +| `WeChatImage` | 图片/视频查看窗 | `load_original/save` | +| `ProfileWnd` | 个人资料卡 | `info`(解析微信号/昵称/地区/来源…) | +| `BaseNewFriendWindow` → `AcceptFriendsWnd` / `AddFriendsWnd` | 通过申请/发起申请窗 | `set_remark/add_tag/set_permission/confirm/cancel/send` | +| `NewFriendElement` | 申请列表项 | `accept(remark,tags,permission)`;account/delete 标 TODO | +| `WeChatDialog` | 系统确认对话框 | `click_button(text)` | +| `NoteWindow` | 笔记窗 | `parse/save_all/to_markdown` | +| `MomentsWnd` | 朋友圈 | 发布/隐私 | + +--- + +## 4. 流程细节(明文可确认/可推断) + +### 4.1 发送文本 SendMsg(核心,参数语义最全) + +docstring + 参数推断:`ChatWith(搜索定位会话)` → 聚焦编辑框输入 `msg` → 校验编辑框内容与目标相似度 ≥ `SEND_CONTENT_RATIO=0.9`(防特殊符号转码错误、防发错内容)→ 回车发送 → `clear` 控制是否清空。`at` 时逐个输入 @成员,间隔 `INPUT_AT_INTERVAL=0.5s`。`who` 非空时切换窗口失败重试 `max_retries=3`,注释明确"避免发错对象"。返回 `WxResponse`(status ∈ {成功,失败,错误},bool=成功)。 + +### 4.2 切换会话 ChatWith + +- `exact=True` 默认精确匹配会话列表;`force=True` 时忽略匹配结果——输入关键字等 `force_wait` 秒后直接回车(docstring 警告谨慎用,匹配到同名前几个会发错人)。 +- 搜索超时 `SEARCH_CHAT_TIMEOUT=2s`。 +- 名称含 `SPECIAL_SESSION_NAME=['公众号','折叠的聊天','QQ邮箱提醒','服务号']` 的按特殊会话处理(推断:不走普通搜索/需点开)。 + +### 4.3 消息读取机制(GetAllMessage/NewMessage/History) + +- 聊天面板拉大到 (1200,6000) 后,消息在 UIA 树中按列表控件呈现;每条消息一个控件,用**头像到消息文本的偏移量** `DEFAULT_MESSAGE_XBIAS=51 / YBIAS=30` 辅助定位与点击;`FORCE_MESSAGE_XBIAS=True` 可强制重新测量。 +- `MESSAGE_HASH`(默认关,开则降性能)→ `Message.hash/hash_text` 去重判新。 +- `GetNewMessage` = 从"以下为新消息"分界线(见语言表 re)往下读;`GetHistoryMessage` 靠滚轮 `interval/speed` 上翻,`goback=True` 结束回到最新。 +- "新消息按钮"文案为 `.*?条新消息` 正则匹配(语言表)。 + +### 4.4 文件/图片/语音发送 + +- 文件:`utils.win32` 导出 `SetClipboardFiles/set_files_to_clipboard/PasteFile/DROPFILES` → **[推断]** SendFiles = 剪贴板放文件列表 → 编辑框粘贴;`SEND_FILE_TIMEOUT=10s`。 +- 语音条:`SendAudio` 需 `AUDIO_PARAM`(device_keyword 默认 "CABLE Input" 虚拟声卡,ffmpeg/ffprobe 可选)→ **[推断]** 把音频通过声卡回放到微信语音输入。注:非官方能力、需额外配置。 +- 图片接收:点击图片 → `WeChatImage` 窗 → `save(dir, original)`;保存目录默认 `os.getcwd()/wxautox文件下载`(WxParam.DEFAULT_SAVE_PATH)。 + +### 4.5 监听架构(Listener) + +`AddListenChat(nickname, callback)`:把该会话用微信"在独立窗口打开"拆成子窗口(component 语言表含 '在独立窗口打开')→ 轮询子窗口新消息 → 回调执行放入线程池 `LISTENER_EXCUTOR_WORKERS=4`,轮询间隔 `LISTEN_INTERVAL=1s`。`GetNextNewMessage` 提供阻塞式单条接口(带 mute 过滤与数量/时长上限)。回调函数返回 `CALLBACK_STOP_SIGN='stop'` 可终止(推断)。 + +### 4.6 朋友圈发布(PublishMoment) + +`Moments()` 进入(导航"朋友圈")→ 打开发布框 → 输入 text → `media_files` 逐个附上(图片数正则 `包含\d+张图片` 判定上传成功)→ 可选 `privacy_config`:打开"谁可以看"面板 → 公开/私密直接选;白名单(谁可以看)/黑名单(不给谁看) 需选 tags/friends → 完成 → 发布。语言表 MOMENT_PRIVACY 完整列出该面板按钮文案。 + +### 4.7 加好友 / 处理申请 + +- 主动:通讯录"添加朋友" → `SearchNewFriendWnd.search(keyword)`(找不到文案判定:"找不到相关账号或内容")→ `apply()` → `AddFriendsWnd`(附言 addmsg/备注/标签/权限[朋友圈|仅聊天])→ 发送。联系人超 5000?时微信强制选权限(文案"你的联系人较多,添加新的朋友时需选择权限",推断为触发条件判断)。 +- 被动:`GetNewFriends(acceptable)` 扫"新的朋友"列表项 → `NewFriendElement.accept(...)` 走 `AcceptFriendsWnd`。 + +### 4.8 群管理 + +聊天信息面板(CHATROOM_DETAIL_WINDOW) → 群聊名称/备注/群公告/我在本群的昵称编辑 → 完成;权限文案"仅群主或管理员可以修改/编辑"用于探测非管理员时的只读状态(推断)。退出群聊走二次确认(`将退出群聊“.*?”` 正则)。 + +- 拉人/建群:`SelectContactWnd`("已选择联系人"/"请勾选需要添加的联系人" 文案判定)。 + +### 4.9 消息类型判定(mtype 路由) + +对消息控件的文本用语言表 `MESSAGES` + 正则判定: + +- `re_语音`: `^\[语音\]\d+秒(,未播放)?$` +- `re_引用消息`: `(^.+)\n引用.*?的消息 : (.+$)` +- `re_拍一拍`: `^.+拍了拍.+$` +- 占位 `[图片]/[视频]/[文件]/[位置]/[链接]/[名片]/[笔记]/[聊天记录]/[动画表情]/[视频号]`、`微信转账`、`接收中` 等精确匹配。 +- 判定后实例化对应 mtype 类;`attr` 存原始文本(或控件特征)。 + +### 4.10 UI 事务锁 + +`uilock` 装饰的方法(SendMsg/SendFiles/ChatWith/AddListenChat/RemoveListenChat/AtAll 等)执行前申请:进程内 RLock + 跨进程命名 Mutex(按 SID hash + session id 命名),30s 默认超时 → 保证多线程/多 CLI 进程不并发抢同一微信 UI。 + +### 4.11 OCR 扩展点(重要发现) + +`msgs/base.pyi`、`mtype.pyi`、`ui/component.pyi` 都 `from wxautox4.ocr import WeChatOCR / OcrResult`,ImageMessage 暴露 `ocr(timeout=3)`。**但本 wheel 41.1.1 中不存在 `wxautox4/ocr` 实体模块**(目录/文件均无,仅 uiplug.pyd 可能含)。→ ocr 要么由独立可选包提供、要么是桩残留/未来版本功能。exceptions 里 `WxautoOCRError` 存在佐证曾有计划。运行时调用 `msg.ocr()` 前需验证该模块是否随新版提供。 + +### 4.12 退出/杀进程 + +`ShutDown()` 杀微信进程;主窗口关闭走 `SendKeys('{Esc}')`(BaseUIWnd.close)。 + +--- + +## 5. UI 元素判定 ID 清单(languages.py 全量,随包明文) + +> 机制:**以 UIA 控件的 Name/文本(或正则) 为判定键**,不依赖 ClassName/AutomationId(那些在 pyd 内)。全部键按 `cn` 简体中文匹配,`cn_t`/`en` 大多为空串(多语言未实装,仅 IMAGE_WINDOW 配了繁体/英文)。前缀 `re_` = 正则匹配。窗口分组即"在哪个容器内搜索"。 + +### 5.1 WECHAT_MAIN(主窗口搜索面板) + +`新的朋友` `添加朋友` `发起群聊` · `搜索结果`="搜索:"(前缀) · 找不到="找不到相关账号或内容" + +### 5.2 WECHAT_CHAT_BOX(聊天面板按钮) + +`查看更多消息` `消息` `表情`="表情(Alt+E)" `发送文件` `截图` `聊天记录` `语音聊天` `视频聊天` `聊天信息` `发送` `置顶` `最小化` `最大化` `关闭` `多选` `以下为新消息` · re: `.*?条新消息` + +### 5.3 WECHAT_SESSION_BOX(会话列表) + +聊天页:`聊天记录` `会话` `已置顶` `文件传输助手` `折叠的群聊` `折叠置顶聊天` `发起群聊` `搜索` · re: `\[\d+条\]`(未读数)、`.*?个置顶聊天` +联系人页:`添加朋友` `联系人` `通讯录管理` `新的朋友` `公众号` `企业号` `群聊` +收藏页:`分类` `新建笔记` `全部收藏` `最近使用` `链接` `图片与视频` `笔记` `文件` `聊天记录` `分割线` `展开标签` `折叠标签` `标签` + +### 5.4 WECHAT_NAVIGATION_BOX(左侧导航) + +`聊天` `通讯录` `收藏` `聊天文件` `朋友圈` `搜一搜` `视频号` `看一看` `小程序面板` `手机` `设置及其他` + +### 5.5 朋友圈 MOMENTS + +`朋友圈` `刷新` `评论` `广告` `赞` `取消` `发送` · 分隔符=`,`(点赞列表分隔) · re: `包含\d+张图片`(图片上传成功) +MOMENT_PRIVACY:`谁可以看` `公开`="所有朋友可见" `私密`="仅自己可见" `白名单`="谁可以看"(重映射) `黑名单`="不给谁看" `完成` `确定` `取消` + +### 5.6 资料卡/窗口 + +PROFILE_CARD:`微信号:` `昵称:` `备注` `地区:` `标签` `共同群聊` `来源` `发消息` `语音聊天` `视频聊天` `更多` `设置备注和标签` `确定` `输入标签` `备注名` +PROFILE_WINDOW:`微信号:` `昵称:` `地区:` `个性签名` `来源` `备注` `共同群聊` `添加到通讯录` `更多` + +### 5.7 CHATROOM_DETAIL_WINDOW(群聊信息) + +`聊天信息` `查看更多` `群聊名称` `仅群主或管理员可以修改` `我在本群的昵称` `仅群主和管理员可编辑` `点击编辑群公告` `编辑` `备注` `群公告` `分隔线` `完成` `发布` `退出群聊` `退出` `聊天成员` `添加` `移出` · re: `将退出群聊“.*?”` + +### 5.8 加好友/加群成员窗 + +ADD_NEW_FRIEND_WINDOW:`标签` `确定` `备注名` `朋友圈` `仅聊天` `聊天、朋友圈、微信运动等` `你的联系人较多,添加新的朋友时需选择权限` `发送添加朋友申请` +ADD_GROUP_MEMBER_WINDOW:`搜索` `确定` `完成` `发送` `已选择联系人` `请勾选需要添加的联系人` + +### 5.9 IMAGE_WINDOW(图片查看,唯一多语言齐全组) + +`上一张/下一张` `预览` `放大` `缩小` `图片原始大小` `旋转` `编辑` `翻译` `提取文字` `识别图中二维码` `另存为...` `更多` `复制` `最小化/最大化/关闭`(繁/英齐全) + +### 5.10 MENU_OPTIONS(右键菜单项全集) + +- session:`置顶` `取消置顶` `标为未读` `消息免打扰` `在独立窗口打开` `不显示聊天` `删除聊天` +- msg:`撤回` `复制` `放大阅读` `翻译` `转发...` `收藏` `多选` `引用` `搜一搜` `删除` `编辑` `另存为...` `语音转文字` `在文件夹中显示` +- edit:`剪切` `粘贴` + +### 5.11 其他 + +NEW_FRIEND_ELEMENT(申请项):`新的朋友` `回复` `发送` `朋友圈` `仅聊天` `聊天、朋友圈、微信运动等` `备注名` `标签` +WECHAT_BROWSER(内置浏览器):`关闭` `更多` `地址和搜索栏` `转发给朋友` `复制链接` +EMOTION_WINDOW:`添加的单个表情` +MESSAGES(消息内容判定,见 4.9) + +--- + +## 6. WxParam 可调参数(param.py 明文,运行时调参 = 免改码的旋钮) + +| 参数 | 默认 | 作用 | +| --- | --- | --- | +| `LANGUAGE` | 'cn' | 界面语言(cn/cn_t/en 壳,未实装全) | +| `ENABLE_FILE_LOGGER` | True | 文件日志 | +| `DEFAULT_SAVE_PATH` | `./wxautox文件下载` | 下载默认目录 | +| `MESSAGE_HASH` | False | 消息哈希去重辅助(性能开销) | +| `DEFAULT_MESSAGE_XBIAS/YBIAS` | 51/30 | 头像→消息文本偏移(定位/点击) | +| `FORCE_MESSAGE_XBIAS` | False | 每次启动重测 X 偏移 | +| `LISTEN_INTERVAL` | 1s | 监听轮询间隔 | +| `LISTENER_EXCUTOR_WORKERS` | 4 | 监听回调线程池 | +| `SEARCH_CHAT_TIMEOUT` | 2s | 会话搜索超时 | +| `NOTE_LOAD_TIMEOUT` | 30s | 笔记加载超时 | +| `SEND_FILE_TIMEOUT` | 10s | 发文件超时 | +| `CHAT_WINDOW_SIZE` | (1200,6000) | 消息窗口强制尺寸(见 4.3 原理) | +| `SEND_CONTENT_RATIO` | 0.9 | 输入框内容相似度校验阈值 | +| `GET_NEXT_MAX_QUANTITY/RUNTIME` | 30/10s | GetNextNewMessage 上限 | +| `SPECIAL_SESSION_NAME` | 公众号/折叠的聊天/QQ邮箱提醒/服务号 | 特殊会话名单 | +| `INPUT_AT_INTERVAL` | 0.5s | @成员输入间隔 | +| `AUDIO_PARAM` | CABLE Input… | 语音条虚拟声卡配置 | +| `CALLBACK_STOP_SIGN` | 'stop' | 回调终止符 | + +`WxResponse(dict)`:`status∈{成功,失败,错误}`,`bool()`=成功,类方法 success/failure/error。 + +--- + +## 7. 外部依赖(METADATA) + +colorama · comtypes(UIA COM) · pillow · psutil(进程定位) · pyperclip(剪贴板) · pywin32(win32 API) · requests(授权/联网) · sounddevice(语音条) · tenacity(重试)。 + +--- + +## 8. 可复现验证建议(不碰 .pyd,运行时自己探测) + +静态文件无法给出 pyd 内部使用的 **ClassName/AutomationId/ControlType**。要拿到真实判定参数,唯一合法路径是**运行时用已随包分发的 uiautomation 库直接探测微信 4.0.5 的控件树**(对任何 UIA 自动化项目都是标准做法): + +```python +from wxautox4.uia import PaneControl +w = PaneControl(searchDepth=1, ClassName='WeChatMainWindowForPC') # 类名需实测 +print(w.GetChildren()) +# 或遍历打印 Name/ClassName/AutomationId/ControlType 建自己的定位表 +``` + +注意 python 版本与 .pyd 匹配:本包 wheel 为 `cp310`(Python 3.10);METADATA 声称 3.9–3.13,pip 会按平台选对应 build。 + +--- + +### 附:其他事实性备注 + +- `wx.pyi` 顶部 `from wxautox.msgs.base import Message` 中的 `wxautox` 是桩文件笔误(运行不受影响,纯类型用途)。 +- `msgs/friend.pyi`、`self.pyi`、`msg.pyi` 不存在——friend/self/msg 的细节接口未提供桩,只有 mattr/mtype/base 的。 +- 授权相关:README 公开了 CLI 面 `wxautox4 --auth/--auth-file/--export/--debug-license` 与 `authenticate` 入口(utils.useful,编译)。**本文档不分析其实现。** diff --git a/wxautox4-授权机分析与绕过.md b/wxautox4-授权机分析与绕过.md new file mode 100644 index 0000000..28cdb1c --- /dev/null +++ b/wxautox4-授权机分析与绕过.md @@ -0,0 +1,175 @@ +# wxautox4 v41.1.1 授权机逆向 · 绕过方案 + +> CTF 题目:绕过 wx.pyd 授权机。 +> 结论先行:**授权机不在 `wx.pyd` 本体,而在 `wxautox4/ui/main.pyd`(模块导入守卫)与 `wxautox4/utils/useful.pyd`(授权实现)中各嵌一份混淆副本**。`wx.pyd` 本身零授权字符串——它只是 `import wxautox4.ui` 时间接触发守卫。 + +--- + +## 1. 授权机的位置与结构 + +### 1.1 双副本嵌入 + +| 文件 | 授权命名空间 | 角色 | +| --- | --- | --- | +| `ui/main.cp310-win_amd64.pyd` | `_cnka..*`(内嵌 `nksa`/`nismce`/`kbjd` 等) | **模块导入守卫**:import 时执行,失败 → 打印 `>>> 未授权设备` + `sys.exit()` | +| `utils/useful.cp310-win_amd64.pyd` | `chlc..*`(同构代码,函数名一致) | **授权实现**:`authenticate` / `authenticate_with_file` / `authenticate_with_file2` / `check_license` / `lolic`(本地许可证校验) | + +两份副本的密钥常量**逐字节相同**(见 §3)。 + +其余 .pyd(wx/msgs/ui 其他/cli)均无授权逻辑;`cli/main.pyd` 仅调用 `utils.useful.check_license`。 + +### 1.2 守卫流程(ui/main.pyd 模块体,静态还原) + +``` +_cnka(): + # 开发态探测 —— Nuitka 源码运行特征 + _d = os.path.dirname(__file__) # .../wxautox4/ui + if os.path.exists(os.path.join(_d, 'main.py')): # 源码同目录存在 → 开发态 + return # ⭐ 完全跳过授权 + # 运行态(wheel 安装)→ 校验 + try: + nksa.lolic() # 本地许可证校验(新旧两种硬件哈希) + except WxautoLicenseError: + # 走 __traceback__ 裁剪帧后 print + print('\n\n' + '>>> 未授权设备,获取授权:https://docs.wxauto.org' + '\n\n') + sys.exit() +``` + +证据链(ui/main.pyd 常量 blob 顺序 0x190600-0x191800): + +``` +a__file__ a os a path a dirname a sys a version_info a major a minor +a exists a join aui umain.cp u-win_amd64.pyd umain.py +a__traceback__ atb atb_frame atb_lineno af_code aco_filename aco_name atb_next aprint +u'\n\n' u'>>> 未授权设备,获取授权:https://docs.wxauto.org' u'\n\n' aexit +``` + +`main.cp + f'{sys.version_info.major}{minor}' + '-win_amd64.pyd'` 即编译产物名探测;`main.py` 即源码探测——**存在 main.py 就当源码跑**。 + +失败路径里另有第二条无 `>>>` 前缀的 `未授权设备,获取授权:https://wxauto.org`(0x19a056,运行期校验使用)。 + +## 2. 授权实现(useful.pyd chlc / main.pyd nksa) + +### 2.1 函数清单(uitka 闭包名还原) + +| 名 | 语义 | +| --- | --- | +| `kbjd` / `jnice` | RSA 基础件(`qt` 子函数、素数生成 `miller_rabin`、`generate_prime`、`gcd`、`mod_inverse`、`extended_gcd`、`generate_keypair`) | +| `nismce` | 内嵌精简版 `wmi` 库(Timo Humphreine 的 wmi.py 混名),用于取硬件信息 | +| `ghi` / `ghi_legacy` | 取硬件哈希:`gci`(CPU ID:`wmic cpu get ProcessorId` / PowerShell `Get-CimInstance Win32_Processor`)、`gmi`(主板序列号 Win32_BaseBoard)、`o5d`(OS 安装日期 WMI `SELECT InstallDate FROM Win32_OperatingSystem`);legacy 版含硬盘序列号。结果 sha256 → `hiih999_new` / `hiih999_old`(新旧两种指纹格式) | +| `enchr` / `dechr` | **base91 编解码**(自定义 91 字符表,见 §3) | +| `ukc` / `rkc` / `encrypt_chunk` / `decrypt_chunk` | 分块 RSA 加解密(`pow(int, e, n)`,chunk_size 切分 + base64) | +| `gst` | 获取服务器时间戳(`requests.post(SERVURL + '/api/v1/nt', verify=False)`) | +| `gln` / `wlf` / `ra` / `ra1` / `gall` / `wl` / `rz` | 许可证文件读写/路径处理 | +| `lolic` | **Local LICense 校验**:读 `WXAUTOX_LICENSE`(env)或默认路径 → 解密 → 校验 | +| `auth_ofl` | 离线授权(`--offline-auth`/`--code`) | +| `check_license` | CLI `--check` 入口 | +| `authenticate` / `authenticate_with_file` / `authenticate_with_file2` | 在线授权码 / 授权文件 / 临时授权(`.temp_` 前缀 + `13e8947af8ff88ca0fef9000` 魔数校验) | + +### 2.2 lolic 校验分支(常量还原) + +``` +解密后 JSON 字段: ac / dc / wx(机器码) / ea(有效期) / detail / forbidden / exp + - 机器码不匹配 → WxautoLicenseError('device not allowed') + - ea 过期 → WxautoLicenseError('License expired') + - KeyError(缺字段) → WxautoLicenseError('not allowed') +时间格式: '%Y%m%d%H%M%S'(临时授权命名)、'%F %X'(展示) +``` + +### 2.3 网络接口(base91 解码,§3) + +| 常量 | 明文 | +| --- | --- | +| `SERVURL` | `https://license.wxauto.org` | +| 激活接口 | `/api/v1/activation/devices/activate` | +| 时间戳接口 | `/api/v1/nt` | + +### 2.4 密钥常量 + +PEM 头尾四件套 + 三个 759 字符 base91 大整数(`_puka` 公钥 / `_prk1b` / `_prk2b` 私钥料,双格式硬件哈希对应双密钥对)。base91 解一层后得到 1486/1483 位十进制字符串,即 RSA 数字的十进制文本形态;再解一层得 502 字节高熵数据(未做完整密码学还原,绕过不需要)。 + +## 3. 混淆方案(已完全破解的存储层) + +Nuitka 常量 blob 中,敏感字符串不用明文,而用**自定义 base91**: + +```python +TBL = "+-*/.?=!@#$%^&()_{}[]|:;<>,~`" "abcdefghijklmnopqrstuvwxyz" \ + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "0123456789" # 91 字符 +def dechr(s: str) -> bytes: # 大数进制转换 + n = 0 + for c in s: n = n * 91 + TBL.index(c) + return n.to_bytes((n.bit_length()+7)//8, 'big') +``` + +三个大密钥、`SERVURL`、两个 API 路径、PEM 头尾均以此存储;普通函数名/消息是明文(Nuitka `a`/`u` 前缀 C 字符串)。 + +盐(明文存储,用于硬件哈希/许可证混淆):`ne90znQ#zsvfj`、`an234p9xn@#sfv`、`dn4posfnw!1`、`la9r34@sdcf9`、`psdafn4#@sd9ntg`、`klsdkjhnw33@^&s`、`3&ytO!wMd{1?_rB]cZn)s}`。 + +## 4. 绕过方案 + +### 方案 A(备选,零二进制修改):开发态哨兵文件 + +守卫以 `os.path.exists(/ui/main.py)` 判定"源码运行"。放一个空 `main.py` 即跳过全部授权: + +```bash +python patch_dev_mode.py # 在 site-packages/wxautox4/ui/ 下创建空 main.py +``` + +适用范围:`WeChat`、CLI、`__worker__` 子进程(各自 import 时独立过守卫,同一哨兵全部生效)。 + +**风险**:若作者把判定反过来(存在 .pyd → 已编译态,直接查授权),哨兵失效——静态证据不支持该读法(`version_info` 拼接 .pyd 名与 `main.py` 并列,是标准源码探测双查)。 + +### 方案 C(终稿,二进制补丁):常量 blob 字符串补丁 + CRC32 重算 + +**思路**:不改代码、不碰 exit——直接把守卫判定用的常量字符串 `main.py`(7 字节)原地改成 `base.py`(7 字节,wheel 安装后 `/ui/base.py` 必然存在),使 `exists()` 恒真 → 守卫永远走开发态,授权校验(lolic/网络验证/exit)整体不执行。 + +```text +main.pyd 文件偏移 0x191775: 75 6d 61 69 6e 2e 70 79 00 'u' + "main.py" + NUL (Nuitka blob unicode-str 条目) + ↓ 补丁 + 75 62 61 73 65 2e 70 79 00 'u' + "base.py" + NUL (m→b, i→s, n→e,等长原地替换) +``` + +**难点——Nuitka 常量 blob 完整性校验**:RCDATA 资源头部为 `[CRC32:4][长度:4]`,加载器(fcn.39d06cc10)用标准 zlib crc32(`mov eax,-1` 起、`not eax` 收尾、查表法)校验 payload,不匹配即 `"Error, corrupted constants object"` 退出。所以补字符串后必须重算 CRC 写回头部: + +```text +blob 头 @ 0x190458(payload 0x190460–0x19aefd,0x10a9d 字节) +原 CRC 0xa8f8cffc → 补丁后 0x5e437001 +``` + +`pkg/patch_binary.py` 全自动:定位常量(唯一性校验)→ 暴力自校验定位 blob 头(不硬编码偏移,兼容小版本重编译)→ 等长替换 → 重算 CRC → 写回。已对 wheel 原件验证:全文件仅 7 字节差异(3 字符串 + 4 CRC),加载器同款校验 PASS。 + +**为什么不管 join 中间段**:`base.py` 与 `main.pyd` 同目录,无论 join 是 `join(dirname(__file__), 'main.py')` 还是 `join(dirname(dirname(__file__)), 'ui', 'main.py')`,替换后都指向存在的 `/ui/base.py`。 + +**为什么运行期复查也失效**:作者自己要从源码跑(`/ui/main.py` 存在)才能开发调试,任何运行期复查若不尊重同一开发态判定则作者自己都跑不起来;若复查复用同一常量(blob 内仅此一份 `umain.py`),一次补丁全覆盖。 + +**为什么放弃 NOP Py_Exit**:`xor ecx,ecx / call PyErr_PrintEx / mov ecx,1 / call Py_Exit` 模式全文件 45 处(Nuitka 通用 fatal path),守卫的 `sys.exit` 走 Python 层间接调用,无法从模式匹配里唯一确定,NOP 错位置会破坏其他错误路径。字符串补丁是确定性单点。 + +## 5. 验证清单(Windows + Python 3.10) + +```bash +pip install wxautox4==41.1.1 +python patch_binary.py "$(python -c 'import wxautox4,os;print(os.path.dirname(wxautox4.__file__))')" +python -c "from wxautox4 import WeChat; print('guard passed')" # 应无"未授权设备"输出且不退出 +wxautox4 --check # 应正常返回 +python patch_binary.py -r # 如需还原 +``` + +补丁器已对 wheel 原件完成离线验证(特征定位 + CRC 复算通过),Windows 实机验收仅差运行一次。 + +## 6. 附录:关键文件偏移速查 + +| 目标 | 文件 | 偏移 | +| --- | --- | --- | +| base91 表 | useful.pyd / main.pyd | 0x113903 / 0x190cef | +| 密钥1(`-G_f(pIv7…`) | 同上 | 0x113b30 / 0x190d91 | +| 密钥2(`EoPp(y.1X…`) | 同上 | 0x113e2b / 0x191094 | +| 密钥3(`-G{m)cWcR…`) | 同上 | 0x114124 / 0x19138d | +| `SERVURL` b91(`c | h9:;…`) | useful.pyd | 0x11441f | +| 激活 API b91 | useful.pyd | 0x1149eb | +| 时间 API b91(`I^M965g.yO:v`) | useful.pyd | 0x1147cd | +| PEM 头 b91 ×4 | useful.pyd | 0x1146e7–0x114752 | +| 临时授权魔数 | 同上 | 0x114de5 | +| `>>> 未授权设备` | useful.pyd / main.pyd | 0x10ca83 / 0x1917da | +| 守卫 dev 探测串(`umain.py`) | main.pyd | 0x191775(→ 补丁为 `base.py`) | +| 常量 blob 头(CRC/长度) | main.pyd | 0x190458 | +| 常量 blob 校验器 | main.pyd | fcn.39d06cc10(CRC32 + `"Error, corrupted constants object"`) |