本文参考了很多前辈的研究, 在这里感谢他们
前言 现有的PCQQ防撤回方案都是通过对 IM.dll
进行修改, PATCH 掉相关逻辑以实现的防撤回,比如 RevokeMsgPatcher 这个方案有很多问题. 比如 无法处理 Windows store
版的QQ, 无法知道哪些消息被撤回等. 那么为什么不直接从源头处理, 直接拦截/修改撤回相关数据包来实现防撤回/撤回提示等操作呢. 前段时间我就做了相关的尝试, 并实现了一个简单的 Demo
, 在这里记录一些思路和当前进度.
准备 由于QQ对数据包进行了加密, 我们需要解密后才能判断哪些数据包需要修改, 而解密需要获取 SessionKey
, 好在前辈已经为我们给出了使用 ollydbg
手动获取 SessionKey
的方法, 可以参考这篇帖子 . 这里需要做的就是将这步自动化.
获取到 SessionKey
后就能对数据包进行解密并拦截/修改了
获取SessionKey
通过阅读这篇帖子 , 可以了解到获取 SessionKey
需要以下几个步骤.
找到进程 QQ.exe
或 TIM.exe
获取 Common.dll
模块的 Base Address
获取 oi_symmetry_decrypt2
的 RVA
进入 Debug Mode
并对 oi_symmetry_decrypt2
下断点 读取寄存器获取入参 1. 找到进程并获取基本信息 这步很简单, 通过 .Net
自带的函数就能实现:
1 2 3 4 5 var target = Process.GetProcessesByName("QQ" ).First(); var common = IntPtr.Zero; foreach (ProcessModule module in target.Modules) if (module.ModuleName == "Common.dll" ) common = module.BaseAddress;
2. 获取函数 oi_symmetry_decrypt2
的 RVA
以计算 VA
由于需要对函数 oi_symmetry_decrypt2
下断, 需要知道目标函数的 VA
(虚拟地址), 而 VA
是通过 BaseAddress + RVA
计算出来的, 所以这步需要获取函数 oi_symmetry_decrypt2
的 RVA
通常获取进程模块的 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))); return (IntPtr) (module.ToInt64() + functionRva); } 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 _original = ReadMemory(Address, 1 ); WriteMemory(Address,new byte []{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 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 );
至此, 我们成功获取到了 SessionKey
抓取数据包 一般情况下, QQ
使用 UDP 8000
端口进行通信, 我们可以使用 WinPcap
等抓包库直接抓取数据包. 然而我们这里需要对数据包进行拦截或修改, 所以在这里我使用的是 WinDivert
. 这个库可以在应用层处理数据包. 首先定义 filter
为 udp.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); WinDivert.WinDivertSend(handle, packet, recvLength, ref addr); }
解密数据包并判断是否撤回 这里参考了一些易语言的 PCQQ
协议实现库, 所以在拿到 SessionKey
后能非常方便的判断是否为撤回包.
PCQQ
数据包的基本结构如下:
内容 类型 协议头 byte
协议版本 int16
指令 int16
包序 int16
QQ号 int32
未知 bytes
3字节加密后的 payload
bytes
剩余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 ); if (reader.BeReadUInt16() == 0x2dc ) continue ;
经过实测确实可以实现防撤回, 如果需要继续实现更多功能的话需要进一步解析数据包了, 这里暂时搁置, 闲下来可能会再捡起来.