This page looks best with JavaScript enabled

Tìm hiểu về CVE-2021-20226

 ·  ☕ 29 min read  ·  🐉 Edisc
Bài viết này nhằm mục đích ghi lại những kiến thức đã học để nhớ và hiểu rõ hơn. Bài viết tham khảo chủ yếu từ CVE-2021–20226 a reference counting bug which leads to local privilege escalation in io_uring.

Đôi nét về io_uring

io_uring Asynchronous I/O (AIO) framework là một giao diện I/O mới cho Linux, được giới thiệu trên linux kernel version 5.1 (tháng 3 - 2019).

Communication channel

io_uring
From: https://flattsecurity.medium.com/cve-2021-20226-a-reference-counting-bug-which-leads-to-local-privilege-escalation-in-io-uring-e946bd69177a

  • io_uring instance có 2 vòng, submission queue (SQ)completion queue (CQ) được chia sẻ giữa kernel và ứng dụng. Những hàng đợi: single producer, single consumer cung cấp 1 giao diện ít khóa, và được phối hợp với rào cản bộ nhớ (memory barrier).
  • Application sẽ tạo một hoặc nhiều SQ entry (SQE) và sẽ cập nhật vào đuôi của hàng đợi SQ. Kernel cũng sử dụng SQE, nó sẽ cập nhật vào đầu của hàng đợi SQ.
  • Kernel sẽ tạo một hoặc nhiều CQ entry (CQE) và sẽ cập nhật vào đuôi của hàng đợi CQ. Application cũng sử dụng thằng CQE này và cập nhật từ đầu của hàng đợi CQ.

Systemcall API

io_uring API gồm 3 system call chính bao gồm: io_uring_setup, io_uring_register, io_uring_enter

io_uring_setup

Thiết lập một ngữ cảnh để thực hiện chạy IO bất đồng bộ.

1
int io_uring_setup(u32 entries, struct io_uring_params *p);

Lệnh này sẽ tiến hành thiết lập 2 hàng đợi submission queuecompletion queue với số lượng các phần tử entries ít nhất. Trả về một file description để thực hiện các hoạt động tiếp theo trên phiên bản io_uring. 2 hàng đợi này được chia sẻ giữa kernel và application, giúp giảm chi phí khi dữ liệu giao tiếp giữa 2 bên (ko cần phải sao chép) khi khởi tạo và thực thi I/O.
Những tham số được sử dụng bởi application để cấu hình cho io_uring instance và kernel sẽ trả lại thông tin về cấu hình đến 2 hàng vòng buffer.
io_uring instance có thể được cấu hình ở 3 chế độ hoạt động chính:

  • Interrupt driven Mặc định, io_uring instance sẽ được thiết lập để điều khiển IO khi lỗi, IO có thể được gửi bằng io_uring_enter() và được lấy trực tiếp từ completion queue.
  • Polled: thực hiện chế độ chờ I/O hoàn thành. File hệ thống và những thiết bị khối (block device) cần phải được hỗ trợ mới sử dụng tính năng này. Tính năng này giúp giảm thiểu độ trễ khi thực hiện IO, tuy nhiên nó lại tốn nhiều resource của CPU hơn việc thực hiện Interrupt. Hiện nay, tính năng này chỉ thực hiện với những file descriptor được mở kèm với cờ O_DIRECT.
    Khi đọc hoặc viết vào ngữ cảnh đã thiết lập poll thì ứng dụng phải tiến hành xem tình trạng hoàn thành trên CQ ring bằng lệnh io_uring_enter(). Việc trộn và kết hợp I / O đã được thăm dò và không được thăm dò trên một phiên bản io_uring là ko được phép.
  • Kernel polled: Trong chế độ này, kernel thread được tạo để thăm dò hàng đợi gửi (submission queue). Một io_uring instanceđược cấu hình theo cách này cho phép ứng dụng xử lí I/O mà không cần xuống kernel. Bằng việc dùng submisstion queue để điền những SQE mới và kiểm tra tình trạng hoàn thành trên completion queue, ứng dụng có thể submit và lấy kết quả I/O mà không cần thực hiện system call.
    Trường hợp kernel rảnh rỗi hơn tổng thời gian người dùng cấu hình, nó sẽ tiếp tục nhàn rỗi đến khi nhận được thông báo từ ứng dụng. Trong trường hợp này, ứng dụng gọi lệnh io_uring_enter() để tiến hành đánh thức kernel.
    io_uring_setup() sẽ trả về một fd (file descripton), giá trị này được sử dụng cho mmap để tạo 2 hàng đợi SQ và CQ, và có thể được sử dụng bởi 2 system call io_uring_register() và io_uring_enter()

io_uring_register

Những file được đăng kí hoặc user buffer chạy IO bất đồng bộ.

