ROP(Return-Oriented Programming)返回导向编程 · 游戏安全相关技术边界探索【3】
系列目录(共四篇)
- ROP 是什么,原理,初步认识 ROP
- 初步探索 — 基于 ROP 技术弹个 MessageBox & 安全退出
- 深入探索 — 挖掘 ROP 在游戏安全领域的能力边界(本文)
- 反作弊对抗相关思考 & 检测手段(待发布)
深入探索 — 挖掘 ROP 在游戏安全领域的能力边界
历史回顾:第 2 期:基于 ROP 技术弹个 MessageBox & 安全退出
上一期,我们走完了一个最简单的 ROP 技术闭环——弹个框结束。
这一期:我们打算在 draw-test 里做一次加压实战,用 ROP 搭出带 循环 与 分支 的绘制逻辑,看看能否像常规程序一样支持 判断、循环、跳转——这也是 ROP 在游戏安全里常被讨论的「能力边界」。
伪代码如下(图 1):

图 1. Draw / getDrawMode 伪代码(循环 + 点/线分支)。
运行效果(图 2):

图 2. draw-test 运行效果(跟随鼠标画点/画线)。
一:整体布局
基于伪代码复杂度分析,实现上要拆成 两条 ROP 链:主链跑 Draw,子链负责 get drawMode。链里还要做出 分支(画点 / 画线)和 循环(线程不退出、反复执行,类似外挂里的常驻逻辑)。画点还是画线,可改工程源码切换对比(图 3):

图 3. 工程中修改 drawMode 等字段,对比画点与画线路径。
主链块与子链块(图 4):

图 4. 主链、子链各自「ROP 链区 + 数据区」分开存放。
讲解:
- 主链块(MainRopBlock):仍按第二期习惯,ROP 链 与 数据区 分开排布。数据区需保留 hDC、鼠标坐标、Sleep 间隔、drawMode 字段位置,供后续 gadget 读/写。
- 子链块(SubRopBlock):对应伪代码里 get drawMode 一段,单独再占一块远程内存;内部同样是 链区 + 数据区,与主链 各管各的,避免切 RSP 时在对方区域上踩栈、踩数据。
五个 API 调用的专用栈(各 8KB)(图 5):

图 5. GetDC / GetCursorPos / NtGdi / Sleep 等调用各用一块 8KB 栈,并预留回到主链的布局。*
讲解:
- 专用栈(同第 2 期):每个 API 调用前切到自己的 8KB 栈,并在栈里留好回到主链的槽位;本期五个调用对应图 5 里五块栈区。
- API 选型:SetPixel、LineTo、Sleep 等 Win32 壳函数带 Shadow space 写入,易踩 ROP 布局;Demo 改用 NtGdiSetPixel、NtGdiLineTo、NtDelayExecution 这些 NT 函数,栈更「干净」。
补充: 上一期已介绍 Shadow space;用 IDA 很容易分辨并规避。
二、主 ROP 链 — GetDC(hDC 写入数据区)
如图 6,主链 gadgets 调用 GetDC,hDC 存回 MainRopBlock 数据区,供后续读取使用。

图 6. GetDC 返回的 hDC 写入 MainRopBlock 数据区。
三、主 ROP 链 — GetCursorPos
如图 7 中选中的 gadgets 调用 GetCursorPos。此处为 循环起点(A_Pin)。后续主 ROP 链末尾会切 RSP 回到此处(图 7、图 8 支撑起循环逻辑)。

图 7. 循环锚点 A_Pin。

图 8. 主 ROP 链末尾切 RSP,回到循环起点 A_Pin。
四、主 ROP 链 — 参数准备

图 9. 为 NtGdiSetPixel / NtGdiLineTo 共用前四参;读 MainRopBlock 数据区填入寄存器。
讲解:
- 准备好参数:NtGdiSetPixel 与 NtGdiLineTo 调用约定相同,图 9 先把 rcx、rdx、r8、r9 填入。
- 读数据区:GetDC、GetCursorPos 等步骤已把 hDC、坐标等放进 MainRopBlock 数据区,此处 gadgets 读出「内存 → 寄存器」。
提醒: rcx~r9 填好后尽量保持不动;后续画点 / 线还要沿用这组值,如必须变动,需先保存再恢复。
五、主 ROP 链 — 调用子 ROP 链
四参已在主链就绪,接下来要按 drawMode 决定画点还是画线——对应图 1 里 get drawMode 的逻辑。因此切 RSP 入 SubRopBlock 子链做判断(图 10)。

图 10. 修改 RSP,进入 get drawMode 子链。
讲解:
- 控制流看 RSP:ROP 里很少用「call 进去、ret 回来」这一套叙事——把 RSP 指到另一段链区,接下来就执行那里的 gadgets,等价于切换控制流;图 10 即主链把 RSP 指到 SubRopBlock。
- 主链、子链可互切:子链跑完 get drawMode 后,还会再 切 RSP 回主链,继续画点或画线。普通顺序代码里不常这样「随时换栈、换链」,多链 ROP 里却很常见。
- 横跳要守上下文:主链与子链并存时,来回切 RSP 会是常态;第四节备好的 rcx~r9 可能被下一链里的 gadget 改掉,所以时刻保持思路清晰,该存则存,别把已准备好的参数弄丢。
六、子 ROP 链 — 保存上下文
切入子链后,先落实上一节的「该存则存」——图 11 在 ropSubChain 链头用 gadgets 把 rcx 写入 save_rcx_store、rdx 写入 save_rdx_store(SubRopBlock 数据区里的两个槽位,Demo 中为 subDataBase+0x00 / +0x08)。

图 11. 子链入口保存寄存器上下文。
讲解:
- 存 rcx、rdx:第四节在主链已把 hDC、坐标等填进这两枚寄存器,正是 NtGdiSetPixel / NtGdiLineTo 的第一、第二参数。子链里 get drawMode 与 IF 判定 还要用 rcx、rdx 做别的事,会覆盖原值——链头先写入
save_rcx_store / save_rdx_store,分支走完再从这里恢复,绘图参数才不会丢。
- 不存 r8、r9:第三、第四参数(如颜色)已在 r8、r9 里就绪;ropSubChain 中未对其进行修改,回到主链后仍可原样用于 NtGdi* 调用。
七、子 ROP 链 — 获取绘制模式 + IF 判定
get drawMode 之后,图 12 选中部分在 ropSubChain 里完成 IF 判定——按 drawMode 在画点、画线两条 ROP 路径间分支(LineTo 一支见图 13)。

图 12. test / cmovz / jmp rax 实现分支。
讲解:
- 分支 = 改 RSP:这里没有常规
if/else,而是先备两枚「跳栈」gadget(add rsp, N; ret),再用 IF 判定 选中其一,jmp rax 跳过去执行——本质仍是改 RSP 指向,落到链上不同编号。
- 两路偏移:rax ←
add rsp, 8h; ret(drawMode 非 0,画点支),rdx ← add rsp, 58h; ret(drawMode 为 0,LineTo 支,对准编号 [26])。
- 技术重点:
test ecx, ecx; cmovz rax, rdx; ret 用 ecx(已载入 drawMode)做零测;为 0 时 cmovz 把 rdx 写入 rax,即选中 58h 偏移那一支。
- 执行跳转:编号 [14] 的 jmp rax 跳入当前 rax 上的 gadget,RSP 一次跳过 58h,下一格即编号 [26](与图 13 高亮段一致)。
- LineTo 收尾(图 13):编号 [26]~[33] 从
save_rcx_store / save_rdx_store 恢复 rcx、rdx;[34]~[35] pop rsp 切回主链 mainRop_lineToPin(mainRopChain[35]),接上第八节 API 调用。

