Skip to content
Lesson 6.3Windows Processes and Callable Interfaces

Process Access Rights

A process ID names a target; tokens, integrity levels, DACLs, and access masks decide which operations Windows permits.

Lesson 6.3
Read or listenPrint chapter or book
About 26 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 6.3Key ideas, reference and questions

A one-page summary of “Process Access Rights” you can keep beside your notes. Plain Markdown.

Lesson 6.3 — Process Access Rights: cheatsheet

  • Remember: A process handle carries specific granted operations; knowing the PID cannot expand those permissions.
  • Also useful: The PID names the building, while the handle is a key issued for particular doors. Finding the address does not provide that key.

Key terms

  • integrity level: An integrity level is a boundary used by Windows to limit which processes may influence others.
  • PROCESS_ALL_ACCESS: PROCESS_ALL_ACCESS is not a convenient “make it work” flag.
  • query_full_process_image_name: OwnedHandle owns the returned handle, and query_full_process_image_name is the path-query helper below.
  • Least privilege: Least privilege means requesting only the capability the next call uses.
  • unsafe: The compiler cannot prove what the operating system will do, so the call is unsafe.

Review

  • Q: A tool only needs to list a process's mapped regions. What access principle should guide OpenProcess? A: Request only the query rights that this one operation needs
  • Q: You have found a running game's PID. What does that give you? A: A way to name the process in OpenProcess, but no access to its memory
Download cheatsheet (.md)

A PID identifies a process but grants no access

Section titled “A PID identifies a process but grants no access”

A build hash can tell us which executable we intend to inspect, and a PID can tell us which running instance to ask about. Neither lets a program read its memory. Windows makes access a separate decision when it issues a process handle.

A process ID identifies a running process. It does not grant access to that process. To ask Windows for a usable process handle, a program calls OpenProcess with an access mask: a group of bits describing the operations it wants.

Windows checks whether the caller may obtain those rights to the target process. The call either returns a handle carrying the granted rights or fails with an error:

target's DACL allows it
target's DACL denies it
PID
OpenProcess access check
requested access mask
caller's access token
and integrity level
handle carrying
the granted rights
error

The handle is not the process itself. It is a Windows-managed reference plus a specific set of permissions.

Think of the PID as the number on a catalogue entry and the handle as a claim ticket issued for a particular visit. Knowing the number lets you ask; Windows checks the caller and the target before issuing a ticket marked with the allowed operations. The analogy ends there: a handle is an opaque value meaningful to Windows in this process, and an API checks its recorded rights, not a handwritten label. This is why finding the right PID does not rescue a handle that lacks PROCESS_VM_READ.

The call that issues the handle takes three arguments:

OpenProcess, argument by argument

dwDesiredAccessaccess maskrequired

The rights to ask for, as bits ORed together. Ask for the smallest set the job needs; the table further down lists the rights this course uses.

bInheritHandlebooldefault false

Whether a process this one starts later inherits the handle. The course passes false, so nothing a tool launches quietly receives access.

dwProcessIdu32required

The PID of the target. It names the process and grants nothing by itself.

return valuehandle, or an error

A handle carrying the granted rights, or an error such as “access denied” when Windows refuses the requested access. Close a handle you no longer need.

Optional: practise the three-argument call below. For a supplied PID, this helper asks only for basic process-query access and disables inheritance. A successful call gives it an owned HANDLE, which it immediately closes; an error returns through ?. Real inspectors keep an ownership guard when more fallible work happens between opening and closing. These are the course’s windows 0.62 projected signatures, with the paired handle-release contract.

use windows::Win32::{
Foundation::{CloseHandle, HANDLE},
System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION},
};
fn probe_query_access(pid: u32) -> windows::core::Result<()> {
// SAFETY: numeric PID, query-only rights, and inheritance disabled.
let handle: HANDLE = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
// SAFETY: this function owns this valid handle and never uses it again.
unsafe { CloseHandle(handle) }?;
Ok(())
}

A capability combines a reference to an object with authority to perform particular operations. A process handle opened for limited queries can identify the process and ask for its image path, but it cannot become a memory-writing handle because a later function wishes it could.

