Linux Privilege Escalation

CVE-2022-27666 > Exploiting heap-based buffer overflow in Linux Kernel's ESP6 modules for LPE

Cấp phát buffer mặc định là order-3 page, tuy nhiên size ghi vào buffer được mô tả có thể lớn hơn, dẫn đến overflow đến 8 page trên heap khi ghi.

May 12, 2026 nday

Introduction

Mình reproduce CVE này với mục đích là bắt đầu làm quen với heap spray trong linux kernel exploitation. Vì bài viết của tác giả tại (https://etenal.me/archives/1825) đã nói khá chi tiết về page-level heap fengshui tại buddy allocator, và các kỹ thuật được sử dụng trong exploit, nên mình sẽ chỉ phân tích lại root cause (vì tác giả ko viết chi tiết phần này) và tóm tắt lại attack plan với làm rõ một số thứ.

Môi trường mình sử dụng là Ubuntu 22.04, thay vì 21.10 như của tác giả, phiên bản kernel còn lỗ hổng đc cài đặt là 5.13.19, chạy trong QEMU/KVM.

Background

struct sk_buff

sk_buff là một metadata structure để biểu diễn một gói tin, vì chỉ là metadata nên ko chứa dữ liệu của gói tin, mà dữ liệu đc lưu trong những buffer đi kèm, trông như sau:

headroom là vùng trống trước data để skb_push() thêm header vào (Ethernet, IP, ESP,…) mà ko cần cấp phát lại. data chứa payload thực sự của gói tin. tailroom là vùng trống sau data, để skb_put() thêm dữ liệu vào cuối, hoặc thêm padding, trailer,… Ngay liền sau tailroomskb_shared_info, chứa danh sách các page fragments.

Khi muốn thêm dữ liệu vào nhưng tailroom đã hết, thì thay vì copy lại toàn bộ vào buffer lớn hơn rồi thêm liền sau, thì linux kernel chỉ việc đính kèm thêm các page chứa dữ liệu vào cuối skb, gọi là paged data. Phần dữ liệu nằm liền trong buffer chính gọi là linear. Trường skb->len là tổng kích thước của linearpaged. Còn skb->data_len chỉ phần paged, nếu skb->data_len = 0, toàn bộ dữ liệu nằm trong linear buffer, khi > 0, phần còn lại nằm trong page fragments.

Page fragment về cơ bản có cấu trúc như sau:

  • Con trỏ đến trang vật lý.
  • Offset trong trang nơi dữ liệu bắt đầu.
  • Số byte kích thước của fragment.

Biết thêm chi tiết tại:

IPSec

IP Security là giao thức bảo mật hoạt động ở tầng 3 - network layer trong mô hình 7 tầng OSI, giúp mã hoá và xác thực các gói tin IP. Gồm 2 giao thức chính:

  • AH (Authentication Header) - chỉ xác thực, không mã hóa.
  • ESP (Encapsulating Security Payload) - vừa mã hóa vừa xác thực.

Gồm 2 chế độ hoạt động:

  • Transport mode - chỉ bảo vệ payload, giữ nguyên IP header gốc.
  • Tunnel mode - bọc toàn bộ gói IP gốc vào bên trong, thêm outer IP header mới.

Ứng dụng phổ biến nhất của Tunnel mode đó là VPN.

XFRM Subsystem

XFRM là một framework trong Linux Kernel quản lý các gói tin của IPSec, viết tắt của “transform” - biến đổi gói tin.

Về cơ bản, nhiệm vụ chính của nó là intercept gói tin in/outbound tại tầng 3, tra cứu Policy -> tìm State -> rồi áp dụng transform, và quản lý 1 số thứ khác. Policy hiểu đơn giản là chỉ ra gói tin nào, khi nào, cần làm gì, dùng kiểu SA - State nào, còn State là làm thế nào (thuật toán mã hoá, giải mã, khoá,…). Một Policy có thể có nhiều State.

Encapsulating Security Payload (ESP)

Dưới đây là layout gói tin ESP hoạt động ở Tunnel mode, với các giá trị đc thể hiện sát với trong source code:

                                              Traffic flow confidentiality padding                                                 
       network_hdr            inner_network_hdr                │                                                                   
            │                         │                        │                      Integrity check value                        
            │   transport_hdr         │ inner_transport_hdr    │                                │                                  
            │        │                │          │             │                                │                                  
            │        │                │          │             │                                │                                  
            │        │ESP header      │          │             │                                │                                  
            ▼        ▼◄─────────►     ▼          ▼             ▼                                ▼                                  
 ┌──────────┬────────┬─────┬─────┬────┬──────────┬─────────┬────────┬─────────┬─────┬────────┬──────┬──────────┬──────────────────┐
 │   ...    │ New IP │ SPI │ Seq │ IV │ Inner IP │ Payload │  TFC   │ Padding │ Pad │  Next  │ ICV  │   ...    │       ...        │
 │          │ header │     │ num │    │  header  │         │        │         │ len │ header │      │          │                  │
 └──────────┴────────┴─────┴─────┴────┴──────────┴─────────┴────────┴─────────┴─────┴────────┴──────┴──────────┴──────────────────┘
 ▲ headroom ▲        |                |◄──────────────────►|◄──────►|          ◄────────────►|◄────►▲ tailroom ▲  skb_shared_info  
 │          │        |                |  Original payload  | tfclen |             2 bytes    | alen │          │                   
 │          │        |                |                    |         ◄──────────────────────►|      │          │                   
 │          │        |                |                    |                   plen          |      │          │                   
 │          │        |                 ◄────────────────────────────────────────────────────►|      │          │                   
 │          │        |                                clen | (Encrypted)                     |      │          │                   
 │          │        |                                      ◄──────────────────────────────────────►│          │                   
 │          │        |                                                       tailen          |      │          │                   
 │          │         ◄─────────────────────────────────────────────────────────────────────►       │          │                   
head       data                                   Authenticated                                    tail       end                  

Khi process từ userspace thực hiện syscall sendto(), kernel xây dựng một sk_buff với layout như vậy sau khi hoàn thành thực thi hàm esp6_output():

static int esp6_output(struct xfrm_state *x, struct sk_buff *skb)
{
	int alen;
	int blksize;
	struct ip_esp_hdr *esph;
	struct crypto_aead *aead;
	struct esp_info esp;

	esp.inplace = true;

	esp.proto = *skb_mac_header(skb);
	*skb_mac_header(skb) = IPPROTO_ESP;

	/* skb is pure payload to encrypt */

	aead = x->data;
	alen = crypto_aead_authsize(aead);

	esp.tfclen = 0;
	if (x->tfcpad) {
		struct xfrm_dst *dst = (struct xfrm_dst *)skb_dst(skb);
		u32 padto;

		padto = min(x->tfcpad, __xfrm_state_mtu(x, dst->child_mtu_cached));
		if (skb->len < padto)
			esp.tfclen = padto - skb->len;
	}
	blksize = ALIGN(crypto_aead_blocksize(aead), 4);
	esp.clen = ALIGN(skb->len + 2 + esp.tfclen, blksize);
	esp.plen = esp.clen - skb->len - esp.tfclen;
	esp.tailen = esp.tfclen + esp.plen + alen;

	esp.esph = ip_esp_hdr(skb);

	esp.nfrags = esp6_output_head(x, skb, &esp);
	if (esp.nfrags < 0)
		return esp.nfrags;

	esph = esp.esph;
	esph->spi = x->id.spi;

	esph->seq_no = htonl(XFRM_SKB_CB(skb)->seq.output.low);
	esp.seqno = cpu_to_be64(XFRM_SKB_CB(skb)->seq.output.low +
			    ((u64)XFRM_SKB_CB(skb)->seq.output.hi << 32));

	skb_push(skb, -skb_network_offset(skb));

	return esp6_output_tail(x, skb, &esp);
}

Hàm thực hiện lưu lại protocol gốc (protocol của gói tin trong Inner IP Header), ghi protocol IPPROTO_ESP vào trường Next header của New IP Header. Tính toán các tham số như blksize, clen, plen, tailen, trường SPI, sequence number cho ESP Header. 2 hàm quan trọng đc gọi là esp6_output_head()esp6_output_tail().

esp6_output_head() có nhiệm vụ cấp phát thêm chỗ chứa cho trailer gồm padding + pad len + next header + ICV vào sau tail.

int esp6_output_head(struct xfrm_state *x, struct sk_buff *skb, struct esp_info *esp)
{
	u8 *tail;
	int nfrags;
	int esph_offset;
	struct page *page;
	struct sk_buff *trailer;
	int tailen = esp->tailen;

	if (x->encap) {
		int err = esp6_output_encap(x, skb, esp);

		if (err < 0)
			return err;
	}

	if (!skb_cloned(skb)) {
		if (tailen <= skb_tailroom(skb)) {
			nfrags = 1;
			trailer = skb;
			tail = skb_tail_pointer(trailer);

			goto skip_cow;
		} else if ((skb_shinfo(skb)->nr_frags < MAX_SKB_FRAGS)
			   && !skb_has_frag_list(skb)) {
			int allocsize;
			struct sock *sk = skb->sk;
			struct page_frag *pfrag = &x->xfrag;

			esp->inplace = false;

			allocsize = ALIGN(tailen, L1_CACHE_BYTES);

			spin_lock_bh(&x->lock);

			if (unlikely(!skb_page_frag_refill(allocsize, pfrag, GFP_ATOMIC))) {
				spin_unlock_bh(&x->lock);
				goto cow;
			}

			page = pfrag->page;
			get_page(page);

			tail = page_address(page) + pfrag->offset;

			esp_output_fill_trailer(tail, esp->tfclen, esp->plen, esp->proto);

			nfrags = skb_shinfo(skb)->nr_frags;

			__skb_fill_page_desc(skb, nfrags, page, pfrag->offset,
					     tailen);
			skb_shinfo(skb)->nr_frags = ++nfrags;

			pfrag->offset = pfrag->offset + allocsize;

			spin_unlock_bh(&x->lock);

			nfrags++;

			skb->len += tailen;
			skb->data_len += tailen;
			skb->truesize += tailen;
			if (sk && sk_fullsock(sk))
				refcount_add(tailen, &sk->sk_wmem_alloc);

			goto out;
		}
	}

cow:
	esph_offset = (unsigned char *)esp->esph - skb_transport_header(skb);

	nfrags = skb_cow_data(skb, tailen, &trailer);
	if (nfrags < 0)
		goto out;
	tail = skb_tail_pointer(trailer);
	esp->esph = (struct ip_esp_hdr *)(skb_transport_header(skb) + esph_offset);

skip_cow:
	esp_output_fill_trailer(tail, esp->tfclen, esp->plen, esp->proto);
	pskb_put(skb, trailer, tailen);

out:
	return nfrags;
}
EXPORT_SYMBOL_GPL(esp6_output_head);

Có 3 nhánh xử lý chính:

  1. Tailroom còn đủ (tailen <= skb_tailroom(skb)), điền luôn trailer vào tailroom.
  2. Tailroom ko đủ, cấp phát page fragment mới qua skb_page_frag_refill() rồi gắn page mới vào skb_shinfo->frags[] nếu số fragment chưa đặt max và frag_list là NULL (là một chain các sk_buff khác đc đính vào cuối, dùng cho IP fragmentation, khi 1 gói IP lớn bị tách thành nhiều fragment), nếu frag_list khác NULL cần cow.
  3. Nếu skb là cloned (nhiều sk_buff cùng chia sẻ chung data buffer, dùng khi kernel cần giữ bản copy của gói tin, ví dụ khi cần gửi cùng 1 data đến nhiều interface,…) -> ko đc sửa trực tiếp data buffer -> cần copy riêng ra trước rồi mới ghi vào bản copy.

Ở nhánh 2, page được cấp phát bởi hàm skb_page_frag_refill():

bool skb_page_frag_refill(unsigned int sz, struct page_frag *pfrag, gfp_t gfp)
{
	if (pfrag->page) {
		if (page_ref_count(pfrag->page) == 1) {
			pfrag->offset = 0;
			return true;
		}
		if (pfrag->offset + sz <= pfrag->size)
			return true;
		put_page(pfrag->page);
	}

	pfrag->offset = 0;
	if (SKB_FRAG_PAGE_ORDER &&
	    !static_branch_unlikely(&net_high_order_alloc_disable_key)) {
		/* Avoid direct reclaim but allow kswapd to wake */
		pfrag->page = alloc_pages((gfp & ~__GFP_DIRECT_RECLAIM) |
					  __GFP_COMP | __GFP_NOWARN |
					  __GFP_NORETRY,
					  SKB_FRAG_PAGE_ORDER);
		if (likely(pfrag->page)) {
			pfrag->size = PAGE_SIZE << SKB_FRAG_PAGE_ORDER;
			return true;
		}
	}
	pfrag->page = alloc_page(gfp);
	if (likely(pfrag->page)) {
		pfrag->size = PAGE_SIZE;
		return true;
	}
	return false;
}
EXPORT_SYMBOL(skb_page_frag_refill);

Để ý ở esp6_output_head(), skb_page_frag_refill() nhận vào szallocsize, là tailen căn chỉnh đến L1_CACHE_BYTES (64 bytes).

Nếu như reference count của pfrag->page (là x->flags đc truyền vào) = 1 thì return true luôn mà ko kiểm tra kích thước sz đc nhận. Nếu ref count != 1 và sz lớn hơn phần còn trống, sẽ đi vào nhánh cấp phát page với order là SKB_FRAG_PAGE_ORDER:

#define SKB_FRAG_PAGE_ORDER     get_order(32768)

32768KB = 8 * 4096 -> page order-3. Nhưng ở đây vẫn ko kiểm tra sz, nếu sz truyền vào lớn hơn 32KB thì có thể dẫn đến OOB read/write sau này. Mà sz đc truyền vào ở đây là tailen:

allocsize = ALIGN(tailen, L1_CACHE_BYTES);

spin_lock_bh(&x->lock);

if (unlikely(!skb_page_frag_refill(allocsize, pfrag, GFP_ATOMIC))) {
	spin_unlock_bh(&x->lock);
	goto cow;
}

Theo code và layout trước đó, tailen = tfclen + plen + alen. Về plenalen thì rất nhỏ, plen chỉ vài byte, alen chỉ vài chục byte. Còn tfclen thì cũng bị giới hạn ở esp6_output() nên ko thể lớn tuỳ ý:

esp.tfclen = 0;
if (x->tfcpad) {
	struct xfrm_dst *dst = (struct xfrm_dst *)skb_dst(skb);
	u32 padto;

	padto = min(x->tfcpad, __xfrm_state_mtu(x, dst->child_mtu_cached));
	if (skb->len < padto)
		esp.tfclen = padto - skb->len;
}

Vậy tailen ở đây ko đủ để gây OOB.

Root cause

Tiếp tục quan sát esp6_output_tail()skb_page_frag_refill() cũng đc gọi. Hàm này thực hiện chức năng mã hoá, có 2 trường hợp ứng với esp->inplace = true hoặc false.

Hàm khởi tạo các scatterlist (cách kernel mô tả packet ko liên tục trong RAM, layout thì cũng khá giống một page fragment), sg - danh sách các vùng bộ nhớ chứa plaintext đc đọc, dsg - danh sách các vùng bộ nhớ sẽ đc ghi ciphertext vào.

Trường hợp inplace = true, mã hoá trực tiếp trên cùng buffer nên sg = dsg. Ngược lại, sgdsg là 2 scatterlist khác nhau. Tuy nhiên, mặc dù inplace = false, vùng linear vẫn đc mã hoá tại chỗ, chỉ khác nhau ở phần page frag.

Ở đây ta chỉ quan tâm tới inplace = false, vì khi đó skb_page_frag_refill() đc gọi để cấp phát page frag mới cho dsg.

int esp6_output_tail(struct xfrm_state *x, struct sk_buff *skb, struct esp_info *esp)
{
	u8 *iv;
	int alen;
	void *tmp;
	int ivlen;
	int assoclen;
	int extralen;
	struct page *page;
	struct ip_esp_hdr *esph;
	struct aead_request *req;
	struct crypto_aead *aead;
	struct scatterlist *sg, *dsg;
	struct esp_output_extra *extra;
	int err = -ENOMEM;

	assoclen = sizeof(struct ip_esp_hdr);
	extralen = 0;

	if (x->props.flags & XFRM_STATE_ESN) {
		extralen += sizeof(*extra);
		assoclen += sizeof(__be32);
	}

	aead = x->data;
	alen = crypto_aead_authsize(aead);
	ivlen = crypto_aead_ivsize(aead);

	tmp = esp_alloc_tmp(aead, esp->nfrags + 2, extralen);
	if (!tmp)
		goto error;

	extra = esp_tmp_extra(tmp);
	iv = esp_tmp_iv(aead, tmp, extralen);
	req = esp_tmp_req(aead, iv);
	sg = esp_req_sg(aead, req);

	if (esp->inplace)
		dsg = sg;
	else
		dsg = &sg[esp->nfrags];

	esph = esp_output_set_esn(skb, x, esp->esph, extra);
	esp->esph = esph;

	sg_init_table(sg, esp->nfrags);
	err = skb_to_sgvec(skb, sg,
		           (unsigned char *)esph - skb->data,
		           assoclen + ivlen + esp->clen + alen);
	if (unlikely(err < 0))
		goto error_free;

	if (!esp->inplace) {
		int allocsize;
		struct page_frag *pfrag = &x->xfrag;

		allocsize = ALIGN(skb->data_len, L1_CACHE_BYTES);

		spin_lock_bh(&x->lock);
		if (unlikely(!skb_page_frag_refill(allocsize, pfrag, GFP_ATOMIC))) {	
			spin_unlock_bh(&x->lock);
			goto error_free;
		}

		skb_shinfo(skb)->nr_frags = 1;

		page = pfrag->page;
		get_page(page);
		/* replace page frags in skb with new page */
		__skb_fill_page_desc(skb, 0, page, pfrag->offset, skb->data_len);
		pfrag->offset = pfrag->offset + allocsize;
		spin_unlock_bh(&x->lock);

		sg_init_table(dsg, skb_shinfo(skb)->nr_frags + 1);
		err = skb_to_sgvec(skb, dsg,
			           (unsigned char *)esph - skb->data,
			           assoclen + ivlen + esp->clen + alen);
		if (unlikely(err < 0))
			goto error_free;
	}

	if ((x->props.flags & XFRM_STATE_ESN))
		aead_request_set_callback(req, 0, esp_output_done_esn, skb);
	else
		aead_request_set_callback(req, 0, esp_output_done, skb);

	aead_request_set_crypt(req, sg, dsg, ivlen + esp->clen, iv);
	aead_request_set_ad(req, assoclen);

	memset(iv, 0, ivlen);
	memcpy(iv + ivlen - min(ivlen, 8), (u8 *)&esp->seqno + 8 - min(ivlen, 8),
	       min(ivlen, 8));

	ESP_SKB_CB(skb)->tmp = tmp;
	err = crypto_aead_encrypt(req);

	switch (err) {
	case -EINPROGRESS:
		goto error;

	case -ENOSPC:
		err = NET_XMIT_DROP;
		break;

	case 0:
		if ((x->props.flags & XFRM_STATE_ESN))
			esp_output_restore_header(skb);
		esp_output_encap_csum(skb);
	}

	if (sg != dsg)
		esp_ssg_unref(x, tmp);

	if (!err && x->encap && x->encap->encap_type == TCP_ENCAP_ESPINTCP)
		err = esp_output_tail_tcp(x, skb);

error_free:
	kfree(tmp);
error:
	return err;
}
EXPORT_SYMBOL_GPL(esp6_output_tail);

sz đc truyền vào skb_page_frag_refill()skb->data_len. Màskb->data_len là kích thước của phần paged data, có thể chứa dữ liệu gốc của gói tin, vậy nên attacker có thể kiểm soát kích thước payload tuỳ ý > 32KB, có thể gây OOB read/write sau này vì skb_page_frag_refill() không kiểm tra sz.

Sau khi cấp phát như vậy, attacker có một page frag dù khai báo với kích thước > 32KB, nhưng backing memory chỉ có 32KB tại:

static inline void __skb_fill_page_desc(struct sk_buff *skb, int i,
					struct page *page, int off, int size)
{
	skb_frag_t *frag = &skb_shinfo(skb)->frags[i];

	/*
	 * Propagate page pfmemalloc to the skb if we can. The problem is
	 * that not all callers have unique ownership of the page but rely
	 * on page_is_pfmemalloc doing the right thing(tm).
	 */
	frag->bv_page		  = page;
	frag->bv_offset		  = off;
	skb_frag_size_set(frag, size);

	page = compound_head(page);
	if (page_is_pfmemalloc(page))
		skb->pfmemalloc	= true;
}

Ví dụ chỉ có 1 page frag sgdsg trông như sau:

  • sg[0] -> linear part của skb, sg[1] -> trailer (page frag) cũ.
  • dsg[0]-> linear part như sg[0], dsg[1] -> page frag mới kích thước 32KB nhưng mô tả lớn hơn.

Vậy nên điều kiện để inplace = false là trước đó tại esp6_output_head() cần phải đi vào nhánh 2 để cấp phát page fragment, nếu ko, mặc định khi vào hàm esp6_output(), esp->inplace = true.

int allocsize;
struct sock *sk = skb->sk;
struct page_frag *pfrag = &x->xfrag;

esp->inplace = false;

allocsize = ALIGN(tailen, L1_CACHE_BYTES);

spin_lock_bh(&x->lock);

if (unlikely(!skb_page_frag_refill(allocsize, pfrag, GFP_ATOMIC))) {
	spin_unlock_bh(&x->lock);
	goto cow;
}

Vậy tailen cần lớn hơn tailroom để inplace = false xảy ra.

Thế còn OOB read/write sau đó như thế nào? Sau khi set sgdsg sẽ thực hiện mã hoá qua hàm crypto_aead_encrypt(req):

int crypto_aead_encrypt(struct aead_request *req)
{
	struct crypto_aead *aead = crypto_aead_reqtfm(req);
	struct crypto_alg *alg = aead->base.__crt_alg;
	unsigned int cryptlen = req->cryptlen;
	int ret;

	crypto_stats_get(alg);
	if (crypto_aead_get_flags(aead) & CRYPTO_TFM_NEED_KEY)
		ret = -ENOKEY;
	else
		ret = crypto_aead_alg(aead)->encrypt(req);
	crypto_stats_aead_encrypt(cryptlen, alg, ret);
	return ret;
}
EXPORT_SYMBOL_GPL(crypto_aead_encrypt);

Tại đây gọi đến crypto_aead_alg(aead)->encrypt(req). Để kiểm soát được dữ liệu OOB tốt nhất, ta có thể dùng null cipher, mã hoá bằng cách copy y nguyên. Khi đó sink cuối cùng đc gọi đến là tại:

static int null_skcipher_crypt(struct skcipher_request *req)
{
	struct skcipher_walk walk;
	int err;

	err = skcipher_walk_virt(&walk, req, false);

	while (walk.nbytes) {
		if (walk.src.virt.addr != walk.dst.virt.addr)
			memcpy(walk.dst.virt.addr, walk.src.virt.addr,
			       walk.nbytes);
		err = skcipher_walk_done(&walk, 0);
	}

	return err;
}

memcpy() sẽ copy tối đa từng page 4KB từ source đến dest, dest ở đây chính là order-3 page mà ta đã cấp phát tại esp6_output_tail() khi inplace = false. Vậy ta có được OOB write trên heap.

Exploitation

Giả sử chúng ta có thể overflow đến 8 page (32KB) ra ngoài order-3 page đc cấp phát (gọi là victim page), ta có thể overwrite gì để trước hết bypass đc KASLR? Một hướng đó là đặt các struct msg_msg ngay sau victim page, và ghi đè trường m_ts thành giá trị lớn hơn kích thước message ban đầu:

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 */
};

