Adım Adım Linux Kernel Exploitation - 2

Selamlar.

Adım Adım Linux Kernel Exploitation serimize kaldığımız yerden devam ediyoruz. Geçen bölümde amacımızı özetlemiş ve lab kurulumu yapmıştık. Bu bölüm biraz sıkıcı olabilir zira ellerimizi kirletmeden önce CVE-2017-11176 analizinin ilk satırında kaybolmamak için, Linux kernel’ının bazı temel kavramlarını tanıtmak gerekmektedir. Burada gösterilen yapıların çoğunun kolay/basit anlaşılabilmesi için eksik bırakıldığını söylemeliyim. (Konunun çok daha iyi anlaşılabilmesi için (gerçek) temel seviyede C, Assembly ve Linux yapısı öğrenilmelidir.)

TEMEL KAVRAMLAR

Kernel’daki en önemli yapılardan biri struct task_struct’tır, ancak anlaşılması biraz zaman alabilir.

Her görevin bellekte yaşayan bir task_struct nesnesi vardır. Kullanıcı alanı süreci, en az bir process’ten (görev/işlem) oluşur. Çok iş parçacıklı bir uygulamada, her iş parçacığı için bir task_struct vardır ve kernel iş parçacıkları ayrıca kendi task_struct’larına sahiptir. (ör. kworker, migration).

task_struct aşağıdaki gibi önemli bilgileri tutar:

// [include/linux/sched.h]

struct task_struct {
    volatile long state;            // işlem durumu (çalışıyor, durmuş, ...)
    void *stack;                    // görevin yığın işaretçisi
    int prio;                       // işlem önceliği
    struct mm_struct *mm;           // bellek adres alanı
    struct files_struct *files;     // açık dosya bilgisi
    const struct cred *cred;        // kimlik bilgileri
  // ...
};

O an çalışan process’e erişmek o kadar yaygın bir işlemdir ki, üzerinde bir pointer (işaretçi) almak için bir makro bile vardır: current.

Dosya Tanımlayıcı, Dosya Nesnesi ve Dosya Tanımlayıcı Tablosu

Herkes Linux’ta “her şeyin bir dosya olduğunu” bilir, ama aslında bu cümle tam olarak ne anlama gelmektedir?

Linux kernel’ında temel olarak yedi tür dosya vardır: Normal dosya, dizin, bağlantı, karakter aygıtı, blok aygıtı, fifo ve socket. Her biri bir dosya tanımlayıcıyla temsil edilebilir. Bir dosya tanımlayıcı, temelde yalnızca belirli bir işlem için anlamlı olan integer veri türlü bir değişkendir. Her dosya tanımlayıcı için o dosya tanımlayıcıya ilişkili bir yapı vardır: struct file.

Yapı dosyası (veya dosya nesnesi), açılmış bir dosyayı temsil eder. Diskteki herhangi bir imaj ile eşleşmesi gerekmez. Örneğin, /proc gibi sözde dosya sistemlerindeki dosyalara erişmeyi düşünün. Bir dosyayı okurken, sistemin imleci takip etmesi gerekebilir. Bu bir yapı dosyasında saklanan bilgi türlerinden birisidir. Yapı dosyasına yönelik işaretçiler genellikle filp (file pointer, dosya işaretçisi) olarak adlandırılır.

Bir yapı dosyasının en önemli alanları şunlardır:

// [include/linux/fs.h]

struct file {
    loff_t                            f_pos;            // dosyayı okuduğu sırada "işaretçi"
    atomic_long_t                     f_count;          // nesnenin referans sayacı
    const struct file_operations      *f_op;            // sanal işlev tablosu (VFT) işaretçisi
  void                              *private_data;      // "specialization" dosyası tarafından kullanılır
  // ...
};

Bir dosya tanımlayıcısını yapı dosyası işaretçisine çeviren eşlemeye, dosya tanımlayıcı tablosu (fdt-file descriptor table) adı verilir. Bunun bire bir eşleme olmadığına dikkat edin, aynı dosya nesnesine işaret eden birkaç dosya tanımlayıcı olabilir. Bu durumda işaret edilmiş dosya nesnesinin referans sayacı bir artar (bknz. Referans Sayaçları). FDT, struct fdtable adlı bir yapıda depolanır. Bu aslında sadece bir dosya tanıtıcısı ile indekslenebilen bir dizi yapı dosyası işaretçisidir.

// [include/linux/fdtable.h]

