..

[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,只不过这个题在这个基础上加了很多限制:

  1. offset 必须要大于 0x1000 才能 OOB
  2. 不可以读写大于 libc 基地址的地址
  3. 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;

有三个地方存在漏洞:

  1. 0x1337 中 memcmp 的 len 参数没有限制到, cmp_buf 位于栈上,kbuf.data 是用户可控数据,如果能够控制 kbuf.data 的内容,并且超过 0x100,那么就有机会利用 memcmp 来 leak 栈上的数据。
  2. 0x1338 也存在类似的漏洞,copy_from_user() 的第三个参数也是没有限制到,这里有个超强栈溢出,如果完成 leak,则可以通过这里提权
  3. 0x13371101 在设置 kbuf.len 的时候,和 house of 那道题一样,没有检查就设置了 kbuf.len = arg_len,虽然在后面改回来了,但是也留下了一段真空期。

漏洞发现了利用思路自然就出来了:

  1. 想要 leak 栈上数据首先得控制 kbuf_.data,由于 data 会被 magic 异或,所以首先需要泄露 magic,0x1338 中可以通过 memcmp 的返回值,逐字节泄露 magic 的数值。
  2. leak 栈上的数据还要求 kbuf.len 得大于 cmp_buf 的长度 0x100,这里新建一个线程执行死循环,一直设置 kbuf.len 为 0x200,而在 0xdeadbeef 中,也可以通过返回值来判断读入数据的时候 kbuf.len 是否篡改成功。
  3. 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

所以 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,这样标准输入输出也能发送出来了。

x11