That is useful architecture, not just security etiquette. Give read-only observation code a type that owns only a query/read handle. Give patching code a separately created write-capable handle after the user selects an explicit lab action. Then ordinary scanner functions cannot accidentally grow into writers without changing their inputs and call sites.

Rights belong to the handle, not permanently to the PID. Opening the same PID twice with different access masks can produce two handles with different capabilities, and closing one does not close the other.

When you sign in, Windows creates security information describing your account and groups. A process receives an access token representing its security context. That token includes identity information, privileges, restrictions, and an integrity level.

An integrity level is a boundary used by Windows to limit which processes may influence others. A normal desktop program commonly runs at medium integrity. A program started through an administrator approval may run at high integrity. A lower-integrity caller generally cannot gain powerful access to a higher-integrity target merely because both processes are visible in Task Manager.

An integrity level is not a score for whether software is good or bad. It is one input to an access decision.

A process object also has a security descriptor. Its DACL—Discretionary Access Control List—describes which trustees may receive which rights. When OpenProcess runs, Windows checks the requested rights against this information and the restrictions on protected processes.

This explains a common beginner mistake: “I found the PID, so why did reading memory fail?” Finding and opening are separate steps with different permissions.

The access check happens when Windows creates or duplicates the handle. Afterward, APIs compare their required rights with the rights recorded on that handle. Asking for extra rights up front can make OpenProcess fail even when the smaller operation you actually needed would have been allowed.

These course tools use several process rights:

Process access rights at a glance

Right What it permits in this course
PROCESS_QUERY_LIMITED_INFORMATION Ask for basic information such as the executable path.
PROCESS_QUERY_INFORMATION Query information needed by several memory APIs.
PROCESS_VM_READ Copy readable bytes from the target with ReadProcessMemory.
PROCESS_VM_OPERATION Change or allocate virtual-memory regions.
PROCESS_VM_WRITE Copy bytes into the target with WriteProcessMemory.

PROCESS_ALL_ACCESS is not a convenient “make it work” flag. It requests many unrelated powers and makes failures harder to diagnose.

The original helper asks Windows for every process right just to query an executable path. OwnedHandle owns the returned handle, and query_full_process_image_name is the path-query helper below.

Before: ask for every process right
fn query_process_path(pid: u32) -> windows::core::Result<PathBuf> {
// SAFETY: OpenProcess accepts a numeric PID; errors are returned normally.
let process = unsafe { OpenProcess(PROCESS_ALL_ACCESS, false, pid) }?;
let process = OwnedHandle::from_raw(process)?;
query_full_process_image_name(process.raw())
}

Least privilege means requesting only the capability the next call uses. Windows still checks whether the caller may receive that right.

Improved code

fn query_process_path(pid: u32) -> windows::core::Result<PathBuf> {
// SAFETY: OpenProcess accepts a numeric PID; errors are returned normally.
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
let process = OwnedHandle::from_raw(process)?;
query_full_process_image_name(process.raw())
}

Show the diff

fn query_process_path(pid: u32) -> windows::core::Result<PathBuf> {
// SAFETY: OpenProcess accepts a numeric PID; errors are returned normally.
let process = unsafe { OpenProcess(PROCESS_ALL_ACCESS, false, pid) }?;
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
let process = OwnedHandle::from_raw(process)?;
query_full_process_image_name(process.raw())
}

Takeaway: match the handle rights to the operation you actually need.

The small request documents the tool’s purpose. If Windows grants it, you know the program did not quietly obtain memory-write, thread-creation, handle-duplication, or termination rights.

An issued handle carries specific rights. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

granted = selected_handle_mask
needed = selected_operation_right
intersection = granted & needed
allowed = intersection == needed
perform only the allowed operation
report the issued authority

Result: read permitted by this mask. A PID or correct build name does not supply a missing right.

