KPTI 자체는 알고 있다고 가정한다.
ROP 기법을 이용한다는 것을 전제로 한다.
KPTI가 활성화되면, 커널모드에서의 page table과 usermode에서의 page table은 서로 달라진다. 특히 PGD의 주소가 다르다.
PGD의 주소는 CR3 레지스터에 저장되어 있고, 이 값을 참조해서 동작을 하게 되는데, 당연히 usermode <---> kernelmode 의 context switching이 일어날 때 CR3 레지스터의 값이 바뀌게 된다.
아이러니하게도 KPTI를 구현하기 위해 만든 코드를 이용해서 KPTI를 우회할 수 있는데, 그 부분은 바로 swapgs_restore_regs_and_return_to_usermode
함수이다.
그러면 해당 함수의 코드를 한번 살펴보자.
소스코드가 읽기 더 힘들어서 disassemble한 결과를 통해 분석해 볼 것이다.
jmp에 의해 실행이 되지 않는 부분은 생략하였다.
0xffffffff87800f10: jmp 0xffffffff87800f2b
...
0xffffffff87800f2b: pop r15
0xffffffff87800f2d: pop r14
0xffffffff87800f2f: pop r13
0xffffffff87800f31: pop r12
0xffffffff87800f33: pop rbp
0xffffffff87800f34: pop rbx
0xffffffff87800f35: pop r11
0xffffffff87800f37: pop r10
0xffffffff87800f39: pop r9
0xffffffff87800f3b: pop r8
0xffffffff87800f3d: pop rax
0xffffffff87800f3e: pop rcx
0xffffffff87800f3f: pop rdx
0xffffffff87800f40: pop rsi
0xffffffff87800f41: mov rdi,rsp
0xffffffff87800f44: mov rsp,QWORD PTR gs:0x6004
0xffffffff87800f4d: push QWORD PTR [rdi+0x30]
0xffffffff87800f50: push QWORD PTR [rdi+0x28]
0xffffffff87800f53: push QWORD PTR [rdi+0x20]
0xffffffff87800f56: push QWORD PTR [rdi+0x18]
0xffffffff87800f59: push QWORD PTR [rdi+0x10]
0xffffffff87800f5c: push QWORD PTR [rdi]
0xffffffff87800f5e: push rax
0xffffffff87800f5f: xchg ax,ax
0xffffffff87800f61: mov rdi,cr3
0xffffffff87800f64: jmp 0xffffffff87800f9a
...
0xffffffff87800f9a: or rdi,0x1000
0xffffffff87800fa1: mov cr3,rdi
0xffffffff87800fa4: pop rax
0xffffffff87800fa5: pop rdi
0xffffffff87800fa6: swapgs
0xffffffff87800fa9: jmp 0xffffffff87800fc6
...
0xffffffff87800fc6: test BYTE PTR [rsp+0x20],0x4
0xffffffff87800fcb: jne 0xffffffff87800fcf
0xffffffff87800fcd: iretq
사실 우리가 필요로 하는 부분은 cr3의 값을 바꾸는 부분에 해당한다.
mov rdi, cr3
명령어 부분부터가 이에 해당한다.
cr3 값을 rdi에 옮기고, rdi에 0x1000을 or 연산하고, 그 후 cr3에 수정된 rdi의 값을 저장한다.
이를 통해 cr3의 값을 바꾸는 것이다.
그 이후에는 pop rax ; pop rdi ;
, 두 번의 pop이 이루어지고,
swapgs
이후 test BYTE PTR [rsp+0x20], 0x4
후 결과에 따라 ireq가 이루어질지 아닐지 결정된다.
여기서 test
명령어의 결과가 어떻게 되는지에 대해 알아보자.
우선 스택의 상태를 먼저 확인해 봐야 한다.
test
명령어가 이루어지는 시점에서의 스택의 상태는 결국 iretq
가 이루어지는 상태와 동일, 즉 restore될 rip, cs, rflags, rsp, ss 값이 순서대로 쌓여있는 상태이다.
이 때 BYTE PTR [rsp+0x20]
의 값은 복원될 usermode ss 레지스터의 값에 해당한다.
x86-64 아키텍쳐의 경우, usermode에서의 ss 레지스터 값은 0x2b로, 딱 0x4의 비트만 비어있다. 그러므로 이 부분의 코드는 정상적으로 동작한다면 jne에 걸리지 않고 iretq가 동작하게 된다.
이 로직대로만 작동한다면 정상적으로 pagetable도 잘 바뀌고, swapgs, iretq도 잘 호출될 것이다.
우리가 필요로 하는 부분의 시작인 mov rdi, cr3
명령어 이전 명령어들을 보자.
ROP payload의 크기를 줄이기 위해서, 그리고 굳이 더 많은 동작을 하지 않게 하기 위해서 그 전까지의 코드는 다 쓸모가 없지 않을까?
그러면 그냥 바로 mov rdi, cr3
부터 실행하게 되면 되는 것이 아닐까?
처음에는 나도 그렇게 생각했었다.
하지만, cr3가 usermode pgd의 값을 가리키게끔 바뀐다면, virtual memory mapping 또한 usermode에 해당하게 되고, 결국 커널 영역의 메모리는 사용할 수가 없게 된다.
그 상태에서 pop rax ; pop rdi ;
가 이루어지기 때문에 rsp가 가리키는 영역을 참조할 수가 없어 커널 패닉이 발생하게 된다.
그렇기 때문에, usermode pagetable에서도 매핑된 커널 메모리가 존재해야 하고, 이 부분에 미리 필요한 값(pop rax ; pop rdi ;
할 값 + iretq
할 값)을 옮겨놔야 한다.
그에 해당하는 것이 바로 mov rdi, cr3
앞에 존재하는 mov rdi, rsp
부분부터의 코드이다. 그 부분을 살펴보자.
0xffffffff87800f41: mov rdi,rsp
0xffffffff87800f44: mov rsp,QWORD PTR gs:0x6004
0xffffffff87800f4d: push QWORD PTR [rdi+0x30]
0xffffffff87800f50: push QWORD PTR [rdi+0x28]
0xffffffff87800f53: push QWORD PTR [rdi+0x20]
0xffffffff87800f56: push QWORD PTR [rdi+0x18]
0xffffffff87800f59: push QWORD PTR [rdi+0x10]
0xffffffff87800f5c: push QWORD PTR [rdi]
0xffffffff87800f5e: push rax
0xffffffff87800f5f: xchg ax,ax
우선 rsp의 값을 rdi에 저장한다.
그 후, rsp에 per-cpu 메모리에 저장된 커널 스택 주소를 저장한다. (여기서 사용하는 커널 스택은 usermode, kernelmode 둘 다 매핑된 영역이다)
새로운 커널 스택에 이전 커널 스택의 값들을 push한다.
이런식으로 세팅해 놓게 되면, cr3 레지스터의 값이 바뀌더라도 스택주소가 유효하므로 정상적으로 usermode로 리턴할 수 있다.