Lúc này, ta có đc OOB read và có khả năng leak được địa chỉ heap hoặc kernel ở sau struct msg_msg hiện tại hoặc sau msg_msgseg qua con trỏ next. Nhưng msg_msg được cấp phát qua slab allocator, cùng lắm có kích thước là 1 page 4KB, vậy làm sao có thể đặt một struct msg_msg vào order-3 page ngay sau victim page? Ta cần spray cho đến khi cạn slabs trong cache đc dùng để cấp phát cho msg_msg, khi đó cần xin slab mới có kích thước 32KB từ buddy allocator, gọi là msg page. Các cache sử dụng slab kích thước 32KB đó là:

kmalloc-8k           308    308   8192    4    8 : tunables    0    0    0 : slabdata     77     77      0
kmalloc-4k           587    608   4096    8    8 : tunables    0    0    0 : slabdata     76     76      0
kmalloc-2k          1082   1152   2048   16    8 : tunables    0    0    0 : slabdata     72     72      0
kmalloc-1k          1824   1824   1024   32    8 : tunables    0    0    0 : slabdata     57     57      0

Vậy ta sẽ spray vào kmalloc-4k với kích thước của msg_msg lớn hơn 4KB để msg_msgseg *next được trỏ vào object trong slab của kmalloc-32 (mà ta đã spray seq_operation) để có khả năng leak kernel address:

struct seq_operations {
	void * (*start) (struct seq_file *m, loff_t *pos);
	void (*stop) (struct seq_file *m, void *v);
	void * (*next) (struct seq_file *m, void *v, loff_t *pos);
	int (*show) (struct seq_file *m, void *v);
};

Nhưng vấn đề là ta ko thể ghi đè m_ts 1 cách thoải mái mà vẫn giữ được nextesp6_output_head() điền trailer, gây lệch dữ liệu ở cuối payload:

static inline void esp_output_fill_trailer(u8 *tail, int tfclen, int plen, __u8 proto)
{
	/* Fill padding... */
	if (tfclen) {
		memset(tail, 0, tfclen);
		tail += tfclen;
	}
	do {
		int i;
		for (i = 0; i < plen - 2; i++)
			tail[i] = i + 1;
	} while (0);
	tail[plen - 2] = plen - 2;
	tail[plen - 1] = proto;
}

Nếu ta ghi đè luôn next thành NULL thì ko còn gì để leak kernel nữa. Vậy trước khi leak kernel, ta nghĩ đến leak heap, cụ thể là leak con trỏ next của msg_msg. Ta sẽ chia ra từng phase, leak heap -> leak kernel -> arbitrary write bằng cách kết hợp msg_msg với FUSE.

