mirror of
https://github.com/youssefnoob003/SindriKit
synced 2026-07-07 21:57:09 +00:00
You're Gonna Carry That Weight
This commit is contained in:
@@ -0,0 +1 @@
|
||||
add_subdirectory(loader)
|
||||
@@ -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.
|
||||
Vendored
BIN
Binary file not shown.
@@ -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}")
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user