1
int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);
  • Được sử dụng cho thực thể io_uring được trỏ bởi fd. Việc đăng kí này giúp kernel thread
    có thời gian tham chiếu tới cấu trúc dữ liệu bên trong kernel liên kết với tệp lâu hơn, hoặc tạo ánh xạ của từng vùng nhớ cụ thể trên ứng dụng với buffer lâu hơn. Chúng ta chỉ cần đăng kí 1 lần thay vì phải thực hiện nhiều lần trong quá trình xử lí, việc này giúp giảm overhead trên IO trong xử lí.
  • Những buffer được đăng kí sẽ bị khóa trên memory và sẽ bị “tính phí” theo giới hạn RLIMIT_MEMLOCK của người dùng. Thường giới hạn này sẽ là 1GB/buffer. Hiện tại, bộ nhớ đệm phải ẩn danh (ANONYMOUS) và không được sao lưu bằng tệp.
  • Có thể thiết lập 1 vùng đệm lớn rồi chọn 1 phần nhỏ cho I/O, miễn phần nhỏ đó nằm trong vùng được ánh xạ. Ứng dụng có thể tăng, giảm kích thước hoặc số lượng buffer đã đăng kí bằng cách hủy đăng kí buffer hiện tại và đăng kí buffer mới với lệnh io_uring_register().
  • Một ứng dụng có thể cập nhật động tập các file đã đăng kí mà không cần hủy đăng kí chúng.
  • Có thể sử dụng eventfd để nhận thông báo về các sự kiện hoàn thành trên phiên bản io_uring. Nếu đạt được, một eventfd file descriptor có thể được đăng kí thông qua system call này.
  • Thông tin đăng nhập của ứng dụng đang chạy có thể được đăng kí với io_uring, nó sẽ trả về một id liên kết với các thông tin đã đăng nhập đó. Các ứng dụng muốn chia sẻ vòng kết nối giữa những người dùng / quy trình riêng biệt có thể chuyển vào id thông tin xác thực này trong trường tính cách SQE. Nếu được đặt, SQE cụ thể đó sẽ được cấp với các thông tin xác thực này.

io_uring_enter

Khởi tạo và hoàn thành I/O bất đồng bộ

  • io_uring_enter() dùng để khởi tạo và hoàn thành I/O sử dụng hàng đợi submission và completion được thiết lập bởi io_uring_setup(). Một lệnh gọi đơn sẽ bao gồm gửi một I/O mới và đợi phản hồi của lệnh gọi này hoặc tất cả các lệnh gọi trước đó đến io_uring_enter().
1
2
int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete,
		unsigned int flags, sigset_t *sig);

Trong đó:

  • fd file descriptor được return bởi io_uring_setup()
  • to_submit chỉ định số lượng I/O để submit từ submission queue. Nếu được chỉ dẫn nhưu vậy, hệ thống sẽ đợi hoàn thành sự kiện min_complete trước khi quay trở lại. Nếu thực thể io_uring được cấu hình để thăm dò, thì min_complete sẽ có ý nghĩa khác một chút.
  • min_complete: như ở trên đã nói, ngoài ra, nếu nó bằng 0 ý nói kernel sẽ trả về bất kì sự kiện nào đã được hoàn thành mà không bị chặn. Nếu khác 0, kernel chỉ trả về lập tức sự kiện đang hoàn thành. Nếu không có sự kiện hoàn thành nào khả dụng, thì cuộc gọi sẽ thăm dò ý kiến ​​cho đến khi có một hoặc nhiều sự kiện hoàn thành hoặc cho đến khi quá trình vượt quá phần thời gian của bộ lập lịch của nó. Lưu ý rằng đối với I / O được điều khiển gián đoạn, một ứng dụng có thể kiểm tra hàng đợi hoàn thành để biết sự kiện hoàn thành mà không cần nhập hạt nhân.
  • io_uring_enter() hỗ trợ nhiều phép toán, bao gồm:
    • Open, close, and stat files
    • Read and write into multiple buffers or pre-mapped buffers
    • Socket I/O operations
    • Synchronize file state
    • Giám sát bất đồng bộ một tập file descriptors
    • Tạo timeout liên kết tới hoạt động cụ thể trong vòng
    • Cố gắng hủy một hoạt động hiện đang bay (thực hiện)
    • Tạo I/O chains
      • Thực hiện theo thứ tự trong một chuỗi
      • Thực thi song song cho nhiều chuỗi

Cơ chế hoạt động

Asynchronous execution

Không phải lúc nào io_uring cũng chạy bất đồng bộ mà nó chỉ chạy khi nó cần. Nguyên nhân là để giảm thiểu tối đa việc gọi io_uring_enter() system call để tăng hiệu suất của chương trình.
Ví dụ ở đoạn code dưới đây (Kernel 5.8)

 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
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/syscall.h>
#include <sys/fcntl.h>
#include <err.h>
#include <unistd.h>
#include <sys/mman.h>
#include <linux/io_uring.h>
#define SYSCHK(x) ({          \
  typeof(x) __res = (x);      \
  if (__res == (typeof(x))-1) \
    err(1, "SYSCHK(" #x ")"); \
  __res;                      \
})

static int uring_fd;
struct iovec *io;

#define SIZE 32

char _buf[SIZE];