Final values: handle = read; operation = read; granted = 0x1010; needed = 0x10; intersection = 0x10; allowed = true; operation_performed = true.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    handle = read; operation = read.

  2. Step 2, line 1. These masks were fixed when this illustrative handle was issued.

    handle = read; operation = read; granted = 0x1010.

  3. Step 3, line 2. The selected operation needs this right.

    handle = read; operation = read; granted = 0x1010; needed = 0x10.

  4. Step 4, line 3. AND keeps rights that both masks contain.

    handle = read; operation = read; granted = 0x1010; needed = 0x10; intersection = 0x10.

  5. Step 5, line 4. All needed bits must remain.

    handle = read; operation = read; granted = 0x1010; needed = 0x10; intersection = 0x10; allowed = true.

  6. Step 6, line 5. The right check permits this operation.

    handle = read; operation = read; granted = 0x1010; needed = 0x10; intersection = 0x10; allowed = true; operation_performed = true.

  7. Step 7, line 6. Result: read permitted by this mask. A PID or correct build name does not supply a missing right.

    handle = read; operation = read; granted = 0x1010; needed = 0x10; intersection = 0x10; allowed = true; operation_performed = true.

Windows VM

This program finds a named process through the shared ToolHelp code, opens it with only PROCESS_QUERY_LIMITED_INFORMATION, and asks Windows for the image path:

Process::find(process_name)
Process::open_with_access
PROCESS_QUERY_LIMITED_INFORMATION
QueryFullProcessImageNameW
(unsafe)
path.truncate(length)
print name, PID, image path
Complete lab source: access_probe.rs

access_probe.rs

windows-labs/src/bin/ · Rust, 43 lines

Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    use anyhow::Context;
    use gha_windows_labs::Process;
    use windows::{
        Win32::System::Threading::{
            PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
        },
        core::PWSTR,
    };

    let process_name = std::env::args()
        .nth(1)
        .unwrap_or_else(|| "wesnoth.exe".to_owned());
    let entry = Process::find(&process_name)?;
    let process = Process::open_with_access(entry, PROCESS_QUERY_LIMITED_INFORMATION)
        .context("Windows denied even limited process information")?;

    let mut path = vec![0_u16; 32_768];
    let mut length = u32::try_from(path.len()).context("path buffer is too large")?;
    // SAFETY: `path` owns `length` writable UTF-16 units, and `length` remains
    // valid while Windows updates it with the number of units actually used.
    unsafe {
        QueryFullProcessImageNameW(
            process.raw_handle(),
            PROCESS_NAME_WIN32,
            PWSTR(path.as_mut_ptr()),
            &mut length,
        )?;
    }
    path.truncate(usize::try_from(length)?);

    println!("Process: {} (PID {})", process.name(), process.id());
    println!("Image:   {}", String::from_utf16_lossy(&path));
    println!("Rights:  PROCESS_QUERY_LIMITED_INFORMATION");
    println!("No memory-read, memory-write, thread, or debug right was requested.");
    Ok(())
}

#[cfg(not(windows))]
fn main() {
    eprintln!("This access-rights lab must run on Windows.");
}
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use anyhow::Context;
use gha_windows_labs::Process;
use windows::{
Win32::System::Threading::{
PROCESS_NAME_WIN32,
PROCESS_QUERY_LIMITED_INFORMATION,
QueryFullProcessImageNameW,
},
core::PWSTR,
};
let process_name = std::env::args()
.nth(1)
.unwrap_or_else(|| "wesnoth.exe".to_owned());
let entry = Process::find(&process_name)?;
let process = Process::open_with_access(
entry,
PROCESS_QUERY_LIMITED_INFORMATION,
)
.context("Windows denied even limited process information")?;
let mut path = vec![0_u16; 32_768];
let mut length = u32::try_from(path.len())
.context("path buffer is too large")?;
// SAFETY: path owns length writable UTF-16 units, and length remains
// valid while Windows replaces it with the number actually used.
unsafe {
QueryFullProcessImageNameW(
process.raw_handle(),
PROCESS_NAME_WIN32,
PWSTR(path.as_mut_ptr()),
&mut length,
)?;
}
path.truncate(usize::try_from(length)?);
println!("Process: {} (PID {})", process.name(), process.id());
println!("Image: {}", String::from_utf16_lossy(&path));
println!("Rights: PROCESS_QUERY_LIMITED_INFORMATION");
println!("No memory-read, memory-write, thread, or debug right was requested.");
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("This access-rights lab must run on Windows.");
}

