[n1ctf2021] 部分 pwn 题 writeup
很久没参加国际赛了,这次只看了 pwn 题,但是很遗憾爆 0 了。一部分原因是因为看的题太多了吧,有两个题都是考虑到了可能有洞的地方,但是不知道为啥审的时候没审出来;不过主要还是因为自己太菜了还需要多学习。
babyFMT
签到题中的签到题了属于是,审洞的时候发现 strlen 可能导致 BUG,但是当时是认为 scanf %s 会 0 截断,不知道咋审的。 |
程序提供了自己实现的 printf 和 scanf。值得注意的地方是在程序的 show 功能上,有一个 printf(input, args...)
的操作,看起来就是 FMT 漏洞利用的地方,但其实漏洞利用的原理和 glibc 的 FMT 漏洞不一样。
//scanf
while ( read(0, &tmp_char, 1uLL) == 1 )
{
tmp_char_ = tmp_char;
if ( (unsigned __int8)(tmp_char - 9) <= 1u || tmp_char == ' ' ) // <===[only '\n' and ' ']
goto next_fmt;
*str_buf_now++ = tmp_char;
if ( str_buf_now == (unsigned __int8 *)buf_end )
goto LABEL_22;
}
//printf
fmt_len = strlen(fmt_str); // <===[bad len]
fmt_char = *fmt_str;
fmt_buf_len = fmt_len;
if ( *fmt_str )
{
fmt_char_ptr = fmt_str;
char_ = *fmt_str;
do
{
if ( char_ == '%' )
fmt_buf_len += 16;
char_ = *++fmt_char_ptr;
}
while ( char_ );
fmt_buf = (char *)malloc(fmt_buf_len);
}
...
default:
v19 = ttbuf;
v20 = 0;
if ( fmt_src[1] ) // <===[skip '%\x00']
{
...
}
fmt_char = fmt_src[2];
fmt_src += 2;
在 scanf 读入 %s 的时候终结符是 ‘\n’ 和 ‘ ‘ 并不是 glibc 情况下的 ‘\0’,但是 printf 中申请 fmt_buf 却是基于 strlen 来实现的。而之后对 fmt_str 的操作,可以利用 ‘%\x00’ 来跳过,实现 OOBWrite。
这道题的堆空间没有初始化,且可以申请 0x500 的堆,所以直接通过 unsorted bin 来 leak 就行,OOBWrite 写 tcache 就完了。
house of pzhxbz
确实由于 musl 太久没看了,这道题也没找出 leak 的方法,就没有花太多时间,甚至没有接触到出题人想到的 trick 的环节。 |
case '1': // read
read(0, &tmp, 4uLL);
size = tmp;
read(0, &tmp, 1uLL);
idx = (unsigned int)(char)tmp;
if ( (unsigned int)idx > 1 )
goto exit_0;
chunks[idx].size = size; // <===[bad assign]
if ( size <= 0x1000 )
{
...
}
洞很简单,add 功能中 size 在检查之前就赋值了,导致可以修改已分配 chunk 的 size 字段,而在 edit 和 show 功能中可以利用 size 来进行 OOBR 和 OOBW,只不过这个题在这个基础上加了很多限制:
- offset 必须要大于 0x1000 才能 OOB
- 不可以读写大于 libc 基地址的地址
- meta 的 prev 和 next 指针必须相等
0x000056484342c000 0x000056484342d000 0x0000000000000000 r-- /mnt/c/Users/87762/Desktop/Doing/tmp1/house_of/ctf
...
0x0000564843430000 0x0000564843431000 0x0000000000003000 rw- /mnt/c/Users/87762/Desktop/Doing/tmp1/house_of/ctf
0x000056484460c000 0x000056484460d000 0x0000000000000000 --- [guard]
0x000056484460d000 0x000056484460e000 0x0000000000000000 rw- [meta_arena]
0x00007f358a8ce000 0x00007f358a8d2000 0x0000000000000000 rw- [heap]
0x00007f358a8d2000 0x00007f358a8e7000 0x0000000000000000 r-- /usr/local/musl/lib/libc.so
...
musl libc 的内存布局受各种启动方式和 libc 位置的影响较大,一般建议使用和远程相同的启动方式。本题内存布局中,可以 OOB 到的就只有 meta_arena 页和动态堆。
meta_arena 里因为静态堆的存在,有 elf(pwn) 内的指针,也有 libc 内的指针,如果能 OOBR 到这里就能 leak 所有的地址。但是 meta_arena 和 elf 之间存在随机偏移,目测大概有 24b 的随机 bit。(但也不是完全没有可能,r3kapig 也是爆的)静态堆由于 group 中包含指向 meta_arena 的地址,可以泄露,但是由于 elf 基地址未知,所以扔无法通过 OOB 来访问,而光有这个地址并不足以完成利用,所以这里需要借助赛题的另一个特点。
// edit
readed = read(0, chunk, size);
if ( readed > 0 )
item_1->off += readed;
else
write(1, "failed", 6uLL);
edit 在 oobw 的时候如果 read 出错,并不会导致程序 crash(但是 write 却会,做题的时候误导了我),所以可以通过 edit 来探测随机的间隙有多大,确定间隙后就可以对 meta_arena 进行 OOBR 然后 leak 出所有的地址,方便后续继续利用。
由于 OOBW 只能写 elf 往上,libc 以下的地址,所以完成利用还需要其他地方的 write 指令,可以发现只有 add 操作的时候 write 的地址才没有检查,所以我们需要获得任意地址分配 chunk 的能力,进而实现任意地址写。
void *malloc(size_t n)
{
sc = size_to_class(n);
rdlock();
g = ctx.active[sc];
for (;;) {
mask = g ? g->avail_mask : 0;
first = mask&-mask;
if (!first) break;
if (RDLOCK_IS_EXCLUSIVE || !MT)
g->avail_mask = mask-first;
else if (a_cas(&g->avail_mask, mask, mask-first)!=mask)
continue;
idx = a_ctz_32(first);
goto success;
}
...
success:
ctr = ctx.mmap_counter;
unlock();
return enframe(g, idx, n, ctr);
}
通过更改 meta_arena 导致任意地址分配最直接的想法就是修改一个不空的 meta 的 mem 字段,mem 指向的 group 也等价于 chunk 所在的地址,malloc 在发现 meta 有可用的 chunk 之后,就会在 mem 指向的地址索引空闲的块来返回。musl 的 malloc() 函数基本不包含检查,改了这个 meta->mem 确实能够实现任意地址分配,但是本题是用的 calloc() 进行分配。
void *calloc(size_t m, size_t n)
{
...
p = malloc(n);
if (!p || (!__malloc_replaced && __malloc_allzerop(p)))
return p;
}
#define is_allzero __malloc_allzerop
int is_allzero(void *p)
{
struct meta *g = get_meta(p);
return g->sizeclass >= 48 ||
get_stride(g) < UNIT*size_classes[g->sizeclass];
}
static inline struct meta *get_meta(const unsigned char *p)
{
assert(meta->mem == base); //<===[]
assert(index <= meta->last_idx);
assert(!(meta->avail_mask & (1u<<index)));
assert(!(meta->freed_mask & (1u<<index)));
}
calloc() 在 malloc() 执行完成后,因为 __malloc_replaced 为 NULL,所以还会调用 __malloc_allzerop(),而这个函数最终会调用到 get_meta(),里面包含了对 group 的检查,所以之前的想法还需要一些改进。这里有两种办法:
法1(预期解)overwrite __malloc_replaced
前面提到了 calloc 走检查的前提条件是 __malloc_replaced 为 NULL,所以如果我们能在 malloc 中完成对 __malloc_replaced 的覆盖,就不会走到检查的路径。
static inline void *enframe(struct meta *g, int idx, size_t n, int ctr)
{
...
p[-3] = idx; // <===[write something near the chunk]
set_size(p, end, n);
return p;
}
每次 malloc() 在返回 chunk 之前都会调用 enframe 对 chunk 的一些元数据进行初始化,其中对 idx 的初始化对我们而言就是伪造 chunk 附近的一次 write 操作。我们只需要伪造 chunk 在 __malloc_replaced 周围,稍微设置一下 idx,然后借助 enframe 就可以实现对 __malloc_replaced 的覆盖。
之后由于 calloc 没有检查,我们再修改 meta->mem 指向任意地址都可以实现任意地址分配。
法2(非预期解)利用 alloc_slot 来初始化 group
因为法 1 的方法伪造的 chunk 通过不了检查,主要是因为周围没有合法的 group 结构体。法 2 不再是任意地址分配 chunk,而是任意地址分配 group。
void *malloc(size_t n)
{
for (;;) {
mask = g ? g->avail_mask : 0;
first = mask&-mask;
if (!first) break;
...
}
upgradelock();
idx = alloc_slot(sc, n); //<===[alloc group]
if (idx < 0) {
unlock();
return 0;
}
g = ctx.active[sc];
success:
ctr = ctx.mmap_counter;
unlock();
return enframe(g, idx, n, ctr);
}
如果 malloc 找不到已有的满足大小的 group 中有空的 chunk,就会调用 alloc_slot() 来分配一个新的 group,而 musl 会优先使用更大的 freed chunk 来分配这个 group。所以间接这个更大的 freed chunk 的 meta->mem,就可以实现任意地址分配 group,也达到了任意地址分配 chunk 的目的,而且还能绕过之后的 check。
最后稍微注意本题的 fini_func 会导致 badsyscall,而 finit_func 会优先 __stdio_exit() 被调用,所以直接 FSOP 的话还没执行程序就崩溃了。这里有两个方法,一个是在 __libc_exit_fini 中同样存在可以利用的地方,也可以实现函数调用;二是吧 fini_head 覆盖为 NULL,就又会执行 __stdio_exit() 了。
_Noreturn void exit(int code)
{
__funcs_on_exit();
__libc_exit_fini();
__stdio_exit();
_Exit(code);
}
void __funcs_on_exit()
{
void (*func)(void *), *arg;
LOCK(lock);
for (; head; head=head->next, slot=COUNT) while(slot-->0) {
func = head->f[slot];
arg = head->a[slot];
UNLOCK(lock);
func(arg);
LOCK(lock);
}
}
baby guess
这道题也是挺简单的一个 kernel 题,当时都审到半边函数的 len 是错误的,对称的另一半导致溢出的 len 却不知道为啥没有看到,导致没有做出来太可惜了。 |
proto_register(socket_proto, 1LL);
sock_register(&qword_1130);
程序不是传统的 proc 或者 dev 的方式来暴露接口,而是注册了一个 sock 还是啥,看一下初始化的参数就可以知道提供了 ioctl 和 setsockopt 两个方式来进行交互。其中的原理我不是很懂,但是通过 socket(AF_KEY, SOCK_RAW, 0);
就可以返回一个可以调用到驱动函数的描述符。
程序主要相关的结构体是一个大小 0x100 的 buf,前面有四个字节来记录当前 kbuf 的长度。还有个长为 0x100 的字符数组 magic,使用随机数进行初始化。
程序的功能:
- setsockopt:
- 0xdeadbeef: 读取 kbuf.len 长度的数据到 kbuf.data 中,并用 magic 来异或 kbuf.data,返回 kbuf.len
- ioctl
- 0x13371001: 可以设置 kbuf.len
- 0x13371001:
- 0x1337: 将参数中给出的 cmp_buf 和 kbuf.data 进行比对,返回比对结果
- 0x1337: 将参数中给出的 cmp_buf 和 magic 进行比对,返回比对结果
if ( arg[0] == 0x1337LL )
{
safe_len_1 = arg[1];
if ( arg[1] > 256LL )
safe_len_1 = 0x100LL;
src = arg[2];
if ( safe_len_1 > 0x7FFFFFFF )
BUG();
check_but_null((__int64)cmp_buf, safe_len_1, 0);
if ( copy_from_user(cmp_buf, src, safe_len_1) )
return 0xFFFFFFEALL;
if ( !memcmp(kbuf.data, cmp_buf, arg[1]) ) // <===[1]{arg[1] unsafe}
return arg[1];
}
else if ( arg[0] == 0x1338LL )
{
safe_len = arg[1];
if ( arg[1] > 256LL )
safe_len = 256LL;
src_1 = arg[2];
unsafe_len = arg[1];
if ( arg[1] > 0x7FFFFFFFuLL )
BUG();
check_but_null((__int64)cmp_buf, arg[1], 0);
if ( copy_from_user(cmp_buf, src_1, unsafe_len) )// <===[2]{arg[1] unsafe}
return 0xFFFFFFEALL;
if ( !memcmp(magic_key, cmp_buf, safe_len) )
return safe_len;
}
//0x13371001
v3 = kbuf.len;
kbuf.len = arg_len;
if ( arg_len <= 0x100 )
return 0LL;
printk(&no_overflow); // <====[3]{race}
kbuf.len = v3;
return 0LL;
有三个地方存在漏洞:
- 0x1337 中 memcmp 的 len 参数没有限制到, cmp_buf 位于栈上,kbuf.data 是用户可控数据,如果能够控制 kbuf.data 的内容,并且超过 0x100,那么就有机会利用 memcmp 来 leak 栈上的数据。
- 0x1338 也存在类似的漏洞,copy_from_user() 的第三个参数也是没有限制到,这里有个超强栈溢出,如果完成 leak,则可以通过这里提权
- 0x13371101 在设置 kbuf.len 的时候,和 house of 那道题一样,没有检查就设置了
kbuf.len = arg_len
,虽然在后面改回来了,但是也留下了一段真空期。
漏洞发现了利用思路自然就出来了:
- 想要 leak 栈上数据首先得控制 kbuf_.data,由于 data 会被 magic 异或,所以首先需要泄露 magic,0x1338 中可以通过 memcmp 的返回值,逐字节泄露 magic 的数值。
- leak 栈上的数据还要求 kbuf.len 得大于 cmp_buf 的长度 0x100,这里新建一个线程执行死循环,一直设置 kbuf.len 为 0x200,而在 0xdeadbeef 中,也可以通过返回值来判断读入数据的时候 kbuf.len 是否篡改成功。
- leak 完就是一个 KROP,没啥好说的。
/ $ ./exp
[!] Compile @ Nov 24 2021, 17:02:06
[+] port: 0 -> 3
[+] Leaking magic 255
[+] Leaking magic done: 0xffdb3b5fc5d3897a
[+] oooooooob started...
[+] Leaking stack 117 (ff)
[+] Leaking stack done
[+] kernel_base: ffffffff84000000
[+] canary: e4def0fe3b619b00
[+] enjoiy root no000ooo0o0oo0o00o0o0ow :)
_ _ ___ _ __
| | _/ |_ __ / _ \ __ _| |/ / _ ___ ___
| |/ / | '__| | | |/ _` | ' / | | |/ _ \/ _ \
| <| | | | |_| | (_| | . \ |_| | __/ __/
|_|\_\_|_| \___/ \__,_|_|\_\__,_|\___|\___|
/ #
easy X11
这道题由于是儒儒出的,还是看了好久,奈何没看到 hint,加上文档上有些东西记录的也不是很全,最后还是放弃了。复现还是花了我好一会儿,踩了很多坑。 |
拿到题首先得一脸懵逼的去看 x11 window system 的相关文档,发现这个东西就是一个 x11 的客户端,得把它发出来的数据接到一个 x11 server 上。linux GUI,wslg 或者 VcXsrv 都可以。
最开始看到有一个事件循环就闷头看这一段:
while ( 1 )
{
while ( 1 )
{
do
XNextEvent(display, &v6);
while ( XFilterEvent(&v6, 0LL) );
if ( v6.type == 2 )
break;
if ( v6.type == 12 )
update_window(display, v11, v10, v12, str);
}
n = Xutf8LookupString(v9, (XKeyPressedEvent *)&v6, str_buf, size - 1, &v8, &v7);
if ( v7 == -1 )
{
printf("reallocate: %lu\n", n + 1);
str_buf = (char *)realloc(str_buf, n + 1);
size = n + 1;
n = Xutf8LookupString(v9, (XKeyPressedEvent *)&v6, str_buf, n, &v8, &v7);
}
if ( n )
{
str_buf[n] = 0;
memset(str, 0, 0xAuLL);
unsaef_read(str, (unsigned __int8 *)str_buf, n); //<===[stack overflow]
update_window(display, v11, v10, v12, str);
result = strncmp(str, "1919810", 7uLL);
if ( !result )
break;
}
}
核心内容就是一直等待 XServer 发来得事件,如果是 KeyPressEvent 的话就调用 Xutf8LookupString() 来查找生成一个字符串,然后把这个字符串拷贝到 str_buf 中,再把 str_buf 中的字符串输出到 window 上面。注意 str_buf 是栈上的变量只有 0xA 大小,而且看题查找的字符串很可能超过 0x10,但是本地调试发现 Xutf8LookupString() 永远只返回一个字符,那不可能溢出啊。
KeyPress
1 2 code
1 KEYCODE detail
2 CARD16 sequence number
4 TIMESTAMP time
4 WINDOW root
4 WINDOW event
4 WINDOW child
KeyPress 事件本身只有 detail 一个字节携带了字符信息,也不可能通过篡改数据包的方式来往里面塞。肯定是由 Xutf8LookupString() 来决定怎么返回字符串,但是本地调试 Xutf8LookupString() 调用时没有给我发送任何数据包,所以最开始觉得是离线处理的,就下载 libx11 的源码,看了半天还是没有头绪。
XSetLocaleModifiers("@im=114514");
v17 = XOpenIM(display, 0LL, 0LL, 0LL);
if ( !v17 )
{
fwrite("XOpenIM @im=114514 faild.\n", 1uLL, 0x1AuLL, _bss_start);
XSetLocaleModifiers(&byte_4020BE);
v17 = XOpenIM(display, 0LL, 0LL, 0LL);
if ( !v17 )
{
fwrite("XOpenIM faild.\n", 1uLL, 0xFuLL, _bss_start);
exit(0);
}
}
注意到初始化的时候有一段奇怪的代码,这个 XOpenIM() 本地其实是执行失败的,所以估计是这个 @im=114514 的东西没整上。在去查文档发现这个函数是和输入法 InputMethod 相关的。
所以 IM Server 成了关键,当时做懵了很疑惑这个 Server 是在题目服务器还是在我这里,因为流量里没有发现 “@im=114514” 这个明文,而且连题目的时候输出里面没有 “XOpenIM @im=114514 faild”(不过后来才意识到题目程序的标准输出并没有发送出来)。然后就在想如果 x11 客户端要请求服务器连接 IMServer 的话肯定是在 XOpenIM 的时候发请求,但是那几个数据包的 opcode 我在文档里没有找到,然后就没做了。
后来看到了 hint 才发现原来就是输入法(做题看到 InputMethod 没想到输入法我英语太差了),也通过关键词知道了怎么在 wsl 上整一个输入法(话说当时如果 github 上搜一搜可能也能搜到,大失败),我这里是用的 fcitx,没有用官方 writeup 中的 fctix5,因为最开始没设置对 fctix5 导致用不了,不过没有本质区别最后也是殊途同归。
只需要安装 fcitx,通过命令 XMODIFIERS=@im=114514 fcitx
启动就行了,之后程序 XOpenIM 就不会失败了,这里再给 fcitx 装上一个 addon 例如 fcitx-sunpinyin 就可以往题目里面塞很多东西。
接下来的问题是 fcitx-sunpinyin 这种输入方式,只能输入可见字符,而这样对于栈溢出来说应该是不足以完成利用的。最开始还准备从 fcitx-sunpinyin 这种 addon 来入手魔改,看了一下源码后发现其只能控制发送的字符串的指针,并不能控制长度,所以还是存在 \0 截断的问题,所以还是需要从 fcitx 本身入手来进行魔改。回顾图中 IM_server 和 IM library 通信的过程,字符串是通过 XIM_COMMIT 这个包来传递的。
XIM_COMMIT (IM Server -> IM library)
2 CARD16 input-method-ID
2 CARD16 input-context-ID
2 BITMASK16 flag
#0001 synchronous
#0002 XLookupChars
#0004 XLookupKeySym
#0006 XLookupBoth = XLookupChars | XLookupKeySym
2 m byte length of committed string
m LISTofBYTE committed string
p unused, p = Pad(m)
其中既可以控制字符串的数据,也可以控制字符串的长度,在发送这个包的地方魔改,就能解决了 \0 截断的问题。看一看源码可以发现最后处理 XIM_COMMIT 是在 xi18n_commit() 函数中,然后很简单地对发送的数据完成一个替换。
diff --git a/src/frontend/xim/lib/i18nMethod.c b/src/frontend/xim/lib/i18nMethod.c
index d50f035e..a0e28370 100644
--- a/src/frontend/xim/lib/i18nMethod.c
+++ b/src/frontend/xim/lib/i18nMethod.c
@@ -812,6 +812,19 @@ static Status xi18n_commit(XIMS ims, XPointer xp)
CARD16 str_length;
call_data->flag |= XimSYNCHRONUS; /* always sync */
+ char *buf = malloc(0x500);
+ int fd = open("/tmp/exp", O_RDONLY);
+ if (fd < 0){
+ printf("[-] open failed.\n");
+ exit(-1);
+ }
+ int buf_len = read(fd, buf, 0x500);
+ if (buf_len < 0){
+ printf("[-] read failed.\n");
+ exit(-1);
+ }
+ printf("[+] read done %d\n", buf_len);
+
if (!(call_data->flag & XimLookupKeySym)
&&
@@ -821,7 +834,9 @@ static Status xi18n_commit(XIMS ims, XPointer xp)
_Xi18nNeedSwap(i18n_core, call_data->connect_id));
/* set length of STRING8 */
- str_length = strlen(call_data->commit_string);
+ call_data->commit_string = buf;
+ str_length = buf_len;
+ // str_length = strlen(call_data->commit_string);
FrameMgrSetSize(fm, str_length);
total_size = FrameMgrGetTotalSize(fm);
reply = (unsigned char *) malloc(total_size);
现在就可以通过 /tmp/exp 文件的内容来控制题目程序接收到的数据内容了。一般的题目就是先 leak 再 read,或者重新回 main 再溢出一次,但是这道题既没有 read 的 plt,也不能重新回到 main() (会崩溃),所以必须在函数返回之前进行 leak。
//updateWindow()
v5 = strlen(stack_str);
return XDrawString(a1, a2, a3, v13, v12, stack_str, v5);
updateWindow() 中对 XDrawString() 调用的参数 len 重新使用 strlen() 进行计算,而这个时候已经完成了溢出,所以可以通过这里来 leak 栈上的 libc 指针。注意这里 leak 出来的数据因为编码问题在 GUI 上是看不到的,需要从客户端发送的设置窗口相关的数据包里去过滤。
leak 之后还有一个地方需要注意,远程题目只会把同 XServer 交互的流量发送过来,而标准输入输出没有发送过来,所以即使执行了 system(“/bin/sh”) 也拿不到数据,需要提前把题目对 XServer socket 操作的描述符 dup 一份到 stdin 和 stdout,这样标准输入输出也能发送出来了。