int main(void) {
    // initialize uring
    struct io_uring_params params = { };
    uring_fd = SYSCHK(syscall(__NR_io_uring_setup, /*entries=*/10, &params));
    unsigned char *sq_ring = SYSCHK(mmap(NULL, 0x1000, PROT_READ|PROT_WRITE,
                                        MAP_SHARED, uring_fd,
                                        IORING_OFF_SQ_RING));
    unsigned char *cq_ring = SYSCHK(mmap(NULL, 0x1000, PROT_READ|PROT_WRITE,
                                        MAP_SHARED, uring_fd,
                                        IORING_OFF_CQ_RING));
    struct io_uring_sqe *sqes = SYSCHK(mmap(NULL, 0x1000, PROT_READ|PROT_WRITE,
                                            MAP_SHARED, uring_fd,
                                            IORING_OFF_SQES));
    io = malloc(sizeof(struct iovec)*1);
    io[0].iov_base = _buf;
    io[0].iov_len = SIZE;

    struct timespec ts = { .tv_sec = 1 };

    sqes[0] = (struct io_uring_sqe) {
        .opcode = IORING_OP_TIMEOUT,
        //.flags = IOSQE_IO_HARDLINK,
        .len = 1,
        .addr = (unsigned long)&ts
    };
    sqes[1] = (struct io_uring_sqe) {
        .opcode = IORING_OP_READV,
        .addr = io,
        .flags = 0,
        .len = 1,
        .off = 0,
        .fd = SYSCHK(open("/etc/passwd", O_RDONLY))
    };

    ((int*)(sq_ring + params.sq_off.array))[0] = 0;
    ((int*)(sq_ring + params.sq_off.array))[1] = 1;
    (*(int*)(sq_ring + params.sq_off.tail)) += 2;

    int submitted = SYSCHK(syscall(__NR_io_uring_enter, uring_fd,
                                    /*to_submit=*/2, /*min_complete=*/0,
                                    /*flags=*/0, /*sig=*/NULL, /*sigsz=*/0));
    while(1){
        usleep(100000);
        if(*_buf){
            puts("READV executed.");
            break;
        }
        puts("Waiting.");
    }
}

Cùng nhìn lại cơ chế làm việc của io_uring
io_uring
From: https://flattsecurity.medium.com/cve-2021-20226-a-reference-counting-bug-which-leads-to-local-privilege-escalation-in-io-uring-e946bd69177a

  • Chúng ta có một cấu trúc iovecline 21 để định nghĩa một phần tử vector
1
2
3
4
struct iovec{
   ptr_t iov_base; /* Starting address */
   size_t iov_len; /* Length in bytes */
};

Thông thường cấu trúc này được sử dụng như một mảng gồm nhiều phần tử. Mỗi lần chuyển đổi dữ liệu, con trỏ iov_base sẽ trỏ vào vùng đệm để nhận dữ liệu (với lệnh readv) hoặc chuyển dữ liệu (với lệnh writev). Còn phần tử iov_len định nghĩa độ dài tối đa được nhận và được viết trong mỗi lần thực thi. Đoạn code từ line 40-42 để cấp phát cũng như định nghĩa giá trị các phần tử iov_baseiov_len.

  • Cấu trúc io_uring_paramsline 29 dùng để chuyển các tùy chọn đến kernel và từ kernel truyền thông tin về bộ đệm vòng (ring buffer)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct io_uring_params {
    __u32 sq_entries;
    __u32 cq_entries;
    __u32 flags;
    __u32 sq_thread_cpu;
    __u32 sq_thread_idle;
    __u32 features;
    __u32 resv[4];
    struct io_sqring_offsets sq_off;
    struct io_cqring_offsets cq_off;
};
  • Code từ line 29-39 dùng để tạo 2 vòng đêm buffer cho SQ và CQ cùng với 1 buffer SQEs để lưu các Submission Entries.
  • Cấu trúc timespecline 44dùng để đặc tả thời gian ở dạng seconds và nanoseconds:
1
2
3
4
5
6
#include <time.h>

struct timespec {
   time_t   tv_sec;
   long     tv_nsec;
}

Ở đây, nó được tạo với 1 sec.

  • Cấu trúc io_uring_sqeline 37, 46, 52 diễn tả cấu trúc của submission queue entry
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct io_uring_sqe {
  __u8  opcode;   /* type of operation for this sqe */
  __u8  flags;    /* IOSQE_ flags */
  __u16  ioprio;  /* ioprio for the request */
  __s32  fd;      /* file descriptor to do IO on */
  __u64  off;     /* offset into file */
  __u64  addr;    /* pointer to buffer or iovecs */
  __u32  len;     /* buffer size or number of iovecs */
  union {
    __kernel_rwf_t  rw_flags;
    __u32    fsync_flags;
    __u16    poll_events;
    __u32    sync_range_flags;
    __u32    msg_flags;
  };
  __u64  user_data;   /* data to be passed back at completion time */
  union {
    __u16  buf_index; /* index into fixed buffers, if used */
    __u64  __pad2[3];
  };
};

Trong đó:

  • opcode: để đặc tả hoạt động. Ví dụ: readv dùng hằng số IORING_OP_READV
  • fd: file descriptor của file muốn đọc
  • addr: được dùng để trỏ đến một mảng các iovec.
  • len: giữ giá trị độ dài của mảng các iovec

Do đó, đoạn code từ line 52-59 khai báo sử dụng lệnh readv trên file /etc/passwd. Còn từ line 44-51 có sử dụng hằng số IORING_OP_TIMEOUT để chỉ định rằng khi ứng dụng đang sleep thì hoạt động này sẽ được thực hiện.
Cả đoạn code trên, khi thực thi, cứ sau 0.1 seconds sẽ kiểm tra xem liệu readv() đã thực thi xong hay chưa. Và vì ts ta thiết lập nó 1 seconds và truyền vào sqes[0] nên readv() thực thi (sqes[1] thực thi) sẽ sau 1 second. Tuy nhiên, khi thực thi, ta thấy readv() lại thực thi ngay

1
2
3
4
root@edisc:/home/test# ./sample 
READV executed
root@edisc:/home/test# ./sample 
READV executed

