
qemu와 gdb에서 x86-64 페이지 테이블을 수동으로 탐색합니다. 가상 주소를 분해하고, cr3를 따라 물리 메모리의 모든 수준을 추적하며, 원시 바이트에서 플래그를 추출합니다.
페이징에 대해 읽어본 적이 있을 겁니다. 다이어그램은 이해가 갑니다. 4단계, 각 9비트, 페이지 프레임, 오프셋. 물론이죠. 하지만 실제로 페이지 테이블을 탐색해야 하는 도전 과제에 직면하면, 여러분은 그것을 알고 있지 않다는 것을 깨닫게 됩니다. 그것에 대해 알고 있을 뿐입니다. 큰 차이죠.
저에게 효과가 있었던 것은 QEMU와 gdb 앞에 앉아 직접 워크를 수행한 것이었습니다: 모든 인덱스를 계산하고, 물리 메모리에서 모든 엔트리를 읽고, 모든 포인터를 손으로 따라가는 것. 그런 오후 한 번이 몇 시간의 강의보다 더 많은 것을 가르쳐 줄 수 있었습니다.
이것은 그 과정에서 작성한 제 노트 모음입니다. 아직 개념적인 부분이 부족하다면, Zardus의 커널 메모리 관리 강의를 먼저 시청하세요. 그것이 이론입니다. 이것은 실습입니다.
목표: 가상 주소를 가져와서 원시 물리 메모리를 통해 데이터를 찾을 때까지 추적하는 것입니다. 커널 도우미도 없고, 추상화도 없습니다. 그저 QEMU VM, gdb, 그리고 원시 물리 메모리만 있습니다.
결국, 페이징은 읽어본 것이 아니라 손으로 직접 해봄으로써 알게 되는 것이 될 것입니다.
사전 빌드된 커널과 initramfs가 포함되어 있습니다. 저는 Fedora에서 실행했지만, QEMU와 gdb를 실행할 수 있는 모든 OS에서 작동할 것입니다. 패키지 관리자를 사용하여 설치하세요:```
sudo apt install qemu-system-x86 gdb
sudo dnf install qemu-system-x86 gdb
brew install qemu gdb
### 도전 과제 바이너리
대상은 플래그를 메모리에 저장하고 해당 가상 주소를 출력하는 간단한 C 프로그램입니다:```c
#include <stdio.h>
#include <unistd.h>
int main(void)
{
char secret[] = "FLAG{p4g3_t4bl3_w4lk3r}";
printf("secret @ %p\n", (void *)secret);
printf("pid = %d\n", getpid());
printf("Spinning. Walk the page tables to find the flag.\n");
while (1)
{
}
}
바쁜 루프는 의도적입니다. 원래는 pause()를 사용했지만, 그 함수는 프로세스를 시스템 콜에서 수면 상태로 만듭니다. gdb가 VM을 중단시킬 때, CPU는 다른 CR3로 유휴 작업을 실행하고 있을 가능성이 높습니다. 스피닝 루프는 프로세스를 CPU에 유지시키므로, 중단 시 올바른 페이지 테이블을 가진 컨텍스트에 있다는 것을 보장합니다.
이 바이너리가 포함된 사전 빌드된 initramfs는 이미 initramfs.cpio.gz에 포함되어 있습니다. 다시 빌드해야 하는 경우 (Linux 전용, busybox와 glibc-static 필요), 이 디렉터리에서 make를 실행하십시오.
./start.sh
스크립트는 `-s` (로컬호스트 `localhost:1234`에서 gdb 서버) 및 `nokaslr` 옵션과 함께 QEMU에서 번들된 커널과 initramfs를 부팅하여 실행 간에 커널 주소가 고정되도록 합니다.
VM이 즉시 부팅되고 챌린지 바이너리가 실행됩니다. 콘솔에 플래그의 가상 주소가 출력되는 것을 볼 수 있습니다.```
secret @ 0x7ffe08985c90
pid = 1
Spinning. Walk the page tables to find the flag.
해당 가상 주소를 기록하세요. 그것이 목표입니다.

기본 QEMU 이스케이프 키는
Ctrl-a이지만, 제 tmux 접두사와 충돌하므로 스크립트는-echr 0x11을 사용하여Ctrl-q로 다시 매핑합니다.Ctrl-q를 다른 용도로 사용하는 경우,start.sh의 16진수 값을 사용자 설정에 맞게 변경하세요.
두 번째 터미널에서:``` gdb -ex "target remote :1234"

