"메모리를 너무 작게 잡아놓고, 큰 데이터를 쓴다"
문제의 코드입니다.
struct bpf_binary_header *bpf_jit_binary_alloc(unsigned int proglen,
u8 **image_ptr,
unsigned int alignment,
bpf_jit_fill_hole_t bpf_fill_ill_insns)
{
struct bpf_binary_header *hdr;
u32 size, hole, start;
size = round_up(proglen + sizeof(*hdr) + 128, PAGE_SIZE);
hdr = module_alloc(size);
if (hdr == NULL)
return NULL;
bpf_fill_ill_insns(hdr, size);
hdr->size = size;
hole = min_t(unsigned int, size - (proglen + sizeof(*hdr)), PAGE_SIZE - sizeof(*hdr));
start = get_random_u32_inclusive(0, hole) & ~(alignment - 1);
*image_ptr = &hdr->image[start];
return hdr;
}
딱 봐도 size가 u32입니다. proglen도 unsigned int, 즉 32비트입니다.
size = round_up(proglen + sizeof(*hdr) + 128, PAGE_SIZE);
proglen이 unsigned int의 최대값 근처, 예를 들어 0xFFFFFF00 이라고 가정합니다.
proglen = 0xFFFFFF00
sizeof(*hdr) = 0x08
128 = 0x80
합계 = 0xFFFFFF00 + 0x08 + 0x80 = 0x100000088
32비트 덧셈이므로 최상위 비트가 잘립니다.
0x100000088 → 0x00000088 = 136
round_up(136, PAGE_SIZE) = PAGE_SIZE = 4096 (= 0x1000)
결론적으로, 4GB 크기의 BPF 프로그램을 위해 4096바이트짜리 메모리만 할당됩니다.
다시 돌아가서, u32가 뭔지부터 설명하면, u32는 부호 없는 32비트 정수입니다. 이게 담을 수 있는 숫자의 범위는 0부터 4,294,967,295까지입니다.
u32 size;
size = round_up(proglen + sizeof(*hdr) + 128, PAGE_SIZE);
proglen: JIT 컴파일된 기계어 코드의 바이트 수. 공격자가 이 값을 크게 만들 수 있습니다.
sizeof(*hdr): 헤더 구조체 크기. 고정값입니다.
128: 패딩용 여유 공간. 역시 고정값입니다.
0xFFFFFF00을 10진수로 바꾸면 4,294,967,040입니다. u32 최대값인 4,294,967,295보다 255 작은 값입니다. 아직 한계 안에 있습니다. 여기에 덧셈을 시작합니다.
sizeof(*hdr)가 8바이트라고 하면,
0xFFFFFF00 + 0x08 = 0xFFFFFF08
아직 u32 범위 안입니다. 괜찮습니다.
(128은 16진수로 0x80입니다.)
0xFFFFFF08 + 0x80 = 0x100000088
이 값은 16진수 9자리입니다. 그런데 u32는 8자리까지밖에 못 담습니다. 9번째 자리, 즉 맨 앞의 1이 그냥 잘려나갑니다.
0x100000088
... 맨 앞 1이 탈락
0x00000088
= 0x88은 10진수로 136입니다. 즉, 40억이 넘는 숫자가 136으로 둔갑했습니다.
round_up(136, PAGE_SIZE)에서 PAGE_SIZE는 4096입니다. 136을 4096의 배수로 올림하면 4096이 됩니다. 즉 커널은 "BPF 프로그램을 위해 4096바이트 할당해라" 라고 지시받습니다.
JIT 컴파일러는 *image_ptr가 가리키는 버퍼에 proglen만큼의 기계어를 씁니다. proglen은 0xFFFFFF00 , 40억 바이트가 넘습니다.
버퍼 끝에서 4GB를 넘어선 범위까지 커널이 데이터를 씁니다. 그 경로에 있는 모든 커널 메모리가 덮어써집니다.
4GB 분량이 쓰이기 때문에 "다음 객체" 하나가 아니라 그 이후 수십만 개의 페이지가 전부 덮입니다.
cred는 프로세스의 권한 정보를 담는 구조체입니다.
struct cred {
atomic_t usage;
kuid_t uid; // 실제 사용자 ID
kgid_t gid;
kuid_t suid; // Set-UID
kuid_t euid; // 유효 사용자 ID , root : 0
kgid_t egid;
// ...
struct user_struct *user;
struct group_info *group_info;
// ...
};
여기서 오버플로가 cred 구조체를 덮으면,
// 오버플로 전
task->cred->euid = 1000; // user
// 오버플로로 0이 덮인 후
task->cred->euid = 0; // root
// 이 상태에서 시스템 콜 호출
setuid(0); // 이미 euid가 0이므로 uid도 0으로 변경 가능
execve("/bin/sh", ...); // root 셸 획득
문제는 이게 즉각적이라는 겁니다. 오버플로가 일어나는 순간 프로세스 권한이 바뀝니다.
열려있는 파일마다 파일 구조체가 커널 힙에 존재합니다. 여기에 중요한 포인터가 있습니다.
struct file {
struct path f_path;
struct inode *f_inode;
const struct file_operations *f_op;
loff_t f_pos;
};
struct file_operations {
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
int (*open)(struct inode *, struct file *);
};
오버플로가 f_op 포인터를 공격자가 원하는 주소로 덮으면,
// 오버플로 전 정상 상태
fp->f_op = &ext4_file_operations;
// 오버플로로 공격자 주소로 덮인 후
fp->f_op = 0xDEADBEEF12345678;
fp->f_op->read(fp, buf, count, pos);
// 공격자가 심어둔 코드로 점프
문제는 커널 모드에서 실행되므로 SMEP/SMAP 같은 보호도 의미가 없습니다. 이미 커널 공간 안입니다.
네트워크 패킷마다 sk_buff 구조체가 할당됩니다. 이게 힙에 있고 오버플로 경로에 있으면,
struct sk_buff {
struct sk_buff *next;
struct sk_buff *prev;
unsigned int len;
unsigned int data_len;
unsigned char *head;
unsigned char *data;
unsigned char *tail;
unsigned char *end;
};
여기서 head 포인터가 덮이면,
// 오버플로로 head가 커널 내부 중요 주소로 바뀜
skb->head = (unsigned char *)&init_cred;
memcpy(skb->head, 네트워크에서 온 데이터, len);
// 공격자가 네트워크로 보낸 데이터가 cred 구조체에 덮어 쓰임
원격에서 네트워크 패킷을 보내는 것만으로 커널 메모리를 쓸 수 있게 됩니다. (!!!)
힙 오버플로의 실제 피해는 무엇이 인접해 있냐에 달려있고, 이건 실행 타이밍과 메모리 레이아웃에 따라 매번 달라집니다. 그래서 단순히 이 구조체를 보호하면 된다는 방식으로는 막기 어렵습니다.
근본 해결책은 딱 하나입니다 오버플로 자체가 일어나지 않도록
proglen + sizeof(*hdr) + 128
계산을 u32가 아닌 u64로 올려서 잘림이 없게 하고, 결과가 합리적 범위인지 검증한 뒤 할당하는 것입니다.