Detecting Suffix-Variant DLL Calls

Most detection rules for rundll32 abuse search command lines for specific function name strings. That approach has a structural blind spot. rundll32 does not run the function the user typed. It runs the function the export table resolves to under rundll32's own lookup logic, which silently tries suffixed variants of the name first. The string in process telemetry and the string of the export that actually executed can be different.

Specifically, rundll32 tries the W and A suffixed variants before the literal name. W and A are Microsoft's suffixes for the two text-encoding versions of a Windows API function. W marks the wide-character version, where strings use two bytes per character (the format Windows uses internally for things like file paths and process names). A marks the ANSI version, where strings use one byte per character, with the encoding tied to the system's locale settings. When a function takes text as input, Microsoft typically exports both versions as separate entries in the DLL, so the export table contains two parallel functions that do the same logical work.

MiniDumpW in C:\Windows\System32\comsvcs.dll is the most recognizable case of this. The function writes the memory contents of any process the caller has handle access to into a file on disk. Pointed at LSASS, the resulting dump contains credentials, password hashes, and Kerberos tickets that follow-on attacks rely on. Detection rules built around rundll32 abuse very often watch specifically for the string MiniDumpW in command lines.

An attacker can defeat that rule by dropping a single letter. The rule looking for MiniDumpW does not match a command line containing MiniDump. Same DLL, same code path, same memory dump. The rule sees nothing, because rundll32 silently resolves MiniDump up to MiniDumpW before calling it. The same evasion applies to any function with a W or A variant that a rule keys on by name. MiniDumpW is the example. The behavior is general.

A real DLL with suffix variants

C:\Windows\System32\comsvcs.dll is the Windows COM+ services library and ships with every Windows install. Most of its exports are COM-related and uninteresting for this post. The relevant entry is MiniDumpW at ordinal 24.

Below is a slice of C:\Windows\System32\comsvcs.dll's export table on the build with SHA256 hash C67F64745F886699A029028A8B0FC3EB8D6294665EA89BA646200A8EEC1644E9. Microsoft updates DLLs across Windows builds and the function-to-ordinal mapping can shift between versions, so reference this slice as a snapshot of one specific build.

OrdinalFunction name
5CoSetGetCallContext
6[Ordinal Only]
7[Ordinal Only]
8CoCreateActivity
9CoEnterServiceDomain
10CoLeaveServiceDomain
11CoLoadServices
12ComSvcsExceptionFilter
13ComSvcsLogError
14DispManGetContext
15DllCanUnloadNow
16DllGetClassObject
17DllRegisterServer
18DllUnregisterServer
19GetMTAThreadPoolMetrics
20GetManagedExtensions
21GetObjectContext
22GetTrkSvrObject
23MTSCreateActivity
24MiniDumpW
25RecycleSurrogate
26SafeRef

MiniDumpW exists at ordinal 24. There is no MiniDumpA in this DLL, and no unsuffixed MiniDump. Only the W form exists. The two entries at ordinals 6 and 7 marked [Ordinal Only] are unrelated to the W/A question and are present because some DLL exports are declared without names at all.

How rundll32's lookup works

When rundll32 receives a function reference on the command line, it tries to resolve the reference against the DLL's export table in a specific order. Given rundll32 C:\path\to\mydll.dll, FunctionName, rundll32 first tries FunctionNameW. If that export does not exist, it then tries FunctionNameA. If neither suffixed form exists, it finally tries the literal FunctionName.

The function name on the command line is what the user typed, not necessarily what ran. If the DLL exports FunctionNameW and the attacker types FunctionName, rundll32 finds and runs FunctionNameW. The command-line string in process telemetry reads FunctionName. The export that actually executed is FunctionNameW.

The lookup behavior is specific to rundll32. Other Windows utilities that load DLL functions, like regsvr32 or odbcconf, call fixed functions by name without trying suffix variants. The reconciliation problem in this post applies only to rundll32 invocations.

The lookup order, step by step

Given a function reference on the command line, rundll32 walks three candidates in order. The first one that exists in the export table is what runs. The remaining candidates are not tried.

Command-line function string1st tried2nd tried3rd tried
MiniDumpMiniDumpWMiniDumpAMiniDump
MiniDumpWMiniDumpWWMiniDumpWAMiniDumpW
MiniDumpAMiniDumpAWMiniDumpAAMiniDumpA
DllRegisterServerDllRegisterServerWDllRegisterServerADllRegisterServer
FooFooWFooAFoo

The pattern is mechanical. Whatever string comes after the comma on the command line, rundll32 appends W, then A, then tries the literal. Notice rows 2 and 3. If the user types the W or A form already, rundll32 still appends another suffix and tries that first, even though the resulting double-suffixed name almost never exists. The rule has no special case for names that already end in W or A.

What it looks like

The lookup order above describes what rundll32 always tries. The next table shows what happens when those candidates are checked against the actual exports of C:\Windows\System32\comsvcs.dll from the slice earlier in the post.