struct fdtable {
    unsigned int max_fds;
    struct file ** fd;      /* geçerli fd dizisi */
  // ...
};

Bir dosya tanımlayıcı tablosunu bir işleme bağlayan şey, struct files_struct’tır. fdtable’ın bir task_struct içine doğrudan gömülmemesinin nedeni, task_struct 'ın başka bilgileri tutmasıdır. Bir struct files_struct aynı zamanda çoklu iş parçacıkları (ör. task_struct ) arasında paylaşılabilir ve bazı optimizasyon numaraları da bulunmaktadır.

// [include/linux/fdtable.h]

struct files_struct {
    atomic_t count;           // referans sayacı
    struct fdtable *fdt;      // dosya tanımlayıcı tablosunun işaretçisi
  // ...
};

Bir files_struct işaretçisi, task_struct’ta (alan dosyaları) saklanmaktadır.

VFT (Sanal Fonksiyon Tablosu)

Linux, çoğunlukla C’de uygulansa da, nesne yönelimli bir kernel olmaya devam ediyor.

Belli oranlarda jeneriklik elde etmenin bir yolu, bir sanal işlev tablosu (virtual function table-vft) kullanmaktır. Sanal fonksiyon tablosu, çoğunlukla fonksiyon işaretçilerinden oluşan bir yapıdır.

En çok bilinen VFT, struct file_processs’dir:

// [include/linux/fs.h]

struct file_operations {
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
  // ...
};

Her şey bir dosya olduğundan ancak aynı türde olmadığından, hepsinin genellikle f_ops adı verilen farklı dosya işlemleri vardır. Bunu yapmak, çekirdek kodunun dosyayı türlerinden ve kod çarpanlarına ayırmasından bağımsız olarak işlemesine olanak tanır.

Bu durum da aşağıdaki türde bir kodun oluşturulabilmesine olanak tanır:

if (file->f_op->read)
            ret = file->f_op->read(file, buf, count, pos);

Socket, Sock ve SKB

Bir yapı soketi (struct socket), ağ yığınının en üst katmanında bulunur. Dosya açısından bakıldığında, bu birinci özelllik seviyesidir. Soket oluşturma (socket() syscall) sırasında, yeni bir yapı dosyası oluşturulur ve dosya işlemi (alan f_op) socket_file_ops olarak ayarlanır.

Her dosya bir dosya tanımlayıcı ile temsil edildiğinden, bir dosya tanımlayıcısını parametre olarak alan herhangi bir sistem çağrısını (örn. read(), write(), close()) bir soket dosya tanımlayıcısıyla kullanabilirsiniz. Aslında “her şey bir dosyadır” mottosunun asıl faydası budur. Kernel, soketin türünden bağımsız olarak, jenerik soket dosyası işlemini başlatır:

// [net/socket.c]

static const struct file_operations socket_file_ops = {
    .read = sock_aio_read,      // <---- calls sock->ops->recvmsg()
    .write =    sock_aio_write, // <---- calls sock->ops->sendmsg()
    .llseek =   no_llseek,      // <---- hata döndürür
  // ...
}

struct soketi aslında BSD soket API’sini (connect(), bind(), accept(), listen(), …) implemente ettiğinden, struct proto_ops türünde özel bir sanal işlev tablosu (vft) yerleştirilmiştir. Her tür soket (ör. AF_INET, AF_NETLINK ) kendi proto_op’larını uygular.

// [include/linux/net.h]

struct proto_ops {
    int     (*bind)    (struct socket *sock, struct sockaddr *myaddr, int sockaddr_len);
    int     (*connect) (struct socket *sock, struct sockaddr *vaddr,  int sockaddr_len, int flags);
    int     (*accept)  (struct socket *sock, struct socket *newsock, int flags);
  // ...
}

BSD tarzı bir sistem çağrısı çağrıldığında (örneğin, bind()), çekirdek genellikle şu şemayı takip eder:

  1. Dosya tanımlayıcı tablosundan bir yapı dosyası alır

  2. Yapı dosyasından bir yapı soketi alır

  3. Özelleştirilmiş proto_ops geri çağrılarını gerçekleştirir (ör. sock->ops->bind())

Bazı protokol işlemlerinin (ör. veri gönderme/alma) aslında ağ yığınının alt katmanına gitmesi gerekebileceğinden, struct soketinde bir struct sock nesnesine yönelik bir işaretçi bulunur. Bu işaretçi genellikle soket protokolü işlemleri (proto_ops) tarafından kullanılır. Sonuç olarak yapı soketi (struct socket), struct dosyası ile struct sock arasındaki bir tür yapıştırıcı görevi görür.

