PWN基础知识

PWN笔记 · 03-21 · 96 人浏览

一、ELF文件结构

Linux下的可执行文件格式为ELF(Executable and Linkable Format),类似Windows的PE格式。ELF文件格式比较简单,PWN参赛者最需要了解的是ELF头、Section(节)、Segment(段)的概念。

ELF头必须在文件开头,表示这是个ELF文件及其基本信息。ELF头包括ELF的magic code、程序运行的计算机架构、程序入口等内容,可以通过“readelf-h”命令读取其内容,一般用于寻找一些程序的入口。

ELF文件由多个(Section)组成,其中存放各种数据。描述节的各种信息的数据统一存放在节头表中。ELF中的节用来存放各种各样不同的数据,主要包括:

 .text节——存放一个程序的运行所需的所有代码。

 .rdata节——存放程序使用到的不可修改的静态数据,如字符串等。

 .data节——存放程序可修改的数据,如C语言中已经初始化的全局变量等。

 .bss节——用于存放程序的可修改数据,与.data不同的是,这些数据没有被初始化,所以没有占用ELF空间。虽然在节头表中存在.bss节,但是文件中并没有对应的数据。在程序开始执行后,系统才会申请一块空内存来作为实际的.bss节。

 .plt节和.got节——程序调用动态链接库(SO文件)中函数时,需要这两个节配合,以获取被调用函数的地址。

