
搞逆向的人第一次打开调试器看到那一大堆十六进制地址时多半都会有点懵。0x0000000140000000、0x7FFA3B1C0000……这些数字到底代表什么为什么换个机器、重启一次程序同一个变量的地址就变了网上教程里经常出现的“模块基址偏移”又是什么意思这些问题不弄明白后面的路基本走不顺。我在带新人时发现凡是卡在内存地址和偏移这两个概念上的后面学调试、学脱壳都要绕很大一圈。所以这篇就把这两个基础概念彻底讲透先讲清楚地址的来龙去脉再配合Cheat Engine和x64dbg做一次完整的定位演示从扫描一个数值开始一步步还原“地址会变、偏移不变”背后的逻辑。如果你打算入门逆向安全、做CTF或者只是好奇程序到底怎么存储数据这篇都适合你。当然先说一句文中演示用的是CE自带的训练程序和常见的学习场景目的是理解原理不是让你去拆商业软件授权。1. 逆向分析到底在分析什么1.1 黑盒观测与结构化还原很多人一听到逆向就觉得是“把程序翻个底朝天”实际上多数时候我们做的不是“读懂一切”而是带着目的去还原部分结构。把程序看成一个黑盒输入、输出、内存状态、系统调用都是可观测的信号。你不需要一开始就通读全部汇编代码只需要通过观测推断出“数据放在哪、这个数据怎么被改的”就已经完成了逆向里最核心的一步。内存地址回答的是“数据放在哪”偏移回答的是“目标数据相对某个稳定点差多远”。这两个概念是所有观测的锚点。举个例子游戏里有金币、血量、坐标三个数据金币可能是个全局int变量血量可能藏在角色对象里需要走好几层指针才能访问到。如果不先搞清楚地址和偏移直接去调试器里翻函数列表那就是大海捞针。1.2 为什么先学内存地址和偏移而不是一上来就啃汇编我见过不少新手兴致勃勃地打开x64dbg看了两天汇编指令就开始打退堂鼓。原因是汇编指令回答的是“做了什么”但如果你连操作对象是谁、数据在哪个内存位置都不知道所有汇编看起来都像天书。我建议的路径是先建立“数据视野”先会定位一个值在哪里学会观察谁改动了它再回头学汇编时很多指令就能自动对号入座。比如某天你看到一条指令mov [rax14], edx如果你之前仔细做过地址偏移的练习一下子就能意识到“edx被写入了一个对象的第0x14字节处”而不是干巴巴地背诵指令含义。地址和偏移就像路标汇编是路上的语言先知道路在哪再学路牌上的字效率高得多。1.3 工具选型与调试环境搭建入门阶段不需要一上来就全套工具堆满我常用的组合很简单但足够覆盖需求工具用途适合阶段Cheat Engine内存扫描、指针扫描、下访问断点入门首选x64dbg反汇编调试、内存映射、硬件断点入门到进阶IDA Pro / Ghidra静态分析、反编译进阶Process Explorer查看进程信息、已加载模块列表辅助Cheat Engine适合做“以数据为中心”的动态分析x64dbg适合做“以指令为中心”的调试定位两者配合可以形成完整闭环先在CE里找到数据再去x64dbg里看数据被谁访问了再回到CE里验证偏移链。环境建议直接放在Windows 10或11上虚拟机也行方便随时做快照恢复。刚开始练习用CE自带的Tutorial和64位训练程序就够了尤其不要拿加壳加固的商业软件练手那是进阶之后的事新手会直接被劝退。2. 把地址和偏移彻底讲透2.1 虚拟地址空间、模块基址与ASLR先搞清楚最底层的事程序运行时的地址都是虚拟地址不是物理内存条上的真实位置。每个进程都以为自己独占一整片连续空间32位下用户态范围大约是0x00000000到0x7FFFFFFF64位下扩大到0x0000000000000000到0x00007FFFFFFFFFFF高地址区域属于内核。CPU透过页表把虚拟地址映射到物理内存这个过程由MMU完成。调试器里显示的地址基本都是虚拟地址所以判断一个地址属不属于当前进程先看范围就能筛掉一批垃圾结果。模块基址就是exe或dll被加载到进程虚拟空间后这段镜像的起始地址。x64下的exe基址通常是0x140000000x86下常见0x400000或0x10000000。除了exe系统里的ntdll.dll、kernel32.dll、游戏里的逻辑dll也都有各自基址。每个模块一旦加载它内部的全局变量、函数、vtable与模块基址之间的距离就固定了这个距离叫RVA也就是我们常说的“偏移”。为什么重启程序后地址会变因为ASLR即地址空间布局随机化。微软从Vista开始默认开启这个机制每次启动时把模块基址随机打乱防止攻击者基于固定地址构造攻击载荷。对安全防御来说是好事对逆向分析者来说就是杀熟你昨天记下的绝对地址今天大概率不好使。但模块内部的偏移不变这才是分析时要牢牢抓住的稳定锚点。举个例子某程序exe基址是0x140000000一个全局变量绝对地址是0x1401A2F34计算可得偏移是0x1A2F34。下次运行后exe基址可能变成0x140900000如果还按0x1401A2F34去读读到的就是垃圾内存但只要写 “exe基址0x1A2F34”无论基址随机到什么位置都能准确命中同一个变量。2.2 偏移的本质编译期就定好的门牌号用生活经验来类比一条街道有一排房子每栋房子的门牌号就是“基址”房子里的每个房间编号就是“偏移”。只要房子位置不变房间号怎么都是准的ASLR相当于整栋房子搬家但房间号是盖房子时就刷在墙上的不会因为搬家而改变。放在程序里偏移是编译器在编译阶段就确定的内存布局。比如下面这个结构体struct Role { char name[32]; // 0x00 int level; // 0x20 int hp; // 0x24 int maxHp; // 0x28 float speed; // 0x2C Role* next; // 0x30 };编译器会把name字段放在对象首地址偏移0x00处level对齐到0x20hp在0x24maxHp在0x28speed在0x2Cnext指针在0x30。如果你在调试器里看到mov [rcx24], eax24是十进制等于十六进制0x18那根据结构体布局它操作的大概率不是maxHp就是别的字段附近的东西。 十六进制和十进制的换算在实际分析中到处都是我建议练到一眼能心算的水平能极大提高看反汇编的速度。数组偏移就更好理解了playerArray[i]等价于playerArray i * sizeof(element)。你要是知道元素大小是0x2C0那遍历这个数组时前后两个对象就相差0x2C0字节。判断候选地址合不合理时这个数字非常有用。2.3 指针与多级指针为什么会有地址的地址现代程序为了管理灵活对象大多分配在堆里栈上或全局区只保留一个指向它的指针。于是你改血量时经常发现一个规律CE里搜到的地址只是一块堆内存里面存的是另一个地址顺着这个地址再往前走才能找到真正的HP值。这就是多级指针。游戏引擎里常见的结构是一个全局管理器对象里保存了玩家列表指针玩家列表元素保存了指向RoleInfo结构体的指针RoleInfo里才是血量字段。写成表达式大概是这样HP *( *( *(base 0x58) 0x20) 0x14 )翻译成人话先从全局基址的0x58偏移处读一个指针得到玩家列表再从列表的0x20偏移处读一个指针得到玩家对象最后从玩家对象的0x14偏移处读出HP。这就是一条三级指针链。为什么这种链很稳定因为堆对象每次分配的位置虽然随机但对象内部的字段偏移是由结构体定义决定的每次都是同一套布局。指针里存的具体值会变但指针与指针之间的“偏移关系”不变。CE的指针扫描功能本质上就是在海量内存中遍历这些“地址里面的地址”帮你把这条链自动找出来。2.4 主动频率偏移法时间维度的“基准-偏移”思想主动频率偏移法这个词原本来自通信领域。收发设备之间的载波频率如果不同步信号就会因为频率偏移产生符号错位接收端会通过导频或循环前缀估计出频偏再对采样时钟做补偿校正。它的核心思路是不消除偏移本身而是把基准“校准”让后续处理正确。这个思路在逆向安全里非常有意思。程序也经常拿时间当“基准”做检测比如调用RDTSC指令读取CPU周期数来衡量一段代码执行耗时调试器单步执行会让时间差异常放大于是程序就能判断自己正在被调试。很多反调试检测就是基于这个原理设计的。顺着主动频率偏移法的思路对策不是去删掉每一条RDTSC比较代码而是把时间基准“整体补偿掉”——让检测代码看到的时间差恢复正常。不少调试器插件就是这么干的Hook时间相关的API模拟时钟计数对RDTSC返回值做差值补偿。你看这跟地址偏移的核心思想完全一致空间维度上基址会漂移所以用“基址偏移”来稳定定位时间维度上时间基准会被干扰所以用“频率偏移补偿”来稳定检测结果。3. 动手实操从“找数值”到“还原偏移链”3.1 准备一个适合练手的示例程序练习对象最省事的选择是CE自带的Tutorial程序。打开Cheat Engine后在Process列表里找到Tutorial进程并附加它会一步步带着你完成搜索过程。不过我个人更推荐用一段自己写的简单C程序因为代码完全透明对照着看效果最好。#include stdio.h #include windows.h typedef struct _Role { int id; int hp; int maxHp; } Role; Role g_roles[3]; Role* g_current; int main() { for (int i 0; i 3; i) { g_roles[i].id i 1; g_roles[i].hp 100 i * 10; g_roles[i].maxHp 200; } g_current g_roles[0]; while (1) { printf(current id%d hp%d\n, g_current-id, g_current-hp); Sleep(1000); } return 0; }这里有一个全局数组g_roles和一个全局指针g_current指针指向当前角色。你可以用CE分别找g_current的值和g_roles里的HP字段把多级指针的完整路径亲手跑出来非常直观。3.2 一步步扫描定位目标数据第一步打开CE点击File → Open Process附加到你的目标进程。注意附加后CE会尝试打开系统调试权限正常确认即可。第二步首次扫描。如果你知道当前HP值直接选Exact Value精确值、Value Type选4 Bytes输入初始值点First Scan。比如游戏显示100就搜100。如果不知道当前值或者值一直在变就用Unknown initial value未知初始值之后每次数据改变时切回CE做Changed value或Unchanged value扫描。第三步让数值变化。在游戏或示例程序里触发一次数据变动比如购买物品、扣血然后回CE输入新值点Next Scan。这个过程重复几次后左侧候选地址会从几百万条缩到几条。重点来了选地址不要只盯着“是不是一个”要看它能不能跟着程序状态同步变化。如果程序里存在缓存、镜像、加密副本一次会搜出多个地址真正驱动游戏逻辑的那个通常在你修改它之后程序界面会立刻产生变化。第四步把候选地址双击添加到下方地址列表。右键这个地址选Find out what writes to this address查找谁改写了这个地址然后在程序里再次触发数值变化CE会捕获到一条写指令。这条指令会告诉你程序到底是怎么把数据写进去的。3.3 顺着写指令回溯基址与偏移假设CE捕获到这样一条指令mov [rax14], edx这条指令的意思是把 edx 的值写入“rax寄存器所指向的地址再加0x14”的位置。那么0x14就是HP字段相对当前对象的偏移。接着你要回答的问题是rax这个对象指针是从哪来的往上翻指令可能会看到这样的序列mov rax, [rbx20] mov edx, [rbp10] mov [rax14], edx说明rax是从rbx指向的结构体偏移0x20处读取的指针rbx又来自更上层。继续往上看直到某个寄存器的值来自全局变量区比如mov rbx, [0x14001A2F00]此时0x14001A2F00减去模块基址0x140000000得到模块内偏移0x1A2F00。于是完整的指针链就清楚了0x14001A2F00 (全局区) - 0x20 - 对象指针 对象指针 - 0x14 - HP如果写指令里的内存操作数是类似mov ecx, [0x1401A2F34]这种直接对绝对地址的读取那就更简单了直接绝对地址减模块基址就是RVA偏移。模块基址去x64dbg的Memory Map里看最准确CE的Memory Viewer也能看到。3.4 用指针扫描批量确认稳定指针链当你已经知道HP字段的偏移是0x14接下来可以用CE的指针扫描自动找完整链路。右键目标地址 → Pointer scan for this address配置扫描参数最大层级设成4级最大偏移设0x1000一般够用。如果目标对象的内部结构里嵌套了大数组最大偏移可能要放宽到0x10000但初始扫描不用开太大不然结果量会很恐怖。扫描产生的候选可能有几百万条要在结果里做筛选。优先看“ModuleOffset”开头的地址意思是起点是一个模块基址内的固定偏移这类地址天然稳定。如果一个候选中带有堆地址形如0x0000027F...基本可以排除因为堆地址每次运行都会变不具备复用性。验证方法很简单用得到的指针链构造一个手动地址。点击CE下方的Add Address Manually勾选Pointer逐层填入基址和偏移比如模块基址 0x1A2F00 0x20 0x14如果这个手动添加的地址能实时读出正确的HP值说明链路正确。接着可以用x64dbg再验证一遍打开Memory Map确认模块基址反汇编窗口找到那条写指令逐层检查寄存器来源确保CE的结果不是误判。3.5 落地成外部脚本把地址链变成代码CE里的结果显示得再清楚最终还是要落成可复用的脚本或程序。用Windows API读取目标进程内存本质上就是按地址链逐级ReadProcessMemory。下面这段C代码演示了如何读取另一个进程的HP值#include windows.h #include stdio.h int main() { DWORD pid 12345; // 替换为目标进程PID HANDLE hProc OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, pid); if (!hProc) { printf(OpenProcess failed: %d\n, GetLastError()); return 1; } unsigned long long base 0x140000000; // 模块基址 unsigned long long p1 0, p2 0; int hp 0; SIZE_T readBytes 0; ReadProcessMemory(hProc, (LPCVOID)(base 0x1A2F00), p1, 8, readBytes); ReadProcessMemory(hProc, (LPCVOID)(p1 0x20), p2, 8, readBytes); ReadProcessMemory(hProc, (LPCVOID)(p2 0x14), hp, 4, readBytes); printf(HP %d\n, hp); CloseHandle(hProc); return 0; }这段代码的逻辑就是把前面CE里验证过的指针链一步步翻译成API调用。第一层读基址0x1A2F00拿到p1第二层读p10x20拿到p2第三层读p20x14就是HP。编译运行时替换成自己的PID和基址就能跑通。要说明的是这种东西只适合在你自己写的程序、CTF训练或授权的安全评估中使用擅自改别人的程序数据可能违约甚至违法。4. 常见问题与排查技巧实录4.1 重启程序后地址失效怎么回事这是最典型的问题。昨天明明找到了一个地址今天一启动程序地址变了CE里显示“???”。原因就是ASLR把模块基址重新随机化了。你记下的绝对地址是基于旧的基址计算的今天当然对不上。解决思路不是去“记住新地址”而是把地址换成“模块基址偏移”的形式。如果你之前没做指针扫描只记了绝对地址也别慌重新定位一次目标值然后用这次的绝对地址减当前模块基址得到偏移保存成RVA。以后不管模块基址怎么变只要模块不变这个RVA就能一直用。4.2 CE扫描结果变成???或者数值锁不住有几个常见原因。第一地址不在当前进程的虚拟地址空间内比如落在0xFFFF000000000000以上的系统保留区CE默认不可直接访问。第二内存页属性特殊比如PAGE_GUARD页普通读取会触发访问异常导致CE显示???。第三数据类型选错了明明是个float你按4字节int去搜当然搜不准。第四程序有多个地方保存同一份逻辑数据你锁定的是某个缓存副本主逻辑根本不读它。排查办法也有顺序先确认地址范围是否合理再用CE的Memory Viewer看该页的属性然后换数据类型重新扫描最后用Find what writes or accesses this address观察看看是哪个环节在改动数据。如果你看到多条不同的写指令都操作同一个地址说明这个数据存在频繁的逻辑副本同步要优先找到“主数据源”。4.3 明明下了断点为什么没停新手最容易卡在这里。一种情况是硬件断点数量满了x64架构下硬件断点最多4个被其他调试器或CE占满后新断点根本不生效。另一种情况是目标数据由驱动或DMA设备写入根本没走当前进程的普通执行路径你下普通代码断点当然拦不到。还有一种常见情况是程序开启了多线程写入发生在另一个CPU核心而调试器的默认断点设置在暂停所有线程时才生效触发瞬间线程已经跑过去了单次扫描很难抓到。处理思路优先用CE的Find what accesses this address它可以捕获读取和写入两种访问比单纯找写更容易命中必要时候挂起所有线程再扫描内存面对驱动级写入或虚拟化保护初学阶段不要死磕直接换一个没有加固的练习程序。4.4 指针扫描结果为空或链路不稳定指针扫描没有绝对结果依赖你的参数配置。最大层级设小了比如实际链路有5层你只允许1层结果自然为空。最大偏移设小了像一些包含大数组的对象字段偏移可能超过0x1000扫描器会很干脆地告诉你“没找到”。还有一种常见错误是基址选错你扫的是exe模块的基址但目标变量实际存放在某个DLL的全局区自然匹配不上。对策很简单层级可以开到8偏移范围开到0x10000先出结果再过滤用CE的Memory Viewer观察目标对象附近有没有可读模块的地址把扫描模式限定到指定模块减少无效堆地址。经验是真实游戏中常见指针链一般不超过5层偏移基本在0x1000以内超过这个范围就要怀疑是不是选错了对象。4.5 遇到时间反调试可以试着“主动偏移频率”如果你练手的目标程序里加了时间检测用x64dbg单步时程序会突然崩溃或退出十有八九是RDTSC这类时间戳指令发现了调试器的存在。判断方法是在x64dbg里搜索指令RDTSC下断点观察返回的周期数如果两次返回结果差距异常巨大基本就是检测点。应对思路就可以借用“主动频率偏移法”不要试图把每个检测点都删掉而是把时间基准整体做一个偏移补偿。ScyllaHide这类插件会在API层面对时间相关调用做模拟比如模拟稳定的QueryPerformanceCounter返回值、模拟时钟频率让检测函数认为运行环境正常。你也可以手动Patch将RDTSC后的比较跳转改成无条件跳转但要注意程序可能带CRC校验改完字节后还要同步修复校验值。这些属于进阶内容初学者先理解思路不必一上来就碰。5. 偏移思维如何影响你的进阶路线5.1 从改数值到分析程序结构一开始你可能只会“改血量”但一旦掌握了偏移思维你就不再是盯着一堆数字而是开始重建程序内部的数据结构树。哪个全局对象是管理器哪个偏移是对象列表哪个偏移是对象类型标识每个字段通过偏移串成一张完整的结构图。这种能力在做漏洞挖掘或恶意样本行为分析时特别关键因为攻击面往往就藏在这些结构关系里。举个例子分析一个恶意样本时如果你在它的全局对象里发现某个偏移经常被回调顺着这个偏移去逆向很可能就摸到了它的核心执行流程。偏移不再是“找数值”的工具而是一把解剖刀。5.2 RVA、补丁对比与漏洞利用中的偏移越往后学偏移的用途越广泛。比较两个版本DLL的差异时直接比绝对地址没有意义要把地址统一转成RVA再对比模块内偏移差异点一目了然。漏洞利用里要计算缓冲区到返回地址的栈偏移本质上也是偏移计算。恶意样本的IOC分析中经常用“模块名偏移”来指认被Hook的API比如user32.dll里某个函数的入口被改写了5个字节用绝对地址写报告换台机器就没人能复现。这一层理解起来不难但需要慢慢积累。建议每次在CE或x64dbg里看到一个地址都养成“模块基址偏移”的记法习惯时间长了你的分析效率和准确率会有肉眼可见的提升。5.3 学到这里下一步怎么走如果你已经能熟练完成一次“找数值→溯源→还原指针链→落地脚本”的完整流程说明你已经有能力进入更深的话题了。下一步可以学调试器底层的断点机制软件断点、硬件断点、内存断点各自怎么实现为什么有的断点会被反调试检测到再往后可以学壳的原理简单壳的入口点还原、IAT重建理解加壳软件如何利用虚拟内存和区块映射来隐藏真实代码。我只提醒一句这些技术最好用在学习、CTF、漏洞研究和自己写的程序上不要拿去做非法破解和盗版分发。工具本身没有善恶用的人有边界。早期的我踩过不少坑也见过身边朋友因为越界把自己搭进去的技术这条路守得住底线才能走得远。这是系列第一篇下一步我会接着聊调试器的断点实现原理把这层窗户纸彻底捅破。