You're Gonna Carry That Weight

This commit is contained in:
sibouzitoun
2026-06-22 12:38:04 +01:00
commit 5e26626615
162 changed files with 10836 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
add_subdirectory(loader)
+9
View File
@@ -0,0 +1,9 @@
# Integration Tests
This directory contains the automated testing infrastructure for validating the SindriKit reflective loading pipeline. Tests are data-driven: a compact specification matrix auto-expands across all `(loader, architecture)` combinations, and a PE mutation engine stress-tests the parser against structurally malformed inputs.
## Table of Contents
- [loader/](loader/)
The reflective loader test suite, test runner, PE mutator, build definitions, and payload source code.
- [fixtures/](fixtures/)
Pre-built binary test assets (e.g., Corkami PE corpus) used for parser fuzz testing.
BIN
View File
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
add_executable(test_exe src/test_exe.c)
add_library(test_dll SHARED src/test_dll.c)
add_executable(test_exe_advanced src/test_exe_advanced.c)
add_library(test_dll_advanced SHARED src/test_dll_advanced.c)
add_library(test_dll_empty SHARED src/test_dll_empty.c)
add_library(test_dll_tls SHARED src/test_dll_tls.c)
# Create an architecture suffix (e.g., _x64 or _x86)
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
set(ARCH_SUFFIX "_x64")
else()
set(ARCH_SUFFIX "_x86")
endif()
# Apply suffixes to the output names of the tests so they match what test_runner.py expects
set_target_properties(test_exe PROPERTIES OUTPUT_NAME "test_exe${ARCH_SUFFIX}")
set_target_properties(test_dll PROPERTIES OUTPUT_NAME "test_dll${ARCH_SUFFIX}")
set_target_properties(test_exe_advanced PROPERTIES OUTPUT_NAME "test_exe_advanced${ARCH_SUFFIX}")
set_target_properties(test_dll_advanced PROPERTIES OUTPUT_NAME "test_dll_advanced${ARCH_SUFFIX}")
set_target_properties(test_dll_empty PROPERTIES OUTPUT_NAME "test_dll_empty${ARCH_SUFFIX}")
set_target_properties(test_dll_tls PROPERTIES OUTPUT_NAME "test_dll_tls${ARCH_SUFFIX}")
+13
View File
@@ -0,0 +1,13 @@
# Loader Test Suite
This directory contains the full integration test infrastructure for the reflective loading pipeline. It validates that both the Win32 (`loader_winapi`) and Native (`loader_nowinapi`) loaders correctly handle DLLs, EXEs, edge cases, and structurally corrupted PE inputs across both x64 and x86 architectures.
## Table of Contents
- [test_runner.py](test_runner.py)
The data-driven integration test runner. Expands compact `Spec` definitions into concrete `TestCase` objects across all `(loader × architecture)` combinations, executes them, and validates stdout output, FFI return values, and process exit codes.
- [pe_mutator.py](pe_mutator.py)
PE mutation engine for stress-testing. Applies targeted structural mutations to valid PE files to produce variants that exercise parser bounds checking and error handling.
- [CMakeLists.txt](CMakeLists.txt)
Build definitions for the test payload binaries. Compiles each test source into an architecture-suffixed output (e.g., `test_dll_x64.dll`).
- [src/](src/)
Source code for the test payload binaries loaded by the runner.
+476
View File
@@ -0,0 +1,476 @@
"""
PE Mutation Engine for SindriKit loader stress-testing.
Applies targeted mutations to valid PE files to produce variants that
exercise the reflective loader's parser and loading pipeline. Mutations
fall into two categories:
- **Benign Edge-Cases**: The PE layout is unusual or hyper-optimized, but
Windows can still load and run it. The custom loader MUST adapt and succeed.
- **Breaking Stressors**: The PE is structurally corrupted. The loader's
parser must detect this boundaries violation and reject it gracefully (NO CRASHES).
Requires: pip install pefile
"""
import os
import struct
from dataclasses import dataclass
from typing import Callable, List
try:
import pefile
except ImportError:
pefile = None # Checked at runtime by the test runner.
class MutationError(Exception):
"""Raised when a mutation cannot be applied."""
# ── Safety Boundary Helpers ──────────────────────────────────────────────────
def _read(path: str) -> bytearray:
if not os.path.exists(path):
raise MutationError(f"Source file does not exist: {path}")
if os.path.getsize(path) < 0x40:
raise MutationError(
f"Source file too small to contain a valid PE DOS Header: {path}"
)
with open(path, "rb") as f:
return bytearray(f.read())
def _write(path: str, data: bytes) -> None:
with open(path, "wb") as f:
f.write(data)
def _pe_modify(src: str, dst: str, modifier: Callable) -> None:
"""Load a PE with safety checks, apply structural modifier, write the result."""
if pefile is None:
raise MutationError("pefile is not installed in the execution environment")
try:
pe = pefile.PE(src)
except Exception as e:
raise MutationError(f"pefile failed to parse baseline image: {e}")
try:
modifier(pe)
_write(dst, pe.write())
except Exception as e:
raise MutationError(f"Modification execution error: {e}")
finally:
pe.close()
# ── Benign Edge-Cases (Windows Loads Them, Your Loader Must Too) ─────────────
def unaligned_sections(src: str, dst: str) -> None:
"""Force equal Section and File alignments (0x200).
Commonly seen in hyper-optimized or packed malware binaries. If your loader
hardcodes assumptions about 4KB (0x1000) page alignments, it will fail to map this.
"""
def _apply(pe):
if hasattr(pe, "OPTIONAL_HEADER") and pe.OPTIONAL_HEADER:
pe.OPTIONAL_HEADER.SectionAlignment = 0x200
pe.OPTIONAL_HEADER.FileAlignment = 0x200
_pe_modify(src, dst, _apply)
def strip_relocs_from_exe(src: str, dst: str) -> None:
"""Strip the relocation directory entirely and set IMAGE_FILE_RELOCS_STRIPPED.
Windows can run EXEs without a .reloc section if they manage to land at their
preferred ImageBase. Your loader shouldn't aggressively demand relocations for EXEs.
"""
def _apply(pe):
pe.FILE_HEADER.Characteristics |= 0x0001 # IMAGE_FILE_RELOCS_STRIPPED
if len(pe.OPTIONAL_HEADER.DATA_DIRECTORY) > 5:
pe.OPTIONAL_HEADER.DATA_DIRECTORY[5].VirtualAddress = 0
pe.OPTIONAL_HEADER.DATA_DIRECTORY[5].Size = 0
_pe_modify(src, dst, _apply)
def garbage_dos_stub(src: str, dst: str) -> None:
"""Overwrite the DOS stub with deterministic garbage.
Only bytes 0-1 (MZ) and 0x3C-0x3F (e_lfanew) matter. Tests if your loader's parser
correctly seeks using offsets instead of reading linearly through the DOS stub.
"""
data = _read(src)
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
upper_bound = min(e_lfanew, len(data))
for i in range(2, upper_bound):
if 0x3C <= i <= 0x3F:
continue # Protect the e_lfanew pointer
data[i] = (i * 7 + 0xAB) & 0xFF
_write(dst, data)
def append_overlay(src: str, dst: str) -> None:
"""Append 4 KB of junk after the last section.
Legitimate installers and SFX archives append data beyond declared section boundaries.
The loader must map the image based strictly on headers, ignoring raw trailing bytes.
"""
data = _read(src)
overlay = bytes([(i * 13 + 0x37) & 0xFF for i in range(4096)])
_write(dst, data + overlay)
def null_section_names(src: str, dst: str) -> None:
"""Zero out every section name string.
Section names are advisory and non-functional. If your loader matches strings
un-safely or logs names via unchecked printFs, this exposes string boundaries bugs.
"""
def _apply(pe):
for section in pe.sections:
section.Name = b"\x00" * 8
_pe_modify(src, dst, _apply)
# ── Breaking Stressors (Parser Verification, to be Rejected) ──────────────────
def corrupt_reloc_block_size(src: str, dst: str) -> None:
"""Set the first Base Relocation block's SizeOfBlock to 0xFFFFFFFF.
If your loader parses relocations using a `while` loop or basic pointer addition
without tracking boundaries against the data directory's size, this triggers a
catastrophic 0xC0000005 pointer overflow loop.
"""
def _apply(pe):
if len(pe.OPTIONAL_HEADER.DATA_DIRECTORY) > 5:
dir_reloc = pe.OPTIONAL_HEADER.DATA_DIRECTORY[5]
if dir_reloc.VirtualAddress != 0 and dir_reloc.Size > 0:
offset = pe.get_offset_from_rva(dir_reloc.VirtualAddress)
if offset and (offset + 8 <= len(pe.__data__)):
# Use pefile's built-in memory-safe modifier instead of direct mmap assignment
pe.set_bytes_at_offset(offset + 4, b"\xff\xff\xff\xff")
_pe_modify(src, dst, _apply)
def massive_size_of_headers(src: str, dst: str) -> None:
"""Set SizeOfHeaders to 0xFFFFFFFF.
Verifies your loader checks limits before executing:
`memcpy(allocated_memory, raw_buffer, nt_headers->OptionalHeader.SizeOfHeaders);`
"""
def _apply(pe):
if hasattr(pe, "OPTIONAL_HEADER") and pe.OPTIONAL_HEADER:
pe.OPTIONAL_HEADER.SizeOfHeaders = 0xFFFFFFFF
_pe_modify(src, dst, _apply)
def section_truncated_raw_data(src: str, dst: str) -> None:
"""Inflate a section's SizeOfRawData beyond the physical boundaries of the file.
Tests if the loader maps sections using `SizeOfRawData` blindly without ensuring
the backing source file buffer actually holds those bytes.
"""
def _apply(pe):
if pe.sections:
pe.sections[0].SizeOfRawData = 0x7FFFFFFF
_pe_modify(src, dst, _apply)
def section_integer_overflow(src: str, dst: str) -> None:
"""Set a section's VirtualSize and SizeOfRawData to 0xFFFFFFFF.
Exercises whether allocation arithmetic formulas inside your engine, such as
`NextSectionVA = CurrentSectionVA + ALIGN(VirtualSize, Alignment)`, can be exploited
via integer wrap-arounds.
"""
def _apply(pe):
if pe.sections:
pe.sections[0].SizeOfRawData = 0xFFFFFFFF
pe.sections[0].Misc_VirtualSize = 0xFFFFFFFF
_pe_modify(src, dst, _apply)
def section_overlap_corruption(src: str, dst: str) -> None:
"""Collapse all section file offsets back down to zero.
Forces headers and execution directories to overlap within memory mappings.
Catches file alignment normalization bugs inside custom tracking systems.
"""
def _apply(pe):
for section in pe.sections:
section.PointerToRawData = 0x00000000
_pe_modify(src, dst, _apply)
def corrupt_pe_signature(src: str, dst: str) -> None:
"""Replace the PE\\0\\0 signature with invalid garbage."""
data = _read(src)
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
if e_lfanew + 4 <= len(data):
data[e_lfanew : e_lfanew + 4] = b"\xde\xad\xbe\xef"
else:
raise MutationError("Parsed e_lfanew boundary points past physical EOF")
_write(dst, data)
def invalid_e_lfanew(src: str, dst: str) -> None:
"""Point e_lfanew 1 MB past the end of the file buffer."""
data = _read(src)
struct.pack_into("<I", data, 0x3C, len(data) + 0x100000)
_write(dst, data)
def zero_size_of_image(src: str, dst: str) -> None:
"""Set SizeOfImage to 0. Processing allocation maps must be rejected."""
def _apply(pe):
if hasattr(pe, "OPTIONAL_HEADER") and pe.OPTIONAL_HEADER:
pe.OPTIONAL_HEADER.SizeOfImage = 0
_pe_modify(src, dst, _apply)
def corrupt_import_rva(src: str, dst: str) -> None:
"""Point the import directory directory entry to an unmapped RVA address (0xDEAD0000)."""
def _apply(pe):
if hasattr(pe, "OPTIONAL_HEADER") and pe.OPTIONAL_HEADER:
if len(pe.OPTIONAL_HEADER.DATA_DIRECTORY) > 1:
pe.OPTIONAL_HEADER.DATA_DIRECTORY[1].VirtualAddress = 0xDEAD0000
_pe_modify(src, dst, _apply)
def corrupt_export_rva(src: str, dst: str) -> None:
"""Point the export directory entry to an unmapped RVA address."""
def _apply(pe):
if hasattr(pe, "OPTIONAL_HEADER") and pe.OPTIONAL_HEADER:
if len(pe.OPTIONAL_HEADER.DATA_DIRECTORY) > 0:
pe.OPTIONAL_HEADER.DATA_DIRECTORY[0].VirtualAddress = 0xDEAD0000
_pe_modify(src, dst, _apply)
def huge_sections_count(src: str, dst: str) -> None:
"""Set NumberOfSections to 0xFFFF to trigger parsing bounds protection."""
data = _read(src)
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
offset = e_lfanew + 4 + 2
if offset + 2 <= len(data):
struct.pack_into("<H", data, offset, 0xFFFF)
else:
raise MutationError("PE header offset structure mismatch")
_write(dst, data)
def unterminated_imports(src: str, dst: str) -> None:
"""Overwrite the null-terminating IMAGE_IMPORT_DESCRIPTOR with garbage.
Catches loaders that iterate imports with a `while(entry->Name != 0)` loop
without checking if they have exceeded the Import Directory's boundary.
"""
def _apply(pe):
if hasattr(pe, "DIRECTORY_ENTRY_IMPORT") and pe.DIRECTORY_ENTRY_IMPORT:
import_dir_rva = pe.OPTIONAL_HEADER.DATA_DIRECTORY[1].VirtualAddress
offset = pe.get_offset_from_rva(import_dir_rva)
if offset:
# Calculate where the null terminator descriptor is located.
# Each IMAGE_IMPORT_DESCRIPTOR is exactly 20 bytes.
num_imports = len(pe.DIRECTORY_ENTRY_IMPORT)
null_desc_offset = offset + (num_imports * 20)
if null_desc_offset + 20 <= len(pe.__data__):
# Overwrite the null terminator using safe memory map API
pe.set_bytes_at_offset(null_desc_offset, b"\xff" * 20)
_pe_modify(src, dst, _apply)
def massive_export_count(src: str, dst: str) -> None:
"""Set NumberOfFunctions and NumberOfNames in the Export Directory to 0xFFFFFFFF.
Catches loaders that try to allocate tracking arrays based on these values
or iterate over them without bounds checking.
"""
def _apply(pe):
if hasattr(pe, "DIRECTORY_ENTRY_EXPORT") and pe.DIRECTORY_ENTRY_EXPORT:
export_dir_rva = pe.OPTIONAL_HEADER.DATA_DIRECTORY[0].VirtualAddress
offset = pe.get_offset_from_rva(export_dir_rva)
if offset and (offset + 40 <= len(pe.__data__)):
# NumberOfFunctions is at offset + 20
# NumberOfNames is at offset + 24
pe.set_bytes_at_offset(offset + 20, b"\xff\xff\xff\xff")
pe.set_bytes_at_offset(offset + 24, b"\xff\xff\xff\xff")
_pe_modify(src, dst, _apply)
def oob_entry_point(src: str, dst: str) -> None:
"""Point AddressOfEntryPoint outside the mapped SizeOfImage boundaries.
The loader must validate that the entry point RVA lands inside a valid,
mapped executable section before attempting to pass execution flow.
"""
def _apply(pe):
if hasattr(pe, "OPTIONAL_HEADER") and pe.OPTIONAL_HEADER:
# Set EP 4KB past the end of the entire loaded image
pe.OPTIONAL_HEADER.AddressOfEntryPoint = (
pe.OPTIONAL_HEADER.SizeOfImage + 0x1000
)
_pe_modify(src, dst, _apply)
# ── Mutation Registry ───────────────────────────────────────────────────────
@dataclass
class Mutation:
"""Describes a single PE mutation configuration structure."""
name: str # Short string identifier
description: str # Human-readable status label
apply: Callable # Target modification logic function pointer
expect_loadable: (
bool # True = Matrix expects operational success; False = Graceful rejection
)
applies_to: str = "both"
BENIGN_MUTATIONS: List[Mutation] = [
Mutation(
"unaligned_sec",
"Unaligned sections (0x200 alignment)",
unaligned_sections,
True,
),
Mutation(
"stripped_relocs",
"Stripped reloc directory (.reloc removed)",
strip_relocs_from_exe,
True,
"exe",
),
Mutation("garbage_dos_stub", "Garbage DOS stub overwrites", garbage_dos_stub, True),
Mutation(
"append_overlay", "4KB unmapped trailing overlay appended", append_overlay, True
),
Mutation(
"null_sec_names", "Zeroed programmatic section names", null_section_names, True
),
]
BREAKING_MUTATIONS: List[Mutation] = [
Mutation(
"oob_reloc_size",
"OOB Relocation Block Size (0xFFFFFFFF)",
corrupt_reloc_block_size,
False,
),
Mutation(
"massive_headers",
"Massive SizeOfHeaders (0xFFFFFFFF)",
massive_size_of_headers,
False,
),
Mutation(
"truncated_sec",
"Truncated Section Raw Boundary",
section_truncated_raw_data,
False,
),
Mutation(
"int_overflow",
"Section Allocation Integer Overflow",
section_integer_overflow,
False,
),
Mutation(
"overlap_sections",
"Collapsing Section Offset Intersections",
section_overlap_corruption,
False,
),
Mutation(
"corrupt_pe_sig",
"Corrupted PE magic identification bytes",
corrupt_pe_signature,
False,
),
Mutation(
"bad_e_lfanew", "Invalid out-of-bounds e_lfanew values", invalid_e_lfanew, False
),
Mutation(
"zero_imgsize", "SizeOfImage set directly to zero", zero_size_of_image, False
),
Mutation(
"bad_import",
"Import Directory maps to unallocated RVA",
corrupt_import_rva,
False,
),
Mutation(
"bad_export",
"Export Directory maps to unallocated RVA",
corrupt_export_rva,
False,
"dll",
),
Mutation(
"huge_sections",
"NumberOfSections set to maximum 0xFFFF",
huge_sections_count,
False,
),
Mutation(
"unterminated_imports",
"Missing null terminator in Import array",
unterminated_imports,
False,
),
Mutation(
"massive_exports",
"Export NumberOfFunctions = 0xFFFFFFFF",
massive_export_count,
False,
"dll",
),
Mutation(
"oob_entry_point", "EntryPoint RVA outside SizeOfImage", oob_entry_point, False
),
]
ALL_MUTATIONS = BENIGN_MUTATIONS + BREAKING_MUTATIONS
def mutate_pe(src_path: str, mutation: Mutation, output_dir: str) -> str:
"""Apply a specified mutation parameters block to a base file,
writing the final asset output into target folders.
"""
base = os.path.basename(src_path)
name, ext = os.path.splitext(base)
dst_path = os.path.join(output_dir, f"{name}_{mutation.name}{ext}")
try:
mutation.apply(src_path, dst_path)
except Exception as e:
if os.path.exists(dst_path):
try:
os.remove(dst_path)
except OSError:
pass
raise MutationError(
f"Failed to apply '{mutation.name}' mutation matrix to {base}: {e}"
) from e
return dst_path
+52
View File
@@ -0,0 +1,52 @@
#include <string.h>
#include <windows.h>
#define TEST_SIG_SUCCESS 0xFEEDC0DE
#define TEST_SIG_BAD_ARGS 0xDEADBEEF
#define TEST_SIG_INIT_OK 0xC001D00D
#define TEST_SIG_NO_INIT 0xBAD10001
#define TEST_SIG_NO_IAT 0xBAD10002
/* ── Relocation & DllMain verification ─────────────────────────────────────
* g_attach_magic lives in .data. If the loader doesn't apply relocations
* every reference to it will hit the wrong address. DllMain sets it to a
* known sentinel; VerifyInit reads it back. */
static volatile ULONG_PTR g_attach_magic = 0;
#define ATTACH_SENTINEL 0xA77AC4ED
/* ── Exports ───────────────────────────────────────────────────────────────*/
/* Original export validates that the caller passed the exact expected
* three arguments through the FFI bridge. */
__declspec(dllexport) ULONG_PTR SayHello(const char *s1, const char *s2, ULONG_PTR n3) {
if (!s1 || !s2)
return TEST_SIG_BAD_ARGS;
if (strcmp(s1, "bonjour") == 0 && strcmp(s2, "hello") == 0 && n3 == 12) {
return TEST_SIG_SUCCESS;
}
return TEST_SIG_BAD_ARGS;
}
/* Checks that DllMain(DLL_PROCESS_ATTACH) ran **and** that the global it
* wrote to is reachable (i.e. relocations were applied correctly).
* Also calls a kernel32 import to verify the IAT was resolved. */
__declspec(dllexport) ULONG_PTR VerifyInit(void) {
/* 1. DllMain must have written the sentinel into the relocated global. */
if (g_attach_magic != ATTACH_SENTINEL)
return TEST_SIG_NO_INIT;
/* 2. Cheap kernel32 call if the IAT wasn't patched this will crash. */
if (GetCurrentProcessId() == 0)
return TEST_SIG_NO_IAT;
return TEST_SIG_INIT_OK;
}
BOOL APIENTRY DllMain(HMODULE hMod, DWORD reason, LPVOID res) {
if (reason == DLL_PROCESS_ATTACH) {
g_attach_magic = ATTACH_SENTINEL;
}
return TRUE;
}
+106
View File
@@ -0,0 +1,106 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <windows.h>
#define TEST_SIG_ADV_SUCCESS 0x1337C0DE
#define TEST_SIG_ADV_FAILED 0xBAADF00D
#define TEST_SIG_MULTI_OK 0xCA11AB1E
#define TEST_SIG_MULTI_FAIL 0xCA110BAD
/* Track DllMain invocations proves it was called exactly once. */
static volatile DWORD g_attach_count = 0;
/* ── AdvancedExport (unchanged behavior) ───────────────────────────────────
* Tests kernel32 imports, heap allocation, string manipulation, and
* dynamic loading of user32.dll. */
__declspec(dllexport) ULONG_PTR AdvancedExport(const char *input_str) {
if (!input_str)
return TEST_SIG_ADV_FAILED;
/* Kernel32: GetSystemInfo */
SYSTEM_INFO sysInfo;
GetSystemInfo(&sysInfo);
if (sysInfo.dwPageSize == 0)
return TEST_SIG_ADV_FAILED;
/* CRT: strlen, malloc, strcpy, strcmp, free */
size_t len = strlen(input_str);
char *dyn_str = (char *)malloc(len + 1);
if (!dyn_str)
return TEST_SIG_ADV_FAILED;
strcpy(dyn_str, input_str);
/* Dynamic loading: LoadLibraryA + GetProcAddress */
HMODULE hUser32 = LoadLibraryA("user32.dll");
if (!hUser32) {
free(dyn_str);
return TEST_SIG_ADV_FAILED;
}
FARPROC pMessageBoxA = GetProcAddress(hUser32, "MessageBoxA");
if (!pMessageBoxA) {
free(dyn_str);
return TEST_SIG_ADV_FAILED;
}
if (strcmp(dyn_str, "advanced_test") == 0) {
free(dyn_str);
return TEST_SIG_ADV_SUCCESS;
}
free(dyn_str);
return TEST_SIG_ADV_FAILED;
}
/* ── VerifyImports ─────────────────────────────────────────────────────────
* Stress-tests IAT resolution by calling a diverse set of kernel32 APIs
* across different import entries. Also checks that DllMain was called
* exactly once (g_attach_count == 1). */
__declspec(dllexport) ULONG_PTR VerifyImports(void) {
/* 1. GetCurrentProcessId */
DWORD pid = GetCurrentProcessId();
if (pid == 0)
return TEST_SIG_MULTI_FAIL;
/* 2. GetCurrentThreadId */
DWORD tid = GetCurrentThreadId();
if (tid == 0)
return TEST_SIG_MULTI_FAIL;
/* 3. VirtualAlloc / VirtualFree memory management APIs */
PVOID mem = VirtualAlloc(NULL, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!mem)
return TEST_SIG_MULTI_FAIL;
/* Write a pattern and read it back. */
memset(mem, 0xAA, 4096);
if (((unsigned char *)mem)[0] != 0xAA) {
VirtualFree(mem, 0, MEM_RELEASE);
return TEST_SIG_MULTI_FAIL;
}
VirtualFree(mem, 0, MEM_RELEASE);
/* 4. GetSystemTimeAsFileTime */
FILETIME ft;
GetSystemTimeAsFileTime(&ft);
if (ft.dwLowDateTime == 0 && ft.dwHighDateTime == 0)
return TEST_SIG_MULTI_FAIL;
/* 5. DllMain must have been called exactly once. */
if (g_attach_count != 1)
return TEST_SIG_MULTI_FAIL;
return TEST_SIG_MULTI_OK;
}
BOOL APIENTRY DllMain(HMODULE hMod, DWORD reason, LPVOID res) {
switch (reason) {
case DLL_PROCESS_ATTACH:
g_attach_count++;
break;
}
return TRUE;
}
+10
View File
@@ -0,0 +1,10 @@
#include <windows.h>
// A completely empty DLL.
// No exports, no standard imports (other than what the compiler forces for
// DllMain). Used to test if the loader gracefully handles missing directories
// (like the Export Directory).
BOOL APIENTRY DllMain(HMODULE hMod, DWORD reason, LPVOID res) {
return TRUE;
}
+45
View File
@@ -0,0 +1,45 @@
#include <windows.h>
/* ── TLS Callback Test DLL ─────────────────────────────────────────────────
* Registers a TLS callback via the CRT initializer list. The reflective
* loader must parse the IMAGE_TLS_DIRECTORY and fire the callbacks
* before calling DllMain. VerifyTLS checks whether the callback ran. */
#define TEST_SIG_TLS_OK 0x71500C01
#define TEST_SIG_TLS_FAIL 0x715FA110
/* Sentinel written by the TLS callback. */
static volatile ULONG_PTR g_tls_magic = 0;
#define TLS_SENTINEL 0x7153CA11
/* ── TLS callback registration (MSVC) ─────────────────────────────────────
* Force the linker to emit an IMAGE_TLS_DIRECTORY so the loader has
* something to parse. */
#ifdef _M_IX86
#pragma comment(linker, "/INCLUDE:__tls_used")
#else
#pragma comment(linker, "/INCLUDE:_tls_used")
#endif
static void NTAPI snd_tls_callback(PVOID DllHandle, DWORD Reason, PVOID Reserved) {
if (Reason == DLL_PROCESS_ATTACH) {
g_tls_magic = TLS_SENTINEL;
}
}
/* Place our callback pointer in the .CRT$XLB section so it appears in
* the TLS callback array between .CRT$XLA and .CRT$XLZ. */
#pragma section(".CRT$XLB", read)
__declspec(allocate(".CRT$XLB")) PIMAGE_TLS_CALLBACK p_tls_callback = snd_tls_callback;
/* ── Export ────────────────────────────────────────────────────────────────*/
__declspec(dllexport) ULONG_PTR VerifyTLS(void) {
if (g_tls_magic == TLS_SENTINEL)
return TEST_SIG_TLS_OK;
return TEST_SIG_TLS_FAIL;
}
BOOL APIENTRY DllMain(HMODULE hMod, DWORD reason, LPVOID res) {
return TRUE;
}
+22
View File
@@ -0,0 +1,22 @@
#include <windows.h>
/* Exit codes the runner matches on these. */
#define EXIT_OK 0x7A /* 122 decimal original success code */
#define EXIT_RELOC_FAIL 0x01 /* Relocation canary corrupted */
#define EXIT_IMPORT_FAIL 0x02 /* Kernel32 import call failed */
/* Global in .data if relocations aren't applied this will read as
* garbage when the image is loaded at a non-preferred base address. */
static volatile ULONG_PTR g_reloc_canary = 0xCAFEBABE;
int main(int argc, char *argv[]) {
/* 1. Relocation check: the canary must still hold its init value. */
if (g_reloc_canary != 0xCAFEBABE)
return EXIT_RELOC_FAIL;
/* 2. Import check: call a kernel32 API through the resolved IAT. */
if (GetCurrentProcessId() == 0)
return EXIT_IMPORT_FAIL;
return EXIT_OK;
}
+42
View File
@@ -0,0 +1,42 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <windows.h>
/* Exit codes */
#define EXIT_OK 0x1337
#define EXIT_ALLOC_BAD 0xBAAD
#define EXIT_CRT_FAIL 0xC210
int main(int argc, char *argv[]) {
/* ── CRT validation ──────────────────────────────────────────────────────
* Verify that the C runtime was initialized properly by the CRT entry
* point. If any of these fail the loader botched CRT startup. */
const char *test_str = "SindriKit";
char buf[16];
memcpy(buf, test_str, strlen(test_str) + 1);
if (strcmp(buf, "SindriKit") != 0)
return EXIT_CRT_FAIL;
/* ── Heap allocation + printf (original behavior) ────────────────────── */
printf("SindriKit Advanced EXE Test\n");
char *buffer = (char *)malloc(1024);
if (buffer) {
sprintf(buffer, "Successfully allocated and printed! Arg count: %d\n", argc);
printf("%s", buffer);
free(buffer);
} else {
return EXIT_ALLOC_BAD;
}
/* ── Multi-API verification ──────────────────────────────────────────── */
DWORD pid = GetCurrentProcessId();
DWORD tid = GetCurrentThreadId();
printf("[+] CRT OK | PID=%lu TID=%lu\n", (unsigned long)pid, (unsigned long)tid);
return EXIT_OK;
}
+600
View File
@@ -0,0 +1,600 @@
"""
SindriKit Integration Test Runner
Data-driven test matrix that auto-expands compact specs across all
loader x architecture combinations. Add a new Spec to SPECS and every
relevant (loader, arch) variant is generated automatically.
Usage:
python tests/test_runner.py [--corkami]
"""
import argparse
import os
import subprocess
import sys
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
# Add this block:
try:
import pe_mutator
except ImportError:
pe_mutator = None
# ── Path Configuration ──────────────────────────────────────────────────────
BIN64_DIR = r"build64\pocs"
BIN32_DIR = r"build32\pocs"
TEST64_DIR = r"build64\tests\loader"
TEST32_DIR = r"build32\tests\loader"
CORKAMI_DIR = r"tests\fixtures\pe\corkami"
CORKAMI_ZIP = r"tests\fixtures\pe\corkami_fixtures.zip"
# Compact lookups used by the matrix generator.
_BIN = {64: BIN64_DIR, 32: BIN32_DIR}
_TEST = {64: TEST64_DIR, 32: TEST32_DIR}
_BITS = {"x64": 64, "x86": 32}
_PTR_WIDTH = {"x64": 16, "x86": 8} # MSVC %p hex-digit count
ARCHES = ("x64", "x86")
LOADERS = ("nowinapi", "winapi")
_LOADER_TAG = {"nowinapi": "NoWinAPI", "winapi": "WinAPI"}
class Colors:
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
RESET = "\033[0m"
# ── Test Case (fully resolved, runnable) ────────────────────────────────────
@dataclass
class TestCase:
"""A concrete, runnable test."""
name: str
cmd: List[str]
expect_stdout: Optional[str] = None
expect_returncode: Optional[int] = None
expect_fail: bool = False
corkami_fuzz: bool = False
# ── Spec (compact, auto-expanding) ──────────────────────────────────────────
@dataclass
class Spec:
"""Architecture- and loader-independent test specification.
Expands into one TestCase per (loader, arch) combination.
"""
loaders: Tuple[str, ...]
payload: str # e.g. "test_dll", "test_exe_advanced"
export: Optional[str] = None
args: List[str] = field(default_factory=list)
expect_stdout: Optional[str] = None # static expected text
expect_retval: Optional[int] = None # expected FFI return (arch-formatted)
expect_rc: Optional[int] = None # expected process exit code
expect_fail: bool = False
label: str = "" # human-readable test label
def _ext(self) -> str:
return ".dll" if "dll" in self.payload else ".exe"
def _format_retval(self, arch: str) -> str:
w = _PTR_WIDTH[arch]
return f"Export returned: 0x{self.expect_retval:0{w}X}"
def to_test_case(self, loader: str, arch: str) -> TestCase:
bits = _BITS[arch]
loader_exe = os.path.join(
_BIN[bits], f"loader_{loader}", "Release", f"loader_{loader}.exe"
)
payload_file = os.path.join(
_TEST[bits], "Release", f"{self.payload}_{arch}{self._ext()}"
)
cmd = [loader_exe, "-f", payload_file]
if self.export:
cmd += ["-e", self.export]
for a in self.args:
cmd += ["-a", a]
expect = self.expect_stdout
if self.expect_retval is not None:
expect = self._format_retval(arch)
tag = _LOADER_TAG[loader]
return TestCase(
name=f"{tag} ({arch}) -> {self.label}",
cmd=cmd,
expect_stdout=expect,
expect_returncode=self.expect_rc,
expect_fail=self.expect_fail,
)
# ── Declarative Test Specs ──────────────────────────────────────────────────
# Each Spec generates len(loaders) x len(ARCHES) concrete TestCases.
# 7 specs x 2 loaders x 2 arches = 28 tests from this table alone.
SPECS = [
# ── DLL: correct args ───────────────────────────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_dll",
"SayHello",
["bonjour", "hello", "12"],
expect_retval=0xFEEDC0DE,
label="Load DLL with exact args",
),
# ── DLL: bad args ───────────────────────────────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_dll",
"SayHello",
["wrong", "args"],
expect_retval=0xDEADBEEF,
label="Edge Case: Bad Args Validation",
),
# ── DLL: missing -e parameter ───────────────────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_dll",
expect_stdout="Error: DLL payload requires an export name",
expect_fail=True,
label="Edge Case: Missing Export Parameter",
),
# ── DLL: advanced (imports, allocs) ─────────────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_dll_advanced",
"AdvancedExport",
["advanced_test"],
expect_retval=0x1337C0DE,
label="Load Advanced DLL (Imports, Allocs)",
),
# ── DLL: empty (missing export directory) ───────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_dll_empty",
"NonExistentExport",
expect_stdout="Error: Requested export was not found",
expect_fail=True,
label="Load Empty DLL (Missing Dirs)",
),
# ── DLL: verify DllMain ran + relocations applied ───────────────────────
Spec(
("nowinapi", "winapi"),
"test_dll",
"VerifyInit",
expect_retval=0xC001D00D,
label="Verify DllMain + Relocations",
),
# ── DLL: verify multi-import IAT resolution ────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_dll_advanced",
"VerifyImports",
expect_retval=0xCA11AB1E,
label="Verify Multi-Import Resolution",
),
# ── DLL: verify TLS callback execution ─────────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_dll_tls",
"VerifyTLS",
expect_retval=0x71500C01,
label="Verify TLS Callbacks",
),
# ── EXE: basic ──────────────────────────────────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_exe",
expect_stdout="Jumping to EXE Entry Point",
expect_rc=122, # 0x7A
label="Run EXE",
),
# ── EXE: advanced (stdlib init, heap allocs) ────────────────────────────
Spec(
("nowinapi", "winapi"),
"test_exe_advanced",
expect_stdout="Successfully allocated and printed",
expect_rc=4919, # 0x1337
label="Run Advanced EXE (Stdlib Init)",
),
]
def expand_specs(specs):
"""Expand Specs into TestCases, grouped by (loader, arch) for clean output."""
cases = []
for loader in LOADERS:
for arch in ARCHES:
for spec in specs:
if loader in spec.loaders:
cases.append(spec.to_test_case(loader, arch))
return cases
# ── Arch-Mismatch Tests ────────────────────────────────────────────────────
def build_mismatch_tests():
"""Generate arch-mismatch guard test cases for nowinapi."""
cases = []
for loader_arch, payload_arch in [("x64", "x86"), ("x86", "x64")]:
lb, pb = _BITS[loader_arch], _BITS[payload_arch]
expect = "Payload architecture does not match loader architecture"
cases.append(
TestCase(
name=f"NoWinAPI ({loader_arch}) -> Arch Mismatch Guard "
f"({loader_arch} loader, {payload_arch} DLL)",
cmd=[
os.path.join(
_BIN[lb],
"loader_nowinapi",
"Release",
"loader_nowinapi.exe",
),
"-f",
os.path.join(_TEST[pb], "Release", f"test_dll_{payload_arch}.dll"),
"-e",
"SayHello",
],
expect_stdout=expect,
expect_fail=True,
)
)
return cases
# ── Corkami Fuzz Tests ──────────────────────────────────────────────────────
def load_corkami_tests(enabled=False):
"""Return Corkami fuzz test cases if enabled, else an empty list."""
if not enabled:
return []
if not os.path.exists(CORKAMI_DIR) or not os.listdir(CORKAMI_DIR):
print(f"\n[{Colors.RED}ERROR{Colors.RESET}] Corkami fixtures missing!")
print(f"[*] Expected: {Colors.YELLOW}{CORKAMI_DIR}{Colors.RESET}")
print(f"[*] Unzip '{CORKAMI_ZIP}' into that folder.")
print(f"[*] Password: {Colors.GREEN}infected{Colors.RESET}\n")
sys.exit(1)
tests = []
for file in sorted(os.listdir(CORKAMI_DIR)):
if file.lower().endswith(".exe"):
tests.append(
TestCase(
name=f"Corkami Parser Stress Test -> {file}",
cmd=[
os.path.join(
_BIN[64],
"loader_winapi",
"Release",
"loader_winapi.exe",
),
"-f",
os.path.join(CORKAMI_DIR, file),
],
corkami_fuzz=True,
)
)
return tests
# ── Preflight & Build-Tree Checks ──────────────────────────────────────────
_BUILD_TREES = [
(BIN64_DIR, TEST64_DIR, "x64", "build64"),
(BIN32_DIR, TEST32_DIR, "x86", "build32"),
]
def preflight_check():
"""Warn about missing build trees. Returns the set of missing dir paths."""
missing_dirs = set()
for bin_dir, test_dir, arch, build_name in _BUILD_TREES:
bin_missing = not os.path.isdir(bin_dir)
test_missing = not os.path.isdir(test_dir)
if not bin_missing and not test_missing:
continue
if bin_missing:
missing_dirs.add(bin_dir)
if test_missing:
missing_dirs.add(test_dir)
if bin_missing and test_missing:
print(
f"[{Colors.YELLOW}WARN{Colors.RESET}] {arch} not compiled. "
f"Run build.bat tests pocs to build everything."
)
elif bin_missing:
print(
f"[{Colors.YELLOW}WARN{Colors.RESET}] {arch} PoC loaders missing ({bin_dir}\\). "
f"Run build.bat pocs."
)
else:
print(
f"[{Colors.YELLOW}WARN{Colors.RESET}] {arch} test fixtures missing ({test_dir}\\). "
f"Run build.bat tests."
)
if missing_dirs:
print()
return missing_dirs
def _missing_dir_for(path):
"""Return the build subdir path if absent, or None."""
if "build32" in path:
check = BIN32_DIR if "pocs" in path else TEST32_DIR
elif "build64" in path:
check = BIN64_DIR if "pocs" in path else TEST64_DIR
else:
return None
return check if not os.path.isdir(check) else None
# ── Test Executor ───────────────────────────────────────────────────────────
def run_test(test, known_missing=None):
cmd = test.cmd
# Skip silently if preflight already warned about this build tree.
if known_missing:
if _missing_dir_for(cmd[0]) in known_missing:
return None
for i, token in enumerate(cmd):
if token == "-f" and i + 1 < len(cmd):
if _missing_dir_for(cmd[i + 1]) in known_missing:
return None
break
print(f"[{Colors.YELLOW}TEST{Colors.RESET}] {test.name}")
if not os.path.exists(cmd[0]):
missing = _missing_dir_for(cmd[0])
if missing:
print(
f" {Colors.BLUE}SKIP{Colors.RESET}: Build dependency {missing} not found."
)
return None
print(f" {Colors.RED}FAIL{Colors.RESET}: Binary missing: {cmd[0]}")
return False
# Check that -f payload exists (e.g. PoCs built but tests weren't).
for i, token in enumerate(cmd):
if token == "-f" and i + 1 < len(cmd):
missing = _missing_dir_for(cmd[i + 1])
if missing:
print(
f" {Colors.BLUE}SKIP{Colors.RESET}: Build dependency {missing} not found."
)
return None
break
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=5,
)
stdout = result.stdout
stderr = result.stderr
returncode = result.returncode
if returncode in [3221225477, -1073741819]:
print(f" {Colors.RED}FAIL (0xC0000005){Colors.RESET}: Access violation!")
print(f" --- STDOUT --- \n{stdout}")
print(f" --- STDERR --- \n{stderr}")
return False
if test.corkami_fuzz:
if "[-] Error:" in stdout or "[-] Error:" in stderr:
print(
f" {Colors.YELLOW}PASS (Safely Rejected Malformed PE){Colors.RESET}"
)
elif "[!]" in stdout and returncode == 0:
print(f" {Colors.GREEN}PASS (True Execution Success){Colors.RESET}")
else:
print(f" {Colors.GREEN}PASS (Handled Safely){Colors.RESET}")
return True
found_expected = True
if test.expect_stdout:
found_expected = (
test.expect_stdout in stdout or test.expect_stdout in stderr
)
expected_fail = test.expect_fail
if test.expect_returncode is not None:
failed = returncode != test.expect_returncode
if failed:
print(
f" {Colors.RED}FAIL{Colors.RESET}: Expected return code "
f"{test.expect_returncode}, got {returncode}"
)
else:
failed = returncode != 0
if (expected_fail == failed) and found_expected:
print(f" {Colors.GREEN}PASS{Colors.RESET}")
return True
else:
print(f" {Colors.RED}FAIL{Colors.RESET}")
if expected_fail and not failed:
print(" Reason: expected non-zero exit but process succeeded.")
elif not expected_fail and failed:
print(" Reason: expected clean exit but process returned an error.")
elif not found_expected:
print(" Reason: expected output not found in stdout or stderr.")
print(f" Expected: '{test.expect_stdout or '(none)'}'")
print(f" Matched : {found_expected}")
print(f" --- STDOUT --- \n{stdout}")
print(f" --- STDERR --- \n{stderr}")
return False
except subprocess.TimeoutExpired:
print(f" {Colors.RED}FAIL (TIMEOUT){Colors.RESET}")
return False
except Exception as e:
print(f" {Colors.RED}FAIL (EXCEPTION){Colors.RESET}: {e}")
return False
# ── Mutation Engine Tests ───────────────────────────────────────────────────
def load_mutation_tests(enabled=False):
"""Generate mutated PEs on the fly and add them to the test matrix."""
if not enabled:
return []
if pe_mutator is None or pe_mutator.pefile is None:
print(
f"\n[{Colors.YELLOW}SKIP{Colors.RESET}] Mutator disabled (pefile not installed)."
)
return []
print(f"\n[{Colors.BLUE}INFO{Colors.RESET}] Generating mutated PE variants...")
tests = []
for arch in ARCHES:
bits = _BITS[arch]
base_exe = os.path.join(_TEST[bits], "Release", f"test_exe_{arch}.exe")
base_dll = os.path.join(_TEST[bits], "Release", f"test_dll_{arch}.dll")
# Create a dedicated directory for mutated payloads
out_dir = os.path.join(f"build{bits}", "mutations")
os.makedirs(out_dir, exist_ok=True)
for loader in LOADERS:
loader_exe = os.path.join(
_BIN[bits], f"loader_{loader}", "Release", f"loader_{loader}.exe"
)
for mutation in pe_mutator.ALL_MUTATIONS:
# Select the correct base file based on mutation requirements
if mutation.applies_to == "dll":
src_file = base_dll
cmd_args = ["-e", "SayHello"] # DLLs need an export target
else:
src_file = base_exe
cmd_args = []
# Skip if the user hasn't built the baseline tests yet
if not os.path.exists(src_file):
continue
try:
mutated_path = pe_mutator.mutate_pe(src_file, mutation, out_dir)
except pe_mutator.MutationError as e:
print(f"[{Colors.YELLOW}WARN{Colors.RESET}] {e}")
continue
tag = _LOADER_TAG[loader]
tests.append(
TestCase(
name=f"{tag} ({arch}) -> Mutation: {mutation.name}",
cmd=[loader_exe, "-f", mutated_path] + cmd_args,
expect_fail=not mutation.expect_loadable,
corkami_fuzz=True, # Reuse fuzz logic to ensure we don't 0xC0000005
)
)
return tests
# ── Entry Point ─────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="SindriKit Integration Test Runner")
parser.add_argument(
"--corkami",
action="store_true",
help="Include Corkami malformed-PE stress tests",
)
# Add this argument
parser.add_argument(
"--mutate",
action="store_true",
help="Generate and run dynamic PE mutations to stress-test the loader",
)
args = parser.parse_args()
print("==================================================")
print(" SindriKit Integration Tests ")
print("==================================================")
preflight_missing = preflight_check()
# Append the mutation tests to the matrix
full_matrix = (
expand_specs(SPECS)
+ build_mismatch_tests()
+ load_corkami_tests(enabled=args.corkami)
+ load_mutation_tests(enabled=args.mutate)
)
print(
f"[*] {len(full_matrix)} test cases "
f"({len(SPECS)} specs x {len(LOADERS)} loaders x {len(ARCHES)} arches)\n"
)
passed = 0
skipped = 0
total = len(full_matrix)
for test in full_matrix:
result = run_test(test, known_missing=preflight_missing)
if result is True:
passed += 1
elif result is None:
skipped += 1
ran = total - skipped
print("==================================================")
if skipped:
print(
f"[{Colors.YELLOW}INFO{Colors.RESET}] {skipped}/{total} tests skipped "
f"(build tree incomplete. Run build.bat tests pocs)."
)
if ran == 0:
print(f"Result: {Colors.YELLOW}No tests ran.{Colors.RESET}")
sys.exit(0)
pct = (passed / ran) * 100
if pct > 95:
print(
f"Result: {Colors.GREEN}{pct:.0f}%. "
f"{passed}/{ran} passed{Colors.RESET}"
+ (f" ({skipped} skipped)" if skipped else "")
)
sys.exit(0)
else:
print(
f"Result: {Colors.RED}{pct:.1f}%. "
f"{passed}/{ran} passed, {ran - passed} failed{Colors.RESET}"
+ (f" ({skipped} skipped)" if skipped else "")
)
sys.exit(1)
if __name__ == "__main__":
os.system("") # Enable VT100 ANSI sequences on Windows consoles
main()