Skip to content
KitploitKITPLOIT
ToolsBlog
Log in
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
page_table_walk — Walk x86-64 page tables by hand in qemu and gdb. Decompose a virtual address, follow cr3 through all levels of physical memory, and extract a flag from raw bytes. | Kitploit
Tools/GitHubGitHub/jazho76/page_table_walk
Memory ForensicsReverse EngineeringDebuggersCTFBinary AnalysisLearning & EducationLabs & Practice
GitHubjazho76/page_table_walk

page_table_walk

Walk x86-64 page tables by hand in qemu and gdb. Decompose a virtual address, follow cr3 through all levels of physical memory, and extract a flag from raw bytes.

View Repository
281126 months agoReviewed by Kitploit

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

Capture the Flag in Physical Memory

You've read about paging. The diagrams make sense. Four levels, 9 bits each, page frame, offset. Sure. But then you hit a challenge that requires actually walking page tables, and you realize you don't know it. You know about it. Big difference.

What worked for me was sitting in front of QEMU and gdb and doing the walk myself: computing every index, reading every entry from physical memory, following every pointer by hand. One afternoon of that could teach more than hours of lectures.

This is a collection of my notes from that process. If you're still missing the conceptual side, watch Zardus's lecture on kernel memory management first. That's the theory. This is the lab.

The goal: take a virtual address and chase it through raw physical memory until we find the data. No kernel helpers. No abstractions. Just a QEMU VM, gdb, and raw physical memory.

By the end, paging won't be something you read about, it'll be something you know because you did it by hand.


Setting up the lab

A pre-built kernel and initramfs are included, I ran this in Fedora, but any OS that runs QEMU and gdb should work. Install them with your package manager:

# Ubuntu/Debian
sudo apt install qemu-system-x86 gdb

# Fedora/RHEL
sudo dnf install qemu-system-x86 gdb

# macOS
brew install qemu gdb

The challenge binary

The target is a trivial C program that stores a flag in memory and prints its virtual address:

#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)
    {
    }
}

The busy loop is intentional. I originally used pause(), but that puts the process to sleep in a syscall: when gdb halts the VM, the CPU is likely running the idle task with a different CR3. A spinning loop keeps the process on-CPU, so halting guarantees you're in its context with the right page tables.

A pre-built initramfs with this binary is already included in initramfs.cpio.gz. If you need to rebuild it (Linux only, requires busybox and glibc-static), run make in this directory.

Launching QEMU

./start.sh

The script boots the bundled kernel and initramfs under QEMU with -s (gdb server on localhost:1234) and nokaslr so kernel addresses stay fixed between runs.

The VM boots immediately and the challenge binary runs. You'll see the flag's virtual address printed to the console.

secret @ 0x7ffe08985c90
pid = 1
Spinning. Walk the page tables to find the flag.

Write down that virtual address. That's your target.

QEMU terminal after boot, showing the challenge binary's output with the flag address and PID

The default QEMU escape key is Ctrl-a, but that collides with my tmux prefix, so the script uses -echr 0x11 to remap it to Ctrl-q. If you use Ctrl-q for something else, change the hex value in start.sh to suit your setup.

Attaching gdb

In a second terminal:

gdb -ex "target remote :1234"

gdb terminal after attaching, halted and ready


Decomposing the virtual address

You have a virtual address. But where's the data, really?

Virtual addresses are the operating system's polite fiction. Every process thinks it has its own private memory starting from zero. In reality, the data lives at some completely unrelated location in physical RAM. The page table is the map between the two: a tree structure that the CPU walks on every single memory access (or looks up from its TLB cache).

So let's do what the CPU does. Manually. To translate that address, we need to decompose it into the indices the CPU uses at each level.

An x86-64 virtual address is 48 bits wide. Those 48 bits are split into five fields:

 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)   │
└────────┴────────┴────────┴────────┴────────┴──────────┘

Each 9-bit index selects one of 512 entries in a page table at that level. The 12-bit offset selects a byte within the final 4 KB (0x1000) page.

To extract the indices, shift and mask:

PGD index  = (VA >> 39) & 0x1FF
PUD index  = (VA >> 30) & 0x1FF
PMD index  = (VA >> 21) & 0x1FF
PT  index  = (VA >> 12) & 0x1FF
Offset     =  VA        & 0xFFF

In gdb, you can compute these directly:

(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

Write these down. You'll use each one at its corresponding level.

Your values will differ. The address 0x7ffe08985c90 is just an example. Use whatever address your challenge binary printed.

A note on 5-level paging. Recent CPUs and kernels support LA57, which adds a fifth level (PML5) above the PGD and extends virtual addresses to 57 bits. The walk is the same pattern: one more 9-bit index, one more table lookup. Most systems still run 4-level paging. You can check yours: cat /proc/cpuinfo | grep la57. Everything in this article assumes 4-level.


Finding CR3: the root of the tree

Every tree has a root. For page tables, that root is in the CR3 register: it holds the physical address of the top-level table, the PGD. Every process gets its own CR3 value, the kernel swaps it on context switch.

This is our entry point into the walk. Read it from gdb:

(gdb) info registers cr3
cr3            0x66c7000           [ PDBR=26311 PCID=0 ]

The page table base is 0x66c7000. The low 12 bits are PCID/flags (zero here), so the base address is the value as-is.

This is where the walk starts.


The walk

Here's the trick: every level follows the same pattern. The flags vary slightly between levels, but the process doesn't. The pattern:

  1. Compute the entry address: base + index * 8 (each entry is 8 bytes)
  2. Read the entry from physical memory using QEMU monitor's xp command
  3. Decode the flags (see the reference below). If Present (bit 0) is 0, the page isn't mapped and the walk stops
  4. Extract the next table's base: mask the entry with & 0x000FFFFFFFFFF000
  5. Move to the next level

Every entry is 64 bits. The common flag bits:

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

Bits [51:12] hold the physical address of the next table (or the page frame at the final level). Bits 9-11 are ignored by hardware and available for OS use. Linux uses them for bookkeeping (soft-dirty tracking, for example). Bits 52-62 are reserved. You'll encounter both when reading PTEs in exploit writeups.

Keep this flag table handy as you walk.

Let's go.

Level 4: PGD (Page Global Directory)

We have the PGD base from CR3: 0x66c7000. Our PGD index is 0xff.

Compute the entry address:

Download Tool