Điều này chứng tỏ, hệ thống không chạy bất đồng bộ, hay nói cách khác, nó chỉ chạy khi nó cần thiết, và trường hợp này là không cần thiết, hoạt động của IORING_OP_TIMEOUT đã bị bỏ qua.
Ta sẽ kiểm tra trong trường hợp chương trình sample chạy bất đồng bộ bằng tool systemtap
Theo tác giả thì khi thêm flag IORING_OP_HARDLINK thì chương trình sample.c sẽ tiến hành kiếm tra sau 0.1s in ra dòng chữ waiting và sau 1s lệnh READV sẽ được thực thi, và sẽ in ra READV executed. Tuy nhiên trong quá trình kiểm tra trên ubuntu 20.04, kernel 5.8.0-59-generic thì lại nhận được thông báo IORING_OP_HARDLINK chưa được khai báo. Trong khi IORING_OP_LINK vẫn bình thường.

Ở trên ta có đề cập tới 2 flag IOSQE_IO_HARDLINKIOSQE_IO_LINK, ta sẽ tìm hiểu nó là gì.

  • IOSQE_IO_LINK: cờ này để chỉ định các SQE được chạy trong hàng đợi sẽ phụ thuộc lẫn nhau, phần tử sau đợi phần tử ở trước, giống như trong 1 gia đình, ông bố phải sinh ra rồi thằng con mới được sinh, nếu ông bố sinh thất bại thì thằng con, cháu và cả dòng họ sau này cũng sẽ ra đi theo ông bố. Độ dài của chuỗi sẽ tùy ý, tự mở rộng nếu có thêm SQE vào.
  • IOSQE_IO_HARDLINK: giống như link nhưng nó mạnh hơn. Một số lệnh bị kết thúc do gặp phải lỗi, lúc này kết quả trả về sẽ < 0. Ví dụ, timeout sẽ không có bộ đếm số lần thực hiện, nó sẽ luôn hoàn thành với -ETIME trừ khi nó bị hủy. Do đó, nếu dùng cờ IOSQE_IO_LINK khi 1 SQE trả về giá trị nhỏ hơn 0, nó sẽ cắt đứt toàn bộ chuỗi phía sau. Trường hợp chúng ta có dùng lệnh và biết chắc nó sẽ xảy ra lỗi, khi đó, IOSQE_IO_HARDLINK sẽ làm liên kết mạnh hơn mà không bị cắt đứt bởi kết quả trả về ở SQE trước đó. Chú ý rằng liên kết vẫn sẽ bị ngắt nếu lệnh trước đó không gửi và thực hiện được, các liên kết chỉ được phục hồi khi yêu cầu trước đó nhận được phản hồi.

Ta có thể thấy, nguyên lí hoạt động của việc quyết định một tác vụ có chạy được bất đồng bộ hay không như sau:
NLHD_syn

Trong ảnh trên, ta thấy mà process bị chặn lại thì nó sẽ chuyển đến IORING_OP_TIMEOUT và từ đây nó sẽ được chuyển sang kernel thread. Như đã đề cập trước đó, API này nó chỉ chạy bất đồng bộ khi cần nghĩa là sẽ có một số điều kiện nào đó để nó chạy bất đồng bộ. Ví dụ một số trường hợp sau.

  1. Khi bật cờ yêu cầu buộc phải chạy bất đồng bộ REQ_F_FORCE_ASYNC
1
2
3
4
5
6
7
8
9
} else if (req->flags & REQ_F_FORCE_ASYNC) {
	......
	/*
	* Never try inline submit of IOSQE_ASYNC is set, go straight
	* to async execution.
	*/
	req->work.flags |= IO_WQ_WORK_CONCURRENT;
	io_queue_async_work(req);  
}

code here

  1. Do logic của từng hoạt động riêng, ví dụ như thêm flag IOCB_NOWAIT khi gọi readv() và trả về EAGAIN khi muốn dừng.
 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
static int io_read(struct io_kiocb *req, struct io_kiocb **nxt,
	bool force_nonblock)
{
	......
	ret = rw_verify_area(READ, req->file, &kiocb->ki_pos, iov_count);
	if (!ret) {
		ssize_t ret2;
		if (req->file->f_op->read_iter)
			ret2 = call_read_iter(req->file, kiocb, &iter);
		else
			ret2 = loop_rw_iter(READ, req->file, kiocb, &iter);
		/* Catch -EAGAIN return for forced non-blocking submission */
		if (!force_nonblock || ret2 != -EAGAIN) {
			kiocb_done(kiocb, ret2, nxt, req->in_async);
		} else {
		copy_iov:
			ret = io_setup_async_rw(req, io_size, iovec,
				inline_vecs, &iter);
			if (ret)
				goto out_free;
			return -EAGAIN;
		}
	}
	......
}

code here
Ở đây có cấu trúc io_kiocb phục vụ cho việc đọc file

 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
/*
 * NOTE! Each of the iocb union members has the file pointer
 * as the first entry in their struct definition. So you can
 * access the file pointer through any of the sub-structs,
 * or directly as just 'ki_filp' in this struct.
 */
