Difficulty: Intermediate

Module 5: Symbol Resolution & Linking

From unresolved names to live function pointers: the runtime linker inside COFFLoader.

Why This Module?

After section data is loaded into memory (Module 4), the code still contains unresolved references. Every call to BeaconPrintf, every reference to KERNEL32$GetCurrentProcessId -- these are just symbol names. COFFLoader must resolve each symbol to an actual memory address. This module covers the process_symbol() function and the three categories of symbols it handles.

Lab Files: step05-symbol-resolution

Adds symbol resolution to the loader: process_symbol(), the InternalFunctions table, and DLL import parsing via LIBRARY$Function. Prints resolution results but still no relocation patching.

Three Categories of Symbols

When COFFLoader encounters a symbol during relocation processing, it must determine what kind of symbol it is and resolve it accordingly. There are three categories:

CategoryHow to IdentifyResolution Method
Internal (section-defined)SectionNumber > 0sectionMapping[SectionNumber - 1] + Value
Beacon APIName starts with __imp_Beacon or matches InternalFunctions tableLook up in the InternalFunctions[30] table
DLL ImportName contains $ separator (LIBRARY$Function)LoadLibraryA + GetProcAddress

Symbol Name Retrieval

Before processing a symbol, COFFLoader must retrieve its name. Each symbol table entry is exactly 18 bytes long.[1] Recall from Module 2 that names can be stored inline (up to 8 chars) or in the string table:[2]

C// Getting the symbol name from a coff_sym_t entry
char* get_symbol_name(coff_sym_t* sym, char* string_table) {
    if (sym->first.value[0] != 0) {
        // Short name: stored inline in the 8-byte Name field
        // Note: may NOT be null-terminated if exactly 8 chars
        return sym->first.Name;  // up to 8 characters
    } else {
        // Long name: first.value[0]==0 means first.value[1] is string table offset
        return string_table + sym->first.value[1];
    }
}

The __imp_ Prefix Convention

This is one of the most critical details in COFF loading. When a BOF declares an imported function with DECLSPEC_IMPORT (__declspec(dllimport)), the compiler generates a symbol with the __imp_ prefix:[3]

C// BOF source declares:
DECLSPEC_IMPORT DWORD WINAPI KERNEL32$GetCurrentProcessId(void);

// Compiler generates symbol: __imp_KERNEL32$GetCurrentProcessId  (x64)
// On x86, it would be:       __imp__KERNEL32$GetCurrentProcessId (extra underscore)

// The __imp_ prefix tells the loader: "this symbol is an INDIRECT reference"
// The BOF code does NOT call the function directly.
// Instead, it reads a function pointer from a known address and calls through it.

Direct vs. Indirect Calls

Without __declspec(dllimport), the compiler would generate a direct CALL to the symbol. With it, the compiler generates an indirect call through a pointer: CALL [rip + offset_to___imp_symbol].[3] The __imp_ symbol resolves to a memory location that contains the function address (a pointer-to-function), not the function itself. This is why COFFLoader stores resolved addresses in the functionMapping table -- the code reads the pointer from that table.

TEXTHow __imp_ works at the machine code level:

Without dllimport:
  E8 xx xx xx xx    CALL function_address    ; direct call (REL32)

With dllimport (__imp_ prefix):
  FF 15 xx xx xx xx CALL [rip + offset]      ; indirect call through pointer

The [rip + offset] points to a slot in functionMapping that contains
the actual address of the function. The loader fills this slot during
symbol resolution.

The process_symbol() Function

COFFLoader's process_symbol() handles all three symbol categories. Here is its logic flow:

