전 절에서는 Holstein 모듈에서 Stack Overflow를 발견하고, 취약점을 이용해 RIP를 탈취할 수 있음을 확인했습니다. 이번 절에서는 이를 LPE로 연결하는 방법과, 다양한 보안 기법을 우회하는 방법을 배웁니다.
권한 상승 방법
권한 상승 방법에는 여러 가지가 있지만, 가장 기본적인 기법은 commit_creds를 사용하는 방법입니다. 이는 커널이 root 권한 프로세스를 생성할 때 실행하는 처리와 동일한 작업을 수행하는 방법으로, 매우 자연스러운 접근 방식입니다.
root 권한을 획득한 후 또 하나 중요한 것이 사용자 공간으로 돌아가는 것입니다. 지금 커널 모듈을 exploit하고 있으므로 컨텍스트는 커널이지만, 최종적으로는 사용자 공간으로 돌아가 root 권한 셸을 획득해야 하므로 크래시 없이 사용자 공간으로 돌아가야 합니다.
먼저 이러한 이론적인 부분에 대해 설명하겠습니다.
prepare_kernel_cred와 commit_creds
모든 프로세스에는 권한이 할당됩니다. 이는 cred 구조체라고 불리는 구조체로 힙 위에서 관리됩니다. 각 프로세스(태스크)는 task_struct 구조체라는 구조체로 관리되며, 그 안에 cred 구조체에 대한 포인터가 있습니다.
1 | struct task_struct { |
cred 구조체는 프로세스가 생성되는 시점 등에 만들어지는데, 이를 담당하는 함수로 prepare_kernel_cred라는 Kernel Exploit에서 매우 중요한 함수가 있습니다. 이 함수를 잠시 읽어봅시다.
1 | /* 인자로 task_struct 구조체에 대한 포인터를 받음 */ |
첫 번째 인자에 NULL을 주고 prepare_kernel_cred를 호출했을 때의 동작을 따라가 봅니다. 먼저 아래 코드로 cred 구조체가 새로 할당됩니다.
1 | new = kmem_cache_alloc(cred_jar, GFP_KERNEL); |
그리고 첫 번째 인자 daemon이 NULL일 때, 다음 코드로 init_cred라고 불리는 cred 구조체의 데이터가 계승됩니다.
1 | old = get_cred(&init_cred); |
그 후 old의 정당성을 검증하고, old에서 new로 적절히 멤버를 계승해 나갑니다.
prepare_kernel_cred(NULL)에 의해 init_cred를 사용한 cred 구조체가 생성됩니다. 그럼 init_cred의 정의도 살펴봅시다.
1 | /* |
코드를 보면 알 수 있듯이, init_cred는 그야말로 root 권한의 cred 구조체가 됩니다.
이제 root 권한의 cred 구조체를 만들 수 있을 것 같습니다. 다음으로 이 권한을 현재 프로세스에 설정해 주어야 합니다. 그 역할을 하는 것이 commit_creds 함수가 됩니다.
1 | int commit_creds(struct cred *new) |
따라서,
1 | commit_creds(prepare_kernel_cred(NULL)); |
를 호출하는 것이 Kernel Exploit에서 권한 상승을 하기 위한 하나의 기법이 됩니다.
[2023년 3월 28일 추가]
Linux 커널 6.2부터는 prepare_kernel_cred에 NULL을 전달할 수 없게 되었습니다.
init_cred는 아직 존재하므로 commit_creds(&init_cred)를 실행하면 동일한 작업이 가능합니다.
swapgs: 사용자 공간으로 복귀
prepare_kernel_cred와 commit_creds로 다행히 root 권한을 얻었지만, 그것으로 끝이 아닙니다.
ROP chain이 끝난 후, 아무 일도 없었다는 듯이 사용자 공간으로 복귀하여 셸을 획득해야 합니다. 모처럼 root 권한을 얻어도 크래시가 발생하거나 프로세스가 종료되어 버리면 의미가 없습니다.
ROP는 본래 저장되어 있던 스택 프레임을 파괴하고 chain을 덮어쓰므로, 원래대로 돌아간다는 것은 직관적으로 매우 어렵습니다. 하지만 Kernel Exploit에서는 어디까지나 취약점을 트리거하는 프로그램(프로세스)은 우리가 만드는 것이므로, ROP 종료 후에 RSP를 사용자 공간으로 되돌리고, RIP를 셸을 획득하는 함수로 설정해 주면 사용자 공간으로 돌아갈 수 있습니다.
애초에 사용자 공간에서 커널 공간으로 이동하는 방법은, CPU 명령어가 특권 모드를 전환함으로써 실현됩니다. 사용자 공간에서 커널 공간으로 가는 방법은 통상 시스템 콜 syscall과 인터럽트 int뿐입니다. 그리고 커널 공간에서 사용자 공간으로 돌아가기 위해서는 보통 sysretq, iretq라는 명령어가 사용됩니다. sysretq보다 iretq가 더 단순하므로, Kernel Exploit에서는 보통 iretq를 사용합니다. 또한, 커널에서 사용자 공간으로 돌아갈 때, 커널 모드의 GS 세그먼트에서 사용자 모드의 GS 세그먼트로 전환해야 합니다. 이를 위해 Intel에서는 swapgs 명령어가 준비되어 있습니다.
흐름으로는 swapgs와 iretq를 순서대로 호출하면 됩니다. iretq를 호출할 때, 스택에는 복귀할 사용자 공간의 정보를 다음과 같이 쌓아두어야 합니다.
사용자 공간의 RSP, RIP에 더해, CS, SS, RFLAGS도 사용자 공간의 것으로 되돌려야 합니다. RSP는 어디라도 상관없고, RIP는 셸을 실행하는 함수로 설정해 두면 됩니다. 나머지 레지스터는 원래 사용자 공간에 있을 때의 값을 사용하면 되므로, 다음과 같이 레지스터 값을 저장하는 보조 함수를 준비해 둡시다. (RSP도 저장해 두었습니다.)
1 | static void save_state() { |
사용자 공간에 있는 동안 이를 호출해 두고, iretq를 호출하는 타이밍에 이 값을 사용할 수 있도록 해 두면 됩니다.
ret2user (ret2usr)
지금까지 설명한 이론을 사용하여, 드디어 권한 상승을 실천해 보겠습니다.
먼저 가장 기초적인 기법인 ret2user에 대해 설명하겠습니다. 이번에는 SMEP가 비활성화되어 있으므로, 사용자 공간의 메모리에 있는 코드를 커널 공간에서 실행할 수 있습니다. 즉, 지금까지 설명한 prepare_kernel_cred, commit_creds, swapgs, iretq의 흐름을 그대로 C언어로 작성해 두면 됩니다.
1 | static void win() { |
이러한 처리는 간단한 Kernel Exploit에서는 빈번하게 나오므로, 각자 자신에게 맞는 코드로 템플릿화해 둡시다. main 함수 앞부분에 save_state를 호출하는 처리를 추가해 둡시다.
escalate_privilege 함수 내에서 prepare_kernel_cred와 commit_creds라는 함수 포인터가 필요한데, 이번에는 KASLR가 비활성화되어 있으므로 이 값은 고정일 것입니다. 실제로 이들 함수의 주소를 획득하여 코드 중에 적어 둡시다.
자, 이제 취약점을 사용하여 escalate_privilege 함수를 호출하면 끝입니다. 적당히 escalate_privilege의 포인터를 대량으로 덮어써도 좋지만, 나중에 ROP를 하게 될 것이므로 리턴 주소의 정확한 오프셋을 파악해 둡시다.
오프셋은 커널 모듈을 IDA 등으로 읽어서 계산해도 좋지만, 이왕이면 gdb를 사용하여 취약점이 트리거되는 부분을 확인해 봅시다.
module_write 안에서 _copy_from_user를 호출하는 곳을 IDA 등으로 보면 주소는 0x190입니다. /proc/modules에서 얻은 베이스 주소와 더해서 브레이크 포인트를 건 상태로 write를 호출해 봅니다.
쓰기 대상인 RDI에서 0x400 뒤는 다음과 같이 되어 있습니다.
그리고 ret까지 진행하면,
이렇게 되어 있고, RSP는 0xffffc90000413eb0를 가리키고 있습니다.
따라서 0x408바이트만큼 더미 데이터를 넣은 뒤부터 RIP를 제어할 수 있을 것 같습니다.
그래서 다음과 같이 exploit을 변경해 보았습니다.
1 | char buf[0x410]; |
최종 exploit은 여기에 두겠습니다.
module_write의 ret 명령에서 멈춰 보면, escalate_privilege에 도달했다는 것을 알 수 있습니다.

nexti 명령을 실행해도 다음 명령에서 멈추지 않을 때가 있어.
그럴 때는 stepi를 시도해 보거나, 조금 뒤에 브레이크 포인트를 걸어보는 게 좋을지도?
올바르게 exploit을 작성했다면 prepare_kernel_cred와 commit_creds를 통과합니다. restore_state 내부도 스텝 인(step-in)으로 살펴봅시다. iretq를 호출할 때의 스택은 다음과 같이 됩니다.
stepi로 win 함수로 점프했다면 성공입니다.
지금은 원래 root 권한이라 성공했는지 알 수 없지만, 어쨌든 사용자 공간으로 돌아왔습니다.
그럼 변경했던 설정(S99pawnyable)을 원래대로 되돌리고, 일반 유저 권한으로 exploit을 실행해 봅시다.
권한 상승에 성공했습니다!
처음 접하는 지식이 많아 조금 어려웠을지도 모르지만, 앞으로 힙이나 레이스 컨디션 등의 취약점을 다루다 보면, 어떤 취약점이든 대부분 하는 일은 같아서 사실 꽤 간단하다는 것을 깨닫게 될 것입니다. 기대해 주세요!
kROP
다음으로 SMEP를 활성화해 봅시다. qemu 실행 시의 cpu 인자에 smep를 붙여 봅시다.
1 | -cpu kvm64,+smep |
이 상태에서 방금 전의 ret2user exploit을 실행해 봅시다.
크래시가 발생했습니다😢
「unable to execute userspace code (SMEP?)」라고 출력되어, SMEP에 의해 사용자 공간의 코드를 실행할 수 없게 되었음을 알 수 있습니다.
이는 사용자 공간에서의 NX(DEP)와 매우 비슷합니다. 사용자 공간 데이터를 읽고 쓸 수는 있지만 실행은 할 수 없게 되었습니다. 따라서 NX를 우회하는 것과 마찬가지로, SMEP는 ROP를 통해 우회 가능합니다. 커널 공간에서의 ROP를 흔히 kROP라고 부릅니다.
Kernel Exploit에 도전하고 계신 여러분이라면 ret2user에서 했던 처리를 ROP로 만드는 것도 간단할 것이라 생각합니다. 실제로 ROP chain을 작성할 때 특별히 주의할 점은 없지만, ROP gadget을 찾는 부분까지는 함께 해 봅시다.
먼저 Linux 커널의 ROP gadget을 찾으려면, bzImage에서 vmlinux라는 커널의 핵심이 되는 ELF를 추출해야 합니다. 이를 위해 공식적으로 extract-vmlinux라는 셸 스크립트가 제공되고 있으니 이용해 봅시다.
1 | $ extract-vmlinux bzImage > vmlinux |
그 다음은 좋아하는 도구를 사용하여 ROP gadget을 찾습니다.
1 | $ ropr vmlinux --noisy --nosys --nojop -R '^pop rdi.+ret;' |
출력되는 주소는 절대 주소로 되어 있습니다. 이 값은 KASLR를 비활성화했을 때의 베이스 주소(0xffffffff81000000)에 상대 주소를 더한 것이므로, 예를 들어 위 예시라면 0x27bbdc가 상대 주소가 됩니다. 이번에는 KASLR가 비활성화되어 있으므로 출력된 주소를 그대로 사용할 수 있지만, KASLR가 활성화된 경우는 상대 주소를 사용하도록 주의합시다.
Linux 커널은 libc 등보다도 방대한 양의 코드이므로, 기본적으로 임의의 조작이 가능할 만큼의 ROP gadget이 있습니다. 이번에는 다음 gadget을 사용했지만, 디버그 연습도 겸해서 자신이 좋아하는 ROP chain을 구성해 보세요.
1 | 0xffffffff8127bbdc: pop rdi; ret; |
마지막으로 iretq가 필요한데, 이는 일반적인 도구에서는 찾아주지 않으므로 objdump 등으로 찾아봅시다.
1 | $ objdump -S -M intel vmlinux | grep iretq |

ROP gadget을 찾기 위한 도구 대부분은 커널 같은 방대한 양의 바이너리에 대해 충분히 테스트되지 않았어.
대응하지 않는 명령을 건너뛰거나, 명령의 prefix를 생략하는 등, 잘못된 출력이 많으니 조심해야 해.
그리고 gadget이 커널 공간에서 실제로 실행 가능 영역에 포함되는지를 올바르게 판별하지 못하는 도구가 대부분이니, 주소가 큰 gadget (예:0xffffffff81cXXXYYY)에는 특히 주의가 필요해.
ROP chain을 작성하는 방법은 자유지만, 필자는 다음과 같이 작성하고 있습니다. gadget을 중간에 추가하거나 삭제해도 오프셋 값을 변경하지 않아도 되므로 추천합니다.
1 | unsigned long *chain = (unsigned long*)&buf[0x408]; |
ROP chain으로 고치기만 하면 되므로, 각자 exploit을 작성해 보세요. exploit 예시는 여기에서 다운로드할 수 있습니다.
ROP chain이 왠지 작동하지 않는데 디버그하기 귀찮은 경우는, 유저 랜드 exploit과 마찬가지로 적당한 주소를 넣어 크래시 메시지를 보고, 거기까지 실행되고 있는지 디버그하는 편이 편합니다.
1 | *chain++ = rop_pop_rdi; |
이때 반드시 커널이나 유저 랜드에 매핑되지 않은 주소를 사용하도록 합시다. ROP가 올바르게 작동하면 SMEP를 우회하여 root 권한을 얻을 수 있을 것입니다.
이번 ROP chain은 커널 공간 스택에서 동작하고 있으므로, 사실 SMAP를 활성화해도 exploit은 그대로 동작합니다. 시도해 보세요.
mov rdi, rax; rep movsq; ret;」 같은 gadget이 존재하여, prepare_kernel_cred(NULL)의 결과를 commit_creds에 전달할 수 있습니다. 혹은 「mov rdi, rax; call rcx;」 같은 gadget으로 commit_creds 앞부분의 push rbp를 건너뛰고 실행해도 좋습니다.도저히 gadget이 보이지 않거나 ROP chain을 짧게 하고 싶을 때는
init_cred를 사용할 수 있습니다. init_cred라는 전역 변수에는 root 권한의 cred 구조체가 들어 있습니다. 즉, 단순히 commit_creds(init_cred)를 실행하는 것만으로도 권한 상승이 가능합니다.
KPTI 다루기
다음으로 SMAP, SMEP, KPTI를 활성화한 상태에서 exploit해 봅시다.
KPTI 자체는 이러한 일반적인 취약점에 대한 완화책이 아니라, Meltdown이라는 특정 사이드 채널 공격에 대응하기 위한 완화책입니다. 그 때문에 지금까지 사용해 온 exploit 기법에 영향은 없지만, KPTI를 활성화한 상태에서 exploit을 실행하면 다음과 같이 사용자 공간에서 크래시가 발생해 버립니다.
사용자 공간에서 죽었으므로 swapgs를 거쳐 iretq로 사용자 공간으로 돌아오긴 했지만, KPTI의 영향으로 페이지 디렉토리가 커널 공간인 상태라서 사용자 공간의 페이지를 읽을 수 없는 상태가 되었습니다.
보안 기법 절에서 보았듯이, 유저 랜드로 돌아가기 전에 CR3 레지스터에 0x1000을 OR해 두어야 합니다. "그런 gadget이 있나?"라고 생각할지 모르지만, 이 처리는 커널에서 사용자 공간으로 돌아가는 정규 처리에 반드시 존재하고 있을 것이므로 100% 찾을 수 있습니다.
구체적으로는 swapgs_restore_regs_and_return_to_usermode 매크로로 구현되어 있습니다. 중요한 것은 다음 부분입니다.
1 | movq %rsp, %rdi |
처음에 push하는 것은 후술하겠지만, iretq를 위해 스택을 정비하는 것입니다. 그 후 SWITCH_TO_USER_CR3_STACK을 사용하여 CR3를 갱신합니다. 이 매크로의 주소를 조사해 봅시다.
1 | / # cat /proc/kallsyms | grep swapgs_restore_regs_and_return_to_usermode |
또한, 심볼이 사라진 경우는 objdump 등으로 CR3에 대한 조작(rdi를 사용하여 조작하는 곳)을 찾으면 됩니다.
자, ROP chain 속에서 이 swapgs_restore_regs_and_return_to_usermode의 어디로 점프할 것인가인데, 목적은 CR3 갱신이므로 언뜻 보면 다음 위치로 점프하면 페이지 디렉토리를 사용자 공간으로 되돌려 줄 것 같습니다.
하지만 CR3를 사용자 공간의 것으로 갱신하면 커널 공간의 스택에 있는 데이터는 더 이상 참조할 수 없으므로, 마지막 pop이나 iretq에서 데이터를 읽어들일 수 없습니다.
사실 (당연하다면 당연하지만) 이 컨텍스트 스위치를 실현하기 위해 사용자 공간에서도 커널 공간에서도 접근이 허용된 장소가 몇 군데 있습니다. 앞서 보았던
1 | movq %rsp, %rdi |
부분은 사전에 스택을 그 장소로 조정했던 것입니다.
그리고 이어지는 push는 본래 iretq에 전달되었어야 할 커널 스택에 있던 데이터를, CR3 갱신 후에도 접근 가능한 영역에 복사하고 있는 코드입니다. 따라서 ROP 중에는 다음 0xffffffff81800e26 위치로 점프해야 합니다.
이번 경우는 swapgs 앞에 pop rax와 pop rdi가 있습니다.
1 | 0xffffffff81800e89: pop rax |
방금 전 그림에서 push [rdi]; push rax했던 값이 여기서 rax, rdi로 되돌려집니다. 그리고 swapgs 시점에서의 스택은 앞부분의
1 | 0xffffffff81800e32: push QWORD PTR [rdi+0x30] |
로 구축된 것(rdi는 원래의 rsp)이므로, gadget 호출의 0x10바이트 뒤에 swapgs에서 사용할 데이터를 두어야 합니다.
1 | *chain++ = rop_bypass_kpti; |
이 점에 주의하여, KPTI 환경에서도 동작하는 kROP를 직접 완성해 보세요.
KASLR 우회
지금까지 KASLR를 비활성화해 왔는데, KASLR가 활성화되어 있다면 exploit이 가능할까요?
KASLR 엔트로피
본론으로 들어가기 전에, 애초에 KASLR는 어떻게 구현되어 있는 걸까요?
커널의 주소 랜덤화는 페이지 테이블 레벨에서 수행되며, kaslr.c의 kernel_randomize_memory 함수에 구현되어 있습니다.
커널은 0xffffffff80000000부터 0xffffffffc0000000까지의 1GB 주소 공간을 확보하고 있습니다. 따라서 KASLR가 활성화되어 있어도 0x810부터 0xc00까지의, 기껏해야 0x3f0가지 정도의 베이스 주소밖에 생성되지 않습니다.

커널 공간의 ASLR은 사용자 공간보다 약하네.
의외일 수도 있지만, 커널에서는 한 번 공격이 실패하면 커널 패닉이 발생해서 브루트 포스가 현실적이지 않기 때문에, 엔트로피가 작아도 충분해.
주소 릭(Leak)
ASLR을 우회하는 것과 마찬가지로, Kernel Exploit에서도 KASLR를 우회하기 위해서는 커널 공간의 주소 릭이 필요합니다. 커널은 모든 프로그램에서 공통이므로, 설령 이 드라이버에 취약점이 없더라도 다른 드라이버나 커널 자체에 주소 릭 취약점이 있다면 그것을 사용할 수 있습니다.
이번에는 module_read에 범위 외 읽기 취약점이 있으므로, 이를 이용해 봅시다. 지금까지는 module_write의 Stack Overflow를 악용했지만, module_read에도 마찬가지로 스택 상에서의 취약점이 존재합니다.
1 | static ssize_t module_read(struct file *file, |
스택 상의 변수 kbuf에 데이터를 넣고 있지만, copy_to_user로 복사할 수 있는 크기는 자유입니다. 따라서 0x400바이트보다 많은 데이터를 스택에서 읽는 것이 가능합니다. 스택 위에는 리턴 주소 외에도 다양한 데이터가 있기 때문에, 커널의 함수나 데이터의 일부를 가리키는 포인터가 반드시 존재합니다. 이를 유출(leak)시킴으로써, 커널이 로드된 베이스 주소를 계산할 수 있고, 나아가 commit_creds 등의 함수 주소도 알 수 있습니다.
먼저 스택 위에 KASLR의 베이스 주소를 특정할 수 있는 주소가 존재하는지 gdb로 확인합니다. 기본적으로 디버그 중에는 KASLR를 비활성화해 둡시다.
0xffffffff81000000 부근을 가리키는 주소를 찾아보면, 위 그림에서 0xffffc9000041beb0와 0xffffc9000041bef0에 각각 0xffffffff8113d33c와 0xffffffff8113d6e3이 존재합니다. 이 주소가 무엇인지 kallsyms에서 조사해 봅시다. 정확히 이 주소에 맞는 심볼은 발견되지 않으므로, 리턴 주소 등 함수의 중간을 가리키는 포인터라고 추측할 수 있습니다. 하위 몇 비트를 제외하고 grep해 보면, 다음과 같이 몇 개가 히트합니다.
vfs_read나 ksys_read 함수의 중간을 가리키고 있는 것 같습니다. 어쨌든 FGKASLR은 비활성화되어 있으므로, 커널의 베이스 주소로부터 이 포인터까지의 오프셋은 고정입니다. 이번에는 첫 번째 vfs_read를 가리키는 포인터를 이용합니다.
1 | /* Leak kernel base */ |
이걸로 SMAP, SMEP, KPTI, KASLR 모두 활성화되어 있어도 동작하는 exploit을 작성할 수 있습니다. ROP gadget이나 각종 함수에, 유출한 커널 베이스 주소를 사용하도록 수정해 보세요. 다음과 같이 KASLR가 활성화되어 있어도 권한 상승이 가능하다면 성공입니다.
Exploit 예시는 여기에서 다운로드할 수 있습니다.
(1) SMAP 무효 / SMEP 무효 / KPTI 유효
(2) SMAP 유효 / SMEP 무효 / KPTI 무효
힌트: ret2usr로 셸코드를 실행하는 순간의 레지스터 값을 확인한다.