feat(native): complete Gargoyle refresh backlog

Complete the native build/runtime refresh while preserving the Win32 proof-of-concept baseline.

- add the GargoyleX64 sibling prototype with x64 NASM PIC and timer reentry parity

- consolidate Visual C++ and NASM behavior into shared MSBuild props/targets

- expand just recipes and the acceptance harness across x86/x64 Debug/Release builds

- harden native quality gates with MSVC warnings-as-errors, code analysis, and ASan build coverage

- refresh README and MkDocs architecture, validation, responsible-use, references, and future-work docs

Validation:

- just ci

- just native-check

- Debug and Release live acceptance

- Debug x64 smoke run

Closes #2.

Closes #13.

Closes #14.

Closes #15.

Closes #16.

Closes #17.

Closes #18.

Closes #19.
This commit is contained in:
Josh Lospinoso
2026-05-14 10:15:43 -10:00
parent 5652741a8c
commit 21f6303990
33 changed files with 1797 additions and 156 deletions
+1
View File
@@ -271,4 +271,5 @@ coverage.xml
htmlcov/
site/
dist/
asan/
+14 -2
View File
@@ -1,20 +1,32 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 14
VisualStudioVersion = 14.0.25420.1
# Visual Studio Version 18
VisualStudioVersion = 18.0.11102.4
MinimumVisualStudioVersion = 10.0.40219.1
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Gargoyle", "Gargoyle.vcxproj", "{76CDDD0D-EA56-4AA9-9B94-41390A34AB23}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "GargoyleX64", "GargoyleX64\GargoyleX64.vcxproj", "{D7F13E1F-6B38-4E38-8B11-30E8B9AF4601}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x86 = Debug|x86
Debug|x64 = Debug|x64
Release|x86 = Release|x86
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{76CDDD0D-EA56-4AA9-9B94-41390A34AB23}.Debug|x86.ActiveCfg = Debug|Win32
{76CDDD0D-EA56-4AA9-9B94-41390A34AB23}.Debug|x86.Build.0 = Debug|Win32
{76CDDD0D-EA56-4AA9-9B94-41390A34AB23}.Debug|x64.ActiveCfg = Debug|Win32
{76CDDD0D-EA56-4AA9-9B94-41390A34AB23}.Release|x86.ActiveCfg = Release|Win32
{76CDDD0D-EA56-4AA9-9B94-41390A34AB23}.Release|x86.Build.0 = Release|Win32
{76CDDD0D-EA56-4AA9-9B94-41390A34AB23}.Release|x64.ActiveCfg = Release|Win32
{D7F13E1F-6B38-4E38-8B11-30E8B9AF4601}.Debug|x86.ActiveCfg = Debug|x64
{D7F13E1F-6B38-4E38-8B11-30E8B9AF4601}.Debug|x64.ActiveCfg = Debug|x64
{D7F13E1F-6B38-4E38-8B11-30E8B9AF4601}.Debug|x64.Build.0 = Debug|x64
{D7F13E1F-6B38-4E38-8B11-30E8B9AF4601}.Release|x86.ActiveCfg = Release|x64
{D7F13E1F-6B38-4E38-8B11-30E8B9AF4601}.Release|x64.ActiveCfg = Release|x64
{D7F13E1F-6B38-4E38-8B11-30E8B9AF4601}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+5 -51
View File
@@ -17,34 +17,14 @@
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="build\Gargoyle.Configuration.props" />
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<Import Project="build\Gargoyle.Cpp.props" />
<ItemGroup>
<CustomBuild Include="setup.nasm">
<FileType>Document</FileType>
<Message>Assembling setup.nasm</Message>
<Command>nasm -f bin setup.nasm -o $(Configuration)\setup.pic</Command>
<Outputs>$(Configuration)\setup.pic</Outputs>
</CustomBuild>
<CustomBuild Include="gadget.nasm">
<FileType>Document</FileType>
<Message>Assembling gadget.nasm</Message>
<Command>nasm -f bin gadget.nasm -o $(Configuration)\gadget.pic</Command>
<Outputs>$(Configuration)\gadget.pic</Outputs>
</CustomBuild>
<NasmPic Include="setup.nasm" />
<NasmPic Include="gadget.nasm" />
</ItemGroup>
<Import Project="build\NasmPic.targets" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
@@ -56,45 +36,19 @@
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<LinkIncremental>true</LinkIncremental>
<LocalDebuggerWorkingDirectory>$(OutDir)</LocalDebuggerWorkingDirectory>
<DebuggerFlavor>WindowsLocalDebugger</DebuggerFlavor>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<LinkIncremental>false</LinkIncremental>
<LocalDebuggerWorkingDirectory>$(OutDir)</LocalDebuggerWorkingDirectory>
<DebuggerFlavor>WindowsLocalDebugger</DebuggerFlavor>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<PrecompiledHeader>
</PrecompiledHeader>
<WarningLevel>Level3</WarningLevel>
<Optimization>Disabled</Optimization>
<PreprocessorDefinitions>WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>DbgHelp.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<PrecompiledHeader>
</PrecompiledHeader>
<Optimization>MaxSpeed</Optimization>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<PreprocessorDefinitions>WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
<AdditionalDependencies>DbgHelp.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
+7 -3
View File
@@ -20,7 +20,11 @@
</ClCompile>
</ItemGroup>
<ItemGroup>
<CustomBuild Include="setup.nasm" />
<CustomBuild Include="gadget.nasm" />
<NasmPic Include="setup.nasm">
<Filter>Source Files</Filter>
</NasmPic>
<NasmPic Include="gadget.nasm">
<Filter>Source Files</Filter>
</NasmPic>
</ItemGroup>
</Project>
</Project>
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<ProjectGuid>{D7F13E1F-6B38-4E38-8B11-30E8B9AF4601}</ProjectGuid>
<Keyword>Win32Proj</Keyword>
<RootNamespace>GargoyleX64</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<Import Project="..\build\Gargoyle.Configuration.props" />
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<Import Project="..\build\Gargoyle.Cpp.props" />
<ImportGroup Label="ExtensionSettings" />
<ImportGroup Label="Shared" />
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<Link>
<AdditionalDependencies>kernel32.lib;user32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<Link>
<AdditionalDependencies>kernel32.lib;user32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="main_x64.cpp" />
</ItemGroup>
<ItemGroup>
<NasmPic Include="setup_x64.nasm" />
<NasmPic Include="reentry_x64.nasm" />
</ItemGroup>
<Import Project="..\build\NasmPic.targets" />
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets" />
</Project>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx;nasm</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="main_x64.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<NasmPic Include="setup_x64.nasm">
<Filter>Source Files</Filter>
</NasmPic>
<NasmPic Include="reentry_x64.nasm">
<Filter>Source Files</Filter>
</NasmPic>
</ItemGroup>
</Project>
+77
View File
@@ -0,0 +1,77 @@
# Gargoyle x64 Example
This directory contains a sibling x64 example for issue #2. It does not replace
the historical Win32 proof of concept in the repository root.
The example is intentionally small and benign:
- `GargoyleX64.vcxproj` builds only `Platform=x64` through the root
`Gargoyle.sln`.
- `setup_x64.nasm` is the main raw x64 PIC entry point.
- `reentry_x64.nasm` is the executable wait/APC re-entry PIC.
- `main_x64.cpp` loads both PIC blobs, prepares a pointer-sized configuration
block, resolves the Windows APIs the PIC needs, and invokes the setup PIC.
- The payload is a repeating `MessageBoxA` with the caption/text
`gargoyle x64`.
## Build
From the repository root:
```powershell
$nasmDir = Join-Path $env:LOCALAPPDATA 'bin\NASM'
if (Test-Path (Join-Path $nasmDir 'nasm.exe')) { $env:Path = "$nasmDir;$env:Path" }
MSBuild.exe Gargoyle.sln /p:Configuration=Debug /p:Platform=x64 /m
MSBuild.exe Gargoyle.sln /p:Configuration=Release /p:Platform=x64 /m
```
With the current Visual Studio defaults, the root solution emits the x64 outputs
under `x64\Debug\` or `x64\Release\`. Run the executable from its output
directory so it can find `setup_x64.pic` and `reentry_x64.pic`:
```powershell
Push-Location x64\Debug
.\GargoyleX64.exe
Pop-Location
```
## Design Notes
The x64 path is a sibling design rather than a direct port of `setup.nasm`. The
Win32 PIC depends on stack arguments and an `esp`-based stack pivot. The x64
example instead uses two PIC blobs:
- `setup_x64.pic` owns the setup state and benign payload loop.
- `reentry_x64.pic` remains executable while `setup_x64.pic` is parked
read-only during alertable waits.
The setup PIC creates a waitable timer, registers the callback entry inside the
re-entry PIC, displays the benign MessageBox payload, and then calls the re-entry
PIC's wait entry. The wait entry marks `setup_x64.pic` `PAGE_READONLY` and enters
`WaitForSingleObjectEx(..., TRUE)`. Timer re-entry restores
`setup_x64.pic` to `PAGE_EXECUTE_READ`, returns to the setup loop, and displays
the benign payload again.
The implementation follows the Win64 ABI requirements that must be correct
before larger designs are considered:
- the configuration block uses pointer-sized fields;
- the setup PIC receives its configuration pointer in `rcx`;
- API calls place the first four arguments in `rcx`, `rdx`, `r8`, and `r9`;
- every call reserves the required 32-byte shadow space;
- the stack is kept 16-byte aligned across calls;
- fifth and sixth arguments are passed on the stack; and
- nonvolatile registers used by the PIC are preserved.
## Validation
Run the platform-aware acceptance harness from a Windows desktop session:
```powershell
uv run --all-groups gargoyle-acceptance --configuration Debug --platform x64
uv run --all-groups gargoyle-acceptance --configuration Release --platform x64
```
The harness validates the x64 setup banner and closes two benign `gargoyle x64`
MessageBox rounds. The first window proves the initial PIC handoff; the second
window proves timer/APC re-entry.
+161
View File
@@ -0,0 +1,161 @@
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <exception>
#include <fstream>
#include <stdexcept>
#include <string>
#include <vector>
#include <Windows.h>
using namespace std;
namespace {
using PicEntry = void (*)(void*);
constexpr DWORD invocation_interval_ms = 15 * 1000;
constexpr size_t reentry_callback_offset = 16;
struct X64Configuration {
uint64_t initialized;
void* setup_address;
uint64_t setup_length;
void* reentry_wait;
void* reentry_callback;
void* VirtualProtectEx;
void* WaitForSingleObjectEx;
void* CreateWaitableTimerW;
void* SetWaitableTimer;
void* MessageBoxA;
void* sleep_handle;
int64_t due_time;
uint32_t interval;
uint32_t old_protection;
};
static_assert(sizeof(void*) == 8, "GargoyleX64 must be built as a 64-bit target.");
static_assert(offsetof(X64Configuration, initialized) == 0x00);
static_assert(offsetof(X64Configuration, setup_address) == 0x08);
static_assert(offsetof(X64Configuration, setup_length) == 0x10);
static_assert(offsetof(X64Configuration, reentry_wait) == 0x18);
static_assert(offsetof(X64Configuration, reentry_callback) == 0x20);
static_assert(offsetof(X64Configuration, VirtualProtectEx) == 0x28);
static_assert(offsetof(X64Configuration, WaitForSingleObjectEx) == 0x30);
static_assert(offsetof(X64Configuration, CreateWaitableTimerW) == 0x38);
static_assert(offsetof(X64Configuration, SetWaitableTimer) == 0x40);
static_assert(offsetof(X64Configuration, MessageBoxA) == 0x48);
static_assert(offsetof(X64Configuration, sleep_handle) == 0x50);
static_assert(offsetof(X64Configuration, due_time) == 0x58);
static_assert(offsetof(X64Configuration, interval) == 0x60);
static_assert(offsetof(X64Configuration, old_protection) == 0x64);
vector<uint8_t> read_binary(const string& filename) {
fstream stream{ filename, fstream::in | fstream::ate | fstream::binary };
if (!stream) {
throw runtime_error("[-] Couldn't open \"" + filename + "\".");
}
const auto size = static_cast<size_t>(stream.tellg());
stream.seekg(0, fstream::beg);
auto result = vector<uint8_t>(size);
stream.read(reinterpret_cast<char*>(result.data()), result.size());
if (!stream) {
throw runtime_error("[-] Couldn't read \"" + filename + "\".");
}
return result;
}
void* resolve_export(const wchar_t* module_name, const char* export_name) {
auto module = GetModuleHandleW(module_name);
if (!module) {
module = LoadLibraryW(module_name);
}
if (!module) {
throw runtime_error("[-] Couldn't load module.");
}
const auto address = GetProcAddress(module, export_name);
if (!address) {
throw runtime_error("[-] Couldn't GetProcAddress.");
}
return reinterpret_cast<void*>(address);
}
void* allocate_pic(const string& filename, size_t& pic_size) {
const auto bytes = read_binary(filename);
pic_size = bytes.size();
const auto memory = VirtualAlloc(nullptr, pic_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!memory) {
throw runtime_error("[-] Couldn't VirtualAlloc.");
}
memcpy(memory, bytes.data(), bytes.size());
DWORD old_protection;
const auto protected_memory = VirtualProtect(memory, pic_size, PAGE_EXECUTE_READ, &old_protection);
if (!protected_memory) {
throw runtime_error("[-] Couldn't VirtualProtect.");
}
return memory;
}
}
void launch(const string& setup_pic_path) {
printf("[ ] Loading x64 setup PIC from \"%s\".\n", setup_pic_path.c_str());
size_t setup_size;
const auto setup_memory = allocate_pic(setup_pic_path, setup_size);
printf("[+] Loaded %zu bytes of x64 PIC.\n", setup_size);
const auto reentry_pic_path = "reentry_x64.pic";
printf("[ ] Loading x64 re-entry PIC from \"%s\".\n", reentry_pic_path);
size_t reentry_size;
const auto reentry_memory = allocate_pic(reentry_pic_path, reentry_size);
const auto reentry_callback =
static_cast<void*>(static_cast<uint8_t*>(reentry_memory) + reentry_callback_offset);
printf("[+] Loaded %zu bytes of x64 re-entry PIC.\n", reentry_size);
auto config = X64Configuration{};
config.setup_address = setup_memory;
config.setup_length = setup_size;
config.reentry_wait = reentry_memory;
config.reentry_callback = reentry_callback;
config.VirtualProtectEx = resolve_export(L"kernel32.dll", "VirtualProtectEx");
config.WaitForSingleObjectEx = resolve_export(L"kernel32.dll", "WaitForSingleObjectEx");
config.CreateWaitableTimerW = resolve_export(L"kernel32.dll", "CreateWaitableTimerW");
config.SetWaitableTimer = resolve_export(L"kernel32.dll", "SetWaitableTimer");
config.MessageBoxA = resolve_export(L"user32.dll", "MessageBoxA");
config.due_time = -static_cast<int64_t>(invocation_interval_ms) * 10000;
config.interval = invocation_interval_ms;
printf("[+] x64 timer/APC prototype configured.\n");
printf(" ================================\n");
printf(" Gargoyle x64 PIC @ ----> 0x%p\n", setup_memory);
printf(" x64 re-entry PIC @ ----> 0x%p\n", reentry_memory);
printf(" x64 APC callback @ ---> 0x%p\n", reentry_callback);
printf(" Configuration @ -------> 0x%p\n", &config);
printf(" VirtualProtectEx @ ----> 0x%p\n", config.VirtualProtectEx);
printf(" WaitForSingleObjectEx @ 0x%p\n", config.WaitForSingleObjectEx);
printf(" CreateWaitableTimerW @ 0x%p\n", config.CreateWaitableTimerW);
printf(" SetWaitableTimer @ ---> 0x%p\n", config.SetWaitableTimer);
printf(" MessageBoxA @ --------> 0x%p\n", config.MessageBoxA);
printf(" Timer period @ -------> %lu ms\n", invocation_interval_ms);
printf("[ ] Entering benign x64 PIC payload loop.\n");
reinterpret_cast<PicEntry>(setup_memory)(&config);
printf("[-] x64 PIC returned unexpectedly.\n");
}
int main() {
setvbuf(stdout, nullptr, _IONBF, 0);
try {
launch("setup_x64.pic");
} catch (const exception& e) {
printf("%s\n", e.what());
return 1;
}
return 0;
}
+85
View File
@@ -0,0 +1,85 @@
BITS 64
DEFAULT REL
STRUC X64Configuration
.initialized: RESQ 1
.setup_addr: RESQ 1
.setup_length: RESQ 1
.reentry_wait: RESQ 1
.reentry_callback: RESQ 1
.VirtualProtectEx: RESQ 1
.WaitForSingleObjectEx: RESQ 1
.CreateWaitableTimerW: RESQ 1
.SetWaitableTimer: RESQ 1
.MessageBoxA: RESQ 1
.sleep_handle: RESQ 1
.due_time: RESQ 1
.interval: RESD 1
.old_protection: RESD 1
ENDSTRUC
wait_entry:
jmp wait_body
times 16 - ($ - wait_entry) db 0x90
callback_entry:
jmp callback_body
times 16 - ($ - callback_entry) db 0x90
; Called from setup_x64 as void (*wait_entry)(X64Configuration*).
; This code remains executable while setup_x64.pic is parked PAGE_READONLY.
wait_body:
push rbx
mov rbx, rcx
sub rsp, 48
; VirtualProtectEx(GetCurrentProcess(), setup_addr, setup_length,
; PAGE_READONLY, &old_protection)
mov rcx, -1
mov rdx, [rbx + X64Configuration.setup_addr]
mov r8, [rbx + X64Configuration.setup_length]
mov r9d, 0x02
lea rax, [rbx + X64Configuration.old_protection]
mov [rsp + 32], rax
call [rbx + X64Configuration.VirtualProtectEx]
; WaitForSingleObjectEx(timer, INFINITE, TRUE)
mov rcx, [rbx + X64Configuration.sleep_handle]
mov edx, 0xFFFFFFFF
mov r8d, 1
call [rbx + X64Configuration.WaitForSingleObjectEx]
; Returning to setup_x64.pic is only safe after its page is executable again.
; This is idempotent when the APC callback already restored PAGE_EXECUTE_READ.
mov rcx, -1
mov rdx, [rbx + X64Configuration.setup_addr]
mov r8, [rbx + X64Configuration.setup_length]
mov r9d, 0x20
lea rax, [rbx + X64Configuration.old_protection]
mov [rsp + 32], rax
call [rbx + X64Configuration.VirtualProtectEx]
add rsp, 48
pop rbx
ret
; Called by the waitable timer APC as:
; void CALLBACK TimerAPCProc(config, timer_low, timer_high).
callback_body:
push rbx
mov rbx, rcx
sub rsp, 48
; VirtualProtectEx(GetCurrentProcess(), setup_addr, setup_length,
; PAGE_EXECUTE_READ, &old_protection)
mov rcx, -1
mov rdx, [rbx + X64Configuration.setup_addr]
mov r8, [rbx + X64Configuration.setup_length]
mov r9d, 0x20
lea rax, [rbx + X64Configuration.old_protection]
mov [rsp + 32], rax
call [rbx + X64Configuration.VirtualProtectEx]
add rsp, 48
pop rbx
ret
+61
View File
@@ -0,0 +1,61 @@
BITS 64
DEFAULT REL
STRUC X64Configuration
.initialized: RESQ 1
.setup_addr: RESQ 1
.setup_length: RESQ 1
.reentry_wait: RESQ 1
.reentry_callback: RESQ 1
.VirtualProtectEx: RESQ 1
.WaitForSingleObjectEx: RESQ 1
.CreateWaitableTimerW: RESQ 1
.SetWaitableTimer: RESQ 1
.MessageBoxA: RESQ 1
.sleep_handle: RESQ 1
.due_time: RESQ 1
.interval: RESD 1
.old_protection: RESD 1
ENDSTRUC
; Call me like void (*entry)(void* configuration).
; RCX holds the configuration pointer on Win64.
push rbx
mov rbx, rcx
sub rsp, 64 ; 32-byte shadow space, two stack arguments, 16-byte alignment.
cmp qword [rbx + X64Configuration.initialized], 0
jne payload
; CreateWaitableTimerW(NULL, FALSE, NULL)
xor ecx, ecx
xor edx, edx
xor r8d, r8d
call [rbx + X64Configuration.CreateWaitableTimerW]
mov [rbx + X64Configuration.sleep_handle], rax
; SetWaitableTimer(timer, &due_time, interval, reentry_callback, config, FALSE)
mov rcx, rax
lea rdx, [rbx + X64Configuration.due_time]
mov r8d, [rbx + X64Configuration.interval]
mov r9, [rbx + X64Configuration.reentry_callback]
mov [rsp + 32], rbx
mov qword [rsp + 40], 0
call [rbx + X64Configuration.SetWaitableTimer]
mov qword [rbx + X64Configuration.initialized], 1
payload:
xor ecx, ecx
lea rdx, [gargoyle_text]
lea r8, [gargoyle_text]
mov r9d, 0x40
call [rbx + X64Configuration.MessageBoxA]
mov rcx, rbx
call [rbx + X64Configuration.reentry_wait]
jmp payload
gargoyle_text:
db 'gargoyle x64', 0
+60 -3
View File
@@ -4,7 +4,13 @@
# Building gargoyle
*gargoyle* is only implemented for 32-bit Windows (64-bit Windows on Windows is fine). The current build-only baseline is tested with:
*gargoyle* is a Windows research proof of concept. The original 32-bit
implementation remains the reference baseline (64-bit Windows on Windows is
fine). This refresh also includes a sibling x64 example in `GargoyleX64\` that
uses pointer-sized configuration, Win64 ABI PIC calls, and a benign timer/APC
re-entry loop without replacing the Win32 design.
The current baseline is tested with:
* [Visual Studio](https://visualstudio.microsoft.com/downloads/): Visual Studio 18 with MSVC toolset `v145`, or a compatible retargeted Visual Studio C++ toolchain.
* Windows 10 SDK. The project uses `WindowsTargetPlatformVersion` `10.0` so MSBuild selects the latest installed Windows 10 SDK.
@@ -22,24 +28,63 @@ Clone *gargoyle*:
git clone https://github.com/JLospinoso/gargoyle.git
```
Open `Gargoyle.sln` and build the `Debug|x86` or `Release|x86` configuration. You can also build from PowerShell:
Open `Gargoyle.sln` and build the `Debug|x86` or `Release|x86` configuration
for the Win32 proof of concept. The same solution also contains the
`GargoyleX64` example under `Debug|x64` and `Release|x64`. You can build the
native examples from PowerShell:
```powershell
& 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe' Gargoyle.sln /p:Configuration=Debug /p:Platform=x86 /m
& 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe' Gargoyle.sln /p:Configuration=Release /p:Platform=x86 /m
& 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe' Gargoyle.sln /p:Configuration=Debug /p:Platform=x64 /m
& 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe' Gargoyle.sln /p:Configuration=Release /p:Platform=x64 /m
```
The executable loads `setup.pic` and `gadget.pic` relative to the current working directory. The Visual Studio debugger is configured to run from the output directory so F5 can find those files. If you launch manually, run from `Debug\` or `Release\`.
Use the `justfile` recipes for the full refreshed build and validation path:
```powershell
uv sync --all-groups
just ci
```
You can run the Python acceptance harness with [uv](https://docs.astral.sh/uv/):
```powershell
uv sync --all-groups
uv run gargoyle-acceptance --configuration Debug
uv run gargoyle-acceptance --configuration Release
uv run gargoyle-acceptance --configuration Debug --platform x64
```
The harness builds the requested configuration, launches `Gargoyle.exe` from the output directory, validates the setup banner, and closes two benign `gargoyle` MessageBox windows to confirm initial PIC execution and timer re-entry. Use `uv run gargoyle-acceptance --help` for options.
The harness builds the requested configuration and platform, launches the
executable from the output directory, validates the setup banner, and closes two
benign MessageBox windows to confirm initial PIC execution and timer re-entry.
Use `uv run gargoyle-acceptance --help` for options.
The native quality gate is MSVC-based:
```powershell
just native-check
```
That recipe runs MSVC code analysis for Debug/Release on x86 and x64, then builds
Debug AddressSanitizer variants for both platforms under the ignored `asan\`
directory. The normal `just ci` gate includes this native quality pass.
The x64 example builds through the root solution:
```powershell
just build-x64-all
```
Run `GargoyleX64.exe` from its configuration output directory so it can find
`setup_x64.pic` and `reentry_x64.pic`. With the current Visual Studio defaults,
the root solution emits the x64 executable and PIC files under `x64\Debug\` or
`x64\Release\`. The x64 example uses a separate executable re-entry PIC to park
`setup_x64.pic` as read-only during alertable waits, restore execute permission
on timer re-entry, and show the benign `gargoyle x64` MessageBox again.
There is some harness code in `main.cpp` that configures the following three components:
@@ -55,6 +100,18 @@ auto gadget_memory = get_gadget(use_mshtml, gadget_pic_path);
Every 15 seconds, gargoyle will pop up a message box. When you click ok, gargoyle sets up the tail calls to mark itself non-executable and to wait for the timer. For fun, use [Sysinternals's excellent VMMap tool](https://technet.microsoft.com/en-us/sysinternals/vmmap.aspx) to examine when *gargoyle*'s PIC is executable. If a message box is active, *gargoyle* will be executable. If it is not, *gargoyle* should not be executable. The PIC's address is printed to `stdout` just before the harness calls into the PIC.
## Documentation
The refreshed documentation includes:
* `docs/acceptance.md` for the Python acceptance harness.
* `docs/validation-checklist.md` for automated and manual runtime checks.
* `docs/win32-architecture.md` for the current Win32 configuration, stack, timer/APC, gadget, and protection-cycle layout.
* `docs/x64-architecture.md` for the sibling x64 setup PIC and re-entry PIC layout.
* `docs/responsible-use.md` for limitations, detection visibility, and responsible-use boundaries.
* `docs/future-work.md` for x64 and post-x64 directions that should not bloat the core proof of concept.
* `docs/references.md` for the original article, detection work, forensics research, and later related techniques.
# More information
See the blog post [available at lospi.net](https://jlospinoso.github.io/security/assembly/c/cpp/developing/software/2017/03/04/gargoyle-memory-analysis-evasion.html) for more information.
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
<UseDebugLibraries>true</UseDebugLibraries>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
</PropertyGroup>
</Project>
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<LocalDebuggerWorkingDirectory>$(OutDir)</LocalDebuggerWorkingDirectory>
<DebuggerFlavor>WindowsLocalDebugger</DebuggerFlavor>
<CodeAnalysisRuleSet>NativeRecommendedRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<LinkIncremental>true</LinkIncremental>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<LinkIncremental>false</LinkIncremental>
</PropertyGroup>
<PropertyGroup Condition="'$(EnableASAN)'=='true'">
<LinkIncremental>false</LinkIncremental>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<WarningLevel>Level4</WarningLevel>
<TreatWarningAsError>true</TreatWarningAsError>
<SDLCheck>true</SDLCheck>
<ConformanceMode>true</ConformanceMode>
<MultiProcessorCompilation>true</MultiProcessorCompilation>
<AdditionalOptions>/FS /Zc:__cplusplus /Zc:preprocessor %(AdditionalOptions)</AdditionalOptions>
</ClCompile>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(EnableASAN)'=='true'">
<ClCompile>
<BasicRuntimeChecks>Default</BasicRuntimeChecks>
<DebugInformationFormat>ProgramDatabase</DebugInformationFormat>
</ClCompile>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile>
<PrecompiledHeader>
</PrecompiledHeader>
<Optimization>Disabled</Optimization>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
<ClCompile>
<PrecompiledHeader>
</PrecompiledHeader>
<Optimization>MaxSpeed</Optimization>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
</Project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<CustomBuild Include="@(NasmPic)">
<FileType>Document</FileType>
<Message>Assembling %(Filename)%(Extension)</Message>
<Command>nasm -f bin "%(FullPath)" -o "$(OutDir)%(Filename).pic"</Command>
<Outputs>$(OutDir)%(Filename).pic</Outputs>
</CustomBuild>
</ItemGroup>
</Project>
+26 -7
View File
@@ -1,12 +1,14 @@
# Acceptance Harness
The acceptance harness is a Python CLI that builds and launches the existing
Win32 Gargoyle binary, validates the setup banner, and closes the benign
MessageBox payload windows.
Gargoyle binary, validates the setup banner, and closes the benign MessageBox
payload windows. It defaults to the Win32 baseline and can also validate the
x64 sibling example.
```powershell
uv run gargoyle-acceptance --configuration Debug
uv run gargoyle-acceptance --configuration Release
uv run gargoyle-acceptance --configuration Debug --platform x64
```
By default the harness:
@@ -14,12 +16,29 @@ By default the harness:
- requires Windows;
- verifies `nasm.exe` is available on `PATH`;
- discovers MSBuild or uses the `--msbuild` path;
- builds `Gargoyle.sln` for `x86`;
- checks that `Gargoyle.exe`, `setup.pic`, and `gadget.pic` exist;
- builds `Gargoyle.sln` for the requested platform;
- checks that the expected executable and PIC files exist;
- launches from the configuration output directory;
- waits for a complete setup banner with non-zero addresses;
- closes two `gargoyle` MessageBox windows to confirm the first payload run and
the timer/APC re-entry.
- waits for a complete platform-specific setup banner with non-zero addresses;
- closes two platform-specific MessageBox windows to confirm the first payload
run and timer/APC re-entry.
Use `--rounds 1` for a shorter check that only confirms the initial PIC handoff.
Use `--skip-build` when you want to validate already-built outputs.
## Platforms
`--platform x86` validates the original Win32 path:
- builds `Debug|x86` or `Release|x86`;
- expects `Gargoyle.exe`, `setup.pic`, and `gadget.pic`;
- parses the Win32 PIC, gadget, configuration, stack, and trampoline addresses;
- closes MessageBox windows titled `gargoyle`.
`--platform x64` validates the sibling x64 path:
- builds `Debug|x64` or `Release|x64`;
- expects `GargoyleX64.exe`, `setup_x64.pic`, and `reentry_x64.pic`;
- parses the setup PIC, re-entry PIC, APC callback, configuration, and API
addresses;
- closes MessageBox windows titled `gargoyle x64`.
+118
View File
@@ -0,0 +1,118 @@
# Future Work And Post-X64 Directions
Future Gargoyle work should preserve the tiny, understandable Win32 proof of
concept while making research directions explicit. New experiments should be
small, reviewable, and easy to separate from the baseline demo.
## Near-Term Direction
- Keep the Win32 path as the reference implementation. Do not replace it with a
generalized framework or hide the original technique behind large abstraction
layers.
- Keep the x64 example documented as a sibling architecture rather than a
transparent port. The x64 calling convention, stack alignment, nonvolatile
registers, unwind metadata, CFG, and CET all change the design constraints.
- Prefer visible, benign payload evidence for every architecture. The MessageBox
pattern is intentionally simple because it makes runtime validation easy and
keeps the repository in research territory.
- Keep defender observations next to offensive-mechanics explanations. Any
future runtime change should update [Responsible Use, Limitations, And
Detection](responsible-use.md) and [Validation Checklist](validation-checklist.md).
## X86 To X64 Contrast To Document
- Stack pivoting: the existing x86 implementation pivots through `esp`; x64
work must account for `rsp` alignment, shadow space, and the Windows x64
register argument convention.
- Callback shape: the current waitable timer path is compact, but x64 designs
may choose waitable timers, timer queues, `NtContinue`-style context
restoration, or another documented callback path. Each choice has different
defensive visibility.
- Gadget assumptions: x86 `pop reg; pop esp; ret`-style pivots are not a direct
recipe for x64. The checked-in x64 example uses a separate re-entry PIC; any
future gadget or context-restoration design should explain provenance and
failure modes without encouraging broad gadget harvesting.
- Mitigation interaction: CFG, CET, shadow stacks, and continuation-target
validation can turn historical ROP assumptions into crashes or detections.
Document those outcomes rather than treating them as blockers to bypass.
- Validation evidence: x64 work needs the same evidence standard as Win32:
build output, setup banner, benign payload, protection transition, timer
re-entry, and clear desktop-only checks.
## Possible Companion Detector
A small defensive companion could make the project more useful without changing
the PoC:
- Read a process memory map and highlight private committed regions whose
protections change between executable and non-executable states.
- Correlate the setup banner addresses with VMMap or debugger observations.
- Emit a short report suitable for the validation checklist.
- Stay local and observational. Do not inject into unrelated processes, bypass
product protections, or collect private data.
## Experiment Placement
- Keep the default branch focused on the buildable baseline and documented
validation workflow.
- Put alternate x64 re-entry work in small branches or clearly named docs until
it has a stable build and a benign acceptance story.
- Document experiments that are interesting but too large, too brittle, or too
operational for this repository instead of merging them into the core.
- Prefer separate PRs when changes have different review surfaces: docs,
Win32 hardening, x64 implementation, and detector experiments should not be
tangled unless the plan explicitly calls for integration.
## Research Questions
- How does the original waitable-timer/APC design compare with later
timer-queue and `NtContinue` sleep-obfuscation families from a defender's
point of view?
- Which observations are stable across Windows versions: memory protections,
VAD state, timer objects, APC delivery, stack shape, and callback targets?
- Can a tiny detector demonstrate the defensive visibility without requiring
kernel components or product-specific telemetry?
- Which x64 design choices are educational enough to include here, and which
belong only as references to external work such as YouMayPasser, Cronos, or
ShellcodeFluctuation?
- The refresh audit mentioned "Mirage" as later related work. What exact public
source should be cited, and does it belong in the same sleep-obfuscation
family tree?
## Non-Goals
- No credential theft, persistence, lateral movement, autonomous exploitation,
staged payload deployment, or C2-oriented features.
- No attempt to keep pace with commercial evasion frameworks.
- No broad payload loader, plugin system, or operator workflow.
- No mitigation-bypass arms race. Platform failures and detections should be
documented as useful research outcomes.
## Issue Coverage
This page contributes to:
- #13 by keeping the refresh scope explicit.
- #19 by recording post-x64 directions that do not bloat the core PoC.
- #17 by carrying responsible-use boundaries into future experiments.
- #18 by requiring a validation story for future architecture work.
+5 -3
View File
@@ -3,7 +3,8 @@
Gargoyle is a small Windows proof of concept for memory-scanner evasion.
The 2026 refresh keeps the original Win32 artifact intact while adding modern
build tooling, acceptance validation, and documentation.
build tooling, acceptance validation, documentation, and a sibling x64
timer/APC example.
## Developer Commands
@@ -11,7 +12,8 @@ build tooling, acceptance validation, and documentation.
uv sync --all-groups
just check
uv run gargoyle-acceptance --configuration Debug
uv run gargoyle-acceptance --configuration Debug --platform x64
```
Runtime validation is Windows-only because it launches `Gargoyle.exe` and
interacts with its benign MessageBox payload.
Runtime validation is Windows-only because it launches the native executable and
interacts with benign MessageBox payloads.
+126
View File
@@ -0,0 +1,126 @@
# Reference Map
This page places Gargoyle in the public research lineage around memory-scanner
evasion, process-memory forensics, and later sleep-obfuscation work. It is a
curated map, not an endorsement of every linked project or an implementation
guide. The repository should remain a small, benign research artifact.
Access dates below use 2026-05-08.
## Original Work
- [Gargoyle: A memory scanning evasion technique](https://lospi.net/security/assembly/c/cpp/developing/software/2017/03/04/gargoyle-memory-analysis-evasion.html)
is the original March 2017 article that explains the waitable-timer, APC,
stack-pivot, and protection-flipping proof of concept. It is still the best
starting point for understanding what this repository is intended to show.
Accessed 2026-05-08.
- [JLospinoso/gargoyle](https://github.com/JLospinoso/gargoyle) is the
original public proof-of-concept repository. The current refresh preserves the
Win32 implementation while adding build, validation, and documentation
scaffolding around it. Accessed 2026-05-08.
## Detection And Forensics
- [Hunting for Gargoyle Memory Scanning Evasion](https://blog.f-secure.com/hunting-for-gargoyle-memory-scanning-evasion/)
documents F-Secure Countercept's defensive analysis and summarizes the core
detection idea: code may be non-executable when memory scanners run, but the
timer/APC and memory-layout artifacts remain observable. Accessed 2026-05-08.
- [WithSecureLabs volatility-plugins: gargoyle.py](https://github.com/WithSecureLabs/volatility-plugins/blob/master/gargoyle.py)
is the public Volatility plugin associated with that detection work. It is a
useful reference for Gargoyle-specific memory-forensics heuristics. Accessed
2026-05-08.
- [Hiding Process Memory Via Anti-Forensic Techniques](https://www.sciencedirect.com/science/article/pii/S2666281720302614)
is the DFRWS USA 2020 open-access paper that compares Gargoyle with stronger
memory-hiding approaches and notes that Gargoyle changes scanner visibility
rather than truly removing the memory range from the process map. Accessed
2026-05-08.
- [Black Hat USA 2020 slides: Hiding Process Memory Via Anti-Forensic Techniques](https://i.blackhat.com/USA-20/Wednesday/us-20-Block-Hiding-Process-Memory-Via-Anti-Forensic-Techniques.pdf)
are the conference slides for the same research thread and cite Gargoyle as
prior work. Accessed 2026-05-08.
- [DFRWS-memory-subversion/DFRWS-USA-2020](https://github.com/DFRWS-memory-subversion/DFRWS-USA-2020)
contains the companion materials for the DFRWS 2020 paper, including detection
and subversion research artifacts. Accessed 2026-05-08.
- [Windows Memory Forensics: Detecting Unintentionally Hidden Injected Code by
Examining Page Table Entries](https://dfrws.org/wp-content/uploads/2019/06/2019_USA_paper-windows_memory_forensics_detecting_unintentionally_hidden_injected_code_by_examining_page_table_entries.pdf)
gives related page-table and memory-forensics context for injected code that
ordinary virtual-address-descriptor views may miss or misclassify. Accessed
2026-05-08.
## Direct Derivatives And Adjacent Experiments
- [Bypassing Memory Scanners with Cobalt Strike and Gargoyle](https://labs.withsecure.com/publications/experimenting-bypassing-memory-scanners-with-cobalt-strike-and-gargoyle)
is an MWR/WithSecure experiment that applied the Gargoyle idea to a larger
payload. It is useful here mainly because it made defender-visible artifacts
explicit and helped motivate the later detection work. Accessed 2026-05-08.
- [waldo-irc/YouMayPasser](https://github.com/waldo-irc/YouMayPasser) describes
itself as an x64 Gargoyle implementation and documents several indicators of
compromise in its README. Treat it as lineage and design context rather than
a target architecture for this repository. Accessed 2026-05-08.
- [mgeeky/ShellcodeFluctuation](https://github.com/mgeeky/ShellcodeFluctuation)
cites Gargoyle as background for cyclically changing memory protections and
optionally encrypting content while dormant. It is part of the broader family
of memory-state fluctuation ideas that followed Gargoyle. Accessed
2026-05-08.
## Later Sleep-Obfuscation Family
- [Idov31/Cronos](https://github.com/Idov31/Cronos) is a waitable-timer based
sleep-obfuscation proof of concept that draws from the Ekko family and uses
repeated protection and encryption state changes during idle periods. It is a
useful comparison point because it keeps timers central while moving beyond
Gargoyle's tiny Win32 shape. Accessed 2026-05-08.
- [Cronos Sleep Obfuscation](https://idov31.github.io/posts/cronos-sleep-obfuscation)
is the companion write-up for Cronos and discusses how later sleep
obfuscators evolved from simple memory-protection toggling into timer-driven
encryption and re-entry strategies. Accessed 2026-05-08.
- [Understanding Sleep Obfuscation](https://binarydefense.com/resources/blog/understanding-sleep-obfuscation/)
gives a defender-oriented comparison of Ekko, Cronos, Foliage, and related
approaches. It is especially useful for thinking about detection categories
rather than any single implementation. Accessed 2026-05-08.
- [Hunting for timer-queue timers](https://labs.withsecure.com/publications/hunting-for-timer-queue-timers)
covers detection work for timer-queue based sleep obfuscation and contrasts
that family with waitable-timer variants such as Cronos. Accessed 2026-05-08.
## Windows API And Tool References
- [VirtualProtectEx](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualprotectex)
is the Win32 API Gargoyle uses to toggle the setup PIC between executable and
non-executable protections. Accessed 2026-05-08.
- [SetWaitableTimer](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-setwaitabletimer)
documents the waitable timer and completion routine behavior that Gargoyle
uses for re-entry. Accessed 2026-05-08.
- [WaitForSingleObjectEx](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobjectex)
documents alertable waits, which are important because queued APC completion
routines only run when the relevant thread enters an alertable state. Accessed
2026-05-08.
- [Using Waitable Timers with an Asynchronous Procedure Call](https://learn.microsoft.com/en-us/windows/win32/sync/using-a-waitable-timer-with-an-asynchronous-procedure-call)
is Microsoft's conceptual example for waitable timers with APC completion
routines. Accessed 2026-05-08.
- [VMMap](https://learn.microsoft.com/en-us/sysinternals/downloads/vmmap) is the
Sysinternals process memory viewer used in the original demo instructions and
in the refreshed manual validation checklist. Accessed 2026-05-08.
## Open Follow-Ups
- The initial refresh audit mentioned "Mirage" among later related work, but no
reliable public source was verified during this docs pass. Keep it as a
follow-up research item before adding it as a citation.
- The x64 lineage deserves an implementation-focused comparison now that
Gargoyle has a sibling x64 timer/APC example. This page intentionally records
public references without prescribing a broader evasion design.
+118
View File
@@ -0,0 +1,118 @@
# Responsible Use, Limitations, And Detection
Gargoyle is a historical Windows research proof of concept. Its refreshed
documentation should help maintainers, defenders, and researchers understand the
technique, reproduce the benign demo, and recognize the artifacts it leaves
behind. It should not evolve into a general offensive framework.
## Responsible-Use Boundaries
- Keep demo payloads benign. The repository's live runtime demonstration should
remain the `gargoyle` MessageBox payload or an equally harmless local
observation.
- Run the proof of concept only in systems you own or have explicit permission
to use. Prefer disposable Windows VMs or lab machines where interactive window
automation is acceptable.
- Do not add credential theft, persistence, lateral movement, autonomous
exploitation, payload deployment, or operational misuse guidance.
- Treat references to larger payload experiments as historical and defensive
context. They explain why the technique was studied and how defenders looked
for it; they are not acceptance criteria for this repository.
- Preserve the small proof-of-concept shape. A maintainable research artifact is
more valuable here than a broader evasion platform.
## Current Limitations
- Architecture: the Win32/x86 path remains the reference implementation. The
checked-in x64 sibling demonstrates timer/APC re-entry with a separate
re-entry PIC, but it is not a transparent port of the Win32 stack-pivot chain.
- Calling convention: `setup.nasm` relies on 32-bit stack calling conventions,
`esp`/`ebp` manipulation, and a compact `pop reg; pop esp; ret`-style pivot.
The x64 sibling uses register arguments, shadow space, stack alignment, and a
separate re-entry surface instead of copying those assumptions.
- Runtime environment: the acceptance harness needs a Windows desktop session
because it closes visible `gargoyle` MessageBox windows. Headless CI can build
and run Python checks, but the live MessageBox validation is local and
interactive by design.
- Gadget source: the demo first tries to locate a compatible pivot gadget in a
system DLL and falls back to the tiny `gadget.pic` artifact if needed. System
DLL layout, mitigation policy, and Windows version differences can affect this
path.
- Visibility: Gargoyle does not make memory disappear. The core idea is to make
the PIC non-executable while idle so simplistic executable-page scans are less
likely to classify it as code. Process memory maps, timer state, private
allocations, and behavioral telemetry can still expose the demo.
- Mitigations and platform changes: Control Flow Guard, CET/shadow-stack
behavior, stricter callback validation, endpoint telemetry, and debugger or
sandbox instrumentation can all affect ROP, callback, and context-restoration
assumptions. Treat failures as research data, not as reasons to add bypasses.
- Timing: the default cadence is intentionally obvious: a MessageBox appears
about every 15 seconds. Scanner timing, scheduler behavior, and manual
interaction can make observations slightly noisy.
## Defensive Visibility
Defenders and maintainers can reason about Gargoyle through several observable
categories:
- Memory protection transitions: the setup PIC is executable while the payload
runs and non-executable while the demo waits. VMMap, debuggers, ETW providers,
or memory forensics can observe the region changing state.
- Private code-adjacent memory: the setup PIC, fallback gadget, scratch stack,
and trampoline are allocated at runtime rather than loaded as ordinary module
code. Even when non-executable, those regions remain part of the process
address space.
- Timer and APC behavior: Gargoyle uses a waitable timer and an alertable wait
path to re-enter the code. Auditing timer completion routines, APC delivery,
and alertable waits can expose behavior that simple page scans miss.
- ROP and stack-pivot shape: the pivot gadget and stack trampoline are small but
distinctive. Defensive work can look for unusual callback targets, stack
movement into private allocations, and return paths that do not resemble
ordinary compiler output.
- Console and UI artifacts: the refreshed demo prints platform-specific PIC,
configuration, callback, and API addresses, then opens benign MessageBox
windows titled `gargoyle` or `gargoyle x64`. Those are acceptance evidence,
not stealth features.
- Forensics clues: Volatility-style plugins, page-table inspection, VAD review,
and memory-map comparisons can all provide evidence even when an executable
page scanner misses the idle PIC.
## Safe Defender Exercises
- Build the Debug and Release configurations and run the acceptance harness
against the benign MessageBox payload.
- Record the setup banner addresses and inspect the Gargoyle process with VMMap
before dismissing the MessageBox, after dismissing it, and after the next
timer re-entry.
- Compare what user-mode memory-map tools show with what a memory-forensics
workflow sees. Focus on whether the region exists, what its protection is, and
whether timers or callback targets explain re-entry.
- Use the public references in [Reference Map](references.md) to compare
Gargoyle-specific heuristics with broader sleep-obfuscation detection ideas.
## Issue Coverage
This page contributes to:
- #13 by framing the 2026 refresh as a research artifact.
- #17 by documenting responsible use, limitations, and defender visibility.
- #18 by naming the observations that the validation checklist should capture.
- #19 by setting boundaries for future work.
+153
View File
@@ -0,0 +1,153 @@
# Validation Checklist
Use this checklist alongside the automated acceptance harness in
[Acceptance Harness](acceptance.md). It is intentionally short and benign: the
goal is to confirm that the Win32 proof of concept still demonstrates the
documented memory-state transition without turning runtime validation into an
operational playbook.
## Prerequisites
- Windows desktop session where visible MessageBox windows are acceptable.
- Current Visual Studio C++ toolchain and Windows 10 SDK.
- NASM available on `PATH`.
- `uv` and Python 3.13 available for the acceptance harness.
- Optional: [Sysinternals VMMap](https://learn.microsoft.com/en-us/sysinternals/downloads/vmmap)
for process memory inspection.
- Optional: debugger or tracing tool for local research observations.
## Automated Checks
Run these before manual runtime validation:
```powershell
uv sync --all-groups
just build-all
just native-check
just check
```
For the live acceptance path, run:
```powershell
uv run --all-groups gargoyle-acceptance --configuration Debug
uv run --all-groups gargoyle-acceptance --configuration Release
uv run --all-groups gargoyle-acceptance --configuration Debug --platform x64
uv run --all-groups gargoyle-acceptance --configuration Release --platform x64
```
Expected automated evidence:
- `Gargoyle.exe`, `setup.pic`, and `gadget.pic` exist in each configuration
output directory.
- `GargoyleX64.exe`, `setup_x64.pic`, and `reentry_x64.pic` exist in each x64
output directory.
- MSVC code analysis completes for Debug/Release on x86 and x64 with warnings as
errors.
- AddressSanitizer Debug builds complete for x86 and x64 under `asan\`.
- The setup banner includes non-zero addresses for the Gargoyle PIC, ROP gadget,
configuration, stack bounds, and stack trampoline.
- The x64 setup banner includes non-zero addresses for the setup PIC, re-entry
PIC, APC callback, configuration, and imported APIs.
- The harness closes at least two benign `gargoyle` MessageBox rounds when run
with its default settings, confirming the initial handoff and one timer/APC
re-entry.
- The x64 harness closes at least two benign `gargoyle x64` MessageBox rounds,
confirming the initial handoff and one timer/APC re-entry.
## Manual Runtime Checklist
1. Build `Debug|x86` or `Release|x86`.
2. Start `Gargoyle.exe` from the matching output directory so `setup.pic` and
`gadget.pic` are beside the executable.
3. Save the console banner. Confirm the printed addresses are non-zero and that
the PIC, configuration, stack, and trampoline are distinct regions.
4. When the first `gargoyle` MessageBox appears, inspect the process in VMMap or
a comparable local tool. The setup PIC address from the banner should be in a
committed region that is executable while the payload is active.
5. Dismiss the MessageBox. During the idle interval, refresh the memory view. The
setup PIC region should remain committed but should no longer be executable.
6. Wait for the next MessageBox. The default interval is approximately 15
seconds. A second window confirms timer/APC re-entry.
7. Dismiss the second MessageBox and close the process after collecting evidence.
The demo should not create files, network connections, persistence, or
non-benign payload effects.
## x64 Manual Runtime Checklist
1. Build `Debug|x64` or `Release|x64`.
2. Start `GargoyleX64.exe` from the matching output directory so `setup_x64.pic`
and `reentry_x64.pic` are beside the executable.
3. Save the console banner. Confirm the setup PIC, re-entry PIC, APC callback,
configuration, and imported API addresses are non-zero.
4. When the first `gargoyle x64` MessageBox appears, inspect the setup PIC
address from the banner. It should be executable while the payload is active.
5. Dismiss the MessageBox. During the idle interval, the setup PIC should be
parked as read-only while the separate re-entry PIC remains executable.
6. Wait for the next `gargoyle x64` MessageBox. The default interval is
approximately 15 seconds. A second window confirms timer/APC re-entry.
7. Dismiss the second MessageBox and close the process after collecting
evidence.
## Optional Diagnostic Observations
- Watch for `VirtualProtectEx` calls that make the setup PIC executable before
payload execution and non-executable afterward.
- Watch for `SetWaitableTimer` and alertable `WaitForSingleObjectEx` behavior
that explains why the timer completion routine runs on re-entry.
- Compare the fallback `gadget.pic` path with the system-DLL gadget path when
`mshtml.dll` is absent or lacks a compatible pivot sequence.
- Record Windows version, Visual Studio toolset, NASM version, configuration,
and whether the process ran under WOW64.
## CI-Safe Versus Desktop-Only
CI-safe:
- NASM assembly and Visual Studio compilation.
- Python formatting, linting, typing, unit tests, and documentation builds.
- Static checks that do not launch the interactive demo.
Desktop-only:
- MessageBox automation.
- VMMap inspection.
- Debugger or live process tracing.
- Any observation that depends on a visible interactive session.
## Failure Clues
- Missing `setup.pic` or `gadget.pic`: launch from the configuration output
directory or rebuild the project.
- Setup banner timeout: inspect the console output, artifact paths, and whether
the process started from the expected working directory.
- Exit before the second MessageBox: suspect gadget selection, stack pivot,
timer setup, or protection-restoration behavior.
- Memory remains executable while idle: inspect the `VirtualProtectEx` tail-call
path and confirm the observed region matches the banner's PIC address.
## Issue Coverage
This page contributes to:
- #13 by making the refreshed artifact easier to verify.
- #18 by capturing reproducible automated and manual validation steps.
- #17 by keeping live validation tied to benign, observable behavior.
+75
View File
@@ -0,0 +1,75 @@
# Win32 Architecture
Gargoyle's current runtime is a small 32-bit Windows proof of concept. The
supported path is the Win32 build produced by `Gargoyle.sln` with `Platform=x86`,
NASM on `PATH`, and a current Visual Studio C++ toolchain targeting Windows SDK
`10.0`.
The demo intentionally keeps the payload benign: each invocation displays a
`gargoyle` MessageBox. The interesting behavior is the setup around that payload,
not the payload itself.
## Runtime Layout
`main.cpp` allocates three executable or mutable regions:
- `setup.pic`, assembled from `setup.nasm`, contains the position-independent
setup and re-entry code.
- A ROP gadget is selected from an executable section of `mshtml.dll` when
possible. If that search fails, `gadget.pic` provides a tiny fallback gadget.
- A read/write workspace contains the configuration block, scratch stack, and
trampoline frame consumed by the setup PIC.
The C++ `SetupConfiguration` and `StackTrampoline` structures mirror the NASM
layout in `setup.nasm`. Keep those layouts paired: the setup PIC reads fields by
offset rather than by symbol names.
## Control Flow
1. `main.cpp` loads `setup.pic` as executable memory, then searches for a
stack-pivot gadget.
2. The workspace is zeroed and populated with Win32 API addresses, the timer
interval, the selected gadget, the scratch stack, and the trampoline frame.
3. The setup PIC receives the configuration pointer, pivots to the scratch stack,
creates a waitable timer, and registers the gadget as the APC completion
callback. The callback argument points at the trampoline frame.
4. Before waiting, the setup PIC tail-calls `VirtualProtectEx` to mark the PIC
region `PAGE_READONLY`, then enters an alertable wait through
`WaitForSingleObjectEx`.
5. On timer delivery, the APC callback enters the gadget. The gadget pivots the
stack to the trampoline frame, which restores the PIC to `PAGE_EXECUTE_READ`
and returns to `setup.pic`.
6. The benign MessageBox payload runs again, and the cycle repeats.
The console banner logs the selected gadget source, timer/APC setup, stack-pivot
target, and protection cycle so a failing run can be diagnosed from stdout before
using a debugger.
## Validation Notes
The acceptance harness can validate the observable behavior in an interactive
Windows desktop session:
```powershell
uv run --all-groups gargoyle-acceptance --configuration Debug
uv run --all-groups gargoyle-acceptance --configuration Release
```
A healthy run prints non-zero addresses for the PIC, gadget, configuration,
scratch stack, and trampoline. It then shows and closes two benign `gargoyle`
MessageBox windows: the initial handoff and the timer/APC re-entry.
Manual validation can additionally confirm:
- the selected gadget source is either `system DLL: mshtml.dll` or
`allocated fallback PIC: gadget.pic`;
- the timer period is 15000 ms;
- the logged protection cycle is `PAGE_EXECUTE_READ -> PAGE_READONLY ->
PAGE_EXECUTE_READ`;
- a debugger or VMMap-style view agrees that the PIC is not left writable after
setup.
The old Windows 7 x64 NULL-jump report is treated as unsupported legacy behavior
for this Win32 refresh. The current issue to watch is whether the fallback gadget
is selected on a supported Win32 run; that path is logged explicitly so it can be
reproduced and debugged without broadening the proof of concept.
+60
View File
@@ -0,0 +1,60 @@
# x64 Architecture
`GargoyleX64` is a sibling Windows x64 example, not a transparent port of the
Win32 stack-pivot design. It keeps the payload benign and visible while proving
the x64-specific mechanics needed for a timer/APC re-entry loop.
## Runtime Layout
The x64 build emits three files beside each other in the Visual Studio output
directory:
- `GargoyleX64.exe`, the C++ harness that loads raw PIC blobs and prepares the
configuration block.
- `setup_x64.pic`, the main x64 setup and payload loop.
- `reentry_x64.pic`, a small executable wait/re-entry surface used while
`setup_x64.pic` is parked read-only.
The C++ `X64Configuration` layout mirrors the NASM layout consumed by both PIC
files. It stores pointer-sized fields for the setup PIC, re-entry wait function,
APC callback, imported Windows APIs, timer handle, relative due time, timer
period, and saved protection value.
## Control Flow
1. `main_x64.cpp` loads `setup_x64.pic` and `reentry_x64.pic` as executable raw
PIC and fills the shared configuration block.
2. `setup_x64.pic` receives the configuration pointer in `rcx`, creates a
waitable timer, and arms it with the callback entry inside `reentry_x64.pic`.
3. The benign payload displays a `gargoyle x64` MessageBox.
4. After the MessageBox closes, `setup_x64.pic` calls the wait entry in
`reentry_x64.pic`.
5. The wait entry marks `setup_x64.pic` `PAGE_READONLY` and enters an alertable
`WaitForSingleObjectEx`.
6. Timer re-entry restores `setup_x64.pic` to `PAGE_EXECUTE_READ` through the
re-entry PIC, then control returns to the setup loop and the benign payload
appears again.
The wait path also reapplies `PAGE_EXECUTE_READ` before returning to the setup
PIC. That restore is idempotent when the APC callback has already run, and it
keeps unexpected waitable-timer return modes from jumping back into a read-only
setup page.
## Validation Notes
Run the x64 acceptance harness from a Windows desktop session:
```powershell
uv run --all-groups gargoyle-acceptance --configuration Debug --platform x64
uv run --all-groups gargoyle-acceptance --configuration Release --platform x64
```
A healthy run prints non-zero addresses for the setup PIC, re-entry PIC, APC
callback, configuration block, and imported APIs. The harness then closes two
`gargoyle x64` MessageBox windows: the initial handoff and one timer/APC
re-entry.
The x64 example intentionally avoids the Win32 `pop reg; pop esp; ret` gadget
assumption. The separate re-entry PIC is the stable executable surface for the
x64 demonstration, while the setup PIC is the region whose idle protection state
is meant to be inspected.
+49 -2
View File
@@ -48,12 +48,58 @@ build-debug:
build-release:
just build Release
build-x64 configuration:
$nasmDir = Join-Path $env:LOCALAPPDATA 'bin\NASM'; if (Test-Path (Join-Path $nasmDir 'nasm.exe')) { $env:Path = "$nasmDir;$env:Path" }; $msbuild = if ($env:MSBUILD) { $env:MSBUILD } elseif (Test-Path 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe') { 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe' } else { 'MSBuild.exe' }; & $msbuild Gargoyle.sln /p:Configuration={{configuration}} /p:Platform=x64 /m
build-x64-debug:
just build-x64 Debug
build-x64-release:
just build-x64 Release
build-x64-all:
just build-x64-debug
just build-x64-release
build-all:
just build-debug
just build-release
just build-x64-all
acceptance configuration="Debug":
uv run --all-groups gargoyle-acceptance --configuration {{configuration}}
native-analyze configuration="Debug" platform="x86":
$nasmDir = Join-Path $env:LOCALAPPDATA 'bin\NASM'; if (Test-Path (Join-Path $nasmDir 'nasm.exe')) { $env:Path = "$nasmDir;$env:Path" }; $msbuild = if ($env:MSBUILD) { $env:MSBUILD } elseif (Test-Path 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe') { 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe' } else { 'MSBuild.exe' }; & $msbuild Gargoyle.sln /p:Configuration={{configuration}} /p:Platform={{platform}} /p:RunCodeAnalysis=true /m:1
native-analyze-all:
just native-analyze Debug x86
just native-analyze Release x86
just native-analyze Debug x64
just native-analyze Release x64
native-asan configuration="Debug" platform="x86":
$nasmDir = Join-Path $env:LOCALAPPDATA 'bin\NASM'; if (Test-Path (Join-Path $nasmDir 'nasm.exe')) { $env:Path = "$nasmDir;$env:Path" }; $msbuild = if ($env:MSBUILD) { $env:MSBUILD } elseif (Test-Path 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe') { 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe' } else { 'MSBuild.exe' }; $outDir = Join-Path $PWD 'asan\{{platform}}\{{configuration}}'; $intDir = Join-Path $PWD 'asan\obj\{{platform}}\{{configuration}}'; New-Item -ItemType Directory -Force $outDir, $intDir | Out-Null; & $msbuild Gargoyle.sln /p:Configuration={{configuration}} /p:Platform={{platform}} /p:EnableASAN=true "/p:OutDir=$outDir\" "/p:IntDir=$intDir\" /m:1
native-asan-all:
just native-asan Debug x86
just native-asan Debug x64
native-check:
just native-analyze-all
just native-asan-all
acceptance configuration="Debug" platform="x86":
uv run --all-groups gargoyle-acceptance --configuration {{configuration}} --platform {{platform}}
acceptance-x86 configuration="Debug":
just acceptance {{configuration}} x86
acceptance-x64 configuration="Debug":
just acceptance {{configuration}} x64
acceptance-all:
just acceptance-x86 Debug
just acceptance-x86 Release
just acceptance-x64 Debug
just acceptance-x64 Release
check:
just format-check
@@ -67,4 +113,5 @@ ci:
just sync
just lock-check
just build-all
just native-check
just check
+127 -36
View File
@@ -1,6 +1,11 @@
#include <algorithm>
#include <array>
#include <cstdio>
#include <cstdint>
#include <exception>
#include <fstream>
#include <stdexcept>
#include <string>
#include <tuple>
#include <vector>
@@ -16,10 +21,10 @@ namespace {
constexpr DWORD invocation_interval_ms = 15 * 1000;
constexpr size_t stack_size = 0x10000;
vector<vector<uint8_t>> rop_gadget_candidates = {
constexpr array<array<uint8_t, 3>, 2> rop_gadget_candidates{ {
{ 0x59, 0x5C, 0xC3 }, // pop ecx; pop esp; ret
{ 0x58, 0x5C, 0xC3 } // pop eax; pop esp; ret
};
} };
/// Mirrors the NASM Configuration layout consumed by setup.nasm.
struct SetupConfiguration {
@@ -57,12 +62,58 @@ namespace {
uint8_t stack[stack_size];
StackTrampoline tramp;
};
struct GadgetSelection {
void* address;
string source;
};
string win32_error(const string& operation, DWORD error = GetLastError()) {
LPSTR message = nullptr;
auto chars = FormatMessageA(
FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
nullptr,
error,
MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
reinterpret_cast<LPSTR>(&message),
0,
nullptr
);
string result = operation + " failed (GetLastError=" + to_string(error) + ")";
if (chars && message) {
string formatted{ message, chars };
while (!formatted.empty() && (formatted.back() == '\r' || formatted.back() == '\n')) {
formatted.pop_back();
}
result += ": " + formatted;
}
if (message) {
LocalFree(message);
}
return result;
}
const char* page_protection_name(DWORD protection) {
switch (protection) {
case PAGE_EXECUTE_READ:
return "PAGE_EXECUTE_READ";
case PAGE_EXECUTE_READWRITE:
return "PAGE_EXECUTE_READWRITE";
case PAGE_READONLY:
return "PAGE_READONLY";
case PAGE_READWRITE:
return "PAGE_READWRITE";
default:
return "unknown protection";
}
}
}
/// Allocates the mutable workspace shared with the PIC.
Workspace& allocate_workspace() {
auto result = VirtualAllocEx(GetCurrentProcess(), nullptr, sizeof(Workspace), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!result) throw runtime_error("[-] Couldn't VirtualAllocEx: " + GetLastError());
if (!result) throw runtime_error("[-] " + win32_error("VirtualAllocEx workspace allocation"));
RtlSecureZeroMemory(result, sizeof(Workspace));
return *static_cast<Workspace*>(result);
}
@@ -74,12 +125,17 @@ MyTuple allocate_pic(const string& filename) {
auto pic_size = static_cast<size_t>(file_stream.tellg());
file_stream.seekg(0, fstream::beg);
auto pic = VirtualAllocEx(GetCurrentProcess(), nullptr, pic_size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!pic) throw runtime_error("[-] Couldn't VirtualAllocEx: " + GetLastError());
if (!pic) throw runtime_error("[-] " + win32_error("VirtualAllocEx PIC allocation"));
file_stream.read(static_cast<char*>(pic), pic_size);
if (!file_stream) throw runtime_error("[-] Couldn't read complete PIC from \"" + filename + "\".");
file_stream.close();
DWORD old_protection;
auto prot_result = VirtualProtectEx(GetCurrentProcess(), pic, pic_size, PAGE_EXECUTE_READ, &old_protection);
if (!prot_result) throw runtime_error("[-] Couldn't VirtualProtectEx: " + GetLastError());
if (!prot_result) throw runtime_error("[-] " + win32_error("VirtualProtectEx PIC protection"));
printf("[ ] Protected \"%s\" as %s (old protection 0x%08lx).\n",
filename.c_str(),
page_protection_name(PAGE_EXECUTE_READ),
old_protection);
return MyTuple(pic, pic_size);
}
@@ -87,37 +143,43 @@ MyTuple allocate_pic(const string& filename) {
void* get_system_dll_gadget(const string& system_dll_filename) {
printf("[ ] Loading \"%s\" system DLL.\n", system_dll_filename.c_str());
auto dll_base = reinterpret_cast<uint8_t*>(LoadLibraryA(system_dll_filename.c_str()));
if (!dll_base) throw runtime_error("[-] Couldn't LoadLibrary: " + GetLastError());
if (!dll_base) throw runtime_error("[-] " + win32_error("LoadLibraryA " + system_dll_filename));
printf("[+] Loaded \"%s\" at 0x%p.\n", system_dll_filename.c_str(), dll_base);
printf("[+] Loaded \"%s\" at 0x%p.\n", system_dll_filename.c_str(), static_cast<void*>(dll_base));
auto pe_header = ImageNtHeader(dll_base);
if (!pe_header) throw runtime_error("[-] Couldn't ImageNtHeader: " + GetLastError());
if (!pe_header) throw runtime_error("[-] ImageNtHeader returned null for \"" + system_dll_filename + "\".");
auto filtered_section_headers = vector<PIMAGE_SECTION_HEADER>();
auto section_header = reinterpret_cast<PIMAGE_SECTION_HEADER>(pe_header + 1);
auto executable_section_headers = vector<PIMAGE_SECTION_HEADER>();
auto current_section_header = reinterpret_cast<PIMAGE_SECTION_HEADER>(pe_header + 1);
for (int i = 0; i < pe_header->FileHeader.NumberOfSections; ++i)
{
if (section_header->Characteristics & IMAGE_SCN_MEM_EXECUTE) {
filtered_section_headers.push_back(section_header);
printf("[ ] Found executable section \"%s\" at 0x%p.\n", section_header->Name, dll_base + section_header->VirtualAddress);
if (current_section_header->Characteristics & IMAGE_SCN_MEM_EXECUTE) {
executable_section_headers.push_back(current_section_header);
printf("[ ] Found executable section \"%.*s\" at 0x%p (%lu bytes).\n",
IMAGE_SIZEOF_SHORT_NAME,
reinterpret_cast<const char*>(current_section_header->Name),
static_cast<void*>(dll_base + current_section_header->VirtualAddress),
static_cast<unsigned long>(current_section_header->Misc.VirtualSize));
}
section_header++;
};
current_section_header++;
}
for (auto section_header : filtered_section_headers)
for (auto candidate_section_header : executable_section_headers)
{
for (auto rop_gadget : rop_gadget_candidates)
for (const auto& rop_gadget : rop_gadget_candidates)
{
auto section_base = dll_base + section_header->VirtualAddress;
vector<uint8_t> section_content(section_base, section_base + section_header->Misc.VirtualSize);
auto search_result = search(begin(section_content), end(section_content), begin(rop_gadget), end(rop_gadget));
if (search_result == end(section_content))
continue;
auto section_base = dll_base + candidate_section_header->VirtualAddress;
auto section_end = section_base + candidate_section_header->Misc.VirtualSize;
auto search_result = search(section_base, section_end, begin(rop_gadget), end(rop_gadget));
if (search_result == section_end)
continue;
auto rop_gadget_offset = section_base + (search_result - begin(section_content));
printf("[+] Found ROP gadget in section \"%s\" at 0x%p.\n", section_header->Name, rop_gadget_offset);
return rop_gadget_offset;
printf("[+] Found ROP gadget in section \"%.*s\" at 0x%p.\n",
IMAGE_SIZEOF_SHORT_NAME,
reinterpret_cast<const char*>(candidate_section_header->Name),
static_cast<void*>(search_result));
return search_result;
}
}
@@ -126,18 +188,24 @@ void* get_system_dll_gadget(const string& system_dll_filename) {
}
/// Selects either a system-DLL gadget or the tiny fallback gadget PIC.
void* get_gadget(bool use_system_dll, const string& gadget_system_dll_filename, const string& gadget_pic_path) {
void* memory;
GadgetSelection get_gadget(bool use_system_dll, const string& gadget_system_dll_filename, const string& gadget_pic_path) {
void* memory = nullptr;
if (use_system_dll) {
printf("[ ] Gadget source candidate: system DLL search in \"%s\".\n", gadget_system_dll_filename.c_str());
memory = get_system_dll_gadget(gadget_system_dll_filename);
if (memory) {
return GadgetSelection{ memory, "system DLL: " + gadget_system_dll_filename };
}
}
if (!use_system_dll || !memory) {
printf("[ ] Gadget source candidate: allocated fallback gadget PIC.\n");
printf("[ ] Allocating executable memory for \"%s\".\n", gadget_pic_path.c_str());
size_t size;
tie(memory, size) = allocate_pic(gadget_pic_path);
printf("[+] Allocated %u bytes for gadget PIC.\n", size);
printf("[+] Allocated %zu bytes for gadget PIC.\n", size);
return GadgetSelection{ memory, "allocated fallback PIC: " + gadget_pic_path };
}
return memory;
return GadgetSelection{ memory, "unknown" };
}
/// Wires together the PIC, gadget, trampoline, scratch stack, and demo payload.
@@ -145,18 +213,20 @@ void launch(const string& setup_pic_path, const string& gadget_system_dll_filena
printf("[ ] Allocating executable memory for \"%s\".\n", setup_pic_path.c_str());
void* setup_memory; size_t setup_size;
tie(setup_memory, setup_size) = allocate_pic(setup_pic_path);
printf("[+] Allocated %d bytes for PIC.\n", setup_size);
printf("[+] Allocated %zu bytes for PIC.\n", setup_size);
auto use_system_dll{ true };
printf("[ ] Configuring ROP gadget.\n");
auto gadget_memory = get_gadget(use_system_dll, gadget_system_dll_filename, gadget_pic_path);
auto gadget = get_gadget(use_system_dll, gadget_system_dll_filename, gadget_pic_path);
auto gadget_memory = gadget.address;
printf("[ ] ROP gadget source: %s at 0x%p.\n", gadget.source.c_str(), gadget_memory);
printf("[+] ROP gadget configured.\n");
printf("[ ] Allocating read/write memory for config, stack, and trampoline.\n");
auto& scratch_memory = allocate_workspace();
auto& config = scratch_memory.config;
auto& tramp = scratch_memory.tramp;
printf("[+] Allocated %u bytes for scratch memory.\n", sizeof(scratch_memory));
printf("[+] Allocated %zu bytes for scratch memory.\n", sizeof(scratch_memory));
printf("[ ] Building stack trampoline.\n");
tramp.old_protections_ptr = &tramp.old_protections;
@@ -167,6 +237,12 @@ void launch(const string& setup_pic_path, const string& gadget_system_dll_filena
tramp.address = setup_memory;
tramp.return_address = setup_memory;
tramp.setup_config = &config;
printf("[ ] Stack pivot target: APC gadget pivots to trampoline 0x%p.\n", static_cast<void*>(&tramp));
printf("[ ] APC re-entry protection restore: VirtualProtectEx(0x%p, %zu, %s).\n",
setup_memory,
setup_size,
page_protection_name(tramp.protections));
printf("[ ] Stack trampoline returns to setup PIC at 0x%p after restoring execute permissions.\n", setup_memory);
printf("[+] Stack trampoline built.\n");
printf("[ ] Building configuration.\n");
@@ -180,16 +256,29 @@ void launch(const string& setup_pic_path, const string& gadget_system_dll_filena
config.tramp_addr = &tramp;
config.interval = invocation_interval_ms;
config.target = gadget_memory;
printf("[ ] Timer setup APIs: CreateWaitableTimerW=0x%p, SetWaitableTimer=0x%p.\n",
config.CreateWaitableTimer,
config.SetWaitableTimer);
printf("[ ] APC setup: callback gadget=0x%p, callback argument trampoline=0x%p.\n",
config.target,
config.tramp_addr);
printf("[ ] Alertable wait: WaitForSingleObjectEx=0x%p, timer period=%lu ms.\n",
config.WaitForSingleObjectEx,
invocation_interval_ms);
printf("[ ] PIC protection cycle: %s -> %s while waiting -> %s on APC re-entry.\n",
page_protection_name(PAGE_EXECUTE_READ),
page_protection_name(PAGE_READONLY),
page_protection_name(PAGE_EXECUTE_READ));
printf("[+] Configuration built.\n");
printf("[+] Success!\n");
printf(" ================================\n");
printf(" Gargoyle PIC @ -----> 0x%p\n", setup_memory);
printf(" ROP gadget @ -------> 0x%p\n", gadget_memory);
printf(" Configuration @ ----> 0x%p\n", &scratch_memory.config);
printf(" Top of stack @ -----> 0x%p\n", &scratch_memory.stack);
printf(" Bottom of stack @ --> 0x%p\n", &scratch_memory.stack[stack_size-1]);
printf(" Stack trampoline @ -> 0x%p\n", &scratch_memory.tramp);
printf(" Configuration @ ----> 0x%p\n", static_cast<void*>(&scratch_memory.config));
printf(" Top of stack @ -----> 0x%p\n", static_cast<void*>(&scratch_memory.stack));
printf(" Bottom of stack @ --> 0x%p\n", static_cast<void*>(&scratch_memory.stack[stack_size-1]));
printf(" Stack trampoline @ -> 0x%p\n", static_cast<void*>(&scratch_memory.tramp));
reinterpret_cast<callable>(setup_memory)(&config);
}
@@ -200,5 +289,7 @@ int main() {
launch("setup.pic", "mshtml.dll", "gadget.pic");
} catch (exception& e) {
printf("%s\n", e.what());
return 1;
}
return 0;
}
+6
View File
@@ -34,4 +34,10 @@ markdown_extensions:
nav:
- Home: index.md
- Acceptance Harness: acceptance.md
- Validation Checklist: validation-checklist.md
- Win32 Architecture: win32-architecture.md
- x64 Architecture: x64-architecture.md
- Responsible Use: responsible-use.md
- Future Work: future-work.md
- References: references.md
- Python API: api.md
+4 -2
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from os import environ, pathsep
from pathlib import Path
from gargoyle_acceptance.environment import Configuration, Toolchain
from gargoyle_acceptance.environment import Configuration, Platform, Toolchain
from gargoyle_acceptance.errors import AcceptanceError
@@ -27,6 +27,7 @@ class BuildResult:
def build_solution(
repo_root: Path,
configuration: Configuration,
platform: Platform,
toolchain: Toolchain,
*,
timeout_seconds: float = 120.0,
@@ -36,6 +37,7 @@ def build_solution(
Args:
repo_root: Repository root containing `Gargoyle.sln`.
configuration: Visual Studio configuration to build.
platform: Visual Studio solution platform to build.
toolchain: Resolved toolchain paths.
timeout_seconds: Maximum time allowed for MSBuild.
@@ -49,7 +51,7 @@ def build_solution(
str(toolchain.msbuild),
"Gargoyle.sln",
f"/p:Configuration={configuration}",
"/p:Platform=x86",
f"/p:Platform={platform}",
"/m",
)
try:
+16 -1
View File
@@ -10,7 +10,11 @@ from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from gargoyle_acceptance.environment import coerce_optional_path, parse_configuration
from gargoyle_acceptance.environment import (
coerce_optional_path,
parse_configuration,
parse_platform,
)
from gargoyle_acceptance.errors import AcceptanceError
from gargoyle_acceptance.harness import AcceptanceReport, run_acceptance
@@ -39,6 +43,14 @@ def acceptance(
help="Repository root. Defaults to upward discovery from the current directory.",
),
] = None,
platform: Annotated[
str,
typer.Option(
"--platform",
"-p",
help="Visual Studio solution platform to build and run.",
),
] = "x86",
msbuild: Annotated[
Path | None,
typer.Option("--msbuild", help="Optional full path to MSBuild.exe."),
@@ -64,6 +76,7 @@ def acceptance(
Args:
configuration: Visual Studio configuration to build and run.
repo_root: Optional repository root.
platform: Visual Studio solution platform to build and run.
msbuild: Optional MSBuild path.
skip_build: Whether to skip the build step.
rounds: Number of MessageBox rounds to validate.
@@ -75,6 +88,7 @@ def acceptance(
try:
report = run_acceptance(
configuration=parse_configuration(configuration),
platform=parse_platform(platform),
repo_root=coerce_optional_path(repo_root),
msbuild=coerce_optional_path(msbuild),
skip_build=skip_build,
@@ -109,6 +123,7 @@ def _render_success(report: AcceptanceReport) -> None:
table.add_column("Check", style="bold")
table.add_column("Value")
table.add_row("Configuration", report.artifacts.configuration)
table.add_row("Platform", report.artifacts.platform)
table.add_row("Executable", str(report.artifacts.executable))
table.add_row("MessageBox rounds", str(report.message_box_rounds))
table.add_row("Setup lines", str(len(report.setup.lines)))
+143 -21
View File
@@ -14,27 +14,33 @@ from gargoyle_acceptance.errors import AcceptanceError
Configuration = Literal["Debug", "Release"]
VALID_CONFIGURATIONS: tuple[Configuration, ...] = ("Debug", "Release")
Platform = Literal["x86", "x64"]
VALID_PLATFORMS: tuple[Platform, ...] = ("x86", "x64")
@dataclass(frozen=True, slots=True)
class GargoyleArtifacts:
"""Paths produced by a Gargoyle Win32 build.
"""Paths produced by a Gargoyle native build.
Attributes:
repo_root: Repository root containing `Gargoyle.sln`.
configuration: Visual Studio configuration name.
platform: Visual Studio solution platform.
output_dir: Directory containing the executable and PIC blobs.
executable: Built `Gargoyle.exe`.
setup_pic: Built `setup.pic`.
gadget_pic: Built `gadget.pic`.
executable: Built executable.
setup_pic: Built setup PIC.
gadget_pic: Optional Win32 stack-pivot gadget PIC.
reentry_pic: Optional x64 wait/re-entry PIC.
"""
repo_root: Path
configuration: Configuration
platform: Platform
output_dir: Path
executable: Path
setup_pic: Path
gadget_pic: Path
gadget_pic: Path | None = None
reentry_pic: Path | None = None
@dataclass(frozen=True, slots=True)
@@ -73,6 +79,29 @@ def parse_configuration(value: str) -> Configuration:
)
def parse_platform(value: str) -> Platform:
"""Normalize a Visual Studio solution platform option.
Args:
value: User-provided platform name.
Returns:
A supported platform literal.
Raises:
AcceptanceError: If the platform is not supported.
"""
normalized = value.strip()
for platform_name in VALID_PLATFORMS:
if normalized.lower() == platform_name.lower():
return platform_name
raise AcceptanceError(
"Unsupported platform",
f"Expected one of {', '.join(VALID_PLATFORMS)}, got {value!r}.",
"Use --platform x86 or --platform x64.",
)
def require_windows(system_name: str | None = None) -> None:
"""Require the current platform to be Windows.
@@ -116,25 +145,26 @@ def resolve_repo_root(start: Path | None = None) -> Path:
)
def artifacts_for(repo_root: Path, configuration: Configuration) -> GargoyleArtifacts:
def artifacts_for(
repo_root: Path,
configuration: Configuration,
platform_name: Platform = "x86",
) -> GargoyleArtifacts:
"""Compute expected build outputs for a configuration.
Args:
repo_root: Repository root.
configuration: Visual Studio configuration.
platform_name: Visual Studio solution platform.
Returns:
Expected artifact paths.
"""
output_dir = repo_root / configuration
return GargoyleArtifacts(
repo_root=repo_root,
configuration=configuration,
output_dir=output_dir,
executable=output_dir / "Gargoyle.exe",
setup_pic=output_dir / "setup.pic",
gadget_pic=output_dir / "gadget.pic",
)
candidates = _artifact_candidates(repo_root, configuration, platform_name)
for candidate in candidates:
if all(path.is_file() for path in _required_artifact_paths(candidate)):
return candidate
return candidates[0]
def verify_artifacts(artifacts: GargoyleArtifacts) -> None:
@@ -146,20 +176,112 @@ def verify_artifacts(artifacts: GargoyleArtifacts) -> None:
Raises:
AcceptanceError: If any expected artifact is missing.
"""
missing = [
path
for path in (artifacts.executable, artifacts.setup_pic, artifacts.gadget_pic)
if not path.is_file()
]
missing = [path for path in _required_artifact_paths(artifacts) if not path.is_file()]
if missing:
formatted = "\n".join(f"- {path}" for path in missing)
raise AcceptanceError(
"Build artifacts missing",
f"The {artifacts.configuration}|x86 output is incomplete:\n{formatted}",
(
f"The {artifacts.configuration}|{artifacts.platform} output is incomplete:\n"
f"{formatted}"
),
"Build the configuration first or run without --skip-build.",
)
def _artifact_candidates(
repo_root: Path,
configuration: Configuration,
platform_name: Platform,
) -> tuple[GargoyleArtifacts, ...]:
"""Return likely Visual Studio output locations.
Args:
repo_root: Repository root.
configuration: Visual Studio configuration.
platform_name: Visual Studio solution platform.
Returns:
Candidate artifact layouts in preference order.
"""
if platform_name == "x86":
return (
_x86_artifacts(repo_root, configuration, repo_root / configuration),
_x86_artifacts(repo_root, configuration, repo_root / "Win32" / configuration),
_x86_artifacts(repo_root, configuration, repo_root / "x86" / configuration),
)
return (
_x64_artifacts(repo_root, configuration, repo_root / "x64" / configuration),
_x64_artifacts(repo_root, configuration, repo_root / "GargoyleX64" / "x64" / configuration),
_x64_artifacts(repo_root, configuration, repo_root / "GargoyleX64" / configuration),
)
def _x86_artifacts(
repo_root: Path, configuration: Configuration, output_dir: Path
) -> GargoyleArtifacts:
"""Create a Win32 artifact layout.
Args:
repo_root: Repository root.
configuration: Visual Studio configuration.
output_dir: Candidate output directory.
Returns:
Win32 artifact paths.
"""
return GargoyleArtifacts(
repo_root=repo_root,
configuration=configuration,
platform="x86",
output_dir=output_dir,
executable=output_dir / "Gargoyle.exe",
setup_pic=output_dir / "setup.pic",
gadget_pic=output_dir / "gadget.pic",
)
def _x64_artifacts(
repo_root: Path, configuration: Configuration, output_dir: Path
) -> GargoyleArtifacts:
"""Create an x64 artifact layout.
Args:
repo_root: Repository root.
configuration: Visual Studio configuration.
output_dir: Candidate output directory.
Returns:
x64 artifact paths.
"""
return GargoyleArtifacts(
repo_root=repo_root,
configuration=configuration,
platform="x64",
output_dir=output_dir,
executable=output_dir / "GargoyleX64.exe",
setup_pic=output_dir / "setup_x64.pic",
reentry_pic=output_dir / "reentry_x64.pic",
)
def _required_artifact_paths(artifacts: GargoyleArtifacts) -> tuple[Path, ...]:
"""Return non-optional artifact paths for a build.
Args:
artifacts: Candidate artifact layout.
Returns:
Required paths.
"""
paths = [artifacts.executable, artifacts.setup_pic]
if artifacts.gadget_pic is not None:
paths.append(artifacts.gadget_pic)
if artifacts.reentry_pic is not None:
paths.append(artifacts.reentry_pic)
return tuple(paths)
def resolve_msbuild(explicit: Path | None = None) -> Path:
"""Resolve MSBuild from an explicit path, PATH, or common Visual Studio installs.
+52 -14
View File
@@ -14,6 +14,7 @@ from gargoyle_acceptance.build import BuildResult, build_solution
from gargoyle_acceptance.environment import (
Configuration,
GargoyleArtifacts,
Platform,
artifacts_for,
require_windows,
resolve_repo_root,
@@ -38,6 +39,25 @@ ADDRESS_LABELS = (
"Bottom of stack",
"Stack trampoline",
)
X64_EXPECTED_MARKERS = (
"[+] x64 timer/APC prototype configured.",
"[ ] Entering benign x64 PIC payload loop.",
)
X64_ADDRESS_LABELS = (
"Gargoyle x64 PIC",
"x64 re-entry PIC",
"x64 APC callback",
"Configuration",
"VirtualProtectEx",
"WaitForSingleObjectEx",
"CreateWaitableTimerW",
"SetWaitableTimer",
"MessageBoxA",
)
MESSAGE_BOX_TITLES: dict[Platform, str] = {
"x86": "gargoyle",
"x64": "gargoyle x64",
}
@dataclass(frozen=True, slots=True)
@@ -108,6 +128,7 @@ class LineReader:
def run_acceptance(
*,
configuration: Configuration,
platform: Platform = "x86",
repo_root: Path | None = None,
msbuild: Path | None = None,
skip_build: bool = False,
@@ -118,6 +139,7 @@ def run_acceptance(
Args:
configuration: Visual Studio configuration to build and run.
platform: Visual Studio solution platform to build and run.
repo_root: Optional repository root. Defaults to upward discovery.
msbuild: Optional explicit MSBuild path.
skip_build: Whether to skip the MSBuild step.
@@ -140,18 +162,19 @@ def run_acceptance(
root = resolve_repo_root(repo_root)
toolchain = resolve_toolchain(msbuild)
build = None if skip_build else build_solution(root, configuration, toolchain)
artifacts = artifacts_for(root, configuration)
build = None if skip_build else build_solution(root, configuration, platform, toolchain)
artifacts = artifacts_for(root, configuration, platform)
verify_artifacts(artifacts)
process = _start_gargoyle(artifacts)
controller = MessageBoxController()
try:
setup = _wait_for_setup(process, timeout_seconds=timeout_seconds)
setup = _wait_for_setup(process, platform=platform, timeout_seconds=timeout_seconds)
closed_rounds = _close_message_boxes(
process=process,
controller=controller,
rounds=rounds,
title=MESSAGE_BOX_TITLES[platform],
timeout_seconds=timeout_seconds,
)
finally:
@@ -164,11 +187,15 @@ def run_acceptance(
)
def parse_setup_output(lines: list[str] | tuple[str, ...]) -> SetupObservation:
def parse_setup_output(
lines: list[str] | tuple[str, ...],
platform: Platform = "x86",
) -> SetupObservation:
"""Parse and validate Gargoyle's setup banner.
Args:
lines: Captured process output.
platform: Platform-specific banner format.
Returns:
Parsed setup evidence.
@@ -176,9 +203,11 @@ def parse_setup_output(lines: list[str] | tuple[str, ...]) -> SetupObservation:
Raises:
AcceptanceError: If required markers or addresses are missing.
"""
missing_markers = [marker for marker in EXPECTED_MARKERS if marker not in lines]
addresses = _parse_addresses(lines)
missing_addresses = [label for label in ADDRESS_LABELS if label not in addresses]
expected_markers = X64_EXPECTED_MARKERS if platform == "x64" else EXPECTED_MARKERS
address_labels = X64_ADDRESS_LABELS if platform == "x64" else ADDRESS_LABELS
missing_markers = [marker for marker in expected_markers if marker not in lines]
addresses = _parse_addresses(lines, address_labels)
missing_addresses = [label for label in address_labels if label not in addresses]
if missing_markers or missing_addresses:
problems = []
if missing_markers:
@@ -200,17 +229,18 @@ def parse_setup_output(lines: list[str] | tuple[str, ...]) -> SetupObservation:
return SetupObservation(lines=tuple(lines), addresses=addresses)
def setup_banner_complete(lines: list[str]) -> bool:
def setup_banner_complete(lines: list[str], platform: Platform = "x86") -> bool:
"""Return whether captured lines contain a complete setup banner.
Args:
lines: Captured process output.
platform: Platform-specific banner format.
Returns:
`True` when the setup banner can be parsed successfully.
"""
try:
parse_setup_output(lines)
parse_setup_output(lines, platform)
except AcceptanceError:
return False
return True
@@ -250,12 +280,14 @@ def _start_gargoyle(artifacts: GargoyleArtifacts) -> subprocess.Popen[str]:
def _wait_for_setup(
process: subprocess.Popen[str],
*,
platform: Platform,
timeout_seconds: float,
) -> SetupObservation:
"""Wait for Gargoyle to print a complete setup banner.
Args:
process: Running Gargoyle process.
platform: Platform-specific banner format.
timeout_seconds: Maximum time to wait.
Returns:
@@ -288,8 +320,8 @@ def _wait_for_setup(
if line is None:
continue
lines.append(line)
if setup_banner_complete(lines):
return parse_setup_output(lines)
if setup_banner_complete(lines, platform):
return parse_setup_output(lines, platform)
raise AcceptanceError(
"Setup timed out",
f"Gargoyle did not print a complete setup banner within {timeout_seconds:.0f} seconds.\n"
@@ -303,6 +335,7 @@ def _close_message_boxes(
process: subprocess.Popen[str],
controller: MessageBoxController,
rounds: int,
title: str,
timeout_seconds: float,
) -> int:
"""Close the benign MessageBox payload for a number of rounds.
@@ -311,6 +344,7 @@ def _close_message_boxes(
process: Running Gargoyle process.
controller: Window controller used to find and close windows.
rounds: Number of payload windows to close.
title: MessageBox title to wait for.
timeout_seconds: Maximum wait per round.
Returns:
@@ -332,7 +366,7 @@ def _close_message_boxes(
)
hwnd = controller.wait_for_message_box(
pid=process.pid,
title="gargoyle",
title=title,
timeout_seconds=timeout_seconds,
)
controller.close_window(hwnd)
@@ -357,18 +391,22 @@ def _terminate_process(process: subprocess.Popen[str]) -> None:
process.wait(timeout=5)
def _parse_addresses(lines: list[str] | tuple[str, ...]) -> dict[str, int]:
def _parse_addresses(
lines: list[str] | tuple[str, ...],
address_labels: tuple[str, ...],
) -> dict[str, int]:
"""Parse address lines from Gargoyle's setup banner.
Args:
lines: Captured process output.
address_labels: Address labels expected for the platform.
Returns:
Address values keyed by banner label.
"""
addresses: dict[str, int] = {}
for line in lines:
for label in ADDRESS_LABELS:
for label in address_labels:
prefix = f"{label} @"
if line.strip().startswith(prefix) and "0x" in line:
raw_address = line.rsplit("0x", maxsplit=1)[-1].strip()
+4 -4
View File
@@ -16,7 +16,7 @@ def test_build_solution_uses_expected_msbuild_command(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The build command stays narrow: configuration, x86 platform, and parallel build."""
"""The build command stays narrow: configuration, platform, and parallel build."""
seen: dict[str, object] = {}
def fake_run(command: tuple[str, ...], **kwargs: object) -> subprocess.CompletedProcess[str]:
@@ -27,7 +27,7 @@ def test_build_solution_uses_expected_msbuild_command(
monkeypatch.setattr(build.subprocess, "run", fake_run)
toolchain = Toolchain(msbuild=Path("MSBuild.exe"), nasm=Path("nasm.exe"))
result = build.build_solution(tmp_path, "Debug", toolchain)
result = build.build_solution(tmp_path, "Debug", "x86", toolchain)
assert result.command == (
"MSBuild.exe",
@@ -64,7 +64,7 @@ def test_build_solution_raises_on_failure(
toolchain = Toolchain(msbuild=Path("MSBuild.exe"), nasm=Path("nasm.exe"))
with pytest.raises(AcceptanceError, match="Build failed"):
build.build_solution(tmp_path, "Release", toolchain)
build.build_solution(tmp_path, "Release", "x64", toolchain)
def test_build_solution_raises_on_timeout(
@@ -80,4 +80,4 @@ def test_build_solution_raises_on_timeout(
toolchain = Toolchain(msbuild=Path("MSBuild.exe"), nasm=Path("nasm.exe"))
with pytest.raises(AcceptanceError, match="Build timed out"):
build.build_solution(tmp_path, "Debug", toolchain, timeout_seconds=1)
build.build_solution(tmp_path, "Debug", "x86", toolchain, timeout_seconds=1)
+1
View File
@@ -16,6 +16,7 @@ def _report(tmp_path: Path) -> AcceptanceReport:
artifacts = GargoyleArtifacts(
repo_root=tmp_path,
configuration="Debug",
platform="x86",
output_dir=tmp_path / "Debug",
executable=tmp_path / "Debug" / "Gargoyle.exe",
setup_pic=tmp_path / "Debug" / "setup.pic",
+38
View File
@@ -11,6 +11,7 @@ from gargoyle_acceptance.environment import (
artifacts_for,
coerce_optional_path,
parse_configuration,
parse_platform,
require_windows,
resolve_msbuild,
resolve_nasm,
@@ -33,6 +34,18 @@ def test_parse_configuration_rejects_unknown_value() -> None:
parse_configuration("x64")
def test_parse_platform_accepts_supported_values() -> None:
"""Platform parsing accepts the two supported Visual Studio solution platforms."""
assert parse_platform("x86") == "x86"
assert parse_platform("X64") == "x64"
def test_parse_platform_rejects_unknown_value() -> None:
"""Platform parsing gives a clear error for unsupported names."""
with pytest.raises(AcceptanceError, match="Unsupported platform"):
parse_platform("arm64")
def test_require_windows_rejects_non_windows() -> None:
"""Platform validation rejects non-Windows systems."""
with pytest.raises(AcceptanceError, match="Windows required"):
@@ -94,9 +107,34 @@ def test_artifacts_for_uses_configuration_output_directory(tmp_path: Path) -> No
artifacts = artifacts_for(tmp_path, "Debug")
assert artifacts.output_dir == tmp_path / "Debug"
assert artifacts.platform == "x86"
assert artifacts.executable == tmp_path / "Debug" / "Gargoyle.exe"
def test_artifacts_for_uses_x64_default_output_directory(tmp_path: Path) -> None:
"""x64 artifact paths match the root solution's Visual Studio default output."""
artifacts = artifacts_for(tmp_path, "Release", "x64")
assert artifacts.output_dir == tmp_path / "x64" / "Release"
assert artifacts.platform == "x64"
assert artifacts.executable == tmp_path / "x64" / "Release" / "GargoyleX64.exe"
assert artifacts.setup_pic == tmp_path / "x64" / "Release" / "setup_x64.pic"
assert artifacts.reentry_pic == tmp_path / "x64" / "Release" / "reentry_x64.pic"
def test_artifacts_for_selects_existing_candidate(tmp_path: Path) -> None:
"""Artifact discovery accepts an alternate VS output candidate when files exist there."""
output_dir = tmp_path / "GargoyleX64" / "Debug"
output_dir.mkdir(parents=True)
(output_dir / "GargoyleX64.exe").write_text("")
(output_dir / "setup_x64.pic").write_text("")
(output_dir / "reentry_x64.pic").write_text("")
artifacts = artifacts_for(tmp_path, "Debug", "x64")
assert artifacts.output_dir == output_dir
def test_resolve_msbuild_accepts_explicit_path(tmp_path: Path) -> None:
"""MSBuild resolution accepts an explicit executable path."""
msbuild = tmp_path / "MSBuild.exe"
+40 -7
View File
@@ -28,6 +28,24 @@ VALID_OUTPUT = [
" Bottom of stack @ --> 0x00A80037",
" Stack trampoline @ -> 0x00A80038",
]
VALID_X64_OUTPUT = [
'[ ] Loading x64 setup PIC from "setup_x64.pic".',
"[+] Loaded 113 bytes of x64 PIC.",
'[ ] Loading x64 re-entry PIC from "reentry_x64.pic".',
"[+] Loaded 144 bytes of x64 re-entry PIC.",
"[+] x64 timer/APC prototype configured.",
" Gargoyle x64 PIC @ ----> 0x000001827D9B0000",
" x64 re-entry PIC @ ----> 0x000001827D9C0000",
" x64 APC callback @ ---> 0x000001827D9C0010",
" Configuration @ -------> 0x00000057D1DAF658",
" VirtualProtectEx @ ----> 0x00007FFD49A72340",
" WaitForSingleObjectEx @ 0x00007FFD49A71230",
" CreateWaitableTimerW @ 0x00007FFD49A70000",
" SetWaitableTimer @ ---> 0x00007FFD49A71111",
" MessageBoxA @ --------> 0x00007FFD4B30CAC0",
" Timer period @ -------> 15000 ms",
"[ ] Entering benign x64 PIC payload loop.",
]
class FakeProcess:
@@ -76,6 +94,14 @@ def test_parse_setup_output_accepts_complete_banner() -> None:
assert parsed.addresses["ROP gadget"] == 0x6AE93472
def test_parse_setup_output_accepts_x64_complete_banner() -> None:
"""Setup parsing accepts the x64 timer/APC banner."""
parsed = harness.parse_setup_output(VALID_X64_OUTPUT, "x64")
assert parsed.addresses["Gargoyle x64 PIC"] == 0x000001827D9B0000
assert parsed.addresses["x64 APC callback"] == 0x000001827D9C0010
def test_parse_setup_output_rejects_missing_marker() -> None:
"""Setup parsing identifies an incomplete setup chain."""
with pytest.raises(AcceptanceError, match="Setup banner incomplete"):
@@ -112,6 +138,7 @@ def test_run_acceptance_orchestrates_build_and_runtime(
artifacts = GargoyleArtifacts(
repo_root=tmp_path,
configuration="Debug",
platform="x86",
output_dir=tmp_path / "Debug",
executable=tmp_path / "Debug" / "Gargoyle.exe",
setup_pic=tmp_path / "Debug" / "setup.pic",
@@ -130,16 +157,19 @@ def test_run_acceptance_orchestrates_build_and_runtime(
monkeypatch.setattr(
harness,
"build_solution",
lambda root, configuration, toolchain: BuildResult(command=("MSBuild.exe",), output="ok"),
lambda root, configuration, platform, toolchain: BuildResult(
command=("MSBuild.exe",),
output="ok",
),
)
monkeypatch.setattr(harness, "artifacts_for", lambda root, configuration: artifacts)
monkeypatch.setattr(harness, "artifacts_for", lambda root, configuration, platform: artifacts)
monkeypatch.setattr(harness, "verify_artifacts", lambda value: calls.append("artifacts"))
monkeypatch.setattr(harness, "_start_gargoyle", lambda value: process)
monkeypatch.setattr(harness, "MessageBoxController", object)
monkeypatch.setattr(
harness,
"_wait_for_setup",
lambda proc, timeout_seconds: harness.parse_setup_output(VALID_OUTPUT),
lambda proc, platform, timeout_seconds: harness.parse_setup_output(VALID_OUTPUT),
)
monkeypatch.setattr(
harness,
@@ -167,7 +197,7 @@ def test_wait_for_setup_reads_complete_banner() -> None:
"""The setup waiter parses complete stdout from a live process."""
process = FakeLiveProcess(StringIO("\n".join(VALID_OUTPUT) + "\n"))
setup = harness._wait_for_setup(process, timeout_seconds=1) # type: ignore[arg-type]
setup = harness._wait_for_setup(process, platform="x86", timeout_seconds=1) # type: ignore[arg-type]
assert setup.addresses["Stack trampoline"] == 0x00A80038
@@ -178,7 +208,7 @@ def test_wait_for_setup_reports_early_exit() -> None:
process.returncode = 1
with pytest.raises(AcceptanceError, match="exited early"):
harness._wait_for_setup(process, timeout_seconds=1) # type: ignore[arg-type]
harness._wait_for_setup(process, platform="x86", timeout_seconds=1) # type: ignore[arg-type]
def test_wait_for_setup_reports_missing_stdout() -> None:
@@ -187,7 +217,7 @@ def test_wait_for_setup_reports_missing_stdout() -> None:
process.stdout = None
with pytest.raises(AcceptanceError, match="stdout unavailable"):
harness._wait_for_setup(process, timeout_seconds=1) # type: ignore[arg-type]
harness._wait_for_setup(process, platform="x86", timeout_seconds=1) # type: ignore[arg-type]
def test_wait_for_setup_reports_timeout() -> None:
@@ -195,7 +225,7 @@ def test_wait_for_setup_reports_timeout() -> None:
process = FakeLiveProcess(StringIO("partial\n"))
with pytest.raises(AcceptanceError, match="Setup timed out"):
harness._wait_for_setup(process, timeout_seconds=0.01) # type: ignore[arg-type]
harness._wait_for_setup(process, platform="x86", timeout_seconds=0.01) # type: ignore[arg-type]
def test_start_gargoyle_reports_launch_failure(
@@ -206,6 +236,7 @@ def test_start_gargoyle_reports_launch_failure(
artifacts = GargoyleArtifacts(
repo_root=tmp_path,
configuration="Debug",
platform="x86",
output_dir=tmp_path,
executable=tmp_path / "Gargoyle.exe",
setup_pic=tmp_path / "setup.pic",
@@ -247,6 +278,7 @@ def test_close_message_boxes_closes_requested_rounds() -> None:
process=FakeProcess(), # type: ignore[arg-type]
controller=controller, # type: ignore[arg-type]
rounds=2,
title="gargoyle",
timeout_seconds=1,
)
@@ -298,5 +330,6 @@ def test_close_message_boxes_reports_early_exit() -> None:
process=process, # type: ignore[arg-type]
controller=object(), # type: ignore[arg-type]
rounds=1,
title="gargoyle",
timeout_seconds=1,
)