动态读取PCQQ的Key并解密数据包实现防撤回

本文参考了很多前辈的研究, 在这里感谢他们

前言

现有的PCQQ防撤回方案都是通过对 IM.dll 进行修改, PATCH 掉相关逻辑以实现的防撤回,比如 RevokeMsgPatcher 这个方案有很多问题. 比如 无法处理 Windows store 版的QQ, 无法知道哪些消息被撤回等. 那么为什么不直接从源头处理, 直接拦截/修改撤回相关数据包来实现防撤回/撤回提示等操作呢. 前段时间我就做了相关的尝试, 并实现了一个简单的 Demo, 在这里记录一些思路和当前进度.

准备

由于QQ对数据包进行了加密, 我们需要解密后才能判断哪些数据包需要修改, 而解密需要获取 SessionKey, 好在前辈已经为我们给出了使用 ollydbg 手动获取 SessionKey 的方法, 可以参考这篇帖子. 这里需要做的就是将这步自动化.

获取到 SessionKey 后就能对数据包进行解密并拦截/修改了

获取SessionKey

通过阅读这篇帖子, 可以了解到获取 SessionKey 需要以下几个步骤.

  • 找到进程 QQ.exeTIM.exe
  • 获取 Common.dll 模块的 Base Address
  • 获取 oi_symmetry_decrypt2RVA
  • 进入 Debug Mode 并对 oi_symmetry_decrypt2 下断点
  • 读取寄存器获取入参

1. 找到进程并获取基本信息

这步很简单, 通过 .Net 自带的函数就能实现:

1
2
3
4
5
var target = Process.GetProcessesByName("QQ").First(); // 获取 QQ.exe
var common = IntPtr.Zero; // Common.dll 的地址
foreach (ProcessModule module in target.Modules)
if (module.ModuleName == "Common.dll")
common = module.BaseAddress;

2. 获取函数 oi_symmetry_decrypt2RVA 以计算 VA

由于需要对函数 oi_symmetry_decrypt2 下断, 需要知道目标函数的 VA (虚拟地址), 而 VA 是通过 BaseAddress + RVA 计算出来的, 所以这步需要获取函数 oi_symmetry_decrypt2RVA

通常获取进程模块的 VA 很简单, 可以直接通过 GetProcAddress 获取, 但 GetProcAddress 无法获取其他进程的信息, 所以这里需要自己实现一个 GetProcAddress

这里直接参考的这篇文章, 使用 C# 实现了一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public IntPtr GetProcAddress(string moduleName, string procName)
{
var module = FindModule(moduleName);
if (module == IntPtr.Zero) return IntPtr.Zero;
var peHeader = ReadMemory<int>((IntPtr) module.ToInt64() + 0x3C);
var optHeader = module.ToInt64() + peHeader + 0x18;
var pExportOffset = ReadMemory<short>((IntPtr) optHeader) == 0x010b ? optHeader + 0x60 : optHeader + 0x70;
var exportDirectory = ReadMemory<ExportDirectory>(module + ReadMemory<int>((IntPtr) pExportOffset));
for (var i = 0; i < exportDirectory.NumberOfNames; i++)
{
var functionName = ReadMemoryString((IntPtr) (module.ToInt64() + ReadMemory<int>((IntPtr) (module.ToInt64() +
exportDirectory.AddressOfNames + i * 4))));
if (functionName != procName) continue;
var functionOrdinal =
ReadMemory<short>((IntPtr) (module.ToInt64() + exportDirectory.AddressOfNameOrdinals + i * 2)) +
exportDirectory.Base;
var functionRva = ReadMemory<int>((IntPtr) (module.ToInt64() + exportDirectory.AddressOfFunctions +
4 * (functionOrdinal - exportDirectory.Base))); // RVA
return (IntPtr) (module.ToInt64() + functionRva); // VA
}
return IntPtr.Zero;
}

3. 实现一个简单的 Debugger 并对 oi_symmetry_decrypt2 下断

