PE Internals Part 1: A few words about Export Address Table (EAT)

To understand how the PE loader finds addresses of functions exported by DLLs, we need to talk about Export Address Table (EAT).

Here I write a few words about EAT in a PE file format.

When we use a function exported by a DLL in our code, the loader will be responsible for parsing the headers of that DLL (say, “kernel32.dll”) until it reaches the Export Directory table on Optional Header -> DataDirectory. The Export Directory is implemented in a well-defined struct and contains all the information required by the loader to discover the relative virtual address (RVA) of exported functions:

Untitled

Some fields of this struct are of special interest to understand how the lookup of a function is done by PE loader:

  • Name: the name of the DLL.
  • Base: the number used to subtract from the ordinal number to get the index into the AddressOfFunctions array.
  • NumberOfFunctions: total number of exported functions, either by name or ordinal.
  • NumberOfNames: number of exported names, which need not be the NumberOfFunctions that presents all of the functions exported by module. Rather than that, this field presents only the number of functions exported by name. Functions can also be exported by ordinal, rather than name. If this value is 0, then all of the functions in this module are exported by ordinal and none of them is exported by name.
  • AddressOfFunctions: a RVA to the list of exported functions – it points to an array of NumberOfFunctions DWORD values. Each value is either an RVA pointing to the desired function, or in the case of forwarded functions, an RVA pointing to a forwarding string.
  • AddressOfNames: a RVA to the list of exported names – it points to an array of NumberOfNames DWORD values, each being a RVA to the exported symbol name.
  • AddressOfNameOrdinals: This field is an RVA and points to an array of WORDs. The WORDs are the export ordinals of all the exported functions in this module. However, don’t forget to add in the starting ordinal number specified in the Base field.

So let’s suppose PE loader wants to load the name3 function in a particular DLL. First step is to parse the DLL headers and get access to the IMAGE_EXPORT_DIRECTORY. Then, the lookup will start as follows:

Untitled (Source: resources.infosecinstitute.com)

  1. Iterate the “AddressOfNames” array from i=0 to i=(NumberOfNames-1), comparing AddressOfNames[i] with the string name3.
  2. Once we have a match in the “i” position, the loader will refer to AddressOfNameOrdinals[i] and get the ordinal associated to this function. Let’s suppose that AddressOfNameOrdinals[i] = 4.
  3. Having the ordinal = 4, the loader will now refer to AddressOfFunctions on 4th position, that is AddressOfFunctions[4], to finally get the RVA associated to the “name3” function.

Note that we are assuming that the loader starts its search from a given function name - name3. However, we could also have the scenario where the address of a function would be resolved from an ordinal “n” instead of a function name. In this scenario, the search would directly return the RVA stored in AddressOfFunctions[n-base], without having to search by a name in AddressOfNames.

In summary, this is the process that PE loader will go through every time it wants to resolve the address of a function. Using PE Bear application, we can validate the _IMAGE_EXPORT_DIRECTORY structure and its fields for a given DLL. Let’s load C:\Windows\System32\kernel32.dll from disk and verify that:

Untitled

Here we have a detailed view of DOS Header, DOS stub, NT headers, sections, disasm contents and more associated to “kernel32.dll”. As we are interested in the export table, we should move to “NT Headers -> Optional Header”:

Untitled

Inside Optional Header, the figure above shows that in the file offset “170” of IMAGE_DATA_DIRECTORY, we have the RVA 99080 which is the RVA of “IMAGE_EXPORT_DIRECTORY”. If we move to this offset:

Note: Remember FileOffset = RVA – section[Virtual address] + section[Pointer to raw data], so the FileOffset of RVA 99080 is 97880.

Untitled

We can see the same fields described in the struct IMAGE_EXPORT_DIRECTORY earlier.

Considering AddressOfNames, for example, this is a pointer to an array of pointers starting at RVA 9AA2C (FileOffset 9922C):

Untitled

Each position of this array contains a RVA to a function name (string):

Position[0] → 0009D07F

Position[1] → 0009D0B8

Position[2] → 0009D0EB

And so on.

If we jump to RVA 0009D07F (position 0), we can see the function name “AcquireSRWLockExclusive”:

Untitled

Untitled

If we want to find the RVA associated to this function, we need to move now to AddressOfNameOrdinals[0], which holds the value 0 on RVA 9C3B0 (FileOffset 9ABB0):

Untitled

Finally, we can get the AcquireSRWLockExclusive RVA on AddressOfFunctions[0]. AddressOfFunctions points to an array of DWORDs starting on RVA 0990A8 (FileOffset 978A8). In the position 0, we have “09D097”, which is the AcquireSRWLockExclusive function RVA on kernel32.dll:

Untitled

As discussed, the AddressOfFunctions is a pointer to an array of function RVAs. Usually, these RVAs would take us to the actual implementation (.text section) for these functions.

However, in this particular case, if we go to 09D097 (FileOffset 9B897), we will NOT reach the .text section of AcquireSRWLockExclusive:

Untitled

Untitled

Instead, we actually get the string “ntdll.RtlAcquireSRWLockerExclusive”.

The reason for this is that the implementation of AcquireSRWLockExclusive is not in kernel32.dll, but in ntdll.dll; and we have a forward. In a forward, as the name suggests, the loader will have to look for the RVA of this function in the library forwarded, in this case, ntdll.dll.

A function is forwarded if the value found in AddressOfFunctions falls within the range:

[Export_Directory.RVA, Export_Directory.RVA + Export_Directory.Size]

In this case, Export_Directory.RVA is 99080 and Export_Directory.Size is DF0C:

Untitled

So the range will be [99080, 99080+DF0C]. Since “9D097” is lower than A6F8C (99080+DF0C), this RVA holds a variable in the format library.name instead of the actual implementation of the function (.text).

If we now consider a function that is actually implemented in kernel32.dll, like “CreateFileA”:

Untitled

We have the RVA 24B50 stored on AddressOfFunctions[C7-base]. If we jump to 24B50 (FileOffset 23F50), we will now reach the .text section of CreateFileA():

Untitled

Untitled

A debugger can be used to validate that 24B50 is truly the RVA of CreateFileA() in kernel32.dll. Below “notepad.exe” is attached on win64dbg:

Untitled

Then, we can confirm that CreateFileA() is located exactly at 00007FFAA0674B50, which is the result of kernel32.dll base address (00007FFAA0650000) + CreateFileA RVA (24B50):

Untitled

If we go to 00007FFAA0674B50, we will find the same code (.text) listed above on PE bear:

Untitled

Written on September 5, 2022