Return-to-Libc¶
约 2701 个字 11 张图片 预计阅读时间 14 分钟
Non-Executable Stack¶
为了防止缓冲区溢出攻击,现代操作系统通常会将栈设置为不可执行,这样攻击者就无法在栈上执行恶意代码。gcc 编译器选项 -z execstack
表示栈可执行,-z noexecstack
表示栈不可执行。这样的选项在二进制文件的头部会有相应的标识。
Tip
事实上,我们可以直接编译程序,不使用编译器选项而使用一个命令行 execstack -s <binary>
来设置栈为可执行,使用 execstack -c <binary>
来设置栈为不可执行。
Return-to-libc Attack : Idea and Challenges¶
当我们的栈不可执行时,我们就没法通过缓冲区溢出攻击将 shellcode 放到栈上并执行了,但我们仍然可以通过缓冲区溢出攻击来修改返回地址,使其指向一些已经存在于内存当中的代码,而存在于内存中的代码有三类:
- 程序本身的代码段,但是程序本身不一定含有我们想要执行的代码
- 库函数
- 内核代码,但是内核代码是受保护的,我们无法直接调用
因此,库函数成为了我们最稳妥的选择,在 C 代码中有一类标准 C 库函数称为 libc 函数,它在大多数程序当中都会以动态链接的方式存在于内存当中,且 libc 函数包含了 system()
函数,能让我们获得一个 Shell 从而执行任意命令。
当然,虽然 libc 函数是我们最常用的攻击目标,但它也有一些挑战:
system()
函数的地址在哪?/bin/sh
字符串的地址在哪?- 我们如何传递字符串
/bin/sh
的地址给system()
函数?
Overcome the Challenges¶
Find system() 's Address¶
我们可以通过 gdb 调试,当运行 main 函数时,会有一系列库导入,这样我们就可以获得 system 函数的地址:
需要注意的是,在地址随机化未开启的情况下,system 函数的地址也是随程序的变化而变化的(即使是在于程序是否为 SetUID 程序的区别都有可能不同),所以攻击者应该调试和需要攻击的程序完全一致的程序。
Find /bin/sh 's Address¶
一般情况下,字符串一定会存在于内存当中,但是其地址并不好找,我们可以通过另一种方式来解决这个问题——环境变量。
对于一个 SetUID 程序来说,父进程的环境变量将会被传递给子进程,而我们可以在父进程中设置一个环境变量,其值为 /bin/sh
,然后在子进程中读取这个环境变量的地址即可。
- 需要注意的是,我们在设置二进制文件名时,需要让这个文件名长度和需要攻击的程序名一致,否则会导致环境变量的地址不一致,例如上图,如果我们要攻击
stack
程序,那么我们就得设置长度为 5 的程序- 这是因为在将环境变量放到栈上之前,程序的文件名也被放到了栈上
Passing Arguments¶
这是 Return-to-libc 攻击最难的部分,它的难点在于,当我们跳转到 system()
函数时,会分配一个新的属于 system()
函数的栈帧,同时 ebp 会改变,我们知道函数的第一个参数的地址为 ebp + 8
,理论上我们只要将这个地址的内容改成 /bin/sh
字符串的地址即可,但是重点在于我们不知道 ebp 改变成了什么。
Ebp 的改变
ebp 的改变一般有两种情况,一种是函数退出,另一种即为进入函数。
如上图,Function Prologue 表示函数的开始部分,Function Epilogue 表示函数的结束部分,其中我们可以看到开始部分 ebp 被保存到栈上,然后 ebp 被设置为当前栈顶的地址,结束部分 leave
指令则代表了两条指令(movl %ebp, %esp
和 popl %ebp
),这使得 ebp 的值被恢复为之前保存的值。
我们可以深入探究一下,当我们从一个函数跳转到另一个函数时,ebp 和 esp 是如何变化的
ebp 和 esp 的变化
如上图所示,我们假设 ebp 的初始值为 X,将 leave 指令展开,一步一步执行汇编代码:
- A 函数的结束部分:
movl %ebp, %esp
将 esp 设置为 ebp 的值,即 Xpopl %ebp
将栈顶的值弹出到 ebp 中,即地址为 X 的内容,设为 Y,同时 esp 的值变为 X + 4(因为弹出一个 4 字节的值)ret
指令将 esp 的值变为 X + 8(因为ret
指令本质上也包含一个popl
,会将栈顶的值当作一个地址并跳转到这个地址)
- B 函数的开始部分:
pushl %ebp
将 ebp 的值推到栈中,将 esp 的值变为 X + 4movl %esp, %ebp
将 esp 的值赋给 ebp,最终 ebp 变成了 X + 4
最终,我们通过上面的推导可以得知,ebp 最终变化为了 ebp + 4,因此,假设 ebp 相对于 buffer 的偏移量为 32,我们的恶意 badfile 应该架构如下:
其中需要注意的是,当我们跳转到 system 函数时,ebp 变成了 ebp + 4,也就是说 ebp + 8 就成为了 system 函数的返回地址,为了能让函数正常返回,我们将这个地址设置为 exit 函数的地址(否则有可能这个地方会被设置成随机值而导致程序崩溃)
Return Oriented Programming¶
类似于上面更改返回地址实现函数的跳转技巧称为面向返回编程(Return Oriented Programming, ROP),它可以通过改变栈帧保存的返回地址从而跳转到攻击者想要的函数上,实现一条调用链
Chaining Function Calls
类似上面的做法,在函数没有参数的情况下,利用调用函数 ebp 变为 ebp + 4 的特点,我们可以不断地覆盖返回地址实现调用链,下图展示了从 foo 函数开始调用若干次 bar 函数的情况:
但是如果当我们调用的函数有参数时,就可能出现冲突问题,这是因为对于一个函数的栈帧来说,ebp + 8 得保存参数的地址,这很可能跟函数链当中的某个函数的返回地址有所冲突。
为了解决这个问题,我们回到之前对于 ebp 的推导:
如上图,当 foo 函数返回时,ebp 有短暂地来到一个值 Y,即 X 指向的内容,且在 Buffer Overflow 当中我们是可以控制这个值 Y 的。我们可以跳过我们所要调用函数的开始部分,直接执行其后面的指令,就能使得 ebp 跳转到一个我们可以控制的地方(我们只要保证不会产生冲突即可),同时将参数填到相应的位置:
如上图,如果我们要在 foo 函数中调用一个参数为 500 的 baz 函数,我们将 Y 设为 X + 100,并将返回地址设置为 baz + 3(这是因为函数的开始部分占 3 个字节,在 gdb 中可以通过 disassemble baz
来查看),这样就实现了带参数的函数调用链。
如果我们想要调用的函数是一个 libc 函数,情况又有所不同,这是因为 libc 函数不同于我们自定义的函数,它是动态链接的,有一套自己的调用方法:
这样我们就没法直接通过跳过开始部分的方式来进行调用了。解决方法是,我们调用一个中间函数 empty
,它不做任何事情,但是这个函数是由我们自定义的,因此我们还是可以利用带参数的调用方法,跳转到 empty + 3 跳过开始部分,通过修改 Y 来空出空间给当前函数放参数,ebp 就跳转到了 Y,此时我们再让 empty 函数直接来到其结束部分(这有个专门的函数称为 leaveret),跳转到 libc 函数,此时 ebp 就变成了 Y + 4,完美地解决了这个问题。
值得一提的是,正常情况下,我们是要在 empty 的 ebp + 4 处填写要调用的函数 B 的地址的,但是同时这个位置还是要填写覆盖 Y 的内容的,这似乎产生了冲突。但是如果我们重新再过一遍之前对于 ebp 的推导,我们会发现,在 B 被调用的过程当中,原来存在 empty 的 ebp 当中的东西是被复制到了新调用的函数 B 的 ebp 指向的位置上的,那么我们只要将需要覆盖 Y 的内容先放在 empty 的 ebp 指向的区域,就自然而然避开了冲突。
The Final Attack¶
看起来,上面展示的 badfile 架构已经能够让我们实现 Return-to-Libc 攻击了,但是事实上,system(cmd)
函数会首先执行 /bin/sh
程序,然后让该 shell 程序运行 cmd
命令。在 Ubuntu 20.04(以及之前的几个版本)中,/bin/sh
实际上是一个指向 /bin/dash
的符号链接。该 shell 程序有一种防范措施,可以防止自己在 Set-UID 进程中被执行。如果 dash 检测到它在 SetUID 进程中执行,它会立即将有效用户 ID 更改为进程的真实用户 ID,从而放弃特权,那么我们就无法获得 Root 权限了。
为了解决这个问题,我们有函数 setuid(0)
,这个函数能让整个进程直接变为 root 进程而非一个 SetUID 进程,因此,我们在跳转 system 函数之前,我们得先跳转到 setuid 函数,并传递参数 0。这就用到了函数调用链的方法。
那么问题又来了,我们需要传递的参数是 0,而 strcpy 遇到 0 就会停止复制,那么后面的内容就没有复制进来,攻击就无法进行下去。解决方法也很简单,我们先用一个非 0 的值作为占位符,再尝试用 0 去直接写到 memory 也就是栈上即可。这样写到 memory 的函数是存在的,叫 sprintf(addr1, addr2)
,它表示将 addr2 的内容写到 addr1 上(写一个字节),那么只要我们构造一个 addr2 指向 \0
字符即可,那么我们最终的函数链就变成了 foo -> sprintf -> sprintf -> sprintf -> sprintf -> setuid -> system
(四个 sprintf 是因为其只能写一个字节而栈上存储参数的内容有 4 字节)