// [include/linux/net.h]

struct socket {
    struct file     *file;
    struct sock     *sk;
    const struct proto_ops  *ops;
  // ...
};

struct sock karmaşık bir veri yapısıdır. Alt katman (ağ kartı sürücüsü) ve daha yüksek seviye (soket) arasında orta seviye bir şey olarak farz edilebilir. Ana amacı, alma/gönderme arabelleklerini jenerik bir şekilde tutma yeteneğidir.

Ağ kartı üzerinden bir ağ paketi alındığında, sürücü ağ paketini sock alma arabelleğinde “sıraya alınır”. Bir program paketi okumaya almaya karar verene kadar orada kalacaktır (recvmsg() syscall). Öte yandan, bir program veri göndermek istediğinde (sendmsg() syscall), bir ağ paketi sock gönderme arabelleğinde “sıraya alınır”. Ardından network paketi yollanmak istendiğinde, ağ kartı bu paketi “sıradan çıkarır” ve gönderir.

Bu “ağ paketleri”, struct sk_buff (veya skb) olarak adlandırılır. Alma/gönderme arabellekleri temelde çift bağlantılı bir skb listesidir:

// [include/linux/sock.h]

struct sock {
    int         sk_rcvbuf;    // veri alma arabelleğinin teorik "maksimum" boyutu
    int         sk_sndbuf;    // veri gönderme arabelleğinin teorik "maks" boyutu
    atomic_t        sk_rmem_alloc;  // veri alma arabelleğinin geçerli boyutu
    atomic_t        sk_wmem_alloc;  // veri gönde arabelleğinin geçerli boyutu
    struct sk_buff_head sk_receive_queue;   // çift bağlantılı listenin başı
    struct sk_buff_head sk_write_queue;     // çift bağlantılı listenin başı
    struct socket       *sk_socket;
  // ...
}

Görüldüğü gibi, bir struct sock bir struct soketine (alan sk_socket) referans gösterirken, struct soketi bir struct sock’a (alan sk) refere edilebilir. Aynı şekilde bir yapı soketi bir yapı dosyasına (alan dosyası) başvurabilirken, yapı dosyası bir yapı soketine başvurur (alan private_data). Bu “2 yönlü mekanizma”, verilerin ağ yığını boyunca yukarı ve aşağı gitmesini sağlayan yapıyı oluşturur.

Not: Kafanız karışmış olabilir. Struct sock nesnelerine genellikle sk, struct soket nesnelerine ise genellikle sock denir.

Netlink Soketi

Netlink soketi, UNIX veya INET soketleri gibi bir soket türüdür.

Netlink soketi (AF_NETLINK), çekirdek ve kullanıcı alanı arasında iletişime izin verir. Yönlendirme tablosunu (NETLINK_ROUTE protokolü) değiştirmek, SELinux olay bildirimlerini (NETLINK_SELINUX) almak ve hatta diğer kullanıcı alanı işlemleriyle (NETLINK_USERSOCK) iletişim kurmak için kullanılabilir.

struct sock ve struct soketi her tür soketi destekleyen jenerik veri yapıları olduğundan, bir noktada onları bir şekilde ayırmak gerekir.

Soket açısından bakıldığında, proto_ops alanının tanımlanması gerekir. Netlink ailesi (AF_NETLINK) için BSD tarzı yuva işlemleri netlink_ops şeklindedir:

// [net/netlink/af_netlink.c]

static const struct proto_ops netlink_ops = {
    .bind =     netlink_bind,
    .accept =   sock_no_accept,     // <--- netlink soketlerinde accept() çağrısının yapılması EOPNOTSUPP hatasına neden oluyor.
    .sendmsg =  netlink_sendmsg,
    .recvmsg =  netlink_recvmsg,
  // ...
}

Sock açısından biraz daha karmaşık hale geldiği görülebilmekte. Bir struct sock’u soyut bir sınıf olarak görebiliriz. Bu nedenle, sock’un diğerlerinden ayrılması/özelleştirilmesi gerekmektedir. Netlink örneğinde, bu struct netlink_sock ile aşağıda belirtildiği gibi yapılabilir:

// [include/net/netlink_sock.h]

