十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

内存地址与偏移:逆向安全的核心地基与调试实战

内存地址与偏移:逆向安全的核心地基与调试实战 如果你要搞懂逆向安全第一个绕不开的概念就是内存地址。很多人看脱壳、补丁、内存数据修改的教程一上来就是“OD附加进程”“CE搜索数值”结果只学会点按钮换个游戏、换个版本就完全不会了。原因很简单没搞懂数据在进程里到底怎么被寻址的。内存地址和偏移是逆向工程的两条腿只要把这两个概念吃透后面看函数、找调用、分析指针链都会快很多。这篇文章我会用尽量直白的语言配合自己写的一个小Demo带你从零认识内存地址、基址和偏移搞清楚它们为什么存在、怎么计算、怎么验证。所有内容都基于合法授权的软件调试和学习实验请一定在你自己拥有或被授权的程序上操作千万别拿它去搞别人的商业软件那是给自己挖坑。1. 为什么要从内存地址和偏移开始很多人觉得逆向安全很难是因为一上来就面对寄存器、汇编指令、调用约定。其实这些都是上层建筑地基是内存。CPU的所有运算都要先到内存里取数据取数据靠的就是“地址”。你写代码时的变量名、对象名编译后统统变成一片内存区域而定位这片区域的方式就是地址编号。偏移Offset则是地址之间的“路标”。往往一个进程里会有多个模块、多个数据区域绝对地址每次运行时都可能变但“谁和谁相对距离多少”却相对稳定。举个例子一个结构体数组首元素地址在0x00A30000第二个元素可能就在0x00A30010。这个0x10就是数组每个元素长度也是元素之间固定的偏移。只要拿到了基址加上偏移就能稳定还原出每一个成员。所以破解也好、安全分析也好最底层的功夫其实就是两件事一是会读内存地址二是会算偏移。这篇文章就是带你走完这两步并且用调试器和内存搜索工具亲手验证把“看起来玄乎”的东西变成手上的工具。2. 内存地址到底是什么2.1 从变量名到地址先纠正一个常见误解变量名不是给计算机看的是给人看的。你写int level 20;编译器在编译期把这个符号跟一个“内存偏移”绑定等程序加载到内存后它再被换算成一个具体的虚拟地址。所以运行时根本没有“level”这个概念只有“在地址0x00...上的4个字节值为0x00000014”。这就像快递柜变量名是快递单上的“收件人姓名”内存地址是“几号柜几号格”。你通过姓名找快件只能靠人工计算机通过格口号找快件才高效。逆向时你手里的工具调试器、内存搜索器也都是按格口号来工作的。这个理解特别重要。很多新手用CECheat Engine改单机游戏里的金币搜到了几个地址下一次重启程序地址变了就一头雾水。原因是程序每次启动时操作系统加载DLL和分配内存的基址可能不同但你关注的这个字段和它所属的全局结构之间的“相对偏移”通常不变于是就有了“基址偏移”的定位方法。2.2 虚拟内存与模块映射进程里的地址并不是直接对应物理内存条里的插槽而是“虚拟地址”。操作系统给每个进程画了一块巨大的虚拟地皮32位程序通常是0x00000000到0xFFFFFFFF64位程序更大达到了0x0000000000000000到0x00007FFFFFFFFFFF用户态。这么做的好处是进程之间互相隔离你访问自己的地址0x00400000和另一个进程访问0x00400000互不相干。在这块虚拟地皮上操作系统会把主模块.exe、系统DLL、堆、栈分别映射到不同区域。用x64dbg或OllyDbg打开一个程序时你会在“内存映射”窗口看到一行行条目每行有起始地址、大小、权限、模块名。权限一般有R读、W写、X执行组合。数据区通常不可执行代码区通常只读可执行。这个权限设定对安全很重要比如现代Windows的DEP和数据执行保护就是靠它阻止代码段被篡改后直接执行。那么基址是什么在Windows里PE文件被加载后其镜像在虚拟内存中的起始地址就是模块基址一般用ImageBase表示。32位程序的默认ImageBase通常是0x0040000064位程序通常是0x0000000140000000。但因为ASLR地址空间布局随机化的存在每次加载可能都不一样。所以逆向一个带ASLR的程序时你要学会先把当前基址算出来而不是用一个写死的绝对地址。2.3 十六进制与寻址空间内存地址为什么都用十六进制写因为十六进制和二进制转换特别方便一个十六进制数字正好表示4个bit两个一组表示一个字节。所以0x1A2B直接一眼能看出来二进制是0001 1010 0010 1011。你要是用十进制写地址比如42027转成二进制反而要算半天。32位进程能寻址2^32个字节也就是4GB64位用户态是2^47字节左右约128TB。看起来很大但因为虚拟内存是按需映射的你并不会真占用那么多物理内存。地址位数决定了指针的大小32位程序里指针占4字节64位里占8字节。这点在逆向时特别重要因为读一个指针字段时你必须知道它到底是4字节还是8字节否则读出的值就是错的。我们调试时看到的地址比如0x00D8F2A0前面那一串0其实也是虚拟地址的一部分。为了区分不同位宽x64dbg在64位模式下地址显示为16位十六进制比如0x0000000140001000常用的低4GB区域会显示成0x0000000000D8F2A0。3. 用调试器观察内存3.1 环境准备与实验Demo工欲善其事必先利其器。我不会推荐那些只能“一键修改”的黑盒工具因为那样你永远学不到原理。推荐两条路线64位Windows下用 x64dbg 主攻它能调试32位和64位程序界面清晰支持脚本。想快速验证“内存搜索偏移定位”用 Cheat Engine简称CE它通俗易懂适合先建立空间感。但注意CE往往被用在游戏修改上所以请只对你自己写的软件或明确允许做安全研究的程序使用。我这里用一个我自己写的C控制台程序当Demo代码很简单生成了几个全局变量和一个结构体数组然后用x64dbg去分析。先给一个迷你示例程序#include cstdio #include cstdlib #include windows.h struct Player { int id; // 0x00 int level; // 0x04 int hp; // 0x08 int mp; // 0x0C int x, y; // 0x10, 0x14 }; Player g_players[5]; int g_score 0; void initGame() { for (int i 0; i 5; i) { g_players[i].id i 1; g_players[i].level 1; g_players[i].hp 100 i * 10; g_players[i].mp 20; g_players[i].x i * 10; g_players[i].y i * 20; } g_score 0; } int main() { initGame(); printf(address of g_score: %p\n, g_score); printf(address of g_players: %p\n, g_players); printf(sizeof Player: %llu\n, sizeof(Player)); // 保持进程不退出方便调试 while (true) { Sleep(1000); } return 0; }编译时建议关闭优化使用Release或Debug都行但明确关闭ASLR可以对初学更友好。关闭ASLR的方法之一是在VS工程中设置“链接器-命令行-/DYNAMICBASE:NO”。实际逆向时你不用强求关掉ASLR但当你能看着固定的基址学稳定了再去开ASLR练手会顺畅很多。3.2 第一个实验定位一个整数变量编译运行这个Demo后控制台会打印g_score和g_players的地址。比如我在一次运行中看到这两个地址分别是0x00007FF64FF0A048和0x00007FF64FF0A050。你没看错在两个地址之间差了8个字节——因为编译时全局变量是连续排布的g_score是一个int4字节但下一个结构体需要8字节对齐所以中间补了4个字节。这就是逆向里常说的“对齐”字段很坑但是很现实。用x64dbg附加到这个进程后在命令行里输入d q 0x00007FF64FF0A048就能看到该地址处的8字节数据。其中前4字节是g_score的值0后面4字节是对齐填充。再输入d q 0x00007FF64FF0A050就能看到第一组Player结构体的前16字节id1level1hp100mp20对照着内存窗口看每个int占4字节十六进制分别是01 00 00 00、01 00 00 00、64 00 00 00、14 00 00 00。这一步没什么难度但请你亲眼看一次“变量名对应的内存长什么样”。以后看任何复杂结构本质都是把一段连续字节按不同类型拆解。3.3 基址与偏移的关系现在假设程序重启过ASLR如果开着控制台打印的地址就变了。但你有没有想过即使地址变了g_players和g_score之间的相对距离大概率还是那8个字节因为在同一个模块内数据布局是编译期确定的偏移不会因为模块加载基址变化而变化。我们把当前进程的模块加载地址找出来。在x64dbg的“符号”标签里找到Demo.exe的模块基址比如是0x00007FF64FED0000。那么g_score的RVA相对虚拟地址就是0x00007FF64FF0A048 - 0x00007FF64FED0000 0x3A048。这个值在每一次启动里几乎都一样除非你重新编译程序导致布局发生变化。于是定位公式就来了最终地址 模块基址 RVA偏移 结构体内部偏移比如g_players[2].hp模块基址0x00007FF64FED0000全局数组 g_players 相对模块基址的偏移0x3A050数组内第2个元素的起始偏移2 * sizeof(Player) 2 * 24 48即0x30hp字段在Player结构体内部的偏移0x08所以最终地址是0x00007FF64FED0000 0x3A050 0x30 0x08 0x00007FF64FF0A088换算成十进制就是你数组第三个人的血量所在的内存位置。是不是感觉突然有了“上帝视角”4. 偏移的计算与应用4.1 偏移为什么要存在有人问既然可以直接记录绝对地址为什么还要用基址加偏移因为绝对地址不稳定。操作系统为了安全每次加载程序时都会随机调整模块基址ASLR如果你写死一个绝对地址下次运行就失效。但偏移不一样就像你把一堆家具按图纸摆进房间房间门牌号可能变但“洗脸台在卧室门左手边第三个柜子上面”这个相对位置是固定的。在实际安全研究中这种“基址偏移”的方式不仅用来定位全局变量还用在分析虚拟函数表、PE导入表、局部线程存储等场景。比如你想HOOK一个导出函数动态计算它为LoadLibrary模块基址 函数RVA而不是硬编码绝对地址这样才能写出健壮的Hook工具。理解了偏移你就理解了RVA、静态偏移、动态偏移这些概念。RVA和文件内偏移还要区分。PE文件加载到内存后的RVA并不总是等于文件里按字节数数过去的偏移因为节的对齐粒度不同。所以如果是从文件里静态解析还要经过区段映射换算而在调试器里看到的已经是加载到内存的样子直接用RVA就行。4.2 手算偏移的两种方式一种是静态计算从符号文件、反汇编代码、结构体布局上得到偏移。比如我们知道C里Player的成员顺序就能推算出hp偏移是8。这种方式适合自己写的Demo、有符号表的程序或者结构简单的小程序。但对于加壳、混淆的程序静态计算很费劲。另一种是动态扫描用CE先模糊搜索或数值搜索定位到目标变量然后用“找出是什么改写了此地址”找到对应的指令再从指令里看到偏移。比如你找到了mov eax, [rsi08]其中rsi存的就是结构体首地址偏移就是0x08。动态扫描的好处是你不需要完整理解整个程序直接从运行现场里提取信息。实际工作中两种方式经常结合。先用静态分析猜目标在哪个模块、哪个函数再用动态调试验证偏移和取值。千万不要一上来就瞎搜目标不明确只会浪费时间。4.3 用偏移读取跨结构数据下面来点稍微进阶的。很多程序里的对象不是直接暴露出来的而是通过指针一层层指向。比如一个GameState全局指针指向动态分配的堆对象堆对象里又有多个结构体的指针。逆向时经常会遇到多级指针链GameState** ppState 0x00007FF64FF0B010;一步步解引用GameState* pState *ppState; PlayerManager* pMgr pState-playerManager; // 偏移 0x20 Player* pPlayer pMgr-players; // 偏移 0x18 int hp pPlayer[2].hp; // 偏移 0x08用地址写出来就是地址1 [0x00007FF64FF0B010] 地址2 [地址1 0x20] 地址3 [地址2 0x18] 最终数据 [地址3 0x30 0x08]在x64dbg的内存窗口里每一条“指针”都是一个8字节的数据值为另一个地址。按一下地址栏里的箭头就能跳转过去。遇到多层指针时不断按下“跳到该地址”就能一直跟踪到最末端。这个过程特别像循着一串线索找东西每到一个节点都要确认这个字段是不是指针。为了验证自己没找错最好的办法是给程序里对应地址写入一个特殊值再回到程序里观察是否按照预期改变逻辑。比如修改hp为9999程序里显示血量变化的函数如果读取该字段就会看到变化。注意只有在修改后产生可见变化才算定位准确否则很可能你找到的只是某个副本或缓存数据。5. 常见坑与排查思路5.1 32位和64位的地址差异这是新手最容易踩的坑。32位程序指针占4字节64位程序指针占8字节。用CE时如果你附加的是64位程序扫描类型里一定要勾选8字节否则你用4字节搜索一个指针值永远搜不到。同理在看x64dbg的寄存器窗口时64位寄存器是RAX、RBX、RCX低32位则叫EAX、EBX、ECX。如果你访问[rax]和[eax]实际读取的内存范围不同[rax]取8字节地址指向的数据[eax]则是把RAX低32位扩展成0后作为地址。这里有个小细节在64位Windows上用户态地址一般在低47位以内高位总是0。所以你看到的可读指针通常形如0x0000000000A0B0C0或者0x00007FF6xxxxyyyy。如果某个指针读出来是0xFFFF8000...那多半指向内核态区域用户程序无权访问直接引用会崩溃。我建议新手优先从64位程序练起因为地址空间大、指针链清晰不会出现32位下常见的“短地址回绕”。但也要明白很多传统破解教程都是32位时代的产物里面的命令如mov eax, dword ptr [ebp-4]在64位程序里可能变成mov eax, dword ptr [rbp-4]不要死套。5.2 指针链断裂和无效地址内存分析最痛苦的事情就是你跟着指针链跳到一个地址发现里面的值是????????或者全部是0。这通常有几种原因你读的字段并不是指针而是普通数值却强行当指针用了。对象已经被释放内存写入了无效数据。对象在堆上而堆块被管理器复用内容被覆盖。程序加壳壳在运行时解压/解密了部分数据而你看到的是壳运行前的原始数据。遇到指针链断裂第一步不是怀疑自己算错了而是回到当前指令执行上下文里看看寄存器里是否还有某个中间指针。比如一个函数接收PlayerManager指针作为参数放入RCX如果你找的链里断在别的寄存器可以顺藤摸瓜找到RCX再用它作为新起点。另外启用x64dbg的“硬件断点”或“内存断点”来监视某块区域是否被写入或执行能更快判断对象生命周期。比如你下了一个内存写断点程序一旦写入该地址就断下你就能看到到底是哪条指令在修改它这比静态猜链可靠得多。5.3 实践验证主动频率偏移的思维最近“主动频率偏移法”这个词在安全圈也有些讨论不过它原本是并网逆变器孤岛检测里的概念系统主动注入一个频率扰动观察电网电压频率是否有对应偏移以此判断是否脱离了主网。这和逆向内存分析挺像——你不能只站在原地看要主动制造一点变化再观察哪个内存、哪个路径跟着变了。我们在内存搜索中也经常用这种“主动扰动”的思路。比如你怀疑某个值是血量但不确定自己在改的是副本还是真实数据。那么你可以用CE或调试器主动把某个候选地址的值改成一个带特征的大数比如0x12345678。回到程序里看是否有效果。如果没有任何反应再改下一个候选地址。这个过程其实就是“控制变量法”只改变一个变量观察结果是否随动。进阶的版本是主动修改指令而不是数据。比如在反汇编窗口里看到一条比较指令cmp [rbx0x14], 64你可以把这句指令改成cmp [rbx0x14], 64 100或直接xor eax, eax; nop跳过校验。但我必须强调这只在你自己的实验程序或授权测试环境中才能做不要用在非法破解上。安全研究里这种主动扰动恰恰是用来检测软件自我保护是否有效的标准手段。5.4 常见问题速查表问题现象可能原因排查方法搜索数值始终搜不到数据类型错误4字节/8字节、值被加密、多线程频繁修改确认进程位数选择正确类型用模糊搜索先暂停程序再搜找到的地址改完没效果是旧副本/缓存不是真实数据用“找出是什么改写此地址”重新定位检查是否存在线程同步写回模块基址每次启动都变ASLR开启正常现象使用“模块基址 RVA”方式动态计算不要记绝对地址指针链中间值为0对象未创建或被释放在对象创建函数下断点跟踪生命周期检查是否用错索引跟随地址后发现权限不足访问了PAGE_NOACCESS区域或内核地址检查地址范围确认指针指向的模块是否正确反汇编窗口指令和静态IDA不一致程序加壳/自修改代码在内存断点、执行断点下观察运行时解密结果这张表我建议保存在手边刚开始做实验时会反复遇到以后就习惯了。要特别提醒的是权限不足不一定代表你找错了有时候是文件映射的共享内存只读要区分“不可读”和“读出来是垃圾”不可读会直接弹异常而垃圾数据是有值的但语义不对。6. 实操总结从Demo看透内存布局我个人的体会是写一个最土的控制台Demo比看十篇教程都有效。因为你亲手打印了地址亲手在调试器里打开内存窗口亲手反复修改验证那种对地址和偏移的“肌肉记忆”才会慢慢长出来。对于完全没基础的朋友我建议按这个顺序走一遍写一个全局变量和结构体数组打印关键地址。用x64dbg附加找到模块基址计算RVA。用“模块基址RVA结构体偏移”定位到每个元素验证数值。把程序改成打开ASLR重新运行时用动态方式计算基址。自己模拟一个二级指针练习跟随指针链。尝试用CE的“找出是什么改写了此地址”定位一条写指令再从反汇编里读出偏移。每完成一步你对内存布局的理解就扎实一分。等到你能不看代码通过纯内存视角判断出一个结构体的大小和成员顺序时再去看复杂的游戏保护、恶意软件分析就会发现底子已经打好了。后续有时间我打算继续写逆向安全系列的第二篇专门讲调用约定和函数参数从寄存器到栈的传递过程。如果你在练习中遇到了什么奇怪的地址问题也欢迎留言说不定你的问题就是下一篇的素材。
返回列表