RowCommand lineLookup triedExport that runs
1rundll32 C:\Windows\System32\comsvcs.dll, MiniDump <args>MiniDumpW (found)MiniDumpW
2rundll32 C:\Windows\System32\comsvcs.dll, MiniDumpW <args>MiniDumpWW (no), MiniDumpWA (no), MiniDumpW (found)MiniDumpW
3rundll32 C:\Windows\System32\comsvcs.dll, MiniDumpA <args>MiniDumpAW (no), MiniDumpAA (no), MiniDumpA (no)Nothing (lookup fails)
4rundll32 C:\Windows\System32\comsvcs.dll, DllRegisterServer <args>DllRegisterServerW (no), DllRegisterServerA (no), DllRegisterServer (found)DllRegisterServer

Rows 1 and 2 produce the same execution behavior despite different command lines. A rule looking for MiniDumpW matches row 2 but misses row 1, even though both run the same code. Row 3 shows the suffix lookup falling all the way through and finding nothing because no MiniDumpA form exists in the DLL. Row 4 is the case where there are no W or A variants, so the lookup falls through to the literal name and runs that instead. The detection target is the canonical resolved export name in column 4, not the command-line string in column 2.

Resolving the call to a canonical identity

Any function reference rundll32 receives goes through the same suffix lookup, so any name-keyed rule has the same exposure for any DLL. Resolving the call to a canonical export name puts every command-line variant onto the same identity, so downstream rules see one tuple per export regardless of which string the attacker typed.

StepWhatWhy
1. Pre-filterMatch process creation events where the image is rundll32.exe.The suffix lookup is rundll32-specific. Other loaders need different rules.
2. ParseSplit the command line on the comma after the DLL path. The first segment is the DLL reference. The second is the function reference as the user typed it.Separates the two pieces of identity that the rest of the pipeline needs.
3. Look up the canonical nameMap the (DLL, command-line function string) pair to a canonical export name using a precomputed table.Replicates rundll32's resolution at O(1) per event, without per-event PE parsing.
4. EmitPass the canonical (DLL identifier, export name) tuple, the original command-line function reference, and the resolution path to the detection layer.Rules compare against tuples. Investigators can still see the original form and the resolution path for forensic accuracy.

The performance trap to avoid is trying to live-parse every rundll32 event's DLL on disk to walk its export table. That works in a lab and breaks at scale. Most rundll32 invocations on a typical Windows host are routine, like Control_RunDLL desk.cpl for opening a settings panel, and have nothing to do with abuse. Resolution effort should focus on DLLs that actually matter for detection.

The practical implementation is a small static lookup table keyed by (DLL identifier, command-line function string) with the canonical export name as the value. Populate it once for known abusable DLLs. For C:\Windows\System32\comsvcs.dll, the entries that matter for MiniDump are:

KeyCanonical export
(C:\Windows\System32\comsvcs.dll, "MiniDump")MiniDumpW
(C:\Windows\System32\comsvcs.dll, "MiniDumpW")MiniDumpW
(C:\Windows\System32\comsvcs.dll, "MiniDumpA")(no resolution)

Per-event cost is one hashmap lookup. Maintenance cost is occasional updates as new abusable DLLs and exports are documented. For DLLs not in the table, fall back to keying on the raw command-line string. Novel DLLs are still tracked, just without suffix reconciliation. Legitimate rundll32 usage against arbitrary DLLs almost never matters for detection, so the long tail can be left unresolved.

A heavier implementation that walks the export table of every observed DLL is possible but rarely worth the engineering investment. The 80/20 lives in the precomputed table.

Two detection patterns

Once events resolve to a canonical tuple, two alerting patterns sit on top. Both are O(1) per event after resolution and have negligible runtime cost.

The first pattern is novelty against a historical lookup table. Maintain a table of every (DLL identifier, canonical export name) tuple that your rundll32 events have produced over a long window. Each new event checks the table. Anything not present alerts and gets added. Anything present is silent. Tuples that have never been seen in the environment surface as alerts, fire once per genuinely new combination, and go quiet once baselined.

The key has to be the resolved tuple. If the table keys on the raw command-line function reference, the same export reached as MiniDump today and MiniDumpW tomorrow looks like two separate novel events. The next time either form appears the rule stays silent because the wrong half of the pair was baselined. Keying on the canonical resolved name keeps both forms collapsed into one entry.

The second pattern is targeted alerts for known-bad tuples. Maintain a separate small list of (DLL identifier, canonical export name) pairs that are always-suspicious regardless of history. Every event checks against this list. Matches alert every time, independent of the baseline.

The two patterns complement each other. Novelty fires once per new tuple and then never again. The first invocation of a documented LOLBin export alerts on novelty grounds, joins the baseline, and goes silent. A targeted rule keyed on the same tuple keeps firing on every subsequent occurrence. Without the targeted rule, the second through Nth invocations pass quietly. Without the novelty rule, anything not on the curated list is invisible.

The known-bad list has to be keyed on canonical export names, not command-line strings. Listing only MiniDumpW misses MiniDump typed on the command line. Listing only MiniDump misses the suffixed form. Listing both is fragile because a future Windows update could add or remove suffix variants. Keying on the canonical name and relying on the resolution step to normalize is what holds up.

Previous
Previous

Detecting Excessive LNK Argument Padding

Next
Next

Detecting Ordinal-Form DLL Calls