Detecting Ordinal-Form DLL Calls

Most detections for DLL function abuse are written against function names. A rule looks for a known-bad function string in the command line and fires when it sees one. That works until the attacker uses the function's ordinal (a number) instead. Same DLL, same function, same code path, but the command line now reads #11 instead of RegisterOCX. The name-keyed rule sees nothing.

Ordinals are not a footnote to function names. They are a first-class way to call DLL functions, supported by the Windows loader and rundll32 alike, and an attacker who knows the rule is keyed on names will switch to the ordinal form to bypass it. Both forms have to reconcile to the same function identity before any behavioral rule can apply.

The clearest way to see why is to look at a real DLL.

Inside a real DLL

ieadvpack.dll ships with every Windows install and lives at C:\Windows\System32\ieadvpack.dll. It is a less famous LOLBin host than the usual suspects. The function names inside it are bureaucratic and forgettable, things like RegisterOCX and LaunchINFSection, which is exactly why it's a useful teaching example. A name-keyed rule built around recognizable abuse strings probably doesn't include RegisterOCX on its watch list, even though RegisterOCX is documented in LOLBAS as a way to execute arbitrary commands through a signed Microsoft DLL.

Here is the full export table for the version with SHA256 hash EFEB1C0931B7C62FB377535878C9CC549B60A67E3E9B67B01D00F4416B02FB8E. Microsoft updates DLLs across Windows builds and the ordinal-to-name mapping can shift between versions, so reference this table as a snapshot of one specific build of C:\Windows\System32\ieadvpack.dll.

OrdinalFunction name
1DelNodeRunDLL32
2DelNodeRunDLL32A
3DoInfInstall
4DoInfInstallA
5DoInfInstallW
6FileSaveRestore
7FileSaveRestoreA
8LaunchINFSection
9LaunchINFSectionEx
10LaunchINFSectionExA
11RegisterOCX
12RegisterOCXW
13AddDelBackupEntry
14AddDelBackupEntryA
15AddDelBackupEntryW
16AdvInstallFile
17AdvInstallFileA
18AdvInstallFileW
19CloseINFEngine
20DelNode
21DelNodeA
22DelNodeRunDLL32W
23DelNodeW
24ExecuteCab
25ExecuteCabA
26ExecuteCabW
27ExtractFiles
28ExtractFilesA
29ExtractFilesW
30FileSaveMarkNotExist
31FileSaveMarkNotExistA
32FileSaveMarkNotExistW
33FileSaveRestoreOnINF
34FileSaveRestoreOnINFA
35FileSaveRestoreOnINFW
36FileSaveRestoreW
37GetVersionFromFile
38GetVersionFromFileA
39GetVersionFromFileEx
40GetVersionFromFileExA
41GetVersionFromFileExW
42GetVersionFromFileW
43IsNTAdmin
44LaunchINFSection
45LaunchINFSectionExW
46LaunchINFSectionW
47NeedReboot
48NeedRebootInit
49OpenINFEngine
50OpenINFEngineA
51OpenINFEngineW
52RebootCheckOnInstall
53RebootCheckOnInstallA
54RebootCheckOnInstallW
55RegInstall
56RegInstallA
57RegInstallW
58RegRestoreAll
59RegRestoreAllA
60RegRestoreAllW
61RegSaveRestore
62RegSaveRestoreA
63RegSaveRestoreOnINF
64RegSaveRestoreOnINFA
65RegSaveRestoreOnINFW
66RegSaveRestoreW
67RunSetupCommand
68RunSetupCommandA
69RunSetupCommandW
70SetPerUserSecValues
71SetPerUserSecValuesA
72SetPerUserSecValuesW
73TranslateInfString
74TranslateInfStringA
75TranslateInfStringEx
76TranslateInfStringExA
77TranslateInfStringExW
78TranslateInfStringW
79UserInstStubWrapper
80UserInstStubWrapperA
81UserInstStubWrapperW
82UserUnInstStubWrapper
83UserUnInstStubWrapperA
84UserUnInstStubWrapperW

A few things to notice. Every row has an ordinal, numbered from 1 to 84 in this build. Every row also has a function name. That's a property of this particular DLL, not of DLLs generally. Microsoft documents that names are optional and other DLLs leave selected entries with no name, reachable only by ordinal. ieadvpack.dll happens to give every entry a name.

RegisterOCX at ordinal 11 is the function involved in the LOLBAS-documented technique for executing arbitrary commands through this DLL. An attacker can invoke it through rundll32 in either form:

  • rundll32 c:\windows\system32\ieadvpack.dll,RegisterOCX <args>

  • rundll32 c:\windows\system32\ieadvpack.dll,#11 <args>

Same code path, different command-line shapes. A name-keyed rule sees the first form. An ordinal-keyed rule sees the second. The reconciliation logic in this post is what bridges them.

What it looks like

These rows are the same calls represented twice, once by name and once by the function's ordinal. The DLL is the same. The function executed is the same. Only the surface form on the command line differs.