图 13. 走 LineTo 路径时的栈偏移。
思考: 另一分支(画点路径)此处不展开,请自行对照工程吃透。
八、主 ROP 链 — mainRop_lineToPin 落脚点
子链在 drawMode == 0 时经 [34]~[35] 把 RSP 落到主链 mainRop_lineToPin(mainRopChain[35])。图 14 即 LineTo 路径在主链上的入口:[35] 切 LineTo 专用栈,[36] 发起 NtGdiLineTo(栈帧布局参考以前的教程)。

图 14. mainRop_lineTo_pin:Sub ROP 在 DrawMode == 0 时落入 [35],[36] 指向 LineTo 专用栈。
rcx~r9 在第四节已备好,子链又从 save_rcx_store / save_rdx_store 恢复了 rcx、rdx,此处只剩换栈 + 调 API——与第二期 MessageBox 段同一套路,故不再逐步展开。调用返回后两路会在 A_pos3(mainRopChain[37])汇合,进入第九节 Sleep 与循环。
九、主 ROP 链 — 执行 Sleep
画点 / 画线两路在 A_pos3(mainRopChain[37])汇合后,图 15 选中段负责节流并闭合循环:先 Sleep 约 500ms(Demo 为 NtDelayExecution),再把 RSP 切回第三节的循环锚点 A_Pin(链上编号 [7])。

图 15. [37]~[42] Delay 专用栈;[43]~[44] 回到 A_Pin([7])。
讲解:
- A_pos3([37]~[42]):rcx = 0(Non-alertable),rdx 指向数据区里的延时(Demo 为 -500ms);pop rsp 切到 Delay 专用栈,调用 NtDelayExecution,返回 A_pos4([43])。
- 闭合循环([43]~[44]):再 pop rsp 写入 A_Pin,下一格从编号 [7] 继续执行——即再次 GetCursorPos,与图 7、图 8 的循环设计首尾相接,进入下一轮 Draw。
收尾问题
- 能做复杂外挂吗? 能——要维护整块「ROP 链王国」,往往还得叠多条 子 ROP 链(本期 draw-test 就是高复杂度样例)。
- 适合当常规开发手段吗? 对半开:调试难、硬伤多;但若防御方不熟 ROP,短期内也可能拦不住。
- ROP 还能玩多久? CET 是方向性克星,落地节奏受生态制约;从攻防时效看,不会一夜退场。
- 缺 gadget 怎么办? 很常见。本期用
test ecx, ecx; cmovz rax, rdx; ret 而非 add rsp, rax; ret 正是此原因;授权研究里也可在目标进程 RWX 区塞小零件(勿滥用)。
- gadget 去哪找? 渗透常扫 系统模块;外挂更宜盯 游戏模块——跟版本、体量大、命中率高。
- 真有人用 ROP 做外挂吗? 有,只是很少公开讲。
本篇小结
- 本期在 主链 + 子链 双块内存上,用 GetDC / GetCursorPos / NtGdi / Sleep* 与 5 个 8KB 专用栈,实现 循环(A_Pin)、主链子链切换 与 IF 分支(cmovz + jmp rax 改 RSP)。
- 修改 RSP = 改控制流;多链横跳是常态,必要时,别忘记保存上下文数据。
- 延续第 2 期:带 Shadow space 的 Win32 API 易破坏链布局,NT 函数或许更加合适;小零件凑不齐时,授权研究可在目标进程 RWX 区自备。
- 从能力边界看:ROP 能 堆高复杂度逻辑,但 工程成本与硬伤 明显,适合作为 边界探索,而非首选开发范式。
开源
本系列相关 Hawkeye 开源仓库:
本篇对应 Demo:553K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6Z5j5i4N6C8k6i4W2W2i4K6u0V1e0r3g2G2i4K6u0r3K9r3q4%4K9$3g2&6k6g2)9J5k6s2u0G2M7q4)9J5c8Y4c8J5k6h3g2Q4x3V1k6E0j5h3W2F1i4K6u0r3c8r3g2E0L8#2)9J5c8X3c8J5j5i4N6Q4x3X3c8@1k6i4y4@1
下一期: 反作弊对抗相关思考 & 检测手段。