Nhưng trước khi leak heap, ta cần phải groom heap đã, thì mới có khả năng lấy các page order-3 liền nhau từ buddy allocator.

Setup - Heap grooming

Vì chúng ta đang target order-3, nên cần phải giảm thiểu noise nhất có thể, vì kernel cấp phát và giải phóng liên tục, nên khó mà 2 lần cấp phát đưa ra 2 page order-3 liền nhau. Để có 2 page order-3 liền nhau, thì order-3 freelist phải trống, dẫn đến lấy 1 page từ order-4 freelist và cắt làm đôi cho 2 cấp phát. Để order-3 freelist gần như luôn ở trạng thái trống, ta phải làm cạn nó, và tránh các chunk đc giải phóng vào freelist. Ta ko thể tránh kernel cấp phát và giải phóng các page order-3, nhưng có thể tránh các page order-2 liền nhau bị merge vào order-3, và order-2 bị cạn nên lấy chunk từ order-3. Kế hoạch đó là:

  • Spray order-2, làm cho freelist order-2 bị cạn và lấy N chunk từ order-3 freelist.
  • Giải phóng N/2 chunk xen kẽ trong N chunk đó để làm đầy order-2 freelist, giữ lại 1 nửa, dẫn đến ko có 2 page order-2 nào cạnh nhau để merge vào order-3.
  • Order-2 freelist bây giờ luôn đầy, nên giảm thiểu khả năng freelist bị trống và xin page từ order-3 freelist.