struct io_kiocb {
	union {
		struct file		*file;
		struct io_rw		rw;
		struct io_poll_iocb	poll;
		struct io_poll_update	poll_update;
		struct io_accept	accept;
		struct io_sync		sync;
		struct io_cancel	cancel;
		struct io_timeout	timeout;
		struct io_timeout_rem	timeout_rem;
		struct io_connect	connect;
		struct io_sr_msg	sr_msg;
		struct io_open		open;
		struct io_close		close;
		struct io_rsrc_update	rsrc_update;
		struct io_fadvise	fadvise;
		struct io_madvise	madvise;
		struct io_epoll		epoll;
		struct io_splice	splice;
		struct io_provide_buf	pbuf;
		struct io_statx		statx;
		struct io_shutdown	shutdown;
		struct io_rename	rename;
		struct io_unlink	unlink;
		/* use only after cleaning per-op data, see io_clean_op() */
		struct io_completion	compl;
	};

Khi giá trị EAGAIN được trả về, process sẽ được đưa vào hàng đợi để chạy bất đồng bộ line 9-23:

 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
static void __io_queue_sqe(struct io_kiocb *req, const struct io_uring_sqe *sqe)
{
	......
	ret = io_issue_sqe(req, sqe, &nxt, true);
	/*
	* We async punt it if the file wasn't marked NOWAIT, or if the file
	* doesn't support non-blocking read/write attempts
	*/
	if (ret == -EAGAIN && (!(req->flags & REQ_F_NOWAIT) ||
		(req->flags & REQ_F_MUST_PUNT))) {
	punt:
		if (io_op_defs[req->opcode].file_table) {
			ret = io_grab_files(req);
			if (ret)
				goto err;
		}
		/*
		* Queued up for async execution, worker will release
		* submit reference when the iocb is actually submitted.
		*/
		io_queue_async_work(req);
		goto done_req;
	}
	......
} 
  1. Khi cờ IOSQE_IO_LINK | IOSQE_IO_HARDLINK được sử dụng, thứ tự thực thi sẽ được chỉ định, hoạt động đang thực hiện trước đó sẽ được yêu cầu chuyển sang bất đồng bộ.
 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
static bool io_submit_sqe(struct io_kiocb *req, const struct io_uring_sqe *sqe,
     struct io_submit_state *state, struct io_kiocb **link)
{
 ......
 /*
  * If we already have a head request, queue this one for async
  * submittal once the head completes. If we don't have a head but
  * IOSQE_IO_LINK is set in the sqe, start a new head. This one will be
  * submitted sync once the chain is complete. If none of those
  * conditions are true (normal request), then just queue it.
  */
 if (*link) {
  ......
  list_add_tail(&req->link_list, &head->link_list);
/* last request of a link, enqueue the link */
  if (!(sqe_flags & (IOSQE_IO_LINK|IOSQE_IO_HARDLINK))) {
   io_queue_link_head(head);
   *link = NULL;
  }
 } else {
  ......
  if (sqe_flags & (IOSQE_IO_LINK|IOSQE_IO_HARDLINK)) {
   req->flags |= REQ_F_LINK;
   INIT_LIST_HEAD(&req->link_list);
if (io_alloc_async_ctx(req)) {
    ret = -EAGAIN;
    goto err_req;
   }
   ret = io_req_defer_prep(req, sqe);
   if (ret)
    req->flags |= REQ_F_FAIL_LINK;
   *link = req;
  } else {
   io_queue_sqe(req, sqe);
  }
 }
return true;
} 

line 12-19 là trường hợp đã tồn tại head request, trong đó: line 12-14 đưa sqe vào đuôi của list. line 16-19 là trường hợp không cờ IOSQE_IO_LINK|IOSQE_IO_HARDLINK không được bật
Nếu không tồn tại head request mà cờ IOSQE_IO_LINK|IOSQE_IO_HARDLINK bật, thì hệ thống sẽ tạo một head mới (line 24), và chuyển sang bất đồng bộ(line 25)