RowCommand lineResolves to
1rundll32 c:\windows\system32\ieadvpack.dll,RegisterOCX <args>(ieadvpack.dll, RegisterOCX)
2rundll32 c:\windows\system32\ieadvpack.dll,#11 <args>(ieadvpack.dll, RegisterOCX)
3rundll32 c:\windows\system32\ieadvpack.dll,LaunchINFSection <args>(ieadvpack.dll, LaunchINFSection)
4rundll32 c:\windows\system32\ieadvpack.dll,#8 <args>(ieadvpack.dll, LaunchINFSection)

A name-keyed rule sees rows 1 and 3. An ordinal-keyed rule sees rows 2 and 4. Neither one alone catches both forms of the same call.

How exports actually work

A Windows DLL doesn't have to export anything at all. A DLL with no export table is still a valid DLL. It can run code through DllMain when the operating system loads it into a process, but external callers have nothing to resolve by name or ordinal. The export table only exists when the DLL author decides to expose functions for outside code to call through standard DLL resolution like LoadLibrary plus GetProcAddress or static linking.

Once that decision is made and an export table exists, Microsoft documents the rules clearly. Every entry in the export table has an ordinal. Names are optional. Some, all, or none of the entries can have names, but there is no way to have a name without an ordinal. ieadvpack.dll happens to assign names to every one of its 84 entries. Other DLLs leave selected entries unnamed, and those exports can only be reached by their numbers.

rundll32 doesn't have a special capability to call by ordinal. The dual-naming behavior at the command line just exposes what GetProcAddress, the Windows API that resolves exports, accepts from any caller. Microsoft's documentation says the lookup parameter is "the function or variable name, or the function's ordinal value." Any process that loads a DLL through GetProcAddress has the same flexibility. rundll32 is one wrapper around that pattern.

Other utilities that load DLL functions

Reconciling name and ordinal references at the command line is a rundll32-specific problem because rundll32 is the utility that lets the attacker pick the function reference. Other signed Windows binaries also load DLLs, but the function they call is fixed by the utility, so the dual-naming question doesn't apply there.

UtilityFunction calledReconciliation needed at command line?
rundll32Whatever the attacker specifies, by name or ordinalYes.
regsvr32, odbcconf, and similarFixed by the utility (DllRegisterServer or DllUnregisterServer for regsvr32, INSTALLDRIVER or REGSVR patterns for odbcconf, per LOLBAS)No. The DLL has to export the right named function, and the function reference is invariant.

Resolving name and ordinal calls

Resolving the call to a normalized identity is the entire mechanic of this post. Once that resolution exists, downstream rules see one identity per function regardless of which surface form the attacker chose.

StepWhatWhy
1. Pre-filterMatch process creation events where the image is rundll32.exe.Limits scope to the loader pattern that surfaces the dual-naming problem.
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. Identify ordinal form by the leading #.Separates the two pieces of identity that the rest of the pipeline needs.
3. ResolveFor ordinal references, look up the corresponding function name in the DLL's export table. The result is a normalized (DLL identifier, function name) tuple regardless of surface form. For ieadvpack.dll, both RegisterOCX and #11 resolve to the same tuple.Without this step, ordinal-form events stay opaque to any rule keyed on names.
4. EmitPass the normalized tuple, the DLL identifier, and the original surface form to the detection layer.The detection layer compares against tuples. Investigators can still see the original form for forensic accuracy.

The DLL's export table is needed for resolution. Two practical options exist for where that resolution happens. Either at query time, with rules joining each event against a maintained reference dataset of DLL exports, or at ingest, with the pipeline tagging each rundll32 event with the resolved function name as a new field. The query-time approach is simpler to set up. The ingest-time approach moves the per-event work out of detection queries and into the pipeline. Both produce the same tuple. The choice is a workload tradeoff, not a correctness one.

For ordinal-only exports where the DLL has no name registered for that entry, the tuple keeps the ordinal as the identifier since there is nothing to substitute.

Two detection patterns

Once events resolve to a normalized tuple, two alerting patterns sit on top. They serve different purposes and complement each other.

The first pattern is novelty against a historical lookup table. Maintain a table of every (DLL identifier, function name) tuple 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. This surfaces tuples that have never been seen in the environment, fires once per genuinely new combination, and goes quiet once a tuple has been baselined.

The key has to be the resolved tuple. If the table keys on the raw command line, the same function called by name today and by ordinal tomorrow looks like two separate novel events, and the next time either form appears it stays silent because the wrong half of the pair was baselined. Keying on the resolved tuple keeps both forms collapsed into one entry.

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

The two patterns are layered for a reason. Novelty fires once per new tuple and then never again. The first malicious RegisterOCX call 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 of a documented LOLBin technique pass quietly. Without the novelty rule, anything not on the curated list is invisible.

The known-bad list has to handle both name and ordinal forms. The clean way is to keep the list keyed on function names and rely on the resolution step to normalize ordinals before the comparison. Listing both RegisterOCX and #11 as separate entries works in the short term but breaks the moment the ordinal mapping shifts in a future Windows update.

Previous
Previous

Detecting Suffix-Variant DLL Calls

Next
Next

Detecting Nested PowerShell Encoding