# 总述
本篇文章主要讲解了栈迁移的原理和使用。
认识了利用 one_gadget 快速 getshell。
了解了和上一节不一样的 ret2reg,返回寄存器式的 rop 攻击。
学习了并且利用部分高级 ROP:ret2dl,SROP,BROP。
# 栈迁移
# 原理:
在栈溢出攻击中我们构造的 rop 链可能比溢出的长度还要长,导致没有办法把完整的 rop 链写入。这个时候就需要栈迁移,利用 ebp/rbp 来控制我们输入的地方,来扩展我们写入的 rop 链长度。
利用上一篇文章的程序,我们来看看怎么去栈迁移。
1 |
|
我们来看看原先的汇编和栈帧是什么样的:
解释一下 rbp 的作用,rbp 是基址指针寄存器,用来存放当前栈底的地址。这里可以看见当我们去执行一个 call 的时候其实会把旧的 rbp 存入栈中,为了之后还原栈底保持原来流程中的栈的位置正确。
我们可以把每一个函数执行时使用的栈空间的位置视为一个单独坐标 y,比如 main 使用的是 y=1 的栈空间,vuln 使用的是 y=2 的栈空间,在每一个函数内程序的执行指令使用的参数视作 x,main 函数对于我们来说是在这个 y=1 坐标尺度下面去找对应(x,1)这样的参数来完成当前的流程,如果说 call 进入一个新函数 vuln,相当于跳转了一个新的坐标尺度 y=2,然后函数使用的是在(x,2)里面的参数去执行指令。这样一步一步的执行,执行完一个 vuln 函数后我们还要回到 main 的尺度去执行完 main 函数,于是要利用 rbp 去记入每一个函数对应 y(使用的栈空间地址)—— 也就是栈底地址。 push rbp
用来保留旧 rbp 的值,也就是 main 函数对应的栈空间尺度 y。
而 rsp 就是对应的使用的栈空间的指标,说明了目前使用的栈顶地址。 mov rbp, rsp
这个指令就是开始设置一个新的栈空间。
当 vuln 函数执行结束时,rip 即将执行 leave 与 ret 两条指令恢复现场,leave 与 ret 指令则相当于完成如下事情来回到原先的尺度:
1 | leave 等价于 |
- 清空当前函数栈以还原栈空间(直接移动栈顶指针 rsp 到当前函数的栈底 rbp );
- 还原栈底(将此时 rsp 所指的上层函数栈底 old rbp 弹入 rbp 寄存器内);
- 还原执行流(将此时 rsp 所指的上层函数调用 foo 时的地址弹入 rip 寄存器内);
我们学习完栈空间的调用,就可以利用下面的方法,改变我们要写入的地点,控制我们要写入的函数 y 的值(32 位 ebp/64 位 rbp)。
总结一下栈迁移的原理,核心是利用两次的 leave;ret,第一次 leave ret; 将 ebp 给放入我们指定的位置(这个位置的就是迁移后的所在位置),第二次将 esp 也迁移到这个位置,并且 pop ebp 之后,esp 也指向了下一个内存单元(此时这里放的就是 system 函数的 plt 地址),最终成功 GetShell。
具体执行步骤是这样的:
step1:
- 确定溢出可以覆盖到 ebp 和 ret 两个位置,也就是有两个偏移的溢出量。选取合适的可执行区域作为我们栈迁移的地址 gold_addr。
step2:
- 寻找 leave ret 这个 gadget 的地址,可以用 ropgadget 找,也可以 ida 里面看看其他函数的尾巴有就可以用。
step3:
- 设置覆盖到 (ebp/rbp) 的位置是我们的 (gold_addr - 0x4/0x8),把 ret 覆盖成 leave ret 这个 gadget。
这样设置完,当前函数结束之后会发生:
mov esp, ebp
,还原栈顶指针,但是 ebp 已经改写到 gold_addr,esp = gold_addr - 0x4 也就是栈顶。
pop ebp
, 把栈顶的值弹出,设置 ebp = gold_addr - 0x4。这个时候 esp 会上移一个偏移,就是 gold_addr, 之后执行
pop eip
把 leave ret 放在 eip 里面了,篡改执行流去执行 gadget leave ret
mov esp ebp
把 esp = gold_addr - 0x4,栈顶被劫持到这里了。
pop ebp
无实际作用,还是 ebp = gold_addr - 0x4,esp 被拉高一个偏移 esp = gold_addr。
pop eip
把 eip = gold_addr 设置完成,实现了栈迁移。
然后我们下一次输入就可以输入一些 shellcode 在这个区域,然后可以去执行了。
# 实例:
ciscn_2019_es_2:
在 vul 函数内部有两次输入,但是溢出长度只有 8(32 位内是两个偏移刚好覆盖 ebp 和 ret),然后利用工具可以发现文件内部有 leave ret
,刚好构成了栈迁移条件,我们可以把栈迁移之后执行的地方放在 s 上面,然后去执行这个 s 地址。发现有 printf 可以利用’\0’截断的特点泄露出 ebp 内部储存的值,也就是旧的 ebp(main 的 ebp)。利用他们之间的偏移量去计算 s 的地址,确定栈迁移地址。然后构建完整攻击链。
偏移量进入到函数内部去查看 stack,可以看见 ebp 存的值就是 main 的 ebp 而这个值和 rbp 差了 0x38。
]
由于第二次利用 pop eip
,esp 会下移,也就是说接下来的执行流是在一个偏移量后,所以我们把 system 放在 aaaa 后面。
exp:
1 | from pwn import * |
# one_gadget:
# 原理:
one gadget 是 libc(动态链接库)中存在的一些执行 execve ("/bin/sh", NULL, NULL) 的片段,这些片段是开发人员留下来的。
one_gadget
是在 CTF(Capture The Flag,网络安全技术竞赛)二进制漏洞利用、渗透测试等领域中一个非常实用的工具,主要用于在利用 Linux 系统下的漏洞时快速找到合适的 execve("/bin/sh", NULL, NULL)
调用链,也就是能够直接获取 shell 的关键代码片段。
one_gadget
工具会对 libc
库文件进行静态分析,通过查找特定的汇编指令序列和寄存器状态,识别出那些在特定条件下可以直接调用 execve("/bin/sh", NULL, NULL)
的代码片段。这些代码片段通常被称为 “one gadget”,因为它们只需要满足一定的寄存器和栈状态条件,就可以直接获取 shell。
1 | #安装 |
# 利用:
在利用漏洞时,如果能够控制程序的执行流程,使其跳转到 One gadget 的地址,就有可能获取 shell。不过,要成功使用 One gadget 通常需要满足一定的条件,这些条件一般和寄存器的状态有关。例如,某些 One gadget 可能要求特定的寄存器(如 rdi
、 rsi
、 rdx
等)具有特定的值。在实际的漏洞利用过程中,你可能需要通过一些技术手段(如 ROP 链,即返回导向编程)来调整寄存器的值,以满足 One gadget 的执行条件。
1 | #exp案例 |
注意事项:
- One gadget 的可用性依赖于目标系统所使用的
libc
库版本。不同版本的libc
库,其 One gadget 的地址和执行条件可能会有所不同。 - 在实际的漏洞利用中,可能需要尝试多个 One gadget,因为某些 One gadget 的执行条件在当前的漏洞场景下可能无法满足。
# ret2reg
return to register,区别于前面的 rop,前面的大多都是 ret2addr 返回地址的的攻击,而 ret2reg 是一种返回寄存器的攻击,安全人员为了避免受到 ret2addr 的攻击,选择 地址混淆技术 它将栈、堆和动态库空间的地址随机化。在 32 位系统上,随机范围是 64M;在 64 位系统上,随机范围是 2G。通过随机化这些内存区域的地址,使得攻击者难以确定 ret2addr 攻击中要注入的固定地址,极大增加了攻击难度,让传统 ret2addr 攻击难以奏效。 这个时候 ret2reg 就成为了更好的选择。
- 原理
- 当函数存在栈溢出时,溢出的缓冲区地址常被加载到某个寄存器中,且在后续运行中该寄存器值相对稳定。攻击者首先要确定栈溢出返回时哪个寄存器指向缓冲区空间。然后查找能跳转或调用该寄存器所指地址的指令(如 call 寄存器或 jmp 寄存器指令 ),将 EIP 设置为该指令地址。最后在寄存器指向的可执行空间(一般是栈上 )注入 shellcode(一段可执行的恶意代码 )。
- 实现
1 |
|
在此示例中,当 vuln()
函数返回时, RAX
寄存器将指向存储在 buffer
中的用户输入:
使用 jmp rax
gadget,可以直接跳转到用户输入,而无需知道它的地址:
使用 Buffer Overflow 将小工具设置为 return 并跳转到 shellcode
得 exp:
1 | from pwn import * |
# ret2dl
全名 ret2dlresolve
(未完待续,太累了)
# ret2VDSO
VDSO (Virtual Dynamically-linked Shared Object) 它是将内核态的调用映射到用户地址空间的库。那么它为什么会存在呢?这是因为有些系统调用经常被用户使用,这就会出现大量的用户态与内核态切换的开销。通过 vdso,我们可以大量减少这样的开销,同时也可以使得我们的路径更好。这里路径更好指的是,我们不需要使用传统的 int 0x80 来进行系统调用,不同的处理器实现了不同的快速系统调用指令
- intel 实现了 sysenter,sysexit
- amd 实现了 syscall,sysret
当不同的处理器架构实现了不同的指令时,自然就会出现兼容性问题,所以 linux 实现了 vsyscall 接口,在底层会根据具体的结构来进行具体操作。而 vsyscall 就实现在 vdso 中。
在 Linux (kernel 2.6 or upper) 中执行 ldd /bin/sh, 会发现有个名字叫 linux-vdso.so.1 (老点的版本是 linux-gate.so.1) 的动态文件,而系统中却找不到它,它就是 VDSO。
除了快速系统调用,glibc 也提供了 VDSO 的支持,open (), read (), write (), gettimeofday () 都可以直接使用 VDSO 中的实现。使得这些调用速度更快。 内核新特性在不影响 glibc 的情况下也可以更快的部署。
这个东西还没有遇见到,遇见之后再更新
# SROP
# 原理:
传统的 ROP 技术,尤其是 amd64 上的 ROP,需要寻找大量的 gadgets 以对寄存器进行赋值,执行特定操
作,如果没有合适的 gadgets 就需要进行各种奇怪的组装。这一过程阻碍了 ROP 技术的使用。而 SROP 技
术的提出大大简化了 ROP 攻击的流程 。
SROP (Sigreturn Oriented Programming) 技术利用了类 Unix 系统中的 Signal 机制
- 当一个用户层进程发起 signal 时,控制权切到内核层
- 内核保存进程的上下文 (对我们来说重要的就是寄存器状态) 到用户的栈上,然后再把 rt_sigreturn 地
址压栈,跳到用户层执行 Signal Handler,即调用 rt_sigreturn - rt_sigreturn 执行完,跳到内核层
- 内核恢复②中保存的进程上下文,控制权交给用户层进程
重点:内核恢复②中保存的进程上下文,控制权交给用户层进程
仔细回顾一下内核在 signal 信号处理的过程中的工作,我们可以发现,内核主要做的工作就是为进程保存上下文,并且恢复上下文。这个主要的变动都在 Signal Frame 中。但是需要注意的是:
- Signal Frame 被保存在用户的地址空间中,所以用户是可以读写的。
- 由于内核与信号处理程序无关 (kernel agnostic about signal handlers),它并不会去记录这个 signal 对应的 Signal Frame,所以当执行 sigreturn 系统调用时,此时的 Signal Frame 并不一定是之前内核为用户进程保存的 Signal Frame。
上面的话都不用看,其实看不太好理解的,是 wiki 上面写的,用人话总结一下就是:利用 linux 下的一个系统调用号 15=0xf—— 去调用 signal 机制,而且 pwntools 已经包装很完整了,不用看那些不像人话的东西,直接看怎么利用就好
# 利用:
exploit 思路:
- 控制程序流(有得大量溢出才可以)
- 构造 ROP 链调用 rt_sigreturn(用 pwntools 的 SigreturnFrame ())
- 能控制栈的布局(设置各个寄存器参数)
这里用 newstar2023-srop 来讲解:
先查看 ida,正确应该是先 checksec,但是我习惯先看看里面,可以发现有足够的输入,有 syscall。同时这题没有开启 canary。
可以看见有 return 15;这个就是我们说的系统调用号 15=0xf,也就是去调用 signal 机制的机会。
把 0x40118c 作为构造的 rip 值,有 leave (mov rsp,rbp,pop rbp), 在 sigreturn 后可以实现栈迁移,我们把 srop 构造成 syscall (0,0,0x404020,0x200), 也就是向 data 段写入 0x200 字节数据,在输入完成后通过栈迁移到 data 段,所以在第一次溢出后 (也就是第一次 srop) 向 data 输入 /bin/sh 字符串和再次构造的 srop,syscall (59,"/bin/sh",0,0) 即可 get shell
exp:
1 | from pwn import * |
# BROP
全名 blind-rop,又叫做盲打。
# 原理讲解:
BROP 是没有对应应用程序的源代码或者二进制文件下,对程序进行攻击,劫持程序的执行流。
攻击条件
- 程序必须存在栈溢出漏洞
- 服务器端的进程在崩溃之后会重新启动,并且重新启动的进程的地址与先前的地址一样(这也就是说即使程序有 ASLR 保护,但是其只是在程序最初启动的时候有效果)。目前 nginx, MySQL, Apache, OpenSSH 等服务器应用都是符合这种特性的。
攻击原理
目前,大部分应用都会开启 ASLR、NX、Canary 保护。这里我们分别讲解在 BROP 中如何绕过这些保护,以及如何进行攻击。
# 基本思路:
在 BROP 中,基本的遵循的思路如下(使用 《HCTF2016 的出题人失踪了》 这题):
-
判断栈溢出长度
- 暴力枚举
-
Stack Reading
堆栈读取
- 获取栈上的数据来泄露 canaries,以及 ebp 和返回地址。
-
Blind ROP
盲注 ROP
- 找到足够多的 gadgets 来控制输出函数的参数,并且对其进行调用,比如说常见的 write 函数以及 puts 函数。
-
Build the exploit
构建漏洞
- 利用输出函数来 dump 出程序以便于来找到更多的 gadgets,从而可以写出最后的 exploit。
# 栈溢出长度:
直接从 1 暴力枚举即可,直到发现程序崩溃。
1 | from pwn import * |
# Stack Reading:
堆栈读取
经典的栈帧布局是:
1 | stack | canary | ebp/rbp | ret |
为了获得 canary,我们也可以使用爆破,因为利用的前提是 canary 不会改变。在 32 位系统中,Canary 值通常为 32 位(4 字节),其可能的取值有 2 的 32 次方种;在 64 位系统中,Canary 值通常为 64 位(8 字节),可能的取值有 2 的 64 次方种。如此庞大的数值范围,逐个枚举显然不现实。我们可以利用字节来逐一爆破,每个字节最多有 256 种可能,所以在 32 位的情况下,我们最多需要爆破 256*4 1024 次,64 位最多爆破 2048 次。
# 找 gadget
获得溢出的 size 之后可以去找可利用的 gadget。我们只能通过控制返回地址来寻找。
当我们控制程序的返回地址时,一般有以下几种情况
- 程序直接崩溃
- 程序运行一段时间后崩溃
- 程序一直运行而并不崩溃
为了有用的 gadget 我们有以下操作:找 stop gadget, 识别 gadget,找 plt 表,找 got 表。
# 找 stop gadget
-
找 stop gadget:
-
所谓
stop gadget
一般指的是这样一段代码:当程序的执行这段代码时,程序会进入无限循环,这样使得攻击者能够一直保持连接状态。 之所以要寻找 stop gadgets,是因为当我们猜到某个 gadgtes 后,如果我们仅仅是将其布置在栈上,由于执行完这个 gadget 之后,程序还会跳到栈上的下一个地址。如果该地址是非法地址,那么程序就会 crash。这样的话,在攻击者看来程序只是单纯的 crash 了。因此,攻击者就会认为在这个过程中并没有执行到任何的useful gadget
,从而放弃它。 -
如果我们布置了
stop gadget
,那么对于我们所要尝试的每一个地址,如果它是一个 gadget 的话,那么程序不会崩溃。接下来,就是去想办法识别这些 gadget。 -
def get_stop_addr(length):
addr = 0x400000
while 1:
try:
sh = remote('127.0.0.1', 9999)
sh.recvuntil('password?\n')
payload = 'a' * length + p64(addr) #尝试地址
sh.sendline(payload)
sh.recv() #连接成功
sh.close()
print 'one success addr: 0x%x' % (addr)
return addr
except Exception:
addr += 1
sh.close()
<!--code10-->
-
# 确认 plt 表
程序的 plt 表具有比较规整的结构,每一个 plt 表项都是 16 字节。而且,在每一个表项的 6 字节偏移处,是该表项对应的函数的解析路径,即程序最初执行该函数的时候,会执行该路径对函数的 got 地址进行解析。
对于大多数 plt 调用来说,一般都不容易崩溃,即使是使用了比较奇怪的参数。所以说,如果我们发现了一系列的长度为 16 的没有使得程序崩溃的代码段,那么我们有一定的理由相信我们遇到了 plt 表。除此之外,我们还可以通过前后偏移 6 字节,来判断我们是处于 plt 表项中间还是说处于开头。
# Blind ROP:
找输出函数,利用输出函数 puts 或者 write 来输出文件内部的信息。下面举例关于 puts 的 BROP。
根据 payload:
1 | payload = 'A'*72 +p64(pop_rdi_ret)+p64(0x400000)+p64(addr)+p64(stop_gadget) |
确定 puts_plt:
1 | def get_puts_addr(length, rdi_ret, stop_gadget): |
确定 puts_got:
1 | def leak(length, rdi_ret, puts_plt, leak_addr, stop_gadget): |
最后,我们将泄露的内容写到文件里。需要注意的是如果泄露出来的是 “”, 那说明我们遇到了’\x00’,因为 puts 是输出字符串,字符串是以’\x00’为终止符的。之后利用 ida 打开 binary 模式,首先在 edit->segments->rebase program 将程序的基地址改为 0x400000,然后找到偏移 0x560 处,如下:
1 | seg000:0000000000400560 db 0FFh |
然后按下 c, 将此处的数据转换为汇编指令,如下:
1 | seg000:0000000000400560 ; --------------------------------------------------------------------------- |
这说明,puts@got 的地址为 0x601018。
# 最终:
1 | ##length = getbufferflow_length() |