# Anatomy of a Windows Service DLL in Ghidra

> Load a Windows service DLL with matching public symbols, separate hosting from service logic, and turn decompiler output into a reliable component map.

- Published: 2026-08-14
- Difficulty: advanced
- Series: Windows Service Internals
- Tags: Windows, Windows Internals, Ghidra, Reverse Engineering, Services
- Source: https://stevenfoerster.com/tutorials/anatomy-of-a-windows-service-dll-in-ghidra/

## Prerequisites

- Familiarity with x86-64 assembly and Windows calling conventions
- Basic experience navigating Ghidra CodeBrowser
- A Windows binary you are authorized to analyze
- Debugging Tools for Windows, which provides symchk, for the symbol steps

A service DLL is not an executable with a different extension. The Service Control Manager starts a service host, the host loads one or more service modules, and each module connects its own initialization, control handling, worker threads, IPC, and cleanup to that hosting contract.

When a service DLL first opens in Ghidra, those responsibilities are mixed together with compiler thunks, imported API wrappers, C++ runtime code, tracing, and error paths. Starting at `DllMain` rarely clarifies the component. The better first objective is a map:

```text
How is the DLL loaded?
Where does service initialization begin?
How are stop and shutdown controls handled?
Which IPC interfaces are registered?
Which functions contain the actual service behavior?
Where does state live, and who cleans it up?
```

This tutorial uses public Windows formats and a target-neutral example. It is about reconstructing component anatomy, not identifying vulnerabilities.

## Work from an exact binary

Windows components change between releases, cumulative updates, and Insider builds. File names can stay constant while function addresses, control flow, imports, and public-symbol identities change.

Record the artifact before creating a Ghidra project:

```powershell
$file = 'C:\lab\ExampleService.dll'

Get-Item $file | Select-Object FullName, Length, LastWriteTime, VersionInfo
Get-FileHash -Algorithm SHA256 $file
```

For a copied system binary, record the source system's:

```powershell
Get-ComputerInfo |
    Select-Object WindowsProductName, WindowsVersion, OsBuildNumber, OsArchitecture
```

The SHA-256 digest is the stable join key between notes, symbols, screenshots, and later reanalysis. A filename and version string are not enough.

> [!warning]
> Do not mix a DLL from one Windows build with a PDB cached for another. A mismatched symbol file can create a more convincing wrong analysis than having no symbols at all.

## Obtain the matching PDB

PE files normally contain a CodeView debug record naming the expected PDB and identifying it with a GUID and age. Microsoft publishes public symbols for many Windows components through its symbol server.

On Windows, `symchk` can resolve and cache the matching symbol file:

```powershell
symchk.exe /v C:\lab\ExampleService.dll `
    /s 'SRV*C:\symbols*https://msdl.microsoft.com/download/symbols'
```

Symbol retrieval here is a Windows step. The server addresses each file by its PDB name, GUID, and age:

```text
https://msdl.microsoft.com/download/symbols/
    <pdb-name>/<guid-and-age>/<pdb-name>
```

If you run Ghidra on Linux, fetch the matching PDB with `symchk` on a Windows system first, then copy the binary and the PDB into the same analysis directory. The remaining Ghidra workflow is cross-platform.

Keep the downloaded PDB beside the binary with the matching base name when possible:

```text
lab/
  ExampleService.dll
  ExampleService.pdb
