From 0f0417bc27fa21dc97c5ae7342426a27460514b5 Mon Sep 17 00:00:00 2001 From: dm Date: Thu, 30 Apr 2026 22:59:54 +0800 Subject: [PATCH 1/2] [feature] add macOS arm64 (Apple Silicon) support - hook.js: add platform-aware getMainModule (WeChatAppEx Framework), CDPFilter mac branch (retval+8 vs *args[0]+8), OnLoadStart x0/x1 registers - index.ts: mac process discovery via Helper ppid, WMPF version from Info.plist, config file naming as mac.addresses.{version}.json - config: add mac.addresses.17078.json for WeChat 3.8.7 (WMPF 17078) - README: add macOS arm64 support docs (en + zh) --- README.md | 29 ++++++- README.zh.md | 29 ++++++- frida/config/mac.addresses.17078.json | 7 ++ frida/hook.js | 85 +++++++++++++------ src/index.ts | 112 +++++++++++++++++--------- 5 files changed, 194 insertions(+), 68 deletions(-) create mode 100644 frida/config/mac.addresses.17078.json diff --git a/README.md b/README.md index 77a198e..4201e8b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [English](README.md) | [中文](README.zh.md) -Yet another WeChat miniapp debugger on Windows (WMPF). +Yet another WeChat miniapp debugger on Windows / macOS (WMPF). This debugger (tweak) exploits Remote Debug feature provided by wechatdevtools and patches serval restrictions to force miniapp runtime to support full Chrome Debug Protocol, and thus can be directly applied to standard devtools shipped with chromium-based browsers. @@ -10,7 +10,11 @@ This debugger (tweak) exploits Remote Debug feature provided by wechatdevtools a ## Support Status -Version histories: +Version histories (macOS): + +* 17078 / WeChat 3.8.7 (macOS arm64, credit @dm) + +Version histories (Windows): * 19459 (latest, credit @snowflake-x) * 19339 (credit @hidacow) @@ -53,7 +57,7 @@ Version histories: To debug web pages of WeChat embedded browser, please refer to [EXTENSION.md](EXTENSION.md). Note that this feature has many limitations currently and is simply a basic workaround. -To check your installed version, navigate to Task Manager -> WeChatAppEx -> Right click -> Open file location -> Check the number between `RadiumWMPF` and `extracted`. +To check your installed version, navigate to Task Manager -> WeChatAppEx -> Right click -> Open file location -> Check the number between `RadiumWMPF` and `extracted`. On macOS, run `grep CFBundleVersion "/Applications/WeChat.app/Contents/MacOS/WeChatAppEx.app/Contents/Info.plist"` and use the last number in the version string. To adapt to another version, please find the instructions in [ADAPTATION.md](ADAPTATION.md). Alternatively, you can submit an issue for new version adaption and I will try that if I have the binary. Note that only newer version adaption requests will be considered. @@ -62,6 +66,25 @@ To upgrade to the latest WMPF (WeChat version > 4.x), download the latest WeChat To upgrade to the latest WMPF (WeChat version < 4.x), type in `:showcmdwnd` in the search bar (do not hit enter), then the command window should pop up. Type in `/plugin set_grayvalue=202&check_update_force` and hit enter, the latest WMPF plugin should be downloaded, if any updates are available. Restart the WeChat to apply plugin upgrade. + +## macOS arm64 Support + +macOS arm64 (Apple Silicon) is now supported. The implementation is based on the same WMPF internals as Windows, with platform-specific adaptations: + +- Module name: `WeChatAppEx Framework` (instead of `flue.dll` on Windows) +- Register conventions: `x0` / `x1` (instead of `rcx` / `rdx` on Windows x64) +- CDPFilter: patched at `retval+8` (instead of `*args[0]+8` on Windows) +- Config file: `mac.addresses.{version}.json` (e.g. `mac.addresses.17078.json`) + +**Check WMPF version on macOS:** + +```bash +grep CFBundleVersion "/Applications/WeChat.app/Contents/MacOS/WeChatAppEx.app/Contents/Info.plist" +# Look for the last number in the version string, e.g. 6.17078 → 17078 +``` + +For new version adaptation on macOS, you can use the Frida auto-detection script at `frida/detect_offsets.js` to find hook offsets automatically. + ## Prerequisites * node.js (requires at least LTS v22) diff --git a/README.zh.md b/README.zh.md index 9e691c3..58f3b0b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,13 +1,17 @@ # WMPFDebugger -又一个 Windows 微信小程序调试工具 +又一个 Windows / macOS 微信小程序调试工具 这个工具通过 patch 一些 Chrome 调试协议(CDP)的过滤器和其他的条件判断来强制小程序连接到外部调试器(也就是远程调试,LanDebug 模式)。这个调试协议是基于 protobuf 实现的私有协议,通过逆向开发者工具提取相应的协议实现,该工具实现了一个简单的小程序调试协议转换为标准 Chrome 调试协议,从而允许我们使用标准基于 chromium 浏览器的内嵌开发者工具来调试任意小程序 ## 支持状态 -支持的 WMPF 版本: +支持的 WMPF 版本 (macOS): + +* 17078 / 微信 3.8.7 (macOS arm64, credit @dm) + +支持的 WMPF 版本 (Windows): * 19459 (最新, credit @snowflake-x) * 19339 (credit @hidacow) @@ -50,7 +54,7 @@ 如何调试微信内置浏览器页面:参见 [EXTENSION.md](EXTENSION.md)。注意,目前该方法仅有基础调试功能 -如何检查版本:打开任务管理器,找到 WeChatAppEx 进程,右键,打开文件所在的位置,检查在 `RadiumWMPF` 和 `extracted` 之间的数字 +如何检查版本:打开任务管理器,找到 WeChatAppEx 进程,右键,打开文件所在的位置,检查在 `RadiumWMPF` 和 `extracted` 之间的数字。macOS 上执行 `grep CFBundleVersion "/Applications/WeChat.app/Contents/MacOS/WeChatAppEx.app/Contents/Info.plist"`,取版本字符串中最后一个数字 如何适配到其他版本:参见 [ADAPTATION.md](ADAPTATION.md)。另外,你也可以提交版本适配的 Issue,我会尝试适配该版本如果我有相应的版本的 binary。仅更新版本的适配请求会被考虑 @@ -59,6 +63,25 @@ 如何更新到最新的 WMPF 版本(微信版本 < 4.x):搜索框输入 `:showcmdwnd`(不要按回车触发搜索)弹出命令窗口,输入 `/plugin set_grayvalue=202&check_update_force` 并回车等待更新(如果有新版本)。重启微信以生效。 +## macOS arm64 支持 + +现已支持 macOS arm64 (Apple Silicon)。实现基于与 Windows 相同的 WMPF 内部机制,针对平台差异做了适配: + +- 模块名:`WeChatAppEx Framework`(对应 Windows 的 `flue.dll`) +- 寄存器约定:`x0` / `x1`(对应 Windows x64 的 `rcx` / `rdx`) +- CDPFilter:修补 `retval+8`(对应 Windows 的 `*args[0]+8`) +- 配置文件:`mac.addresses.{version}.json`(如 `mac.addresses.17078.json`) + +**如何在 macOS 上检查 WMPF 版本:** + +```bash +grep CFBundleVersion "/Applications/WeChat.app/Contents/MacOS/WeChatAppEx.app/Contents/Info.plist" +# 查看版本字符串中的最后一个数字,如 6.17078 → 17078 +``` + +适配新版本:可使用 `frida/detect_offsets.js` 自动探测 hook 偏移量。 + + ## 准备 * node.js (需要至少 LTS v22) diff --git a/frida/config/mac.addresses.17078.json b/frida/config/mac.addresses.17078.json new file mode 100644 index 0000000..2e05ecf --- /dev/null +++ b/frida/config/mac.addresses.17078.json @@ -0,0 +1,7 @@ +{ + "Version": 17078, + "Platform": "mac", + "LoadStartHookOffset": "0x4F0620C", + "CDPFilterHookOffset": "0x81BFC04", + "SceneOffsets": [1432, 1376, 488] +} diff --git a/frida/hook.js b/frida/hook.js index 7ab3ef2..061c187 100644 --- a/frida/hook.js +++ b/frida/hook.js @@ -1,4 +1,7 @@ -const getMainModule = (version) => { +const getMainModule = (version, platform) => { + if (platform === "mac") { + return Process.findModuleByName("WeChatAppEx Framework"); + } if (version >= 13331) { return Process.findModuleByName("flue.dll"); } @@ -7,28 +10,45 @@ const getMainModule = (version) => { const patchCDPFilter = (base, config) => { // xref: SendToClientFilter OR devtools_message_filter_applet_webview.cc + const isMac = config.Platform === "mac"; const offset = config.CDPFilterHookOffset; Interceptor.attach(base.add(offset), { onEnter(args) { - send( - `[patch] CDP filter on enter, original value of input: ${args[0].readPointer()}`, - ); - this.inputValue = args[0]; + if (!isMac) { + // x64 windows: save args[0] for use in onLeave + send( + `[patch] CDP filter on enter, original value of input: ${args[0].readPointer()}`, + ); + this.inputValue = args[0]; + } }, onLeave(retval) { - const inputValue = this.inputValue.readPointer(); - if (inputValue.isNull() || inputValue.add(8).isNull()) { - // there's a chance the value could be null - // return here to avoid crash - return; - } - - send( - `[patch] CDP filter on leave, patch input, now value: ${inputValue}; ` + - `*(input + 8) = ${inputValue.add(8).readU32()}`, - ); - if (inputValue.add(8).readU32() == 6) { - inputValue.add(8).writeU32(0x0); + if (isMac) { + // arm64 mac: caller checks retval+8 == 6, patch it to 0 + if (retval.isNull()) return; + try { + const val = retval.add(8).readU32(); + send(`[patch] CDP filter on leave (mac), retval+8 = ${val}`); + if (val === 6) { + retval.add(8).writeU32(0x0); + send("[patch] CDP filter patched (mac)"); + } + } catch (e) { + send(`[patch] CDP filter error: ${e}`); + } + } else { + // x64 windows: patch *args[0]+8 + const inputValue = this.inputValue.readPointer(); + if (inputValue.isNull() || inputValue.add(8).isNull()) { + return; + } + send( + `[patch] CDP filter on leave, patch input, now value: ${inputValue}; ` + + `*(input + 8) = ${inputValue.add(8).readU32()}`, + ); + if (inputValue.add(8).readU32() == 6) { + inputValue.add(8).writeU32(0x0); + } } }, }); @@ -81,18 +101,29 @@ const hookOnLoadScene = (a1, sceneOffsets) => { const patchOnLoadStart = (base, config) => { // xref: AppletIndexContainer::OnLoadStart + const isMac = config.Platform === "mac"; Interceptor.attach(base.add(config.LoadStartHookOffset), { onEnter(args) { + // arm64 (mac): x0=this, x1=debug_flag + // x64 (windows): rcx=this, rdx=debug_flag + const thisPtr = isMac ? this.context.x0 : this.context.rcx; send( `[inteceptor] AppletIndexContainer::OnLoadStart onEnter, ` + - `indexContainer.this: ${this.context.rcx}`, + `indexContainer.this: ${thisPtr}`, ); - // write dl to 0x1 - if ((this.context.rdx & 0xff) !== 1) { - this.context.rdx = (this.context.rdx & ~0xff) | 0x1; + if (isMac) { + // arm64: set x1 (debug flag) to 1 + if ((this.context.x1.toInt32() & 0xff) !== 1) { + this.context.x1 = ptr(1); + } + } else { + // x64: write dl to 0x1 + if ((this.context.rdx & 0xff) !== 1) { + this.context.rdx = (this.context.rdx & ~0xff) | 0x1; + } } // handle onLoad scene - hookOnLoadScene(this.context.rcx, config.SceneOffsets); + hookOnLoadScene(thisPtr, config.SceneOffsets); }, onLeave(retval) { // do nothing @@ -116,9 +147,13 @@ const parseConfig = () => { const main = () => { const config = parseConfig(); - const mainModule = getMainModule(config.Version); + const mainModule = getMainModule(config.Version, config.Platform); patchOnLoadStart(mainModule.base, config); - patchCDPFilter(mainModule.base, config); + if (!config.DisableCDPFilter) { + patchCDPFilter(mainModule.base, config); + } else { + send("[info] CDPFilter hook disabled"); + } }; main(); diff --git a/src/index.ts b/src/index.ts index 5c391a7..1b82d37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { promises } from "node:fs"; +import { execSync } from "node:child_process"; import { EventEmitter } from "node:events"; import path from "node:path"; import * as frida from "frida"; @@ -137,42 +138,83 @@ const proxy_server = (options: CliOptions, logger: Logger) => { }; const frida_server = async (options: CliOptions, logger: Logger) => { + const isMac = require("node:os").platform() === "darwin"; const localDevice = await frida.getLocalDevice(); const processes = await localDevice.enumerateProcesses({ scope: frida.Scope.Metadata, }); - const wmpfProcesses = processes.filter( - (process) => process.name === "WeChatAppEx.exe", - ); - const wmpfPids = wmpfProcesses.map((p) => - p.parameters.ppid ? p.parameters.ppid : 0, - ); - // find the parent process - const wmpfPid = wmpfPids - .sort( - (a, b) => - wmpfPids.filter((v) => v === a).length - - wmpfPids.filter((v) => v === b).length, - ) - .pop(); - if (wmpfPid === undefined) { - throw new Error("[frida] WeChatAppEx.exe process not found"); - return; + let wmpfPid: number | undefined; + + if (isMac) { + // mac: enumerateProcesses can't see WeChatAppEx main process due to sandbox + // find it via ppid of WeChatAppEx Helper processes + const helperProcess = processes.find( + (p) => p.name === "WeChatAppEx Helper" && p.parameters.ppid, + ); + if (!helperProcess) { + throw new Error("[frida] WeChatAppEx Helper process not found (mac)"); + } + wmpfPid = helperProcess.parameters.ppid as number; + logger.info(`[frida] mac: found WeChatAppEx pid=${wmpfPid} (via Helper ppid)`); + } else { + // windows: find parent of WeChatAppEx.exe + const wmpfProcesses = processes.filter( + (process) => process.name === "WeChatAppEx.exe", + ); + const wmpfPids = wmpfProcesses.map((p) => + p.parameters.ppid ? (p.parameters.ppid as number) : 0, + ); + wmpfPid = wmpfPids + .sort( + (a, b) => + wmpfPids.filter((v) => v === a).length - + wmpfPids.filter((v) => v === b).length, + ) + .pop(); + if (wmpfPid === undefined) { + throw new Error("[frida] WeChatAppEx.exe process not found"); + } } - const wmpfProcess = processes.filter( - (process) => process.pid === wmpfPid, - )[0]; - const wmpfProcessPath = wmpfProcess.parameters.path as string | undefined; - const wmpfVersionMatch = wmpfProcessPath - ? wmpfProcessPath.match(/\d+/g) - : ""; - const wmpfVersion = wmpfVersionMatch - ? new Number(wmpfVersionMatch.pop()) - : 0; - if (wmpfVersion === 0) { - throw new Error("[frida] error in find wmpf version"); - return; + + const wmpfProcess = processes.find((p) => p.pid === wmpfPid); + const wmpfProcessPath = wmpfProcess?.parameters.path as string | undefined; + + // determine config filename + let configFileName: string; + if (isMac) { + // mac: extract WMPF version from Info.plist + // CFBundleVersion format: "6.17078" → 17078 + const plistPath = + "/Applications/WeChat.app/Contents/MacOS/WeChatAppEx.app/Contents/Info.plist"; + let wmpfVersion = 0; + try { + const plistRaw = execSync( + `grep -A1 CFBundleVersion "${plistPath}" | grep string`, + ).toString(); + const versionMatch = plistRaw.match(/>([^<]*\.(\d+)) { try { configContent = ( await promises.readFile( - path.join( - projectRoot, - "frida/config", - `addresses.${wmpfVersion}.json`, - ), + path.join(projectRoot, "frida/config", configFileName), ) ).toString(); configContent = JSON.stringify(JSON.parse(configContent)); } catch (e) { - throw new Error(`[frida] version config not found: ${wmpfVersion}`); + throw new Error(`[frida] version config not found: ${configFileName}`); } if (scriptContent === null || configContent === null) { @@ -232,7 +270,7 @@ const frida_server = async (options: CliOptions, logger: Logger) => { }); await script.load(); logger.info( - `[frida] script loaded, WMPF version: ${wmpfVersion}, pid: ${wmpfPid}`, + `[frida] script loaded, config: ${configFileName}, pid: ${wmpfPid}`, ); logger.info(`[frida] you can now open any miniapps`); }; From 46ae180adc9b8271fe4adb66a9edca5815370a20 Mon Sep 17 00:00:00 2001 From: dm Date: Wed, 3 Jun 2026 15:14:18 +0800 Subject: [PATCH 2/2] [fix] handle SIGINT for graceful shutdown WebSocketServer and Frida session keep the event loop alive, causing Ctrl+C to be ignored. Register a SIGINT handler that closes both WebSocket servers and detaches the Frida session before exiting. Also remove unreachable return statements after throw that caused TypeScript errors. --- src/index.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1b82d37..1dfc4c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ const bufferToHexString = (buffer: ArrayBuffer) => { .join(""); }; -const debug_server = (options: CliOptions, logger: Logger) => { +const debug_server = (options: CliOptions, logger: Logger): WebSocketServer => { const wss = new WebSocketServer({ port: options.debugPort }); logger.info( `[server] debug server running on ws://localhost:${options.debugPort}`, @@ -100,9 +100,11 @@ const debug_server = (options: CliOptions, logger: Logger) => { } }); }); + + return wss; }; -const proxy_server = (options: CliOptions, logger: Logger) => { +const proxy_server = (options: CliOptions, logger: Logger): WebSocketServer => { const wss = new WebSocketServer({ port: options.cdpPort }); logger.info( `[server] proxy server running on ws://localhost:${options.cdpPort}`, @@ -135,9 +137,11 @@ const proxy_server = (options: CliOptions, logger: Logger) => { } }); }); + + return wss; }; -const frida_server = async (options: CliOptions, logger: Logger) => { +const frida_server = async (options: CliOptions, logger: Logger): Promise => { const isMac = require("node:os").platform() === "darwin"; const localDevice = await frida.getLocalDevice(); const processes = await localDevice.enumerateProcesses({ @@ -236,7 +240,6 @@ const frida_server = async (options: CliOptions, logger: Logger) => { ).toString(); } catch (e) { throw new Error("[frida] hook script not found"); - return; } let configContent: string | null = null; @@ -253,7 +256,6 @@ const frida_server = async (options: CliOptions, logger: Logger) => { if (scriptContent === null || configContent === null) { throw new Error("[frida] unable to find hook script"); - return; } // load script @@ -273,14 +275,24 @@ const frida_server = async (options: CliOptions, logger: Logger) => { `[frida] script loaded, config: ${configFileName}, pid: ${wmpfPid}`, ); logger.info(`[frida] you can now open any miniapps`); + + return session; }; const main = async () => { const options = parse_cli_options(); const logger = create_logger(options); - debug_server(options, logger); - proxy_server(options, logger); - frida_server(options, logger); + const debugWss = debug_server(options, logger); + const proxyWss = proxy_server(options, logger); + const session = await frida_server(options, logger); + + process.on("SIGINT", async () => { + logger.info("[server] shutting down..."); + debugWss.close(); + proxyWss.close(); + await session.detach(); + process.exit(0); + }); }; (async () => {