Cuối cùng làm cạn sạch order-3 freelist.

Phase 1 - Heap Leak

Mục tiêu bây giờ là leak đc 1 con trỏ next của msg_msg mà ngay sau object msg_msgsegnext trỏ tới là seq_operations (để sau này OOB read để leak kernel).

Ý tưởng của tác giả đó là cấp phát 3 order-3 page liên tiếp. Lần lượt dành cho ESP payload, user_key_payloadmsg_msg, vì họ đã tìm được object user_key_payload có layout như sau:

struct user_key_payload {
	struct rcu_head	rcu;		/* RCU destructor */
	unsigned short	datalen;	/* length of this data */
	char		data[] __aligned(__alignof__(u64)); /* actual data */
};

Có thể ghi toàn bộ rcu về NULL, ghi vào data không vấn đề gì -> có thể ghi datalen tuỳ ý để OOB write khi đọc data. Vậy kế hoạch là OOB write từ victim page để ghi đè datalen để OOB read con trỏ next của các struct msg_msg trong order-3 page ngay liền sau đó. Và lưu ý rằng, các msg_msg này đã được spray với size vừa đủ để next trỏ đến nơi object msg_msgsegseq_operations ngay liền sau trong kmalloc-32.

Nhưng điều kiện để có 3 page order-3 liền nhau đó là order-3 freelist và order-4 freelist đều phải trống, để lấy một order-5 freelist, rồi mới có thể split thành 3 page order-3 liền nhau. Vì thế cần spray order-3 hơn mức cần thiết.

