Skip to content

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.

ObjectKernel type nameCreated withWhat malware uses it for
MutexMutantCreateMutexA/W, CreateMutexExWSingle-instance marker; serialising access to a shared file or log
EventEventCreateEventA/WSignalling between components: "payload is mapped, start", "shut down now"
SemaphoreSemaphoreCreateSemaphoreA/WLimiting concurrent workers; occasionally used as an instance marker instead of a mutex
SectionSectionCreateFileMappingA/WShared memory between processes, including staging code for injection
Named pipeFile (on the NPFS driver)CreateNamedPipeA/W, opened with CreateFileWTwo-way channel between processes, locally or over SMB
MailslotFile (on the MSFS driver)CreateMailslotA/WOne-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:

text
\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\name is created in \BaseNamedObjects and 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 requires SeCreateGlobalPrivilege; 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 same Local\ 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; a Global\ 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.

ToolMutexes / events / sectionsNamed pipes
System Informer — Handles tabYes, while the handle is open, with the full object pathYes, 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 nameYes
WinObjYes — browses the namespace itself, independent of any processNot the pipe list itself
Handle (Sysinternals CLI)handle -a -p <pid>, or handle -a <name> to searchYes
SysmonNo event for mutex or event creationEvent 17 (pipe created), Event 18 (pipe connected)
Process MonitorNot recordedCreateFile, ReadFile, WriteFile on the pipe path
Sandboxes (CAPE and similar)Usually listed in the behaviour summary from API hooksUsually 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:

  1. Imports. CreateMutexA/W, OpenMutexW, CreateEventW, CreateNamedPipeW or CreateFileMappingW in the import table (see Reading Capabilities from Imports) tell you a named object is likely. Paired with GetLastError shortly after, it is almost certainly an instance check.
  2. Wide strings. The W APIs take UTF-16LE strings, which a default strings run misses entirely. Search with strings -el (or FLOSS, covered in Strings and Obfuscated Strings).
  3. The call site. On x64 the name is the third argument of CreateMutexW (lpName), so it arrives in r8; for OpenMutexW it is the third as well; for CreateEventW it is the fourth, in r9. Find the call through the IAT slot, then read the lea that loads the argument register (see calling conventions).
  4. 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 as rundll32.exe or a binary in %TEMP%.
  • Remote access to IPC$. Windows Security Event 5145 (detailed file-share auditing) shows a remote client opening a pipe name through IPC$, 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:

FieldExample
TypeMutex
Value as passed to the APILocal\LabDemoMutex
Observed full path\Sessions\1\BaseNamedObjects\LabDemoMutex
StabilityStatic string in .rdata; same across runs and hosts
Behaviour on collisionExits if ERROR_ALREADY_EXISTS
Encoding in fileUTF-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:

text
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:

text
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, ProcessGuid

For 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.

  1. 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.exe

    On 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
  2. 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.txt
    text
    pid 32: created Local\LabDemoMutex, holding it for 10 seconds
    pid 32: released mutex, exiting
    pid 200: mutex already exists, another copy is running - exiting

    Wine is not Windows; it shows the logic, and the VM steps show the real namespace.

  3. 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.exe
    text
    	00008270  <none>  0097  CloseHandle
    	00008278  <none>  00ef  CreateMutexW
    	00008298  <none>  0288  GetLastError
    pid %lu: created Local\LabDemoMutex, holding it for 10 seconds
    CreateMutexW
    Local\LabDemoMutex

    The ASCII search finds the name only inside a printf format 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
  4. Locate the call with Capstone. In a virtual environment with pip install pefile capstone, save this script as find_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.exe
    text
    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     0x140001529

    Read it as the calling convention dictates: ecx = 0 is lpMutexAttributes = NULL, edx = 0 is bInitialOwner = FALSE, and r8 points at the name. The second indirect call resolves to 0x140008298, the GetLastError slot from step 3, and cmp eax, 0xb7 is the ERROR_ALREADY_EXISTS test. The je at 0x1400014c5 is the branch a debugger user would flip to let a second copy run.

  5. Check your YARA string. Compile the rule from the previous section and run it against mutexdemo.exe (with yara or yara-python). It matches twice: the UTF-16LE name at file offset 0x220c and the ASCII text inside the format string at 0x229f. Decide which of the two you would trust in a rule for a family that does not print its mutex name.

  6. Observe it on Windows. In the analysis VM, run mutexdemo.exe from 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.

  7. Search from the other side. In Process Explorer, press Ctrl+F and search for LabDemoMutex; the result names the owning process. From an elevated prompt, handle.exe -a LabDemoMutex does the same from the command line.

  8. Browse the namespace. Open WinObj as administrator and go to \Sessions\<n>\BaseNamedObjects. Find LabDemoMutex with type Mutant. After the program exits, refresh: the object is gone, because its last handle was closed.

  9. 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 to Global\LabDemoMutex, rebuild, and repeat. If the second copy prints CreateMutexW 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\ or Local\) 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 CreateMutexW call site: the name is in r8, and a cmp eax, 0xb7 after GetLastError is 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.