Linking a PE¶
In this tutorial, you will be guided through loading and linking multiple PE32+ object files.
Note
This tutorial builds on Loading a PE; it’s highly recommended you read it first.
The majority of PE executables on a desktop or server system will be dynamically linked. This means some of their code is referenced from other PE files, known as Dynamically Linked Libraries (DLLs).
The program loader is responsible for loading all DLLs needed by an executable, and resoving any code and data cross-referenced between them.
SmallWorld doesn’t require that you fully link a dynamic executable, but you may want to harness code that calls a function or references data from a DLL.
Consider the example tests/link_pe/link_pe.pe.c and tests/link_pe/link_pe.dll.c:
extern int my_atoi(const char *arg);
int main(int argc, char *argv[]) {
int out = 0xc001d00d;
if (argc < 2) {
return out;
}
return my_atoi(argv[1]);
}
#include <stddef.h>
int my_atoi(const char *arg) {
int sign = 1;
int res = 0;
if(arg == NULL) {
return 0;
}
if(*arg == '-') {
sign = -1;
arg++;
}
while(*arg >= '0' && *arg <= '9') {
res *= 10;
res += (int)((*arg) - '0');
arg++;
}
return res * sign;
}
link_pe.pe.c calls the function my_atoi(),
which is defined in link_pe.dll.c.
As the names suggest, we are going to build these two files
into a separate executable and DLL,
so the harness will need to resolve the cross-reference between the two binaries.
You can build these into link_pe.amd64.pe and link_pe.amd64.dll
using the following commands:
cd smallworld/tests
# NOTE: The order of these files is important
# link_pe.amd64.dll must exist for link_pe.amd64.pe to link correctly.
make link_pe/link_pe.amd64.dll link_pe/link_pe.amd64.pe
Warning
This requires a version of GCC targeting MinGW.
On Debian and Ubuntu, this can be installed via the apt package
gcc-mingw-w64.
Specifying dynamic dependencies¶
The first part of the dynamic linking process is identifying necessary DLLs.
SmallWorld leaves this process up to the harness author, allowing them to provide specific versions of any required DLLs, or to leave out DLLs that won’t be relevant to the harness.
We can get a list of the DLLs required by an executable or DLL
using objdump -p. Let’s try this with link_pe.amd64.pe:
$ objdump -p link_pe.amd64.pe | grep 'DLL Name'
DLL Name: link_pe.amd64.dll
DLL Name: api-ms-win-crt-heap-l1-1-0.dll
DLL Name: api-ms-win-crt-private-l1-1-0.dll
DLL Name: api-ms-win-crt-runtime-l1-1-0.dll
DLL Name: api-ms-win-crt-stdio-l1-1-0.dll
DLL Name: api-ms-win-crt-string-l1-1-0.dll
DLL Name: KERNEL32.dll
DLL Name: api-ms-win-crt-environment-l1-1-0.dll
DLL Name: api-ms-win-crt-math-l1-1-0.dll
DLL Name: ntdll.dll
We see that, aside from the core windows libraries KERNEL32.dll and msvcrt.dll,
our program requires link_pe.amd64.dll.
Loading multiple PEs¶
Loading multiple PE files is as simple as calling the PE loader multiple times,
and adding multiple objects to the Machine.
Like other PEs, DLLs specify a default load address which can be overridden.
filename = "link_pe.amd64.pe"
with open(filename, "rb") as f:
code = smallworld.state.memory.code.Executable.from_pe(
f, platform=platform, address=0x400000
)
machine.add(code)
libname = "link_pe.amd64.dll"
with open(libname, "rb") as f:
lib = smallworld.state.memory.code.Executable.from_pe(
f, platform=platform, address=0x800000
)
machine.add(lib)
Linking PEs¶
The final step is to resolve the cross-references between our binaries, a process called “linking”.
PE file linking is mercifully simple. DLLs provide a data structure called the Export Address Table (EAT). This is essentially just a list of addresses that can be looked up by name or by a numeric index called an “ordinal”.
PE files and DLLs include a set of Import Address Tables, one for each required DLL. These are blank lists of addresses, with each entry tied to the name or ordinal of an export from the particular DLL.
The PE dynamic linker simply looks up the named export in the relevant EAT, and writes its value into the relevant IAT entry. Any code that needs the imported address will refer to its IAT entry.
Contrast this with ELF, where there’s essentially no limit to where and in what form an imported value can appear in code or data. The PE system is inflexible, but it’s far simpler to model.
SmallWorld offers a few interfaces for simulating this process.
A harness can link a specific import by updating its value; the PE file will write the correct IAT entry opaquely.
# Remember, exports and imports are defined by (DLL name, export name)
atoi = lib.get_export("link_pe.amd64.dll", "my_atoi")
code.update_import("link_pe.amd64.dll", "my_atoi", atoi)
This method is also useful if a harness needs to provide its own address, say for a function hook (for more information, see Modeling and Hooking):
atoi = smallworld.state.models.Model.lookup(
"atoi", platform, smallworld.platforms.ABI.SYSTEMV, 0x10000
)
machine.add(atoi)
code.update_import("link_pe.amd64.dll", "my_atoi", atoi.address)
Beware that the IAT can define an import according
to either the name or ordinal; if one doesn’t work, try the other.
Also, the link-level interface of some binaries may not match their public API.
If an update replaced my_atoi() with a macro calling another function,
the code above would not be resilient to the change.
SmallWorld also offers a method PEExecutable.link_pe()
to perform this operation for every import
in a destination PE which has a corresponding export
in a source PE:
code.link_pe(lib)
Since PEs index symbols by DLL name, this is somewhat resilient to load order.
Putting it all together¶
Using what we’ve learned about the PE loader and linker model,
we can build link_pe.amd64.py:
import logging
import sys
import smallworld
# Set up logging and hinting
smallworld.logging.setup_logging(level=logging.INFO)
# Define the platform
platform = smallworld.platforms.Platform(
smallworld.platforms.Architecture.X86_64, smallworld.platforms.Byteorder.LITTLE
)
# Create a machine
machine = smallworld.state.Machine()
# Create a CPU
cpu = smallworld.state.cpus.CPU.for_platform(platform)
machine.add(cpu)
# Load and add code into the state
filename = (
__file__.replace(".py", ".pe")
.replace(".angr", "")
.replace(".panda", "")
.replace(".pcode", "")
)
with open(filename, "rb") as f:
code = smallworld.state.memory.code.Executable.from_pe(
f, platform=platform, address=0x400000
)
machine.add(code)
libname = (
__file__.replace(".py", ".dll")
.replace(".angr", "")
.replace(".panda", "")
.replace(".pcode", "")
)
with open(libname, "rb") as f:
lib = smallworld.state.memory.code.Executable.from_pe(
f, platform=platform, address=0x800000
)
machine.add(lib)
# Load and add code from lib.
code.link_pe(lib)
# Set entrypoint. PE doesn't make this easy.
entrypoint = code.address + 0x1000
cpu.rip.set(entrypoint)
# Create a stack and add it to the state
stack = smallworld.state.memory.stack.Stack.for_platform(platform, 0x2000, 0x4000)
machine.add(stack)
# Push a string onto the stack
string = sys.argv[1].encode("utf-8")
string += b"\0"
string += b"\0" * (16 - (len(string) % 16))
stack.push_bytes(string, None)
str_addr = stack.get_pointer()
# Push argv
stack.push_integer(0, 8, None) # NULL terminator
stack.push_integer(str_addr, 8, None) # pointer to string
stack.push_integer(0x10101010, 8, None) # Bogus pointer to argv[0]
# Push address of argv
argv = stack.get_pointer()
stack.push_integer(argv, 8, None)
# Push argc
stack.push_integer(2, 8, None)
# Configure the stack pointer
sp = stack.get_pointer()
cpu.rsp.set(sp)
# Set argument registers
cpu.rcx.set(2)
cpu.rdx.set(argv)
# Configure _main model
class InitModel(smallworld.state.models.Model):
name = "_main"
platform = platform
abi = smallworld.platforms.ABI.NONE
def model(self, emulator: smallworld.emulators.Emulator) -> None:
# Return
pass
init = InitModel(code.address + 0x16C0)
machine.add(init)
# Emulate
emulator = smallworld.emulators.UnicornEmulator(platform)
# Use code bounds from the ELF
emulator.add_exit_point(0)
for bound in code.bounds:
machine.add_bound(bound[0], bound[1])
for bound in lib.bounds:
machine.add_bound(bound[0], bound[1])
# I happen to know where the code _actually_ stops
emulator.add_exit_point(code.address + 0x10D2)
final_machine = machine.emulate(emulator)
final_cpu = final_machine.get_cpu()
print(final_cpu.rax)
This includes code for linking a PE file,
as well as setting up the stack and registers to provide
argc and argv to main,
as well as stubbing out the library initializers.
Here is what running this harness looks like:
$ python3 link_pe.amd64.py 42
[+] starting emulation at 0x401000
[+] emulation complete
Reg(rax,8)=0x2a
Since 0x2a is the integer version of 42,
we have successfully harnessed link_pe.amd64.pe
and link_pe.amd64.dll