  • Nói một cách chính xác, với IORING_OP_TIMEOUT có một chút đặc biệt, đó là nó không trả về EAGAIN như những minh họa trên, tuy nhiên tác giả nghĩ đây là một ví dụ dễ để hiểu nên ông ta đã quyết định dùng nó để giải thích, minh họa trong blog của mình.
    Khi thay thêm .flags = IOSQE_IO_HARDLINK thì IORING_OP_TIMEOUT sẽ được thực thi bởi 1 thread khác, còn IORING_OP_READV sẽ được thực thi sau 1s. Tuy nhiên, tại thời điểm tôi thực hiện trên linux kernel 5.8.0-59-generic, thì lại không tồn tại flag IOSQE_IO_HARDLINK, Tôi vẫn không biết lí do trên là do io_uring đã bỏ nó đi để fix lỗi, hay do trong quá trình cài đặt tôi đã bị thiếu gì đó. Và một điều nữa, tác giả sử dụng systemstap để debug rất mượt, còn tôi, trong quá trình follow theo thì lại tốn khá nhiều thời gian cho script này và đến bây giờ vẫn thất bại.
    error
    Rõ ràng trên hệ thống chỉ có IOSQE_IO_LINK, nhưng khi bật flag này, như đã nói ở trước đó, kết quả đạt được là không như mong muốn vì sqe[0] chưa hoàn thành nên sqe[1] (IORING_OP_READV) sẽ không thực thi.
    iosqe_io_link.png
    Mặc dù nó không đưa ra kết quả như mong muốn (cần dùng IOSQE_IO_HARDLINK) nhưng khi dùng IOSQE_IO_LINK từ phía kernel nhìn xuống ta vẫn thấy IORING_OP_READVIORING_OP_TIMEOUT vẫn được xử lí như 2 thằng, và IORING_OP_TIMEOUT được xem như worker.
    iosqe_io_link_result.png
    Kết quả trên minh họa cho đoạn code để tạo worker từ kernel như sau:
1
2
3
4
5
6
7
static bool create_io_worker(struct io_wq *wq, struct io_wqe *wqe, int index)
{
 ......
worker->task = kthread_create_on_node(io_wqe_worker, worker, wqe->node,
    "io_wqe_worker-%d/%d", index, wqe->node);
 ......
}  

Khi io_timeout() được gọi, nó sẽ đặt io_timeout_fn() trong handler và bắt đầu bộ đếm thời gian. Sau khi hết thời gian cho phép (timeout) io_timeout_fn() sẽ được gọi để load các hoạt động được kết nối trong hàng đợi để thực thi bất đồng bộ. Tức là IORING_OP_TIMEOUT không có được đặt vào hàng đợi thực thi bất đồng bộ. Tác giả dùng TIMEOUT để giải thích đơn giản vì khi nhắc tới TIMEOUT chúng ta sẽ liên tưởng ngay tới việc luồng thực thi hiện tại sẽ được dừng lại.
io_uring_fix

Precautions when offloading I/O operations to the Kernel

Chúng ta đã thấy hoạt động bất đồng bộ trên io_uring sẽ được thực hiện bởi worker và chạy dưới dạng Kernel Thread. Tuy nhiên cần có một số lưu ý: Vì nó chạy ở dạng Kernel Thread, nên ngữ cảnh thực thi (execution context) sẽ khác với khi được gọi bởi io_uring. Ở đây, execution context ám chỉ cấu trúc task_struct của process và các thông tin tương ứng của nó. Ví dụ như mm (Manage the virtual memory space of the process), cred (holds UID/GID/Capability), file struct (holds a table for file descriptors)

  • Nếu 1 process khi thực thi ở Kernel Thread mà không tham chiếu đúng để cấu trúc trên khi thực hiện systemcall nó có thể trỏ tới bộ nhớ ảo sai, trỏ tới file description table, hoặc đưa ra các hoạt động đặc quyền với Kernel Thread (quyền hạn như root). Trong CVE này, lỗi xảy ra ở đây, người viết tại thời điểm trên quên chuyển cred và làm cho những hoạt động lúc này có thể được thực thi cùng quyền hạn như root. Mặc dù những hoạt động tương đương với open() tại thời điểm chưa được hiện thực, nên chúng ta không thể đọc file, nhưng chúng ta có thể bắn những thông báo (message) đặc quyền trong sendmsg's với tùy chọn SCM_CREDENTIALS để thông báo cho cơ quan có thẩm quyền của người gửi (sender’s authority). Vấn đề này liên quan tới D-Bus.
  • Do đó, trong io_uring những tham khảo đó được đưa vào worker để worker chia sẻ execution context bằng cách chuyển qua context của nó trước khi thực thi.
  • Ví dụ: trong đoạn code sau đây, tại line 6 và line 9 chúng ta có thể thấy tham khảo tới mmcred được đưa vào req->work:
 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
static inline void io_req_work_grab_env(struct io_kiocb *req,
     const struct io_op_def *def)
{
    if (!req->work.mm && def->needs_mm) {
        mmgrab(current->mm);
        req->work.mm = current->mm;
    }
    if (!req->work.creds)
        req->work.creds = get_current_cred();

    if (!req->work.fs && def->needs_fs) {
        spin_lock(&current->fs->lock);
        if (!current->fs->in_exec) {
            req->work.fs = current->fs;
            req->work.fs->users++;
        } else {
            req->work.flags |= IO_WQ_WORK_CANCEL;
        }

        spin_unlock(&current->fs->lock);
    }

    if (!req->work.task_pid)
        req->work.task_pid = task_pid_vnr(current);
}

file struct cũng được đưa vào req->work ở đoạn code sau:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
static int io_grab_files(struct io_kiocb *req)
{
    ......
    if (fcheck(ctx->ring_fd) == ctx->ring_file) {
        list_add(&req->inflight_entry, &ctx->inflight_list);
        req->flags |= REQ_F_INFLIGHT;
        req->work.files = current->files;
        ret = 0;
    }
    ......
}  

Và trước khi nó chuyển sang thực thi dưới dạng kernel thread, worker sẽ thay thế những tham khảo được truyền vào với nội dung hiện tại của nó.

 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
static void io_worker_handle_work(struct io_worker *worker)
 __releases(wqe->lock)
{
    struct io_wq_work *work, *old_work = NULL, *put_work = NULL;
    struct io_wqe *wqe = worker->wqe;
    struct io_wq *wq = wqe->wq;
    do {
        ......
        if (work->files && current->files != work->files) {
            task_lock(current);
            current->files = work->files;
            task_unlock(current);
        }
        if (work->fs && current->fs != work->fs)
            current->fs = work->fs;

        if (work->mm != worker->mm)
            io_wq_switch_mm(worker, work);

        if (worker->cur_creds != work->creds)
            io_wq_switch_creds(worker, work);
        ......
        work->func(&work);
    ......
    } while (1);
} 

context_switch

Vulnerability explanation

Reference counter in files_struct structure when sharing with the worker

Trong đoạn code sau đây, worker đang chuyển một tham chiếu đến cấu trúc files_struct của luồng đang thực hiện lệnh gọi hệ thống tới cấu trúc mà worker sẽ tham chiếu sau này mà không cần tăng bộ đếm tham chiếu.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
static int io_grab_files(struct io_kiocb *req)
{
    ......
    if (fcheck(ctx->ring_fd) == ctx->ring_file) {
        list_add(&req->inflight_entry, &ctx->inflight_list);
        req->flags |= REQ_F_INFLIGHT;
        req->work.files = current->files;
        ret = 0;
    }
    ......
}

Bằng cách này, khi vào hàng đợi thực thi bất đồng bộ, tham chiếu tới cấu trúc file được giữ lại đầu tiên từ bộ mô tả tệp được chỉ định

 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
static int io_req_set_file(struct io_submit_state *state, struct io_kiocb *req,
      const struct io_uring_sqe *sqe)
{
    struct io_ring_ctx *ctx = req->ctx;
    unsigned flags;
    int fd;
    flags = READ_ONCE(sqe->flags);
    fd = READ_ONCE(sqe->fd);
    if (!io_req_needs_file(req, fd))
        return 0;

    if (flags & IOSQE_FIXED_FILE) {
        if (unlikely(!ctx->file_data ||
            (unsigned) fd >= ctx->nr_user_files))
        return -EBADF;

        fd = array_index_nospec(fd, ctx->nr_user_files);
        req->file = io_file_from_index(ctx, fd);
        if (!req->file)
            return -EBADF;
        req->flags |= REQ_F_FIXED_FILE;
        percpu_ref_get(&ctx->file_data->refs);
    } else {
        if (req->needs_fixed_file)
            return -EBADF;
        trace_io_uring_file_get(ctx, fd);
        req->file = io_file_get(state, fd);
        
        if (unlikely(!req->file))
            return -EBADF;
    }
    return 0;
}

asyc_work
Do đó, worker không cần phải trích xuất nó từ fd hoặc trỏ tới cấu trúc files_struct một lần nữa. Nếu như thế thì việc không tăng bộ đếm tham chiếu tới files_struct không bị ảnh hưởng gì. Nhưng giả định trên không đúng với Linux Kernel 5.5 trở lên bởi vì những system call sẽ ảnh hưởng tới file descriptor table như open/close/accept đã có sẵn trên io_uring, do đó, chúng ta có thể sử dụng để khai thác. Tuy nhiên,