QueryFullProcessImageNameW writes UTF-16 text into caller-owned memory. The compiler cannot prove what the operating system will do, so the call is unsafe.

The comment states the exact proof:

  1. path owns the buffer;
  2. length begins as the buffer capacity;
  3. both remain alive during the call;
  4. Windows reports the number of UTF-16 units it wrote;
  5. The wrapper truncates the vector before converting the used portion to a string.

unsafe does not mean “skip safety.” It means the programmer must supply the proof the compiler cannot check.

Start a local match, then use a normal, non-administrator terminal:

Terminal window
cd windows-labs
cargo run --release --target i686-pc-windows-msvc `
--bin access_probe -- wesnoth.exe

Repeat with:

Terminal window
cargo run --release --target i686-pc-windows-msvc `
--bin access_probe -- ac_client.exe
cargo run --release --target i686-pc-windows-msvc `
--bin access_probe -- Quake3-UrT.exe

The result should show the exact executable path. That helps prevent a tool from acting on a different file that happens to share a familiar process name.

Possible reasons include:

  • the target runs under another account or at a higher integrity level;
  • the DACL does not grant the requested rights;
  • Windows restricts access because the target is a protected process;
  • the process exited between enumeration and OpenProcess;
  • the PID was reused after the old process ended;
  • the caller requested more rights than the job required.

The first troubleshooting step is to print the exact error and requested mask. Do not automatically enable debug privileges or elevate the whole tool. For the offline open-source course games, matching the user and integrity level is normally enough.

The shared Process wrapper starts with query and read rights. It adds operation and write rights only when the caller passes allow_write: true:

Optional: practise the access-mask decision that adds write rights only when requested.

let mut access = PROCESS_QUERY_INFORMATION | PROCESS_VM_READ;
if allow_write {
access |= PROCESS_VM_OPERATION | PROCESS_VM_WRITE;
}

That boolean is a visible decision at the call site. A scanner can open read-only; a verified patcher must deliberately opt into writing.

The “group of bits” from the top of this lesson is not a metaphor. Microsoft documents PROCESS_QUERY_INFORMATION as 0x0400 and PROCESS_VM_READ as 0x0010. ORing them sets bit 10 and bit 4 of one 32-bit value and leaves every other bit 0, producing 0x0410. Allowing writes ORs in PROCESS_VM_OPERATION (0x0008) and PROCESS_VM_WRITE (0x0020), setting two more bits for a final mask of 0x0438. OpenProcess compares that exact bit pattern against what the target’s DACL permits; there is no separate “and also let me read memory” step hiding behind the named constant.

Predict first

A scanner found the game's correct PID and opened it with only PROCESS_QUERY_LIMITED_INFORMATION, mask 0x1000.

It now calls ReadProcessMemory with that handle. What happens?

Answer: ReadProcessMemory fails with access denied. 0x1000 has only bit 12 set. VM_READ is bit 4, 0x0010, and 0x1000 AND 0x0010 is 0, so the handle lacks the one right a read needs.

Which calls does this handle allow?The PID only chose the process. Every later call checks the rights stored with the handle.

The handle’s mask, then the call’s check

Rights stored with the handle

Bits 12, 10, 5, 4 and 3
1QUERY_LIMITED
0QUERY_INFO
0VM_WRITE
0VM_READ
0VM_OPERATION
OR of the set bits
0x1000 = 0x1000

ReadProcessMemory checks the handle

Needs
VM_READ
Result
fails: access denied

0x1000 does not include VM_READ. The PID is the right one, but ReadProcessMemory checks the rights stored with the handle, so it fails with access denied.

The complete buildable tool is access_probe.rs.

References: Microsoft process security and access rights, OpenProcess, and QueryFullProcessImageNameW.

Transfer the idea to a file: CreateFileW can return a handle opened for reading only. Knowing the path does not turn that existing handle into a writing one. The file API may succeed at a read and refuse a write for the same reason this chapter’s query-only process handle cannot read game memory.

Knowledge check

Request the smallest capability

A tool only needs to list a process's mapped regions. What access principle should guide OpenProcess?

Finished Lesson 6.3?

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.

Chapter 6: 11 lessons

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.