```

Ghidra's PDB Universal analyzer can then locate the file during analysis. If it does not, run the analyzer explicitly and select the local PDB.

Public symbols are valuable but incomplete. They may provide exported names, selected internal names, namespaces, and other public records. They generally do not provide the full local-variable, source-line, and private-type detail available to the component's developers. Treat a public PDB as a strong set of landmarks, not recovered source code.

## Import the PE correctly

Create a non-shared Ghidra project for the component and import the DLL. The Portable Executable loader should identify:

- Language: `x86:LE:64:default` for a typical modern 64-bit Windows service
- Compiler specification: `windows`
- Image base from the PE optional header
- Sections such as `.text`, `.rdata`, `.data`, `.pdata`, and `.reloc`
- Imports and exports
- Exception and unwind metadata

Accept the detected image base unless you have a specific relocation reason. Static addresses in notes should be expressed as an RVA when possible:

```text
RVA = virtual address - image base
```

An RVA survives normal process relocation and is easier to compare across tools. It does not survive recompilation, so always pair it with the binary hash.

### Initial analyzers

For a Windows x64 service DLL, enable the standard PE and x86 analysis plus:

- `PDB Universal`
- `Windows x86 PE Exception Handling`
- `Demangler Microsoft`
- `Function Start Search` and `Reference`
- `Stack` and `Decompiler Parameter ID`
- `ASCII Strings`

Analyzer labels move between Ghidra releases, and several of these are separate
entries rather than the single conceptual step they describe. Match them against
your own Analysis Options list rather than assuming the strings are stable.

Run broad automatic analysis once. Re-running every analyzer after each manual edit can overwrite useful types and comments or spend substantial time without improving the map.

## Start with the hosting boundary

Open **Symbol Tree -> Exports** before following arbitrary call graphs.

Common landmarks include:

- `ServiceMain` or a service-specific main export
- `SvchostPushServiceGlobals`
- COM registration exports
- RPC proxy/stub exports
- Library-specific initialization functions exposed to the host

Do not assume every service DLL exports the same set. Service hosting has evolved, and some modules use host-specific callbacks or tables rather than a textbook standalone-service shape.

`DllMain` is still worth labeling, but its role is constrained by the loader lock. Well-designed DLLs keep it small: save the module handle, initialize thread-local storage, disable thread callbacks, or perform similarly limited setup. Blocking IPC registration and long-running work normally belong elsewhere.

A minimal `DllMain` decompile may look like:

```c
BOOL DllMain(HINSTANCE module, DWORD reason, void *reserved) {
    if (reason == DLL_PROCESS_ATTACH) {
        g_Module = module;
        DisableThreadLibraryCalls(module);
    }
    return TRUE;
}
```

That function tells you how the DLL enters the process, not how the service enters the running state.

## Find service initialization

A conventional service entry point quickly registers a control handler and reports status to the Service Control Manager.

Search imports and cross-references for:

- `RegisterServiceCtrlHandlerExW`
- `SetServiceStatus`
- `StartServiceCtrlDispatcherW` in standalone service executables
- Thread-pool creation or work submission
- Event and synchronization object creation
- IPC registration APIs

A decompiler view might begin as:

```c
void ServiceMain(DWORD argc, wchar_t **argv) {
    g_StatusHandle = RegisterServiceCtrlHandlerExW(
        L"ExampleService",
        ServiceControlHandler,
        NULL
    );
    if (g_StatusHandle == NULL) return;

    ReportServiceStatus(SERVICE_START_PENDING, NO_ERROR, 3000);

    DWORD error = InitializeServiceState();
    if (error != ERROR_SUCCESS) {
        ReportServiceStatus(SERVICE_STOPPED, error, 0);
        return;
    }

    ReportServiceStatus(SERVICE_RUNNING, NO_ERROR, 0);
}
```

The names may be missing, but imported API calls and status constants provide stable semantic anchors.

Rename by demonstrated behavior:

```text
FUN_180012340 -> InitializeServiceState
FUN_1800148a0 -> ReportServiceStatus
FUN_180018210 -> StopServiceWorkers
```

Avoid names that assert intent you have not established. `AuthorizeCaller` is a claim; `CheckTokenMembershipForAdministrators` is an observation.

## Separate the control plane from the work plane

The service control handler receives lifecycle events such as stop, shutdown, pause, continue, power, or session changes. It should usually signal work rather than perform lengthy cleanup inline.

Map each control code to its effect:

```c
DWORD ServiceControlHandler(
    DWORD control,
    DWORD eventType,
    void *eventData,
    void *context
) {
    switch (control) {
    case SERVICE_CONTROL_STOP:
        SetEvent(g_StopEvent);
        ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 5000);
        return NO_ERROR;

    case SERVICE_CONTROL_SESSIONCHANGE:
        return QueueSessionEvent(eventType, eventData);

    default:
        return ERROR_CALL_NOT_IMPLEMENTED;
    }
}
```

This produces the first useful component table:

| Responsibility | Function | Evidence |
| --- | --- | --- |
| Service entry | `ServiceMain` | Export and status registration |
| Status reporting | `ReportServiceStatus` | `SetServiceStatus` call |
| Control handling | `ServiceControlHandler` | Registered callback and control constants |
| Worker startup | `InitializeServiceState` | Creates events and submits work |
| Shutdown | `StopServiceWorkers` | Signals stop, waits, releases handles |

## Map IPC registration as a subsystem

Service DLLs commonly expose RPC, COM, named pipes, WNF callbacks, or custom local IPC. Treat each registration site as a subsystem root.

For RPC, useful imports include:

- `RpcServerUseProtseq*`
- `RpcServerUseProtseqEp*`
- `RpcServerRegisterIf*`
- `RpcEpRegister*`
- `RpcServerListen`
- `NdrServerCall*`

Follow cross-references to the interface-registration function. Then identify:

- Protocol sequence and endpoint strings
- Interface specification pointers
- Registration flags
- Security callback
- Dispatch or manager tables
- Cleanup and unregister paths

A forthcoming part of this series, [Windows Local RPC and ALPC](https://stevenfoerster.com/tutorials/windows-local-rpc-and-alpc/), takes up that architecture directly. In Ghidra, the immediate goal is simply to connect registration artifacts to the functions that implement the interface.

Do the same for other IPC types. A COM service may center on class-factory registration. A named-pipe server centers on pipe creation, security attributes, connection handling, and impersonation. A notification-driven service may register callbacks and do almost no work in its service entry point.

## Use imports as semantic landmarks

Imported API calls are among the most trustworthy names in a stripped binary. Group them by responsibility instead of reading one long Imports list.

### Hosting and lifecycle

```text
RegisterServiceCtrlHandlerExW
SetServiceStatus
CreateEventW
SetEvent
WaitForSingleObject
CloseHandle
```

### Identity and authorization

```text
RpcImpersonateClient
RpcRevertToSelf
OpenThreadToken
GetTokenInformation
CheckTokenMembership
AccessCheck
```

### Registry state

```text
RegOpenKeyExW
RegCreateKeyExW
RegQueryValueExW
RegSetValueExW
RegDeleteTreeW
```

### RPC registration and dispatch

```text
RpcServerUseProtseqEpW
RpcServerRegisterIfEx
RpcServerListen
NdrServerCallAll
```

Cross-references from an import produce a behavior-oriented call graph. The presence of an import does not prove every path uses it. It tells you where a behavior exists somewhere in the module.

## Recover types before trusting pseudocode

Ghidra decompilation quality depends heavily on types. A wrong parameter type can make correct machine code look nonsensical.

Windows x64 passes the first four integer or pointer arguments in `RCX`, `RDX`, `R8`, and `R9`; additional arguments use the stack. The compiler reserves shadow space for the callee. Ghidra generally understands the calling convention, but it cannot infer every application structure.

Suppose the initial decompile shows:

```c
uVar1 = FUN_180021090(param_1, *(longlong *)(param_2 + 0x18), 2, &local_48);
```

After applying types and renaming parameters and fields:

```c
status = OpenPolicyKey(
    clientContext,
    request->policyId,
    KEY_SET_VALUE,
    &key
);
```

That improvement may require:

1. Define or import the request structure.
2. Apply the structure pointer to `param_2`.
3. Set the callee signature.
4. Rename `param_1` to `clientContext`, and the field at offset `0x18`, only
   after corroborating their use.
5. Re-run decompilation for the affected functions.

Use the Data Type Manager for standard Win32 structures, GUIDs, security descriptors, service-status structures, RPC types, and your own recovered records.

## GUIDs, strings, and tables

Service modules often carry important identity in `.rdata` rather than code.

### GUIDs

A GUID in memory uses mixed-endian field representation. Applying Ghidra's `GUID` type is safer than transcribing 16 bytes by eye. Once typed, search references to find registration or comparison sites.

### UTF-16 strings

Windows service names, registry paths, endpoint names, event sources, and tracing messages are commonly UTF-16. If Ghidra misses one, define a Unicode string manually and revisit cross-references.

### Function-pointer tables

Callback and dispatch tables often appear as arrays of code pointers. Define one pointer, then create an array across the aligned region. A table can reveal a whole subsystem at once:

```text
entry 0 -> Initialize
entry 1 -> Query
entry 2 -> Update
entry 3 -> Shutdown
```

Do not assign semantic method names from table order alone. Confirm each function through constants, called APIs, strings, and data flow.

## Distinguish wrappers from workers

Windows service DLLs accumulate layers:

```text
RPC or COM stub
    -> parameter validation wrapper
        -> policy or identity helper
            -> state-management worker
                -> Win32 or NT API