struct netlink_sock {
    /* struct sock has to be the first member of netlink_sock */
    struct sock     sk;
    u32         pid;
    u32         dst_pid;
    u32         dst_group;
  // ...
};

Başka bir deyişle netlink_sock, bazı ek özelliklere (örn. kalıtım) sahip bir "sock"tur.

Üst düzey yorum son derece önemlidir. Çekirdeğin, kesin türünü bilmese dahi genel bir yapı sock’unu değiştirmesine olanak tanır. Ayrıca üst düzey yorum &netlink_sock.sk ve &netlink_sock adresleri için takma adları avantajını da sağlar. Sonuç olarak &netlink_sock.sk işaretçisini serbest bırakmak, aslında tüm netlink_sock nesnesini serbest bırakmak anlamına gelmektedir. Bir yazılım dili teorisi perspektifinden bakıldığında, Linux kernel’ı polimorfizmi bu şekilde sağlarken, C dilinin bu gibi bir poliformizmi sağlayamadığını görürüz.

Temel Yapı İlişkisi

Artık temel veri yapıları tanıtıldığına göre, ilişkilerini görselleştirmek için hepsini bir şemaya koyabiliriz:

linux_kernel_core_struct_relationship

Her ok bir işaretçiyi (pointer) temsil eder. Hiçbir çizgi birbirini kesmez. “sock” yapısı, “netlink_sock” yapısının içine gömülmüş biçimde resmedilmiştir.

Referans Sayaçları

Temel kernel kavramlarının giriş bölümünü tamamlamak için, Linux çekirdeğinde referans sayaçlarının nasıl işlem gördüğünün anlaşılması gerekmektedir.

Çekirdekteki bellek sızıntılarını azaltmak ve use-after-free durumunu önlemek için çoğu Linux veri yapısı bir “başvuru sayacı (refcounter)” yerleştirir. Referans sayacının kendisi, integer veri yapısına sahip olan bir atomic_t türüyle temsil edilir. Refcounter yalnızca aşağıdakiler gibi atomik işlemlerle manipüle edilebilir:

  • atomic_inc()

  • atomic_add()

  • atomic_dec_and_test() // 1 çıkart ve 0’a eşit olup olmadığını kontrol et

“Akıllı işaretçi” (smart pointer) (veya operatör aşırı yükleme öğeleri) olmadığından, referans sayacı işlemleri geliştiriciler tarafından manuel olarak yapılmaktadır. Bu olay, bir nesneye başka bir nesne tarafından referans verildiğinde, referans sayacının arttırılması şeklinde meydana gelir. Bunun tersine bir referans bırakıldığında ise referans sayacının azaltılması gerekmektedir. Nesne, referans sayacı sıfıra ulaştığında genellikle serbest bırakılır.

Not: Referans sayacını artırmaya genellikle “referans alma” denirken, referans sayacını düşürmeye “referans bırakma ya da yalnızca bırakma” denir.

Bununla birlikte, herhangi bir zamanda bir dengesizlik olursa (örneğin bir referans alıp iki referans düşürürseniz), hafıza bozulması riski ortaya çıkmaktadır:

  • referans sayacı iki defa üst üste düşürüldüğünde: use-after-free durumu

  • referans sayacı iki defa üst üste arttırıldığında: use-after-free’ye yol açan başvuru sayacında bellek sızıntısı veya int-taşması

Linux kernel’ı, ortak bir arayüzle referans sayaçlarını (kref, kobject) kontrol etmek için çeşitli olanaklara sahiptir. Ancak sistematik olarak kullanılmamaktadır ve burada manipüle edeceğimiz nesnelerin kendi referans sayaç yardımcıları vardır. Genel olarak, referans alma çoğunlukla “_get() " benzeri işlevlerden yapılırken, bırakma referansı " _put()” benzeri fonksiyonlardır.

Bizim durumumuzda, her nesnenin farklı yardımcı adları vardır:

  • struct sock: sock_hold(), sock_put()

  • struct file: fget(), fput()

  • struct files_struct: get_files_struct(), put_files_struct()

Uyarı: Bir fonksiyonun ismine bağlı olarak ne yaptığı hakkında hiçbir şey varsaymayın, her zaman o fonksiyonun ne işe yaradığını kontrol edin. Mesela, skb_put() aslında herhangi bir referans sayacını azaltmaz, verileri sk arabelleğine “iter”.

Şimdi yazılım hatasını anlamak için gereken her veri yapısı anlatıldığına göre, devam edelim ve CVE’yi analiz edelim.

