ARTICLE DETAIL

资讯详情

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

CE第九关共用代码定位与条件注入实战

CE第九关共用代码定位与条件注入实战 CE的练习靶场里前面八关基本都在教你同一套动作找数值、改数值、锁定数值。对象之间各过各的你改谁是谁互不干扰。到第九关画风突变——几根血条背后连着同一段汇编你手一抖把那段指令改成不扣血结果是全部血条一起停住有时候还会集体抽风闪退。很多人卡在这一关不是因为不会点按钮而是脑子里没有建立起“这段代码正在同时为多个对象干活”这个概念。一旦你知道共用代码是怎么产生的、寄存器里到底留下了什么线索再把判断条件塞进注入脚本里这一关其实比前面几关更简单因为你要做的就一件事让代码只认你想认的那个对象。这篇东西我想把它写成一份能直接照着做的实操记录。前半部分讲清楚共用代码的来龙去脉和定位方法中间把代码注入模板一层层拆开后半段直接把第九关的完整流程走一遍顺便把踩过的坑和排查思路摆出来。不管你是刚学CE的新手还是已经能改点简单数值、想往逆向分析再走一步的人都能从里面挑到能用的东西。1. 共用代码到底是什么——先搞懂第九关在考什么1.1 一段指令服务多个对象问题就从这里冒出来程序员写游戏逻辑的时候很少会给每个敌人单独写一遍“扣血”的代码。对象多的时候他们会把血条、敌人、道具这些做成结构相同的对象放在一块连续的内存里用同一个函数去处理。打个比方不是给每个学生配一个老师而是一个老师对着花名册点名谁的血少了就减谁。编译成汇编以后这个“点名”的动作就体现在寻址方式上。你打开反汇编窗口经常会看到类似mov [esiedx*410],eax这样的指令。这里的esi是整块对象数组的起始地址edx是当前正在处理的对象编号10是这个对象内部血量字段的偏移。处理器执行这条指令时edx是几就改第几个对象的血。换句话说同一段代码靠寄存器里的值来决定“这一次到底为谁服务”。这就是共用代码最典型的形态。理解了这个结构你就能明白为什么第九关的几根血条会同步变化——它们走的是同一条指令。你在这一条指令上动手脚等于对着花名册一次性改了所有人的分数不管你本来想不想改。1.2 直接改指令为什么会“一锅端”新手最容易犯的错误就是沿用前几关的思维找到扣血指令直接把它替换成NOP或者改成写固定值。前几关里因为对象各自用独立代码你改完只影响目标第九关里这一改就全停了。进攻方和防守方的血条都不动了练习程序甚至会提示你操作有误。这里的原因不复杂你改的是“公共通道”不是“某一个人的门”。所有对象都得从这条通道过你把通道堵了谁都走不了。正确思路不是堵通道而是在通道上设一个岗哨——来的对象符合条件才放行你的特殊处理不符合就按原样走。这个“设岗哨”的动作在CE里就是代码注入配合条件跳转。还有一种更隐蔽的翻车方式你不光改了逻辑还没保护寄存器。原指令依赖eax、ecx这些寄存器的值继续往下算你在中间插了一段用自己的值的代码把寄存器污染了后面的逻辑全乱套游戏直接崩。这也是第九关要让你养成的好习惯——动手之前先看清这一条指令前后依赖哪些寄存器。1.3 第九关真正想让你建立的三个思维第一是“对象视角”。你要把游戏里的每个单位看成一个结构体里面有血量、坐标、状态这些字段它们在内存里排成一排靠基址加偏移来访问。带着这个视角去看反汇编那些奇怪的偏移量就不再是天书。第二是“区分变量”。共用代码的钩子上必须有一个能区分对象的变量。它可能是edx里那个0、1、2、3的索引也可能是esi里每个对象唯一的基址还可能是对象结构里某个特征字段。找到这个变量你才知道该在什么条件下执行你的处理。第三是“最小干预”。你只想影响一个对象就不要碰其他对象应有的行为。注入代码里除了必要的判断和赋值其余寄存器状态最好原样还回去。这三个思维建立起来第九关就是水到渠成后面的指针扫描、结构分析也都会顺很多。2. 把共用代码找出来定位链路一步步走2.1 从数值到写入地址先把落点找到任何分析都从数值开始。你先在练习程序里找到当前那根血条的数值用CE的数值扫描把它筛出来得到一个地址。这一步和前面几关没区别血条多半是浮点数扫描时记得把数值类型切到浮点。扫出唯一地址后先别急着改改了就看不到它掉血的过程反而不好观察。拿到地址后右键这个地址选择“找出是什么访问了这个地址”。CE会挂上调试器等游戏下一次改动这个地址时把指令记下来。你让它跑几秒血条自然掉血的那一刻写入指令就会出现在列表里。正常情况下你会看到一条或者几条候选通常只有一条是真正的写入者其余是读取或者别的操作。这一步的关键是耐心。有的程序血条更新频率低你得等一会有的会有好几处写入需要你挨个看。挑那条明显在“赋值”的指令比如mov [xxx],eax这种形态基本就是它了。提示如果你点头“找出是什么访问”列表一直是空的先确认游戏在正常跑动血条确实在变化再检查CE是否挂上了调试权限。很多时候只是你没等到写入发生。2.2 读懂反汇编基址和索引分别藏在哪拿到写入指令后点“显示反汇编程序”把当前这条指令所在的上下文完整看一遍。你要重点关注的是方括号里的寻址表达式它通常长成三种样子[esi10]esi就是对象基址每个对象一个独立地址偏移10是血量字段。这种情况区分对象靠比较esi。[esiedx*410]esi是数组基址edx是对象索引4是每个对象占的字节数或者说指针大小。这种情况区分对象靠比较edx。[ebxecx8]ebx和ecx共同决定地址得结合上下文判断谁是指针谁是偏移。判断到底是哪一种最直接的办法是看寄存器当前的值。CE的反汇编窗口旁边通常会显示寄存器状态你刷新几次观察哪个寄存器在变、哪个不变。一直不变、数值又比较大的多半是基址在小范围内规律跳动的比如0到3来回多半是索引。这里有个经验索引如果是0、1、2、3这种连续小整数几乎可以断定它就是对象编号。因为没人会把一个很大的地址值塞进索引里去那不符合数组访问的写法。2.3 建立“指令—对象”的对应关系光看一条指令还不够你要把这条指令和眼前的几个对象对上号。做法是让血条依次掉血每次掉血时观察edx或对应寄存器变成了几。比如第1根掉血时edx是0第2根是1第3根是2第4根是3那你就把编号和目标对象一一对应上了。这个对应关系是后面写判断条件的依据。假设你要处理的是第4根血条对应索引3那注入脚本里就写cmp edx,3相等时才走你的特殊逻辑不相等就老老实实执行原指令。整个过程就像给花名册上每个学生编号你只对3号学生做特殊交待其他人照常。如果程序用的是指针基址而不是索引那对应关系就是“哪个esi值对应哪个对象”。这时候你可以在写出指令的那一刻右键把esi的值记下来或者干脆在CE里给这个地址设个断点把目标对象的基址抄在一个笔记本里后面比较时用。这个基址在单次运行里是稳定的但重启游戏通常会变所以最后要落到脚本里动态获取或者干脆用索引法绕开这个问题。3. 区分目标对象的三种实用手段3.1 索引比较法最省事优先考虑如果寄存器里留下的是对象索引那恭喜你这是最好处理的一种。判断条件就是一句cmp edx,目标索引配合jne跳过你的特殊逻辑。脚本干净、稳定、不依赖内存地址重启游戏也不受影响因为索引本身是程序按顺序分配的不会因为堆内存重新分配而变化。使用这个方法要注意两件事。一是确认真的是索引不是别的偏移量。你可以通过观察它在每次处理时是否递增来验证。二是确认这个索引和目标对象的稳定对应关系有的程序对象创建顺序会变索引对应关系可能不固定那种情况下要退回指针比较法。在实际项目里索引法能解决七八成的共用代码问题遇到就优先用别把事情复杂化。3.2 指针快照法对象基址唯一时用它如果寄存器里存的是对象自己的基址它们彼此不同而且各不相同那就用指针比较。核心思路是在脚本分配的内存里留一个位置把目标对象的基址存进去钩子里拿当前寄存器值和它比。获取这个目标基址有两条路。一条是分析时直接在反汇编窗口读出来手动记下另一条是更省事的做法先用“找出是什么访问”定位到指令后在断点里查看寄存器把目标对象的基址写到脚本里再在运行时把它动态更新。不过手动写死地址最大的问题是重启会失效所以更稳妥的写法是在脚本里加一段初始化代码用某个固定线索比如血量值去找到当前的目标基址并存起来。指针法适合对象基址启动后就会重新分配、但结构固定的情况。它比索引法麻烦但覆盖面更广。3.3 特征字段法用对象自身的属性做暗号有些情况下索引和指针都不好用那就退一步用对象结构里的某个特征字段来识别。比如目标对象的血量上限是100其他对象是80或者目标对象有一个阵营标记等于1其他是0。你在钩子里读这个字段符合条件才执行特殊逻辑。特征字段法适合那种对象结构里带有明显区分标志的场景。它的缺点是不稳定性相对高——一旦特征字段的值在游戏过程中变化比如血量上限会被加成判断就可能失效。所以用它之前要确认这个字段在你想影响的整个时间段里都保持稳定。稳妥起见可以把特征字段和索引组合起来做双重判断提高命中率。区分手段依赖的线索稳定性适用场景索引比较法寄存器里的对象编号高数组式对象编号固定指针快照法对象独立基址中对象结构不一基址会变特征字段法结构内的某个属性中低有唯一标志字段的对象4. 代码注入模板逐行拆解4.1 模板骨架newmem、原始代码、返回点CE的自动汇编提供了一个“代码注入”模板打开后你会看到几个关键部分。alloc(newmem,2048)是向目标进程申请一块内存用来存放你自己的代码大小默认2048字节一般情况下够用。label(code)、label(return)是声明标签方便你在代码里跳转。核心结构通常是这样的alloc(newmem,2048) label(code) label(return) newmem: // 你的判断和处理逻辑放这里 code: // 原始指令照抄在这里 jmp return 原始地址: jmp newmem nop return:理解这个骨架很重要。程序原本会执行“原始地址”这条指令现在你让它先跳到newmem在newmem里走你的逻辑判断完之后再跳回code执行原始指令最后jmp return回到程序原来的执行流。你把原指令完整搬了过来只是中间插了一段自己的判断。这个过程没有破坏原有逻辑只是“劫持”了一下。jmp指令在32位程序里占5个字节一个操作码加四个字节的相对偏移所以你在原始位置改写时被覆盖的字节数必须是5的整数倍不够就往后多吃几条指令并把这些指令一起搬进newmem。这是很多人第一次写注入最容易漏掉的细节覆盖长度对不上汇编直接报错。4.2 条件跳转怎么组织才不会跳错你的特殊逻辑不是无条件执行的得包在判断里。假设用索引法逻辑大概是这样先比较索引是不是目标值不是就跳到code执行原指令是才执行你的赋值。newmem: cmp edx,3 jne code mov dword ptr [esiedx*410], (float)100 jmp return这里的顺序要理清楚jne code是“不相等就跳去执行原始逻辑”。如果相等就往下走你的赋值赋值完直接jmp return返回。两条路径最后都要回到程序正常的执行流不能有哪条路径断在半路否则程序就跑飞了。写跳转时最容易错的是标签名对不上或者漏了某条路径的返回。我习惯把code和return两个标签固定成模板每次只改判断和赋值部分减少手误。4.3 寄存器现场保护和浮点数的坑寄存器保护这件事很多人不做也没翻车但那只是运气好。稳妥的做法是在你的代码里如果改动了原指令后续要用到的寄存器就先把它们存起来用完再恢复。简单粗暴的办法是在进入newmem时pushad离开时popad把八个通用寄存器一次性保存和恢复。不过这会把esp也一起动有些场景反而不合适更精细的做法是只 push/pop 你实际改动的那几个。浮点数是个高频坑。血条很多时候是浮点类型写入用的是movss之类的指令。你在注入里想写一个固定血量不能写mov eax,100那写进去的是整数编码显示出来是个接近0的怪异小数。正确写法是把100按浮点解释比如mov eax,(float)100或者mov dword ptr [addr],(float)100。这个坑不少人踩过血条数值变成 1.4e-43 那种看着就懵。注意改浮点值前先确认字段类型。可以在CE内存浏览器里切换显示类型整数和浮点显示出来的值差别很大看一眼就清楚了。4.4 AOB注入与符号注册比起按固定地址打补丁用AOB字节数组扫描定位指令更抗折腾。aobscanmodule会按特征字节在指定模块里搜索指令找到后再注入。写起来大概是这样aobscanmodule(TUT,Tutorial-i386.exe,F3 0F 11 04 96) alloc(newmem,$1000,TUT)这样即使程序更新、地址偏移变了只要字节特征还在脚本就能自己找到位置。缺点是有时候特征字节不够独特会扫到多处这时候要把特征写长一点把前后文里更有辨识度的字节也带上。脚本写完执行后习惯用registersymbol把注入点注册成一个符号方便以后引用在[DISABLE]段里别忘了把原始字节写回去并用unregistersymbol和dealloc清理。一个完整的脚本必须能干净地开启和关闭只开不关游戏重启前会一直挂在那块内存上容易出各种幺蛾子。5. 第九关完整复现从找地址到存成CT表5.1 定位写入指令并读出关键寄存器打开练习程序进第九关能看到几根血条。先找其中一根的数值地址扫描时把类型设成浮点扫出唯一地址后右键“找出是什么访问了这个地址”。等血条掉血列表里出现写入指令通常是mov [esiedx*410],eax这种形式。点“显示反汇编程序”观察edx的变化。让几根血条分别掉血记下每根对应的edx值。你在这一步要拿到两个信息一是确认区分变量是edx索引二是确认目标血条对应的索引值。假设目标血条对应索引3后面就围绕这个值写判断。如果你观察到的寄存器是esi而不是edx说明程序用的是对象基址那就改走指针比较法记下当前目标对象的esi值。两种路径的处理思路一致只是比较的对象不同。5.2 目标字段偏移的确认与验证光有索引还不够你还得知道血量字段在对象里的偏移也就是那个10是不是真的要改的位置。做法是在反汇编窗口里看这条指令访问的内存地址记下这个地址再回CE内存浏览器里看它附近的内容。血量值附近通常还有别的字段通过对比多个对象的内存布局你能确认哪个偏移是血量。确认之后先手动改一次这个地址的值看血条有没有跟着变。变了说明偏移对了再把值恢复别让它影响你后面写脚本时的观察。这一步是复查很多脚本写完了不生效根子就在于偏移抄错了。5.3 写脚本并逐段验证按前面的模板写注入脚本先只放判断和原指令不放你的特殊赋值执行一下看看程序有没有崩。这一步是验证注入点是否安全。如果这时候就崩了多半是覆盖字节长度不对或者寄存器被污染。确认注入点稳定后再加你的特殊逻辑。以索引法为例脚本大体是这样字节和偏移以你实际反汇编结果为准[ENABLE] aobscanmodule(TUT,Tutorial-i386.exe,F3 0F 11 04 96) alloc(newmem,$1000,TUT) label(code) label(return) newmem: cmp edx,3 jne code mov dword ptr [esiedx*410],(float)100 jmp return code: movss [esiedx*410],xmm0 jmp return TUT: jmp newmem nop return: registersymbol(TUT) [DISABLE] TUT: db F3 0F 11 04 96 unregistersymbol(TUT) dealloc(newmem)执行后回到游戏里看目标血条保持满值其他血条正常掉血说明判断条件生效了。如果全部血条都停了说明判断写反了jne应该改成je或者索引值填错了。逐个条件排查很快就能定位。5.4 保存成CT表并做好注释脚本验证通过后把它保存到CT表里。保存之前我习惯做几件事给脚本起个看得懂的名字比如“第九关-只锁定目标血条”在脚本里加几行注释写清楚注入点、判断依据、重启游戏后可能需要重扫的提示。CT表这东西过段时间再看很容易忘注释是给自己留的后路。另外CT表里的脚本最好和“找出是什么访问”记录一起保存方便以后复查。如果用的是AOB注入通常重启游戏后还能直接生效如果写死了地址就得标注清楚是临时方案。养成这个习惯你的CT表会越攒越有用而不是每次都要从头重做。6. 常见问题与排查技巧实录6.1 注入后游戏崩溃的几种典型原因崩溃排在第一位的原因就是覆盖字节长度不对。你的jmp占了5字节但如果只覆盖了4字节的原指令剩下1字节成了半条指令程序执行到那儿必然出事。解决办法是数清原指令的机器码长度不够就多吃一条并把吃掉的指令完整搬进newmem。第二个原因是寄存器污染。你的判断逻辑用了eax或者ecx但没恢复原指令后面的代码依赖这些值算出来就错。解决办法是判断逻辑里尽量少碰寄存器碰了就 push/pop 还回去。第三个原因是浮点和整数写反。原指令操作的是xmm寄存器你却用mov eax,值去写写进去的字节含义完全不同游戏后续计算直接乱掉。看清楚原指令操作数类型再动手。6.2 脚本失效与地址漂移脚本昨天还好好的今天一开就没反应八成是地址漂移。写死地址的脚本在游戏重启后基本都会失效因为堆内存重新分配对象地址变了。解决办法就是前面说的AOB注入让脚本自己去找指令。如果用了AOB还是失效可能是特征字节被更新覆盖了或者扫描到了错误的匹配点。这时候把aobscanmodule的特征写长一点带上更独特的字节序列再用调试确认扫出来的地址是不是你要的那条指令。还有一种隐蔽的失效脚本执行成功了但游戏版本更新后逻辑改了原来那条写入指令不再是共用代码。表现是判断条件还在但对象行为对不上。这种只能重新分析一遍好在前面的方法论是通用的重做一次很快。6.3 误伤其他对象的排查思路判断条件写错是误伤的头号来源。比如你本来是cmp edx,3结果手滑写成cmp edx,2目标就变成了另一个对象。表现是“我锁的血条没动隔壁的却满了”。遇到这种情况先把判断条件里的值挨个试一遍对比哪根血条有反应就能确定索引对应关系。如果是用指针比较误伤常常是因为目标基址取错了或者运行过程中基址变了导致比较失败、退化成无判断执行。这时候检查你的基址获取逻辑确认它在整个运行期间稳定。用特征字段法的则要确认特征字段的值没有在运行中途变化。6.4 常见问题速查表现象可能原因排查方向所有对象都停止变化直接改了共用指令未加判断加入条件跳转只处理目标游戏立刻崩溃覆盖字节长度不对数清原指令长度补足NOP或搬迁指令执行后数值变成极小的小数浮点写成了整数用(float)修饰写入值重启游戏后脚本失效写死了地址改用AOB扫描定位判断不生效对象无反应索引或指针取错逐个值验证对应关系目标对象正常其他对象也变了条件写反或分支未返回检查jne/je与返回标签脚本能开不能关DISABLE段不完整补全原始字节还原与去分配写到这里我自己回头复现第九关的时候最省时间的做法其实不是一开始就写脚本而是先花几分钟把反汇编窗口里那条指令的上下文看透把寄存器的角色搞清楚。多少次翻车都是因为跳过了观察这一步急着写注入结果字节数对不上、判断条件取错来回折腾的时间比老老实实看一遍多得多。共用代码这个坎跨过去之后你会发现后面指针扫描、多级指针那些内容本质上都是同一套“找到对象、识别对象、精准干预”的思路在延伸。
返回列表