[Linux] bpf_jit_binary_alloc() 고찰

SeongJin. H·6일 전

Security

목록 보기
3/4

"메모리를 너무 작게 잡아놓고, 큰 데이터를 쓴다"

문제의 코드입니다.

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: 패딩용 여유 공간. 역시 고정값입니다.

공격자가 proglen을 0xFFFFFF00으로 맞췄다고 가정합니다.

0xFFFFFF00을 10진수로 바꾸면 4,294,967,040입니다. u32 최대값인 4,294,967,295보다 255 작은 값입니다. 아직 한계 안에 있습니다. 여기에 덧셈을 시작합니다.

첫 번째 덧셈: proglen + sizeof(*hdr)

sizeof(*hdr)가 8바이트라고 하면,

0xFFFFFF00 + 0x08 = 0xFFFFFF08

아직 u32 범위 안입니다. 괜찮습니다.

두 번째 덧셈: 0xFFFFFF08 + 128

(128은 16진수로 0x80입니다.)

0xFFFFFF08 + 0x80 = 0x100000088

이 값은 16진수 9자리입니다. 그런데 u32는 8자리까지밖에 못 담습니다. 9번째 자리, 즉 맨 앞의 1이 그냥 잘려나갑니다.

0x100000088
... 맨 앞 1이 탈락
0x00000088
= 0x88은 10진수로 136입니다. 즉, 40억이 넘는 숫자가 136으로 둔갑했습니다.

round_up이 이걸 더 악화시킵니다.

round_up(136, PAGE_SIZE)에서 PAGE_SIZE는 4096입니다. 136을 4096의 배수로 올림하면 4096이 됩니다. 즉 커널은 "BPF 프로그램을 위해 4096바이트 할당해라" 라고 지시받습니다.

그런데 실제로 써야 할 데이터는 얼마인가

JIT 컴파일러는 *image_ptr가 가리키는 버퍼에 proglen만큼의 기계어를 씁니다. proglen은 0xFFFFFF00 , 40억 바이트가 넘습니다.
버퍼 끝에서 4GB를 넘어선 범위까지 커널이 데이터를 씁니다. 그 경로에 있는 모든 커널 메모리가 덮어써집니다.

4GB 분량이 쓰이기 때문에 "다음 객체" 하나가 아니라 그 이후 수십만 개의 페이지가 전부 덮입니다.

<상황 1> cred 구조체가 덮이는 경우

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 셸 획득

문제는 이게 즉각적이라는 겁니다. 오버플로가 일어나는 순간 프로세스 권한이 바뀝니다.

<상황 2> file 구조체가 덮이는 경우

열려있는 파일마다 파일 구조체가 커널 힙에 존재합니다. 여기에 중요한 포인터가 있습니다.


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 같은 보호도 의미가 없습니다. 이미 커널 공간 안입니다.

<상황 3> 소켓 버퍼가 덮이는 경우

네트워크 패킷마다 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로 올려서 잘림이 없게 하고, 결과가 합리적 범위인지 검증한 뒤 할당하는 것입니다.

0개의 댓글