
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는 워크 중에 이를 한 레벨씩 발견합니다.