在对 QQ 下断前, 我本来认为 QQ 自带的流氓服务 QQProtect 会对 QQ 本体进行一些保护. 然而实测后却发现这玩意对 QQ 本体并没有任何保护, 任意进程都能对 QQ 进行任何操作. 所以这里不需要 bypass 保护, 直接以普通进程的方式对待即可.

众所周知, 在 Windows 下, 要 Debug 进程一般情况下有两种方案:

  • 在进程启动时设置相关参数
  • 在进程启动后调用 BOOL DebugActiveProcess(DWORD dwProcessId) 函数

原先我是打算在启动时直接让 QQ 进入 Debug Mode, 但是经过一番研究发现, QQ 的登录器在登录后会直接自杀并创建一个新的 QQ.exe 进程用于处理消息, 所以方案1是不可行的.

对目标函数下断

众所周知, 一般情况下我们可以有两种断点类型选择, 一种是 Memory BreakPoint 一种是 Int3 BreakPoint.

  • Memory BreakPoint 的原理为将目标内存页设置为 PAGE_NOACCESS, 这样这个内存页将无法被访问, 如果程序调用了这部分的话就会触发一个 EXCEPTION, 我们只需要捕获这个异常, 并判断地址是否是目标地址即可.
  • Int3 BreakPoint 是对目标地址写入 0xCC 指令, 处理器在执行到这个指令后就会抛出一个异常信号, 同 Memory BreakPoint, 我们只需要捕获这个异常并判断地址即可. 缺点是比较容易被 Anti debugger 检测到.

由于 QQProtect 并没有对 QQ 进行任何保护, 这里我们使用比较方便的 Int3 BreakPoint 来对 oi_symmetry_decrypt2 下断.

在写入指令前, 我们需要先备份原指令用于还原:

1
2
3
// Address 为目标函数的 VA
_original = ReadMemory(Address, 1); // 读取原指令并备份
WriteMemory(Address,new byte[]{0xCC}) // 写入 0xCC 指令

还原时只需要将 _original 写回目标地址即可

获取并处理 Debug Event

首先定义相关函数导入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern bool DebugActiveProcess(int dwProcessId);

[DllImport("kernel32.dll", SetLastError = true)]
internal static extern bool DebugActiveProcessStop(int dwProcessId);

[DllImport("kernel32.dll", SetLastError = true, EntryPoint = "WaitForDebugEvent")]
private static extern bool WaitForDebugEventInternal(out DebugEvent lpDebugEvent, uint dwMilliseconds);

internal static DebugEvent WaitForDebugEvent(uint dwMilliseconds)
{
WaitForDebugEventInternal(out var e, dwMilliseconds);
return e;
}

[DllImport("kernel32.dll", SetLastError = true)]
internal static extern bool ContinueDebugEvent(uint dwProcessId, uint dwThreadId, ContinueStatus dwContinueStatus);

[DllImport("kernel32.dll", SetLastError = true, EntryPoint = "GetThreadContext")]
private static extern bool GetThreadContextInternal(IntPtr hThread,ref Context lpContext);

internal static Context GetThreadContext32(IntPtr hThread)
{
var ctx = new Context {ContextFlags = ContextFlags.CONTEXT_ALL};
GetThreadContextInternal(hThread, ref ctx);
return ctx;
}

[DllImport("kernel32.dll", SetLastError = true,EntryPoint = "SetThreadContext")]
private static extern bool SetThreadContext32Internal(IntPtr hThread, [In] ref Context lpContext);

internal static void SetThreadContext32(IntPtr hThread, Context context) => SetThreadContext32Internal(hThread, ref context);

然后这里需要创建一个单独的线程来调用 WaitForDebugEvent 以获取 Debug Event 并处理相关事件

注意: DebugActiveProcess 函数和 WaitForDebugEvent 必须在同一个线程

1
2
3
4
5
6
7
8
9
10
// Debugger loop
new Thread(() =>
{
DebugActiveProcess(TargetProcess.Id);
while(true){
var nextEvent = WaitForDebugEvent(uint.MaxValue);
// 处理事件
ContinueDebugEvent(nextEvent.dwProcessId, nextEvent.dwThreadId, ContinueStatus.DBG_CONTINUE); // 恢复线程
}
}){IsBackground = true}.Start();

