4140 字
21 分钟
阅读量 --
Hits
[9th XCTF Final AwD Pwn] 出题心得

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 设计其他的漏洞。

漏洞清单#

题目中包含两个直接漏洞

  1. 默认 admin token 以及 Patch 延迟公开导致 admin token 泄漏
  2. strncpy 拼接并由 printf % s 泄漏 admin token

以及四个非直接的漏洞,因为涉及 session 中间量,所以分成两个过程,分别是 过程 A 和过程 B

过程 A 漏洞

  1. 弱随机数种子 srand (time (0))
  2. 利用生日攻击 MD5,得到 1 最少的 user token,通过统计泄漏的 session 恢复 admin token

过程 B 漏洞

  1. login 逻辑中,scanf 未初始化导致泄漏 session
  2. 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。

111.drawio

此漏洞为逻辑漏洞,无法修补,建议修补其他泄漏 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 了)

这道题目核心是一道堆菜单题,并且需要利用好防御方和攻击方的作用。

这道菜单题,包含多个堆原语的操作。

  1. Add 操作 (分成 malloc 和 calloc)
  2. Free 操作 (存在 UAF)
  3. Edit 操作 (写入长度由攻击方控制,可以 overflow)
  4. Show 操作 (输出长度由攻击方控制,越界泄漏)

防御方#

本题设计的难点主要在于防御方。为了能更好的融入题目,这里设计了一个 firewall 文件,防御方需要编写 amd64 的机器码,当程序启动的时候,会加载这个 firewall 文件到内存中,随后在堆操作前后会调用这个文件,进行 check(check 返回约定是,返回 0 表示正常,返回非 0 程序将 exit 退出)

由于防御方拥有当前进程执行任意代码的能力,所以我需要给防御方增加编写 amd64 代码的难度。

  1. 首先这道题目开启了沙箱,并且 firewall 不允许包含0x0f 0x05 syscall/0x0f 0x34 sysenter/0xcd 0x80 int 0x80 这些会产生系统调用的指令

    # 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
  2. 为了防止防御者可以通过地址计算,逃逸到 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);
  3. 其次执行 firewall 的时候需要清空所有寄存器包括 xmm 寄存器和 fs、gs 寄存器,但是由于需要记录返回地址,所以我将返回地址记录在了 firewall 代码段的最后方,此时又需要防止 firewall 读取到这个地址导致逃逸,所以我设置了 firewall 代码段--x的权限

    mprotect(dynamic_obj->func, 0x1000, PROT_EXEC);
  4. 为了让 firewall 程序编程无状态的,所以每次执行的时候都会将 firewall 执行栈清空

    memset(dynamic_obj->stack, 0, 0x1000);
  5. 最后为了防止防御方很简单的通过 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;
    }
  6. 最后防御方还有一个最困难的一点,防御方无法判断当前被调用的地方是在 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 长度而变化

  1. 当 firewall 长度 <=5 (初始情况)

    • 攻击方没有降低难度
  2. 当 firewall 长度 <=30 (基本情况)

    • 攻击方能直接获得 heap 地址、libc 地址、一个随机可读可写地址 Addr
  3. 当 firewall 长度 <=50 (基本上能检查 size 长度,阻止溢出了)

    • 攻击方能直接获得 heap 地址、libc 地址、一个随机可读可写地址 Addr
    • 并且攻击方只需要在这个随机可读可写地址 Addr 写入 win 函数地址即可拿到 flag
  4. 当 firewall 长度 <=80 (此时能做一点简易的 uaf 检查)

    • 攻击方能直接获得 heap 地址、libc 地址、一个随机可读可写地址 Addr
    • 并且攻击方只需要在这个随机可读可写地址 Addr 任何非 0 的数值即可拿到 flag
  5. 当 firewall 长度 <=120 (此时基本上能做较强的 uaf 检查,以及可以做一些 size 检查)

    • 攻击方能直接获得 heap 地址、libc 地址
    • 并且攻击方只需要 堆上的一个位置写入 win 函数地址 即可拿到 flag
  6. 当 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 讨论。

[9th XCTF Final AwD Pwn] 出题心得
https://blog.csome.cc/p/xctf-adpwnwp/
作者
Csome
发布于
2025-11-03
许可协议
CC BY-SA 3.0