由于ELF格式的可扩展性,甚至在编译链接程序时还可以创建自定义的节区。ELF中其实可以包括很多与程序执行无关的内容,如程序版本、Hash或者一些符号调试信息等。但是操作系统执行ELF程序时并不会解析ELF中的这些信息,需要解析的是ELF头和程序头表(Program Head Table)。解析ELF文件头的目的是确定程序的指令集构架、ABI版本等系统是否支持信息,以及读取程序入口。然后,Linux解析程序头表来确定需要加载的程序段。程序头表其实是一个程序头(Program Head结构体数组,其中的每项都包含这个段的描述信息。与Windows一样,Linux也有内存映射文件功能。操作系统执行程序时需要按照程序头表中指定的段信息来将ELF文件中的指定内容加载到内存的指定位置。所以,每个程序头的内容主要包括段类型、其在ELF文件中的地址、加载到内存中的哪个地址、段长度、内存读写属性等。

比如,ELF中存放代码的段内存读写属性是可读可执行,存放数据的段则是可读可写或者只读等。注意,有些段可能在ELF文件中没有对应的数据内容,如未初始化的静态内存,为了压缩ELF文件,只会在程序头表中存在一个字段,由操作系统进行内存申请和置零的操作。操作系统也不会关心每个段中的具体内容,只需按照要求加载各段,并将PC指针指向程序入口。

这里可能有人会对节与段之间的关系及其区别产生疑惑,其实二者只是解释ELF中数据的两种形式而已。就像一个人有多种身份,ELF同时使用段和节两种格式描述一段数据,只是侧重点不同。操作系统不需要关心ELF中的数据具体功能,只需知道哪一块数据应该被加载到哪一块内存,以及内存的读写属性即可,所以会按照段来划分数据。

而编译器、调试器或者IDA更需要知道数据代表的含义,就会按照节来解析划分数据。通常,节比段更细分,如.text、rdata往往会划分为一个段。有些纯粹用来描述程序的附加信息,而与程序运行无关的节甚至会没有对应的段,在程序运行过程中也不会加载到内存。

二、Linux内存布局

Kernel Space:Kernel space 是 Linux 内核的运行空间,User space 是用户程序的运行空间。在设计时考虑到安全因素,内核空间和用户用户是隔离的,即使用户的程序崩溃了,内核也不受影响。

Stack: Linux中的栈与数据结构中的栈类似,是计算机程序中非常重要的理论之一,可以说没有一个程序程序可以离开这种结构栈。用户或者程序都可以把数据压入栈中,不管如何栈始终有一个特性:先入栈的数据最后出栈(First In Last Out, FIFO)。

Heap:堆相对相对与栈来说比较复杂,编程人员在设计时程序可能会申请一段内存,或者删除掉一段已经申请过的内存,而且申请的大小也不确定,可以是从几个字节,也可以是数 GB ,所以堆的管理相对来说比较复杂。

bss段:BSS段通常是一块内存区域用来存放程序中未初始化的或者初始化为0的全局变量和静态变量的。特点是可读写的,程序初始化时会自动清零。

Data段 :数据段是一块内存区域它用来存放程序中已初始化的全局变量的。数据段是静态内存分配。

Code段:代码段是一块内存区域来存放程序执行代码的。代码段在程序运行前就已经确定,代码段在内存中是一段只读空间,但有些架构也允许代码段可读写,即允许自修改程序。

缓冲区溢出分为栈溢出堆溢出。栈溢出是由于在栈的空间内,放入大于栈空间的数据,导致栈空间以外有用的内存单元被改写,这种现象就称为栈溢出

普通的溢出不会有太大危害,但是如果向溢出的内存中写入的是精心准够着的数据(payload),就可能使得程序流程被劫持,使得危险的代码被执行,最终造成重大危害。

三、Linux下的漏洞缓解措施(ELF文件保护机制)

Linux ELF文件的保护主要有四种:CanaryNXPIERELRO
在Linux中可以用checksec来检测文件的保护机制

1.NX

NX即No-eXecute(不可执行)的意思,NX(DEP)的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令。

(1)原理

NX保护在Windows中也被称为DEP,是通过现代操作系统的内存保护单元(Memory Protect Unit,MP)机制对程序内存按页的粒度进行权限设置,其基本规则为可写权限与可执行权限互斥。因此,在开启NX保护的程序中不能直接使用shellcode执行任意代码。所有可以被修改写入shellcode的内存都不可执行,所有可以被执行的代码数据都是不可被修改的。

GCC默认开启NX保护,关闭方法是在编译时加入“-z execstack”参数。

(2)机制绕过

当程序开启NX时, 如果我们在堆栈上部署自己的 shellcode 并触发时, 只会直接造成程序的崩溃,开启NX之后栈和bss段就只有读写权限,没有执行权限了,所以就要用到rop这种方法拿到系统权限,如果程序很复杂,或者程序用的是静态编译的话,那么就可以使用ROPgadget这个工具很方便的直接生成rop利用链。有时候好多程序不能直接用ROPgadget这个工具直接找到利用链,所以就要手动分析程序来getshell了。

2.Stack Canary

在函数返回值之前添加的一串随机数(不超过机器字长)(也叫做cookie),末位为/x00(提供了覆盖最后一字节输出泄露Canary的可能),如果出现缓冲区溢出攻击,覆盖内容覆盖到Canary处,就会改变原本该处的数值,当程序执行到此处时,会检查Canary值是否跟开始的值一样,如果不一样,程序会崩溃,从而达到保护返回地址的目的。

(1)原理

Canary是金丝雀的意思。技术上表示最先的测试的意思。这个来自以前挖煤的时候,矿工都会先把金丝雀放进矿洞,或者挖煤的时候一直带着金丝雀。金丝雀对甲烷和一氧化碳浓度比较敏感,会先报警。所以大家都用Canary来搞最先的测试。Stack Canary表示栈的报警保护。

Stack Canary保护是专门针对栈溢出攻击设计的一种保护机制。由于栈溢出攻击的主要目标是通过溢出覆盖函数栈高位的返回地址,因此其思路是在函数开始执行前,即在返回地址前写入一个字长的随机数据,在函数返回前校验该值是否被改变,如果被改变,则认为是发生了栈溢出。程序会直接终止。

GCC默认使用Stack Canary保护,关闭方法是在编译时加入“-fno-stack-protector”参数。

也称为栈保护技术,用来检测栈溢出攻击:
在函数返回值之前添加的一串随机数(不超过机器字长)(也叫做cookie),末位为/x00(提供了覆盖最后一字节输出泄露Canary的可能),如果出现缓冲区溢出攻击,覆盖内容覆盖到Canary处,就会改变原本该处的数值,当程序执行到此处时,会检查Canary值是否跟开始的值一样,如果不一样,程序会崩溃,从而达到保护返回地址的目的。

(2)机制绕过

开启canary后就不能直接使用普通的溢出方法来覆盖栈中的函数返回地址了,要用一些巧妙的方法来绕过或者利canary本身的弱点来攻击

(1)泄露栈中的 Canary:泄露栈中的 Canary 的方法是打印栈中 Canary 的值。 这种利用方式需要存在合适的输出函数得到canary的值。再构造payload的时候再将cannary的值写回栈中从而绕过CANNARY的保护。
(2)爆破 Canary:对于 Canary,虽然每次进程重启后的 Canary 不同,但是同一个进程中的不同线程的 Canary 是相同的,并且通过 fork 函数创建的子进程的 Canary 也是相同的,因为 fork 函数会直接拷贝父进程的内存。我们可以利用这样的特点,彻底逐个字节将 Canary 爆破出来。
(3)劫持__stack_chk_fail 函数:Canary 失败的处理逻辑会进入到 __stack_chk_failed 函数,__stack_chk_failed 函数是一个普通的延迟绑定函数,可以通过修改 GOT 表劫持这个函数。
(4)覆盖 TLS 中储存的 Canary 值:Canary 储存在 TLS 中,在函数返回前会使用这个值进行对比。当溢出尺寸较大时,可以同时覆盖栈上储存的 Canary 和 TLS 储存的 Canary 实现绕过。

3.ASLR(Address Space Layout Randomization)

ASLR的目的是将程序的堆栈地址和动态链接库的加载地址进行一定的随机化,这些地址之间是不可读写执行的未映射内存,降低攻击者对程序内存结构的了解程序。这样,即使攻击者布置了shellcode并可以控制跳转,由于内存地址结构未知,依然无法执行shellcode。

ASLR是系统等级的保护机制,关闭方式是修改/proc/sys/kernel/randomize_va_space文件的内容为0。

4.PIE

一般情况下NX(Windows平台上称为DEP)和地址空间分布随机化(PIE/ASLR)(address space layout randomization)会同时工作。内存地址随机化机制有三种情况:

0 – 表示关闭进程地址空间随机化。

1 – 表示将mmap的基地址,栈基地址和.so地址随机化

2 – 表示在1的基础上增加heap的地址随机化

该保护能使每次运行的程序的地址都不同,防止根据固定地址来写exp执行攻击。

可以防止Ret2libc方式针对DEP的攻击。ASLR和DEP配合使用,能有效阻止攻击者在堆栈上运行恶意代码

(1)原理

与ASLR保护十分相似,PIE保护的目的是让可执行程序ELF的地址进行随机化加载,从而使得程序的内存结构对攻击者完全未知,进一步提高程序的安全性。

GCC编译时开启PIE的方法为添加参数“-fpic-pie”。较新版本GCC默认开启PIE,可以设置“-no-pie”来关闭。

(2)机制绕过

PIE 保护机制和ASLR,影响的是程序加载的基址,并不会影响指令间的相对地址,因此如果我们能够泄露程序的某个地址,就可以通过修改偏移获得程序其它函数的地址。

5.Full Relro

Relocation Read-Only (RELRO) 此项技术主要针对 GOT 改写的攻击方式。它分为两种,Partial RELRO 和 Full RELRO。

部分RELRO 易受到攻击,例如攻击者可以atoi.got为system.plt,进而输入/bin/sh\x00获得shell

完全RELRO 使整个 GOT 只读,从而无法被覆盖,但这样会大大增加程序的启动时间,因为程序在启动之前需要解析所有的符号。

(1)原理

Full Relro保护与Linux下的Lazy Binding机制有关,其主要作用是禁止.GOT.PLT表和其他一些相关内存的读写,从而阻止攻击者通过写.GOT.PLT表来进行攻击利用的手段。

GCC开启Full Relro的方法是添加参数“-z relro”。

(2)机制绕过:

如果程序开启了FULL RELRO,意味着我们无法修改got表,所以一般也采用通过ROP绕过的方法。

四、整数溢出

整数溢出在PWN中属于比较简单的内容,当然并不是说整数溢出的题目比较简单,只是整数溢出本身不是很复杂,情况较少而已。但是整数溢出本身是无法利用的,需要结合其他手段才能达到利用的目的。

(1)整数的运算

计算机并不能存储无限大的整数,计算机中的整数类型代表的数值只是自然数的一个子集。比如在32位C程序中,unsigned int类型的长度是32位,能表示的最大的数是0xffffffff。如果将这个数加1,其结果0x100000000就会超过32位能表示的范围,而只能截取其低32位,最终这个数字就会变为0。这就是无符号上溢。

计算机中有4种溢出情况,以32位整数为例。

 无符号上溢:无符号数0xffffffff加1变为0的情况。

 无符号下溢:无符号数0减去1变为0xffffffff的情况。

 有符号上溢:有符号数正数0x7fffffff加1变为负数0x80000000,即十进制-2147483648的情况。

 无符号下溢:有符号负数0x80000000减去1变为正数0x7fffffff的情况。

除此之外,有符号数字与无符号数直接的转换会导致整数大小突变。比如,有符号数字-1和无符号数字0xffffffff的二进制表示是相同的,二者直接进行转换会导致程序产生非预期的效果。

(2)整数溢出如何利用

整数溢出虽然很简单,但是利用起来实际上并不简单。整数溢出不像栈溢出等内存破坏可以直接通过覆盖内存进行利用,常常需要进行一定转换才能溢出。常见的转换方式有两种。

1.整数溢出转换成缓冲区溢出

整数溢出可以将一个很小的数突变成很大的数。比如,无符号下溢可以将一个表示缓冲区大小的较小的数通过减法变成一个超大的整数。导致缓冲区溢出。

另一种情况是通过输入负数的办法来绕过一些长度检查,如一些程序会使用有符号数字表示长度。那么就可以使用负数来绕过长度上限检查。而大多数系统API使用无符号数来表示长度,此时负数就会变成超大的正数导致溢出。

2.整数溢出转数组越界

数组越界的思路很简单。在C语言中,数组索引的操作只是简单地将数组指针加上索引来实现,并不会检查边界。因此,很大的索引会访问到数组后的数据,如果索引是负数,那么还会访问到数组之前的内存。

通常,整数溢出转数组越界更常见。在数组索引的过程中,数组索引还要乘以数组元素的长度来计算元素的实际地址。以int类型数组为例,数组索引需要乘以4来计算偏移。假如通过传入负数来绕过
边界检查,那么正常情况下只能访问数组之前的内存。但由于索引会被乘以4,那么依然可以索引数组后的数据甚至整个内存空间。例如,想要索引数组后0x1000字节处的内容,只需要传入负数-2147482624,该值用十六进制数表示为0x80000400,再乘以元素长度4后,由于无符号整数上溢结果,即为0x00001000。可以看到,与整数溢出转缓冲区溢出相比,数组越界更容易利用

五、栈溢出

栈(stack是一种简单且经典的数据结构,最主要的特点是使用先进后出(FILO)的方式存取栈中的数据。一般情况下,最后放入栈中的数据被称为栈顶数据,其存放的位置被称为栈顶。向栈中存放数据的操作被称为入栈(push),取出栈顶数据的操作被称为出栈(pop)。有关栈的详细内容可以参考数据结构相关资料。

由于函数调用的循序也是最先调用的函数最后返回,因此栈非常适合保存函数运行过程中使用到的中间变量和其他临时数据。

目前,大部分主流指令构架(x86、ARM、MIPS等)都在指令集层面支持栈操作,并且设计有专门的寄存器保存栈顶地址。大部分情况下,将数据入栈会导致栈顶从内存高地址向低地址增长。

(1)栈溢出原理

栈溢出是缓冲区溢出中的一种。函数的局部变量通常保存在栈上。如果这些缓冲区发生溢出,就是栈溢出。最经典的栈溢出利用方式是覆盖函数的返回地址,以达到劫持程序控制流的目的。

x86构架中一般使用指令call调用一个函数,并使用指令ret返回。CPU在执行call指令时,会先将当前call指令的下一条指令的地址入栈,再跳转到被调用函数。当被调用函数需要返回时,只需要执行ret指令。CPU会出栈栈顶的地址并赋值给EIP寄存器。这个用来告诉被调用函数自己应该返回到调用函数什么位置的地址被称为返回地址。理想情况下,取出的地址就是之前调用call存入的地址。这样程序可以返回到父函数继续执行了。编译器会始终保证即使子函数使用了栈并修改了栈顶的位置,也会在函数返回前将栈顶恢复到刚进入函数时候的状态,从而保证取到的返回地址不会出错。

(2)常发生栈溢出的危险函数

通过寻找危险函数,我们可以快速确定程序是否可能有栈溢出,以及栈溢出的位置。常见的危险函数如下。

 输入:gets(),直接读取一行,到换行符'\n'为止,同时'\n'被转换为'\x00';scanf(),格式化字符串中的%s不会检查长度;vscanf(),同上。

 输出:sprintf(),将格式化后的内容写入缓冲区中,但是不检查缓冲区长度。

 字符串:strcpy(),遇到'\

 字符串:strcpy(),遇到'\x00'停止,不会检查长度,经常容易出现单字节写0(off by one)溢出;strcat(),同上。

(3)可利用的栈溢出覆盖位置

可利用的栈溢出覆盖位置通常有3种:

覆盖函数返回地址,之前的例子都是通过覆盖返回地址控制程序。

覆盖栈上所保存的BP寄存器的值。函数被调用时会先保存栈现场,返回时再恢复,具体操作如下(以x64程序为例)。调用时:

返回时:如果栈上的BP值被覆盖,那么函数返回后,主调函数的BP值会被改变,主调函数返回指行ret时,SP不会指向原来的返回地址位置,而是被修改后的BP位置。

根据现实执行情况,覆盖特定的变量或地址的内容,可能导致一些逻辑漏洞的出现。

六、返回导向编程

现代操作系统往往有比较完善的MPU机制,可以按照内存页的粒度设置进程的内存使用权限。内存权限分别有可读(R)、可写(W)和可执行(X)。一旦CPU执行了没有可执行权限的内存上的代码,操作系统会立即终止程序。

在默认情况下,基于漏洞缓解的规则,程序中不会存在同时具有可写和可执行权限的内存,所以无法通过修改程序的代码段或者数据段来执行任意代码。针对这种漏洞缓解机制,有一种通过返回到程序中特定的指令序列从而控制程序执行流程的攻击技术,被称为返回导向式编程(Return-Oriented Programming,ROP)。本节介绍如何利用这种技术来实现在漏洞程序中执行任意指令。

上节介绍了栈溢出的原理和通过覆盖返回地址的方式来劫持程序的控制流,并通过ret指令跳转到shell函数来执行任意命令。但是正常情况下,程序中不可能存在这种函数。但是可以利用以ret(0xc3)指令结尾的指令片段(gadget)构建一条ROP链,来实现任意指令执行,最终实现任意代码执行。具体步骤为:寻找程序可执行的内存段中所有的ret指令,然后查看在ret前的字节是否包含有效指令;如果有,则标记片段为一个可用的片段,找到一系列这样的以ret结束的指令后,则将这些指令的地址按顺序放在栈上;这样,每次在执行完相应的指令后,其结尾的ret指令会将程序控制流传递给栈顶的新的Gadget继续执行。栈上的这段连续的Gadget就构成了一条ROP链,从而实现任意指令执行。

1.寻找gadget

理论上,ROP是图灵完备的。在漏洞利用过程中,比较常用的GADGET有以下类型:

 保存栈数据到寄存器,如:

 系统调用,如:

 会影响栈帧的Gadget,如:

寻找Gadget的方法包括:寻找程序中的ret指令,查看ret之前有没有所需的指令序列。也可以使用ROPgadget、Ropper等工具(更快速)。

2.返回导向式编程

【例6-4-1】

用如下命令进行编译:

与之前栈溢出所用的例子的差别在于,程序中并没有预置可以用来执行命令的函数。

先用ROPgadget寻找这个程序中的Gadget:

得到如下Gadget:

这个程序很小,可供使用的Gadget非常有限,其中没有syscall这类可以用来执行系统调用的Gadget,所以很难实现任意代码执行。但是可以想办法先获取一些动态链接库(如libc)的加载地址,再使用libc中的Gadget构造可以实现任意代码执行的ROP。

程序中常常有像puts、gets等libc提供的库函数,这些函数在内存中的地址会写在程序的GOT表中,当程序调用库函数时,会在GOT表中读出对应函数在内存中的地址,然后跳转到该地址执行(见图6-4-1),所以先利用puts函数打印库函数的地址,减掉该库函数与libc加载基地址的偏移,就可以计算出libc的基地址。

图6-4-1

程序中的GOT表见图6-4-2。puts函数的地址被保存在0x601018位置,只要调用puts(0x601018),就会打印puts函数在libc中的地址。

根据puts函数在libc库中的偏移地址,就可以计算出libc的基地址,然后可以利用libc中的Gadget构造可以执行“/bin/sh”的ROP,从而获得shell。可以直接调用libc中的system函数,也可以使用syscall系统调用来完成。调用system函数的方法与之前的类似,所以这里改为用系统调用来进行演示。

图6-4-2

通过查询系统调用表,可以知道execve的系统调用号为59,想要实现任意命令执行,需要把参数设置为:

在x64位操作系统上,设置方式为在执行syscall前将rax设为59,rdi设为字符串"/bin/sh"的地址,rsi和rdx设为0。字符串"/bin/sh"可以在libc中找到,不需另外构造。

虽然不能直接改写寄存器中的数据,但是可以将要写入寄存器的数据和Gadget一起入栈,然后通过出栈指令的Gadget,将这些数据写入寄存器。本例需要用到的寄存器有RAX、RDI、RSI、RDX,可以从libc中找到需要的Gadget:

泄露库函数地址后,接下来要做的就是控制程序重新执行main函数,这样可以让程序重新执行,从而可以读入并执行新的ROP链来实现任意代码执行。

完整利用脚本如下:

ROP的基本介绍如上,读者可以按照上面的例子,在调试器中单步跟踪ROP的执行过程。这样可以深刻理解ROP执行的原理和过程。ROP更加高级的用法,如循环选择等,需要根据一定条件修改RSP的值来实现。读者可以自己动手尝试构造,不再赘述。

七、格式化字符串漏洞

(1)格式化字符串漏洞基本原理

C语言中常用的格式化输出函数如下:

它们的用法类似,本节以printf为例。在C语言中,printf的常规用法为:

其中,函数第一个参数带有%d、%s等占位符的字符串被称为格式化字符串,占位符用于指明输出的参数值如何格式化。

占位符的语法为:

parameter可以忽略或者为n$,n表示此占位符是传入的第几个参数。

flags可为0个或多个,主要包括:

 +—总是表示有符号数值的'+'或'-',默认忽略正数的符号,仅适用于数值类型。

 空格—有符号数的输出如果没有正负号或者输出0个字符,则以1个空格作为前缀。

 -—左对齐,默认是右对齐。

 #—对于'g'与'G',不删除尾部0以表示精度;对于'f'、'F'、'e'、'E'、'g'、'G',总是输出小数点;对于'o'、'x'、'X',在非0数值前分别输出前缀0、0x和0X,表示数制。

 0—在宽度选项前,表示用0填充。

field width给出显示数值的最小宽度,用于输出时填充固定宽度。实际输出字符的个数不足域宽时,根据左对齐或右对齐进行填充,负号解释为左对齐标志。如果域宽设置为“*”,则由对应的函数参数的值为当前域宽。

precision通常指明输出的最大长度,依赖于特定的格式化类型:

 对于d、i、u、x、o的整型数值,指最小数字位数,不足的在左侧补0。

 对于a、A、e、E、f、F的浮点数值,指小数点右边显示的位数。

 对于g、G的浮点数值,指有效数字的最大位数。

 对于s的字符串类型,指输出的字节的上限。

如果域宽设置为“*”,则对应的函数参数的值为precision当前域宽。

length指出浮点型参数或整型参数的长度:

 hh—匹配int8大小(1字节)的整型参数。

 h—匹配int16大小(2字节)的整型参数。

 l—对于整数类型,匹配long大小的整型参数;对于浮点类型,匹配double大小的参数;对于字符串s类型,匹配wchar_t指针参数;对于字符c类型,匹配wint_t型的参数。

 ll—匹配long long大小的整型参数。

 L—匹配long double大小的整型参数。

 z—匹配size_t大小的整型参数。

 j—匹配intmax_t大小的整型参数。

 t—匹配ptrdiff_t大小的整型参数。

type表示如下:

 d、i—有符号十进制int值。

 u—十进制unsigned int值。

 f、F—十进制double值。

 e、E—double值,输出形式为十进制的“[-]d.ddd e[+/-]ddd”。

 g、G—double型数值,根据数值的大小,自动选f或e格式。

 x、X—十六进制unsigned int值。

 o—八进制unsigned int值。

 s—字符串,以\x00结尾。

 c—一个char类型字符。

 p—void*指针型值。

 a、A—double型十六进制表示,即"[-]0xh.hhhh p±d",指数部分为十进制表示的形式。

 n—把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

 %—'%'字面值,不接受任何flags、width、precision或length。

如果程序中printf的格式化字符串是可控的,即使在调用时没有填入对应的参数,printf函数也会从该参数位置所对应的寄存器或栈中取出数据作为参数进行读写,容易造成任意地址读写。

(2)格式化字符串漏洞基本利用方式

通过格式化字符串漏洞可以进行任意内存的读写。由于函数参数通过栈进行传递,因此使用“%X$p(X为任意正整数)可以泄露栈上的数据。并且,在能对栈上数据进行控制的情况下,可以事先将想泄露的地址写在栈上,再使用“%X$p”,就可以以字符串格式输出想泄露的地址。

除此之外,由于“%n”可以将已经成功输出的字符的个数写入对应的整型指针参数所指的变量,因此可以事先在栈上布置想要写入的内存的地址。再通过“%Yc%X$n”(Y为想要写入的数据)就可以进行任意内存写。

【例6-5-1】

用如下命令编译例6-5-1的程序:

在printf处设置断点,此时RSP正好在我们输入字符串的位置,即第6个参数的位置(64位Linux前5个参数和格式化字符串由寄存器传递),我们输入“AAAAAAAA%6$p”:

程序确实把输入的8个A当作指针型变量输出了,我们可以先利用这个进行信息泄露。

栈中有__libc_start_main调用__libc_csu_init前压入的返回地址(见图6-5-1),根据这个地址,就可以计算libc的基地址,可以计算出该地址在第21个参数的位置;同理,_start在第17个参数的位置,通过它可以计算出fsb程序的基地址。

图6-5-1

有了libc基地址后,就可以计算system函数的地址,然后将GOT表中printf函数的地址修改为system函数的地址。下一次执行printf(format)时,实际会执行system(format),输入format为“/bin/sh”即可获得shell。利用脚本如下:


脚本中将system的地址(6字节)拆分为3个word(2字节),是因为如果一次性输出一个int型以上的字节,printf会输出几GB的数据,在攻击远程服务器时可能非常慢,或者导致管道中断(broken pipe)。注意,64位的程序中,地址往往只占6字节,也就是高位的2字节必然是“\x00”,所以3个地址一定要放在payload最后,而不能放在最前面。虽然放在最前面,偏移量更好计算,但是printf输出字符串时是到“\x00”为止,地址中的“\x00”会截断字符串,之后用于写入地址的占位符并不会生效。

(3)格式化字符串不在栈上的利用方式

有时输入的字符串并不是保存在栈上的,这样没法直接在栈上布置地址去控制printf的参数,这种情况下的利用相对比较复杂。

因为程序有在调用函数时将rbp压入栈中或者将一些指针变量存在栈中等操作,所以栈上会有很多保存着栈上地址的指针,而且容易找到三个指针p1、p2、p3,形成p1指向p2、p2指向p3的情况,这时我们可以先利用p1修改p2最低1字节,可以使p2指向p3指针8字节中的任意1字节并修改它,这样可以逐字节地修改p3成为任意值,间接地控制了栈上的数据。

【例6-5-2】

用如下命令编译例6-5-2的程序:


在printf处设置断点,此时栈分布情况见图6-5-2。0x7ffffffee030处保存的指针指向0x7ffffffee060,而0x7ffffffee060处保存的指针又指向了0x7ffffffee080,满足了上面的要求,这3个指针分别在printf第10、16、20个参数的位置。该程序在循环执行30次输入、输出前申请了一个内存块,用于存放输入的字符串,循环结束后会释放掉这个内存块然后退出程序。我们可以将0x7ffffffee080处的值改为GOT表中free函数项的地址,再将其中的函数指针改为system函数的地址。这样在执行free(format)时,实际执行的就是system(format)了,只要输入"/bin/sh"即可拿到shell。

图6-5-2

完整脚本如下:

(4)格式化字符串的一些特殊用法

格式化字符串有时会遇到一些比较少见的占位符,如""表示取对应函数参数的值来作为宽度,printf("%d",3,1)输出" 1"。

【例6-5-3】

如在例6-5-3中,猜测两个数的和,猜对后可以拿到shell。不考虑爆破的情况,虽然格式化字符串可以泄露这两个数的值,但是输入是在泄露前,泄露后已经无法修改猜测的值,所以必须利用这个机会,直接往num中填上a与b的和,这就需要用到占位符"*"。

在printf(buf)处设置断点,此时栈上的数据见图6-5-3。a、b两个数(分别为0x1b2d、0xc8e3)在第8、9个参数位置,num_ptr在第11个参数位置。a、b两个数作为两个输出宽度,输出的字符数就是a、b之和,再用“%n”写入num中,即可达到num==a+b的效果。

图6-5-3

脚本如下:

(5)格式化字符串小结

格式化字符串利用最终还是任意地址的读写,一个程序只要能做到任意地址读写,距离完全控制就不远了。

有时候程序会开启Fortify保护机制,这样程序在编译时所有的printf()都会被__printf_chk()替换。两者之间的区别如下:
 当使用位置参数时
,必须使用范围内的所有参数,不能使用位置参数不连续地打印。例如,要使用“%3$x”,必须同时使用“%1$x”和“%2$x”。

 包含“%n”的格式化字符串不能位于内存中的可写地址。

这时虽然任意地址写很难,但可以利用任意地址读进行信息泄露,配合其他漏洞使用。

PWN ROP 栈溢出 ELF Linux NX Canary ASLR PIE Full Relro 整数溢出 返回导向编程 gadget 格式化字符串
Theme Jasmine by Kent Liao