Chasing Ghosts: Phantom DLLs in Mirror's Edge
Table of Contents
In 2020, I started speedrunning the game Mirror’s Edge and quickly felt connected and welcomed into its small, close-knit community. I accumulated many hours in the game over the years, dedicating time to improving my skills, participating in tournaments, and exploring ways to enhance the overall experience. With a growing interest in computer security, I applied my knowledge to enhance one of the community’s favorite modifications: the open-source Multiplayer Mod. I researched improving the mod’s security and usability by removing its need for a privileged program which abused Windows administrator privileges. I demonstrated these improvements in a proof of concept project that relies on a Windows hacking technique called phantom DLL hijacking.
Origins of the Multiplayer Mod
Mirror’s Edge is a single-player parkour action game which requires players to be fast and precise with their in-game movement. Speedrunning means that players aim to complete a game as fast as possible by exploiting in-game bugs. Mirror’s Edge normally takes over 5 hours to complete. Experienced speedrunners can finish it in under an hour. I found other speedrunners’ competitive spirit and enthusiasm to be contagious. As I started to speedrun the game, I grew close to the community and participated in speedrun charity events and tournaments.
Through tournaments, I learned about “races”, which were friendly competitions to see who could complete the speedrun the fastest. With the aid of btbd’s “Multiplayer Mod”, players could connect and see each other’s player models, bringing a new level of intensity to races. I was eager to contribute and got involved by reaching out to Toyro98, the mod’s maintainer at the time. I offered to assist with the mod’s Golang-based server component by implementing an additional game mode that Toyro98 was developing. As I explored how the mod worked, I gained a better understanding of its privileged functionality that also caused headaches for players.
How the mod works
The mod consists of two files: a dynamic-link library (DLL) that provides the multiplayer code and a “launcher” executable that loads this DLL into the game. A DLL is a collection of code that cannot be directly executed but can be loaded into a program at run-time. DLLs are not specific to any particular application, allowing them to be shared across multiple programs. For example, DLLs in game development can offer shared functionality for networking, graphics, and audio.
Running the Multiplayer Mod was often a challenge for players due to interference from Windows built-in antivirus, Windows Defender. Several Defender prompts would appear when downloading the launcher, warning players that the launcher program was a potential virus. Players would need to disable real-time protection in Windows’ security settings to prevent Defender from deleting the launcher. Eventually this setting would get automatically re-enabled by Windows and Defender would delete the launcher program. The next time players wanted to run the mod, they would need to re-download the files and deal with Windows warnings again. Overcoming hurdles to run the mod was often frustrating, especially with the added pressure of it keeping other players waiting.
Why does Windows Defender dislike the launcher program to begin with? The launcher uses code injection to load the DLL, a technique commonly used by malware, which is why antivirus software usually flags it as suspicious.
When the launcher starts, a User Account Control (UAC) prompt appears, asking for administrator permissions. This prompt can be jarring and unclear, leaving users unsure about what exactly is being authorized. The UAC prompt appears because one of the launcher’s functions uses Windows’ AdjustTokenPrivileges
function to effectively give the launcher “god mode” access to the user’s computer using the powerful SE_DEBUG
privilege. This privilege grants the launcher not only full control over Mirror’s Edge but also over the entire system, bypassing configured access controls.
int WINAPI WinMain(HINSTANCE, HINSTANCE, char *, int) {
// ...
if (!AdjustCurrentPrivilege(SE_DEBUG_NAME)) {
MessageBox(0, L"Failed to adjust privileges to debug", L"Failure", MB_OK);
return 1;
}
// ...
}
bool AdjustCurrentPrivilege(const wchar_t *privilege) {
LUID luid;
if (!LookupPrivilegeValue(nullptr, privilege, &luid)) {
return FALSE;
}
TOKEN_PRIVILEGES tp = {0};
tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
HANDLE token;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &token)) {
return FALSE;
}
if (!AdjustTokenPrivileges(token, false, &tp, sizeof(tp), nullptr, nullptr)) {
CloseHandle(token);
return FALSE;
}
// ...
}
The launcher uses the SE_DEBUG
privilege to open a handle to the Mirror’s Edge process. A handle is an operating system abstraction that grants access to resources like files or network connections.
In the snippet below, the handle’s PROCESS_ALL_ACCESS
flag allows the launcher to terminate the game, read or write its memory, and even create a new thread within it. The launcher uses the handle to start a new thread of execution within the Mirror’s Edge process and instructs the game to load the multiplayer DLL.
int WINAPI WinMain(HINSTANCE, HINSTANCE, char *, int) {
// ...
const HANDLE process = OpenProcess(PROCESS_ALL_ACCESS, false, processInfo.th32ProcessID);
if (!process) {
TerminateThread(thread, 0);
MessageBox(0, L"Failed to open a handle to the process", L"Failure", 0);
return 1;
}
const auto status = LoadClient(process);
// ...
}
bool LoadClient(HANDLE process) {
std::wstring path;
if (!GetDllPath(path)) {
MessageBox(0, L"Failed to get dll path", L"Failure", 0);
return false;
}
// ...
const auto arg =
VirtualAllocEx(process, nullptr, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (arg) {
if (WriteProcessMemory(process, arg, path.c_str(), size, nullptr)) {
const auto thread =
CreateRemoteThread(process, nullptr, 0,
reinterpret_cast<LPTHREAD_START_ROUTINE>(GetProcAddress(
GetModuleHandle(L"kernel32.dll"), "LoadLibraryW")),
arg, 0, nullptr);
// ...
}
In the initial release of btbd’s mod, the launcher would automatically download the DLL from the mod’s GitHub repository, store it in the Windows temporary directory, and load the DLL into the game. Subsequent launcher executions would reuse the existing mod DLL stored in the temporary directory.
Storing the mod’s DLL in the temporary directory created several issues. Users were unfamiliar with navigating to the directory and, as its name implies, this directory was not intended for long-term file storage. A Windows Defender exclusion for the entire temporary directory was also required to prevent Defender from deleting the DLL. This posed a significant security risk, as any application can access this directory by design, allowing them to potentially place malicious code within the directory which the mod may accidentally load.
LucasOe made security improvements by having the launcher load the DLL from the game’s executable directory (called Binaries
). Since this directory is not writable by standard users, malicious software could no longer potentially replace the mod DLL with a malicious copy. This change allowed the launcher to easily find the mod’s DLL using a file path relative to the game’s executable, both located in the Binaries directory. To run the mod, users manually downloaded the latest multiplayer DLL and placed it in the Binaries directory before running the launcher.
While LucasOe’s change was a significant security improvement, it required users to manually copy files to filesystem locations they were unfamiliar with, which can be a challenge for less-technical players.
The Multiplayer Mod saw little development after LucasOe’s involvement in 2022. Significant progress resumed about a year later when Toyro98 became actively involved in the project. Toyro98 addressed various performance issues, made improvements for speedrun training, and added several features, such as more character models, Chaos Mode, and Tag Mode.
Evading Windows Defender
My partner, Stephen and I worked together to restructure the server-side Go code to support Toyro’s enhancements. This was the point when we realized how big of a headache UAC prompts and Defender could be. We wanted to take the time to ensure security was properly integrated without compromising the user experience.
By creating an installer, we aimed to resolve the issue of Windows Defender blocking mod files, which was a major pain point for players. However, since our new installer contained the mod files, Windows Defender now flagged the installer as malicious (meanie 😡). Even after downloading and running the installer, Windows Defender would eventually remove the mod’s launcher program unless we created a Windows Defender exclusion for the launcher or its parent directory.
To avoid triggering Windows Defender, we initially used some improvised methods, as we didn’t fully understand the root cause of the issue at the time. We attempted to mask the launcher’s actions within our installer by applying XOR obfuscation. Additionally, we included a custom helper program in the installer called Squibbles
. During installation, Squibbles
creates an exclusion in Windows Defender for the mod’s program files directory and restores the files to their original, non-XORed state, now safe from deletion.
While we disliked the idea of creating any Defender exclusions, we felt like it was an acceptable security compromise. We felt this way because the installer places the mod files in a directory that only administrator-level users can write to. A malicious application would need to elevate its privileges to exploit our installer’s Defender exclusion, but at that point, it could just create its own exclusion instead.
This method was a decent short-term solution, but would be brittle in the long run since Windows Defender may eventually improve its detection logic and flag poor Squibbles
. We wanted to explore whether we could avoid Windows Defender altogether by not relying on code injection and reducing the privilege granted to the mod.
Our solution would need to meet the following constraints to improve on the current method:
- Don’t modify the Mirror’s Edge application files or Windows files
- Don’t rely on the
SE_DEBUG
privilege,PROCESS_ALL_ACCESS
, or any functionality that triggers UAC - Ensure compatibility with other versions of the game (i.e. Origin, Reloaded, GOG)
Rather than telling the game to load the Multiplayer Mod, perhaps we can trick the game into loading the mod…
Looking for phantom DLL hijacking opportunities
We needed a secure, less-privileged method of loading the multiplayer code into the game. As a starting point, we examined the DLLs that Mirror’s Edge loads under normal circumstances and checked if any of those DLLs were not included with the game.
When an application tries to load DLLs that aren’t included with the application, the program may unintentionally load a malicious DLL. Phantom DLL hijacking is a technique where an attacker tricks an application into loading a malicious DLL when a required or legitimate DLL is missing. As long as the imposter DLL has the same name as the real one, Windows will treat it as the intended DLL. This allows the imposter DLL to be loaded without raising suspicion. We will leverage this behavior to load the Multiplayer Mod.
When a program starts, the operating system checks the program’s manifest and the registry to identify any required DLLs. These DLLs are searched in a specific sequence known as the “DLL search order”. This Microsoft documentation outlines the default DLL search order.
To summarize, the search order may vary depending on how the operating system is configured but often looks something like this:
- The directory from which the application loaded
- The system directory
- The 16-bit system directory
- The Windows directory
- The process’ current working directory
- The directories that are listed in the system’s PATH environment variable
The standard search order includes the directories listed by the PATH
environment variable. The inclusion of the PATH variable in the search order poses a security risk. For example, random Windows applications may modify the PATH variable to include user-writable directories. This potentially allows a malicious application to supply an imposter DLL using a directory specified by the PATH variable.
To identify potential phantom DLLs, we monitored the game’s DLL loading attempts with Process Monitor and looked for DLLs that exhaust the entire search order without being loaded. We filtered Process Monitor’s output to focus on DLLs outside of the Windows
or Program Files
directories, as changes to those directories could cause unexpected behavior in the game or system. Additionally, we refined the results to display only file paths ending in .dll
and focused on the QueryOpen
and Load
operations.
The game searches for the following “phantom” DLLs:
- NVCPL.DLL
- AgPerfMon.dll
- FonixTtsDtSimpleus.dll
- FonixTtsDtSimplefr.dll
- FonixTtsDtSimplegr.dll
- FonixTtsDtSimplesp.dll
- FonixTtsDtSimpleuk.dll
- FonixTtsDtSimplela.dll
- FonixTtsDtSimpleit.dll
These phantom DLLs could be remnants from the game engine or leftover artifacts from development. They might also be references to debugging tools that were not included in the final build of the game. The rush to release Mirror’s Edge on PC with the new PhysX engine might have contributed to this oversight. The FonixTtsDtSimple
libraries are likely related to text-to-speech functions based on their file names containing “tts” for “text to speech”. NVCPL.DLL
is associated with the NVIDIA Control Panel. AgPerfMon.dll
(Ageia Performance Monitor) appears to be related to the NVIDIA PhysX performance monitoring (it has Gantt charts! 📈).
We picked one of the DLLs to experiment with—in this case, AgPerfMon.dll
.
When analyzing the Mirror’s Edge executable in rizin
(a toolkit for reverse engineering), we were surprised to find no references to AgPerfMon
as a library or a string. Since we discovered that AgPerfMon is associated with NVIDIA and PhysX, we suspected it might be referenced in other PhysX-related DLLs loaded by the game. PhysXCore.dll
seemed promising and indeed contained the code that attempts to load AgPerfMon.dll
:
; 0x1029d610
; "AgPerfMon.dll"
push str.AgPerfMon.dll
; 0x1027f030
; HMODULE LoadLibraryA(LPCSTR lpLibFileName)
call dword [sym.imp.KERNEL32.dll_LoadLibraryA]
This could explain why the leftover DLL went unnoticed. Anyone reviewing the game’s source code wouldn’t see the loading code, as it resided in an external DLL that may have been maintained by NVIDIA.
To create a dummy AgPerfMon.dll
that loads the Multiplayer Mod’s DLL, we considered two options:
Option 1: Proxy the DLL’s functions
By reusing the same function signatures as those in the real AgPerfMon.dll
, we could craft our own DLL with the same function names and type signatures which execute our own custom code. One of these new functions could be designed to load the multiplayer DLL, while the other functions could do nothing. This approach allows us to safely keep our fake AgPerfMon.dll loaded while the game is running.
To discover AgPerfMon.dll
’s functions, we downloaded the real DLL from NVIDIA’s website and extracted the DLL from its installer using innoextract
. We wrote a small script that uses rizin
to create a list of functions and their signatures to proxy in our new DLL. This script allowed us to quickly assess the number of functions that needed to be defined and gauge the complexity of implementing them, as seen below:
void AgPmChainEvent(int32_t arg_4h, int32_t arg_10h, int32_t arg_ch, int32_t arg_8h);
void AgPmCreateSinkConnection(int32_t arg_4h, int32_t arg_814h);
void AgPmSubmitEvent(int32_t arg_4h, int32_t arg_14h, int32_t arg_10h, int32_t arg_ch, int32_t arg_8h);
void AgPmSubmitPerfCounterConfig(int32_t arg_4h, int32_t arg_14h, int32_t arg_8h, int32_t arg_10h);
void AgPmSubmitTimerConfig(int32_t arg_4h, int32_t arg_ch, int32_t arg_10h);
// (... and a lot more functions)
We ultimately decided against this method because implementing so many functions would be cumbersome. While we could use dummy functions, there’s no way to predict what the game code might expect from those functions. Since we don’t fully understand how the code is expected to behave, using this method could introduce instability in the game that would be difficult to detect.
Option 2: DllMain function
The DllMain
function is an optional entry-point into a DLL. If this function is defined, it gets called by Windows when a process or thread loads or unloads the DLL. Below is an example of such a function for illustration purposes:
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
break;
case DLL_PROCESS_DETACH:
// Runs when the DLL is deloaded.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}
This entry-point function can be used to perform simple initialization and cleanup tasks, allowing programmers to allocate or release additional resources as needed. Using DllMain
, we can define an entry-point function that runs when Mirror’s Edge loads our DLL. This function will handle loading the multiplayer DLL into Mirror’s Edge.
After loading the multiplayer DLL, we will have our DllMain return a value of FALSE
. Returning FALSE
tells Windows to unload our DLL from the game process. This behavior is outlined in Microsoft’s DllMain documentation:
If the return value is
FALSE
whenDllMain
is called because the process uses theLoadLibrary
function,LoadLibrary
returnsNULL
. (The system immediately calls your entry-point function withDLL_PROCESS_DETACH
and unloads the DLL.)
- DllMain entry point documentation
By forcing our DLL to be unloaded, we prevent the game from interacting with the DLL any further. This side-steps any expectations the game may have about calling functions in our fake AgPerfMon.dll. As a result, we opted to create a DLL that utilized DllMain rather than proxying functions.
Creating a Rust-based DLL
We used Rust to create a DLL containing a DllMain
function that loads the Multiplayer Mod DLL into Mirror’s Edge and then returns FALSE
to unload itself. We chose Rust because it has a robust foreign function interface (FFI) for interacting with code written in different languages. Using Rust allowed us to take advantage of its memory safety features while also interacting with the C++ code of Mirror’s Edge. We also wanted to learn about Rust and how it compares with languages like Go.
For our first prototype, we tried displaying a message box window when our DLL was loaded. We compiled our DLL, named it AgPerfMon.dll
, and placed it in one of the directories listed in PATH. If the message box appears when we started Mirror’s Edge, we knew that the game found and loaded our DLL. Unfortunately, the message box wouldn’t appear, and Process Explorer confirmed that our DLL was not loaded into the process.
While troubleshooting this issue, we tried renaming the Multiplayer Mod DLL to AgPerfMon.dll and placed it in a PATH directory. Since the mod has its own DllMain, we expected launching Mirror’s Edge would load the DLL - which it did! This indicated that the mod’s DLL had something that our prototype lacked. We decided to investigate both DLLs using rizin
and discovered that the mod’s DLL was compiled for 32-bit programs, whereas our DLL was compiled for 64-bit programs. Since Mirror’s Edge is a 32-bit program, it made sense why it couldn’t properly load our DLL.
In Rust, the compiler target
specifies the architecture to build for. In our case, we needed the i686-pc-windows-msvc
target to build a 32-bit application. We followed instructions on how to set this target - but when we reanalyzed the DLL in rizin
, we noticed it was still 64-bit.
This was probably due to Rust being installed with the Windows toolchain into Program Files
, which requires admin privileges to change targets. Rust was initially installed through the WinGet
tool. This resulted in a lot of troubleshooting and wasted time, as things didn’t work as expected. Ultimately, we reinstalled Rust using the prescribed Rustup
method. After reinstalling Rust, the target change resulted in a 32-bit DLL. Our silly message box finally appeared when we started Mirror’s Edge, confirming that our custom AgPerfMon.dll was successfully loaded and the DllMain function was executed. 🥳
With the DLL functioning, we modified the entry-point function to load the Multiplayer Mod DLL into Mirror’s Edge. Our code begins by verifying that the current process is Mirror’s Edge by checking the executable’s name. We added this precaution to prevent other applications from accidentally loading our fake DLL. If the process is Mirror’s Edge, the code will proceed to load the Multiplayer Mod DLL into the process. After loading, our function returns false in DllMain, causing our imposter DLL to be unloaded.
We rebuilt the DLL, placed it in the PATH, and launched Mirror’s Edge. Upon launch, we confirmed the multiplayer mod was running. Process Monitor validated our approach, showing that the Multiplayer Mod DLL was loaded while our “loader” DLL was not. A snippet of our proof of concept DLL is shown below:
#[no_mangle]
#[allow(unused_variables)]
extern "system" fn DllMain(
dll_module: HINSTANCE,
call_reason: u32,
_: *mut ())
-> bool
{
match call_reason {
DLL_PROCESS_ATTACH => attach(),
_ => ()
}
// Unloads the dll
false
}
fn attach() {
let exe_path = match env::current_exe() {
Ok(p) => p,
Err(e) => {
error_message_box(format!("failed to get current exe path: {e}"));
return;
}
};
if !exe_path.ends_with("MirrorsEdge.exe") {
return;
}
let dll_file = "C:\\Program Files\\Mirror's Edge Multiplayer\\bin\\mmultiplayer.dll";
let dll_file_cstring: CString = CString::new(dll_file).expect("CString::new failed");
let h_dll: *mut u8 = unsafe {LoadLibraryA(dll_file_cstring.as_ptr())};
if h_dll == ptr::null_mut() {
let os_error = Error::last_os_error();
error_message_box(format!("failed to load DLL ({dll_file}) - last os error: {os_error}"));
return;
}
}
Our next step was to determine a directory for saving the loader DLL, balancing user convenience and security.
Directory for the loader DLL
Two different options were considered for where the DLL could be saved, but the final decision will require input from the Mirror’s Edge community:
- Option 1: The installer places the loader DLL in the Mirror’s Edge Binaries directory.
- Option 2: The installer creates a directory in
Program Files
with the loader DLL and appends it to PATH.
We initially wanted to avoid placing files in the Binaries directory, as the mod’s DLL could be removed during game uninstallation or reinstallation. The advantage is that Mirror’s Edge already searches for DLLs in the Binaries directory, eliminating the need to add extra directories to the PATH.
The other option was having the installation process append our Multiplayer Mod directory to the PATH. The installer creates a Multiplayer Mod directory in Program Files
, providing a centralized spot for the loader DLL and mod files. By default, Windows enforces permissions on the Program Files
directory, requiring admin rights to add or modify files. This is important to prevent untrusted actions from attempting to modify the multiplayer DLL, which could then be executed by Mirror’s Edge.
Implementation
If we keep the DLL in the binaries or PATH, the Multiplayer Mod would load every time the game starts. We need a solution that gives users control and awareness of when the Multiplayer Mod is loaded and running. Determining the best approach will require feedback and testing from the Mirror’s Edge community.
Possible options for implementing this include:
- System tray application: A “systray” program could be used to enable or disable the mod and indicate when it is loaded. This would require communication between the DLL code and the systray program
- Hold Button During Startup: A more complex option might involve holding a button during game startup to load or unload the mod.
- Visible In-Game Icon: Another possibility is displaying a constant visible icon over the game to show that the Multiplayer Mod is loaded.
What I learned
DLL hijacking is an effective method to trick an application into loading a custom DLL by placing it in the search order. Phantom DLL hijacking, in particular, takes advantage of the trust programs have in the DLL search order and the DLLs themselves. It was unsettling to learn that the standard DLL search order includes the PATH directories. As long as the imposter DLL has the same name as the real one, Windows will assume it’s legitimate and load it without suspicion.
I’ve discovered that Rust is a powerful language for DLL development and cross-compilation. Despite some initial setup challenges, Rust’s flexibility and helpful compiler made it an enjoyable experience, and I’m eager to explore more.
🍪🐇
Super Thanks
A super thank you to Stephen for your support and guidance throughout this process. A huge thanks to the Mirror’s Edge community.