Harden CI digest parsing and failure cleanup

This commit is contained in:
nefarius committed 2026-10-26 15:04:22 +01:00
1 parent 7985b23be5
commit 195b9fccf3
6 files changed
+492 -239

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Code Integrity regression tests
From an **x64 Visual Studio developer shell**, with the Windows SDK and Python 3:
```powershell
python tests\run-digest-tests.py
```
The runner extracts the production digest, pattern-search, algorithm-mapping,
and legacy-validation functions without changing their bodies. The C++ harness
uses real user-mode BCrypt hashing and test doubles for pool allocation, file
reads, and CI. No driver is installed or loaded. Build outputs are temporary.
Coverage includes PE32/PE32+ digest fixtures, checksum and certificate exclusions,
a DOS stub larger than 4 MiB, truncated headers next to inaccessible guard pages,
invalid offsets and lengths, absent security-directory entries, injected hashing
and allocation failures, deterministic failure outputs, allocation ownership,
and rejection of successful-but-short certificate reads before invoking CI.
The tests do not validate CI trust policy, asynchronous kernel I/O behavior, or
general Authenticode hashing of unusual section layouts. The digest implementation
still uses its existing linear byte-order convention and heuristic signature
algorithm detection; replacing those requires a separate compatibility corpus.
+259
View File
@@ -0,0 +1,259 @@
// User-mode regression harness. The runner extracts the production functions
// unchanged; pool, file-I/O, and CI calls are test doubles. BCrypt is real CNG.
#define WIN32_NO_STATUS
#include <windows.h>
#undef WIN32_NO_STATUS
#include <ntstatus.h>
#include <bcrypt.h>
#include <wintrust.h>
#include <cstdio>
#include <cstdlib>
#include <vector>
#include <cstring>
#define NT_SUCCESS(s) (((NTSTATUS)(s)) >= 0)
#define ASSERT(x) Check(!!(x), #x)
#define DOMITO_POOL_TAG 0x446f6d69
#define NonPagedPoolNx 0
#define PagedPool 1
#define PAGED_CODE() ((void)0)
#define MINCRYPT_SHA1_LENGTH 20
#define MINCRYPT_SHA256_LENGTH 32
typedef const UCHAR* PCUCHAR;
static unsigned checks, allocations, ciCalls, hashCalls;
static int failOperation, failHashCall;
static ULONG_PTR bytesRead;
static void Check(bool ok, const char* message)
{
++checks;
if (!ok) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
}
static PVOID ExAllocatePoolWithTag(int, SIZE_T size, ULONG)
{
if (failOperation == 4) return nullptr;
void* p = HeapAlloc(GetProcessHeap(), 0, size);
if (p) ++allocations;
return p;
}
static void ExFreePoolWithTag(PVOID p, ULONG)
{
Check(p != nullptr && allocations != 0, "allocation ownership");
--allocations;
Check(HeapFree(GetProcessHeap(), 0, p) != FALSE, "heap free");
}
static NTSTATUS TestCreateHash(BCRYPT_ALG_HANDLE a, BCRYPT_HASH_HANDLE* h,
PUCHAR o, ULONG os, PUCHAR s, ULONG ss, ULONG f)
{
if (failOperation == 1) return STATUS_UNSUCCESSFUL;
return BCryptCreateHash(a, h, o, os, s, ss, f);
}
static NTSTATUS TestHashData(BCRYPT_HASH_HANDLE h, PUCHAR b, ULONG n, ULONG f)
{
++hashCalls;
if (failOperation == 2 && hashCalls == (unsigned)failHashCall) return STATUS_UNSUCCESSFUL;
return BCryptHashData(h, b, n, f);
}
static NTSTATUS TestFinishHash(BCRYPT_HASH_HANDLE h, PUCHAR b, ULONG n, ULONG f)
{
if (failOperation == 3) return STATUS_UNSUCCESSFUL;
return BCryptFinishHash(h, b, n, f);
}
// Minimal dependencies for the legacy wrapper; no actual CI code is invoked.
struct MINCRYPT_POLICY_INFO { ULONG Size; };
struct KAPC_STATE { ULONG unused; };
struct IO_STATUS_BLOCK { NTSTATUS Status; ULONG_PTR Information; };
static PVOID PsInitialSystemProcess = nullptr;
static void CiFreePolicyInfo(MINCRYPT_POLICY_INFO* p) { p->Size = 0; }
static void KeStackAttachProcess(PVOID, KAPC_STATE*) {}
static void KeUnstackDetachProcess(KAPC_STATE*) {}
static NTSTATUS ZwReadFile(HANDLE, PVOID, PVOID, PVOID, IO_STATUS_BLOCK* io,
PVOID buffer, ULONG size, LARGE_INTEGER*, PVOID)
{
io->Status = STATUS_SUCCESS;
io->Information = bytesRead;
std::memset(buffer, 0, (size_t)min(bytesRead, (ULONG_PTR)size));
return STATUS_SUCCESS;
}
static NTSTATUS ZwWaitForSingleObject(HANDLE, BOOLEAN, PVOID) { return STATUS_SUCCESS; }
static NTSTATUS CiCheckSignedFile(PVOID, UINT32, ALG_ID, PVOID, UINT32,
MINCRYPT_POLICY_INFO*, LARGE_INTEGER*, MINCRYPT_POLICY_INFO*)
{
++ciCalls;
return STATUS_SUCCESS;
}
static NTSTATUS CiVerifyHashInCatalog(PVOID, UINT32, ALG_ID, BOOLEAN, UINT32, UINT32,
MINCRYPT_POLICY_INFO*, PVOID, LARGE_INTEGER*, MINCRYPT_POLICY_INFO*)
{
++ciCalls;
return STATUS_SUCCESS;
}
#define BCryptCreateHash TestCreateHash
#define BCryptHashData TestHashData
#define BCryptFinishHash TestFinishHash
#include "digest-under-test.inc"
#undef BCryptCreateHash
#undef BCryptHashData
#undef BCryptFinishHash
static std::vector<UCHAR> Image(bool pe64, ULONG nt = 128, bool certificate = false)
{
std::vector<UCHAR> b((size_t)nt + 896 + (certificate ? 32 : 0), 0);
IMAGE_DOS_HEADER dos = {};
dos.e_magic = IMAGE_DOS_SIGNATURE; dos.e_lfanew = (LONG)nt;
std::memcpy(b.data(), &dos, sizeof(dos));
ULONG signature = IMAGE_NT_SIGNATURE;
std::memcpy(b.data() + nt, &signature, sizeof(signature));
IMAGE_FILE_HEADER f = {};
f.NumberOfSections = 1;
f.SizeOfOptionalHeader = pe64 ? sizeof(IMAGE_OPTIONAL_HEADER64) : sizeof(IMAGE_OPTIONAL_HEADER32);
std::memcpy(b.data() + nt + 4, &f, sizeof(f));
IMAGE_OPTIONAL_HEADER64 h64 = {};
IMAGE_OPTIONAL_HEADER32 h32 = {};
h64.Magic = IMAGE_NT_OPTIONAL_HDR64_MAGIC; h32.Magic = IMAGE_NT_OPTIONAL_HDR32_MAGIC;
h64.NumberOfRvaAndSizes = h32.NumberOfRvaAndSizes = 16;
h64.SizeOfHeaders = h32.SizeOfHeaders = nt + 384;
if (certificate)
{
h64.DataDirectory[4] = h32.DataDirectory[4] = { nt + 896, 32 };
ULONG length = 32;
std::memcpy(b.data() + nt + 896, &length, sizeof(length));
}
std::memcpy(b.data() + nt + 24, pe64 ? (void*)&h64 : (void*)&h32, f.SizeOfOptionalHeader);
IMAGE_SECTION_HEADER section = {};
section.PointerToRawData = nt + 384; section.SizeOfRawData = 512;
std::memcpy(b.data() + nt + 24 + f.SizeOfOptionalHeader, &section, sizeof(section));
for (ULONG i = nt + 384; i < nt + 896; ++i) b[i] = (UCHAR)(i * 31);
return b;
}
static void Set32(std::vector<UCHAR>& b, size_t at, ULONG value)
{
std::memcpy(b.data() + at, &value, sizeof(value));
}
static void Set16(std::vector<UCHAR>& b, size_t at, USHORT value)
{
std::memcpy(b.data() + at, &value, sizeof(value));
}
static std::vector<UCHAR> Digest(PUCHAR b, ULONG size, NTSTATUS expected = STATUS_SUCCESS)
{
UINT32 algorithm = 0xdead;
ULONG length = 0xdead, certSize = 0xdead;
PVOID digest = (void*)1;
LPWIN_CERTIFICATE cert = (LPWIN_CERTIFICATE)1;
hashCalls = 0;
NTSTATUS s = DomitoCalculatePortableExecutableDigest(b, size, &algorithm, &length,
&digest, &cert, &certSize);
Check(s == expected, "digest status");
std::vector<UCHAR> result;
if (NT_SUCCESS(s))
{
Check(algorithm == CALG_SHA1 && length == 20 && digest != nullptr, "SHA1 output");
Check((cert == nullptr) == (certSize == 0), "certificate outputs agree");
if (cert) Check((PUCHAR)cert >= b && (PUCHAR)cert + certSize <= b + size, "borrowed certificate span");
result.assign((PUCHAR)digest, (PUCHAR)digest + length);
DomitoFreePortableExecutableDigest(digest);
}
else
{
Check(!algorithm && !length && !digest && !cert && !certSize, "failure outputs cleared");
}
Check(allocations == 0, "no leaked pool allocations");
return result;
}
static std::vector<UCHAR> Reference(const std::vector<UCHAR>& b, ULONG checksum,
ULONG securitySlot, ULONG end)
{
// Explicit fixture ranges, independent of the production parser.
std::vector<UCHAR> bytes(b.begin(), b.begin() + checksum);
bytes.insert(bytes.end(), b.begin() + checksum + 4, b.begin() + securitySlot);
if (securitySlot != end) bytes.insert(bytes.end(), b.begin() + securitySlot + 8, b.begin() + end);
std::vector<UCHAR> digest(20);
BCRYPT_ALG_HANDLE algorithm = nullptr;
Check(NT_SUCCESS(BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA1_ALGORITHM, nullptr, 0)), "reference algorithm");
Check(NT_SUCCESS(BCryptHash(algorithm, nullptr, 0, bytes.data(), (ULONG)bytes.size(), digest.data(), 20)), "reference digest");
BCryptCloseAlgorithmProvider(algorithm, 0);
return digest;
}
static void GuardedInvalid(const std::vector<UCHAR>& b, NTSTATUS expected)
{
SYSTEM_INFO si; GetSystemInfo(&si);
const SIZE_T span = ((b.size() + si.dwPageSize - 1) / si.dwPageSize) * si.dwPageSize;
UCHAR* region = (UCHAR*)VirtualAlloc(nullptr, span + si.dwPageSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
Check(region != nullptr, "guard allocation");
DWORD old;
Check(VirtualProtect(region + span, si.dwPageSize, PAGE_NOACCESS, &old) != FALSE, "guard protection");
UCHAR* input = region + span - b.size();
std::memcpy(input, b.data(), b.size());
Digest(input, (ULONG)b.size(), expected);
VirtualFree(region, 0, MEM_RELEASE);
}
int main()
{
for (bool pe64 : {false, true})
{
for (bool certificate : {false, true})
{
auto b = Image(pe64, 128, certificate);
auto result = Digest(b.data(), (ULONG)b.size());
Check(result == Reference(b, 216, pe64 ? 296 : 280, 1024), "fixture digest equals independent CNG reference");
Set32(b, 216, 0x12345678);
Check(result == Digest(b.data(), (ULONG)b.size()), "checksum excluded");
if (certificate)
{
b.back() ^= 0x55;
Check(result == Digest(b.data(), (ULONG)b.size()), "certificate contents excluded");
}
}
}
auto large = Image(true, 4 * 1024 * 1024);
Check(Digest(large.data(), (ULONG)large.size()) == Reference(large, 4194392, 4194472, (ULONG)large.size()), "large DOS stub");
auto b = Image(true);
for (ULONG size = 0; size < 392; ++size)
{
std::vector<UCHAR> prefix(b.begin(), b.begin() + size);
if (size == 0) Digest(b.data(), 0, STATUS_INVALID_IMAGE_NOT_MZ);
else GuardedInvalid(prefix, size < 64 ? STATUS_INVALID_IMAGE_NOT_MZ : STATUS_INVALID_IMAGE_FORMAT);
}
auto bad = b; Set32(bad, 60, 0xffffffff); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = b; Set32(bad, 60, 0x7fffffff); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = b; Set16(bad, 148, 1); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = b; Set16(bad, 152, 0x999); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = b; Set32(bad, 260, 17); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
for (ULONG offset : {200UL, 2048UL, 0xffffffffUL})
{
bad = b; Set32(bad, 296, offset); Set32(bad, 300, 32); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
}
bad = b; Set32(bad, 300, 32); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = b; Set32(bad, 296, 800); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = b; Set32(bad, 296, 800); Set32(bad, 300, 0xffffffff); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = Image(true, 128, true); Set32(bad, 1024, 33); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = Image(true, 128, true); Set32(bad, 1024, 7); GuardedInvalid(bad, STATUS_INVALID_IMAGE_FORMAT);
bad = Image(true); Set32(bad, 260, 0); // No directory entry: don't exclude its physical bytes.
Check(Digest(bad.data(), (ULONG)bad.size()) == Reference(bad, 216, 1024, 1024), "absent security directory");
for (int failure : {1, 2, 3, 4})
{
failOperation = failure;
for (int call = 1; call <= (failure == 2 ? 3 : 1); ++call)
{
failHashCall = call;
Digest(b.data(), (ULONG)b.size(), failure == 4 ? STATUS_INSUFFICIENT_RESOURCES : STATUS_UNSUCCESSFUL);
}
}
failOperation = 0;
Digest(nullptr, 1024, STATUS_INVALID_PARAMETER);
IMAGE_DATA_DIRECTORY directory = {1024, 32};
MINCRYPT_POLICY_INFO policy = {}, timestamp = {};
LARGE_INTEGER signing = {};
UCHAR hash[20] = {};
bytesRead = 16; ciCalls = 0;
Check(DomitoValidateFileLegacyMode(nullptr, hash, 20, CALG_SHA1, &directory,
&policy, &signing, &timestamp) == STATUS_END_OF_FILE, "short certificate read rejected");
Check(ciCalls == 0 && allocations == 0, "short read does not reach CI or leak");
bytesRead = 32;
Check(NT_SUCCESS(DomitoValidateFileLegacyMode(nullptr, hash, 20, CALG_SHA1, &directory,
&policy, &signing, &timestamp)), "complete certificate read accepted");
Check(ciCalls == 1 && allocations == 0, "complete read reaches CI once");
std::printf("PASS: %u checks (guarded malformed inputs, PE32/PE32+, CNG digests, failure cleanup, short reads).\n", checks);
}
+34
View File
@@ -0,0 +1,34 @@
"""Run in an x64 VS developer shell with Windows SDK and Python 3 installed.
Extracts the actual production functions without changing their bodies, replacing
only kernel dependencies with the test harness. No driver is loaded. This tests
parsing, hashing and cleanup, not real CI policy or kernel I/O synchronization.
"""
import argparse
from pathlib import Path
import subprocess
import tempfile
parser = argparse.ArgumentParser()
parser.add_argument('--source-root', type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument('--digest-source', type=Path)
args = parser.parse_args()
root = args.source_root
digest = (args.digest_source or root / 'src/Domito.CodeIntegrity.cpp').read_text(encoding='utf-8-sig')
digest = digest.replace('#include "Domito.Internal.h"', '').replace('#include "Domito.MinCrypt.h"', '')
common = (root / 'src/Domito.cpp').read_text(encoding='utf-8-sig')
start = common.rfind('_Success_', 0, common.index('DomitoMemorySearchPattern('))
end = common.index('_Success_', common.index('DomitoMemorySearchPattern('))
search = common[start:end]
public = (root / 'include/Domito.h').read_text(encoding='utf-8-sig')
start = public.index('#if !defined(CALG_SHA1)')
end = public.index('/*', public.index('DOMITO_CALG_TO_BCRYPT_ALGORITHM('))
algorithm = public[start:end]
with tempfile.TemporaryDirectory(prefix='domito-digest-tests-') as temp:
build = Path(temp)
(build / 'digest-under-test.inc').write_text(algorithm + '\n' + search + '\n' + digest, encoding='utf-8')
test_source = Path(__file__).with_name('digest-tests.cpp')
subprocess.run(['cl', '/nologo', '/EHsc', '/W4', '/WX', '/std:c++17',
'/I' + str(build), str(test_source), '/Fe' + str(build/'digest-tests.exe'),
'/Fo' + str(build/'digest-tests.obj'), '/link', 'bcrypt.lib'], cwd=build, check=True)
subprocess.run([str(build/'digest-tests.exe')], cwd=build, check=True)