从Pwn栈原理过渡到Re控制流劫持+实操演示

前言

逆向工程(RE)与漏洞利用(Pwn)看似是两个独立的技术方向,但核心都围绕「理解程序执行逻辑」与「控制程序运行流程」展开

文章前半部分以理论先行,后程以实战为导向,不讲冗余理论,快速掌握逆向控制流劫持的核心技巧,轻松实现程序校验绕过

其实操作起来是很简单的,理论是为了更好厘清逻辑,如果觉得比较难理解,可以先看实操演示

Pwn栈原理

接下来我要先引入一个概念,在我们学的ret2libc1中,32位程序下,在编写EXP时,payload的system地址和/bin/sh地址之间是要填入一串四字节的垃圾数据的。看似随意的一个操作,其实在正常而非攻击的32位程序的情况下,是call一个函数之后,执行结束后的返回地址。比如我执行完system函数,我要返回main函数,main函数的地址就写在那

只不过,我们在攻击时,已经获取了shell之后,至于原程序,要不要返回,返回到哪,我们不需要管,爱去哪去哪,填入这四个字节的数据,只是为了将我后面/bin/sh的地址放在正确的位置上,而不是放在返回地址上而已

# 示例1:32位 re2libc1 EXP 核心片段(需填充4字节垃圾数据)
from pwn import *
p = process("./re2libc1")
system_addr = 0x08048400  # 示例system地址
binsh_addr = 0x0804A020   # 示例/bin/sh地址
# payload = 垃圾数据(4字节) + system地址 + 垃圾数据(返回地址) + /bin/sh地址
payload = b'A'*4 + p32(system_addr) + b'B'*4 + p32(binsh_addr)
p.sendline(payload)
p.interactive()

刚刚说的是32位的程序,那么64位的程序为什么不用写那串返回地址了呢。是因为64位的程序,system执行时的返回地址,是system_addr下一个栈位置的内容,我们用不到,自然不用写

# 示例2:64位程序 EXP 核心片段(无需填充4字节返回地址)
from pwn import *
p = process("./re2libc1_64")
system_addr = 0x400500     # 示例system地址
binsh_addr = 0x400600      # 示例/bin/sh地址
# 64位通过rdi传参,直接构造rop链,无需填充返回地址
payload = b'A'*0x18 + p64(0x400700) + p64(binsh_addr) + p64(system_addr)
p.sendline(payload)
p.interactive()

Payload 构造对比

32位 (ret2libc1)
+00

垃圾数据 (padding)
+04

System 函数地址
+08

垃圾返回地址 (4字节)
▲ 重点: 32位必须填这个占位
+0C

/bin/sh 参数地址

64位 (ret2libc1_64)
+00

垃圾数据 (padding)
+18

POP RDI ; RET
+20

/bin/sh 参数地址
+28

System 函数地址
不用填返回地址,直接 ROP 链

理解完这个内容之后,我们会容易理解逆向中的控制流劫持

Re

刚刚了解了程序的返回,那么执行的流程是怎么样的呢?

  1. 函数执行到末尾,会遇到ret指令(这是函数返回的触发指令);
  2. 执行ret指令时,CPU 会做两件事:
  • 第一步:从栈顶弹出「返回地址」(这个地址之前被call指令压入栈);
  • 第二步:把弹出的返回地址直接写入 RIP 寄存器;
  1. 此时 RIP 指向原流程的指令地址,程序跳回之前的执行流程,完成 “返回”。

返回地址平时 “待在栈里”,只有返回的瞬间才会临时进入 RIP—— 没有任何寄存器会长期存储它,栈才是它的存储区,这一点 32 位和 64 位程序完全一致。

如果我们能知道这一点,那么我们就可以控制程序要返回的地址

  1. ret指令前下断点,运行到断点处;
  2. 查看栈顶返回地址:64 位用rsp、32 位用esp,栈顶(*$rsp)存储的就是 “校验函数执行完后要返回的地址”(比如原返回地址是 0x401000,对应 “校验失败提示” 的代码);
  3. 修改栈顶返回地址:把它改成核心功能代码的起始地址(比如 0x401500,对应 “程序主功能” 的入口);
  4. 继续执行,程序会跳过原返回流程,直接进入核心功能 —— 相当于绕开了校验。

原理是这样,那么具体要怎么做呢

结合我们之前学过的控制流劫持,我们可以做到控制程序的走向

IDA动态调试–控制流劫持

之前讲过,在我们在控制程序的跳转中,在跳转前将目标地址压入RIP寄存器,这样就能跳转到我们想要的地址。

ret本身也是一种跳转,因此也符合这样原理,只不过,在跳转前,函数的返回地址是先从其他寄存器转来RIP的,64 位是rsp、32 位是esp。我们提前修改这里的地址,就可以达到想要的效果。

那现在,思路打开,我既然可以控制程序走向,是不是可以绕过检测,破解程序了。想要永久破解程序,我们最基础的方法就是改机器码。

控制程序

现在你用 IDA 打开了一个可怜的64位程序,它有一个校验函数,此时我们要绕过这个函数,直接使用程序的功能

控制跳转

我们需要什么?

  1. cmp eax, 0x1(对比校验代码)
  2. 0x401020: jne 0x401080(失败跳转到退出),其中 0x401020jne 的地址, 0x401080 是 “校验失败” 地址
  3. 0x401050 是 “核心功能” 地址
  4. jne核心功能的偏移量

很显然,我们需要:1. 让跳转变为短地址无条件跳转,2. 让跳转的地址变成 “核心功能” 的地址

这里要补充两个知识点:

1.在 PE 程序里,汇编指令会有对应的机器码,由操作码和操作数组成,在 IDA 中可以看到其 16 进制代码。例如,XOR EAX, EAX对应的机器码省略 0x,就是33 C0,其中33是操作码,表示XOR

2.Windows的小程序正常情况下都是采用小端序规则存储机器码的,简单来说,程序是从高地址向低地址读码的,例如E9 7B 00 00 00,程序是从右边往左边读的,所以E9后面的地址是0x0000007B

现在进入正题:0x401020: jne 0x401080的机器码是这样的:75 5E。拆解一下,75jne的操作码,跳转指令后面跟的是偏移量,5E就是从0x401020 +20x401080的 1 字节偏移量,+2的原因就是因为jne这个指令本身占2字节,从jne后面开始计算偏移量

如果要改成跳转到核心功能0x401050,先算偏移:0x401050 - (0x401020 + 2) = 0x2E。现在只需把0x401020地址处的机器码75 5E替换成EB 2E,(EB是短跳转jmp的操作码,2E是偏移)就能直接跳去核心功能,绕过校验

IDA View (Patch 演示)
x86/x64

[原始指令] 校验未通过
0x401020
75 5E
jne 0x401080
// 若不相等(EAX!=1),跳转到失败处

⬇️ 修改机器码 (Patch) ⬇️

[修改后] 强制跳转绕过
0x401020
EB 2E
jmp 0x401050
// 无条件跳转到核心功能区
计算公式:
目标(0x401050) – (当前(0x401020) + 2) = 0x2E

如果核心功能地址和 jne 地址的偏移超过 ±127(比如要跳0x401100),则改用长跳转:

  • 长跳转公式:偏移量 = 目标地址 - (当前指令地址 + 5)(E9 长跳转占 5 字节);
  • 机器码格式:E9 xx xx xx xxE9 是 jmp 长跳转操作码,后 4 字节是小端序补码偏移)。

比如要从0x4010200x401100(长跳转):

  • 偏移计算:0x401100 - (0x401020 + 5) = 0x7B
  • 机器码:E9 7B 00 00 00

那么如果是往回跳呢?例如:0x401080 :jne 指令要跳到0x401050,还是先算偏移0x401050 - (0x401080 + 2) = -0x32,然后用0xFF + 1 - 0x32 = 0xCE,机器码为EB CE,+1因为是用高一位0x100来减,也就是0xff+1

长跳转回跳呢?比如要跳0x400F00,4 字节补码计算规则为0x100000000 - 绝对值(0x100000000 是 4 字节的模),即0x100000000 - 0x185 = 0xFFFFFE7B(小端序7B FE FF FF),因此长跳转机器码为E9 7B FE FF FF

你说算偏移量太麻烦了,有没有简单的方法。那是当然!

控制返回

假如0x401090 处是 ret 指令(机器码C3),原本执行后会跳回0x401000(校验失败地址)。我们可以替换这个ret指令,我们通过push 目标地址 + ret的组合,直接写死目标地址,实现直接跳转到核心功能——最大的好处是无需计算偏移量

32位中,如果要让ret跳转到核心功能地址0x401050,只需把0x401090处的C3替换为机器码:68 50 10 40 00 C3。
- C3 是ret指令的操作码;
- 68 是push imm32的操作码,作用是把后面4字节的地址(小端序50 10 40 00,对应0x00401050)压入栈;

64位有点区别,需要替换为:48 68 50 10 40 00 00 00 00 00 C3
- 48 68是push imm64的操作码,(48是64位的模式前缀,68是推送操作码)将后8字节的地址压入栈

实操演示

下面将展示我破解我自己的64位程序,为了方便演示,我将程序逆向后的可视化优化到最好,几乎IDA看到的就是源码。

可以清晰地看出程序的逻辑,一个对比函数cmp,以及密码正确和错误跳转到的函数ok和err。简单地模拟了一个程序的运行流程,现在我们要破解这个程序,也就是让它永远执行ok函数。

在cmp判断处进入汇编界面,可以看到,call cmp对比后错误就会jnzcall err,我们想执行的okjnz下面一行。

接下来先展示用动态调试进行控制流劫持方法(64位程序):在jnz跳转处下断点,并且记住ok函数地址

进入动态调试,随便输入内容,修改RIP寄存器为ok函数地址

修改完后不要忘记,在左上角Edit – Patch program – Apply patches to应用修改的内容。在运行程序,就可以发现我们成功改变了程序运行逻辑。

但是动调调试只能临时改变逻辑,如果想永久破解程序,我们就要对机器码下手。

方法1:直接nop掉jne前两字节的机器码,这样程序就不会触发跳转,直接向下执行ok函数

方法2:计算偏移量,将机器码patch成EB 00,Edit – Patch program – Apply patches to应用修改的内容,这样就无条件跳转到下一条指令call ok

当然,如果有retn指令可用,也是可以用它来控制的

免责声明

本文所涉及的逆向工程技术、代码示例、调试方法等内容,仅用于逆向工程学习研究、软件安全技术交流及合法的程序漏洞分析

作者编写本文的核心目的是分享技术知识,帮助学习者理解程序执行逻辑与安全防护原理。任何个人或组织不得将本文中的技术、方法用于破解商业软件、入侵他人系统

对于擅自利用本文内容从事非法活动所产生的一切法律责任、经济损失及不良后果,均由行为人自行承担,本文作者不承担任何连带责任。

请所有读者遵守法律法规和行业道德,尊重软件著作权,共同维护健康的网络安全环境

如文章存在学术性错误,请联系penetr4t10n@163.com指出
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