mremap() TLB issue (CVE-2018-18281)¶
Linux 3.2'den beri
mremap(), TLB'yi page-table lock'u bıraktıktan sonra flush'lıyordu; eşzamanlı birftruncate()o pencere içinde bir page'i free edebilir ve geride, artık free edilmiş (ve yeniden tahsis edilmiş) physical page'i hâlâ map'leyen stale bir TLB entry bırakır — userspace'ten erişilebilen physical memory'nin bir use-after-free'i.
Mechanism¶
Neden çalışır
TLB, virtual→physical translation'ları cache'ler. Kernel'in page-table entry'lerini taşımak/kaldırmak için uyguladığı güvenlik kuralı şudur: PTE'yi kaldır, physical page'e bir reference tut, TLB'yi flush'la ve ancak ondan sonra reference'ı bırak. Eğer page reference'ını (page'in free edilmesine izin vererek) TLB'yi flush'lamadan önce bırakırsan, eski translation'ı hâlâ cache'inde tutan herhangi bir CPU, frame geri dönüştürüldükten sonra o physical frame'i okuyabilir/yazabilir.
mremap() bir VMA'nın PTE'lerini relocate eder:
move_ptes(), her source PTE'yi atomik olarak read-and-clear etmek için
ptep_get_and_clear() kullanır, onu destination'a yazar, ardından TLB'yi
flush'lamadan önce page-table lock'u bırakıyordu. Kritik nokta şu:
mremap() yalnızca mmap_sem tutar — bu da preemption'ı engellemez ve
page-cache truncation path'ini bloklamaz.
Bir dosyayı küçülten eşzamanlı bir ftruncate(), aynı address space'in page
table'larını yalnızca page-table lock'ları tutarak (yani mmap_sem
tutmadan) dolaşır. Dolayısıyla ftruncate(), mremap() ile race'e girebilir:
mremap()'in source PTE'lerden zaten temizlediği ama henüz TLB'den
flush'lamadığı page'leri kaldırır/free eder. Page, per-CPU page allocator'a
geri döner ve yeniden tahsis edilir; bu sırada race'e giren CPU üzerindeki stale
bir TLB entry hâlâ ona işaret eder.
Bu stale entry üzerinden userspace şunları yapabilir:
- free edilmiş page'in yeniden kullanıldıktan sonraki yeni içeriğini okuyabilir (info leak), ve
- şimdi frame'e sahip olan nesneyi bozmak için onun üzerinden yazabilir.
Pencere normalde çok küçüktür, ancak CONFIG_PREEMPT kernel'lerde (örneğin Pixel 2'nin 4.4 branch'i) task, unlock'tan sonra ama flush'tan önce preempt edilebilir; bu da pencereyi milisaniyelere genişleterek deterministik biçimde sürülecek kadar büyütür. Jann Horn bug'ı raporladı ve test etti.
Walkthrough¶
Yalnızca yetkili test
Yalnızca sahip olduğun bir kernel/VM üzerinde reproduce et. Aşağıdaki teknik, Project Zero'nun analizini ve PoC stratejisini başka kelimelerle aktarır; bu bir race olduğu için başarı scheduling'e bağlıdır.
1. Flush'lanmamış TLB penceresini belirle. Açığa yol açan sıralama
move_ptes() içindedir: PTE'ler temizlenip yeniden yerleştirilir, ardından lock
bırakılır, sonra (çok geç olarak) TLB flush'lanır. İş, bu boşluk sırasında bir
source page'i free etmektir.
2. Kontrollü preemption kur. Project Zero'nun PoC'si, race'e giren task'leri
tek bir core'a pin'ler ve mremap() task'inin priority'sini düşürerek kolayca
yield etmesini sağlar:
// pin processes to one CPU and run the mremap task at idle priority
cpu_set_t set; CPU_ZERO(&set); CPU_SET(0, &set);
sched_setaffinity(0, sizeof(set), &set);
struct sched_param sp = {0};
sched_setscheduler(0, SCHED_IDLE, &sp); // yields to any higher-prio task
3. Page-table allocation'ını procfs üzerinden tespit et. mremap()'in
destination page table'larını ne zaman tahsis ettiğini (yani race için doğru
fazda olduğunu) öğrenmek için VmPTE alanını poll et:
4. Race'i tetikle. mremap() operasyon ortasında duraklatılmışken (unlock'tan
sonra, flush'tan önce preempt edilmişken), source page'leri free etmek için onları
besleyen dosya üzerinde ftruncate() çağır; tam o anda higher-priority task'i
uyandırmak için bir pipe write kullanılır. Free edilmiş page daha sonra per-CPU
freelist'in cold ucundan yeniden tahsis edilir.
5. Stale mapping'i tespit et ve frame'i yeniden kullan. Eski virtual address
üzerinden tekrar tekrar oku; free edilmiş frame, hedef bir page-cache page'ine
yeniden tahsis edildiğinde (Project Zero, zygote tarafından kullanılan
libandroid_runtime.so'nun nativeForkAndSpecialize()'ini hedef aldı), stale TLB
entry o page'i açığa çıkarır — ve üzerine yazmana izin verir — bu da privileged bir
process'e code injection sağlar.
Observable success: reads through the old VA return contents that no longer
belong to the mremap'd region (the reused frame), and writes through it
persist into the reallocated page.
Detection¶
- Race, tasarımı gereği sessizdir; instrumented kernel'lerde TLB state ile PTE
state arasındaki uyumsuzluklar normalde raporlanmaz. Tespit, davranışsal
düzeyde pratiktir:
mremap(),ftruncate(),SCHED_IDLEaffinity pinning ve/proc/self/statusVmPTEpolling'ini sıkı sıkıya iç içe geçiren unprivileged bir process anormal bir pattern'dir. - KASAN, yeniden kullanılan frame'in bazı reclaim path'lerinde kullanımını yakalayabilir.
Mitigation¶
- Patch: upstream commit
eb66ae030829605d61fbef1909ce310e29f78821("mremap: properly flush TLB before releasing the page") ile 4.9.135, 4.14.78, 4.18.16 ve 4.19'da düzeltildi. Fix,flush_tlb_range()'i destination page-table lock bırakılmadan önce çalışacak şekilde taşır:
// ... after moving the PTEs ...
if (force_flush)
flush_tlb_range(vma, old_end - len, old_end);
if (new_ptl != old_ptl)
spin_unlock(new_ptl); // TLB now flushed BEFORE unlock
Jann Horn, flush'ın yalnızca source lock'tan değil, destination page-table
lock'tan da önce gelmesi gerektiğine dikkat çekti. move_ptes(),
move_huge_pmd() ve move_page_tables() buna göre düzenlendi.
- Underlying TLB-flush-after-unlock davranışı Linux 3.2'den beri mevcuttu
(NVD/oss-security), dolayısıyla 2016 öncesi kernel'ler de etkilenmiştir. Kasım
2016'daki 5d1904204c99 (ilk olarak v4.9) commit'i bug'ı getirmedi; yalnızca
exploitability'yi değiştirdi: bu commit'ten önce race penceresi non-dirty PTE'ler
için de use-after-free write'lara açıktı, sonrasında ise Dirty olmayan
PTE'lerde pencere use-after-free read'lerle sınırlandı (write yalnızca Dirty
PTE üzerinden). CVE, 4.9.135, 4.14.78, 4.18.16 ve 4.19'da düzeltildi.