Creating Extensions for Compiled Applications: Interacting with Memory Using Python and Windows API
Rei Sato
Sep. 2024
1. Introduction
This article introduces methods for using certain Windows API functions from Python, which are useful for creating extensions for applications running on Windows
whose source code is not available. Windows API (also commonly known as Win32 API) is a collection of DLLs (Dynamic Link Libraries) that provide an interface for
accessing Windows functionality. The diverse functionalities of the Windows API include connecting to a running application (process), reading from and writing to its
memory space, detecting access to specific memory regions, and intervening in the processing of threads within the process. By utilizing these functions, it is
possible to implement extensions, such as recording the application's state over time or dynamically modifying its behavior (albeit in a limited manner), without
accessing its source code. The functionalities of the Windows API can be accessed from Python through ctypes, a standard Python library. In this article, building on
the background outlined above, I aim to share insights and Python code to assist those with similar goals.
Disclaimer
Software reverse engineering and modification may constitute a violation of laws or terms of service. This article does not intend to promote any illegal activities,
and you should always check the relevant laws and licenses before proceeding.
Environment
I have tested these programs in the following environment: The OS is Windows 11 23H2 (OS build 22631.4112), which is x64-based. The application used to test the
implemented code operates as a 32-bit application within the WOW64 (Windows 32-bit on Windows 64-bit), and my code has been optimized accordingly. More precisely, the
definitions of certain structures and the functions used differ when analyzing 64-bit applications. Python version is 3.12.6.
2. Reading Memory
The following script (memory_reader.py
) defines functions to obtain the process ID or handle of a running process, enumerate the memory regions used by a
process, and read specified memory regions.
[memory_reader.py]:
3. Enumerating Modules
The following script (module_reader.py
) defines a function that enumerates the modules (e.g. .exe, .dll) loaded into memory by a process. It also
provides example code that displays the memory regions occupied by each module. The memory address where a module is loaded may differ each time, but knowing the base
address of a specific module enables the resolution of multi-level pointers, as described in the next section.
[module_reader.py]:
4. Resolving Multi-Level Pointers
The following script (pointer_chaser.py
) defines a function to resolve multi-level pointers. The purpose of this script is to access a specific variable
within a process. Typically, due to ASLR (Address Space Layout Randomization) and dynamic memory allocation, the address of a variable is determined randomly. The
simplest way to locate it is to identify a rule, such as "the variable exists at a certain offset from the base address of a specific module within the process."
However, in practice, it is often necessary to resolve pointers hierarchically, where a pointer at a certain offset from the module's base address points to another
pointer.
The get_address_by_pointer_offsets
function defines the logic to resolve a pointer chain by navigating through a series of pointer offsets. This function
takes a module name and a series of offsets, resolving the final memory address by applying each offset sequentially.
[pointer_chaser.py]:
As an example, the above function was used to identify the address where the memory stores the player's XYZ coordinates in a game. The specific values of the pointer
chain were identified using Cheat Engine 7.5, and the steps for this operation were guided by this
blog.
5. Writing Memory
The following script (memory_writer.py
) defines a function that allows writing a specified value into memory. By using this function, you may be able to
modify the behavior of a running application.
[memory_writer.py]:
6. Intercepting and Modifying Memory Access
I have previously explained how to identify the memory address where a variable is stored and how to write a new value to that address. But what happens if the number
of bytes in the new value is greater than in the old value? For example, when modifying a string, if the new value ($str_new
) is longer than the old
value ($str_old
), and another variable is stored immediately after the end of $str_old
, the memory area of that variable may be overwritten
and corrupted.
Here is one method to solve this problem. First, let's denote the starting address of $str_old
as $addr
. We define
memory[$addr : $addr + len($str_old)]
as region_0
and memory[$addr + len($str_old) : $addr + len($str_new)]
as
region_1
. If the monitored process accesses region_0
, write $str_new
to the combined area of region_0
and
region_1
. Additionally, if region_1
is accessed, write back the original value before $str_new
was written. By repeating this
procedure, the monitored process is expected to correctly access both $str_new
and the variable in region_1
.
As a preparation for introducing the code to achieve this, the necessary constants are defined in the following script (const.py
).
[const.py]:
Next, I will explain the following script (memory_interceptor.py
) which implements the idea described above.
VirtualProtectEx
function sets access restrictions on memory pages containing the target address and catches ACCESS_VIOLATION
exception when
access occurs. During exception handling, the execution of the attached process is temporarily suspended, allowing you to modify the contents of the memory in the
meantime.
Additionally, during this exception handling, the access restriction is temporarily lifted, and the thread that caused the access is configured to report
WX86_SINGLE_STEP
exception. This exception occurs when a thread with a specific value set in the EFLAGS register executes a single instruction.
Afterward, by calling ContinueDebugEvent
function with DBG_CONTINUE
flag, the exception handling concludes, and the instruction is
re-executed. During re-execution, since the access restriction on memory is lifted, the instruction that caused the exception is processed normally. Once the
instruction completes, WX86_SINGLE_STEP
exception is raised, and the access restriction on memory is re-applied during this exception handling. By
repeating this procedure, it becomes possible to detect when the monitored process accesses specific memory regions and to switch the memory contents on a
per-instruction basis.
intercept_memory_access
function, which constitutes the loop mentioned above, automatically terminates after the timeout period from the initial memory
modification. However, there is room for considering more efficient methods, such as terminating when a specific instruction is executed.
[memory_interceptor.py]: