具体见:利用 - CTF Wiki (ctf-wiki.org)
写法
在使用printf函数时,会用到如下内容:
1 | %d //打印整数 |
这里重点说明下 %s,%n;
%s 虽说时用来打印字符串的,但其本质是将参数视作指针,打印指针指向的内容,一直显示到 ‘\x00’ ,当使用 recv() 函数接收时,得到的是 byte 类型,所以可以得到 int 类型;
%n 如下图所示:
它将存储出现在 %n 之前的字符数量到对应的参数变量中,本身不会有任何显示;
如上图实际打印的内容是: “geeks for geeks” ;
且 %n 一般跑在gcc编译的c中,Windows上的编译器会有问题;
同样只能跑在 gcc 中的另类写法,也是格式化字符串利用的核心:
1 | %3$x |
用下面代码举例,上面的写法会打印出printf的第4个参数的hex形式,也就是c;
1 | printf("%3$x",a,b,c,d); |
3的意思是从格式化字符串开始往后算的第三个参数,x表示格式;
原理
在32位程序中,调用函数时,变量都是存在栈上的,比如当调用下示代码时,会有如此的栈格式:
1 | printf("%3$x",a,b,c,d); |
栈:
ebp-> | 0xold_ebp //printf函数内部栈帧 |
---|---|
0地址 | 0xretaddr //返回地址 |
1地址 | 0xstringaddr //格式化字符串 “%3$x” 地址 |
2地址 | a |
3地址 | b |
4地址 | c |
5地址 | d |
当然真实情况中,这些参数的顺序会有变化(一般就是这样),但是可以通过调试确定下来;
格式化字符串符号 %
的作用就是:读取栈中这些变量的内容,对应的将其打印出来;
第一个%打印第一个参数,第二个%打印第二个参数,也就是栈中的a,b;
当然用特殊的写法可打印对应的参数的内容;
泄露内存
利用 %k$p
获取数据内容,利用 %k$s
获取指针指向的内容,利用 [AimAddr]%k$s
获取指定地址处的内容;
泄露栈变量内存
考虑如下代码:
1 | printf("%2$x"); |
它没有跟参数能这样写吗?
答案是可以,拿上面用表格画的栈图来说,此时它的作用就是将3地址的内容以十六进制打印出来,尽管此时3地址里放的是奇怪的东西;
于是就可以先算出想要得到的 在main栈帧 里的数据对于地址1的间隔 k;
比如现在main中想得到其上级函数的ebp值,调试可知ebp的值存于 m地址 中,那么 k 就应该是:
$$
k = (m地址 - 1地址) / 指针长度
$$
指针长度即为对齐,32位是4,64位是8;
此时执行 printf("%k$p");
便可得到main中储存的上级函数ebp的值;
这个k的数值,也同样是printf函数的第 k+1 个参数,因为1地址中存的是格式化字符串,也就是printf函数的第 1 个参数;
泄露以变量为指针指向的内存
还记得 %s 的作用吗,它会打印出以变量为指针所指的内容;
got表就是一个指针,对于在got表地址上的函数,其实都是指向其真实存在的地址的指针;
假设scanf在got中的地址就是0x12345678,那么利用如下代码,便可打印出 scanf 函数的真实地址:
1 | printf("0x12345678%x%k$s"); |
当此格式化字符串在输出函数调用时是第 k+1个参数的时候,这么写,就能让 %k$s 去格式化 0x12345678 字符串,从而就能得到 0x12345678 指向的内容,进而打印出来得到 scanf 真正的地址;
但很多时候,第k + 1个参数是 “3456..” 或者干脆 “烫烫烫0x1234..” 诸如此类的;
意思是,它并没有对齐,所以当调试结果为上述情况时,请在0x12345678地址前添加垃圾信息,使得在 整数倍的 k + 1 上能够直接拿到 0x12345678 地址,进而使得后面的特殊写法打印出该地址指向的内容;
获取栈中指定指针内存就没那么麻烦了,正如泄露变量内存一样的写法,只是将p改为s;
覆盖内存
这个时候,%n会帮大忙,它可以将其对应参数视为指针,以int型填入在%n前面的字符数量;
所以当知道要覆盖内存的地址,格式化字符串相对于输出函数的偏移就可以进行覆盖了;
具体格式如下:
1 | printf("...[AimAddr]...%k$n"); |
如上的…为垃圾内容负责填充 AimAddr 的对齐,k要找到 AimAddr的位置,后面的…是为了与前面的字符一起扩展成想要的长度,使其填入 AimAddr 中;
覆盖小数字
此时要把 AimAddr 放在 %n 的后面,这样能控制填入的数据始终可以小于4;
具体格式如下:
1 | printf("...%k$n...[AimAddr]"); |
此时…的作用就反转了,前面的是控制写入的数,后者为控制地址的对齐;
覆盖大数字
可以用 … 来扩展很长很长,但会使得程序的性能变低,出来的速度下降;
以 %hhn 来写,可以使得填入变量的类型为字节,以 %hn 来写,可以使得填入变量的类型为双字;
由此可以控制单个字节的覆盖;
而一个int型是需要占4个字节的,那么分别填入单字节内容,使其最后呈现出大数字的效果就行;
比如:c变量的地址为:[c],而想对其填入0x12345678则其内存中应该是如此分布的:
地址 | 存储 |
---|---|
[c] | 0x78 |
[c+1] | 0x56 |
[c+2] | 0x34 |
[c+3] | 0x12 |
x86为小端序存储;
那么对应printf中的内容应该是这样的:
1 | printf("...[c][c+1][c+2][c+3]...%k$hhn...%k+1$hhn...%k+2$hhn...%k+3$hhn"); |
使得k能找到[c]的位置,且控制 %k$hhn 前面的数量为 0x78 ,而第二个%hhn的控制数量应该是 0x156,因为只能增大,不能减小,但是填入的是一个字节的内容,所以只会填入 0x56,后面同理;
基本用法
用题来举例;
goodluck
看IDA的main函数:
可以看的出来整个程序逻辑为,输入后和远程服务器的flag文件比较,仅此而已;
但是它会用printf泄露出输入的内容,所以可以想到用格式化字符串的方法泄露v10的信息;
因为程序是x64,所以函数存放参数的话,是先放在前6个寄存器中,多余的参数放在栈上,所以寻找到合适的栈偏移后,需要加上6;
如上图,这是在printf函数内部,此时的栈图rsp刚刚指向返回地址,那么下面的就都可以看成是 “参数” 了;
可以看到构造的flag文件的内容被放到了rsp往下第四个,加上前6个参数,这算作printf函数的第10个参数,所以在邪路时,写为:
1 | "%9$s" |
用指针方式读出该flag;
解题脚本如下:
1 | from pwn import * |
hijack GOT
未开启 RELRO 保护(Partial RELRO)的程序是可以修改 GOT 表的;
那么可以覆盖System地址给目标函数的地址到got表,此时执行目标函数也就是执行System函数了;
一般步骤:
获取目标函数的got表地址:IDA查询;
获取System函数的内存地址:通过泄露计算;
写入:运用ROP或者write函数或者%n覆盖;
1
2
3
4//此时目标函数为printf
pop eax; ret; # printf@got -> eax
pop ebx; ret; # (addr_offset = system_addr - printf_addr) -> ebx
add [eax] ebx; ret; # [printf@got] = [printf@got] + addr_offset
看题:
查看IDA代码:
主函数一开始有个比较密码,这个逆向还原就行;
接下来是模拟shell,可以输入三种命令:put get dir,分别创建 file_head ,打印content,打印 file_head;
这个题里不存在栈溢出,所以没办法ret,但却是 partial relro,且有格式化字符串漏洞;
所以可以把puts的got表作为指针修改为System地址去执行,因为两个参数类型数量也一样,满足调用约定;
则binsh字符串写入file_head中,格式化字符串漏洞的内容写入content;
此时可以通过格式化字符串漏洞泄露出printf got表地址,从而得到system函数地址;
具体思路:
- 通过密码;
- 执行put,写入任意和字符串漏洞内容;
- 执行get,泄露system地址;
- 执行put,写入字符串漏洞%n覆盖got表指向地址;
- 执行get,完成got表覆盖;
- 再次执行put,写入binsh字符串;
- 执行dir,完成攻击;
首先通过调试把字符串漏洞的偏移确定下来为8;也就是 %7$s
;
通过代码:
1 | from pwn import * |
至于为什么前两次发送 file_head 的时候要加\X00,是因为最后执行system(s)的时候,s会有bug,不加00的话,最后的结果是三串字符串连接在一起,导致system找不到路径;
hijack retaddr
最重要的思想:通过rbp取栈地址;
查看IDA:
上面是main函数,一开始会让输入账户和密码,也就是register函数,同时是两个可以利用的缓冲区;
进入choice函数后,和上道题一样,有三个选择,其中edit是重新写入账户密码,show是两次字符串漏洞,quit是执行一个puts函数;
但这道题开启了RELRO保护,所以不能和上道题一样改变puts的got表,所以思想是覆盖返回地址;
返回地址在栈上,既然要覆盖它,就必须拿到栈上的地址,那么rbp存储的内容就很值得推敲了;
在show中拥有两次字符串漏洞,分别展示之前输入的账户和密码:
1 | int __fastcall sub_400B07(int a1, int a2, int a3, int a4, int a5, int a6, char format, int a8, __int64 a9) |
在此程序的地址:0x4008AA处,会发现一个system(“/bin/sh”)的调用,那么可以利用字符串漏洞覆盖返回地址为该地址;
输入se和ss并在return的printf处打下断点并查看栈图:
可以看到printf的第三个参数是输入的账号,第一排为printf的返回地址,而第二个参数是show函数的返回地址,如下方汇编所示;
第一个参数是show函数的rbp指向,则旧rbp值也是指向栈的,所以可以利用旧rbp值来进行便宜计算,拿到返回地址的地址;
$$
c0 - 80 - 8 = 38
$$
则用旧rbp值减去0x38便可以得到返回choice函数地址的地址;
代码如下:
1 | from pwn import * |
字符串盲打
字如其名,手里没有可逆向的文件,只能靠格式化字符串输入来获取远程文件的信息以攻占shell;
一般来说有如下步骤:
- 确定程序位数
- 确定漏洞位置
- 利用
栈泄露
查看题目输入%p查看多少位:
此图的上半部分展示了它是64位的程序;
且它有提示告知了:flag is on the stack;
那么就循环输入%p一直查看栈上的内容,正如上图的下半部分所示即可得出flag;
代码如下:
1 | from pwn import * |
劫持got
依然是查看位数和确定字符串偏移:
此时的偏移就有用了,因为要劫持got;
如图可知,偏移为:6,也就是%6$p;
程序一般是从0x400000开始,要劫持got表就需要知道got内容,所以直接用字符串漏洞的方法泄露整个程序的数据:
1 | from pwn import * |
通用格式,直接套就行,使用之后便可得到原程序binary;
之后分析binary:
看得出整个程序非常简单;
可以知道的是无法利用栈溢出;
若要劫持got表也只有printf函数的;
所以思路是:
- 泄露printf自身函数地址并计算出system;
- 覆盖got表;
- 输入binsh执行system函数成功攻击;
代码如下:
1 | from pwn import * |