  • Chỉ gọi những syscall 1 cách thông thường sẽ chẳng có gì xảy ra khi cấu trúc file_struct đã sẵn sàng, và ko thể tạo ra race condition vì syscall có countermeasures khi xử lí một file bằng nhiều thread.
  • Giải phóng files_struct bằng cách thiết lập reference counter bằng 0 => 1 process nào đó có thể dùng nó như là file_struct của nó để thực hiện. Một ví dụ như nhà của bạn mà bạn để bảng “nhà hoang” thì một người nào đó có thể đi vào đó ở, sinh sống. Chúng ta có thể chèn 1 cấu trúc file vào file description table của process mới bằng mở 1 file, nhưng nó sẽ không được reference vì người dùng không sử dụng cố định số lượng file descriptor trong khi lập trình.

Mechanism of reference counter in open/close system call

Để hiểu reference counter trong cấu trúc file hoạt động như thế nào, trước tiên cần hiểu cách thức hoạt động của open/close. Cách thức này sẽ phụ thuộc vào từng file riêng, nhưng có 1 số điểm chung:

Open

  1. Tạo 1 cấu trúc file và thiết lập reference counter = 1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
static struct file *__alloc_file(int flags, const struct cred *cred)
{
    struct file *f;
    int error;
    f = kmem_cache_zalloc(filp_cachep, GFP_KERNEL);
    ......
    atomic_long_set(&f->f_count, 1);
    ......
    return f;
}
  1. Đăng kí nó vào file description table (fd_install).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
static long do_sys_openat2(int dfd, const char __user *filename,
      struct open_how *how)
{
 ......
    fd = get_unused_fd_flags(how->flags);
    if (fd >= 0) {
        struct file *f = do_filp_open(dfd, tmp, &op);
        if (IS_ERR(f)) {
            put_unused_fd(fd);
            fd = PTR_ERR(f);
        } else {
            fsnotify_open(f);
            fd_install(fd, f);
        }
    }
    putname(tmp);
    return fd;
}

close

1 Xóa trong file description table.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int __close_fd(struct files_struct *files, unsigned fd)
{
    struct file *file;
    struct fdtable *fdt;

    spin_lock(&files->file_lock);
    fdt = files_fdtable(files);

    if (fd >= fdt->max_fds)
        goto out_unlock;

    file = fdt->fd[fd];

    if (!file)
        goto out_unlock;
    rcu_assign_pointer(fdt->fd[fd], NULL);
    __put_unused_fd(files, fd);
    spin_unlock(&files->file_lock);
    return filp_close(file, files);
    out_unlock:
    spin_unlock(&files->file_lock);
    return -EBADF;
}
  1. Giảm reference counter của cấu trúc file (fput)
1
2
3
4
5
6
int filp_close(struct file *filp, fl_owner_t id)
{
    ......
    fput(filp);
    return retval;
}  

Ở đây chú ý tới hàm fget()/fput(). Chúng sẽ tăng hoặc giảm reference counter và fput() sẽ giải phóng của cấu trúc file khi reference counter bằng 0. Nếu chúng ta mở file bằng fget() reference counter sẽ không thể bằng 0 ngay cả khi ta đóng file trước khi gọi fput() (bộ đếm sẽ có gía trị là 1 khi chạy lệnh open(), tăng lên 2 khi gọi fget() và nếu lúc này đóng thì nó sẽ giảm xuống còn 1). Do đó, nếu file bị đóng khi đang sử dụng vẫn không có vấn đề gì, vì reference counter vẫn chưa bằng 0 nên cấu trúc file không thể bị giải phóng.
Xét trường hợp gọi mmap, sẽ có vấn đề xảy ra nếu vùng nhớ được giải phóng trước khi gọi munmap, thậm chí là khi gọi close() rồi mà tiếp tục gọi munmap vẫn sẽ bị lỗi. Do đó, fget() được dùng trong mmap để tránh trường hợp bộ nhớ bị giải phóng.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
unsigned long ksys_mmap_pgoff(unsigned long addr, unsigned long len,
         unsigned long prot, unsigned long flags,
         unsigned long fd, unsigned long pgoff)
{
    struct file *file = NULL;
    unsigned long retval;
    if (!(flags & MAP_ANONYMOUS)) {
        audit_mmap_fd(fd, flags);
        file = fget(fd);
    ......
}

fdget() which doesn’t change reference counter

2 hàm fdget()/fdput() thường xuyên được sử dụng để tham khảo tới những cấu trúc file được sử dụng nhiều trong system call handlers.
Ví dụ, trong system call read, cấu trúc file được sử dụng giữa fdget()/fdget_pos()fdput()/fdput_pos() được sử dụng như sau:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
    struct fd f = fdget_pos(fd);
    ssize_t ret = -EBADF;
    if (f.file) {
        loff_t pos, *ppos = file_ppos(f.file);
        if (ppos) {
            pos = *ppos;
            ppos = &pos;
        }

        ret = vfs_read(f.file, buf, count, ppos);
        if (ret >= 0 && ppos)
        f.file->f_pos = pos;
        fdput_pos(f);
    }
    return ret;
}

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
    return ksys_read(fd, buf, count);
}

