【技术解析】使用Python与Pymem实现PCVX内存操作与远程函数调用
⚠️ 法律声明与免责声明: 本文旨在探讨Windows操作系统下的进程内存管理、动态链接库(DLL)加载机制以及远程线程注入技术。所提供的代码和方法仅供技术研究和学习之用。严禁将本文内容用于非法获取用户信息、破坏计算机系统或任何违反法律法规的活动。读者应自行承担因使用本文技术所产生的一切法律后果,作者对此不承担任何责任。
一、引言
在Windows应用开发和逆向工程领域,直接操作目标进程的内存空间是一项关键技术。它允许我们读取运行时数据或在目标进程的上下文中执行代码。本文将深入分析如何使用Python结合pymem
库,对PC版VX(WeChat for Windows)进行内存读取(获取用户信息)和远程函数调用(发送消息)。
我们将重点关注以下核心技术点:
- 进程附加与模块jz定位。
- 基于静态偏移的内存数据读取。
- x64调用约定的Shellcode构建。
- 使用
CreateRemoteThread
实现远程代码执行。
二、核心技术原理
在深入代码实现之前,需要理解几个Windows内存管理和进程交互的基础概念。
1. 内存读写与Pymem库
操作系统通过虚拟内存机制为每个进程分配独立的地址空间。要访问另一个进程的内存,需要使用特定的Windows API,如OpenProcess
, ReadProcessMemory
(RPM) 和 WriteProcessMemory
(WPM)。
Pymem
是一个Python库,它封装了这些底层的Windows API调用,提供了一个面向对象的接口,简化了跨进程内存操作的复杂性。
2. 模块jz(Base Address)与偏移量(Offset)
现代操作系统普遍采用地址空间布局随机化(ASLR)技术。这意味着,程序的主执行文件(.exe)和它所依赖的动态链接库(DLL)每次加载到内存时的起始地址(jz)都是变化的。
然而,DLL文件内部的结构是固定的。一个特定的变量或函数相对于该DLL加载jz的距离(偏移量)是恒定的(在同一软件版本内)。
因此,访问特定数据的标准流程是:
- 定位目标DLL(如
WeChatWin.dll
)在当前进程中的加载jz。
- 结合已知的静态偏移量,计算出目标数据的实际内存地址。
实际地址 = 模块jz + 静态偏移量
3. 远程线程注入(Remote Thread Injection)
为了在目标进程中执行一个函数(例如发送消息的函数),我们不能在外部进程中直接调用它。我们需要让目标进程自己去执行。
远程线程注入是通过Windows API CreateRemoteThread
实现的。其原理是在目标进程空间内申请一段内存,写入要执行的机器码(Shellcode),然后在目标进程中创建一个新的线程,并将该线程的执行起点指向这段Shellcode。
三、代码实现深度解析
我们将分析WeChatMemoryReader
类的关键实现。
1. 初始化与jz定位
import psutil
from pymem import Pymem
# ... (其他导入)
class WeChatMemoryReader:
def __init__(self, pid: int):
self.pid = pid
# 警告:以下偏移量与WeChat版本强相关,版本更新后极大概率失效。
self.offsets = {
'phone': 0x5A2CB68,
'wxid': 0x5A2D160,
'send_message': 0x22D4A90,
}
# ...
try:
# 附加到目标进程
self.pm = Pymem(pid)
self.init_base_address()
except Exception as e:
# ... 错误处理 ...
def init_base_address(self):
"""初始化WeChatWin.dlljz"""
# 遍历进程加载的所有模块
for module in self.pm.list_modules():
if module.name == "WeChatWin.dll":
self.base_address = module.lpBaseOfDll
print(f"[PID {self.pid}] WeChatWin.dll Base Address: {hex(self.base_address)}")
return
Pymem(pid)
:通过进程ID附加到目标进程,获取操作句柄。
init_base_address()
:利用pymem.list_modules()
枚举VX进程加载的所有DLL,找到核心逻辑模块WeChatWin.dll
,并记录其lpBaseOfDll
(jz),为后续操作提供基准。
2. 内存数据读取与指针解引用
读取用户信息是相对直接的操作,通过jz + 偏移量
定位数据地址。
def get_user_info(self):
# ...
# 读取VX号
wxid_address = self.base_address + self.offsets['wxid']
# 使用自定义函数处理可能的指针结构
wxid = self.read_comment_string(wxid_address, 20)
# ...
在C/C++编译的程序中(如VX),字符串通常不会直接存储在静态偏移位置,而是存储一个结构体(如std::string
或自定义结构),该结构体内部包含一个指向堆内存中实际字符串的指针。
def read_comment_string(self, address, max_length=500):
try:
# 1. 尝试将该地址内容视为一个64位指针 (longlong)
pointer = self.pm.read_longlong(address)
if pointer:
try:
# 2. 如果是指针,则读取指针指向的地址内容
result = self.pm.read_string(pointer, max_length)
if result:
return result
except:
pass
# 3. 备用方案:如果不是指针(例如短字符串优化SSO),尝试直接从原地址读取
result = self.pm.read_string(address, max_length)
return result
# ...
read_comment_string
体现了处理这种内存结构的策略:首先尝试进行指针解引用(Dereference),如果失败,则尝试直接读取地址内容。
3. 远程函数调用:发送消息
这是最复杂的部分,涉及内存写入、Shellcode构建和线程注入。目标是调用WeChatWin.dll
内部的发送消息函数。
步骤 1: 参数准备与内存写入
VX的内部函数通常使用复杂的结构体作为参数,而非简单的基本类型。我们需要在VX的内存空间中模拟构建这些结构体。
def send_message(self, wxid, message):
# ...
# 1. 准备WXID结构体 (模拟类似 std::wstring 的结构)
# VX内部通常使用UTF-16LE编码
wxid_unicode = wxid.encode('utf-16le') + b'\x00\x00'
# 在目标进程中分配内存存储字符串内容
wxid_ptr = self.pm.allocate(len(wxid_unicode))
self.pm.write_bytes(wxid_ptr, wxid_unicode, len(wxid_unicode))
# 在目标进程中分配内存存储结构体 (假设结构体大小为32字节)
wxid_struct = self.pm.allocate(32)
# 写入结构体成员:指针、长度等
self.pm.write_longlong(wxid_struct, wxid_ptr) # Offset 0: 指向字符串的指针
self.pm.write_longlong(wxid_struct + 8, len(wxid)) # Offset 8: 字符串长度
# ... (其他成员初始化为0)
# 2. 准备消息内容结构体 (同理)
# ...
步骤 2: 构建Shellcode(x64调用约定)
为了调用函数,我们需要一段机器码(Shellcode)来按照Windows x64应用程序二进制接口(ABI)的规定设置参数。
在Windows x64 ABI中,前四个整数或指针参数通常通过寄存器RCX
, RDX
, R8
, R9
传递,其余参数通过堆栈传递。
def _create_call_shellcode(self, func_addr, rcx, rdx, r8, r9):
shellcode = bytearray()
# --- 函数序言 (保存寄存器状态) ---
# ... (PUSH RAX, RCX, RDX, R8, R9等)
# --- 设置参数 (根据x64调用约定) ---
# mov rcx, rcx_value
shellcode.extend([0x48, 0xB9])
shellcode.extend(struct.pack('<Q', rcx)) # RCX = 参数1
# mov rdx, rdx_value
shellcode.extend([0x48, 0xBA])
shellcode.extend(struct.pack('<Q', rdx)) # RDX = 参数2
# ... (设置R8, R9) ...
# --- 准备堆栈空间 (Shadow Space) ---
shellcode.extend([0x48, 0x83, 0xEC, 0x20]) # sub rsp, 0x20
# --- 调用函数 ---
# mov rax, func_addr
shellcode.extend([0x48, 0xB8])
shellcode.extend(struct.pack('<Q', func_addr))
# call rax
shellcode.extend([0xFF, 0xD0])
# --- 函数尾声 (清理堆栈,恢复寄存器) ---
# ... (ADD RSP, POP等)
shellcode.extend([0xC3]) # ret (线程返回)
return bytes(shellcode)
这段Shellcode精确地模拟了编译器在调用函数时生成的汇编指令。
步骤 3: 执行远程线程
最后一步是将Shellcode注入目标进程并执行。
def _remote_function_call(self, func_addr, rcx, rdx, r8, r9):
# 1. 构建Shellcode
shellcode = self._create_call_shellcode(func_addr, rcx, rdx, r8, r9)
# 2. 将Shellcode写入目标进程内存
shellcode_addr = self.pm.allocate(len(shellcode))
self.pm.write_bytes(shellcode_addr, shellcode, len(shellcode))
# 3. 使用CreateRemoteThread
kernel32 = ctypes.windll.kernel32
# 【关键】为64位环境正确定义API原型
# 必须明确指定参数类型,防止ctypes在处理64位指针时发生溢出错误 (OverflowError)
kernel32.CreateRemoteThread.argtypes = [
wintypes.HANDLE, # hProcess
ctypes.c_void_p, # lpThreadAttributes
ctypes.c_size_t, # dwStackSize
ctypes.c_void_p, # lpStartAddress (Shellcode地址)
ctypes.c_void_p, # lpParameter
wintypes.DWORD, # dwCreationFlags
ctypes.c_void_p # lpThreadId
]
kernel32.CreateRemoteThread.restype = wintypes.HANDLE
# 执行注入
thread_handle = kernel32.CreateRemoteThread(
self.pm.process_handle,
None, 0,
shellcode_addr, # 线程从这里开始执行
None, 0, None
)
# 4. 等待执行并清理资源
if thread_handle:
kernel32.WaitForSingleObject(thread_handle, 5000)
# ... (获取退出码,关闭句柄,释放内存) ...
CreateRemoteThread
API在VX进程中启动了一个新线程,该线程执行我们注入的Shellcode,从而完成了对send_message
函数的调用。
四、操作注意事项与稳定性分析
1. 依赖环境
- Python环境(建议使用64位Python以匹配64位VX进程)。
- 依赖库:
pip install pymem psutil pywin32
(pywin32通常是pymem的依赖)。
- 权限:执行脚本通常需要足够的权限(有时需要管理员权限)才能附加到其他进程。
2. 静态偏移量的脆弱性(Critical Limitation)
本文提供的代码实现存在一个重大的工程局限性:对硬编码静态偏移量的依赖。
self.offsets = {
'phone': 0x5A2CB68,
'wxid': 0x5A2D160,
# ...
}
由于软件开发迭代的特性,VX客户Duan每次更新(即使是小版本更新),WeChatWin.dll
都会被重新编译。这将导致内部函数和变量的偏移量发生变化。一旦偏移量失效:
- 读取数据: 将读取到错误的数据。
- 调用函数: 将调用错误的地址,几乎必然导致目标进程(VX)崩溃。
3. 维护与进阶
要维持此类工具的有效性,需要掌握逆向工程技能:
- 动态分析: 使用调试器(如x64dbg, WinDbg)分析程序执行流程,定位关键函数。
- 静态分析: 使用反汇编工具(如IDA Pro, Ghidra)分析DLL文件结构,定位数据引用。
- 特征码扫描(Signature Scanning): 为了提高兼容性,更健壮的实现应避免使用硬编码偏移量,转而使用特征码(一段独特的字节序列)在内存中动态搜索目标地址。
五、总结
本文详细解析了使用Python和pymem
库操作PCVX内存的技术实现。我们探讨了如何定位DLLjz、读取内存数据结构,以及如何通过构建符合x64调用约定的Shellcode并利用CreateRemoteThread
实现远程函数调用。
虽然基于静态偏移的实现非常脆弱,但该过程完整展示了Windows环境下进程间通信和内存操作的核心原理,为深入学习逆向工程和系统底层机制提供了实践基础。