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:
| Component | What it is | What you look for |
|---|---|---|
| Virtual address space | Private memory map: the image, DLLs, heaps, stacks, other allocations | Executable regions that are not backed by a file on disk |
| Handle table | The process's open references to kernel objects: files, registry keys, other processes, threads, mutexes, sections | Handles to other processes, named mutexes, files in odd locations |
| Access token | The security identity: user SID, groups, privileges, integrity level | Who it runs as, whether it is elevated, which privileges are enabled |
| PEB | User-mode Process Environment Block: image base, loaded-module lists, process parameters | Command line, image path, current directory, environment |
| At least one thread | The things that actually execute | Where each thread started |
| Identifiers | Process ID (PID), parent PID, session ID, creation time | Tree 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 field | Offset | Why analysts care |
|---|---|---|
ProcessParameters | 0x20 | Pointer 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.
SeDebugPrivilegeenabled in an ordinary program means it intends to open other users' or system processes. Most privileges are present but disabled until code callsAdjustTokenPrivileges. - 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.exedeserves 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
gson x64 (fson x86, see segment registers); - a thread ID (TID), a priority, and a start address.
| x64 TEB field | Offset | Contents |
|---|---|---|
NtTib.StackBase / StackLimit | 0x08 / 0x10 | Bounds of the thread's user stack |
NtTib.Self | 0x30 | Linear address of the TEB itself |
ClientId | 0x40 | PID and TID of this thread |
ProcessEnvironmentBlock | 0x60 | Pointer to the PEB |
LastErrorValue | 0x68 | What 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:
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 plumbingThe 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
SetThreadContextkeeps 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.
| Tool | Best 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 |
| Sysmon | Persistent, 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 / CIM | Quick text dumps of PID, parent PID and command line |
For a quick tree from a shell:
Get-CimInstance Win32_Process |
Select-Object ProcessId, ParentProcessId, Name, CommandLine |
Sort-Object ParentProcessId | Format-Table -AutoSize -WrapA 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:
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.exewhose parent is notservices.exe;lsass.exeanywhere butC:\Windows\System32or with more than one instance; lookalike names such asscvhost.exeorlsasss.exe. - Short-lived helpers.
cmd.exe /cchildren 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:
- 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. - Parent PID spoofing. A creator can pass any process it can open as the
"parent" through the
PROC_THREAD_ATTRIBUTE_PARENT_PROCESSattribute in aSTARTUPINFOEXstructure. 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). - 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:
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 persistenceTwo 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
ProcessParametersat 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,-eand-ecare the same PowerShell switch;^and"can be sprinkled throughcmd.execommand 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:
| API | What it does | What you observe |
|---|---|---|
CreateProcessA/W | The general case: image, command line, flags, startup info | Child directly under the sample |
CreateProcessAsUserW | Same, with a token the caller supplies | Child running as a different user or integrity level |
CreateProcessWithTokenW, CreateProcessWithLogonW | Start under a duplicated token or new credentials; the work is done by the Secondary Logon service | Child whose token differs from the caller's |
ShellExecute(Ex)W | Go through the shell: file associations, URLs, the runas verb for elevation | Associated handler starts (for example .js → wscript.exe); runas triggers a UAC prompt |
WinExec, CRT system() / _popen | Legacy or convenience wrappers; system runs cmd.exe /c … | An extra cmd.exe layer in the tree |
WMI Win32_Process.Create, Task Scheduler, service creation | Ask another component to start the process | Parent 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 untilResumeThread. 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) andDETACHED_PROCESS(0x8), orSTARTF_USESHOWWINDOWwithSW_HIDE— run a console program without a visible window.EXTENDED_STARTUPINFO_PRESENT(0x00080000) — aSTARTUPINFOEXwith an attribute list follows; check it forPROC_THREAD_ATTRIBUTE_PARENT_PROCESS(parent spoofing) or mitigation-policy attributes such as blocking non-Microsoft DLLs.lpApplicationName = NULLwith an unquoted path containing spaces — Windows tries each space-separated prefix, soC:\Program Files\App\app.exemay first tryC:\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
| Path | When | Observable as |
|---|---|---|
| Static import | At load time, by the loader, from the import table | Present from the start; listed in the PE's imports |
LoadLibrary(Ex) | At runtime, when code asks | Image-load event after process start (Procmon Load Image, Sysmon Event 7) |
| Delay-load | On first call to one of its functions | Same as LoadLibrary, but listed in the delay-import directory |
| A host binary | rundll32.exe dll,Export, regsvr32.exe dll (calls DllRegisterServer), a service DLL under svchost.exe | A signed Windows binary whose command line names the DLL |
| Search-order abuse | A legitimate program loads a planted DLL of the expected name | Signed EXE in a writable folder, DLL next to it — see DLL search order hijacking |
| Manual mapping | Code copies and relocates a DLL itself, without the loader | Nothing 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:
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(orSysWOW64for 32-bit processes) and WinSxS. Anything under%TEMP%,%APPDATA%,ProgramDataor 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.dllorwinhttp.dllloaded 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.
-
Create a small DLL with a
DllMainand onerundll32-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); } -
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; } -
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 -
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
OutputDebugStringmessage fromDllMain. -
Run
lab.exefrom a command prompt. Leave it waiting. -
Read the tree. In Process Explorer, find
cmd.exe→lab.exe→cmd.exe→PING.EXE. Aconhost.exemay 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. -
Read the threads. Open
lab.exe→ Threads. You should find the main thread starting inlab.exe, your worker thread starting at a differentlab.exe+0x…offset, and possibly thread-pool workers inntdll.dll. Select the worker and click Stack: it is waiting inNtDelayExecution, called fromSleepEx. -
Read the modules and handles. Press
Ctrl+D:labdll.dllis listed with the lab folder as its path and no verified signer, among System32 DLLs. PressCtrl+H: find the Process handle to thecmd.exechild — the handle you deliberately kept open. -
Cross-check memory. In System Informer, open
lab.exe→ Memory. Find the region at the base address the program printed forlabdll.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. -
Watch a host binary. Run
rundll32.exe labdll.dll,Hello "loaded by rundll32". In Process Explorer, the process is the signed Microsoftrundll32.exe; only its command line and its module list reveal what it is really running. -
Recover the history. After
PING.EXEfinishes (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 momentlabdll.dllwas 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_SUSPENDEDand attribute lists change what you should expect to see — and are the first thing to recover when a sample importsCreateProcess. - 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.