Có một lí do nào đó để hệ thống không cần phải tăng/giảm reference counter của cấu trúc file một cách thường xuyên, có lẽ do ảnh hưởng của bộ nhớ cache(). Do đó, fdget() sẽ không tăng reference counter của cấu trúc file dưới một số điều kiện nhất định. fdget() cuối cùng sẽ gọi hàm __fget_light()

 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
/*
 * Lightweight file lookup - no refcnt increment if fd table isn't shared.
 *
 * You can use this instead of fget if you satisfy all of the following
 * conditions:
 * 1) You must call fput_light before exiting the syscall and returning control
 *    to userspace (i.e. you cannot remember the returned struct file * after
 *    returning to userspace).
 * 2) You must not call filp_close on the returned struct file * in between
 *    calls to fget_light and fput_light.
 * 3) You must not clone the current task in between the calls to fget_light
 *    and fput_light.
 *
 * The fput_needed flag returned by fget_light should be passed to the
 * corresponding fput_light.
 */
static unsigned long __fget_light(unsigned int fd, fmode_t mask)
{
    struct files_struct *files = current->files;
    struct file *file;

    if (atomic_read(&files->count) == 1) {
        file = __fcheck_files(files, fd);
        if (!file || unlikely(file->f_mode & mask))
            return 0;
        return (unsigned long)file;
    } else {
        file = __fget(fd, mask, 1);
        if (!file)
            return 0;
        return FDPUT_FPUT | (unsigned long)file;
    }
}

Trong chương trình đa luồng, file descriptor sẽ được chia sẻ (&file->count >= 2) và cùng 1 file descriptor trỏ đến đến cùng 1 tệp. Trong trường hợp này, những thread khác có gọi close() trong khi read() vẫn đang thực thi. Lúc này, fdget() của read() system call được gọi để giảm reference counter.
read_sys
Tuy nhiên, với chương trình chạy đơn luồng khác, tại 1 thời điểm chỉ có 1 thread thực hiện, nên sẽ không thể xảy tra trường hợp trên, do đó, fdget() không cần phải tăng reference counter. Do đó, nó sẽ không tăng reference counter trừ khi file descriptor table được chia sẻ.

Combining vulnerabilities with the fdget() spec

  • Lỗ hổng từ việc chuyển 1 tham khảo file struct vào 1 cấu trúc worker trỏ tới mà không tăng reference counter và nếu chương trình gốc là đơn luồng, thì &file->count = 1 dù cho file descripton table được chia sẻ.
  • &files->count = 1 có nghĩa là fdget() sẽ không tăng reference counter trong cấu trúc file. Do đó, vùng nhớ của cấu trúc file được sự dụng bởi fdget() có thể bị giải phóng ở 1 thời điểm nào đó.
    vulnerable

Tóm tắt

Lỗi này như sau:

  • AIO worker chia sẻ cấu trúc file_struct với thread được gọi, và tại thời điểm, reference counter của cấu trúc files_struct không tăng lên.
  • vì fdget không tăng reference counter của cấu trúc file trong khi reference counter được khởi tạo có giá trị là 1. file được mở bởi fdget()có thể bị đóng và giải phóng trong worker.
  • Vì cấu trúc file bị giải phóng nên lỗi UAF có thể xảy ra.
Share on

Edisc
WRITTEN BY
Edisc
Cyber Security Engineer

 
What's on this Page