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.
| Ordinal | Function name |
|---|---|
| 1 | DelNodeRunDLL32 |
| 2 | DelNodeRunDLL32A |
| 3 | DoInfInstall |
| 4 | DoInfInstallA |
| 5 | DoInfInstallW |
| 6 | FileSaveRestore |
| 7 | FileSaveRestoreA |
| 8 | LaunchINFSection |
| 9 | LaunchINFSectionEx |
| 10 | LaunchINFSectionExA |
| 11 | RegisterOCX |
| 12 | RegisterOCXW |
| 13 | AddDelBackupEntry |
| 14 | AddDelBackupEntryA |
| 15 | AddDelBackupEntryW |
| 16 | AdvInstallFile |
| 17 | AdvInstallFileA |
| 18 | AdvInstallFileW |
| 19 | CloseINFEngine |
| 20 | DelNode |
| 21 | DelNodeA |
| 22 | DelNodeRunDLL32W |
| 23 | DelNodeW |
| 24 | ExecuteCab |
| 25 | ExecuteCabA |
| 26 | ExecuteCabW |
| 27 | ExtractFiles |
| 28 | ExtractFilesA |
| 29 | ExtractFilesW |
| 30 | FileSaveMarkNotExist |
| 31 | FileSaveMarkNotExistA |
| 32 | FileSaveMarkNotExistW |
| 33 | FileSaveRestoreOnINF |
| 34 | FileSaveRestoreOnINFA |
| 35 | FileSaveRestoreOnINFW |
| 36 | FileSaveRestoreW |
| 37 | GetVersionFromFile |
| 38 | GetVersionFromFileA |
| 39 | GetVersionFromFileEx |
| 40 | GetVersionFromFileExA |
| 41 | GetVersionFromFileExW |
| 42 | GetVersionFromFileW |
| 43 | IsNTAdmin |
| 44 | LaunchINFSection |
| 45 | LaunchINFSectionExW |
| 46 | LaunchINFSectionW |
| 47 | NeedReboot |
| 48 | NeedRebootInit |
| 49 | OpenINFEngine |
| 50 | OpenINFEngineA |
| 51 | OpenINFEngineW |
| 52 | RebootCheckOnInstall |
| 53 | RebootCheckOnInstallA |
| 54 | RebootCheckOnInstallW |
| 55 | RegInstall |
| 56 | RegInstallA |
| 57 | RegInstallW |
| 58 | RegRestoreAll |
| 59 | RegRestoreAllA |
| 60 | RegRestoreAllW |
| 61 | RegSaveRestore |
| 62 | RegSaveRestoreA |
| 63 | RegSaveRestoreOnINF |
| 64 | RegSaveRestoreOnINFA |
| 65 | RegSaveRestoreOnINFW |
| 66 | RegSaveRestoreW |
| 67 | RunSetupCommand |
| 68 | RunSetupCommandA |
| 69 | RunSetupCommandW |
| 70 | SetPerUserSecValues |
| 71 | SetPerUserSecValuesA |
| 72 | SetPerUserSecValuesW |
| 73 | TranslateInfString |
| 74 | TranslateInfStringA |
| 75 | TranslateInfStringEx |
| 76 | TranslateInfStringExA |
| 77 | TranslateInfStringExW |
| 78 | TranslateInfStringW |
| 79 | UserInstStubWrapper |
| 80 | UserInstStubWrapperA |
| 81 | UserInstStubWrapperW |
| 82 | UserUnInstStubWrapper |
| 83 | UserUnInstStubWrapperA |
| 84 | UserUnInstStubWrapperW |
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.
| Row | Command line | Resolves to |
|---|---|---|
| 1 | rundll32 c:\windows\system32\ieadvpack.dll,RegisterOCX <args> | (ieadvpack.dll, RegisterOCX) |
| 2 | rundll32 c:\windows\system32\ieadvpack.dll,#11 <args> | (ieadvpack.dll, RegisterOCX) |
| 3 | rundll32 c:\windows\system32\ieadvpack.dll,LaunchINFSection <args> | (ieadvpack.dll, LaunchINFSection) |
| 4 | rundll32 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.
| Utility | Function called | Reconciliation needed at command line? |
|---|---|---|
| rundll32 | Whatever the attacker specifies, by name or ordinal | Yes. |
| regsvr32, odbcconf, and similar | Fixed 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.
| Step | What | Why |
|---|---|---|
| 1. Pre-filter | Match process creation events where the image is rundll32.exe. | Limits scope to the loader pattern that surfaces the dual-naming problem. |
| 2. Parse | Split 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. Resolve | For 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. Emit | Pass 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.