Lesson 5.4 · Windows Internals for Analysts· 35 min
Mutexes, Events and Inter-Process Communication
Named mutexes, events, sections and pipes as host evidence: where they live, how to observe and extract them, and how to turn them into detections.
Objectives
- Name the kernel object types malware creates by name and explain the Global\, Local\ and per-session namespaces
- Explain why a single-instance mutex is a strong IOC, when mutex vaccination works, and when it does not
- Observe named objects with System Informer, Process Explorer, WinObj and Sysmon, and know which tools cannot see them
- Recover a mutex name statically from UTF-16 strings and from the arguments of the CreateMutex call
- Turn named objects into IOC entries, YARA strings and named-pipe hunting queries
In Processes, Threads and DLLs you read a process's handle table and met one entry type in passing: a Mutant with a name. Many samples create kernel objects named by the author — a mutex so only one copy runs, an event that tells an injected thread when to start, a pipe for a helper process to talk to its parent. Those names are picked by a human, rarely change between builds, and no legitimate program has a reason to pick the same one: some of the cheapest high-confidence indicators a sample hands over.
Named kernel objects
Every kernel object is reached through a handle. Some object types can also be given a name at creation, so that a second process can open the same object by name instead of inheriting or duplicating a handle.
| Object | Kernel type name | Created with | What malware uses it for |
|---|---|---|---|
| Mutex | Mutant | CreateMutexA/W, CreateMutexExW | Single-instance marker; serialising access to a shared file or log |
| Event | Event | CreateEventA/W | Signalling between components: "payload is mapped, start", "shut down now" |
| Semaphore | Semaphore | CreateSemaphoreA/W | Limiting concurrent workers; occasionally used as an instance marker instead of a mutex |
| Section | Section | CreateFileMappingA/W | Shared memory between processes, including staging code for injection |
| Named pipe | File (on the NPFS driver) | CreateNamedPipeA/W, opened with CreateFileW | Two-way channel between processes, locally or over SMB |
| Mailslot | File (on the MSFS driver) | CreateMailslotA/W | One-way datagrams, local or domain broadcast; rare today |
A mutex has at most one owning thread, and a thread that asks for an owned
mutex waits until it is released. For most malware the ownership is irrelevant:
what matters is that the name exists. CreateMutexW returns a handle whether it created a
new object or opened an existing one, and in the second case GetLastError
returns ERROR_ALREADY_EXISTS (183, 0xB7). That one bit of information is the
whole single-instance check.
The object namespace
Named objects live in a tree maintained by the object manager, much like a file system. The directories that matter here:
\BaseNamedObjects global namespace ("Global\" prefix; session 0's local names)
\Sessions\1\BaseNamedObjects local namespace for interactive session 1 ("Local\" prefix)
\Sessions\1\AppContainerNamedObjects\<SID> objects created inside an AppContainer sandbox
\Device\NamedPipe\ named pipes, opened as \\.\pipe\<name>
\Device\Mailslot\ mailslots, opened as \\.\mailslot\<name>The Win32 prefix decides where a name lands:
Global\nameis created in\BaseNamedObjectsand is visible from every session. A sample that wants one instance per machine, not per logged-on user, uses it. Creating a file mapping there from a session other than 0 requiresSeCreateGlobalPrivilege; mutexes and events do not.Local\name, or a name with no prefix, goes into the caller's session directory. Two users logged on at once each get their own copy — so a per-session mutex does not stop a second infection in another session.- Services run in session 0, where the local namespace is
\BaseNamedObjects. The sameLocal\name therefore resolves to a different full path when the sample runs as a service than when a user double-clicks it. Record the name the code passes, and note the full path you observed.
Named pipes and mailslots sit outside this scheme: they are files on dedicated
file-system drivers, with a single machine-wide namespace. A pipe name is
reachable from other hosts as \\host\pipe\name over SMB, through the IPC$
share, which is what makes pipes interesting for lateral movement.
Why the mutex name is such a good IOC
An author picks a mutex name once and reuses it, because changing it would let
two versions run side by side and fight over the same files and ports. Names
such as Local\LabDemoMutex do not occur by accident, so the indicator combines
high specificity and low churn. A
file hash dies with the next recompile; a mutex name often survives repacking,
re-encryption and new C2 infrastructure, because it lives in the code that runs
after all of those layers.
The same property enables vaccination: create the mutex yourself on endpoints, before the sample arrives, and a sample that checks it will conclude it is already running and exit. This has been used in real incidents as a stop-gap while proper detection was deployed. Its limits are strict:
- It only works against the exact name and namespace. A
Local\vaccine does not protect other sessions; aGlobal\sample ignores a vaccine in\Sessions\1\BaseNamedObjects. - It only works against samples that exit (or block) on
ERROR_ALREADY_EXISTS; some ignore the result. - The vaccine must be held open by a running process; it is gone after reboot until that process starts again.
- It does nothing against randomised or host-derived names.
That last point is the main weakness of the whole indicator class. Authors who know defenders collect mutexes derive the name at runtime: a hash of the computer name, the volume serial number or the user SID, formatted as a GUID or hex string. The result is stable on one machine (so the single-instance check still works) and different on every other. For such families the literal name is a host IOC for this incident only; the durable indicator is the algorithm — the format string, the hash, the inputs — which you recover by reversing and can reproduce in a detection script.
Tip: Before reporting a mutex name as a family IOC, check it across two runs on differently named VMs. If it changes, report the pattern (for example "32 hex digits derived from the volume serial") rather than the value.
Observing named objects at runtime
Mutexes and events leave no files and no registry values, so the tools from Behavioural Monitoring see them unevenly.
| Tool | Mutexes / events / sections | Named pipes |
|---|---|---|
| System Informer — Handles tab | Yes, while the handle is open, with the full object path | Yes, as File handles on \Device\NamedPipe\… |
Process Explorer — handle view (Ctrl+H), Find (Ctrl+F) | Yes; Find Handle or DLL searches every process for a name | Yes |
| WinObj | Yes — browses the namespace itself, independent of any process | Not the pipe list itself |
| Handle (Sysinternals CLI) | handle -a -p <pid>, or handle -a <name> to search | Yes |
| Sysmon | No event for mutex or event creation | Event 17 (pipe created), Event 18 (pipe connected) |
| Process Monitor | Not recorded | CreateFile, ReadFile, WriteFile on the pipe path |
| Sandboxes (CAPE and similar) | Usually listed in the behaviour summary from API hooks | Usually listed |
Two consequences follow. First, live views only show what is still open:
a sample that finds its mutex already exists and exits leaves nothing for
Process Explorer. Hook-based tracing — a sandbox report, an
API monitor, or a breakpoint on CreateMutexW in a debugger — catches every
attempt, including the failed ones. Second, production telemetry rarely
contains mutexes. Sysmon has no mutex event, and most EDRs do not stream
object creation. On a live host you hunt for them by enumerating: a live
response session running handle.exe, a Velociraptor artifact that walks the
object directories, or a memory image analysed afterwards. Named pipes are the
exception, because Sysmon and most EDRs do log them.
Finding the name statically
A sample that uses a fixed name must carry it somewhere in the binary. Where to look:
- Imports.
CreateMutexA/W,OpenMutexW,CreateEventW,CreateNamedPipeWorCreateFileMappingWin the import table (see Reading Capabilities from Imports) tell you a named object is likely. Paired withGetLastErrorshortly after, it is almost certainly an instance check. - Wide strings. The
WAPIs take UTF-16LE strings, which a defaultstringsrun misses entirely. Search withstrings -el(or FLOSS, covered in Strings and Obfuscated Strings). - The call site. On x64 the name is the third argument of
CreateMutexW(lpName), so it arrives inr8; forOpenMutexWit is the third as well; forCreateEventWit is the fourth, inr9. Find the call through the IAT slot, then read theleathat loads the argument register (see calling conventions). - Hidden names. When no string shows up, the name is built at runtime — stack strings, an XOR-encrypted blob, or a format string fed with host data. The call site still gives you the answer: set a breakpoint on the API and read the argument, a pattern the Debugging Malware with x64dbg lesson covers in depth.
The instructions right after the call are worth reading too. A comparison of
GetLastError's result against 0xB7, followed by a jump to an exit path, is
the single-instance check in its purest form — and the exact place an analyst
patches to let a second copy run under a debugger.
Named pipes and other IPC as indicators
A sample that creates a pipe is usually telling you it is more than one process: a loader and its payload, an implant and a post-exploitation job it spawned, or a service and the remote operator driving it over SMB. That makes pipes a structural clue as well as an IOC.
What defenders commonly log and hunt for:
- Sysmon Event 17 and 18. Event 17 records the process that created a pipe
and its name; Event 18 records who connected. Hunting starts with pipe names
that match known tooling defaults — remote-administration tools such as
PsExec (
\PSEXESVC) and the published default patterns of common red-team frameworks — then moves on to pipes created by processes that have no business serving IPC, such asrundll32.exeor a binary in%TEMP%. - Remote access to
IPC$. Windows Security Event 5145 (detailed file-share auditing) shows a remote client opening a pipe name throughIPC$, which is how SMB-based lateral movement and peer-to-peer implants appear on the receiving host. - Shape rather than value. Framework defaults are configurable, so mature hunts look for patterns: random-looking names under a fixed prefix, pipes that exist for a few seconds around a process creation, one process creating many similarly named pipes.
Sections and events are less visible in logs. A named section is shared memory, and an executable view of one in another process is a classic injection primitive (see section mapping injection and Understanding Process Injection). A named event that one process waits on explains why an injected component sits idle.
Reporting and detection content
Record each named object as an IOC entry with enough context that someone else can use it correctly:
| Field | Example |
|---|---|
| Type | Mutex |
| Value as passed to the API | Local\LabDemoMutex |
| Observed full path | \Sessions\1\BaseNamedObjects\LabDemoMutex |
| Stability | Static string in .rdata; same across runs and hosts |
| Behaviour on collision | Exits if ERROR_ALREADY_EXISTS |
| Encoding in file | UTF-16LE |
The same facts feed three kinds of detection content. For
YARA (see
Writing Your First YARA Rules), match the name as
wide — and ascii too if you are not sure which API the family uses:
rule LabDemo_Mutex_Name
{
meta:
description = "Lab sample: LabDemoMutex single-instance marker"
strings:
$mutex = "LabDemoMutex" wide ascii
condition:
uint16(0) == 0x5A4D and $mutex
}Leave the Local\ or Global\ prefix out of the string: it is not part of the
object's name and some variants switch it. For endpoint content, a named pipe
becomes a query over Sysmon Event 17; a sketch in KQL-style syntax:
Sysmon
| where EventID in (17, 18)
| where PipeName has_any ("\\PSEXESVC", "\\LabDemoPipe")
or (PipeName matches regex @"^\\[0-9a-f]{8}$" and Image endswith @"\rundll32.exe")
| project TimeGenerated, Computer, EventID, PipeName, Image, ProcessGuidFor mutexes, hand the SOC a live-response check instead of a streaming
rule: a script that tries OpenMutexW on the name, or a
handle.exe -a LabDemoMutex sweep, and say so in the
triage report.
Lab: a single-instance mutex, observed and extracted
This lab uses a harmless program you compile yourself. It creates
Local\LabDemoMutex, reports whether it already existed, holds it for ten
seconds and exits. The static steps run on any machine with mingw-w64 and
Python; the runtime steps use Wine if you have it and your Windows analysis VM
from Building a Safe Analysis Lab.
-
Build the program.
c // mutexdemo.c — single-instance marker with a named mutex (harmless) #include <windows.h> #include <stdio.h> int main(void) { HANDLE m = CreateMutexW(NULL, FALSE, L"Local\\LabDemoMutex"); if (m == NULL) { printf("CreateMutexW failed: %lu\n", GetLastError()); return 1; } if (GetLastError() == ERROR_ALREADY_EXISTS) { printf("pid %lu: mutex already exists, another copy is running - exiting\n", GetCurrentProcessId()); CloseHandle(m); return 0; } printf("pid %lu: created Local\\LabDemoMutex, holding it for 10 seconds\n", GetCurrentProcessId()); Sleep(10000); CloseHandle(m); printf("pid %lu: released mutex, exiting\n", GetCurrentProcessId()); return 0; }bash x86_64-w64-mingw32-gcc -O1 -s -o mutexdemo.exe mutexdemo.c file mutexdemo.exe shasum -a 256 mutexdemo.exeOn the build machine used for this lesson (GCC 15.2; your hash will differ):
text mutexdemo.exe: PE32+ executable (console) x86-64 (stripped to external PDB), for MS Windows ea6cec36447c9069c1c207f3dd1a204f1bc8bdac9acc871e011d3fd34a2510e0 mutexdemo.exe -
Show the single-instance behaviour. Under Wine, start one copy in the background and a second one a few seconds later:
bash export WINEDEBUG=-all wine mutexdemo.exe > run1.txt 2>&1 & sleep 4; wine mutexdemo.exe > run2.txt 2>&1 sleep 9; cat run1.txt run2.txttext pid 32: created Local\LabDemoMutex, holding it for 10 seconds pid 32: released mutex, exiting pid 200: mutex already exists, another copy is running - exitingWine is not Windows; it shows the logic, and the VM steps show the real namespace.
-
Read the imports and strings.
bash x86_64-w64-mingw32-objdump -p mutexdemo.exe | grep -iE 'Mutex|GetLastError|CloseHandle' strings -n 8 mutexdemo.exe | grep -i mutex x86_64-w64-mingw32-strings -el -n 6 mutexdemo.exetext 00008270 <none> 0097 CloseHandle 00008278 <none> 00ef CreateMutexW 00008298 <none> 0288 GetLastError pid %lu: created Local\LabDemoMutex, holding it for 10 seconds CreateMutexW Local\LabDemoMutexThe ASCII search finds the name only inside a
printfformat string — an accident of this program. The name actually passed to the API is found only by the UTF-16LE search (-el). In a hex dump it looks like this:text 00002200: 4c00 6f00 6300 6100 6c00 5c00 4c00 6100 L.o.c.a.l.\.L.a. 00002210: 6200 4400 6500 6d00 6f00 4d00 7500 7400 b.D.e.m.o.M.u.t. 00002220: 6500 7800 0000 4372 6561 7465 4d75 7465 e.x...CreateMute -
Locate the call with Capstone. In a virtual environment with
pip install pefile capstone, save this script asfind_mutex.py:python # find_mutex.py — locate a UTF-16LE mutex name and the CreateMutexW call site import sys, pefile from capstone import Cs, CS_ARCH_X86, CS_MODE_64 pe = pefile.PE(sys.argv[1]) base = pe.OPTIONAL_HEADER.ImageBase data = pe.get_memory_mapped_image() name = "Local\\LabDemoMutex".encode("utf-16le") str_rva = data.find(name) print(f"UTF-16LE name at VA {base + str_rva:#x}") iat = {imp.name.decode(): imp.address for entry in pe.DIRECTORY_ENTRY_IMPORT for imp in entry.imports if imp.name} target = iat["CreateMutexW"] print(f"IAT slot for CreateMutexW at {target:#x}") text = next(s for s in pe.sections if s.Name.startswith(b".text")) md = Cs(CS_ARCH_X86, CS_MODE_64) insns = list(md.disasm(text.get_data(), base + text.VirtualAddress)) def rip_target(i): # resolve [rip + disp] operands to an absolute address if "rip" not in i.op_str: return None disp = int(i.op_str.split("rip")[1].strip(" +]-").split("]")[0], 16) sign = -1 if "rip -" in i.op_str else 1 return i.address + i.size + sign * disp # the call may go through a small "jmp [rip+x]" thunk; collect thunks first thunks = {i.address for i in insns if i.mnemonic == "jmp" and rip_target(i) == target} for idx, i in enumerate(insns): t = rip_target(i) hit_call = i.mnemonic == "call" and (t == target or (i.op_str.startswith("0x") and int(i.op_str, 16) in thunks)) if hit_call: for j in insns[max(0, idx - 6): idx + 7]: note = "" if rip_target(j) == base + str_rva: note = " ; -> L\"Local\\LabDemoMutex\"" if j is i: note = " ; CreateMutexW" print(f" {j.address:#x}: {j.mnemonic:6} {j.op_str}{note}")bash python find_mutex.py mutexdemo.exetext UTF-16LE name at VA 0x140004000 IAT slot for CreateMutexW at 0x140008278 0x140001491: push rbx 0x140001492: sub rsp, 0x28 0x140001496: call 0x140001620 0x14000149b: lea r8, [rip + 0x2b5e] ; -> L"Local\LabDemoMutex" 0x1400014a2: mov edx, 0 0x1400014a7: mov ecx, 0 0x1400014ac: call qword ptr [rip + 0x6dc6] ; CreateMutexW 0x1400014b2: mov rbx, rax 0x1400014b5: test rax, rax 0x1400014b8: je 0x14000150e 0x1400014ba: call qword ptr [rip + 0x6dd8] 0x1400014c0: cmp eax, 0xb7 0x1400014c5: je 0x140001529Read it as the calling convention dictates:
ecx = 0islpMutexAttributes = NULL,edx = 0isbInitialOwner = FALSE, andr8points at the name. The second indirect call resolves to0x140008298, theGetLastErrorslot from step 3, andcmp eax, 0xb7is theERROR_ALREADY_EXISTStest. Thejeat0x1400014c5is the branch a debugger user would flip to let a second copy run. -
Check your YARA string. Compile the rule from the previous section and run it against
mutexdemo.exe(withyaraoryara-python). It matches twice: the UTF-16LE name at file offset0x220cand the ASCII text inside the format string at0x229f. Decide which of the two you would trust in a rule for a family that does not print its mutex name. -
Observe it on Windows. In the analysis VM, run
mutexdemo.exefrom a command prompt and, within the ten seconds, open System Informer →mutexdemo.exe→ Handles. Find the handle of type Mutant named\Sessions\<n>\BaseNamedObjects\LabDemoMutex, where<n>is your session number. Change the source to hold the mutex longer if you need more time. -
Search from the other side. In Process Explorer, press
Ctrl+Fand search forLabDemoMutex; the result names the owning process. From an elevated prompt,handle.exe -a LabDemoMutexdoes the same from the command line. -
Browse the namespace. Open WinObj as administrator and go to
\Sessions\<n>\BaseNamedObjects. FindLabDemoMutexwith typeMutant. After the program exits, refresh: the object is gone, because its last handle was closed. -
Test the namespace boundary. Log a second user on with fast user switching so two sessions exist. With the
Local\build, run one copy in each session inside the ten seconds and note that both create the mutex. Change the name toGlobal\LabDemoMutex, rebuild, and repeat. If the second copy printsCreateMutexW failed: 5, the object exists but its default security, set by the other user's token, denies access. Consider what each outcome means for a vaccine deployed by one account.
Questions to answer: Which string search found the name that matters, and
why did the plain ASCII search mislead you? Where in WinObj would the object
appear if the program ran as a service? If the name were built from the
computer name with wsprintfW, what would you report as the IOC and how would a
SOC check for it on a live host? Which of this lesson's tools would have seen
nothing if the second copy was the only one you watched?
Key takeaways
- Mutexes, events, semaphores and sections can be named so that unrelated
processes find the same object; the prefix (
Global\orLocal\) and the session decide where the name lands in the object namespace. - A fixed mutex name is specific and long-lived, which makes it a strong IOC and sometimes a vaccine — but only for the exact name, namespace and exit behaviour, and never for randomised or host-derived names.
- Live handle views and WinObj see objects only while a handle is open; Sysmon records named pipes (Events 17 and 18) but not mutexes, so mutex hunting relies on sandbox reports and live enumeration.
- Statically, look for wide strings and read the
CreateMutexWcall site: the name is inr8, and acmp eax, 0xb7afterGetLastErroris the single-instance check. - Named pipes signal multi-process tooling and lateral movement; hunt on known defaults, suspicious creators and name patterns rather than on single values alone.