Phase 2 - Kernel leak

Giờ đã có con trỏ next, chúng ta thực hiện ý tưởng ban đầu là OOB write trường m_ts của msg_msg trong order-3 page liền sau của victim page, để có kernel leak.

Phase 3 - Arbitrary write

Để có đc arb write, chúng ta tiếp tục lợi dụng con trỏ next của msg_msg khi vừa mới tạo msg bằng hàm msgsnd(), khi đó tại kernel thực thi hàm load_msg():

struct msg_msg *load_msg(const void __user *src, size_t len)
{
	struct msg_msg *msg;
	struct msg_msgseg *seg;
	int err = -EFAULT;
	size_t alen;

	msg = alloc_msg(len);
	if (msg == NULL)
		return ERR_PTR(-ENOMEM);

	alen = min(len, DATALEN_MSG);
	if (copy_from_user(msg + 1, src, alen))
		goto out_err;

	for (seg = msg->next; seg != NULL; seg = seg->next) {
		len -= alen;
		src = (char __user *)src + alen;
		alen = min(len, DATALEN_SEG);
		if (copy_from_user(seg + 1, src, alen))
			goto out_err;
	}

	err = security_msg_msg_alloc(msg);
	if (err)
		goto out_err;

	return msg;

out_err:
	free_msg(msg);
	return ERR_PTR(err);
}