C// Simplified process_symbol() logic
void* process_symbol(char* symbolName) {

    // 1. Check if it is a Beacon internal function
    //    Strip the __imp_ prefix first, then check the InternalFunctions table
    char* cleanName = symbolName;
    if (starts_with(symbolName, "__imp_")) {
        cleanName = symbolName + 6;  // skip "__imp_"
    }
    // On x86: skip "__imp__" (7 chars) due to extra underscore

    // Check against InternalFunctions[30] table
    for (int i = 0; i < 30; i++) {
        if (InternalFunctions[i][0] != NULL) {
            if (strcmp(cleanName, (char*)InternalFunctions[i][0]) == 0) {
                // Found it -- return the function pointer
                return (void*)InternalFunctions[i][1];
            }
        }
    }

    // 2. Not a Beacon function -- must be a DLL import
    //    Parse the LIBRARY$Function format
    char  libraryName[256];
    char  functionName[256];
    // Split cleanName on '$' character
    // e.g., "KERNEL32$GetCurrentProcessId" -> library="KERNEL32", function="GetCurrentProcessId"

    HMODULE hLib = LoadLibraryA(libraryName);
    if (hLib == NULL) return NULL;

    void* addr = GetProcAddress(hLib, functionName);
    return addr;
}

The InternalFunctions Table

COFFLoader maintains a static array of 30 entries mapping Beacon API function names to their implementation addresses.[7] This table is populated before RunCOFF() processes any symbols:

C// Declared in beacon_compatibility.h:
extern unsigned char* InternalFunctions[30][2];

// Each entry is: { "FunctionName", function_pointer }
// Populated in RunCOFF() before relocation processing:

InternalFunctions[0][0] = (unsigned char*)"BeaconDataParse";
InternalFunctions[0][1] = (unsigned char*)&BeaconDataParse;

InternalFunctions[1][0] = (unsigned char*)"BeaconDataInt";
InternalFunctions[1][1] = (unsigned char*)&BeaconDataInt;

InternalFunctions[2][0] = (unsigned char*)"BeaconDataShort";
InternalFunctions[2][1] = (unsigned char*)&BeaconDataShort;

InternalFunctions[3][0] = (unsigned char*)"BeaconDataLength";
InternalFunctions[3][1] = (unsigned char*)&BeaconDataLength;

InternalFunctions[4][0] = (unsigned char*)"BeaconDataExtract";
InternalFunctions[4][1] = (unsigned char*)&BeaconDataExtract;

InternalFunctions[5][0] = (unsigned char*)"BeaconFormatAlloc";
InternalFunctions[5][1] = (unsigned char*)&BeaconFormatAlloc;

InternalFunctions[6][0] = (unsigned char*)"BeaconFormatReset";
InternalFunctions[6][1] = (unsigned char*)&BeaconFormatReset;

InternalFunctions[7][0] = (unsigned char*)"BeaconFormatFree";
InternalFunctions[7][1] = (unsigned char*)&BeaconFormatFree;

InternalFunctions[8][0] = (unsigned char*)"BeaconFormatAppend";
InternalFunctions[8][1] = (unsigned char*)&BeaconFormatAppend;

InternalFunctions[9][0] = (unsigned char*)"BeaconFormatPrintf";
InternalFunctions[9][1] = (unsigned char*)&BeaconFormatPrintf;

InternalFunctions[10][0] = (unsigned char*)"BeaconFormatToString";
InternalFunctions[10][1] = (unsigned char*)&BeaconFormatToString;

InternalFunctions[11][0] = (unsigned char*)"BeaconFormatInt";
InternalFunctions[11][1] = (unsigned char*)&BeaconFormatInt;

InternalFunctions[12][0] = (unsigned char*)"BeaconPrintf";
InternalFunctions[12][1] = (unsigned char*)&BeaconPrintf;

InternalFunctions[13][0] = (unsigned char*)"BeaconOutput";
InternalFunctions[13][1] = (unsigned char*)&BeaconOutput;

// ... additional entries for BeaconUseToken, BeaconRevertToken,
//     BeaconIsAdmin, BeaconGetSpawnTo, BeaconSpawnTemporaryProcess,
//     BeaconInjectProcess, BeaconInjectTemporaryProcess,
//     BeaconCleanupProcess, toWideChar, etc.

DLL Import Resolution

For symbols that are not Beacon API functions, COFFLoader parses the LIBRARY$Function naming convention[4] using LoadLibraryA[5] and GetProcAddress[6] for resolution:

TEXTSymbol Name Parsing:

Input:  "__imp_KERNEL32$GetCurrentProcessId"
Step 1: Strip __imp_ prefix  -> "KERNEL32$GetCurrentProcessId"
Step 2: Split on '$'          -> library = "KERNEL32", function = "GetCurrentProcessId"
Step 3: LoadLibraryA("KERNEL32")
Step 4: GetProcAddress(hModule, "GetCurrentProcessId")
Result: 0x00007FFA1A2B3C4D (address of GetCurrentProcessId in kernel32.dll)

Input:  "__imp_NTDLL$NtQuerySystemInformation"
Step 1: Strip __imp_         -> "NTDLL$NtQuerySystemInformation"
Step 2: Split on '$'          -> library = "NTDLL", function = "NtQuerySystemInformation"
Step 3: LoadLibraryA("NTDLL")
Step 4: GetProcAddress(hModule, "NtQuerySystemInformation")
Result: 0x00007FFA1B2C3D4E

Ordinal-Based Imports

Some DLL functions are exported by ordinal (a numeric identifier) rather than by name. COFFLoader supports ordinal-based resolution using the LIBRARY$Function#ordinal format. When the symbol contains a # in the function name position, COFFLoader extracts the ordinal number and uses it with GetProcAddress (passing the ordinal as the low-word of the name parameter via MAKEINTRESOURCE).[8] This is rare in BOFs but supported for completeness.

Internal Symbol Resolution

Not all symbols require external resolution. Symbols defined within the BOF itself (local functions, static variables, section names) have SectionNumber > 0.[10] These are resolved directly from the sectionMapping array:

C// For a symbol with SectionNumber > 0:
// The symbol is defined in the COFF file itself.
// Its address = base of its section + its Value offset.

if (coff_symbol_is_defined(&symbols[symIdx])) {
    int sectionIndex = symbols[symIdx].SectionNumber - 1;  // 0-based
    void* address = sectionMapping[sectionIndex] + symbols[symIdx].Value;
    // 'address' now points to the symbol in loaded memory
}
TEXTExample: Resolving the "go" function symbol