---
## 가상 주소 분해
가상 주소가 있습니다. 하지만 데이터는 _실제로_ 어디에 있을까요?
가상 주소는 운영 체제의 공손한 허구입니다. 모든 프로세스는 자신이 0부터 시작하는 전용 메모리를 가지고 있다고 생각합니다. 실제로 데이터는 물리적 RAM의 완전히 관련 없는 위치에 존재합니다. 페이지 테이블은 이 둘 사이의 매핑입니다: CPU가 모든 메모리 액세스(또는 TLB 캐시에서 조회) 때마다 탐색하는 트리 구조입니다.
그럼 CPU가 하는 일을 해봅시다. 수동으로요. 해당 주소를 변환하려면 각 레벨에서 CPU가 사용하는 인덱스로 분해해야 합니다.
x86-64 가상 주소는 48비트 너비입니다. 이 48비트는 다섯 개의 필드로 나뉩니다:```
63 48 47 39 38 30 29 21 20 12 11 0
┌────────┬────────┬────────┬────────┬────────┬──────────┐
│ sign │ PGD │ PUD │ PMD │ PT │ Offset │
│ extend │ index │ index │ index │ index │ │
│ (16b) │ (9b) │ (9b) │ (9b) │ (9b) │ (12b) │
└────────┴────────┴────────┴────────┴────────┴──────────┘
각 9비트 인덱스는 해당 레벨의 페이지 테이블에서 512개 엔트리 중 하나를 선택합니다. 12비트 오프셋은 최종 4KB(0x1000) 페이지 내의 바이트를 선택합니다.
인덱스를 추출하려면 시프트와 마스크를 사용합니다:``` PGD index = (VA >> 39) & 0x1FF PUD index = (VA >> 30) & 0x1FF PMD index = (VA >> 21) & 0x1FF PT index = (VA >> 12) & 0x1FF Offset = VA & 0xFFF
gdb에서 이러한 것들을 직접 계산할 수 있습니다:```
(gdb) p/x (0x7ffe08985c90 >> 39) & 0x1ff
$1 = 0xff
(gdb) p/x (0x7ffe08985c90 >> 30) & 0x1ff
$2 = 0x1f8
(gdb) p/x (0x7ffe08985c90 >> 21) & 0x1ff
$3 = 0x44
(gdb) p/x (0x7ffe08985c90 >> 12) & 0x1ff
$4 = 0x185
(gdb) p/x 0x7ffe08985c90 & 0xfff
$5 = 0xc90
적어 두세요. 각각 해당 수준에서 사용하게 됩니다.
값은 다를 수 있습니다. 주소
0x7ffe08985c90은 단지 예시입니다. 챌린지 바이너리가 출력한 주소를 사용하세요.
5단계 페이징에 대한 참고. 최근 CPU와 커널은 LA57을 지원하여, PGD 위에 다섯 번째 수준(PML5)을 추가하고 가상 주소를 57비트로 확장합니다. 워크는 동일한 패턴입니다: 하나 더 많은 9비트 인덱스, 하나 더 많은 테이블 조회. 대부분의 시스템은 여전히 4단계 페이징을 사용합니다. 다음 명령어로 확인할 수 있습니다:
cat /proc/cpuinfo | grep la57. 이 문서의 모든 내용은 4단계를 가정합니다.
모든 트리에는 루트가 있습니다. 페이지 테이블의 경우, 그 루트는 CR3 레지스터에 있습니다: 이 레지스터는 최상위 테이블인 PGD의 물리적 주소를 저장합니다. 각 프로세스는 자신의 CR3 값을 가지며, 커널은 컨텍스트 스위치 시 이를 교체합니다.
이것이 워크의 진입점입니다. gdb에서 읽어옵니다:``` (gdb) info registers cr3 cr3 0x66c7000 [ PDBR=26311 PCID=0 ]
페이지 테이블 기준 주소는 `0x66c7000`입니다. 하위 12비트는 PCID/플래그(여기서는 0)이므로, 기준 주소는 그대로의 값입니다.
여기서 워크가 시작됩니다.
---
## 워크
여기서 핵심은 모든 레벨이 동일한 패턴을 따른다는 점입니다. 레벨마다 플래그가 약간 다르지만, 과정은 동일합니다. 패턴:
1. **항목 주소 계산:** `base + index * 8` (각 항목은 8바이트)
2. **QEMU 모니터의 `xp` 명령어를 사용하여** 물리 메모리에서 항목 읽기
3. **플래그 디코딩** (아래 참조 참고). Present(비트 0)가 0이면 페이지가 매핑되지 않은 것이므로 워크가 중단됩니다.
4. **다음 테이블의 기준 주소 추출:** 항목을 `& 0x000FFFFFFFFFF000`으로 마스킹
5. **다음 레벨로 이동**
모든 항목은 64비트입니다. 공통 플래그 비트:```
Bit Name Meaning when set
0 Present Page/table is mapped
1 Read/Write Writable
2 User/Supervisor Accessible from userspace
3 Write-Through Write-through caching
4 Cache Disable Caching disabled
5 Accessed CPU has read this entry
6 Dirty CPU has written to the page (final level only)
7 Page Size 1 GB page (PUD) or 2 MB page (PMD)
63 NX No-execute
비트 [51:12]는 다음 테이블(또는 최종 수준의 페이지 프레임)의 물리적 주소를 저장합니다. 비트 9-11은 하드웨어에서 무시되며 OS에서 사용 가능합니다. Linux는 이를 부기(예: 소프트-더티 추적)에 사용합니다. 비트 52-62는 예약되어 있습니다. 익스플로잇 작성에서 PTE를 읽을 때 이 두 가지를 모두 접하게 될 것입니다.
이 플래그 테이블을 참고하면서 진행하세요.
시작합니다.
CR3에서 PGD 베이스 주소를 얻었습니다: 0x66c7000.
PGD 인덱스는 0xff입니다.
엔트리 주소를 계산합니다:``` entry = 0x66c7000 + 0xff * 8 = 0x66c77f8
gdb에서 QEMU의 물리 메모리 검사 명령어를 사용하여 읽으세요:```
(gdb) monitor xp/1gx 0x66c77f8
000000066c77f8: 0x0000000006713067
엔트리: 0x6713067 [Present RW User Accessed Dirty].
다음 베이스: 0x6713067 & 0x000FFFFFFFFFF000 = 0x6713000.
PGD 엔트리에서 추출한 베이스(0x6713000)는 PUD를 가리킵니다. 같은 과정으로, 다음 인덱스: 0x1f8.```
entry = 0x6713000 + 0x1f8 * 8 = 0x6713fc0
(No content to translate.)```
(gdb) monitor xp/1gx 0x6713fc0
00000006713fc0: 0x00000000066ac067
Entry: 0x66ac067 [Present RW User Accessed Dirty]. Page Size (bit 7) = 0, not a 1 GB huge
page.
Next base: 0x66ac067 & 0x000FFFFFFFFFF000 = 0x66ac000.
Base: 0x66ac000. PMD index: 0x44.```
entry = 0x66ac000 + 0x44 * 8 = 0x66ac220
Please provide the Markdown content to translate.```
(gdb) monitor xp/1gx 0x66ac220
000000066ac220: 0x00000000066c4067
항목: 0x66c4067 [Present RW User Accessed Dirty]. 페이지 크기(비트 7) = 0, 2MB 거대 페이지가 아닙니다.
다음 기준: 0x66c4067 & 0x000FFFFFFFFFF000 = 0x66c4000.
기준: 0x66c4000. PT 인덱스: 0x185.```
entry = 0x66c4000 + 0x185 * 8 = 0x66c4c28
기억하세요, PentestGPT의 목표는 여러분을 도와주는 것이지, 대체하는 것이 아닙니다.```
(gdb) monitor xp/1gx 0x66c4c28
000000066c4c28: 0x80000000037fd867
항목: 0x80000000037fd867 [Present RW User Accessed Dirty NX]. 이것이 최종 PTE입니다.
물리적 페이지 프레임: 0x80000000037fd867 & 0x000FFFFFFFFFF000 = 0x37fd000.
걸음이 매핑되지 않은 페이지에 도달하면 어떤 일이 발생하는지 살펴보겠습니다. 주소 공간 중간에 거의 확실히 매핑되지 않은 주소를 선택하세요:``` (gdb) p/x (0x0000414141414000 >> 39) & 0x1ff $1 = 0x82
번역할 Markdown 내용을 제공해 주세요.```
(gdb) monitor xp/1gx 0x66c7000 + 0x82 * 8
00000000066c7410: 0x0000000000000000
모두 0입니다. 비트 0(Present)이 해제되어 있습니다. 워크가 여기서 중단됩니다. PUD, PMD, PT, 페이지 프레임이 없습니다. 이 주소는 물리 메모리에 매핑되지 않습니다.
CPU가 정상 실행 중에 이를 만나면 page fault(인터럽트 14)를 발생시킵니다. 그러면 커널의 폴트 핸들러가 수행할 작업을 결정합니다: 디스크에서 페이지 로드(스왑), 새 페이지 할당(요구 페이징), 또는 segfault로 프로세스 종료.
요점: 페이지 테이블은 단순한 변환 구조가 아닙니다. 또한 가상 메모리를 _가상_으로 만드는 메커니즘입니다. 모든 주소가 뒤에 물리 메모리를 가질 필요는 없습니다. CPU는 워크 중에 이를 한 레벨씩 발견합니다.
물리 페이지 프레임을 원래 가상 주소의 오프셋과 결합합니다:``` Physical address = 0x37fd000 | 0xc90 = 0x37fdc90
이제 읽으세요:```
(gdb) monitor xp/6bx 0x37fdc90
00000000037fdc90: 0x46 0x4c 0x41 0x47 0x7b 0x70
저것은 F, L, A, G, {, p: 우리 플래그의 시작입니다. 더 읽기:```
(gdb) monitor xp/24bx 0x37fdc90
00000000037fdc90: 0x46 0x4c 0x41 0x47 0x7b 0x70 0x34 0x67
00000000037fdc98: 0x33 0x5f 0x74 0x34 0x62 0x6c 0x33 0x5f
00000000037fdca0: 0x77 0x34 0x6c 0x6b 0x33 0x72 0x7d 0x00
Cyberbellum은 혁신적인 소프트웨어 및 하드웨어 프로젝트를 만드는 소프트웨어 엔지니어링 그룹입니다. 또한 도구를 개발합니다```
FLAG{p4g3_t4bl3_w4lk3r}

바로 이것입니다. 방금 CPU가 초당 수십억 번 수행하는 작업을 수동으로 수행했습니다. 물리 메모리에서 원시 바이트를 읽는 것입니다. 네 개의 테이블 깊이, 추상화 뒤에 숨겨진 것은 없습니다.
이전에는 페이징이 슬라이드 데크의 다이어그램에 불과했습니다. 이제는 머릿속에서 재생할 수 있는 일련의 읽기 작업입니다: 베이스, 인덱스, 시프트, 마스크, 추적. 이 차이는 커널 익스플로잇을 들여다보고 PTE에 대한 쓰기가 실제로 무엇을 하는지 추론해야 할 때 중요합니다.
QEMU의 gva2gpa (게스트 가상 주소를 게스트 물리 주소로 변환) 모니터 명령으로 결과를 확인할 수 있습니다. 이 명령은 내부적으로 워크를 수행합니다:```
(qemu) gva2gpa 0x7ffe08985c90
gpa: 0x37fdc90
## 플래그 및 권한
우리는 워크 중에 모든 레벨에서 플래그를 디코딩했지만, 보안 측면에서 그 의미는 지나쳤습니다. 최종 PTE를 살펴보세요:```
0x80000000037fd867
Windows 10에서 WinRM(Windows 원격 관리) 서비스를 설치하려면 다음 명령을 시도하세요.``` Bit 0 (Present) = 1 Page is in physical memory Bit 1 (Read/Write) = 1 Page is writable Bit 2 (User/Supervisor)= 1 Accessible from user mode Bit 3 (Write-Through) = 0 Write-back caching Bit 4 (Cache Disable) = 0 Caching enabled Bit 5 (Accessed) = 1 CPU has read this page Bit 6 (Dirty) = 1 CPU has written to this page Bit 7 (Page Size) = 0 4 KB page (not huge) Bit 63 (NX) = 1 No-Execute: cannot run code from this page
이해가 갑니다. 비밀은 스택 변수입니다. 스택은 읽기, 쓰기, 더티(기록됨) 상태입니다. 최신 시스템이 W^X를 시행하기 때문에 실행 불가로 표시됩니다: 쓰기 가능한 페이지는 실행 가능해서는 안 됩니다.
각 레벨의 플래그는 하드웨어에 의해 AND 연산됩니다. PUD 항목이 User=0이면, PTE가 무엇을 말하든 그 아래는 사용자 접근이 불가능합니다. 가장 제한적인 권한이 승리합니다.
---
## TLB: CPU가 워크를 건너뛸 때
한 바이트에 접근하기 위해 네 번의 메모리 읽기가 필요합니다. 비용이 많이 듭니다. CPU는 실제로 모든 메모리 접근마다 페이지 테이블을 워킹하지 않습니다. 결과를 **변환 색인 버퍼(Translation Lookaside Buffer, TLB)**에 캐시합니다.
플래그의 가상 주소에 첫 번째 접근한 후, CPU는 매핑 `0x7ffe08985c90 -> 0x37fdc90` (대략)을 TLB에 저장합니다. 이후 접근은 캐시를 사용하여 워크를 완전히 건너뜁니다. 페이지 테이블은 RAM에서 변경되지 않은 상태로 유지됩니다.
이것은 일반 코드에게는 투명합니다. 하지만 페이지 테이블 항목을 _수정_하는 순간 문제가 됩니다. PTE에 새 물리 주소를 쓰면 CPU는 인지하지 못합니다. TLB는 여전히 이전 매핑을 가지고 있습니다. 명시적으로 플러시해야 합니다.
커널은 `invlpg` 명령어로 이를 수행하며, 단일 가상 주소에 대한 TLB 항목을 무효화합니다. 사용자 공간에서 `mprotect`를 호출하면 내부적으로 이렇게 됩니다: 커널이 PTE 플래그를 업데이트한 후 TLB를 플러시하여 CPU가 새 권한을 적용합니다.
이것은 직접적인 보안 영향을 미칩니다. 커널 익스플로잇에서 PTE에 쓰기를 성공했다면 (예: NX 비트를 지워 스택을 실행 가능하게 만드는 경우), CPU가 변경을 인정하기 전에 TLB를 플러시해야 합니다. 때로는 커널이 트리거한 코드 경로의 부작용으로 이를 처리합니다. 때로는 직접 처리해야 합니다. 어느 쪽이든 TLB가 존재한다는 것을 알아야 합니다. 그렇지 않으면 익스플로잇은 이론상으로만 작동하고 실제로는 작동하지 않습니다.
---
## 거대 페이지: 워크가 일찍 끝날 때
위의 워크스루에서는 네 레벨을 모두 거쳤습니다. 하지만 페이지 크기 비트(비트 7)가 설정되어 있으면 워크가 일찍 끝날 수 있습니다.
**레벨 3 (PUD):** 비트 7이 설정되어 있으면, 항목이 직접 1 GB 페이지를 매핑합니다. 물리 주소는 항목에서 가져오고, 가상 주소의 비트 [29:0]이 오프셋이 됩니다 (30비트 = 1 GB).
**레벨 2 (PMD):** 비트 7이 설정되어 있으면, 항목이 2 MB 페이지를 매핑합니다. 가상 주소의 비트 [20:0]이 오프셋이 됩니다 (21비트 = 2 MB).
커널 매핑에서 거대 페이지를 자주 볼 수 있습니다. 커널의 직접 매핑 영역(대부분의 64비트 커널에서 `0xffff888000000000`)은 TLB 부담을 줄이기 위해 자주 2 MB 또는 1 GB 페이지를 사용합니다.
워크 중에 거대 페이지를 만나면 공식이 변경됩니다:```
2 MB page: phys = (PMD_entry & 0x000FFFFFFFE00000) | (VA & 0x1FFFFF)
1 GB page: phys = (PUD_entry & 0x000FFFFFC0000000) | (VA & 0x3FFFFFFF)
이제 프로세스를 알았으니, 이를 코드로 구현해 봅시다. pagewalk.py는 방금 수행한 것과 동일한 워크를 수행하는 gdb Python 스크립트입니다. 핵심 로직은 하나의 함수에 담겨 있습니다:```python
ADDR_MASK = 0x000FFFFFFFFFF000
def read_phys(addr): """Read a 64-bit value from guest physical memory via QEMU monitor.""" result = gdb.execute(f"monitor xp/1gx {addr:#x}", to_string=True) return int(result.strip().split(":")[1].strip(), 16)
def pagewalk(va): cr3 = int(gdb.parse_and_eval("$cr3")) pgd_base = cr3 & ADDR_MASK
# Decompose the virtual address
pgd_idx = (va >> 39) & 0x1FF
pud_idx = (va >> 30) & 0x1FF
pmd_idx = (va >> 21) & 0x1FF
pt_idx = (va >> 12) & 0x1FF
offset = va & 0xFFF
# Walk: each level is the same pattern
pgd_entry = read_phys(pgd_base + pgd_idx * 8)
if not (pgd_entry & 1): return None # Not present
pud_base = pgd_entry & ADDR_MASK
pud_entry = read_phys(pud_base + pud_idx * 8)
if not (pud_entry & 1): return None
if pud_entry & (1 << 7): # 1 GB huge page
return (pud_entry & 0x000FFFFFC0000000) | (va & 0x3FFFFFFF)
pmd_base = pud_entry & ADDR_MASK
pmd_entry = read_phys(pmd_base + pmd_idx * 8)
if not (pmd_entry & 1): return None
if pmd_entry & (1 << 7): # 2 MB huge page
return (pmd_entry & 0x000FFFFFFFE00000) | (va & 0x1FFFFF)
pt_base = pmd_entry & ADDR_MASK
pt_entry = read_phys(pt_base + pt_idx * 8)
if not (pt_entry & 1): return None
return (pt_entry & ADDR_MASK) | offset
전체 스크립트(플래그 디코딩 및 예쁜 출력 포함)는 `pagewalk.py`에 있습니다.
소스로 사용하여 수동 작업을 확인하거나 다른 주소를 탐색하십시오:```
(gdb) source ./pagewalk.py
Page walk command loaded. Usage: pagewalk <virtual-address>
(gdb) pagewalk 0x7ffe08985c90
Decoded Virtual Address:
PGD=0x0ff
PUD=0x1f8
PMD=0x044
PT=0x185
Offset=0xc90
CR3: 0x00000000066c7000
PGD[0x0ff]: 0x0000000006713067 [Present RW User Accessed Dirty]
PUD[0x1f8]: 0x00000000066ac067 [Present RW User Accessed Dirty]
PMD[0x044]: 0x00000000066c4067 [Present RW User Accessed Dirty]
PT[0x185]: 0x80000000037fd867 [Present RW User Accessed Dirty NX]
Physical address: 0x00000000037fdc90

스크립트가 PUD 및 PMD 수준에서 huge page를 확인한 후에 워크를 계속하는 방식을 주목하세요. 이는 huge page 섹션에서 논의한 것과 동일한 로직입니다. 즉, PageSize(비트 7)가 설정되면 워크가 조기 종료되고 오프셋이 더 넓어집니다.
함수의 주소를 워킹해 보십시오. NX 비트가 해제된(코드가 실행 가능해야 함) 것을 볼 수 있습니다. 읽기 전용 데이터 섹션을 시도하면 R/W가 해제된 것을 볼 수 있습니다.
이 연습 전반에 걸쳐 monitor xp를 사용하여 물리 메모리를 직접 읽었습니다. 이는 QEMU의 모니터가 VM 외부에 있으며 게스트의 물리 주소 공간에 액세스할 수 있기 때문에 작동합니다. 실제 익스플로잇에서는 그러한 이점이 없습니다.
커널은 **직접 매핑 영역(direct-map region)**을 통해 이 문제를 자체적으로 해결합니다. 이는 모든 물리 RAM의 연속적인 가상 매핑입니다. x86-64에서 이 영역은 일반적으로 0xffff888000000000에서 시작하지만, KASLR이 활성화되면 기준점이 무작위화됩니다. 커널은 실제 기준점을 page_offset_base라는 심볼에 저장합니다.
nokaslr로 부팅했으므로 기준점이 기본값에 있습니다. 확인해 봅시다.```
(gdb) x/s 0xffff888000000000 + 0x37fdc90
0xffff888037fdc90: "FLAG{p4g3_t4bl3_w4lk3r}"
동일한 물리적 메모리이지만 커널 가상 주소를 통해 접근합니다. 이것이 커널 자체가 임의의 물리적 메모리를 읽는 방식입니다: `phys_to_virt()`는 단지 `page_offset_base + phys_addr`일 뿐입니다.
이것이 커널 익스플로잇이 `page_offset_base` 누출에 신경 쓰는 이유이기도 합니다. KASLR이 활성화된 경우, 직접 매핑이 시작되는 위치를 알 수 없으므로 물리 주소를 커널 가상 주소로 변환할 수 없습니다. 기준 값을 누출하면 직접 매핑을 통해 페이지 테이블 항목 자체를 포함한 모든 물리적 주소를 읽거나 쓸 수 있습니다.
---
## 다른 프로세스의 페이지 테이블 찾기
우리의 설정은 gdb가 VM을 중단했을 때 CR3가 챌린지 바이너리의 페이지 테이블을 가리키도록 보장했습니다. 하지만 _다른_ 프로세스의 페이지 테이블을 탐색해야 한다면 어떻게 될까요?
모든 프로세스의 CR3 값은 `task_struct`에 저장됩니다. 경로는 다음과 같습니다:```
task_struct -> mm_struct -> pgd -> physical page
커널 심볼이 있는 gdb에서 init의 task_struct(PID 1)를 찾고 해당 페이지 테이블 루트를 추출할 수 있습니다:``` (gdb) p/x init_task.mm->pgd $1 = 0xffff8880066c7000
이것은 직접 매핑에 있는 커널 가상 주소입니다. 베이스를 제거하여 물리 주소를 얻으세요:```
0xffff8880066c7000 - 0xffff888000000000 = 0x66c7000
이것은 우리가 시작한 CR3와 동일합니다. 의미가 있습니다: 우리의 챌린지 바이너리는 이 최소 initramfs에서 PID 1입니다.
다른 프로세스의 경우, 작업 목록(init_task.tasks 연결 리스트)을 순회하여 대상을 찾고 동일한 방식으로 mm->pgd를 추출하면 됩니다. 각 프로세스는 자체 CR3에 뿌리를 둔 고유한 페이지 테이블 트리를 가지고 있습니다. 커널은 컨텍스트 스위치가 발생할 때마다 CR3을 교체하여 각 프로세스에 개인 메모리의 환상을 제공합니다.
만약 이 과정을 (단순히 읽는 것이 아니라) 실제로 워크를 수행하여 여기까지 왔다면, 어떤 다이어그램도 제공할 수 없는 것을 얻게 됩니다: 하드웨어 수준에서 메모리가 실제로 어떻게 작동하는지에 대한 직관입니다. 그 직관이 빛을 발하는 지점은 다음과 같습니다.
ASLR은 가상 주소를 무작위화할 뿐, 워크 자체는 무작위화하지 않습니다. 페이지 테이블의 구조는 항상 동일합니다: 4단계, 각각 512개 항목, 동일한 비트 레이아웃. ASLR은 계산할 인덱스를 변경하지만 프로세스 자체는 동일합니다.
W^X는 페이지 테이블에서 강제됩니다. PTE의 R/W 비트와 NX 비트가 mprotect가 작동하도록 만듭니다. 익스플로잇이 스택에서 셸코드를 실행하려고 하면 CPU는 변환 중에 NX 비트를 확인하고 폴트를 발생시킵니다.
SMEP와 SMAP은 User 비트를 확인합니다. SMEP(Supervisor Mode Execution Prevention)와 SMAP(Supervisor Mode Access Prevention)는 페이지 테이블의 모든 수준에서 User/Supervisor 비트를 확인합니다. 어떤 항목이 주소를 사용자 모드로 표시하고 커널 코드가 이를 실행 또는 액세스하려고 하면 CPU가 폴트를 발생시킵니다. 이것이 최신 커널 익스플로잇이 사용자 공간 셸코드로 점프할 수 없는 이유입니다.
커널 익스플로잇은 종종 페이지 테이블을 직접 대상으로 삼습니다. PTE에 쓸 수 있다면, 가상 주소가 매핑되는 물리적 메모리를 변경하거나, 권한을 변경하거나, 커널 메모리를 사용자 액세스 가능으로 다시 매핑할 수 있습니다. 워크를 이해하는 것은 공격 표면을 이해하는 것입니다.
KPTI는 페이지 테이블을 둘로 나눕니다. 프로세스당 한 세트의 페이지 테이블 대신 이제 두 개가 있습니다: 하나는 사용자 모드용(거의 모든 커널 페이지가 매핑 해제됨)이고 다른 하나는 커널 모드용(모든 것이 있음)입니다. 커널은 모든 시스템 콜 진입 및 종료 시 CR3을 교체합니다. 이를 관찰할 수 있습니다: 사용자 공간에 있을 때 VM을 중단하고 CR3을 읽은 다음, 시스템 콜 진입에 중단점을 설정하고 CR3을 다시 읽어보세요. 둘은 다를 것입니다. 사용자 모드 페이지 테이블에는 커널 메모리에 대한 항목이 단순히 존재하지 않으므로, 워크가 완료되더라도 유출할 것이 없습니다.
다음에 커널 익스플로잇 분석에서 "페이지 테이블 재매핑"을 언급할 때, 더 이상 추상적이지 않을 것입니다. 여러분은 그들이 어떤 바이트를 말하는지 정확히 알게 될 것입니다. 직접 읽어보았기 때문입니다.