System Calls and Privilege Checks
Privilege bits, page permissions, API stubs, handle checks, and NTSTATUS together explain one request's route through the kernel.
Cheatsheet for Lesson 12.6
A one-page summary of “System Calls and Privilege Checks” you can keep beside your notes. Plain Markdown.
Lesson 12.6 — System Calls and Privilege Checks: cheatsheet
- Remember: An existing handle keeps its granted rights; a later operation cannot add permissions by asking more forcefully.
- Also useful: An access mask is a stencil: retain the requested bits that were granted, then confirm every required bit survived.
Key terms
syscall: The most important of them is thesyscallinstruction, and the rest of this lesson follows one trip through it.- stub: The
ntdllfunction is a stub: a few instructions whose only job is to make the system call. eax: The number ineaxis a system service number, an index into the kernel's table of routines.- system service number: The number in
eaxis a system service number, an index into the kernel's table of routines. ret: The kernel places a status code inraxand executessysret, the reverse ofsyscall: the level drops back to 3, and execution resumes at the address saved inrcx, which is theretright aftersyscallin the stub.- NTSTATUS: The status is an NTSTATUS, a 32-bit code whose top two bits give its severity:
00success,01information,10warning,11error. STATUS_ACCESS_DENIED:STATUS_ACCESS_DENIEDis0xC000_0022; hexCis binary1100, so its top two bits are11, an error.C:STATUS_ACCESS_DENIEDis0xC000_0022; hexCis binary1100, so its top two bits are11, an error.STATUS_PARTIAL_COPY:STATUS_PARTIAL_COPYis0x8000_000D; hex8is1000, so its top two bits are10, a warning, because some of the bytes did arrive.
Review
- Q: Kernel pages have their user/supervisor bit cleared, so ring-3 code faults if it touches them. Why must the kernel's
NtReadVirtualMemorystill check that the requested range and the caller's buffer lie in user space? A: The copy runs in ring 0, where supervisor pages are allowed, so an unchecked range could expose kernel memory - Q: A 32-bit game running on 64-bit Windows shows
cs=0023in x64dbg. At which privilege level is its code running? A: Ring 3, because0x23is0010 0011and its lowest two bits are11
Lesson 12.4 showed the processor keeping user-mode code away
from the kernel and drew one ReadProcessMemory call crossing into the kernel
and back. This lesson opens both up. It reads the bits the processor actually
checks, then follows that one call a layer at a time, with every value the
kernel compares along the way.
The current level is two bits of a register
Section titled “The current level is two bits of a register”The processor keeps the current privilege level in the lowest two bits of the
cs register, which tells the processor which code segment it is running. Two
bits can hold 2 × 2 = 4 patterns, 00, 01, 10, and 11, which is
exactly why there are four rings.
Open any 64-bit program in x64dbg and the register panel shows cs as 0033:
3311 = 3cs = 0x33 in a 64-bit program. The upper bits choose a code segment; the lowest two bits are the current privilege level.0x33 is 3 × 16 + 3 = 51. Each hex digit stands for four bits, so 0x33 is
0011 0011. Its lowest two bits are 11, which is 2 + 1 = 3: ring 3, user
mode. The kernel’s own code segment is 0x10, which is 0001 0000; its lowest
two bits are 00, ring 0.
A program cannot promote itself by loading 0x10 into cs. The processor
refuses any change of code segment that would raise the privilege level, except
through a few entry points that the kernel prepared in advance. The most
important of them is the syscall instruction, and the rest of this lesson
follows one trip through it.
One bit marks a page as the kernel’s
Section titled “One bit marks a page as the kernel’s”Lesson 12.4 said that a page-table entry’s user/supervisor bit, bit 2, decides whether ring 3 may touch the page. An entry combines the address of a physical page with flags describing that mapping. For the ordinary 4 KiB pages used here, the page address is aligned on a 4,096-byte boundary, leaving the low twelve bits available for flags. Chapter 12’s address-translation lesson will follow a complete walk; here we need only the permission flags in the lowest byte:
0x67.0x67 is 6 × 16 + 7 = 103, and 103 = 64 + 32 + 4 + 2 + 1, so bits 6, 5, 2, 1,
and 0 are set. Bit 0 says the page is present, bit 1 that it may be written,
and bit 2 that ring 3 may use it. The processor itself sets bit 5, accessed,
the first time the page is used, and bit 6, dirty, the first time it is
written. Bits 3, 4, and 7 control caching and are 0 here.
A page that holds kernel data looks almost the same:
0x63. Only bit 2 differs, so ring 3 is refused.0x67 − 0x63 = 4, and 4 = 2², so exactly one bit differs between these two
example leaf entries: bit 2. A kernel page can be present, writable, and
recently used, and still refuse access from ring 3. In a real page-table walk,
the processor also considers permissions in the entries at higher levels; a
single leaf byte is a teaching example, not a complete access decision.
The leaf byte and the decision
Leaf byte 0x67
Decision at this level
Not the whole decision
0x67 has bit 2 set, so ring 3 is allowed by this level.
The same call, one layer at a time
Section titled “The same call, one layer at a time”Take a read like the ones in Lesson 6.3: a handle to the game
opened with only query and read rights, and a request for 4 bytes at address
0x0050_1000. Lesson 12.4 drew the whole round trip as one sequence diagram,
from your tool through kernel32.dll, kernelbase.dll, and ntdll.dll to the
processor and the kernel, and back. This section follows that diagram one
participant at a time.
Inside your process
Section titled “Inside your process”ReadProcessMemory in kernel32.dll forwards to kernelbase.dll
(Lesson 12.5), which calls NtReadVirtualMemory in
ntdll.dll with the same arguments. The ntdll function is a stub: a few
instructions whose only job is to make the system call. On 64-bit Windows its
core is four instructions:
mov r10, rcx ; the first argument moves out of rcxmov eax, <number> ; the system service number: which kernel routine to runsyscall ; enter the kernelret ; back to kernelbase, with the status in eaxThe first line exists because of the first thing syscall does. The Windows
x64 calling convention passes a function’s first argument in rcx, but
syscall overwrites rcx with the return address, so the stub moves the
argument to r10 first, and the kernel knows to look for it there.
The number in eax is a system service number, an index into the kernel’s
table of routines. The numbers are internal to Windows and change between
builds, which is why this book never copies one into a program. In a debugger
the stub also shows a test and a jump to an older entry instruction, int 2Eh,
that some configurations use; either way, the processor arrives at an address
the kernel chose.
Crossing over
Section titled “Crossing over”The kernel’s entry code assumes that everything user mode handed it could be
hostile. It switches from the program’s stack to a kernel stack that belongs to
the thread, because the program’s stack pointer could point anywhere. It saves
the user registers so they can be put back exactly. Then it looks the service
number up in its table, rejects a number that is out of range, and calls the
kernel’s own NtReadVirtualMemory.
Inside the kernel
Section titled “Inside the kernel”Most of the kernel’s work is checking.
The address ranges must lie inside user space. The request covers
0x0050_1000 up to 0x0050_1000 + 4 = 0x0050_1004, and the tool’s buffer is
also a user address. Both are far below the top of user space, which is a
little under 0x0000_8000_0000_0000 on 64-bit Windows. A range that reached
into the kernel’s half, or one long enough to run past the largest 64-bit
number and wrap around to a small address, would be rejected here. The
user/supervisor bit cannot do this job for the kernel: the copy runs in ring 0,
and ring 0 may touch supervisor pages. So the kernel checks every range itself,
and that check is why ReadProcessMemory cannot read kernel memory, whatever
handle you hold.
The handle must carry the right the operation needs. The kernel finds the
handle in the tool’s handle table and reads the rights granted when the handle
was opened. Reading needs PROCESS_VM_READ, which is 0x0010. This handle was
opened with PROCESS_QUERY_LIMITED_INFORMATION and PROCESS_VM_READ, so its
rights are 0x1000 + 0x0010 = 0x1010. Written as bits, one byte at a time:
100x1010. 0x1000 is 16³ = 4,096 = 2¹², so it is bit 12.100x0010 is 16 = 2⁴, bit 4, the read right. Bit 5, the write right, is 0.The test is a single AND. The kernel keeps only the bits that are set in both
the handle’s rights and the rights it needs, and allows the operation only if
every needed bit survived. 0x1010 AND 0x0010 = 0x0010, the whole
requirement, so the read is allowed.
WriteProcessMemory needs PROCESS_VM_WRITE, 0x0020. That is 2 × 16 = 32 =
2⁵, bit 5, and bit 5 of 0x1010 is 0, so 0x1010 AND 0x0020 = 0: denied,
whatever the tool’s author intended. The rights were fixed when the handle was
created, and nothing done with the handle afterward can add to them.
Only then does it copy. The source address belongs to the game’s address space and the destination buffer belongs to the tool’s. The kernel must interpret each in the correct process context while copying; the exact internal mapping and copy strategy is a Windows implementation detail. If a page becomes unavailable during the operation, the API may report a failed or partial read. The caller must check the returned byte count instead of assuming that asking for four bytes means four arrived.
Returning
Section titled “Returning”The kernel places a status code in rax and executes sysret, the reverse of
syscall: the level drops back to 3, and execution resumes at the address saved
in rcx, which is the ret right after syscall in the stub.
The status is an NTSTATUS, a 32-bit code whose top two bits give its
severity: 00 success, 01 information, 10 warning, 11 error.
STATUS_SUCCESS is 0. STATUS_ACCESS_DENIED is 0xC000_0022; hex C is
binary 1100, so its top two bits are 11, an error. STATUS_PARTIAL_COPY is
0x8000_000D; hex 8 is 1000, so its top two bits are 10, a warning,
because some of the bytes did arrive. kernelbase.dll translates the status
into a Win32 error code for GetLastError, 5 for access denied and 299 for a
partial copy, and reports success or failure to your tool.
Successful status and exact byte count differ. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
read the selected inputs
evaluate each stated condition
combine the required conditions
perform the effect only when accepted
otherwise retain the old state
report the result and its limitsResult: all four bytes arrived in the tool’s separate buffer.
Final values: right = yes; copied = 4; status = yes; read_granted = true; status_success = true; requested = 4; exact = true; accepted = true; decoded = complete four-byte value.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
right = yes; copied = 4; status = yes.
Step 2, line 2. A caller must check permission, status and the count before interpreting a complete field.
right = yes; copied = 4; status = yes; read_granted = true; status_success = true; requested = 4; exact = true.
Step 3, line 3. Accept only when the required conditions all hold.
right = yes; copied = 4; status = yes; read_granted = true; status_success = true; requested = 4; exact = true; accepted = true.
Step 4, line 4. The model performs the accepted effect.
right = yes; copied = 4; status = yes; read_granted = true; status_success = true; requested = 4; exact = true; accepted = true; decoded = complete four-byte value.
Step 5, line 6. Result: all four bytes arrived in the tool’s separate buffer.
right = yes; copied = 4; status = yes; read_granted = true; status_success = true; requested = 4; exact = true; accepted = true; decoded = complete four-byte value.
A native 64-bit read request moves from user-mode wrappers into the controlled kernel entry. Windows checks the four-byte range and read rights, copies four symbolic bytes from game memory into a separate tool buffer, and returns status zero and byte count four for the caller to check.
Read the steps
- The tool requests four bytes starting at game address 0x00501000. Its destination buffer is separate. The symbols b0 through b3 stand for those four bytes, not chosen byte values.
- ReadProcessMemory passes through kernel32 and kernelbase to the ntdll stub. The moving request remains above the privilege boundary: every wrapper is still user-mode code.
- syscall enters the prepared kernel path. Entry code saves the user context, switches to the kernel stack, validates the service number, and dispatches this request.
- The four-byte range ends at 0x00501000 + 4 = 0x00501004. The existing handle passes the read-right check: 0x1010 AND 0x0010 equals the full required 0x0010.
- Windows interprets the source in the game address space and the destination in the tool address space. Copies of all four bytes travel to the tool’s buffer; the game keeps its bytes.
- This successful path returns NTSTATUS 0 and a byte count of 4 through sysret and the application wrappers. The tool checks both success and the returned count; a partial copy would not make a four-byte value valid.
b0–b3 identify the requested bytes without inventing their contents. Their moving copies illustrate process context; the exact Windows copy implementation is unspecified.
References: process security and access rights, using NTSTATUS values, and the Intel Software Developer Manuals for syscall, privilege levels, and page-table bits.
The process handle in this example authorized a copy between two processes. The processor supplied a controlled entrance; Windows checked the request inside it. The next lesson returns to the process view and saves a debugging snapshot of threads, modules, and registers in a dump. Chapter 12 then follows requests across DLL and driver boundaries, where the same checks on authority, arguments, and returned results still apply.
Why the kernel checks addresses itself
Kernel pages have their user/supervisor bit cleared, so ring-3 code faults if it touches them. Why must the kernel's NtReadVirtualMemory still check that the requested range and the caller's buffer lie in user space?
The user/supervisor bit stops code running at ring 3. The copy itself runs in ring 0, inside the kernel, where the processor allows access to supervisor pages. If the kernel accepted a range in its own half of the address space, or one long enough to wrap around to a small address, it would copy kernel memory out to the caller or write the caller's bytes over kernel memory. So the kernel checks every address that user mode hands it before using it.
Finished Lesson 12.6?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.