Ta sẽ mmap 1 page bình thường và FUSE page đặt gần cuối buffer như sau:

                          blocked, spray + overwrite next ptr, resume                            
       ┌───────────────────────────────────────┬─────────────────────────────────────────┐    
src ─► │ normal page                           │ FUSE page                               │    
       │                                       │                                         │    
       └───────────────────────────────────────┴─────────────────────────────────────────┘    
              |◄──────────────────────────────────►|◄───────────────────────────────────►|       
              | copy_from_user(msg + 1, src, alen) | copy_from_user(seg + 1, src, alen)  |       
              ┌────────────────────────────────────┬─────────────────────────────────────┐       
dst ────────► │ msg_msg struct                 |   │ modprobe_path                       │       
              │                                |   │                                     │       
              └────────────────────────────────────┴─────────────────────────────────────┘       
                                                  next                                           

Khi copy_from_user(msg + 1, src, alen) vào msg_msg và chạm trang FUSE, kernel bị treo lại, chờ FUSE handler trả data. Trong lúc kernel đang trao, một thread khác spray và OOB write con trỏ next trỏ đến địa chỉ của modprobe_path - 8 (vì msg_msgseg cũng có con trỏ next ở đầu). OOB write xong, resume FUSE, kernel đọc phải next đã bị ghi đè, tiếp tục copy_from_user(seg + 1, src, alen) payload của attacker trả về qua handler thẳng vào modprobe_path.

LPE

Cuối cùng, ta thực thi 1 file với magic number ko hợp lệ -> binary tại modprobe_path được thực thi với quyền root -> leo quyền thành công.

PoC

Đây là link tới PoC của mình: https://github.com/ngtuonghung/CVE-2022-27666, khả năng thành công chỉ khoảng 70%, đôi lúc sẽ hang 1 lúc và crash luôn kernel. Mình nghĩ nguyên nhân đó là với những failed attempt, OOB write có thể ghi đè vào object quan trọng nào đó của kernel và gây crash.

Demo:

References

  1. https://etenal.me/archives/1825
  2. https://docs.kernel.org/networking/skbuff.html
  3. http://oldvger.kernel.org/~davem/skb_data.html
  4. https://a13xp0p0v.github.io/2021/02/09/CVE-2021-26708.html
  5. https://elixir.bootlin.com/linux/v5.13.19/source
  6. https://syst3mfailure.io/linux-page-allocator/
  7. Claude Code, Codex,… :)