banner
约 3,900 字
13 分钟

RevokeMsgPatcher 核心机制深度解析:防撤回与多开原理

目录#

  1. [整体架构概述](#1-整体架构概述)

  2. [防撤回技术原理](#2-防撤回技术原理)

  • 2.1 问题本质:DLL 字节级覆盖

  • 2.2 双轨补丁匹配策略

  • 2.3 特征码搜索算法

  • 2.4 模糊匹配实现细节

  • 2.5 补丁执行与状态检测

  • 2.6 撤回功能阻断机制(目标行为)

  1. [多开技术原理](#3-多开技术原理)

  • 3.1 Windows 互斥对象机制

  • 3.2 HANDLE 枚举与查询

  • 3.3 互斥句柄的精确识别与关闭

  • 3.4 Timer 驱动的主动清理模式

  1. [数据模型详解](#4-数据模型详解)

  2. [关键异常处理与边界条件](#5-关键异常处理与边界条件)

  3. [两个模块的代码路径对比](#6-两个模块的代码路径对比)


1. 整体架构概述#

RevokeMsgPatcher 由两个独立但互补的程序集组成:

程序集

用途

核心技术

RevokeMsgPatcher

防撤回补丁注入

PE DLL 字节级修改(精准 SHA1 + 模糊特征码)

RevokeMsgPatcher.MultiInstance

多开工具

Windows HANDLE 级别操作(NtQuerySystemInformation + DuplicateHandle)

二者作用层次完全不同:

  • 防撤回:在进程外部,通过修改磁盘上的 .dll 二进制文件来实现,需要目标应用处于**关闭状态**。

  • 多开:在进程运行期间,通过操作系统内核对象实现,目标应用**必须正在运行**。


2. 防撤回技术原理#

2.1 问题本质:DLL 字节级覆盖#

微信/QQ/TIM 等应用在收到"撤回消息"指令后,会在客户端侧执行以下逻辑链:

纯文本
服务端推送 RecallMsg 事件
    → 调用本地 UI 层的消息删除函数
    → 从会话列表中移除该消息条目
    → 清空显示区域中对应消息气泡

防撤回的本质是:找到控制这条逻辑链的关键函数调用点,将其目标指令替换为 NOP(空操作)或跳转指令,使函数执行时无害返回。

由于这些应用程序每次更新都会重新编译 DLL,内存中的函数偏移地址会变化,因此无法硬编码偏移量。RevokeMsgPatcher 采用的是运行时不变的字节序列特征(opcode pattern)来定位目标位置——这组 opcode 在执行相同逻辑功能的函数中保持稳定,即使应用升级也会保留。

2.2 双轨补丁匹配策略#

AppModifier.ValidateAndFindModifyInfo() 中的匹配流程是一个严格的多级决策树:

纯文本
输入:FileHexEditor(已计算好 FilePath、FileVersion、FileSHA1)
      + categories(用户勾选的功能类别列表)
输出:TargetChanges(List<Change>,每条包含 Position + Content)

步骤 1:SHA1 精准匹配
├── FileSHA1 == ModifyInfo.SHA1Before → 使用 FileModifyInfo.Changes(精准补丁)
├── FileSHA1 == ModifyInfo.SHA1After  → 抛出 BusinessException("installed") 报错:"你已经安装过此补丁"
└── 均不匹配 → 进入步骤 2

步骤 2:版本范围 + 特征码匹配
├── 找到 CommonModifyInfo 满足 StartVersion < FileVersion <= EndVersion
├── 用 ReplacePatterns 在文件中搜索所有匹配位置
├── 对每个 matchIndex:
│   ├── IsEqual(matchIndex, ReplacePattern.Replace) == false
│   │   → 加入 TargetChanges(需要执行替换)
│   └── IsEqual(...) == true
│       → 跳过(该位置已是替换后的状态)
└── 验证 matchNum == ReplacePatterns.Count,否则报错

为何有两种策略?

策略

适用场景

优点

缺点

SHA1 精准匹配

已知确切版本的 DLL

零误判、结果确定

需维护大量版本的 SHA1 记录

特征码模糊匹配

版本跨越但逻辑函数未变

覆盖版本区间

可能有多个匹配点,需额外校验

2.3 特征码搜索算法:Boyer-Moore#

BoyerMooreMatcher.MatchAll() 实现了经典的 **Boyer-Moore 字符串匹配算法**,用于在二进制文件(DLL 的字节数组)中高效查找所有符合 Search 模式的偏移位置。

预处理阶段(扫描模式串一次)#

Bad Character Heuristic(坏字符规则):

纯文本
int[] badCharShifts = new int[256]; // 256 个可能的 byte 值
for (int i = 0; i < m; i++) {
    badCharShifts[pattern[i]] = m - 1 - i; // 记录每个字符最后一次出现的位置距末尾的距离
}

含义:当文本中某个字符与模式串不匹配时,将模式串向右移动,直到坏字符与模式串中最右侧的同字符对齐。若该字符在模式串中不存在,则移动 m 位(整个模式长度)。

Good Suffix Heuristic(好后缀规则):

纯文本
// 构建 suffixLengthArray[i] = 以 i 结尾的最长公共后缀长度
// 再用它推导 goodSuffixShifts[i]

含义:当已经成功匹配了模式串的后缀部分后发生失配,利用已匹配的好后缀信息来决定最大安全移位距离,避免遗漏可能的匹配位置。

匹配阶段(滑动窗口)#

纯文本
while (s <= n - m) {
    j = m - 1;
    while (j >= 0 && pattern[j] == text[s + j])
        j--;
    
    if (j < 0) {
        // 完全匹配,记录位置 s
        shiftIndexes[c++] = s;
        s += goodSuffixShifts[0]; // 继续搜索下一个匹配
    } else {
        // 失配,取两种启发式规则的最大移位
        s += Max(goodSuffixShifts[j], 
                 badCharShifts[text[s + j]] - (m - 1) + j);
    }
}

关键在于每次失配都取 max(坏字符移位, 好后缀移位) —— 这是 Boyer-Moore 的标准优化:两条规则独立计算,取较大值可以跳得更远而不遗漏匹配。

时间复杂度:最坏情况 $O(nm)$,平均情况 $O(n/m)$,对于典型的短特征码(几十字节)在大 DLL(几 MB)中搜索效率极高。

2.4 模糊匹配实现细节:FuzzyMatcher.IsEqual()#

IsEqual() 用于判断某段文件内容是否"已经等于目标替换值",支持 通配符匹配:

纯文本
pattern:   [0xCC, 0x00, 0xCC, 0xFF]   // 0xCC 表示通配符(任意字节)
content:   [0xCC, 0x01, 0xCC, 0x8A]
结果:      匹配(通配符位置的任意字节均视为相等)

这使得特征码定义时可以忽略不相关的寄存器值/立即数,只关注核心 opcode 序列。

在 ModifyFinder.FindChanges() 中,对每个匹配位置执行如下判断:

纯文本
foreach (ReplacePattern pattern in replacePatterns) {
    int[] matchIndexs = FuzzyMatcher.MatchAll(fileByteArray, pattern.Search);
    
    foreach (int idx in matchIndexs) {
        // 仅当当前位置的字节 ≠ 目标替换字节时,才记录为需要执行的变更
        if (!FuzzyMatcher.IsEqual(fileByteArray, idx, pattern.Replace)) {
            changes.Add(new Change(idx, pattern.Replace));
        }
    }
}

这一步至关重要:防止重复打补丁时产生破坏性写入。如果某位置已经是正确的替换值,就不再做无意义的写操作。

2.5 补丁执行与状态检测#

执行流程(AppModifier.Patch())#

纯文本
1. 遍历所有 editors,调用 editor.Backup()
   → 复制原 DLL → DLL.h.bak
   → 若备份已存在且版本不同,覆盖备份
   
2. 遍历所有 editors,调用 editor.Patch()
   → FileUtil.EditMultiHex(FilePath, TargetChanges)
   → 按偏移量顺序依次写入替换字节(需保证偏移不重叠)
   
3. try/catch 保护:
   → 任一 editor 失败 → 对所有已成功 editor 调用 Restore()
   → AfterPatchFail() 钩子

状态检测(补丁已安装的判定)#

ValidateAndFindModifyInfo() 中存在三种状态检测:

状态

触发条件

处理方式

installed

FileSHA1 == SHA1After

直接抛异常,告知用户已安装

match_already_replace

FindChanges 返回空 list(全部 IsEqual 为 true)

抛异常,告知已安装

match_part_replace

matchNum > 0 但 changes.Count == 0(部分位置已被替换)

抛异常,提示可能与其他补丁冲突

match_inconformity

matchNum < replacePatterns.Count 且 none replaced

提示特征码数量不匹配,可能是最新版本

卸载还原(Restore())#

纯文本
BackupExists() 检查所有 .h.bak 存在且版本号一致
    → 逐个 File.Copy(backupPath, originalPath, overwrite=true)
    → 若版本不一致,弹对话框让用户确认

2.6 撤回功能阻断机制(目标行为)#

当补丁成功后,目标 DLL 中原本控制撤回行为的函数体被改写。以微信的 WeChatWin.dll 为例,典型的目标函数伪代码:

原始逻辑(有撤回):

纯文本
; 某函数处理消息回调
mov     eax, [esp+8]      ; 加载消息类型
cmp     eax, REVOKE_MSG_TYPE
je      @handle_revoke
; ... 其他消息处理
@handle_revoke:
    call    DeleteMessageFromSession  ; 从会话中删除消息
    call    RefreshChatUI             ; 刷新界面
    ret

补丁后逻辑(防撤回):

纯文本
; 同一位置被替换为 NOP 或直接 ret
nop
nop
nop
nop
nop
nop
nop
nop
ret          ; 直接返回,不走撤回处理分支

或者更常见的做法是将 je(条件跳转)替换为 jne 或无条件跳过整个撤回处理块。具体字节替换模式取决于 DLL 的实际汇编布局,由 patch.json 中定义的 ReplacePattern.Search 和 ReplacePattern.Replace 精确描述。


3. 多开技术原理#

3.1 Windows 互斥对象机制#

Windows 提供内核级同步原语——互斥体(Mutant / Mutex),用于确保某资源在任意时刻只被一个进程访问。

微信实现单实例限制的逻辑如下:

纯文本
WeChat.exe 启动
    → OpenMutex("_WeChat_App_Instance_Identity_Mutex_Name", ...)
    → 若返回非 NULL 句柄 → 已有实例在运行 → 本进程退出或切换到已有实例
    → 若返回 NULL → 无实例 → CreateMutex() 占有互斥体 → 正常启动

关键事实:互斥对象属于系统全局命名空间(\Sessions\X\BaseNamedObjects\),其句柄在进程内部持有,但从外部无法直接感知哪个句柄属于哪个互斥对象。

3.2 HANDLE 枚举与查询#

ProcessUtil.GetHandles() 通过调用 Windows 内部未导出 API NtQuerySystemInformation 来获取系统级句柄表:

纯文本
// InformationClass = 0x10 (SystemHandleInformation)
NtQuerySystemInformation(CNST_SYSTEM_HANDLE_INFORMATION, ptrHandleData, size, ref length);

返回值是一个 SYSTEM_HANDLE_INFORMATION 数组,每条记录包含:

字段

说明

ProcessID

持有该句柄的进程 ID

Handle

句柄值(进程虚拟地址空间内的索引)

Object_Pointer

内核对象地址(不可直接解引用)

AccessMask

访问权限掩码

由于句柄是进程地址空间内的虚拟索引,要访问它需要先通过 DuplicateHandle() 将其复制到当前进程的句柄表:

纯文本
OpenProcess(PROCESS_ACCESS_FLAGS.DupHandle | PROCESS_ACCESS_FLAGS.VMRead, false, processId)
    → DuplicateHandle(srcHandle, targetHandle, GetCurrentProcess(), out ipHandle, ...)

然后对复制出的句柄调用 NtQueryObject(InformationClass = ObjectNameInformation = 1)获取对象的名称字符串。

3.3 互斥句柄的精确识别与关闭#

FindAndCloseWeChatMutexHandle() 的核心流程:

纯文本
1. DuplicateHandle → 将目标句柄复制到当前进程
2. NtQueryObject(ObjectNameInformation) → 获取对象名称
   → 典型名称:\Sessions\1\BaseNamedObjects\_WeChat_App_Instance_Identity_Mutex_Name
3. 字符串包含 "_Instance_Identity_Mutex_Name" → 确认为微信实例互斥体
4. 再次 DuplicateHandle (DUPLICATE_CLOSE_SOURCE) → 关闭源进程的该句柄
5. CloseHandle(mHandle) → 关闭当前进程中的副本

DUPLICATE_CLOSE_SOURCE 是关键标志:它让 DuplicateHandle 在执行时直接关闭源进程的句柄,而不是真正"复制"。这相当于向内核发送一个"关闭此句柄"的请求,而无需进程自身的参与。

3.4 Timer 驱动的主动清理模式#

FormMultiInstance 采用 Timer(Windows Forms System.Windows.Forms.Timer)周期性轮询:

纯文本
mutexHandleCloseTimer.Tick 每约 500ms 触发一次

每次 Tick:
  1. Process.GetProcessesByName("WeChat") → 获取所有微信进程
  2. 与已有 wechatProcesses 列表比对,发现新进程则追加
  3. 遍历列表中 MutexClosed == false 的进程:
     → ProcessUtil.CloseMutexHandle(process)
     → 成功则标记 MutexClosed = true
  4. 移除已退出的进程(HasExited == true)
  5. 更新 lblProcNum 显示当前存活微信数量

用户点击【启动多开】即开始 Timer,此后每当有新的微信进程启动(或由用户手动启动第二个实例),Timer 检测到该进程尚未关闭互斥句柄,便立即执行关闭操作,使其能通过微信的单实例检查。

手动模式按钮:

按钮

操作

关闭所有微信进程

Process.Kill() 终止所有 WeChat 进程

清理所有微信互斥句柄

一次性对当前所有 WeChat 进程执行 CloseMutexHandle


4. 数据模型详解#

4.1 补丁配置模型#

补丁信息以 JSON 形式内嵌于程序资源或从远程服务器动态获取,结构如下:

纯文本
{
  "LatestVersion": "2.1",
  "PatchVersion": 10,
  "Apps": {
    "Wechat": {
      "Name": "Wechat",
      "FileTargetInfos": {
        "key": {
          "Name": "WeChatWin.dll",
          "AbsolutePath": "xxx\\WeChatApp.exe", // 备用
          "StartVersion": "",
          "EndVersion": ""
        }
      },
      "FileModifyInfos": {
        "WeChatWin.dll": [
          {
            "Name": "WeChatWin.dll",
            "Version": "3.9.10.27",
            "SHA1Before": "abc123...",
            "SHA1After":  "def456...",
            "Changes": [
              { "Offset": 0x12345, "Original": [0xCC, 0x00], "Replacement": [0x90, 0x90] }
            ]
          }
        ]
      },
      "FileCommonModifyInfos": {
        "WeChatWin.dll": [
          {
            "Name": "通用特征补丁",
            "StartVersion": "3.9.0.0",
            "EndVersion": "3.9.99.99",
            "ReplacePatterns": [
              {
                "Search": [0xE8, 0x00, 0x00, 0x00, 0x00, 0x8B, 0x4D, 0xFC],
                "Replace": [0xC3, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90],
                "Category": "防撤回",
                "Tips": "阻止消息被撤回",
                "SimilarCategories": "防撤回(老),防撤回带提示(新)"
              }
            ]
          }
        ]
      }
    }
  }
}

关键字段含义:

  • SHA1Before / SHA1After:补丁前/补丁后的文件 SHA1 哈希,用于精确身份验证。

  • Changes[].Offset:在 DLL 中的字节偏移量(十六进制)。

  • ReplacePatterns[].Search:特征码(支持通配符 0x00 匹配任意字节)。

  • ReplacePatterns[].Replace:替换为目标字节序列。

  • Category:功能类别标签("防撤回"、"多开"等),用于 UI 多选框筛选。

  • SimilarCategories:同类互斥标签,防止用户同时勾选互斥功能。

4.2 变更对象模型#

纯文本
public class Change {
    public long Position;     // 文件内偏移(long,因为 DLL 可能超过 2GB)
    public byte[] Content;    // 要写入的字节序列
}

FileUtil.EditMultiHex() 将所有 Change 按 Position 升序排序后,依次对文件进行 FileStream.Seek + Write 操作,不使用临时文件,直接原地修改。


5. 关键异常处理与边界条件#

5.1 文件被占用(IOException)#

当目标 DLL 被微信/QQ 进程锁定时,File.ReadAllBytes() 或 File.Copy() 会抛出 IOException。此时必须:

  • 确保目标应用已完全关闭

  • 以管理员权限运行补丁工具

  • 部分情况下需要进入安全模式或通过外部工具解锁句柄

5.2 版本范围边界#

IsInVersionRange() 使用字符串比较版本(而非数值比较),存在边界情况:

纯文本
StartVersion = "3.9.0", EndVersion = "3.9.99"
FileVersion = "3.9.100" → 字符串比较 "3.9.100" > "3.9.99",超出范围

这是版本字符串比较的经典缺陷,但在实际应用中影响较小,因为版本号通常保持相同的位数格式。

5.3 特征码多匹配位置#

同一特征码在 DLL 中可能出现多次(例如同一个函数被内联展开到多个位置)。FindChanges() 会对**所有匹配位置**都执行替换,这意味着如果某些匹配位置不应被修改,会导致不可预期的行为。这也是为什么 ReplacePattern.Tips 和 SimilarCategories 字段存在——用于在 UI 层引导用户做出正确选择。

5.4 多开工具的进程生命周期管理#

wechatProcesses 列表在 Timer 运行时持续累积,只有当进程 HasExited == true 时才被移除。若用户手动终止了某个微信进程但 Timer 未检测到(极端情况),该条目会永久残留,直到工具重启。


6. 两个模块的代码路径对比#

6.1 防撤回模块主路径#

纯文本
FormMain.btnPatch_Click()
  ├─ InitEditors(txtPath.Text)               // 创建 FileHexEditor 列表
  ├─ SetVersionLabelAndCategoryCategories()  // 确定支持的功能类别
  ├─ ValidateAndFindModifyInfo(categories)   // 匹配补丁信息
  │    ├─ SHA1 精准匹配 → 直接使用 FileModifyInfo.Changes
  │    └─ 版本范围匹配 → BoyerMooreMatcher.MatchAll() + FuzzyMatcher.IsEqual()
  ├─ Patch()                                 // 备份 + 写入
  │    ├─ editor.Backup()                    // 创建 .h.bak
  │    └─ editor.Patch()                     // FileUtil.EditMultiHex()
  └─ 异常处理 → Restore() 回滚              // 从 .h.bak 恢复

6.2 多开模块主路径#

纯文本
FormMultiInstance.btnStartTimer_Click()
  └─ mutexHandleCloseTimer.Start()           // 启动定时轮询
       └─ Timer.Tick()
            ├─ Process.GetProcessesByName("WeChat")
            ├─ 增量添加新进程到 wechatProcesses
            └─ 遍历未关闭互斥句柄的进程
                 └─ ProcessUtil.CloseMutexHandle(process)
                      ├─ GetHandles(process)                 // NtQuerySystemInformation
                      └─ FindAndCloseWeChatMutexHandle()
                           ├─ DuplicateHandle (读取)
                           ├─ NtQueryObject (获取名称)
                           └─ DuplicateHandle(DUPLICATE_CLOSE_SOURCE)

附录:关键 API 对照表#

Windows API

用途

所在模块

NtQuerySystemInformation(0x10)

枚举系统级 HANDLE 表

MultiInstance

NtQueryObject(ObjectNameInformation)

查询内核对象名称

MultiInstance

OpenProcess(DupHandle \| VMRead)

打开目标进程以复制句柄

MultiInstance

DuplicateHandle(src→self, CLOSE_SOURCE)

关闭目标进程中的指定句柄

MultiInstance

CloseHandle()

关闭当前进程中的句柄副本

MultiInstance

GetFileVersionInfo()

获取 DLL 文件版本

RevokeMsgPatcher

ComputeSHA1()

计算文件 SHA1 用于精准匹配

RevokeMsgPatcher

FileStream.Seek + Write

原地写入替换字节

RevokeMsgPatcher

END