Symbol table entry:
  Name = "go"
  Value = 0x00        (offset 0 within its section -- it's the first function)
  SectionNumber = 1   (defined in section 1, which is .text)
  StorageClass = 2    (EXTERNAL -- globally visible)

Resolution:
  sectionIndex = 1 - 1 = 0
  address = sectionMapping[0] + 0x00
  address = 0x00007FF8A1230000   (base of .text allocation)

This is the entry point address that COFFLoader will call.

Storing Resolved Addresses

For external symbols (Beacon API and DLL imports) with the __imp_ prefix, the resolved function address is stored in the functionMapping table during relocation processing (Module 6). The table is allocated with one slot per symbol table entry and indexed by the symbol's index in the symbol table:

C// Allocate one pointer slot per symbol (most will stay NULL)
void** functionMapping = (void**)calloc(numSymbols, sizeof(void*));

// During symbol resolution, store by symbol table index:
functionMapping[symIdx] = process_symbol(symbolName);

// During relocation, the __imp_ case takes the ADDRESS of the slot:
// symbolAddress = (char*)&functionMapping[symIdx];
// The BOF code reads the pointer from this slot via CALL [rip+offset].

Symbol Resolution Flow

Symbol Name
from symbol table
process_symbol()
classify & resolve
functionMapping[i]
store pointer

Architecture Differences: x64 vs x86

COFFLoader handles both architectures with a preprocessor-based prefix. The extra underscore on x86 follows the cdecl calling convention, which prepends an underscore to all external C symbol names:[9]

C// The prefix before symbol names varies by architecture:
#ifdef _WIN64
    #define PREPENDSYMBOLVALUE "__imp_"    // x64: __imp_FunctionName
#else
    #define PREPENDSYMBOLVALUE "__imp__"   // x86: __imp__FunctionName (extra _)
#endif

// x86 C calling convention prepends an underscore to all symbol names.
// Combined with __declspec(dllimport), x86 gets __imp__ (double underscore)
// while x64 gets __imp_ (single underscore after imp).
ArchitecturePrefixExample Symbol
x64 (AMD64)__imp___imp_KERNEL32$GetCurrentProcessId
x86 (i386)__imp____imp__KERNEL32$GetCurrentProcessId
x64 entry(none)go
x86 entry__go

The Complete Resolution Path

When COFFLoader processes relocations, it encounters a symbol index. It looks up the symbol in the symbol table, retrieves its name, and calls process_symbol(). For external symbols, the name is checked against the InternalFunctions table (Beacon API), and if not found, parsed as LIBRARY$Function for DLL resolution. The resolved address is stored in functionMapping, and the relocation engine patches the BOF code to reference the correct slot. This is essentially runtime linking -- what ld.exe or link.exe would do at build time, COFFLoader does at load time.[7]

Challenges and Edge Cases

When resolving symbols at runtime, several edge cases can cause failures or unexpected behavior that a robust loader must handle. Unlike a static linker that can report errors at build time, COFFLoader discovers these problems only when the BOF is already running inside the target process.

DLLs That Fail to Load

LoadLibraryA can return NULL for several reasons: the specified DLL does not exist on the target system, one of its own dependencies is missing, or the process lacks the necessary access rights to load it. A BOF compiled on a development machine might reference a DLL that is not present on a hardened server or a stripped-down Windows edition. A production-quality loader should log which library failed and abort gracefully rather than crashing on a NULL dereference when GetProcAddress is called on the invalid handle.

Functions That Do Not Exist

Even when a DLL loads successfully, GetProcAddress may return NULL if the function name is misspelled in the BOF source or if the BOF targets a Windows API that does not exist on the current OS version. For example, a BOF referencing KERNEL32$CreatePseudoConsole will fail on Windows versions prior to 1809, where that API was not yet available. Similarly, APIs introduced in Windows 11 (such as certain thread-name functions) will not resolve on Windows 10 targets. The TrustedSec COFFLoader handles this by checking the return value from process_symbol() and printing a diagnostic message before continuing.

Ordinal-Only Exports

Some DLLs export functions exclusively by ordinal with no name string in the export address table. This is common in older COM-related libraries and certain internal Windows components. The BOF author must know the correct ordinal number and use the LIBRARY$Function#ordinal format, since there is no name to match against. Getting the ordinal wrong silently resolves to the wrong function, which can cause crashes or unpredictable behavior in the target process.

Forwarded Exports

The Windows loader transparently handles export forwarding -- for example, many kernel32.dll functions actually forward to ntdll.dll or kernelbase.dll internally. When COFFLoader uses GetProcAddress, forwarded exports are resolved automatically by the OS. However, loaders that bypass GetProcAddress and parse the export address table manually must detect forwarding entries (identified by the export RVA pointing into the export directory itself rather than into a code section) and follow the forwarding chain to the final target function.

Security Considerations

The use of LoadLibraryA and GetProcAddress for symbol resolution has significant implications in adversarial environments. Both functions are well-known targets for user-mode API hooking by Endpoint Detection and Response (EDR) products.[5] An EDR typically overwrites the first bytes of these functions (or their downstream targets like LdrLoadDll and LdrGetProcedureAddress in ntdll.dll) with a JMP instruction that redirects execution to the EDR's inspection module. When COFFLoader calls LoadLibraryA("NTDLL") followed by GetProcAddress(hModule, "NtAllocateVirtualMemory"), the EDR intercepts both calls and can log the resolution attempt, flag the specific API being resolved, and correlate it with other suspicious activity in the process.

To reduce this detection surface, advanced BOF loaders implement alternative resolution strategies. One common approach is walking the Process Environment Block (PEB) to enumerate loaded modules directly from the InMemoryOrderModuleList linked list without calling any API function. The loader reads the PEB address from the Thread Environment Block (TEB), traverses the LDR_DATA_TABLE_ENTRY structures, and locates the target module's base address. It can then parse the module's export address table manually to find function addresses. Another approach is hash-based function resolution, where function names are compared as pre-computed hashes (such as CRC32 or DJB2) rather than plaintext strings, making static analysis of the loader binary more difficult for defenders.

These evasion techniques are discussed further in Module 8 (Full Chain & Security). For our standalone COFFLoader, the straightforward LoadLibraryA / GetProcAddress approach is sufficient and considerably easier to follow while learning the fundamentals of COFF loading.

Pop Quiz: Symbol Resolution

Q1: A symbol is named "__imp_ADVAPI32$OpenProcessToken". How does COFFLoader resolve it?

COFFLoader first strips the __imp_ prefix to get "ADVAPI32$OpenProcessToken". It checks the InternalFunctions table (no match -- this is not a Beacon API function). Then it splits on $ to get library="ADVAPI32" and function="OpenProcessToken", calls LoadLibraryA to get the DLL handle, and GetProcAddress to get the function address.

Q2: Why does the compiler generate an __imp_ prefix for dllimport symbols?

The __imp_ prefix is the Microsoft convention for indirect import references. With __declspec(dllimport), the compiler generates code that reads a function pointer from a known location (CALL [rip + offset]) rather than a direct CALL. The __imp_ symbol resolves to the slot containing the pointer, not to the function itself. COFFLoader fills this slot in the functionMapping table.

Q3: How is a symbol defined within the BOF (SectionNumber=1, Value=0x20) resolved?

A symbol with SectionNumber > 0 is defined internally. SectionNumber is 1-based, so section 1 maps to sectionMapping[0]. The Value field (0x20) is the offset within that section. The resolved address is sectionMapping[0] + 0x20, which points directly into the loaded section memory.

References

  1. Microsoft Corporation, "PE Format: COFF Symbol Table," Microsoft Learn. Documents the 18-byte symbol record structure including the Name, Value, SectionNumber, Type, StorageClass, and NumberOfAuxSymbols fields.
  2. Microsoft Corporation, "PE Format: COFF Symbol Table -- Symbol Name Representation," Microsoft Learn. Specifies that symbol names of 8 bytes or fewer are stored inline in the Name field, while longer names use a zero in the first four bytes followed by a 4-byte offset into the string table.
  3. Microsoft Corporation, "__declspec(dllimport)," Microsoft Learn. Describes how the __declspec(dllimport) storage-class attribute causes the compiler to emit an __imp_ prefixed symbol reference and generate an indirect call through a pointer rather than a direct call.
  4. Cobalt Strike, "Beacon Object Files," Fortra. Documents the LIBRARY$Function naming convention used by BOFs to declare dynamic imports, where the library name and function name are separated by a dollar sign.
  5. Microsoft Corporation, "LoadLibraryA function (libloaderapi.h)," Microsoft Learn. Reference for the Win32 function that loads a DLL module into the address space of the calling process and returns a module handle.
  6. Microsoft Corporation, "GetProcAddress function (libloaderapi.h)," Microsoft Learn. Reference for the Win32 function that retrieves the address of an exported function or variable from a specified DLL.
  7. TrustedSec, "COFFLoader," GitHub. Open-source COFF loader implementation including the InternalFunctions[30] table and process_symbol() function used as the reference for this course.
  8. Microsoft Corporation, "GetProcAddress function," Microsoft Learn. Documents ordinal-based imports: when the high-order word of the lpProcName parameter is zero, the low-order word is treated as the function's ordinal value (via MAKEINTRESOURCE).
  9. Microsoft Corporation, "Decorated Names: C Specific," Microsoft Learn. Specifies that the cdecl calling convention on x86 prepends an underscore character to all external C symbol names, producing the double-underscore __imp__ pattern for imported symbols.
  10. Microsoft Corporation, "PE Format: COFF Symbol Table -- Section Number Values," Microsoft Learn. Defines that a SectionNumber greater than zero indicates the symbol is defined in the corresponding one-based section index, while zero or negative values indicate external or special symbols.

Further Reading