9th XCTF Final AwD Pwn 出题心得
前言
有幸收到Crazyman的邀请,参与了9th XCTF Final AwD赛的赛题。
今年XCTF Final的赛制进行了很大的创新,除了传统的解题赛之外,还包含了RealWorld、IoT、AwD,其中AwD独占一天8.5h。对于AwD的赛制规则,今年也有大幅的改动。之前国内赛的AwD多数以AwDplus为主,少数是基于选手SSH维护服务的经典AwD,而今年的XCTF Final AwD赛制是既不会像经典AwD那样那么混乱导致选手体验失衡,也不会像AwDplus过于的束缚选手的想象力。同时XCTF Final的AwD引入了类似DEFCON的Patch和流量延迟公开的机制,使得选手对于赛制的策略需要有较大的变化。
AwD的记分也有比较大的改动,为按轮次记分,被攻陷扣除30%当前得分,并平分给成功攻陷的队伍。也就是如果一支队伍一道题目很强,获得了几万分,但是如果修补存在漏洞,会导致其他队伍直接获得这几万份的30%,一举翻盘。
当我收到这个赛制信息的时候是7月份,我十分喜爱这个创新,并打算构思一些有意思的题目,虽然延期了(我也10月份才动工)。最后为了适应Patch和流量延迟公开的机制以及赛题记分的机制,我贡献了两道题目somehash和someheap。
somehash
设计构思
本题是先构思的一道题目,最开始的设计是本题需要结合Crypto、Reverse的一道Pwn题。并且也不打算加入传统Pwn题打ROP、HOOK、IO FILE等技术,所以本题的核心是信息泄漏,攻击者需要思考如何从靶机中获得Admin token。
首先,我设计了一个Challenge-Response挑战应答模式的登陆认证,如果认证成功,那么即可获得Shell(使用挑战应答模式可以避免其他队伍批量尝试token的时候流量造成的泄漏,以及这里也可以埋入一个漏洞点)。
之后我结合Patch延迟公开的机制,设计了一个外部文件生成Admin token的过程,此过程设计上需要加入一点点的逆向,并且此处默认config是空,以及如果config校验失败也会导致Admin token为默认,其他队伍可以通过默认Admin token获得flag。
由于Patch会延迟公布,所以这里要求选手,编写自动化的config生成,并且每回合都需要提交新的Patch;同理攻击者也需要编写自动化下载Patch分析config生成admin token去批量攻击其他队伍。(这同时也使得抄Patch也容易导致admin token泄漏)
那么接下来,继续围绕admin token设计其他的漏洞。
漏洞清单
题目中包含两个直接漏洞
- 默认admin token以及Patch延迟公开导致admin token泄漏
- strncpy拼接并由printf %s泄漏admin token
以及四个非直接的漏洞,因为涉及session中间量,所以分成两个过程,分别是 过程A和过程B
过程A漏洞
- 弱随机数种子 srand(time(0))
- 利用生日攻击MD5,得到1最少的user token,通过统计泄漏的session恢复admin token
过程B漏洞
- login逻辑中,scanf未初始化导致泄漏session
- show heap逻辑中,由于使用了strncpy方法导致泄漏session
过程A中选择一种方法,过程B中选择一种方法,可以组合出4种攻击方法
修补
这道题目依旧保留了AwDplus类似的patch elf,并check修改的字符是否在给定的允许patch范围的白名单中,本题对于ELF的patch校验十分严格,只允许4个位置,但是对于config并没有校验
1 | WHITE_LIST = [ |
直接漏洞1
默认config文件是空,generate_token过程中memset(token, 0x41, 0x10);将token初始化为0x41,config文件未反序列化成功,token仍将保持为0x41。
修补建议,需要逆向config文件反序列化过程,编写自动化生成config文件与提交patch的脚本。
config生成
本题config生成是一个划分问题,题目要求将下面这个文本(随便找的0w0),划分成若干份,并按照size|nonce|content为一个block的方式写入文件。题目还要求block的顺序必须与与其自身的MD5顺序相同,所以需要爆破多次尝试nonce,直到符合条件(这个爆破复杂度并不大)。随后程序会根据划分的每一个block的size,拼接,计算MD5,得到admin token。
1 | There, my blessing with thee. And these few precepts in thy memory Look thou character. Give thy thoughts no tongue, Nor any unproportioned thought his act. Be thou familiar but by no means vulgar. Those friends thou hast, and their adoption tried, Grapple them unto thy soul with hoops of steel, But do not dull thy palm with entertainment Of each new-hatched, unfledged comrade. Beware Of entrance to a quarrel, but being in, Bear 't that th' opposed may beware of thee. Give every man thy ear but few thy voice. Take each man's censure but reserve thy judgment. Costly thy habit as thy purse can buy, But not expressed in fancy-rich, not gaudy, For the apparel oft proclaims the man, And they in France of the best rank and station Are of a most select and generous chief in that. Neither a borrower nor a lender be, For loan oft loses both itself and friend, And borrowing dulls the edge of husbandry. This above all: to thine own self be true, And it must follow, as the night the day, Thou canst not then be false to any man. Farewell. My blessing season this in thee. |
所以选手逆向理解这个逻辑之后,给大模型应该很快就能写出自动化生成的脚本。
直接漏洞2
在login_user逻辑中strncpy(user_username, username, 0x10);会填满user_username导致后续help方法泄漏admin token。
修补建议,patch为strncpy(user_username, username, 0xf);
间接漏洞-过程A-1
弱随机数种子来自的time(0);,可能组合后续的leak session导致admin token泄漏。
修补建议,使用其他数值替换srand参数,例如基于PIE的随机数种子等。
间接漏洞-过程A-2
在login_user过程中,xor_chars(user_token, tmp, 0x10);存在生日攻击可能,但就算不xor也能构造出1多0少的md5,之后通过泄漏session,即可通过纵向统计session中相同位置的bit的0和1的数量即可恢复admin token。

此漏洞为逻辑漏洞,无法修补,建议修补其他泄漏session的过程
间接漏洞-过程B-1
在login函数中,scanf("%lx", &chall->session.a);可能导致chall->session.a未初始化问题,导致堆上数据泄露,从而可以组合过程A,泄漏admin token。
修补建议,将生成challenge的函数rand_bytes参数增加rand_bytes((unsigned char*)chall->challenge, 0x20);
间接漏洞-过程B-2
在view_note的函数中,strncpy(tmp, buffer[idx], sizes[idx]);此处使用strncpy拷贝堆上xor后的数据,而堆上数据可能会因为0截断,导致拷贝并不完全,导致tmp数据缺失部分数据,最后进行decrypt的时候泄漏session
修补建议,将strncpy替换成memcpy即可。
someheap
本题是在XCTF比赛前一周开始构思的题目,一样需要结合XCTF Final的Patch公开机制和流量公开机制,打算设计一个竞技性很强的题目。本题也是打算设计成比较贴近DEFCON Final题目的类型,让选手体验竞技的刺激。
(其实也是不想写传统的白名单限制允许patch的区域,这种模式有点像是填空题了,所以这道题目设计上不希望选手能patch ELF了)
这道题目核心是一道堆菜单题,并且需要利用好防御方和攻击方的作用。
这道菜单题,包含多个堆原语的操作。
- Add操作 (分成malloc和calloc)
- Free操作 (存在UAF)
- Edit操作 (写入长度由攻击方控制,可以overflow)
- Show操作 (输出长度由攻击方控制,越界泄漏)
防御方
本题设计的难点主要在于防御方。为了能更好的融入题目,这里设计了一个firewall文件,防御方需要编写amd64的机器码,当程序启动的时候,会加载这个firewall文件到内存中,随后在堆操作前后会调用这个文件,进行check(check返回约定是,返回0表示正常,返回非0程序将exit退出)
由于防御方拥有当前进程执行任意代码的能力,所以我需要给防御方增加编写amd64代码的难度。
首先这道题目开启了沙箱,并且firewall不允许包含
0x0f 0x05syscall/0x0f 0x34sysenter/0xcd 0x80int 0x80这些会产生系统调用的指令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# check if arch is X86_64
A = arch
A == ARCH_X86_64 ? next : dead
A = sys_number
A >= 0x40000000 ? dead : next
A == open ? ok : next
A == read ? ok : next
A == write ? ok : next
A == close ? ok : next
A == brk ? ok : next
A == exit ? ok : next
A == exit_group ? ok : next
A == futex ? ok : next
A == getrandom ? ok : next
if(A == arch_prctl) goto prctl_test
goto dead
prctl_test:
A = args[0]
A == 0x1001 ? ok : next
A == 0x1002 ? ok : next
A == 0x1003 ? ok : next
A == 0x1004 ? ok : next
goto dead
ok:
return ALLOW
dead:
return KILL为了防止防御者可以通过地址计算,逃逸到bss上,或者libc中,这里需要防止防御者使用代码计算得到bss/libc地址,所以这里采用了强随机地址,使用
/dev/urandom生成强随机的地址区域,作为firewall代码段、firewall执行的stack空间等。1
2dynamic_obj->func = mmap((void*)((size_t)get_random_addr() & (~0xfff)), 0x1000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
dynamic_obj->stack = mmap((void*)((size_t)get_random_addr() & (~0xfff)), 0x1000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);其次执行firewall的时候需要清空所有寄存器包括xmm寄存器和fs、gs寄存器,但是由于需要记录返回地址,所以我将返回地址记录在了firewall代码段的最后方,此时又需要防止firewall读取到这个地址导致逃逸,所以我设置了firewall代码段
--x的权限1
mprotect(dynamic_obj->func, 0x1000, PROT_EXEC);
为了让firewall程序编程无状态的,所以每次执行的时候都会将firewall执行栈清空
1
memset(dynamic_obj->stack, 0, 0x1000);
最后为了防止防御方很简单的通过idx信息进行记录对应的堆状态,这里允许攻击者设置srand种子,并且firewall获得idx参数时解密前的参数,真实idx是
encrypt(idx) % 0x100(这里为了rand()数据在stack上残留,故意开了O2)1
2
3
4__attribute__((optimize("O2"))) size_t encrypt(size_t val) {
register size_t rand_val = (size_t)rand();
return val + rand_val;
}最后防御方还有一个最困难的一点,防御方无法判断当前被调用的地方是在add/free/show/edit哪一个功能中,因为每一个传入的参数都是
(idx, size, heap_addr)
这些大概就是防御方的限制,可以看到,防御方只能通过一些堆的状态进行判断。
比较好的是,进行校验的位置都是堆地址申请出来之后,释放之前这个区间进行校验,也就是说,堆地址正常生命周期处在被使用的时候会进入check,所以最简单的校验是通过prev_inuse位置来判断,当前堆地址是否存在UAF问题。(当然这也只能解决unsortedbins、smallbins、largebins这些相关的UAF,并不能解决fastbins、tcachebins的UAF问题)
其次,为了解决Overflow的问题,也可以通过获取堆的size,进行判断。这两个校验应该是比较容易想到的。
如何解决tcachebins的UAF问题?
我也没想到很完备的方案能解决这个问题,但是可以稍微限制的是,可以通过解析tcache_entry结构体,来校验是否存在tcache UAF问题。
1 |
|
当然这个检查也有机会绕过,通过在页对齐的地方布置一个伪造的tcache_entry即可。
后门?
在比赛的开始也给了提示,这道题目后门是允许的预期。虽然我在后台看日志的时候没找到后门的样本,如果有遗漏,欢迎大家评论或者提交issue!
这里给一个预期中的后门设计方法。
由于这道题目firewall的限制非常大,在一个构造的沙箱中,我们可以通过一些特征的size或者heap内容特定的字符串,或者是heap特定的size等等,这些进行校验是否进入后门分支。其次,由于需要逃逸沙箱,所以需要保证堆数据中包含libc地址,比如说main_arena地址,之后firewall就可以从main_arena逃逸到_environ,逃逸到stack上,stack上包含程序的基地址,从而计算出bss地址,最后即可得到dynamic_obj中所有的信息,其中包含了win函数的地址,直接jmp到win,即可获得flag。
1 | unsigned char win[] = { 0x48, 0xb8, 0x2f, 0x66, 0x6c, 0x61, 0x67, 0x00, 0x00, 0x00, 0x50, 0x48, 0x89, 0xe7, 0x48, 0xc7, 0xc6, 0x00, 0x00, 0x00, 0x00, 0x48, 0xc7, 0xc0, 0x02, 0x00, 0x00, 0x00, 0x0f, 0x05, 0x48, 0x89, 0xc7, 0x48, 0x89, 0xe6, 0x48, 0xc7, 0xc2, 0x40, 0x00, 0x00, 0x00, 0x48, 0xc7, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x05, 0x48, 0xc7, 0xc7, 0x01, 0x00, 0x00, 0x00, 0x48, 0x89, 0xe6, 0x48, 0xc7, 0xc2, 0x40, 0x00, 0x00, 0x00, 0x48, 0xc7, 0xc0, 0x01, 0x00, 0x00, 0x00, 0x0f, 0x05, 0x48, 0xc7, 0xc0, 0x3c, 0x00, 0x00, 0x00, 0x48, 0x31, 0xff, 0x0f, 0x05 }; |
进攻方
题目对于进攻方的交互上没有特殊的设计,就是常规的交互。
由于规则限制,所有选手需要至少提交一次flag,才能patch此题目,所以大家需要攻击默认状态下的题目,而默认状态下firewall为空,也就是简单的tcache UAF利用打IO FILE,比较基础。
由于防御方依旧可以通过一些技巧,特定的限制进攻方的利用,导致利用难度剧增,本题设计了一个Level机制。
本题目分成6个level,level根据防御方firewall长度而变化
当firewall长度<=5 (初始情况)
- 攻击方没有降低难度
当firewall长度<=30 (基本情况)
- 攻击方能直接获得heap地址、libc地址、一个随机可读可写地址Addr
当firewall长度<=50 (基本上能检查size长度,阻止溢出了)
- 攻击方能直接获得heap地址、libc地址、一个随机可读可写地址Addr
- 并且攻击方只需要在这个随机可读可写地址Addr写入win函数地址即可拿到flag
当firewall长度<=80 (此时能做一点简易的uaf检查)
- 攻击方能直接获得heap地址、libc地址、一个随机可读可写地址Addr
- 并且攻击方只需要在这个随机可读可写地址Addr任何非0的数值即可拿到flag
当firewall长度<=120 (此时基本上能做较强的uaf检查,以及可以做一些size检查)
- 攻击方能直接获得heap地址、libc地址
- 并且攻击方只需要 堆上的一个位置写入win函数地址 即可拿到flag
当firewall长度<=1000 (最后所有情况)
- 攻击方能直接获得heap地址、libc地址
- 并且攻击方只需要 堆上的一个位置写入非0 即可拿到flag
1 | void info() { |
所以进攻方,可以根据当前题目的level,进行针对性的编写exp即可。
后记
本题代码将在 https://github.com/CsomePro/9th-XCTF-Final-ADPWN 开源,欢迎预期或非预期提交issue讨论。