```

A wrapper may only translate errors or acquire a lock. A worker may be shared by RPC, internal timers, and service startup. Build both caller and callee views before deciding where a behavior belongs.

Ghidra's Function Call Trees and Function Graph help with different questions:

- **Call tree:** Which functions lead to this API or worker?
- **Function graph:** Which branches and joins exist inside this function?

The decompiler is best for data flow after types are improved. The listing is the authority for exact calls, branches, constants, and stack behavior.

## Treat decompiler output as a hypothesis

Decompiler output is C-like notation generated from machine code. It can be wrong about:

- Signedness
- Structure boundaries
- Array lengths
- Calling conventions on unusual thunks
- Inlined functions
- Exception paths
- Ownership and lifetime
- Whether two variables are logically the same object

When a conclusion depends on one branch, verify it in the listing:

```text
decompiler claim -> inspect branch instruction
parameter meaning -> trace register definition
structure field -> compare accesses at the same offset
function boundary -> check unwind metadata and callers
API return handling -> inspect test/compare and jump
```

Public PDB names improve navigation, but they do not make pseudocode authoritative. Symbols identify locations; the instructions establish behavior.

## Build an architecture worksheet

Before writing prose about a component, fill out a compact worksheet.

### Artifact

```text
Filename:
SHA-256:
File version:
PE timestamp:
PDB name:
PDB GUID and age:
Image base:
Source Windows build:
```

### Hosting

```text
Host process:
Service name:
Service account:
Entry export or callback:
Control handler:
Start path:
Stop path:
```

### Interfaces

```text
RPC:
COM:
Named pipes:
Notifications:
Other IPC:
```

### State and authority

```text
Global state:
Locks:
Registry roots:
Files or devices:
Token transitions:
Security descriptors:
```

### Confidence

Mark every row as:

- **Observed:** directly visible in imports, instructions, metadata, or public symbols
- **Inferred:** supported by multiple observations but not directly named
- **Unknown:** unresolved

That distinction keeps a component map useful even before every function is understood.

## A stopping rule

A service DLL can contain thousands of functions. The anatomy pass is complete when you can explain:

1. How the host loads and starts the service
2. How the service reports and handles lifecycle state
3. Which IPC surfaces accept work
4. How a request reaches a worker
5. Which resources and security contexts the worker uses
6. How shutdown releases work and state

You do not need to rename every helper. A reliable map of boundaries is more valuable than a directory full of speculative names.

## References

- [Ghidra Getting Started](https://github.com/NationalSecurityAgency/ghidra/blob/master/GhidraDocs/GettingStarted.md), National Security Agency
- [Improving Disassembly and Decompilation](https://ghidra.re/ghidra_docs/GhidraClass/Advanced/improvingDisassemblyAndDecompilation.pdf), Ghidra Advanced Class
- [Advanced SymSrv use](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/advanced-symsrv-use), Microsoft Learn
- [Public and private symbols](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/public-and-private-symbols), Microsoft Learn
- [Service startup](https://learn.microsoft.com/en-us/windows/win32/services/service-startup), Microsoft Learn
- [Database of installed services](https://learn.microsoft.com/en-us/windows/win32/services/database-of-installed-services), Microsoft Learn