DebugInfo 相关结构体以及状态可参考 MSDN 相关接口文档.

这里我们需要记录三种事件:

  • CREATE_THREAD_DEBUG_EVENT 线程创建事件, 需要记录 ThreadId 以及 Handle.
  • EXIT_THREAD_DEBUG_EVENT 线程销毁事件, 删除记录.
  • EXCEPTION_DEBUG_EVENT EXCEPTION 事件, 我们的断点命中会抛出该事件.

处理 EXCEPTION 事件

在收到 EXCEPTION 事件后, 我们需要判断这个事件是否是我们自己的硬件断点抛出的, 这里需要用到 CREATE_THREAD_DEBUG_EVENT 事件所记录的线程信息:

1
var thread = _threads.FirstOrDefault(t => t.Id == e.dwThreadId);

然后可以通过 GetThreadContext 函数获取线程上下文:

1
var ctx = GetThreadContext32(thread.Handle);

这里我们需要对比 EIP 寄存器, 将 EIP 寄存器的地址 -1 , 对比我们下断的函数地址, 如果地址现同的话即算是命中断点.

1
var hit = BreakPointAddress == (IntPtr)(ctx.Eip - 1);

然后还原函数头指令并将 EIP 寄存器往前移一位.

1
2
3
WriteMemory(Address, _original);
ctx.Eip -- ;
SetThreadContext32(thread.Handle, ctx);

注意: 这里必须在确认命中断点后才将 EIP 后移一位并还原指令, 不然会将错误的指令写入.

然后我们可以在 还原线程以前 读取 ESP 寄存器以获取入参

1
2
var ptr = ReadMemory<IntPtr>((IntPtr) ctx.Esp + 0x0C);
var sessionKey = ReadMemory(ptr, 16); // 16 字节定长

至此, 我们成功获取到了 SessionKey

抓取数据包

一般情况下, QQ 使用 UDP 8000 端口进行通信, 我们可以使用 WinPcap 等抓包库直接抓取数据包.
然而我们这里需要对数据包进行拦截或修改, 所以在这里我使用的是 WinDivert. 这个库可以在应用层处理数据包.
首先定义 filterudp.DstPort == 8000 or udp.SrcPort == 8000
然后开始接收并处理数据包:

1
2
3
4
5
6
7
8
var packet = new WinDivertBuffer();
var addr = new WinDivertAddress();
uint recvLength = 0;
while(true){
WinDivert.WinDivertRecv(handle, packet, ref addr,ref recvLength); // 接收数据包
// 处理数据包, 如果为撤回的话就 continue
WinDivert.WinDivertSend(handle, packet, recvLength, ref addr); // 将数据包发回QQ
}

解密数据包并判断是否撤回

这里参考了一些易语言的 PCQQ 协议实现库, 所以在拿到 SessionKey 后能非常方便的判断是否为撤回包.

PCQQ 数据包的基本结构如下:

内容类型
协议头byte
协议版本int16
指令int16
包序int16
QQ号int32
未知bytes 3字节
加密后的 payloadbytes剩余payload长度-1
协议尾byte

使用 SessionKey 解密 payload 后 判断包头的 cmd 是否为 0x17 然后继续跳过 17 字节读取 int16 并判断是否为 0x2dc 这就是撤回相关的包

1
2
3
4
var decrypted = Tea.Decrypt(payload,SessionKey); // 解密数据包
using var reader = new BinaryReader(new MemoryStream(decryoted));
reader.ReadBytes(17); // 跳过17字节, 这个是来源相关信息
if(reader.BeReadUInt16() == 0x2dc) continue; // 注意大端, 直接丢包

经过实测确实可以实现防撤回, 如果需要继续实现更多功能的话需要进一步解析数据包了, 这里暂时搁置, 闲下来可能会再捡起来.