Windows Objects
The Object Manager, kernel objects, handles, reference counting, named objects, and the object namespace — how Windows represents every resource as a typed, access-controlled object
You're analyzing malware and see it call CreateMutexA(NULL, FALSE, "Global\\MyMutex1234"). You immediately recognize this is a mutex-based single-instance check — the malware creates a named mutex and checks whether one already exists. If it does, the malware exits (another instance is running). To sandbox-detect this, you need to understand named objects, the object namespace, and how handles work. That knowledge starts here.
What Is a Windows Object
In Windows, every kernel resource — a file, a process, a thread, a mutex, a semaphore, an event, a registry key, a device — is represented as a kernel object. Objects are the fundamental unit of resource management in the Windows Executive. Rather than having ad-hoc structures for each resource type, the Object Manager provides a uniform framework for creating, naming, sharing, protecting, and destroying all kernel resources.
An object has three parts:
- Object header: Common metadata managed by the Object Manager — name, type, reference counts, security descriptor, quota charges
- Object body: The type-specific data — for a file, this is the file control block; for a process, this is the EPROCESS structure; for an event, this is the signaling state
- Object type: Defines the object's behavior — what operations are valid, what quotas apply, what cleanup runs when the last reference is released
Kernel Object Layout in Memory ───────────────────────────────────────────────────────────────── ┌──────────────────────────────────────┐ │ OBJECT_HEADER │ ← Object Manager metadata │ TypeIndex : 7 (Process) │ │ HandleCount : 3 │ │ PointerCount : 5 │ │ SecurityDescriptor pointer │ │ Name: "\\Sessions\\1\\csrss.exe" │ │ CreatorInfo (optional header) │ │ QuotaInfo (optional header) │ └──────────────────────────────────────┘ ┌──────────────────────────────────────┐ │ OBJECT BODY │ ← Type-specific data │ │ │ (For Process: EPROCESS structure) │ │ DirectoryTableBase │ │ UniqueProcessId │ │ ActiveProcessLinks │ │ ImageFileName │ │ ... │ └──────────────────────────────────────┘
Object Header Fields
The OBJECT_HEADER structure precedes every kernel object body in memory. The pointer to an object body can be converted to the header by subtracting the body offset.
| Field | Description | Security Relevance |
|---|---|---|
| PointerCount | Total references to this object (handles + internal pointers) | When it reaches 0, the object is freed. Reference count attacks try to force premature freeing. |
| HandleCount | Number of open handles to this object (subset of PointerCount) | Handle leak detection; when HandleCount drops to 0, name is removed from the namespace |
| TypeIndex | Index into the global ObTypeIndexTable — identifies what kind of object this is | Type confusion attacks; DKOM rootkits may corrupt this |
| SecurityDescriptor | Pointer to the object's SECURITY_DESCRIPTOR — DACL, SACL, owner, group | Access control enforcement: checked by SRM on every OpenXxx() call |
| Flags | OB_FLAG_PERMANENT (never freed), OB_FLAG_EXCLUSIVE (only one handle allowed), etc. | Permanent objects survive even when all handles are closed |
Handles — The User-Mode Reference
User-mode code cannot hold a pointer to a kernel object directly — those pointers are kernel-mode addresses. Instead, user-mode code holds a handle: a 32-bit or 64-bit integer that the kernel maps to a kernel object pointer internally.
When you call CreateFile(), the kernel:
- Creates or opens a File object in kernel memory
- Increments the object's PointerCount and HandleCount
- Allocates an entry in the calling process's handle table pointing to the object
- Returns the handle index to the caller
Handle to Kernel Object Mapping
─────────────────────────────────────────────────────────────────
User Mode (Process A) Kernel Memory
Handle 0x14 ──────────────────────► OBJECT_HEADER
+ File Object Body
(path: C:\secret.txt)
PointerCount: 2
HandleCount: 1
Handle 0x18 ──────────────────────► OBJECT_HEADER
+ Process Object Body (EPROCESS)
(notepad.exe, PID 1234)
PointerCount: 4
HandleCount: 2
▲
Process B's Handle 0x4C ──────────────────────┘
(Both processes have a handle to notepad.exe's EPROCESS)
Handle Values
Handle values are always multiples of 4 on 64-bit Windows (the lowest 2 bits are used as flags). A few special handles are recognized by the OS:
| Handle Value | Meaning |
|---|---|
-1 / 0xFFFFFFFFFFFFFFFF | Pseudo-handle for the current process (GetCurrentProcess()) |
-2 / 0xFFFFFFFFFFFFFFFE | Pseudo-handle for the current thread (GetCurrentThread()) |
0 / NULL | Invalid handle — no object |
INVALID_HANDLE_VALUE = -1 | Used by some APIs (CreateFile) to indicate failure, same bit pattern as current process pseudo-handle in different context |
The Handle Table
Each process maintains a handle table in kernel memory (pointed to by the EPROCESS structure). The handle table maps handle values to kernel object pointers. It's a three-level paged structure similar to page tables, allowing up to 16 million handles per process.
// Inspecting handles with WinAPI (enumerate all handles in current process)
// Use GetProcessHandleCount() for a quick count
DWORD handle_count;
GetProcessHandleCount(GetCurrentProcess(), &handle_count);
printf("Open handles: %lu\n", handle_count);
// For forensics: NtQuerySystemInformation with SystemHandleInformation
// lists ALL handles in ALL processes (requires SYSTEM privileges)
// This is how Process Explorer and handle.exe work
NTSTATUS status = NtQuerySystemInformation(
SystemHandleInformation, // 0x10
buffer,
bufferSize,
&returnedLength
);
Handle Inheritance and Duplication
Handles are process-local by default. To share an object between processes, use DuplicateHandle() or create the handle with SECURITY_ATTRIBUTES.bInheritHandle = TRUE (child processes inherit marked handles). Malware uses DuplicateHandle to obtain handles to privileged processes — a technique that bypasses some EDR monitoring since the duplicated handle doesn't show up in the original process.
Reference Counting and Object Lifetime
The Object Manager tracks two reference counts: HandleCount (number of open handles) and PointerCount (total references including internal kernel pointers). The object is freed only when PointerCount reaches zero. HandleCount dropping to zero triggers name removal from the object namespace and calls the Close procedure, but the object body persists until all kernel pointers are released.
Object Lifetime
─────────────────────────────────────────────────────────────────
CreateEvent() PointerCount = 1, HandleCount = 1
Object name "MyEvent" registered in namespace
OpenEvent() PointerCount = 2, HandleCount = 2
(second handle) Two processes can now signal/wait
CloseHandle() ×1 PointerCount = 1, HandleCount = 1
CloseHandle() ×2 PointerCount = 1, HandleCount = 0
Name REMOVED from namespace (no new opens possible)
Object body still exists (kernel still holds PointerCount=1)
Kernel releases PointerCount = 0
internal pointer DeleteProcedure called → object memory freed
Named Objects and the Object Namespace
Objects can have names, making them discoverable by other processes using a path-based namespace. Named objects live in the Windows Object Namespace, which is separate from the file system but uses a similar hierarchical structure with backslash separators.
Windows Object Namespace (partial)
─────────────────────────────────────────────────────────────────
\ ← Root directory object
├── \Device ← Device objects (disk, keyboard, etc.)
│ ├── \Device\HarddiskVolume3
│ └── \Device\Null
├── \Driver ← Driver objects
├── \KnownDlls ← Known DLL objects (pre-mapped)
├── \Windows ← Window stations
├── \Sessions ← Per-session objects
│ ├── \Sessions\0 ← Session 0 (services)
│ └── \Sessions\1 ← Session 1 (first logged-in user)
│ └── \Sessions\1\BaseNamedObjects\
│ └── MyMutex1234 ← Per-session named mutex
└── \BaseNamedObjects ← Global namespace
├── Global\MyMutex1234 ← Globally visible named object
└── Local\SomeName ← Local alias (same as \Sessions\N\BaseNamedObjects\)
Global vs Local Namespace
Named objects created with plain names (no prefix) go into \Sessions\N\BaseNamedObjects\ — visible only within the session. The Global\ prefix routes to \BaseNamedObjects\ — visible across all sessions, including Session 0 (services). The Local\ prefix is an alias for the current session's namespace.
This is exactly what the malware mutex example uses: Global\\MyMutex1234 goes into the global namespace, ensuring the mutex is visible even if the malware runs in Session 0 (as a service) and checks against an instance in Session 1 (interactive user), or vice versa.
// Creating a named mutex — malware single-instance check pattern
HANDLE hMutex = CreateMutexA(
NULL, // no security attributes (inheritable = FALSE)
FALSE, // don't take initial ownership
"Global\\MyMalwareMutex2024" // name in global namespace
);
if (GetLastError() == ERROR_ALREADY_EXISTS) {
// Another instance is running — exit silently
CloseHandle(hMutex);
return 0;
}
// First instance — proceed with malicious activity
Common Object Types
| Object Type | Created by | Represents |
|---|---|---|
| Process | CreateProcess, NtCreateProcess | Running process (EPROCESS body) |
| Thread | CreateThread, NtCreateThread | Execution thread (ETHREAD body) |
| File | CreateFile, NtCreateFile | Open file, pipe, device, or directory |
| Section | CreateFileMapping, NtCreateSection | Shared memory / memory-mapped file |
| Event | CreateEvent, NtCreateEvent | Signaling / synchronization event |
| Mutex | CreateMutex, NtCreateMutant | Mutual exclusion object |
| Semaphore | CreateSemaphore | Count-based synchronization |
| Token | LogonUser, NtCreateToken | Security identity (SID, privileges, groups) |
| Key | RegCreateKeyEx, NtCreateKey | Registry key |
| Job | CreateJobObject | Process group with shared limits |
| Timer | CreateWaitableTimer | Waitable timer object |
| Desktop | CreateDesktop | Window desktop |
Inspecting the Object Namespace with WinObj
Sysinternals WinObj is the essential tool for exploring the Windows Object Namespace. It provides a GUI tree view of the entire namespace, showing object names, types, and security descriptors. Run it as Administrator to see all objects.
; Useful WinObj investigations:
; \BaseNamedObjects — global named mutexes, events, semaphores
; \Sessions\1\BaseNamedObjects — per-session objects for interactive user
; \Device — all device objects (see what drivers are loaded)
; \KnownDlls — DLLs that are pre-loaded at system startup
; \ObjectTypes — all registered object type objects
From a forensics perspective, named mutexes in \BaseNamedObjects are IOC gold — malware families often use consistent mutex names, and finding a known malware mutex name in WinObj confirms active infection without needing to find the executable.
// Checking for known malware mutex names programmatically
// OpenMutex with SYNCHRONIZE access returns NULL if mutex doesn't exist
HANDLE hMutex = OpenMutexA(SYNCHRONIZE, FALSE, "Global\\MyMalwareMutex2024");
if (hMutex != NULL) {
printf("INFECTED: mutex found!\n");
CloseHandle(hMutex);
} else {
printf("Mutex not found: %d\n", GetLastError());
// ERROR_FILE_NOT_FOUND (2) = mutex doesn't exist
// ERROR_ACCESS_DENIED (5) = mutex exists but can't open it
}
A mutex returning ERROR_ACCESS_DENIED instead of ERROR_FILE_NOT_FOUND means the object exists — it was created with a DACL that denies your access. This is itself suspicious behavior: legitimate applications rarely create mutexes with restrictive DACLs. Malware sometimes does this to prevent security tools from opening the mutex and detecting its presence.
Q & A
Why can't user-mode code just hold a pointer to a kernel object?
Two reasons: (1) Memory protection: Kernel objects live at kernel-mode virtual addresses (above the user/kernel split, typically above 0x00007FFFFFFFFFFF on x64). User-mode code cannot read or write those addresses — the CPU page tables mark them as supervisor-only. Any attempt to dereference a kernel pointer from Ring 3 results in an access violation. (2) Lifetime management: If user-mode code held a raw pointer to a kernel object, the kernel would have no way to know whether user code still needed the object. Reference counting through the handle system solves this: when you close a handle, the HandleCount decrements and eventually triggers cleanup. If user-mode held raw pointers, there would be no defined way to communicate "I'm done with this" to the kernel. The handle abstraction also enables access control: every open of an object's handle records what access rights were granted at open time, and all subsequent operations using that handle are restricted to those rights. With a raw pointer, there would be no enforcement point.
What is handle inheritance and how does malware abuse it?
When a parent process creates a child process with CreateProcess(..., bInheritHandles=TRUE, ...), the child receives copies of all inheritable handles from the parent. An inheritable handle is created by setting SECURITY_ATTRIBUTES.bInheritHandle = TRUE when calling CreateFile(), CreateEvent(), etc. The child gets the same handle values, pointing to the same underlying kernel objects. Malware abuses inheritance in several ways: (1) Handle passing to legitimate processes: A malware process creates a privileged handle (e.g., an event or section with elevated access), then spawns a legitimate process with handle inheritance enabled. The legitimate process inherits the privileged handle and the malware can use it for signaling or shared memory without opening new suspicious handles. (2) Avoiding explicit handle opening: Some EDR products monitor OpenProcess() calls but not inherited handle usage. By inheriting an LSASS handle from a parent that legitimately opened it, the child can use it without triggering a new open event. (3) DuplicateHandle() is a related technique: rather than inheriting, the malware duplicates a handle from one process into another, requiring only that it can open the source process with PROCESS_DUP_HANDLE access — a lower-privilege right than PROCESS_VM_READ.
Can malware delete another process's named mutex to break its single-instance check?
Yes — this is a known anti-anti-malware technique. The target (say, a security product) creates a named mutex as a run-detection check. The attacker opens the mutex with the maximum allowed access and closes all handles. However, this doesn't immediately destroy the mutex — it only decrements HandleCount. The mutex body persists as long as any other handle is open. The reliable technique is to open the mutex and then not close the handle — this is pointless since you'd be keeping it alive. The actual reliable approach is: since you can't force-delete an object while other handles are open, the attacker instead focuses on creating a mutex with the target name before the target process starts. When the target then calls CreateMutex(..., name), it gets ERROR_ALREADY_EXISTS and may (if poorly coded) assume another legitimate instance is running and exit. This is why security-critical code should verify the handle's SECURITY_DESCRIPTOR when checking ERROR_ALREADY_EXISTS — to confirm the existing mutex was created by the expected process, not a hijacker.
What is the \KnownDlls directory and why does it matter for DLL hijacking?
\KnownDlls is an Object Namespace directory containing Section objects (memory-mapped files) for a fixed list of system DLLs — ntdll.dll, kernel32.dll, kernelbase.dll, and others. When the loader needs one of these DLLs, instead of searching the file system (which is subject to DLL search order hijacking), it opens the pre-existing Section object from \KnownDlls. This maps the DLL directly from a trusted, already-loaded image — no file system access required. This is a defense against DLL planting in application directories. The list of KnownDlls is stored at HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs. DLLs not on this list are subject to standard DLL search order (application directory first on some code paths), which is the basis for DLL hijacking attacks. An attacker planting version.dll or winhttp.dll in an application's directory succeeds precisely because those DLLs are not in \KnownDlls.