Genel Bilgi

Hatayı incelemeden önce, mq_notify() sistem çağrısının temel amacını açıklayalım. Adam tarafından belirtildiği gibi, “mq_*”, “POSIX mesaj kuyrukları” anlamına gelir ve eski SystemV mesaj kuyruklarının yerine geçer:

POSIX mesaj kuyrukları, süreçlerin mesaj biçiminde veri alışverişi yapmasına izin verir.
Bu API, System V mesaj kuyrukları tarafından sağlanandan farklıdır  (msgget(2),
msgsnd(2), msgrcv(2), vb.), ancak benzer işlevsellik sağlar.`

mq_notify() sistem çağrısının kendisi, eşzamansız bildirimler için kaydolmak/kaydı iptal etmek için kullanılır.

mq_notify(), çağrı sürecinin bir teslimat için kayıt olmasına veya kaydını silmesine izin verir.
boş mesaj kuyruğuna yeni bir mesaj geldiğinde zaman eşzamansız bildirim
mqdes tanımlayıcısı tarafından ifade edilir.

Bir CVE’yi anlamaya çalışırken, o CVE’nin barındırdığı zafiyeti gideren açıklama ve yama ile başlamak her zaman çok daha verimli sonuçlar ortaya koymaktadır.

4.11.9 sürümlü Linux kernel’ındaki Themq_notify işlevi, yeniden deneme mantığına girildiğinde sock işaretçisini NULL olarak ayarlamamaktadır. Bir Netlink soketinin kullanıcı alanı tarafında kapatılması sırasında, saldırganların hizmet dışı bırakma (use-after-free) veya muhtemelen belirtilmemiş başka bir etkiye neden olmasına izin vermektedir.

Zafiyeti gideren yamaya buradan ulaşılabilir:

**`diff --git a/ipc/mqueue.c b/ipc/mqueue.cindex c9ff943..eb1391b 100644**--- a/ipc/mqueue.c
+++ b/ipc/mqueue.c
**@@ -1270,8 +1270,10 @@ retry:**

      timeo = MAX_SCHEDULE_TIMEOUT;
      ret = netlink_attachskb(sock, nc, &timeo, NULL);
-     if (ret == 1)
+     if (ret == 1) {
+       sock = NULL;
        goto retry;
+     }
      if (ret) {
        sock = NULL;
        nc = NULL;`

Tek satırlık kolay bir yama…

Son olarak, yama açıklaması yazılım hatasını anlamak için birçok yararlı bilgi sağlamaktadır:

mqueue: fix a use-after-free in sys_mq_notify()
The retry logic for netlink_attachskb() inside sys_mq_notify()
is nasty and vulnerable:

1) The sock refcnt is already released when retry is needed
2) The fd is controllable by user-space because we already
   release the file refcnt

so we then retry but the fd has been just closed by user-space
during this small window, we end up calling netlink_detachskb()
on the error path which releases the sock again, later when
the user-space closes this socket a use-after-free could be
triggered.

Setting 'sock' to NULL here should be sufficient to fix it
//orijinal
mqueue: sys_mq_notify() fonksiyonu içindeki use-after-free düzeltilmiştir.
sys_mq_notify() içindeki netlink_attachskb() için yeniden deneme mantığı
zafiyetlidir:
1) Yeniden deneme gerektiğinde refcnt sock'u zaten serbest bırakılmıştır.
2) fd, kullanıcı alanı tarafından kontrol edilebilir çünkü refcnt dosyası 
zaten serbest bırakılmıştı

böylece biz yeniden denediğimizde fd kullanıcı alanı tarafından, bu küçük 
pencere çıktığında kapanmıştı.ardından netlink_detachskb() fonksiyonuna 
sock'u yeniden serbest bırakan hata yolundan çağrı yaptık, ardından kullanıcı
alanı bu sock u kapattığında bir use-after-free tetiklenebilmektedir.

Yama açıklamasında yalnızca tek bir hata var: Yazılım hatasının “açık saçık” biçimde görülebilmesine rağmen “küçük pencere sırasında” deyimi yazılım hatasını doğru tanımlayamamış. O küçük pencerenin aslında deterministtik bir şekilde süresiz olarak genişletilebileceğini göreceğiz.

Kafa karışıklığı oluşturmuş olabilirim :) Bir sonraki bölümde görüşmek üzere :)

3 Beğeni