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 并没有校验
WHITE_LIST = [ Allowed(offset=0x14F1, length=0x19), # 修改 srand(time(0)),增强随机数 Allowed(offset=0x2057, length=5), # 防止login入口泄漏session Allowed(offset=0x22cd, length=5), # 防止login_user拼接泄漏admin token Allowed(offset=0x2b9c, length=5), # 防止堆操作泄漏session]直接漏洞 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。
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 这些会产生系统调用的指令# check if arch is X86_64A = archA == ARCH_X86_64 ? next : deadA = sys_numberA >= 0x40000000 ? dead : nextA == open ? ok : nextA == read ? ok : nextA == write ? ok : nextA == close ? ok : nextA == brk ? ok : nextA == exit ? ok : nextA == exit_group ? ok : nextA == futex ? ok : nextA == getrandom ? ok : nextif(A == arch_prctl) goto prctl_testgoto deadprctl_test:A = args[0]A == 0x1001 ? ok : nextA == 0x1002 ? ok : nextA == 0x1003 ? ok : nextA == 0x1004 ? ok : nextgoto deadok:return ALLOWdead:return KILL -
为了防止防御者可以通过地址计算,逃逸到 bss 上,或者 libc 中,这里需要防止防御者使用代码计算得到 bss/libc 地址,所以这里采用了强随机地址,使用
/dev/urandom生成强随机的地址区域,作为 firewall 代码段、firewall 执行的 stack 空间等。dynamic_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的权限mprotect(dynamic_obj->func, 0x1000, PROT_EXEC); -
为了让 firewall 程序编程无状态的,所以每次执行的时候都会将 firewall 执行栈清空
memset(dynamic_obj->stack, 0, 0x1000); -
最后为了防止防御方很简单的通过 idx 信息进行记录对应的堆状态,这里允许攻击者设置 srand 种子,并且 firewall 获得 idx 参数时解密前的参数,真实 idx 是
encrypt(idx) % 0x100(这里为了 rand () 数据在 stack 上残留,故意开了 O2)__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 问题。
#include <stddef.h>
# define TCACHE_MAX_BINS 64
typedef struct tcache_entry{ struct tcache_entry *next; /* This field exists to detect double frees. */ size_t key;} tcache_entry;
typedef struct tcache_perthread_struct{ unsigned short counts[TCACHE_MAX_BINS]; tcache_entry *entries[TCACHE_MAX_BINS];} tcache_perthread_struct;
#define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
size_t check(size_t a, size_t b, size_t addr) { size_t base = addr & (~0xfff);
while (1) { size_t* p = (size_t*)(base + 0x8); if(*p != 0x291) { base -= 0x1000; continue; } break; }
tcache_perthread_struct* tcache = (tcache_perthread_struct*)(base + 0x10); size_t size = *(size_t*)(addr - 0x8);
if(size > 0x410) { return 0; }
size_t tc_idx = (size - 0x20) >> 4; size_t tc_cnt = tcache->counts[tc_idx]; int cnt = 0; tcache_entry *tmp; for (tmp = tcache->entries[tc_idx]; tmp; tmp = REVEAL_PTR (tmp->next), ++cnt) { if (cnt >= tc_cnt) return 0; if ((size_t)tmp == addr) return 1; } return 0;}当然这个检查也有机会绕过,通过在页对齐的地方布置一个伪造的tcache_entry即可。
后门?
在比赛的开始也给了提示,这道题目后门是允许的预期。虽然我在后台看日志的时候没找到后门的样本,如果有遗漏,欢迎大家评论或者提交 issue!
这里给一个预期中的后门设计方法。
由于这道题目 firewall 的限制非常大,在一个构造的沙箱中,我们可以通过一些特征的 size 或者 heap 内容特定的字符串,或者是 heap 特定的 size 等等,这些进行校验是否进入后门分支。其次,由于需要逃逸沙箱,所以需要保证堆数据中包含 libc 地址,比如说 main_arena 地址,之后 firewall 就可以从 main_arena 逃逸到_environ,逃逸到 stack 上,stack 上包含程序的基地址,从而计算出 bss 地址,最后即可得到dynamic_obj中所有的信息,其中包含了 win 函数的地址,直接 jmp 到 win,即可获得 flag。
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 };size_t win_addr = (size_t)get_random_addr();dynamic_obj->win = mmap((void*)(win_addr & (~0xfff)), 0x2000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);size_t win_addr_page = (size_t)dynamic_obj->win;dynamic_obj->win = (void*)(((size_t)dynamic_obj->win) + (win_addr & 0xfff));memcpy(dynamic_obj->win, win, sizeof(win));mprotect((void*)win_addr_page, 0x2000, PROT_EXEC);进攻方
题目对于进攻方的交互上没有特殊的设计,就是常规的交互。
由于规则限制,所有选手需要至少提交一次 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
void info() { printf("win: %p\n", dynamic_obj->win); if(dynamic_obj->level.info_leak) { char *p = malloc(0x500); printf("printf: %p\n", printf); printf("heap: %p\n", p); free(p); } if(dynamic_obj->level.in_heap == 0) { printf("target: %p\n", dynamic_obj->near_heap_addr); }}
void win_flag() { if(dynamic_obj->level.near_heap == 1) { if((*((size_t*)dynamic_obj->near_heap_addr)) == (size_t)dynamic_obj->win) dynamic_obj->win(); } else if(dynamic_obj->level.near_heap == 2) { if(*((size_t*)(dynamic_obj->near_heap_addr))) dynamic_obj->win(); } else { _IO_flush_all(); ___exit(0); }}所以进攻方,可以根据当前题目的 level,进行针对性的编写 exp 即可。
后记
本题代码将在 https://github.com/CsomePro/9th-XCTF-Final-ADPWN 开源,欢迎预期或非预期提交 issue 讨论。
Translate by Kimi-K3
9th XCTF Final AwD Pwn Challenge Authoring Notes
Preface
I was fortunate to receive an invitation from Crazyman to contribute challenges to the 9th XCTF Final AwD competition.
This year’s XCTF Final introduced major innovations to the format: in addition to the traditional jeopardy-style contest, it included RealWorld, IoT, and AwD, with AwD alone taking up a full 8.5-hour day. The AwD rules also changed significantly this year. In the past, most domestic AwD events in China used the AwDplus format, with a few using classic AwD where teams maintain services over SSH. This year’s XCTF Final AwD format avoids both the chaos of classic AwD (which unbalances the player experience) and the way AwDplus overly constrains players’ imagination. At the same time, the XCTF Final AwD introduced DEFCON-style delayed disclosure of patches and traffic, which requires teams to substantially rethink their strategies.
The AwD scoring also changed considerably: scoring is per-round, and being compromised deducts 30% of your current points, which are then split evenly among the teams that successfully compromised you. In other words, if a team is very strong on a particular challenge and has earned tens of thousands of points, but their patch still contains vulnerabilities, other teams can directly take 30% of those tens of thousands of points and turn the tables in one move.
I received this format information in July, and I loved the innovation. I planned to design some interesting challenges, although things got delayed (I didn’t actually start until October). In the end, to fit the delayed patch/traffic disclosure mechanism and the scoring mechanism, I contributed two challenges: somehash and someheap.
somehash
Design Concept
This was the first challenge I conceived. The original design was a Pwn challenge combined with Crypto and Reverse. I also didn’t plan to include traditional Pwn techniques like ROP, HOOK, or IO FILE, so the core of this challenge is information leakage: the attacker needs to figure out how to obtain the Admin token from the target machine.
First, I designed a Challenge-Response login authentication. If authentication succeeds, you get a shell. (Using a challenge-response scheme avoids leakage from traffic when other teams brute-force tokens in bulk, and it also lets me embed a vulnerability here.)
Next, combined with the delayed patch disclosure mechanism, I designed a process where an external file generates the Admin token. This process intentionally requires a bit of reversing, and the default config is empty. If config validation fails, the Admin token also stays at its default value, so other teams can use the default Admin token to get the flag.
Since patches are disclosed with a delay, players are required to write automated config generation and submit a new patch every round. Likewise, attackers need to write automation that downloads patches, analyzes the config, generates the admin token, and attacks other teams in bulk. (This also means that blindly copying patches can easily lead to admin token leakage.)
With that in place, I designed the remaining vulnerabilities around the admin token.
Vulnerability List
The challenge contains two direct vulnerabilities:
- The default admin token, plus the delayed patch disclosure causing admin token leakage
strncpyconcatenation followed byprintf %sleaking the admin token
And four indirect vulnerabilities. Since they involve session intermediates, they are split into two processes: Process A and Process B.
Process A vulnerabilities:
- Weak random seed
srand(time(0)) - Using a birthday attack on MD5 to obtain the user token with the fewest 1 bits, then recovering the admin token by statistically analyzing leaked sessions
Process B vulnerabilities:
- In the login logic,
scanfleaving a variable uninitialized leaks the session - In the show heap logic, the use of
strncpyleaks the session
Choosing one method from Process A and one from Process B yields 4 possible attack combinations.
Patching
This challenge kept the AwDplus-style approach of patching the ELF and checking whether the modified bytes fall within a given whitelist of allowed patch regions. The ELF patch validation for this challenge was very strict, allowing only 4 locations, but the config was not validated at all.
WHITE_LIST = [ Allowed(offset=0x14F1, length=0x19), # Modify srand(time(0)) to strengthen randomness Allowed(offset=0x2057, length=5), # Prevent session leak at the login entry Allowed(offset=0x22cd, length=5), # Prevent admin token leak from login_user concatenation Allowed(offset=0x2b9c, length=5), # Prevent session leak from heap operations]Direct Vulnerability 1
The default config file is empty. In generate_token, memset(token, 0x41, 0x10); initializes the token to 0x41, and if the config file fails to deserialize, the token remains 0x41.
Suggested fix: reverse the config file deserialization process and write a script that automatically generates the config file and submits the patch.
Config Generation
Config generation in this challenge is a partitioning problem. The challenge requires taking the following text (just something I grabbed, 0w0), splitting it into several parts, and writing them to a file as blocks in the form size|nonce|content. The challenge also requires that the order of the blocks match the order of their own MD5 hashes, so you need to brute-force the nonce multiple times until the condition is met (the brute-force complexity is not large). The program then concatenates each block according to its size, computes the MD5, and obtains the admin token.
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.So once players reverse and understand this logic, they can hand it to an LLM and quickly get an automated generation script.
Direct Vulnerability 2
In the login_user logic, strncpy(user_username, username, 0x10); completely fills user_username, causing the subsequent help method to leak the admin token.
Suggested fix: patch it to strncpy(user_username, username, 0xf);.
Indirect Vulnerability - Process A-1
The weak random seed comes from time(0);, which can be combined with the later session leak to leak the admin token.
Suggested fix: replace the srand argument with another value, such as a PIE-based random seed.
Indirect Vulnerability - Process A-2
In the login_user process, xor_chars(user_token, tmp, 0x10); is susceptible to a birthday attack. But even without the xor, you can construct an MD5 with many 1s and few 0s. Then, by leaking sessions, you can recover the admin token simply by vertically counting the number of 0s and 1s at the same bit position across sessions.

This is a logic vulnerability and cannot be patched directly; it’s recommended to patch the other session-leaking processes instead.
Indirect Vulnerability - Process B-1
In the login function, scanf("%lx", &chall->session.a); may leave chall->session.a uninitialized, causing on-heap data to leak. This can then be combined with Process A to leak the admin token.
Suggested fix: increase the rand_bytes argument in the challenge-generation function to rand_bytes((unsigned char*)chall->challenge, 0x20);.
Indirect Vulnerability - Process B-2
In the view_note function, strncpy(tmp, buffer[idx], sizes[idx]); uses strncpy to copy the XORed on-heap data. Since the on-heap data may be truncated by a NUL byte, the copy can be incomplete, leaving part of tmp’s data missing. The subsequent decrypt then leaks the session.
Suggested fix: replace strncpy with memcpy.
someheap
This challenge was conceived one week before the XCTF competition. It likewise needed to fit the XCTF Final patch disclosure and traffic disclosure mechanisms, and I intended to design a highly competitive challenge. I also planned to make it resemble a DEFCON Final–style challenge, to give players a taste of competitive intensity.
(Honestly, I also didn’t want to write another traditional whitelist that restricts the allowed patch regions — that model feels a bit like a fill-in-the-blank exercise — so this challenge was designed so that players cannot patch the ELF.)
At its core, this challenge is a heap menu challenge, and it requires making good use of the roles of both defender and attacker.
This menu challenge includes multiple heap primitives:
- Add operation (split into malloc and calloc)
- Free operation (has a UAF)
- Edit operation (write length is attacker-controlled, allowing overflow)
- Show operation (output length is attacker-controlled, allowing out-of-bounds leak)
The Defender
The main design difficulty of this challenge lies on the defender’s side. To better integrate with the challenge, I designed a firewall file: the defender must write amd64 machine code. When the program starts, it loads this firewall file into memory, and then calls it before and after heap operations to perform a check (the check’s contract is: returning 0 means OK, returning non-zero makes the program exit).
Since the defender has the ability to execute arbitrary code in the current process, I needed to make writing amd64 code harder for the defender.
-
First, the challenge enables a sandbox, and the firewall is not allowed to contain instructions that trigger system calls:
0x0f 0x05syscall /0x0f 0x34sysenter /0xcd 0x80int 0x80.# check if arch is X86_64A = archA == ARCH_X86_64 ? next : deadA = sys_numberA >= 0x40000000 ? dead : nextA == open ? ok : nextA == read ? ok : nextA == write ? ok : nextA == close ? ok : nextA == brk ? ok : nextA == exit ? ok : nextA == exit_group ? ok : nextA == futex ? ok : nextA == getrandom ? ok : nextif(A == arch_prctl) goto prctl_testgoto deadprctl_test:A = args[0]A == 0x1001 ? ok : nextA == 0x1002 ? ok : nextA == 0x1003 ? ok : nextA == 0x1004 ? ok : nextgoto deadok:return ALLOWdead:return KILL -
To prevent the defender from computing addresses to escape onto the bss or into libc, I needed to stop the defender from using code to derive bss/libc addresses. So I used strong random addresses:
/dev/urandomgenerates strongly random address regions that serve as the firewall code segment, the stack space for firewall execution, and so on.dynamic_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); -
Next, when executing the firewall, all registers must be cleared, including the xmm registers and the fs/gs registers. But since the return address must be recorded, I stored it at the very end of the firewall code segment. That, in turn, requires preventing the firewall from reading this address to escape, so I set the firewall code segment’s permissions to
--x.mprotect(dynamic_obj->func, 0x1000, PROT_EXEC); -
To keep the firewall program stateless, the firewall execution stack is cleared on every invocation.
memset(dynamic_obj->stack, 0, 0x1000); -
Finally, to prevent the defender from trivially recording heap state indexed by idx, the attacker is allowed to set the srand seed, and the idx parameter the firewall receives is the pre-decryption parameter; the real idx is
encrypt(idx) % 0x100. (Here, to keep rand() data from lingering on the stack, I deliberately enabled O2.)__attribute__((optimize("O2"))) size_t encrypt(size_t val) {register size_t rand_val = (size_t)rand();return val + rand_val;} -
Lastly, the defender faces one more extremely difficult point: they cannot tell which function the firewall is currently being called from — add, free, show, or edit — because the parameters passed are always
(idx, size, heap_addr).
Those are roughly the defender’s constraints. As you can see, the defender can only make judgments based on some aspects of heap state.
What’s nice is that the checks all happen in the window after a heap address is allocated and before it’s freed. In other words, a heap address enters the check during the part of its normal lifecycle when it is in use. So the simplest check is to use the prev_inuse bit to determine whether the current heap address has a UAF problem. (Of course, this only solves UAF issues related to unsorted bins, small bins, and large bins — it cannot solve UAF in fastbins or tcache bins.)
Second, to address the overflow problem, you can also obtain the chunk size and check it. These two checks should be fairly easy to come up with.
How to solve the tcache bins UAF problem?
I couldn’t think of a fully complete solution either, but a partial restriction is possible: parse the tcache_entry structure to check for tcache UAF issues.
#include <stddef.h>
# define TCACHE_MAX_BINS 64
typedef struct tcache_entry{ struct tcache_entry *next; /* This field exists to detect double frees. */ size_t key;} tcache_entry;
typedef struct tcache_perthread_struct{ unsigned short counts[TCACHE_MAX_BINS]; tcache_entry *entries[TCACHE_MAX_BINS];} tcache_perthread_struct;
#define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
size_t check(size_t a, size_t b, size_t addr) { size_t base = addr & (~0xfff);
while (1) { size_t* p = (size_t*)(base + 0x8); if(*p != 0x291) { base -= 0x1000; continue; } break; }
tcache_perthread_struct* tcache = (tcache_perthread_struct*)(base + 0x10); size_t size = *(size_t*)(addr - 0x8);
if(size > 0x410) { return 0; }
size_t tc_idx = (size - 0x20) >> 4; size_t tc_cnt = tcache->counts[tc_idx]; int cnt = 0; tcache_entry *tmp; for (tmp = tcache->entries[tc_idx]; tmp; tmp = REVEAL_PTR (tmp->next), ++cnt) { if (cnt >= tc_cnt) return 0; if ((size_t)tmp == addr) return 1; } return 0;}Of course, this check can also be bypassed — just place a fake tcache_entry at a page-aligned location.
Backdoor?
At the start of the competition we also gave a hint that a backdoor was within the intended design for this challenge. I didn’t find any backdoor samples while reviewing the backend logs — if I missed any, feel free to comment or submit an issue!
Here is an intended backdoor design approach.
Since the firewall restrictions in this challenge are extremely tight and it runs inside a constructed sandbox, we can use some characteristic sizes, specific strings in heap contents, or particular heap sizes as checks for whether to enter the backdoor branch. Second, since we need to escape the sandbox, we must ensure the heap data contains a libc address, such as the main_arena address. The firewall can then escape from main_arena to _environ, then to the stack, which contains the program’s base address, from which we compute the bss address, and finally obtain all the information in dynamic_obj — including the address of the win function. Jumping directly to win gets the flag.
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 };size_t win_addr = (size_t)get_random_addr();dynamic_obj->win = mmap((void*)(win_addr & (~0xfff)), 0x2000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);size_t win_addr_page = (size_t)dynamic_obj->win;dynamic_obj->win = (void*)(((size_t)dynamic_obj->win) + (win_addr & 0xfff));memcpy(dynamic_obj->win, win, sizeof(win));mprotect((void*)win_addr_page, 0x2000, PROT_EXEC);The Attacker
There’s nothing special about the interaction design for the attacker side — it’s just regular interaction.
Due to the rules, every team must submit at least one flag before they can patch this challenge, so everyone has to attack the challenge in its default state, where the firewall is empty. That means a simple tcache UAF exploit against IO FILE — fairly basic.
Since the defender can still use various tricks to specifically restrict the attacker’s exploitation, dramatically increasing the difficulty, this challenge was designed with a Level mechanism.
The challenge is divided into 6 levels, which change according to the length of the defender’s firewall:
-
When firewall length <= 5 (initial state)
- No difficulty reduction for the attacker
-
When firewall length <= 30 (basic state)
- The attacker directly obtains the heap address, the libc address, and a random readable/writable address Addr
-
When firewall length <= 50 (at this point size-length checks are basically possible, blocking overflow)
- The attacker directly obtains the heap address, the libc address, and a random readable/writable address Addr
- And the attacker only needs to write the win function address at this random readable/writable address Addr to get the flag
-
When firewall length <= 80 (at this point simple UAF checks are possible)
- The attacker directly obtains the heap address, the libc address, and a random readable/writable address Addr
- And the attacker only needs to write any non-zero value at this random readable/writable address Addr to get the flag
-
When firewall length <= 120 (at this point fairly strong UAF checks are possible, along with some size checks)
- The attacker directly obtains the heap address and the libc address
- And the attacker only needs to write the win function address at a location on the heap to get the flag
-
When firewall length <= 1000 (all remaining cases)
- The attacker directly obtains the heap address and the libc address
- And the attacker only needs to write a non-zero value at a location on the heap to get the flag
void info() { printf("win: %p\n", dynamic_obj->win); if(dynamic_obj->level.info_leak) { char *p = malloc(0x500); printf("printf: %p\n", printf); printf("heap: %p\n", p); free(p); } if(dynamic_obj->level.in_heap == 0) { printf("target: %p\n", dynamic_obj->near_heap_addr); }}
void win_flag() { if(dynamic_obj->level.near_heap == 1) { if((*((size_t*)dynamic_obj->near_heap_addr)) == (size_t)dynamic_obj->win) dynamic_obj->win(); } else if(dynamic_obj->level.near_heap == 2) { if(*((size_t*)(dynamic_obj->near_heap_addr))) dynamic_obj->win(); } else { _IO_flush_all(); ___exit(0); }}So the attacker can simply write a targeted exploit based on the challenge’s current level.
Afterword
The code for these challenges will be open-sourced at https://github.com/CsomePro/9th-XCTF-Final-ADPWN. Intended and unintended solutions alike are welcome — feel free to submit issues for discussion.