diff --git a/NOTES.md b/NOTES.md index cc4d759..85aaae9 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,5 +1,10 @@ # CI APIs +These historical availability notes are unverified. The analyzed ci.dll +10.0.26100.8328 x64 still exports the digest-based APIs listed below. Exact +first-available builds and ABI changes need matching old binaries. See +[the current contracts and remaining work](docs/mincrypt-contracts.md). + ## Available starting with 1511 - CiCheckSignedFile diff --git a/README.md b/README.md index e0f9449..aa58660 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Built for and tested on **Windows 10 version 1507 (or newer) x64/ARM64**. 32-Bit - I do not want to force any consumer of the library to drag C++ paradigms into their project. I do expect the user to utilize a modern compiler though, so the library sources themselves may have some 'C++-ish touches' here and there, for my own convenience ๐Ÿ˜‰ - Compatibility with every Windows 10 version. - APIs not available on older builds will give you a `STATUS_NOT_IMPLEMENTED` instead of hard-linking and therefore making your driver fail to load ๐Ÿคž - - **Caution:** this claim comes with an asterisk though; due to PatchGuard (or some other security mechanism) I couldn't get run-time dynamic linking for `ci.dll` exports to work, so you need to actively avoid implementing code depending on exports that do not exist on earlier versions of Windows. Once I have a compatibility matrix for all of them I'll update the documentation accordingly. + - CI entry points are currently static imports from `ci.lib`; consumers must avoid imports unavailable on their target Windows builds. Export lookup now uses the correct name-ordinal mapping and rejects forwarders, but this does not establish a complete CI availability or runtime ABI matrix. See the remaining-work ledger below. - No conflicts with WDF or DMF - The consuming driver *may* (but doesn't have to) utilize Microsoft WDF or DMF in addition without having to fear any incompatibilities ๐Ÿ’ช - Reliable SAL annotations. @@ -73,6 +73,10 @@ Copy the provided `Domito.props` file into the directory of your `.vcxproj` file ## Code Integrity API notes +- [Algorithm-explicit Authenticode hashing and legacy catalog policy](docs/authenticode-digests.md) +- [Mincrypt contracts, lifetimes, flags and opt-in x64 26100 layouts](docs/mincrypt-contracts.md) +- [Implemented corrections and remaining VM/binary work](docs/implementation-status.md) + - [CiValidateFileObject flag 0x20: evidence, limitations, and kernel test procedure](docs/ci-validate-file-object.md) ## Sources & 3rd party credits diff --git a/docs/authenticode-digests.md b/docs/authenticode-digests.md new file mode 100644 index 0000000..1525f32 --- /dev/null +++ b/docs/authenticode-digests.md @@ -0,0 +1,142 @@ +# Authenticode digests and legacy verification + +Prefer `DomitoCalculatePortableExecutableDigestEx` when computing a PE digest. +It takes an explicit SHA-1/256/384/512 algorithm, validates its supported PE layout, +and returns a digest independently of whether the file has an embedded signature. +This allows SHA-256 catalog lookup for a file without an embedded certificate. +Hash calculation is separate from signature verification and signer acceptance. + +The older `DomitoCalculatePortableExecutableDigest` and +`DomitoGetPortableExecutableDigestKind` retain their compatibility behavior: +linear byte hashing and heuristic pattern discovery with a SHA-1 fallback. They +remain bounded, but should not be used to discover a general signature policy. +The new API is additive so existing callers do not silently acquire different +selection or rejection rules. + +## Supported PE layout + +The explicit API validates DOS/NT/optional-header spans, PE32 and PE32+ magic, +declared data-directory count, the complete section table, `SizeOfHeaders`, every +nonempty section's raw interval, and the certificate table. It sorts section +descriptors by raw file offset before validating their sequence. At most 96 +sections are accepted, keeping stack storage and sorting work bounded. + +Nonempty sections must form a contiguous sequence starting at `SizeOfHeaders`. +Section-header order can differ from raw-file order. Zero-raw-size sections +contribute no bytes. An overlay between the raw sections and the certificate +table, or EOF for an unsigned file, is included. The checksum and physical +certificate-directory slot are excluded. A present certificate table must be +8-byte aligned and end at EOF; every entry must have a bounded length, version +`0x200`, type PKCS#7 SignedData, and complete 8-byte alignment storage. + +For this subset, sorted section bytes coincide with linear file order, so the +implementation can stream validated ranges directly to BCrypt without copying +or allocating a whole-file scratch buffer. Digest allocation is the only pool +allocation in the hashing path and is released on every failure. + +Gaps, overlapping raw sections, certificates in the middle of a file, and absent +certificate-directory slots are unsupported. Some malformed inputs return a +format error; ambiguous or excluded layouts return `STATUS_NOT_SUPPORTED`. +The missing-slot boundary is deliberate: Windows SIP produced a different hash +than the old linear helper on a fixture with zero declared directories. Do not +infer Windows parity from the older helper's successful return on that input. + +This is a validated subset, not a claim that every PE accepted by every Windows +SIP is supported. Microsoft's [PE format documentation](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) +describes section ordering and exclusions, but unusual overlay behavior requires +measurements against the relevant Windows implementation. The local tests use +the actual [CryptSIPCreateIndirectData](https://learn.microsoft.com/en-us/windows/win32/api/mssip/nf-mssip-cryptsipcreateindirectdata) +provider as a differential oracle, rather than treating a prose summary as the +complete algorithm. SIP hash agreement does not establish CI trust-policy parity. + +## Discovering the first embedded content's digest algorithm + +`DomitoGetPortableExecutableDigestKindEx` navigates a bounded DER subset: +PKCS#7 ContentInfo / SignedData / encapsulated `SpcIndirectDataContent` / +`DigestInfo.AlgorithmIdentifier`. It reads the algorithm associated with the +file digest, avoiding unrelated algorithm patterns in certificates, attributes, +timestamps, or signer information. SHA-1/256/384/512 are recognized. Algorithm +parameters may be absent or ASN.1 NULL; the digest OCTET STRING must have the +corresponding length. DER lengths must be bounded, definite, and minimally +encoded. Up to seven zero bytes of aligned padding after the outer DER are +accepted when included in `WIN_CERTIFICATE.dwLength`, as observed on Microsoft +system binaries. Other trailing bytes are rejected. + +An absent certificate table returns `STATUS_NOT_FOUND`, an unsupported algorithm +or certificate type returns `STATUS_NOT_SUPPORTED`, and malformed supported +structure returns a format error. The output algorithm is zero on failure; no +default algorithm is silently manufactured. + +The caller supplies an array containing the security-directory entry and a +stable input buffer. The helper inspects the first table entry. It does not +enumerate nested/multiple signatures, validate the entire ASN.1 document, +validate signerInfos, verify cryptographic signatures, or establish trust. +Indefinite-length BER is outside this supported DER subset. Choose explicit +algorithms when handling catalog-only files or application-defined lookup order. + +## Explicit legacy catalog policy + +`DomitoValidateFileLegacyModeEx` takes a caller-selected +`AcceptedCatalogPolicyBits` mask, a `RequireTrustedCatalog` marker gate, and a +`ValidationSource` output. Source is `DOMITO_CI_SOURCE_EMBEDDED` or +`DOMITO_CI_SOURCE_CATALOG` on success and zero on failure. + +This helper accepts SHA-1 with exactly 20 bytes or SHA-256 with exactly 32 bytes. +Unsupported algorithms return `STATUS_NOT_SUPPORTED`; mismatched lengths return +`STATUS_INVALID_PARAMETER`. The underlying observed embedded API supports more +algorithms than this helper's catalog workflow, as documented in +[the mincrypt contracts](mincrypt-contracts.md). + +Both policy objects must be zero-initialized before first use, or own valid CI +results. The helper frees previous contents on entry and before each retry and +clears signing time between attempts. The caller owns both final policy results, +including any returned on failure, and frees them with `CiFreePolicyInfo`. +Embedded-to-catalog fallback and catalog reload occur only for +`STATUS_INVALID_IMAGE_HASH`; resource, I/O, and other policy errors propagate. + +Before certificate allocation, the helper references the file object and checks +the supplied table interval against the current file size. It then uses the +exact-read `DomitoReadFileAt`, which waits on a private completion event. An EOF +short read fails before CI sees any uninitialized bytes. The caller must keep +file identity/bytes stable between hashing and consumption; a size check cannot +prevent concurrent mutation. Handles must be valid in the current process +context and satisfy the documented kernel I/O/APC requirements. + +The older `DomitoValidateFileLegacyMode` delegates with the existing +`DOMITO_CI_LEGACY_CATALOG_POLICY_BITS` (`0x2007F`) and a false marker gate. That +value is a compatibility default with broad categories, not an application trust +recommendation. The explicit mask uses **any-overlap** catalog filtering. + +For a catalog-only file, choose an allowed algorithm sequence as application +policy (for example SHA-256 followed by SHA-1 when legacy acceptance is intended). +For each algorithm, call `DomitoCalculatePortableExecutableDigestEx`, then +`DomitoValidateFileLegacyModeEx` with a zero security directory. Free the digest +and both policy objects before retrying. Continue to another algorithm only on +the specifically intended lookup/signature-mismatch result, not on all failures. +Preserve the actual algorithm and validation source in the application's result. +This supports an explicit search strategy; it does not reproduce every native +CI scenario-selection rule or WinVerifyTrust policy. + +## Validation performed + +On the local Windows 26100 system, host tests compare all four digest algorithms +against Windows SIP for PE32/PE32+, unsigned/signed fixtures, overlays, reverse +section-header order, multiple certificate entries, and certificate alignment. +The installed `ci.dll`, `ntdll.dll`, `kernel32.dll`, and `notepad.exe` form a +real-file corpus. Embedded digest algorithms are independently extracted with +Crypt32, and computed file digests are compared with the signed +`SpcIndirectDataContent` values where an embedded signature exists. This reads +files and hashes bytes; it does not load or execute them as code. + +Guard-page truncations, malformed offsets/lengths, unsupported layouts, +algorithm selection, injected CNG/allocation failures, retry ownership, exact +read failures, and explicit catalog controls are also covered. Actual CI calls +are mocked in those host tests. The kernel translation units and layout +assertions compile for x64/ARM64 with warnings treated as errors; the opt-in +26100 runtime-layout observations remain x64-specific. + +Still pending are historical/ARM64 CI runtime compatibility, Driver Verifier, +real catalog-only acceptance across algorithms, nested/multiple-signature +selection, weak-crypto/revocation/timestamp policy, and extension of supported PE +layouts. Use the [remaining-work ledger](implementation-status.md) and the +[flag probe](ci-validate-file-object.md) to plan the VM run. diff --git a/docs/implementation-status.md b/docs/implementation-status.md new file mode 100644 index 0000000..b53c372 --- /dev/null +++ b/docs/implementation-status.md @@ -0,0 +1,79 @@ +# CI SDK implementation status + +This ledger tracks the source audit S01โ€“S18 and the remaining binary research. +The original CodeIntegrity-RE audit remains a snapshot of commit `7985b23`; +it should not be read as describing the corrected source unchanged. + +## Implemented with host tests or compilation + +- **S01โ€“S04:** subtraction-safe PE ranges, no fixed 4 MiB copy, bounded certificate + intervals, deterministic failure outputs, and digest cleanup. Compatibility + and explicit paths share the bounded hashing core. +- **S05:** actual certificate read length checked; allocation bounded against + file size; per-operation completion waits through the exact-read helper. +- **S06:** `DomitoReadFileAt` uses an explicit offset and private kernel event; + `DomitoReadFile` reads exactly from offset zero. Pending completion cannot + outlive its stack state or caller buffer. Short reads return EOF. +- **S10:** export lookup uses the name ordinal to index the EAT, bounds tables, + names and resolved RVAs, and rejects forwarders. A valid loader-mapped module + remains a precondition because the API has no externally supplied image size. +- **S11โ€“S12:** corrected policy pointer typedef and publisher return/ownership docs. +- **S14:** policy cleanup before embedded/catalog/reload retries, with host tests + allocating results even on failures. Real CI failure ownership still needs + the VM tests below. +- **S17:** legacy hash algorithm/length combinations validated and calculation + versus verification capabilities documented. +- **S18:** byte-count SAL, buffer-capacity rules, scalar input annotations, and + kernel read context/APC requirements documented. + +## Implemented within an explicit support boundary + +- **S07:** new bounded DER discovery reads the first outer SPC file-digest + AlgorithmIdentifier; new explicit digest API needs no discovery. The old + heuristic entry point retains compatibility behavior. Arbitrary BER, nested + signatures, and full signature enumeration remain outside this parser. +- **S08:** unsigned/catalog-only files can be hashed with a caller-chosen + SHA-1 or SHA-256 strategy. The policy-explicit legacy helper forwards catalog + controls and reports the successful source. Real catalog lookup comparisons + across OS versions are pending; no implicit algorithm retry policy is imposed. +- **S09:** the explicit digest path enforces a contiguous raw-section subset and + EOF certificate tables, with Windows SIP and real embedded-digest comparisons. + Gaps, overlaps, missing directory slots and nonterminal certificates are + rejected. The old linear compatibility entry point is not a universal hasher. +- **S13/S15/S16:** recovered fields, parameter meanings, policy categories, sizes, + name encoding, borrowed lifetimes, and allocator ownership are documented. + The opt-in x64 26100 header provides distinct observed layouts and assertions. + Default layouts remain compatible; runtime layout identity cannot be inferred + from `NTDDI_VERSION` or `ChainInfo->Size` alone. + +## Requires the test VM or additional matching binaries + +The VM is not available yet. Host mocks and compilation do not close these: + +1. Run the paired `CiValidateFileObject` flag `0`/`0x20` probe on 19041/19045 + with TESTSIGNING off and a loadable signed test driver. Preserve complete + statuses, policies and fixture/environment identities (issue #3). +2. Verify historical and ARM64 ABI, structure evolution and first-available + export versions using matching binaries/symbols and runtime tests. Preserve + default layouts until that evidence supports changing them. +3. Test real CI policy ownership on every failure/retry path under Driver + Verifier, including allocation failures and initialized outputs. +4. Compare real embedded/catalog-only SHA-1/SHA-256 results, catalog cache/reload + behavior, trust-mask choices, requested signing levels and flag combinations. +5. Measure signing-time propagation, zero/sentinel values, timestamp expiry, + revocation freshness/network policy and weak-crypto policy. Record OS policy, + Secure Boot/HVCI/WDAC and exact files, not just a boolean result. +6. Establish multiple/nested signature-selection behavior with a signed corpus; + extend supported PE gaps/overlays only after differential evidence. +7. Resolve page-hash allocation destruction, normalized-entry/truncation meaning, + terminal-entry handling and cross-version layouts before publishing a general + page-hash API. `CiFreePolicyInfo` is not an established destructor for it. +8. Investigate protected-media/initialization and ordinal-only exports separately + if they are needed. Recovered export names alone are insufficient contracts. + +## Checks + +Run the three host runners in `tests/README.md`. Kernel compilation also covers +the default modern x64/ARM64 structures, the pre-Windows-10 x64 compile variant, +and 14 assertions in the opt-in observed-build header. Those are build checks, +not a claim that historical or ARM64 CI runtime ABI has been verified. diff --git a/docs/mincrypt-contracts.md b/docs/mincrypt-contracts.md new file mode 100644 index 0000000..c2879c5 --- /dev/null +++ b/docs/mincrypt-contracts.md @@ -0,0 +1,211 @@ +# Code Integrity and mincrypt contracts + +The default header retains the existing structure layouts and calling shapes. +Parameter names and ownership comments now distinguish the known controls from +the verification result. `VerificationStatus` is an `NTSTATUS` with the same +32-bit storage. The additional `Domito.MinCrypt.26100.h` header is an **opt-in +description of ci.dll 10.0.26100.8328 x64**. It neither adds imports nor extends +the default structures on every Windows target. + +The evidence is static REA/Ghidra analysis of SHA-256 +`c7d23814f3202c51fb23e3ca3d4328855787383cee5d14c4a986a13ec2aeda0b`, with +Microsoft ci.pdb GUID `1CAE574B-CF1F-3564-5F74-49567A128629`, age 1 supplying the +address/name map. The companion CodeIntegrity-RE repository retains the full +records and readable `evidence/` files. These observations do not establish +older-Windows or ARM64 ABI compatibility. Compile-target `NTDDI_VERSION` is not +runtime layout negotiation. + +## Results and trust policy + +Check the API's returned `NTSTATUS` first. `PolicyInfo.VerificationStatus`, +`PolicyBits`, signer details, and timestamps supply additional observations. +Successful chain processing does not imply that the caller's file digest matched +the signed digest; the comparison can occur afterward. Diagnostic policy data +may exist on failure. An extracted publisher name is display information and +does not establish publisher identity or signer trust. + +Define the policy the application needs: embedded signature validation, +membership in an accepted installed catalog, an expected publisher/key, or a +Windows signing scenario. Preserve which path produced a result. A nonzero +`PolicyBits` value is not automatically failure, and any one root bit is not a +complete trust decision. File bytes and object identity must remain tied to the +bytes the application will subsequently use. + +The [file-object flag document](ci-validate-file-object.md) covers `0x20`, hash +capacity, signing level, and its pending VM experiment. Other observed flag +effects on this build are: + +- `0x1` selects hash scenario 5. +- Otherwise `0x2` selects scenario 4 for signing levels <=4; higher levels use + the level mapping. +- Otherwise `0x80000000` selects scenario 5 for levels <=4; higher levels use + the level mapping. It takes precedence over `0x4`. +- Otherwise `0x4` selects scenario 1; without these choices the signing level + maps to a scenario. +- `0x00400000` suppresses accepted-root bit `0x8` when the relevant helper mode + is enabled; CI can add this flag internally. Bits `0x2` and `0x8` also affect + later signing-level/EKU restrictions. + +The scenarios resolve through initialized signing-policy state. Static defaults +are not a guarantee of a running system's algorithm, and the flags should not +be named as universal fixed SHA selectors. Unknown bits remain unknown. Evidence: +`CipGetHashAlgorithmForLegacyScenario`, `CipGetAcceptedRootKeys`, and +`CipMinCryptToSigningLevel` in the research bundle. + +## Ownership and initialization + +Zero-initialize both `MINCRYPT_POLICY_INFO` output objects before their first +use. Free both with `CiFreePolicyInfo` after use and before reuse, including +failure paths. Never call the destructor on arbitrary uninitialized stack data. +Do not clear a nonzero `Size` while a chain is still owned: the observed cleanup +helper skips an object whose `Size` is zero. + +`ChainInfo` owns one allocation. Public-key blobs, names, encoded certificates, +EKUs, attributes, and chain elements are borrowed views into it. Do not free +children separately or retain them after releasing the owner. Copying the policy +structure with `memcpy` creates two aliases to one allocation, not independent +ownership. CI's internal deep-copy helper uses the total allocation size and +rebases interior pointers; Domito does not expose that helper. + +The default `CiFreePolicyInfo` declaration retains its legacy `PVOID` return to +avoid a broad source compatibility change. **Ignore that value**: observed x64 +assembly provides no defined pointer result. The opt-in build-specific function +pointer type uses `VOID`. + +`CatalogName` is a separate `RtlDuplicateUnicodeString` allocation in the observed +catalog export. Initialize it empty, release a returned buffer with +`RtlFreeUnicodeString`, and release both policies separately. Publisher-name +storage is allocated through the caller's callback and must be released with +that allocator's matching free operation, including an allocated buffer on a +later conversion failure. It is not owned by either policy object. + +Evidence: `MincryptFreePolicyInfo`, `MincryptCopyPolicyInfo`, +`I_MincryptAddChainInfo`, `CiVerifyHashInCatalog`, and `CiGetCertPublisherName`. + +## Digest-based verification inputs + +`CiCheckSignedFile` consumes a caller-computed file digest. It does not reopen +the file or compute its Authenticode hash. On the observed build it accepts +SHA-1/256/384/512 with exactly 20/32/48/64 bytes respectively. A wrong algorithm, +length, or digest fails the final comparison. + +Its signature input points to a `WIN_CERTIFICATE` header. The observed wrapper +requires 4-byte pointer alignment, supplied size >=12, revision `0x200`, type 2, +and `8 < dwLength <= supplied size`. The PKCS#7 payload begins at header+8. +The wrapper selects the first certificate-table entry. Multiple table entries +and nested signatures are separate concepts; this is not a complete signature +enumeration interface. + +These exports remain implemented on the analyzed Windows 11 build. The older +header's โ€œWin7โ€“8.1 onlyโ€ comments described a workflow, not verified export +availability. Exact first-available builds and pre/post-KB3033929 ABI changes +still require matching historical binaries. Import-library symbol names and +stdcall stack-byte counts alone do not settle parameter semantics. + +## Catalog controls + +`ReloadCatalogs` controls catalog-store reload and related context setup. +`RequireTrustedCatalog` is a nonzero gate requiring an observed catalog extension +marker: its matching bytes include `PE`, integer `0x10001`, and `TRUSTED`. +The marker is an additional catalog condition, not proof of a Microsoft signer. + +`AcceptedCatalogPolicyBits` accepts a candidate when +`(RequestedMask & Catalog.PolicyInfo.PolicyBits) != 0`: **any overlap** is enough. +It neither enables policy flags nor requires every requested bit. The existing +Domito compatibility value is named `DOMITO_CI_LEGACY_CATALOG_POLICY_BITS` +(`0x2007F`). It includes test/DMD categories and diagnostic bit `0x20000`, while +omitting other categories. It is retained as an existing behavior, not promoted +as a universally safe trust mask. Establish and test the application's accepted +signer policy before changing it. + +Observed file-hash lookup supports SHA-1 and SHA-256; the internal path has no +SHA-384/512 file-hash case. Embedded-signature algorithm support and catalog +lookup support are different capabilities. Catalog-only files need an explicit, +tested algorithm strategy rather than assuming SHA-1 from an absent embedded +table. Catalog stores, caches, silo/process context, and signing policy affect +results. The new `DomitoValidateFileLegacyModeEx` forwards a caller-selected mask and +marker gate and reports embedded/catalog origin. Its compatibility wrapper retains +the existing mask. See [digest and workflow details](authenticode-digests.md). + +Domito's older-Windows helper keeps its system-process attachment until +historical runtime evidence supports changing it. + +Evidence: `I_FindFileOrHeaderHashInLoadedCatalogs` and its assembly +`ev_c42e7ee76099d55e7cf7cdd3d9f79e99030ba637680365612afb4906694ae3a8`, +`I_ParseCatalog`, and marker matcher +`ev_a1d3a5844a2b6be6c16175e307956c9819d0b64a4fe70216f292c12e4ea38d82`. + +## Opt-in x64 26100 structure observations + +The version header has distinct type names and 14 compile-time layout assertions. +Use it only after independently establishing the matching runtime build and +architecture. A large `ChainInfo->Size` alone does not identify the header: it +includes variable trailing data, so an older allocation could also be large. +Only after layout identity is established may a size check protect a field read. + +- `DOMITO_CI26100_NAME_BLOB`: pointer at +0, byte length at +8, ASN.1 string tag + at +10. Names are not necessarily NUL-terminated. Observed conversion handles + UTF8String tag `0x0C` and BMPString `0x1E`; `PCHAR` in the default declaration + must not be read as a guarantee of ANSI encoding. +- `DOMITO_CI26100_CHAIN_INFO`: `Size` is total allocation bytes; fixed header is + 104 bytes. Root ID at +0x34 is an opaque integer, not policy bits. The 32 bytes + at +0x48 are a platform-manifest binary ID from an authenticated attribute, + not a file digest or certificate thumbprint. Zero/nonzero does not by itself + prove trust. Require the matching layout and `Size >=104` before reading it. +- `DOMITO_CI26100_POLICY_INFO`: 48 bytes; `NotBefore`/`NotAfter` come from the + signer certificate validity fields, not an intersection of the whole chain. + Interpret `RevocationTime` only with the relevant result. A zero signing time + is not a real timestamp to display as a signing date. + +Construction/copy evidence: `ev_d7110cfc711074a4af1874b5c8e701c13bcc3dc58a1596c64dca4ca98966c79e`, +`ev_858afecf2170484325379b6a8bc307fb72dad2f9a5a765169192c7363a3386af`, +`ev_f82cbee977715399323bb398781c125a9d19b4ea29acd05063ec55b711cfc670`. + +## Partial policy-bit dictionary + +Names in the opt-in header are Domito descriptions of observed 26100 branches, +not official Microsoft constants or a universal trusted-root enumeration. + +Root/provenance categories include `0x1` self-issued terminal path, `0x2` selected +built-in Microsoft roots, `0x4` test roots, `0x8` the built-in root associated with +embedded picroot handling, `0x10` tolerated partial/unresolved chain, `0x20` DMD, +`0x40` DMD test, `0x80` another built-in Microsoft root, `0x400` flight root, +`0x2000` third-party marketplace, `0x4000` explicit supplied certificate/key +acceptance, and `0x8000` Microsoft Identity Root 2020. `0x800` and `0x1000` are +additional discriminators on some root entries; their independent meanings +remain unresolved. These categories do not establish a full trust result. + +Observed diagnostic branches set `0x20000` chain failure, `0x40000` invalid +cryptographic/signature data, `0x80000` unmet EKU, `0x100000` page-hash failure, +`0x200000` revocation, `0x400000` certificate validity failure (including +not-yet-valid), `0x08000000` path-length constraints, `0x10000000` generic fallback, +and `0x20000000` CA/BasicConstraints failure. `0x04000000` marks a no-usable- +timestamp branch during revoked-certificate recovery, not universal timestamp +absence. `0x50000` and `0x60000` are observed composites; do not infer a unique +meaning for every constituent bit. Timestamp-before-revocation processing can +clear a revocation bit. Internal checks of high bits are not a replacement for +the API status and application policy. + +## Remaining runtime and API coverage + +The missing page-hash exports have recovered argument shapes in the research +report. Their normalized allocation uses a 24-byte x64 header and 24-byte entries +even for stronger source algorithms. Field meaning, truncation intent, source +terminal-entry handling, and a supported destructor remain open. `CiFreePolicyInfo` +is not a demonstrated destructor for that allocation. No universal page-hash SDK +or static import is added until those ownership and cross-version contracts can +be tested. + +`CiGetPEInformation` is a protected-media dispatcher, not a general PE parser. +`CiInitialize` is initialization machinery. `CiSetTrustedOriginClaimId` writes a +CI global; it is not an ordinary per-file trust selector. Ordinal-only exports +and other initialization interfaces require their own evidence and callers. + +Still required: historical/ARM64 layout and parameter verification, real CI +output lifetimes and retry behavior under failures, timestamp propagation, +revocation freshness/network policy, weak-crypto selection, multiple/nested +signatures, catalog-only algorithms, and extension of the bounded digest subset to additional PE layouts. +The explicit digest helper now has local Windows SIP and real embedded-digest +comparisons; this does not close those historical or unusual-layout cases. Run the prepared probes in disposable VMs with exact OS, +CI binary, configuration, and fixture identities. Compilation and host mocks do +not close those runtime questions. diff --git a/include/Domito.MinCrypt.26100.h b/include/Domito.MinCrypt.26100.h new file mode 100644 index 0000000..bf7a123 --- /dev/null +++ b/include/Domito.MinCrypt.26100.h @@ -0,0 +1,157 @@ +/* Opt-in observed-build declarations, not a universal Windows CI SDK. + * Observed: ci.dll 10.0.26100.8328, x64, SHA-256 documented in docs/mincrypt-contracts.md. + * Include after ntddk.h. Uses distinct names and function-pointer types so + * including this file does not change the default layouts or add static imports. + * Verify the actual CI build/architecture before use; Size alone cannot prove + * layout identity. See docs/mincrypt-contracts.md for provenance and limits. + * Field/parameter names are descriptive analyst names unless stated otherwise. + */ +#pragma once + +#if !defined(_M_AMD64) && !defined(__x86_64__) +#error This research layout is validated only for x64. +#endif + +#pragma pack(push, 8) +typedef struct _DOMITO_CI26100_BLOB { + ULONG Size; + UCHAR *Data; +} DOMITO_CI26100_BLOB; + +typedef struct _DOMITO_CI26100_NAME_BLOB { + UCHAR *Data; /* Borrowed, length-delimited encoded bytes. */ + USHORT Size; /* Bytes, not characters. */ + UCHAR Asn1StringTag; /* Observed: 0x0c UTF8String, 0x1e BMPString. */ +} DOMITO_CI26100_NAME_BLOB; + +typedef struct _DOMITO_CI26100_CHAIN_ELEMENT { + ULONG HashAlgId; + ULONG HashSize; + UCHAR Hash[64]; + DOMITO_CI26100_NAME_BLOB Subject; + DOMITO_CI26100_NAME_BLOB Issuer; + DOMITO_CI26100_BLOB Certificate; +} DOMITO_CI26100_CHAIN_ELEMENT; + +typedef struct _DOMITO_CI26100_CHAIN_INFO { + ULONG Size; /* Entire allocation, including trailing data. */ + DOMITO_CI26100_BLOB *PublicKeys; + ULONG NumberOfPublicKeys; + DOMITO_CI26100_BLOB *EKUs; + ULONG NumberOfEKUs; + DOMITO_CI26100_CHAIN_ELEMENT *ChainElements; + ULONG NumberOfChainElements; + ULONG RootId; /* Opaque classification, NOT policy bits. */ + DOMITO_CI26100_BLOB AuthenticodeAttributes; + UCHAR PlatformManifestBinaryId[32]; /* NOT a file or certificate hash. */ +} DOMITO_CI26100_CHAIN_INFO; + +typedef struct _DOMITO_CI26100_POLICY_INFO { + ULONG Size; + NTSTATUS VerificationStatus; /* Does not replace the API return status. */ + ULONG PolicyBits; + DOMITO_CI26100_CHAIN_INFO *ChainInfo; + LARGE_INTEGER RevocationTime; + LARGE_INTEGER NotBefore; + LARGE_INTEGER NotAfter; +} DOMITO_CI26100_POLICY_INFO, *PDOMITO_CI26100_POLICY_INFO; +#pragma pack(pop) + +C_ASSERT(sizeof(DOMITO_CI26100_BLOB) == 16); +C_ASSERT(sizeof(DOMITO_CI26100_NAME_BLOB) == 16); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_NAME_BLOB, Asn1StringTag) == 10); +C_ASSERT(sizeof(DOMITO_CI26100_CHAIN_ELEMENT) == 120); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_CHAIN_ELEMENT, Certificate) == 104); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_CHAIN_INFO, RootId) == 52); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_CHAIN_INFO, AuthenticodeAttributes) == 56); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_CHAIN_INFO, PlatformManifestBinaryId) == 72); +C_ASSERT(sizeof(DOMITO_CI26100_CHAIN_INFO) == 104); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_POLICY_INFO, ChainInfo) == 16); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_POLICY_INFO, RevocationTime) == 24); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_POLICY_INFO, NotBefore) == 32); +C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_POLICY_INFO, NotAfter) == 40); +C_ASSERT(sizeof(DOMITO_CI26100_POLICY_INFO) == 48); + +/* Zero-initialize policies before first use. Free existing owned chains before + * reuse. Never copy these structs as independently owned objects with memcpy. + * Size is an output/initialization marker here, not runtime ABI negotiation. + */ +typedef VOID (NTAPI *DOMITO_CI26100_FREE_POLICY_INFO)( + DOMITO_CI26100_POLICY_INFO *PolicyInfo); + +typedef NTSTATUS (NTAPI *DOMITO_CI26100_VALIDATE_FILE_OBJECT)( + PFILE_OBJECT FileObject, + ULONG ValidationFlags, + UCHAR RequestedSigningLevel, + DOMITO_CI26100_POLICY_INFO *PolicyInfo, + DOMITO_CI26100_POLICY_INFO *TimeStampPolicyInfo, + LARGE_INTEGER *SigningTime, + UCHAR *Hash, /* Provide 64-byte capacity, even for SHA-1. */ + ULONG *HashSize, /* In: capacity >=64. Out success: actual size. */ + ULONG *HashAlgId); + +typedef NTSTATUS (NTAPI *DOMITO_CI26100_CHECK_SIGNED_FILE)( + PVOID Hash, + ULONG HashSize, + ULONG HashAlgId, + PVOID WinCertificate, /* WIN_CERTIFICATE header, not bCertificate. */ + ULONG SizeOfSecurityDirectory, + DOMITO_CI26100_POLICY_INFO *PolicyInfo, + LARGE_INTEGER *SigningTime, + DOMITO_CI26100_POLICY_INFO *TimeStampPolicyInfo); + +/* The existing BOOLEAN reload parameter ABI was not independently resolved + * beyond the full-width use in this build; retain Domito's declaration until + * cross-architecture call-site evidence settles its formal type. + * Fifth argument nonzero filters on the catalog TRUSTED marker. + * Sixth argument requires ANY overlap with catalog PolicyInfo.PolicyBits. + */ +typedef NTSTATUS (NTAPI *DOMITO_CI26100_VERIFY_HASH_IN_CATALOG)( + PVOID Hash, + ULONG HashSize, + ULONG HashAlgId, + BOOLEAN ReloadCatalogs, + ULONG RequireTrustedCatalog, + ULONG AcceptedCatalogPolicyBits, + DOMITO_CI26100_POLICY_INFO *PolicyInfo, + UNICODE_STRING *CatalogName, /* Optional; separate RtlFreeUnicodeString. */ + LARGE_INTEGER *SigningTime, + DOMITO_CI26100_POLICY_INFO *TimeStampPolicyInfo); + +typedef PVOID (NTAPI *DOMITO_CI26100_ALLOCATE)(SIZE_T ByteSize); +typedef NTSTATUS (NTAPI *DOMITO_CI26100_GET_CERT_PUBLISHER_NAME)( + DOMITO_CI26100_BLOB *Certificate, + DOMITO_CI26100_ALLOCATE AllocateRoutine, + UNICODE_STRING *PublisherName); /* Free with callback's allocator pair. */ + +/* Proposed descriptive diagnostic names. Not official Microsoft names, not + * an exhaustive dictionary, and not accepted-input validation flags. + * See the report for narrower branch conditions and composite values. + */ +#define DOMITO_CI26100_POLICY_SELF_ISSUED_TERMINAL 0x00000001UL +// Also present on rgbMicrosoftRoot4 together with bit 0x800. +#define DOMITO_CI26100_POLICY_MICROSOFT_ROOT_0_1_2 0x00000002UL +#define DOMITO_CI26100_POLICY_TEST_ROOT 0x00000004UL +#define DOMITO_CI26100_POLICY_MICROSOFT_ROOT_3 0x00000008UL +#define DOMITO_CI26100_POLICY_DMD_ROOT 0x00000020UL +#define DOMITO_CI26100_POLICY_DMD_TEST_ROOT 0x00000040UL +#define DOMITO_CI26100_POLICY_MICROSOFT_ROOT_5 0x00000080UL +#define DOMITO_CI26100_POLICY_FLIGHT_ROOT 0x00000400UL +#define DOMITO_CI26100_POLICY_MARKETPLACE_ROOT 0x00002000UL +#define DOMITO_CI26100_POLICY_SUPPLIED_CERT_OR_KEY 0x00004000UL +#define DOMITO_CI26100_POLICY_IDENTITY_ROOT_2020 0x00008000UL +#define DOMITO_CI26100_POLICY_PARTIAL_CHAIN 0x00000010UL +#define DOMITO_CI26100_POLICY_CHAIN_FAILURE 0x00020000UL +#define DOMITO_CI26100_POLICY_CRYPTO_FAILURE 0x00040000UL +#define DOMITO_CI26100_POLICY_EKU_FAILURE 0x00080000UL +#define DOMITO_CI26100_POLICY_PAGE_HASH_FAILURE 0x00100000UL +#define DOMITO_CI26100_POLICY_CERT_REVOKED 0x00200000UL +#define DOMITO_CI26100_POLICY_CERT_VALIDITY_FAILURE 0x00400000UL +#define DOMITO_CI26100_POLICY_PATH_LENGTH_FAILURE 0x08000000UL +#define DOMITO_CI26100_POLICY_GENERIC_FAILURE 0x10000000UL +#define DOMITO_CI26100_POLICY_CA_CONSTRAINT_FAILURE 0x20000000UL + +/* Narrow branch observation: no usable timestamp during revoked-cert recovery; + * not a universal flag for all signatures without a timestamp. + */ +#define DOMITO_CI26100_POLICY_REVOCATION_NO_TIMESTAMP 0x04000000UL diff --git a/include/Domito.MinCrypt.h b/include/Domito.MinCrypt.h index da97823..78d54d4 100644 --- a/include/Domito.MinCrypt.h +++ b/include/Domito.MinCrypt.h @@ -277,6 +277,8 @@ MINCRYPT_DIGEST_BLOB, * PMINCRYPT_DIGEST_BLOB, MINCRYPT_DER_BLOB, * PMINCRYPT_DER_BLOB, MINCRYPT_ATTR_BLOB, * PMINCRYPT_ATTR_BLOB; +// Encoded, length-delimited name bytes; not necessarily ANSI or NUL-terminated. +// The observed 26100 x64 string tag occupies padding; see the opt-in version header. typedef struct _MINCRYPT_NAME_BLOB { _Field_size_bytes_(Size) PCHAR Data; @@ -287,6 +289,8 @@ typedef struct _MINCRYPT_NAME_BLOB #define MINCRYPT_MAX_HASH_LENGTH (64) #define MINCRYPT_SHA1_LENGTH (160/8) #define MINCRYPT_SHA256_LENGTH (256/8) +#define MINCRYPT_SHA384_LENGTH (384/8) +#define MINCRYPT_SHA512_LENGTH (512/8) typedef struct _MINCRYPT_CHAIN_ELEMENT { @@ -301,9 +305,11 @@ typedef struct _MINCRYPT_CHAIN_ELEMENT }MINCRYPT_CHAIN_ELEMENT, * PMINCRYPT_CHAIN_ELEMENT; +// CI owns one chain allocation; nested pointers borrow that allocation. +// Do not free child blobs separately or use memcpy to create a second owner. typedef struct _MINCRYPT_CHAIN_INFO { - UINT32 Size; + UINT32 Size; // Observed 26100 x64: total allocation bytes, including trailing data. MINCERT_BLOB* PublicKeys; UINT32 NumberOfPublicKeys; @@ -319,7 +325,9 @@ typedef struct _MINCRYPT_CHAIN_INFO // ASN.1 blob of authenticated attributes - spcSpOpusInfo, contentType, etc. MINCRYPT_ATTR_BLOB AuthenticodeAttributes; - // UINT8 Hash[32]; + // Observed 26100 x64 adds RootId at +0x34 and a 32-byte platform-manifest + // binary ID at +0x48. These are exposed only by Domito.MinCrypt.26100.h. + // Do not infer their availability solely from NTDDI_VERSION or Size. #endif // NTDDI_VERSION >= NTDDI_WIN10 @@ -338,7 +346,7 @@ typedef struct _MINCRYPT_CHAIN_INFO typedef struct _MINCRYPT_POLICY_INFO { UINT32 Size; - UINT32 VerificationStatus; + NTSTATUS VerificationStatus; // Check API return status separately. UINT32 PolicyBits; MINCRYPT_CHAIN_INFO* ChainInfo; @@ -347,8 +355,8 @@ typedef struct _MINCRYPT_POLICY_INFO #if (NTDDI_VERSION >= NTDDI_WIN10) - LARGE_INTEGER NotBefore; // The certificate is not valid before this time - LARGE_INTEGER NotAfter; // The certificate is not valid after this time + LARGE_INTEGER NotBefore; // Observed: signer certificate validity start. + LARGE_INTEGER NotAfter; // Observed: signer certificate validity end. #endif // NTDDI_VERSION >= NTDDI_WIN10 @@ -358,12 +366,16 @@ typedef struct _MINCRYPT_POLICY_INFO /** -* Resets a PolicyInfo struct - frees the dynamically allocated buffer in PolicyInfo (ChainInfo) if not null. -* Zeros the entire PolicyInfo struct. +* Release a known initialized policy's owned ChainInfo allocation and reset it. +* Zero-initialize policy objects before first use. Release both signer and TSA +* policies after use, including failures, and before reusing output storage. +* Borrowed child pointers become invalid when the owner is released. Never clear +* Size while a chain is still owned. Size does not negotiate the runtime layout. * -* @param PolicyInfo - the struct to reset. +* @param PolicyInfo - initialized policy, not arbitrary uninitialized storage. * -* @return the struct which was reset. +* Ignore the return value. The legacy PVOID declaration is retained for source +* compatibility; observed 26100 x64 code provides no defined pointer return. */ _IRQL_requires_max_(PASSIVE_LEVEL) MINCRYPTAPI @@ -375,18 +387,23 @@ CiFreePolicyInfo( /** -* Win7SP1-Win8.1 only (KB3033929 installed). Use CiValidateFileObject on Win10! +* Digest-based workflow, also implemented on observed Windows 11 build 26100. +* Prefer the file-object workflow when its policy fits. Older-build ABI and +* KB3033929 requirements need a separately verified compatibility matrix. * * Given a file digest and signature of a file, verify the signature and provide information regarding * the certificates that was used for signing (the entire certificate chain) * -* @param Hash - buffer containing the digest +* @param Hash[in] - caller-computed file digest; CI does not recompute the file bytes * * @param HashSize - size of the digest, e.g. 0x14(160bit) for SHA1, 0x20(256bit) for SHA256 * * @param HashAlgId - digest algorithm identifier, e.g. CALG_SHA1(0x8004), CALG_SHA_256(0x800C) * -* @param SecurityDirectory - pointer to the start of the security directory +* @param SecurityDirectory - WIN_CERTIFICATE header, not the PKCS#7 payload. +* Observed 26100 x64 checks 4-byte pointer alignment, revision 0x200, +* type 2, supplied size >=12 and 8 < dwLength <= supplied size. +* This wrapper selects the first entry; it does not enumerate the table. * * @param SizeOfSecurityDirectory - size the security directory * @@ -396,9 +413,12 @@ CiFreePolicyInfo( * * @param TimeStampPolicyInfo[out] - PolicyInfo containing information about the timestamping authority (TSA) certificate chain * -* @return STATUS_SUCCESS if the file digest in the signature matches the given digest and the signer cetificate is verified. +* @return Overall verification NTSTATUS. A successful policy VerificationStatus +* does not replace this return value: the final file-digest comparison +* can still fail. Both policy outputs may carry diagnostics on failure. +* See docs/mincrypt-contracts.md for ownership and policy limits. * Various error values otherwise, for example: -* STATUS_INVALID_IMAGE_HASH - the digest does not match the digest in the signature +* STATUS_INVALID_IMAGE_HASH - digest/signature or selected CI policy rejected * STATUS_IMAGE_CERT_REVOKED - the certificate used for signing the file is revoked * STATUS_IMAGE_CERT_EXPIRED - the certificate used for signing the file has expired */ @@ -407,10 +427,10 @@ MINCRYPTAPI NTSTATUS NTAPI CiCheckSignedFile( - _In_ PVOID Hash, + _In_reads_bytes_(HashSize) PVOID Hash, _In_ UINT32 HashSize, _In_ ALG_ID HashAlgId, - _In_ PVOID SecurityDirectory, + _In_reads_bytes_(SizeOfSecurityDirectory) PVOID SecurityDirectory, _In_ UINT32 SizeOfSecurityDirectory, _Out_ MINCRYPT_POLICY_INFO* PolicyInfo, _Out_ LARGE_INTEGER* SigningTime, @@ -418,36 +438,51 @@ CiCheckSignedFile( ); +// Existing Domito compatibility filter, not a universal trusted-root policy. +// Includes test/DMD categories and a diagnostic bit; select policy deliberately. +#define DOMITO_CI_LEGACY_CATALOG_POLICY_BITS 0x0002007FUL + /** -* Win7SP1-Win8.1 only (KB3033929 installed). Use CiValidateFileObject on Win10! +* Digest-based workflow, also implemented on observed Windows 11 build 26100. +* Prefer the file-object workflow when its policy fits. Older-build ABI and +* KB3033929 requirements need a separately verified compatibility matrix. * -* Checks if the SHA-1 message digest is contained within a verified system catalog +* Finds a caller-computed file digest in an accepted installed catalog. +* Observed 26100 x64 file-hash lookup supports SHA-1 and SHA-256. * * @note must be attached to the PsInitialSystemProcess first! * -* @param Hash - buffer containing the digest +* @param Hash[in] - caller-computed file digest; CI does not recompute the file bytes * * @param HashSize - size of the digest, e.g. 0x14(160bit) for SHA1, 0x20(256bit) for SHA256 * * @param HashAlgId - digest algorithm identifier, e.g. CALG_SHA1(0x8004), CALG_SHA_256(0x800C) * -* @param IsReloadCatalogs - is reload catalogs cache. +* @param ReloadCatalogs - request catalog-store reload; process/silo and cache state matter. * -* @param Always0 - this is for IsReloadCatalogs, Always0 != 0 ? 16 : 24; +* @param RequireTrustedCatalog - nonzero requires the observed catalog TRUSTED +* extension marker. This does not itself establish Microsoft signer trust. * -* @param Always2007F - unknown, always 0x2007F, maybe a mask. +* @param AcceptedCatalogPolicyBits - candidate catalog policy-bit filter. +* Observed 26100 x64 requires ANY overlap with catalog PolicyBits, not +* every requested bit. This is not a flags-to-enable input. * * @param PolicyInfo[out] - PolicyInfo containing information about the signer certificate chain. * -* @param CatalogName[out option] - catalog file name. +* @param CatalogName[out option] - initialize empty; observed 26100 x64 returns +* a separate duplicated string allocation. Release a returned Buffer +* with RtlFreeUnicodeString, separately from both policy outputs. * * @param SigningTime[out] - when the file was signed (FILETIME format) * * @param TimeStampPolicyInfo[out] - PolicyInfo containing information about the timestamping authority (TSA) certificate chain. * -* @return STATUS_SUCCESS if the file digest in the signature matches the given digest and the signer cetificate is verified. +* @return Overall verification NTSTATUS. A successful policy VerificationStatus +* does not replace this return value. Catalog selection and the +* selected policy still apply. Outputs may carry diagnostics on failure. +* See docs/mincrypt-contracts.md for ownership and policy limits. * Various error values otherwise, for example: -* STATUS_INVALID_IMAGE_HASH - the digest does not match the digest in the signature +* STATUS_INVALID_IMAGE_HASH - digest/signature or selected CI policy rejected * STATUS_IMAGE_CERT_REVOKED - the certificate used for signing the file is revoked * STATUS_IMAGE_CERT_EXPIRED - the certificate used for signing the file has expired */ @@ -456,12 +491,12 @@ MINCRYPTAPI NTSTATUS NTAPI CiVerifyHashInCatalog( - _In_ PVOID Hash, + _In_reads_bytes_(HashSize) PVOID Hash, _In_ UINT32 HashSize, _In_ ALG_ID HashAlgId, - _In_ BOOLEAN IsReloadCatalogs, - _In_ UINT32 Always0, - _In_ UINT32 Always2007F, + _In_ BOOLEAN ReloadCatalogs, + _In_ UINT32 RequireTrustedCatalog, + _In_ UINT32 AcceptedCatalogPolicyBits, _Out_ MINCRYPT_POLICY_INFO* PolicyInfo, _Out_opt_ UNICODE_STRING* CatalogName, _Out_ LARGE_INTEGER* SigningTime, @@ -552,7 +587,7 @@ CiSetTrustedOriginClaimId( * @return STATUS_SUCCESS when verification succeeds under the selected CI policy. * This does not by itself establish the application's signer trust policy. * Various error values otherwise, for example: -* STATUS_INVALID_IMAGE_HASH - the digest does not match the digest in the signature +* STATUS_INVALID_IMAGE_HASH - digest/signature or selected CI policy rejected * STATUS_IMAGE_CERT_REVOKED - the certificate used for signing the file is revoked * STATUS_IMAGE_CERT_EXPIRED - the certificate used for signing the file has expired */ @@ -562,12 +597,12 @@ NTSTATUS NTAPI CiValidateFileObject( _In_ FILE_OBJECT* FileObject, - _In_opt_ UINT32 ValidationFlags, - _In_opt_ UINT32 RequestedSigningLevel, + _In_ UINT32 ValidationFlags, + _In_ UINT32 RequestedSigningLevel, _Out_ MINCRYPT_POLICY_INFO* PolicyInfo, _Out_ MINCRYPT_POLICY_INFO* TimeStampPolicyInfo, _Out_ LARGE_INTEGER* SigningTime, - _Out_ UINT8* Hash, + _Out_writes_bytes_(*HashSize) UINT8* Hash, _Inout_ UINT32* HashSize, _Out_ ALG_ID* HashAlgId ); diff --git a/include/Domito.h b/include/Domito.h index 4ada98c..b1a820f 100644 --- a/include/Domito.h +++ b/include/Domito.h @@ -184,6 +184,43 @@ DomitoGetPortableExecutableDigestKind( _In_ PIMAGE_DATA_DIRECTORY pImgDataDirectory ); +// Reads the first PKCS#7 entry's SpcIndirectDataContent DigestInfo algorithm +// using bounded DER navigation. Does not validate the signature or select among +// multiple/nested signatures. Absent table => STATUS_NOT_FOUND; unsupported OID +// or certificate type => STATUS_NOT_SUPPORTED; malformed DER => format error. +// Unlike the compatibility helper above, it never silently falls back to SHA-1. +_IRQL_requires_max_(DISPATCH_LEVEL) +EXTERN_C +NTSTATUS +DomitoGetPortableExecutableDigestKindEx( + _In_reads_bytes_(PeSize) const UCHAR* PeBytes, + _In_ ULONG PeSize, + _In_reads_(IMAGE_DIRECTORY_ENTRY_SECURITY + 1) const IMAGE_DATA_DIRECTORY* Directories, + _Out_ PUINT32 DigestCalg +); + +// Algorithm-explicit Authenticode digest for the supported layout subset: +// bounded PE32/PE32+ headers, contiguous nonoverlapping raw sections in file +// offset order, optional overlay before a complete EOF certificate table. +// SHA-1/256/384/512 supported. Gaps/overlaps/nonterminal certificates are rejected. +// No signature trust decision is made. Outputs are cleared on failure; the +// certificate borrows stable input memory, and only the digest must be freed. +// See docs/authenticode-digests.md for policy and Windows SIP comparisons. +_Success_(return == STATUS_SUCCESS) +_Must_inspect_result_ +_IRQL_requires_max_(PASSIVE_LEVEL) +EXTERN_C +NTSTATUS +DomitoCalculatePortableExecutableDigestEx( + _In_reads_bytes_(PeSize) const UCHAR* PeBytes, + _In_ ULONG PeSize, + _In_ UINT32 HashAlgId, + _Out_ PULONG DigestSize, + _Outptr_result_bytebuffer_(*DigestSize) PVOID* Digest, + _Outptr_result_maybenull_ LPWIN_CERTIFICATE* Certificate, + _Out_ PULONG SecuritySize +); + // // Calculates a PE digest using the existing linear hashing convention. // Validates accessed header/certificate ranges and clears outputs on failure @@ -217,8 +254,40 @@ DomitoFreePortableExecutableDigest( _In_ PVOID pDigestOut ); +#define DOMITO_CI_SOURCE_EMBEDDED 1UL +#define DOMITO_CI_SOURCE_CATALOG 2UL + +// Policy-explicit legacy helper. SHA-1/256 only; hash size must match algorithm. +// Policy objects must be zero-initialized or own valid CI results; both are freed +// before each attempt. Caller owns both final policy outputs, including failure. +// Embedded validation is tried first; only INVALID_IMAGE_HASH permits catalog +// fallback/reload. AcceptedCatalogPolicyBits uses ANY-overlap catalog filtering; +// RequireTrustedCatalog gates the observed marker, not publisher identity. +// Source is 0 on failure, EMBEDDED/CATALOG on success. Stable file bytes and a +// handle valid in the caller's current process context are required during I/O. +_Success_(return == STATUS_SUCCESS) +_Must_inspect_result_ +_IRQL_requires_max_(PASSIVE_LEVEL) +EXTERN_C +NTSTATUS +DomitoValidateFileLegacyModeEx( + _In_ HANDLE FileHandle, + _In_reads_bytes_(HashSize) PVOID Hash, + _In_ UINT32 HashSize, + _In_ ALG_ID HashAlgId, + _In_ const IMAGE_DATA_DIRECTORY* SecurityDirectory, + _Inout_ MINCRYPT_POLICY_INFO* PolicyInfo, + _Out_ LARGE_INTEGER* SigningTime, + _Inout_ MINCRYPT_POLICY_INFO* TimeStampPolicyInfo, + _In_ UINT32 AcceptedCatalogPolicyBits, + _In_ BOOLEAN RequireTrustedCatalog, + _Out_ PULONG ValidationSource +); + // -// Verifies if the Authenticode signature of a give PE file matches the provided (e.g. SHA1) file digest. +// Compatibility wrapper around DomitoValidateFileLegacyModeEx, with catalog +// mask DOMITO_CI_LEGACY_CATALOG_POLICY_BITS and RequireTrustedCatalog = FALSE. +// The same initialization, ownership, hash, handle, and stable-file rules apply. // _Success_(return == STATUS_SUCCESS) _Must_inspect_result_ @@ -258,7 +327,10 @@ DomitoFindModuleBaseAddress( ); // -// Finds the address of an exported function by name. +// Finds an export by name (case-insensitive) in a valid loader-mapped module. +// ModuleBase must remain mapped; arbitrary file buffers are not supported. +// Export table spans are checked against SizeOfImage. Forwarded exports return +// STATUS_NOT_SUPPORTED; the optional output is cleared before lookup. // _Success_(return == STATUS_SUCCESS) _Must_inspect_result_ @@ -289,7 +361,28 @@ DomitoMemorySearchPattern( ); // -// Reads from the beginning of a file until the end or the buffer size is reached. +// Reads exactly BufferSize bytes at a nonnegative absolute file offset. +// Same completion, buffer, handle, APC, and short-read contract as DomitoReadFile. +// +_Success_(return == STATUS_SUCCESS) +_Must_inspect_result_ +_IRQL_requires_max_(PASSIVE_LEVEL) +EXTERN_C +NTSTATUS +DomitoReadFileAt( + _In_ HANDLE FileHandle, + _Out_writes_bytes_(BufferSize) PVOID Buffer, + _In_ ULONG BufferSize, + _In_ const LARGE_INTEGER* ByteOffset +); + +// +// Reads exactly BufferSize bytes from file offset zero. Short reads return +// STATUS_END_OF_FILE; Buffer may then contain a partial read. Zero length succeeds. +// Waits for pending I/O on a private event before returning. Requires PASSIVE_LEVEL +// with special kernel APCs enabled, a readable handle valid in the current process +// (or a kernel handle), and a buffer that remains valid for the entire call. +// For synchronous handles, the explicit read also updates the file position. // _Success_(return == STATUS_SUCCESS) _Must_inspect_result_ diff --git a/src/Domito.CodeIntegrity.cpp b/src/Domito.CodeIntegrity.cpp index c0c2d02..7e67656 100644 --- a/src/Domito.CodeIntegrity.cpp +++ b/src/Domito.CodeIntegrity.cpp @@ -89,11 +89,252 @@ DomitoGetPortableExecutableDigestKind( return CALG_SHA1; } +// Bounded DER navigation for the outer Authenticode content only. This does not +// validate certificates, signerInfos, nested signatures, or cryptographic trust. +typedef struct _DOMITO_DER_VIEW +{ + const UCHAR* Bytes; + ULONG Length; +} DOMITO_DER_VIEW; + +static BOOLEAN DomitoDerTake(DOMITO_DER_VIEW* Input, UCHAR Tag, DOMITO_DER_VIEW* Value) +{ + if (Input->Length < 2 || Input->Bytes[0] != Tag) + return FALSE; + ULONG length = Input->Bytes[1]; + ULONG header = 2; + if (length & 0x80) + { + const ULONG count = length & 0x7F; + if (!count || count > sizeof(ULONG) || count > Input->Length - header || + Input->Bytes[header] == 0) + return FALSE; // No indefinite or nonminimal DER lengths. + length = 0; + for (ULONG i = 0; i < count; ++i) + length = (length << 8) | Input->Bytes[header++]; + if (length < 0x80) + return FALSE; + } + if (length > Input->Length - header) + return FALSE; + Value->Bytes = Input->Bytes + header; + Value->Length = length; + Input->Bytes += header + length; + Input->Length -= header + length; + return TRUE; +} + +static BOOLEAN DomitoDerOid(DOMITO_DER_VIEW* Input, const UCHAR* Oid, ULONG Size) +{ + DOMITO_DER_VIEW value; + return DomitoDerTake(Input, 0x06, &value) && value.Length == Size && + memcmp(value.Bytes, Oid, Size) == 0; +} + +_IRQL_requires_max_(DISPATCH_LEVEL) +NTSTATUS +DomitoGetPortableExecutableDigestKindEx( + _In_reads_bytes_(PeSize) const UCHAR* PeBytes, + _In_ ULONG PeSize, + _In_reads_(IMAGE_DIRECTORY_ENTRY_SECURITY + 1) const IMAGE_DATA_DIRECTORY* Directories, + _Out_ PUINT32 DigestCalg) +{ + if (!DigestCalg) + return STATUS_INVALID_PARAMETER; + *DigestCalg = 0; + if (!PeBytes || !Directories) + return STATUS_INVALID_PARAMETER; + const IMAGE_DATA_DIRECTORY security = Directories[IMAGE_DIRECTORY_ENTRY_SECURITY]; + if (!security.VirtualAddress && !security.Size) + return STATUS_NOT_FOUND; + if (!security.VirtualAddress || (security.VirtualAddress & 7) || + security.VirtualAddress > PeSize || security.Size > PeSize - security.VirtualAddress || + security.Size < 8) + return STATUS_INVALID_IMAGE_FORMAT; + ULONG length; + USHORT revision, type; + const UCHAR* certificate = PeBytes + security.VirtualAddress; + memcpy(&length, certificate, 4); + memcpy(&revision, certificate + 4, 2); + memcpy(&type, certificate + 6, 2); + if (length <= 8 || length > security.Size) + return STATUS_INVALID_IMAGE_FORMAT; + if (revision != 0x0200 || type != WIN_CERT_TYPE_PKCS_SIGNED_DATA) + return STATUS_NOT_SUPPORTED; + + const UCHAR signedDataOid[] = {0x2A,0x86,0x48,0x86,0xF7,0x0D,0x01,0x07,0x02}; + const UCHAR indirectOid[] = {0x2B,0x06,0x01,0x04,0x01,0x82,0x37,0x02,0x01,0x04}; + const UCHAR sha1Oid[] = {0x2B,0x0E,0x03,0x02,0x1A}; + const UCHAR sha2Prefix[] = {0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x02}; + DOMITO_DER_VIEW payload = {certificate + 8, length - 8}; + DOMITO_DER_VIEW content, wrapped, signedData, ignored, contentInfo, indirect, digest, algorithm, oid; + if (!DomitoDerTake(&payload, 0x30, &content) || + !DomitoDerOid(&content, signedDataOid, sizeof(signedDataOid)) || + !DomitoDerTake(&content, 0xA0, &wrapped) || content.Length || + !DomitoDerTake(&wrapped, 0x30, &signedData) || wrapped.Length || + !DomitoDerTake(&signedData, 0x02, &ignored) || + !DomitoDerTake(&signedData, 0x31, &ignored) || + !DomitoDerTake(&signedData, 0x30, &contentInfo) || + !DomitoDerOid(&contentInfo, indirectOid, sizeof(indirectOid)) || + !DomitoDerTake(&contentInfo, 0xA0, &wrapped) || contentInfo.Length || + !DomitoDerTake(&wrapped, 0x30, &indirect) || wrapped.Length || + !DomitoDerTake(&indirect, 0x30, &ignored) || + !DomitoDerTake(&indirect, 0x30, &digest) || indirect.Length || + !DomitoDerTake(&digest, 0x30, &algorithm) || + !DomitoDerTake(&algorithm, 0x06, &oid)) + return STATUS_INVALID_IMAGE_FORMAT; + // Windows-signed entries can include up to seven zero alignment bytes in + // dwLength itself. Accept only that bounded, aligned padding after the DER. + if (payload.Length > 7 || (payload.Length && (length & 7))) + return STATUS_INVALID_IMAGE_FORMAT; + for (ULONG i = 0; i < payload.Length; ++i) + if (payload.Bytes[i]) + return STATUS_INVALID_IMAGE_FORMAT; + if (algorithm.Length && (!DomitoDerTake(&algorithm, 0x05, &ignored) || + ignored.Length || algorithm.Length)) + return STATUS_INVALID_IMAGE_FORMAT; + UINT32 calg = 0; + ULONG expectedLength = 0; + if (oid.Length == sizeof(sha1Oid) && !memcmp(oid.Bytes, sha1Oid, sizeof(sha1Oid))) + { + calg = CALG_SHA1; + expectedLength = 20; + } + else if (oid.Length == sizeof(sha2Prefix) + 1 && + !memcmp(oid.Bytes, sha2Prefix, sizeof(sha2Prefix))) + { + switch (oid.Bytes[sizeof(sha2Prefix)]) + { + case 1: calg = CALG_SHA256; expectedLength = 32; break; + case 2: calg = CALG_SHA384; expectedLength = 48; break; + case 3: calg = CALG_SHA512; expectedLength = 64; break; + } + } + if (!calg) + return STATUS_NOT_SUPPORTED; + if (!DomitoDerTake(&digest, 0x04, &ignored) || digest.Length || ignored.Length != expectedLength) + return STATUS_INVALID_IMAGE_FORMAT; + *DigestCalg = calg; + return STATUS_SUCCESS; +} + +// Conservative section-layout boundary: contiguous raw sections (any header +// order), optional overlay before an EOF certificate table. Gaps, overlaps, and +// certificates in the middle are rejected rather than assigned an ambiguous hash. +static NTSTATUS DomitoValidateAuthenticodeLayout(const UCHAR* Bytes, ULONG Size) +{ + if (!Bytes || Size < sizeof(IMAGE_DOS_HEADER)) + return STATUS_INVALID_IMAGE_FORMAT; + IMAGE_DOS_HEADER dos; + memcpy(&dos, Bytes, sizeof(dos)); + if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew < 0) + return STATUS_INVALID_IMAGE_FORMAT; + const ULONG nt = (ULONG)dos.e_lfanew; + const ULONG prefix = sizeof(ULONG) + sizeof(IMAGE_FILE_HEADER); + if (nt > Size || prefix > Size - nt) + return STATUS_INVALID_IMAGE_FORMAT; + ULONG signature; + IMAGE_FILE_HEADER file; + memcpy(&signature, Bytes + nt, sizeof(signature)); + memcpy(&file, Bytes + nt + sizeof(signature), sizeof(file)); + if (signature != IMAGE_NT_SIGNATURE || !file.NumberOfSections || file.NumberOfSections > 96) + return STATUS_INVALID_IMAGE_FORMAT; + const ULONG optional = nt + prefix; + if (file.SizeOfOptionalHeader < 2 || file.SizeOfOptionalHeader > Size - optional) + return STATUS_INVALID_IMAGE_FORMAT; + USHORT magic; + memcpy(&magic, Bytes + optional, sizeof(magic)); + ULONG directory; + if (magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) + directory = FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, DataDirectory); + else if (magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) + directory = FIELD_OFFSET(IMAGE_OPTIONAL_HEADER64, DataDirectory); + else + return STATUS_INVALID_IMAGE_FORMAT; + if (file.SizeOfOptionalHeader < directory) + return STATUS_INVALID_IMAGE_FORMAT; + ULONG count, headers; + memcpy(&count, Bytes + optional + directory - 4, sizeof(count)); + memcpy(&headers, Bytes + optional + FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, SizeOfHeaders), sizeof(headers)); + if (count > (file.SizeOfOptionalHeader - directory) / sizeof(IMAGE_DATA_DIRECTORY)) + return STATUS_INVALID_IMAGE_FORMAT; + if (count <= IMAGE_DIRECTORY_ENTRY_SECURITY) + return STATUS_NOT_SUPPORTED; // SIP exclusion behavior differs for absent slots. + const ULONG table = optional + file.SizeOfOptionalHeader; + const ULONG tableSize = file.NumberOfSections * sizeof(IMAGE_SECTION_HEADER); + if (tableSize > Size - table || headers < table + tableSize || headers > Size) + return STATUS_INVALID_IMAGE_FORMAT; + struct { ULONG Offset; ULONG Length; } sections[96]; + ULONG used = 0; + for (ULONG i = 0; i < file.NumberOfSections; ++i) + { + IMAGE_SECTION_HEADER section; + memcpy(§ion, Bytes + table + i * sizeof(section), sizeof(section)); + if (!section.SizeOfRawData) + continue; + if (section.PointerToRawData < headers || section.PointerToRawData > Size || + section.SizeOfRawData > Size - section.PointerToRawData) + return STATUS_INVALID_IMAGE_FORMAT; + ULONG at = used; + while (at && sections[at - 1].Offset > section.PointerToRawData) + { + sections[at] = sections[at - 1]; + --at; + } + sections[at].Offset = section.PointerToRawData; + sections[at].Length = section.SizeOfRawData; + ++used; + } + ULONG end = headers; + for (ULONG i = 0; i < used; ++i) + { + if (sections[i].Offset != end) + return STATUS_NOT_SUPPORTED; + end += sections[i].Length; + } + if (count > IMAGE_DIRECTORY_ENTRY_SECURITY) + { + IMAGE_DATA_DIRECTORY security; + memcpy(&security, Bytes + optional + directory + + IMAGE_DIRECTORY_ENTRY_SECURITY * sizeof(security), sizeof(security)); + if (security.VirtualAddress || security.Size) + { + if (!security.VirtualAddress || !security.Size || (security.VirtualAddress & 7) || + security.VirtualAddress < end || security.VirtualAddress > Size || + security.Size != Size - security.VirtualAddress) + return STATUS_NOT_SUPPORTED; + // Validate every aligned certificate entry, not just the first. + ULONG offset = security.VirtualAddress; + while (offset < Size) + { + if (Size - offset < 8) + return STATUS_INVALID_IMAGE_FORMAT; + ULONG length; + USHORT revision, type; + memcpy(&length, Bytes + offset, 4); + memcpy(&revision, Bytes + offset + 4, 2); + memcpy(&type, Bytes + offset + 6, 2); + if (length <= 8 || length > Size - offset) + return STATUS_INVALID_IMAGE_FORMAT; + if (revision != 0x0200 || type != WIN_CERT_TYPE_PKCS_SIGNED_DATA) + return STATUS_NOT_SUPPORTED; + const ULONG padding = (8 - (length & 7)) & 7; + if (padding > Size - offset - length) + return STATUS_INVALID_IMAGE_FORMAT; + offset += length + padding; + } + } + } + return STATUS_SUCCESS; +} + + _Success_(return == STATUS_SUCCESS) _Must_inspect_result_ _IRQL_requires_max_(PASSIVE_LEVEL) -NTSTATUS -DomitoCalculatePortableExecutableDigest( +static NTSTATUS +DomitoCalculatePortableExecutableDigestInternal( + _In_ UINT32 ExplicitCalg, _In_ PUCHAR pPeBytes, _In_ ULONG PeSize, _Out_ PUINT32 pDigestCalgOut, @@ -231,7 +472,8 @@ DomitoCalculatePortableExecutableDigest( BCRYPT_HASH_HANDLE hbHash = nullptr; ULONG hashLength = 0; ULONG resultLength = 0; - const UINT32 peDigestKind = DomitoGetPortableExecutableDigestKind(pPeBytes, PeSize, directories); + const UINT32 peDigestKind = ExplicitCalg ? ExplicitCalg : + DomitoGetPortableExecutableDigestKind(pPeBytes, PeSize, directories); NTSTATUS status = BCryptOpenAlgorithmProvider( &hbAlg, DOMITO_CALG_TO_BCRYPT_ALGORITHM(peDigestKind), MS_PRIMITIVE_PROVIDER, 0); if (!NT_SUCCESS(status)) @@ -315,6 +557,47 @@ cleanup: } +_Success_(return == STATUS_SUCCESS) +_Must_inspect_result_ +_IRQL_requires_max_(PASSIVE_LEVEL) +NTSTATUS +DomitoCalculatePortableExecutableDigest( + PUCHAR PeBytes, ULONG PeSize, PUINT32 DigestCalg, PULONG DigestSize, + PVOID* Digest, LPWIN_CERTIFICATE* Certificate, PULONG SecuritySize) +{ + return DomitoCalculatePortableExecutableDigestInternal(0, PeBytes, PeSize, + DigestCalg, DigestSize, Digest, Certificate, SecuritySize); +} + +_Success_(return == STATUS_SUCCESS) +_Must_inspect_result_ +_IRQL_requires_max_(PASSIVE_LEVEL) +NTSTATUS +DomitoCalculatePortableExecutableDigestEx( + const UCHAR* PeBytes, ULONG PeSize, UINT32 HashAlgId, PULONG DigestSize, + PVOID* Digest, LPWIN_CERTIFICATE* Certificate, PULONG SecuritySize) +{ + if (!DigestSize || !Digest || !Certificate || !SecuritySize) + return STATUS_INVALID_PARAMETER; + *DigestSize = 0; + *Digest = nullptr; + *Certificate = nullptr; + *SecuritySize = 0; + if (!PeBytes) + return STATUS_INVALID_PARAMETER; + if (HashAlgId != CALG_SHA1 && HashAlgId != CALG_SHA256 && + HashAlgId != CALG_SHA384 && HashAlgId != CALG_SHA512) + return STATUS_NOT_SUPPORTED; + NTSTATUS status = DomitoValidateAuthenticodeLayout(PeBytes, PeSize); + if (!NT_SUCCESS(status)) + return status; + UINT32 actualCalg = 0; + // Contiguous sorted sections make the validated section sequence identical + // to linear byte order; overlay precedes an EOF certificate table. + return DomitoCalculatePortableExecutableDigestInternal(HashAlgId, (PUCHAR)PeBytes, + PeSize, &actualCalg, DigestSize, Digest, Certificate, SecuritySize); +} + _IRQL_requires_max_(DISPATCH_LEVEL) void DomitoFreePortableExecutableDigest( @@ -333,8 +616,8 @@ _Success_(return == STATUS_SUCCESS) _Must_inspect_result_ _IRQL_requires_max_(PASSIVE_LEVEL) #pragma code_seg("PAGED") -NTSTATUS -DomitoValidateFileLegacyMode( +static NTSTATUS +DomitoValidateFileLegacyModeInternal( _In_ HANDLE FileHandle, _In_ PVOID Hash, _In_ UINT32 HashSize, @@ -342,11 +625,20 @@ DomitoValidateFileLegacyMode( _In_ const IMAGE_DATA_DIRECTORY * SecurityDirectory, _Inout_ MINCRYPT_POLICY_INFO * PolicyInfo, _Out_ LARGE_INTEGER * SigningTime, - _Inout_ MINCRYPT_POLICY_INFO * TimeStampPolicyInfo + _Inout_ MINCRYPT_POLICY_INFO * TimeStampPolicyInfo, + _In_ UINT32 AcceptedCatalogPolicyBits, + _In_ BOOLEAN RequireTrustedCatalog, + _Out_ PULONG ValidationSource ) { PAGED_CODE(); + if (!ValidationSource) + return STATUS_INVALID_PARAMETER; + *ValidationSource = 0; + if (!Hash || !SecurityDirectory || !PolicyInfo || !SigningTime || + !TimeStampPolicyInfo || PolicyInfo == TimeStampPolicyInfo) + return STATUS_INVALID_PARAMETER; NTSTATUS status = STATUS_SUCCESS; PVOID certDirectory = nullptr; KAPC_STATE systemContext = {}; @@ -358,15 +650,48 @@ DomitoValidateFileLegacyMode( CiFreePolicyInfo(PolicyInfo); CiFreePolicyInfo(TimeStampPolicyInfo); - if (HashSize != MINCRYPT_SHA1_LENGTH && HashSize != MINCRYPT_SHA256_LENGTH) + if (HashAlgId != CALG_SHA1 && HashAlgId != CALG_SHA256) { - status = STATUS_INVALID_IMAGE_HASH; + status = STATUS_NOT_SUPPORTED; + break; + } + if (HashSize != (UINT32)(HashAlgId == CALG_SHA1 ? MINCRYPT_SHA1_LENGTH : MINCRYPT_SHA256_LENGTH)) + { + status = STATUS_INVALID_PARAMETER; + break; + } + if ((SecurityDirectory->Size != 0) != (SecurityDirectory->VirtualAddress != 0)) + { + status = STATUS_INVALID_IMAGE_FORMAT; break; } if (SecurityDirectory->Size != 0u && SecurityDirectory->VirtualAddress != 0u) { + if ((SecurityDirectory->VirtualAddress & 7) || SecurityDirectory->Size < 12) + { + status = STATUS_INVALID_IMAGE_FORMAT; + break; + } + // Bound the allocation/read against the file before trusting its table. + PFILE_OBJECT fileObject = nullptr; + status = ObReferenceObjectByHandle(FileHandle, FILE_READ_DATA, *IoFileObjectType, + KernelMode, (PVOID*)&fileObject, nullptr); + if (!NT_SUCCESS(status)) + break; + LARGE_INTEGER fileSize = {}; + status = FsRtlGetFileSize(fileObject, &fileSize); + ObDereferenceObject(fileObject); + if (!NT_SUCCESS(status)) + break; + if (fileSize.QuadPart < 0 || + (ULONGLONG)SecurityDirectory->VirtualAddress > (ULONGLONG)fileSize.QuadPart || + SecurityDirectory->Size > (ULONGLONG)fileSize.QuadPart - SecurityDirectory->VirtualAddress) + { + status = STATUS_INVALID_IMAGE_FORMAT; + break; + } #pragma warning(disable:4996) certDirectory = ExAllocatePoolWithTag( PagedPool, @@ -381,44 +706,10 @@ DomitoValidateFileLegacyMode( } LARGE_INTEGER offset = {}; - IO_STATUS_BLOCK ioStatusBlock = {}; - - offset.LowPart = SecurityDirectory->VirtualAddress; - - status = ZwReadFile( - FileHandle, - NULL, - NULL, - NULL, - &ioStatusBlock, - certDirectory, - SecurityDirectory->Size, - &offset, - NULL - ); - - if (status == STATUS_PENDING) - { - ZwWaitForSingleObject( - FileHandle, - FALSE, - NULL - ); - - MemoryBarrier(); - status = ioStatusBlock.Status; - } - + offset.QuadPart = SecurityDirectory->VirtualAddress; + status = DomitoReadFileAt(FileHandle, certDirectory, SecurityDirectory->Size, &offset); if (!NT_SUCCESS(status)) - { break; - } - - if (ioStatusBlock.Information != SecurityDirectory->Size) - { - status = STATUS_END_OF_FILE; - break; - } KeStackAttachProcess(PsInitialSystemProcess, &systemContext); { @@ -437,6 +728,7 @@ DomitoValidateFileLegacyMode( if (NT_SUCCESS(status)) { + *ValidationSource = DOMITO_CI_SOURCE_EMBEDDED; break; } @@ -446,6 +738,9 @@ DomitoValidateFileLegacyMode( } } + CiFreePolicyInfo(PolicyInfo); + CiFreePolicyInfo(TimeStampPolicyInfo); + SigningTime->QuadPart = 0; KeStackAttachProcess(PsInitialSystemProcess, &systemContext); { status = CiVerifyHashInCatalog( @@ -453,8 +748,8 @@ DomitoValidateFileLegacyMode( HashSize, HashAlgId, FALSE, - 0, - 0x2007F, + RequireTrustedCatalog, + AcceptedCatalogPolicyBits, PolicyInfo, NULL, SigningTime, @@ -463,13 +758,16 @@ DomitoValidateFileLegacyMode( if (status == STATUS_INVALID_IMAGE_HASH) { + CiFreePolicyInfo(PolicyInfo); + CiFreePolicyInfo(TimeStampPolicyInfo); + SigningTime->QuadPart = 0; status = CiVerifyHashInCatalog( Hash, HashSize, HashAlgId, TRUE, - 0, - 0x2007F, + RequireTrustedCatalog, + AcceptedCatalogPolicyBits, PolicyInfo, NULL, SigningTime, @@ -478,6 +776,8 @@ DomitoValidateFileLegacyMode( } } KeUnstackDetachProcess(&systemContext); + if (NT_SUCCESS(status)) + *ValidationSource = DOMITO_CI_SOURCE_CATALOG; } while (FALSE); @@ -488,4 +788,34 @@ DomitoValidateFileLegacyMode( return status; } + +_Success_(return == STATUS_SUCCESS) +_Must_inspect_result_ +_IRQL_requires_max_(PASSIVE_LEVEL) +NTSTATUS +DomitoValidateFileLegacyMode( + HANDLE FileHandle, PVOID Hash, UINT32 HashSize, ALG_ID HashAlgId, + const IMAGE_DATA_DIRECTORY* SecurityDirectory, MINCRYPT_POLICY_INFO* PolicyInfo, + LARGE_INTEGER* SigningTime, MINCRYPT_POLICY_INFO* TimeStampPolicyInfo) +{ + ULONG source; + return DomitoValidateFileLegacyModeInternal(FileHandle, Hash, HashSize, HashAlgId, + SecurityDirectory, PolicyInfo, SigningTime, TimeStampPolicyInfo, + DOMITO_CI_LEGACY_CATALOG_POLICY_BITS, FALSE, &source); +} + +_Success_(return == STATUS_SUCCESS) +_Must_inspect_result_ +_IRQL_requires_max_(PASSIVE_LEVEL) +NTSTATUS +DomitoValidateFileLegacyModeEx( + HANDLE FileHandle, PVOID Hash, UINT32 HashSize, ALG_ID HashAlgId, + const IMAGE_DATA_DIRECTORY* SecurityDirectory, MINCRYPT_POLICY_INFO* PolicyInfo, + LARGE_INTEGER* SigningTime, MINCRYPT_POLICY_INFO* TimeStampPolicyInfo, + UINT32 AcceptedCatalogPolicyBits, BOOLEAN RequireTrustedCatalog, PULONG ValidationSource) +{ + return DomitoValidateFileLegacyModeInternal(FileHandle, Hash, HashSize, HashAlgId, + SecurityDirectory, PolicyInfo, SigningTime, TimeStampPolicyInfo, + AcceptedCatalogPolicyBits, RequireTrustedCatalog, ValidationSource); +} #pragma code_seg() diff --git a/src/Domito.cpp b/src/Domito.cpp index 41c9dab..1ea20e4 100644 --- a/src/Domito.cpp +++ b/src/Domito.cpp @@ -129,6 +129,13 @@ DomitoFindModuleBaseAddress( return status; } +// This helper handles directory RVAs after the loader-mapped header establishes +// SizeOfImage. ModuleBase is not an arbitrary file buffer (there is no size input). +static BOOLEAN DomitoImageRvaSpan(ULONG ImageSize, ULONG Rva, ULONG Count, ULONG ElementSize) +{ + return Rva <= ImageSize && ElementSize != 0 && Count <= (ImageSize - Rva) / ElementSize; +} + _Success_(return == STATUS_SUCCESS) _Must_inspect_result_ _IRQL_requires_max_(PASSIVE_LEVEL) @@ -139,56 +146,113 @@ DomitoFindExportedFunctionAddress( _Inout_opt_ PVOID * FunctionAddress ) { - NTSTATUS status = STATUS_NOT_FOUND; - ULONG exportSize; - - if (G_Common.RtlImageDirectoryEntryToData == NULL) + if (FunctionAddress) + { + *FunctionAddress = nullptr; + } + if (!ModuleBase || !FunctionName || !FunctionName->Buffer || !FunctionName->Length) + { + return STATUS_INVALID_PARAMETER; + } + if (!G_Common.RtlImageDirectoryEntryToData) { return STATUS_NOT_IMPLEMENTED; } - // Retrieve the export directory information - const PIMAGE_EXPORT_DIRECTORY exportDirectory = (PIMAGE_EXPORT_DIRECTORY)G_Common.RtlImageDirectoryEntryToData( - ModuleBase, - TRUE, - IMAGE_DIRECTORY_ENTRY_EXPORT, - &exportSize - ); - - if (exportDirectory == NULL) + // Initial headers belong to a valid loaded module, as required by this API. + const PUCHAR base = (PUCHAR)ModuleBase; + IMAGE_DOS_HEADER dos; + memcpy(&dos, base, sizeof(dos)); + if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew < (LONG)sizeof(dos)) + { + return STATUS_INVALID_IMAGE_FORMAT; + } + const PUCHAR nt = base + dos.e_lfanew; + ULONG signature; + memcpy(&signature, nt, sizeof(signature)); + USHORT magic; + memcpy(&magic, nt + sizeof(ULONG) + sizeof(IMAGE_FILE_HEADER), sizeof(magic)); + if (signature != IMAGE_NT_SIGNATURE || + (magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC && magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC)) + { + return STATUS_INVALID_IMAGE_FORMAT; + } + // SizeOfImage is at the same offset in PE32 and PE32+ optional headers. + ULONG imageSize; + memcpy(&imageSize, nt + sizeof(ULONG) + sizeof(IMAGE_FILE_HEADER) + + FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, SizeOfImage), sizeof(imageSize)); + if (!DomitoImageRvaSpan(imageSize, (ULONG)dos.e_lfanew, + sizeof(ULONG) + sizeof(IMAGE_FILE_HEADER) + + FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, SizeOfImage) + sizeof(imageSize), 1)) { return STATUS_INVALID_IMAGE_FORMAT; } - STRING currentFunctionName; - - const PULONG functionAddresses = (PULONG)((ULONG_PTR)ModuleBase + exportDirectory->AddressOfFunctions); - const PULONG functionNames = (PULONG)((ULONG_PTR)ModuleBase + exportDirectory->AddressOfNames); - const PUSHORT functionOrdinals = (PUSHORT)((ULONG_PTR)ModuleBase + exportDirectory->AddressOfNameOrdinals); - - for (ULONG i = 0; i < exportDirectory->NumberOfNames; i++) + ULONG exportSize = 0; + const PVOID directory = G_Common.RtlImageDirectoryEntryToData( + ModuleBase, TRUE, IMAGE_DIRECTORY_ENTRY_EXPORT, &exportSize); + const ULONG_PTR directoryOffset = (ULONG_PTR)directory - (ULONG_PTR)ModuleBase; + if (!directory || directoryOffset > imageSize || exportSize < sizeof(IMAGE_EXPORT_DIRECTORY) || + exportSize > imageSize - directoryOffset) { - const char* functionName = (const char*)((ULONG_PTR)ModuleBase + functionNames[i]); - const USHORT functionOrdinal = functionOrdinals[i]; - UNREFERENCED_PARAMETER(functionOrdinal); - - const ULONG functionRva = functionAddresses[i]; - const PVOID functionAddress = (PVOID)((ULONG_PTR)ModuleBase + functionRva); - - RtlInitAnsiString(¤tFunctionName, functionName); - - if (0 == RtlCompareString(FunctionName, ¤tFunctionName, TRUE)) - { - if (FunctionAddress) - { - status = STATUS_SUCCESS; - *FunctionAddress = functionAddress; - } - break; - } + return STATUS_INVALID_IMAGE_FORMAT; } - - return status; + IMAGE_EXPORT_DIRECTORY exports; + memcpy(&exports, directory, sizeof(exports)); + if (!DomitoImageRvaSpan(imageSize, exports.AddressOfFunctions, exports.NumberOfFunctions, sizeof(ULONG)) || + !DomitoImageRvaSpan(imageSize, exports.AddressOfNames, exports.NumberOfNames, sizeof(ULONG)) || + !DomitoImageRvaSpan(imageSize, exports.AddressOfNameOrdinals, exports.NumberOfNames, sizeof(USHORT))) + { + return STATUS_INVALID_IMAGE_FORMAT; + } + for (ULONG i = 0; i < exports.NumberOfNames; ++i) + { + ULONG nameRva; + USHORT ordinal; + memcpy(&nameRva, base + exports.AddressOfNames + i * sizeof(ULONG), sizeof(nameRva)); + memcpy(&ordinal, base + exports.AddressOfNameOrdinals + i * sizeof(USHORT), sizeof(ordinal)); + if (nameRva >= imageSize || ordinal >= exports.NumberOfFunctions) + { + return STATUS_INVALID_IMAGE_FORMAT; + } + ULONG length = 0; + while (length < imageSize - nameRva && base[nameRva + length] != 0) + { + ++length; + } + if (length == imageSize - nameRva || length > MAXUSHORT) + { + return STATUS_INVALID_IMAGE_FORMAT; + } + ANSI_STRING current; + current.Buffer = (PCHAR)(base + nameRva); + current.Length = (USHORT)length; + current.MaximumLength = (USHORT)length; + if (RtlCompareString(FunctionName, ¤t, TRUE) != 0) + { + continue; + } + ULONG functionRva; + memcpy(&functionRva, base + exports.AddressOfFunctions + ordinal * sizeof(ULONG), sizeof(functionRva)); + if (!functionRva) + { + return STATUS_NOT_FOUND; // Empty EAT slot. + } + if (functionRva >= imageSize) + { + return STATUS_INVALID_IMAGE_FORMAT; + } + if (functionRva >= directoryOffset && functionRva - directoryOffset < exportSize) + { + return STATUS_NOT_SUPPORTED; // Forwarder string, not a callable address. + } + if (FunctionAddress) + { + *FunctionAddress = base + functionRva; + } + return STATUS_SUCCESS; + } + return STATUS_NOT_FOUND; } _Success_(return == STATUS_SUCCESS) @@ -240,6 +304,68 @@ DomitoMemorySearchPattern( return STATUS_NOT_FOUND; } +_Success_(return == STATUS_SUCCESS) +_Must_inspect_result_ +_IRQL_requires_max_(PASSIVE_LEVEL) +NTSTATUS +DomitoReadFileAt( + _In_ HANDLE FileHandle, + _Out_ PVOID Buffer, + _In_ ULONG BufferSize, + _In_ const LARGE_INTEGER* ByteOffset +) +{ + if (!FileHandle || !ByteOffset || ByteOffset->QuadPart < 0 || (!Buffer && BufferSize)) + { + return STATUS_INVALID_PARAMETER; + } + if (!BufferSize) + { + return STATUS_SUCCESS; + } + + HANDLE eventHandle = nullptr; + OBJECT_ATTRIBUTES attributes; + InitializeObjectAttributes(&attributes, nullptr, OBJ_KERNEL_HANDLE, nullptr, nullptr); + NTSTATUS status = ZwCreateEvent(&eventHandle, EVENT_ALL_ACCESS, &attributes, + NotificationEvent, FALSE); + if (!NT_SUCCESS(status)) + { + return status; + } + PVOID eventObject = nullptr; + status = ObReferenceObjectByHandle(eventHandle, SYNCHRONIZE, *ExEventObjectType, + KernelMode, &eventObject, nullptr); + if (!NT_SUCCESS(status)) + { + ZwClose(eventHandle); + return status; + } + + IO_STATUS_BLOCK ioStatusBlock = {}; + LARGE_INTEGER offset = *ByteOffset; + status = ZwReadFile(FileHandle, eventHandle, nullptr, nullptr, &ioStatusBlock, + Buffer, BufferSize, &offset, nullptr); + if (status == STATUS_PENDING) + { + // A private event identifies this request even if the file has other I/O. + // KernelMode + nonalertable + no timeout cannot return before completion. + KeWaitForSingleObject(eventObject, Executive, KernelMode, FALSE, nullptr); + status = ioStatusBlock.Status; + } + if (NT_SUCCESS(status)) + { + status = ioStatusBlock.Status; + if (NT_SUCCESS(status) && ioStatusBlock.Information != BufferSize) + { + status = STATUS_END_OF_FILE; + } + } + ObDereferenceObject(eventObject); + ZwClose(eventHandle); + return status; +} + _Success_(return == STATUS_SUCCESS) _Must_inspect_result_ _IRQL_requires_max_(PASSIVE_LEVEL) @@ -250,33 +376,8 @@ DomitoReadFile( _In_ ULONG BufferSize ) { - NTSTATUS status = STATUS_SUCCESS; - IO_STATUS_BLOCK ioStatusBlock; - - // Read the file into memory using ZwReadFile - if (!NT_SUCCESS(status = ZwReadFile( - FileHandle, - NULL, - NULL, - NULL, - &ioStatusBlock, - Buffer, - BufferSize, - NULL, - NULL - ))) - { - return status; - } - - // Check if the file was read successfully - if (!NT_SUCCESS(ioStatusBlock.Status)) - { - return ioStatusBlock.Status; - } - - // File read successfully - return status; + const LARGE_INTEGER offset = {}; + return DomitoReadFileAt(FileHandle, Buffer, BufferSize, &offset); } _Success_(return == STATUS_SUCCESS) @@ -306,7 +407,7 @@ DomitoGetProcessImageName( } CLIENT_ID cid; - cid.UniqueProcess = (HANDLE)ProcessId; + cid.UniqueProcess = ULongToHandle(ProcessId); cid.UniqueThread = NULL; OBJECT_ATTRIBUTES objAttr; diff --git a/src/Domito.vcxproj b/src/Domito.vcxproj index 760de2e..83e45d4 100644 --- a/src/Domito.vcxproj +++ b/src/Domito.vcxproj @@ -127,6 +127,7 @@ + diff --git a/src/Domito.vcxproj.filters b/src/Domito.vcxproj.filters index 1b7ac8a..08dd0ce 100644 --- a/src/Domito.vcxproj.filters +++ b/src/Domito.vcxproj.filters @@ -39,6 +39,9 @@ Header Files\Public + + Header Files\Public + Header Files\Public diff --git a/tests/README.md b/tests/README.md index 9888878..e639c3f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,30 +1,62 @@ -# Code Integrity regression tests +# Regression tests From an **x64 Visual Studio developer shell**, with the Windows SDK and Python 3: ```powershell python tests\run-digest-tests.py +python tests\run-common-tests.py +python tests\run-ci-probe-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. +Build outputs are temporary. No driver is installed or loaded. Test runners +extract the actual production functions or include the probe body unchanged; +kernel calls, CI calls, and pool allocation are replaced with test doubles. +The digest harness uses real BCrypt, Crypt32 and the Windows PE SIP provider. -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. +## Digest and legacy verification tests -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. +Coverage includes PE32/PE32+ files, all four supported hash algorithms, checksum +and certificate exclusions, overlays, reverse section-header order, multiple +certificate entries, certificate padding, unsupported section layouts, bounded +DER algorithm discovery, SHA-1 pattern decoys, truncated ASN.1, explicit catalog +policy forwarding and cleanup between failed/successful CI attempts. + +The test compares digests against `CryptSIPCreateIndirectData` and reads the +installed system `ci.dll`, `ntdll.dll`, `kernel32.dll`, and `notepad.exe` as a +real-file corpus. For embedded signatures, Crypt32 independently decodes the SPC +algorithm and expected file digest. These checks do not verify signature trust. + +Guard pages cover truncated headers and invalid ranges in both compatibility +and strict paths. Fault injection covers allocation/hash failures, deterministic +outputs, short certificate reads and digest ownership. A DOS stub larger than +4 MiB exercises the removed scratch-buffer limit. Older entry points retain +legacy selection/layout behavior; strict Ex APIs enforce their documented subset. + +## File and export helper tests + +The common harness checks explicit and zero offsets, exact/short/failed reads, +pending completion, event/object cleanup, and error propagation. Export fixtures +cover nonidentity name-ordinal mappings, forwarders, empty slots, invalid spans, +unbounded strings, and deterministic failure outputs. The export API requires a +valid loader-mapped module; it is not an arbitrary file parser. ## File-object validation flag probe See [the flag documentation and VM procedure](../docs/ci-validate-file-object.md) -for `CiValidateFileObject` flag `0x20`. Run `python tests\run-ci-probe-tests.py` -from the same developer shell to test probe plumbing with mock CI. The kernel -probe must still be run in a suitable VM to verify actual policy behavior. +for `CiValidateFileObject` flag `0x20`. The host runner tests probe plumbing with +mock CI. The kernel probe must still run in a suitable VM to verify actual +policy behavior. + +## Kernel compile checks and remaining validation + +`mincrypt-layout-tests.cpp` asserts default modern x64/ARM64 and pre-Windows-10 +compile layouts. On x64 it also includes the opt-in observed 26100 header's 14 +layout assertions. Compile it with the same WDK/kernel architecture flags and +include paths as the library. This checks structure definitions, not runtime +compatibility on those systems. `ci-file-object-probe.cpp` is separately compiled +as kernel code for x64/ARM64; its real CI behavior is still untested. + +The host tests do not establish CI trust policy, real asynchronous kernel I/O, +real policy ownership under Driver Verifier, revocation/timestamp behavior, or +all Windows-version/layout combinations. See the +[implementation ledger](../docs/implementation-status.md) for the remaining work. diff --git a/tests/common-tests.cpp b/tests/common-tests.cpp new file mode 100644 index 0000000..25c2c64 --- /dev/null +++ b/tests/common-tests.cpp @@ -0,0 +1,152 @@ +// Tests extract the unchanged production bodies. Kernel I/O is simulated. +#define WIN32_NO_STATUS +#include +#undef WIN32_NO_STATUS +#include +#include +#include +#include +#include +#include +#define NT_SUCCESS(s) (((NTSTATUS)(s)) >= 0) +#define MAXUSHORT 0xffff +#define Executive 0 +#define KernelMode 0 +#define NotificationEvent 0 +#define OBJ_KERNEL_HANDLE 0x200 +#define InitializeObjectAttributes(a,n,f,r,s) ((a)->Attributes=(f)) +struct OBJECT_ATTRIBUTES { ULONG Attributes; }; +struct ANSI_STRING { USHORT Length, MaximumLength; PCHAR Buffer; }; +typedef ANSI_STRING* PANSI_STRING; +struct IO_STATUS_BLOCK { NTSTATUS Status; ULONG_PTR Information; }; +static int objectType; +static void* objectTypeValue = &objectType; +static void** ExEventObjectType = &objectTypeValue; +static unsigned checks; +static void Check(bool value, const char* label) { + ++checks; + if (!value) { std::fprintf(stderr, "FAIL: %s\n", label); std::exit(1); } +} +static LONG RtlCompareString(PANSI_STRING a, PANSI_STRING b, BOOLEAN insensitive) { + if (a->Length != b->Length) return 1; + for (USHORT i = 0; i < a->Length; ++i) { + unsigned char x = (unsigned char)a->Buffer[i], y = (unsigned char)b->Buffer[i]; + if (insensitive) { x = (unsigned char)std::tolower(x); y = (unsigned char)std::tolower(y); } + if (x != y) return 1; + } + return 0; +} +static void* exportPointer; +static ULONG exportBytes; +static PVOID Directory(PVOID, BOOLEAN mapped, USHORT index, PULONG bytes) { + Check(mapped && index == IMAGE_DIRECTORY_ENTRY_EXPORT, "mapped export lookup"); + *bytes = exportBytes; return exportPointer; +} +struct { PVOID (*RtlImageDirectoryEntryToData)(PVOID, BOOLEAN, USHORT, PULONG); } G_Common = { Directory }; +static int eventToken, fileToken; +static unsigned created, referenced, closed, dereferenced, reads, waits; +static int ioMode; +static bool outstanding; +static IO_STATUS_BLOCK* pendingIosb; +static ULONG requested; +static LONGLONG expectedOffset; +static NTSTATUS ZwCreateEvent(HANDLE* event, ACCESS_MASK access, OBJECT_ATTRIBUTES* attributes, int type, BOOLEAN signaled) { + Check(access == EVENT_ALL_ACCESS && attributes->Attributes == OBJ_KERNEL_HANDLE && type == NotificationEvent && !signaled, "private kernel event"); + if (ioMode == 4) return STATUS_INSUFFICIENT_RESOURCES; + ++created; *event = &eventToken; return STATUS_SUCCESS; +} +static NTSTATUS ObReferenceObjectByHandle(HANDLE event, ACCESS_MASK access, void* type, int mode, void** object, void*) { + Check(event == &eventToken && access == SYNCHRONIZE && type == *ExEventObjectType && mode == KernelMode, "reference own event"); + if (ioMode == 5) return STATUS_ACCESS_DENIED; + ++referenced; *object = &eventToken; return STATUS_SUCCESS; +} +static void ZwClose(HANDLE event) { + Check(event == &eventToken && !outstanding, "close only after completion"); ++closed; +} +static void ObDereferenceObject(void* object) { + Check(object == &eventToken && !outstanding, "dereference only after completion"); ++dereferenced; +} +static NTSTATUS ZwReadFile(HANDLE file, HANDLE event, void*, void*, IO_STATUS_BLOCK* iosb, void*, ULONG size, LARGE_INTEGER* offset, void*) { + ++reads; + Check(file == &fileToken && event == &eventToken && offset && offset->QuadPart == expectedOffset, "file and explicit offset"); + Check(iosb->Status == 0 && iosb->Information == 0, "initialized IOSB"); + requested = size; + if (ioMode == 6) return STATUS_ACCESS_DENIED; + if (ioMode == 1 || ioMode == 2) { outstanding = true; pendingIosb = iosb; return STATUS_PENDING; } + iosb->Status = STATUS_SUCCESS; iosb->Information = ioMode == 3 ? size - 1 : size; + return STATUS_SUCCESS; +} +static NTSTATUS KeWaitForSingleObject(void* object, int reason, int mode, BOOLEAN alertable, LARGE_INTEGER* timeout) { + ++waits; + Check(object == &eventToken && reason == Executive && mode == KernelMode && !alertable && !timeout && outstanding, "noninterruptible per-request wait"); + outstanding = false; + pendingIosb->Status = ioMode == 2 ? STATUS_END_OF_FILE : STATUS_SUCCESS; + pendingIosb->Information = ioMode == 2 ? 0 : requested; + return STATUS_SUCCESS; +} +#include "common-under-test.inc" +static std::vector Image() { + std::vector image(4096); + auto dos = (IMAGE_DOS_HEADER*)image.data(); dos->e_magic = IMAGE_DOS_SIGNATURE; dos->e_lfanew = 128; + auto nt = (IMAGE_NT_HEADERS64*)(image.data() + 128); nt->Signature = IMAGE_NT_SIGNATURE; + nt->OptionalHeader.Magic = IMAGE_NT_OPTIONAL_HDR64_MAGIC; nt->OptionalHeader.SizeOfImage = (ULONG)image.size(); + auto exports = (IMAGE_EXPORT_DIRECTORY*)(image.data() + 512); + exports->NumberOfFunctions = 3; exports->NumberOfNames = 2; + exports->AddressOfFunctions = 801; exports->AddressOfNames = 901; exports->AddressOfNameOrdinals = 1001; + const ULONG functions[] = {2000, 2100, 2200}, names[] = {1100, 1120}; + const USHORT ordinals[] = {2, 0}; + std::memcpy(image.data()+801, functions, sizeof(functions)); std::memcpy(image.data()+901, names, sizeof(names)); + std::memcpy(image.data()+1001, ordinals, sizeof(ordinals)); + std::memcpy(image.data()+1100, "Beta", 5); std::memcpy(image.data()+1120, "Alpha", 6); + return image; +} +static NTSTATUS Lookup(std::vector& image, const char* name, PVOID* output) { + ANSI_STRING string = {(USHORT)std::strlen(name), (USHORT)std::strlen(name), (PCHAR)name}; + exportPointer = image.data() + 512; exportBytes = 256; + return DomitoFindExportedFunctionAddress(image.data(), &string, output); +} +int main() { + auto image = Image(); PVOID output = nullptr; + Check(Lookup(image,"Beta",&output) == STATUS_SUCCESS && output == image.data()+2200, "ordinal-indexed EAT"); + Check(Lookup(image,"aLpHa",&output) == STATUS_SUCCESS && output == image.data()+2000, "case-insensitive name"); + Check(Lookup(image,"Alpha",nullptr) == STATUS_SUCCESS, "optional output discovery"); + Check(Lookup(image,"missing",&output) == STATUS_NOT_FOUND && !output, "notfound clears output"); + auto exports = (IMAGE_EXPORT_DIRECTORY*)(image.data()+512); + const USHORT invalidOrdinal = 3; std::memcpy(image.data()+1001,&invalidOrdinal,2); + Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT && !output, "ordinal bounded"); + image=Image(); const ULONG forwarder=600; std::memcpy(image.data()+801+8,&forwarder,4); + Check(Lookup(image,"Beta",&output) == STATUS_NOT_SUPPORTED && !output, "forwarder rejected"); + image=Image(); const ULONG zero=0; std::memcpy(image.data()+801+8,&zero,4); + Check(Lookup(image,"Beta",&output) == STATUS_NOT_FOUND, "empty EAT slot"); + image=Image(); const ULONG outside=4096; std::memcpy(image.data()+801+8,&outside,4); + Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "function RVA bounded"); + image=Image(); exports=(IMAGE_EXPORT_DIRECTORY*)(image.data()+512); exports->NumberOfNames=0xffffffff; + Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "table multiplication bounded"); + image=Image(); exports=(IMAGE_EXPORT_DIRECTORY*)(image.data()+512); exports->AddressOfFunctions=4090; + Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "EAT span bounded"); + image=Image(); const ULONG lateName=4094; std::memcpy(image.data()+901,&lateName,4); image[4094]='x';image[4095]='x'; + Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "unterminated name bounded"); + image=Image(); std::memcpy(image.data()+901,&outside,4); + Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "name RVA bounded"); + image=Image(); ANSI_STRING name={4,4,(PCHAR)"Beta"}; exportPointer=image.data()+512; exportBytes=4096; + Check(DomitoFindExportedFunctionAddress(image.data(),&name,&output)==STATUS_INVALID_IMAGE_FORMAT, "export directory span bounded"); + output=(void*)1; Check(DomitoFindExportedFunctionAddress(nullptr,&name,&output)==STATUS_INVALID_PARAMETER && !output,"invalid arguments clear output"); + char buffer[32]; + for (ioMode=0;ioMode<=6;++ioMode) { + created=referenced=closed=dereferenced=reads=waits=0; outstanding=false; expectedOffset=123; + LARGE_INTEGER offset;offset.QuadPart=expectedOffset; + NTSTATUS status=DomitoReadFileAt(&fileToken,buffer,sizeof(buffer),&offset); + const NTSTATUS expected[]={STATUS_SUCCESS,STATUS_SUCCESS,STATUS_END_OF_FILE,STATUS_END_OF_FILE,STATUS_INSUFFICIENT_RESOURCES,STATUS_ACCESS_DENIED,STATUS_ACCESS_DENIED}; + Check(status==expected[ioMode],"read status and exact-byte contract"); + Check(created==closed && referenced==dereferenced && !outstanding,"event lifetime balanced"); + Check(waits==((ioMode==1||ioMode==2)?1u:0u),"wait pending only"); + } + ioMode=0; expectedOffset=0; Check(DomitoReadFile(&fileToken,buffer,sizeof(buffer))==STATUS_SUCCESS,"zero-offset wrapper"); + LARGE_INTEGER offset={}; offset.QuadPart=-1; + Check(DomitoReadFileAt(&fileToken,buffer,sizeof(buffer),&offset)==STATUS_INVALID_PARAMETER,"negative offset rejected"); + Check(DomitoReadFileAt(&fileToken,buffer,sizeof(buffer),nullptr)==STATUS_INVALID_PARAMETER,"null offset rejected"); + offset.QuadPart=0; + Check(DomitoReadFileAt(&fileToken,nullptr,0,&offset)==STATUS_SUCCESS,"zero byte read"); + Check(DomitoReadFileAt(&fileToken,nullptr,1,&offset)==STATUS_INVALID_PARAMETER,"null buffer rejected"); + std::printf("PASS: %u common-helper checks (mock kernel I/O)\n", checks); +} diff --git a/tests/digest-tests.cpp b/tests/digest-tests.cpp index 1a85994..227db68 100644 --- a/tests/digest-tests.cpp +++ b/tests/digest-tests.cpp @@ -6,10 +6,12 @@ #include #include #include +#include #include #include #include #include +#include #define NT_SUCCESS(s) (((NTSTATUS)(s)) >= 0) #define ASSERT(x) Check(!!(x), #x) @@ -19,11 +21,21 @@ #define PAGED_CODE() ((void)0) #define MINCRYPT_SHA1_LENGTH 20 #define MINCRYPT_SHA256_LENGTH 32 +#define DOMITO_CI_LEGACY_CATALOG_POLICY_BITS 0x0002007FUL +#define DOMITO_CI_SOURCE_EMBEDDED 1UL +#define DOMITO_CI_SOURCE_CATALOG 2UL typedef const UCHAR* PCUCHAR; static unsigned checks, allocations, ciCalls, hashCalls; static int failOperation, failHashCall; static ULONG_PTR bytesRead; +static bool allocatePolicies; +static NTSTATUS embeddedResult = STATUS_SUCCESS; +static NTSTATUS catalogResults[2] = {STATUS_SUCCESS, STATUS_SUCCESS}; +static unsigned catalogCalls; +static ULONG expectedCatalogMask = DOMITO_CI_LEGACY_CATALOG_POLICY_BITS; +static BOOLEAN expectedTrustedMarker; +static LONGLONG observedFileSize = 1056; static void Check(bool ok, const char* message) { ++checks; @@ -61,33 +73,67 @@ static NTSTATUS TestFinishHash(BCRYPT_HASH_HANDLE h, PUCHAR b, ULONG n, ULONG f) } // Minimal dependencies for the legacy wrapper; no actual CI code is invoked. -struct MINCRYPT_POLICY_INFO { ULONG Size; }; +struct MINCRYPT_POLICY_INFO { ULONG Size; PVOID ChainInfo; }; 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 CiFreePolicyInfo(MINCRYPT_POLICY_INFO* p) { + if (p->ChainInfo) ExFreePoolWithTag(p->ChainInfo, DOMITO_POOL_TAG); + *p = {}; +} +static void PopulatePolicies(MINCRYPT_POLICY_INFO* policy, MINCRYPT_POLICY_INFO* timestamp) +{ + Check(!policy->ChainInfo && !timestamp->ChainInfo, "previous CI attempt released before reuse"); + if (allocatePolicies) { + policy->Size = timestamp->Size = sizeof(*policy); + policy->ChainInfo = ExAllocatePoolWithTag(PagedPool, 1, DOMITO_POOL_TAG); + timestamp->ChainInfo = ExAllocatePoolWithTag(PagedPool, 1, DOMITO_POOL_TAG); + } +} 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) +typedef PVOID PFILE_OBJECT; +static PVOID fileObjectType; +static PVOID* IoFileObjectType = &fileObjectType; +#define KernelMode 0 +static NTSTATUS ObReferenceObjectByHandle(HANDLE, ULONG, PVOID, int, PVOID* object, PVOID) { - io->Status = STATUS_SUCCESS; - io->Information = bytesRead; + *object = &fileObjectType; + return STATUS_SUCCESS; +} +static void ObDereferenceObject(PVOID) {} +static NTSTATUS FsRtlGetFileSize(PFILE_OBJECT, LARGE_INTEGER* size) +{ + size->QuadPart = observedFileSize; + return STATUS_SUCCESS; +} +static NTSTATUS DomitoReadFileAt(HANDLE, PVOID buffer, ULONG size, const LARGE_INTEGER* offset) +{ + Check(offset->QuadPart == 1024, "explicit certificate read offset"); std::memset(buffer, 0, (size_t)min(bytesRead, (ULONG_PTR)size)); - return STATUS_SUCCESS; + return bytesRead == size ? STATUS_SUCCESS : STATUS_END_OF_FILE; } -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*) + MINCRYPT_POLICY_INFO* policy, LARGE_INTEGER* time, MINCRYPT_POLICY_INFO* timestamp) { ++ciCalls; - return STATUS_SUCCESS; + Check(time->QuadPart == 0, "fresh signing time for embedded validation"); + PopulatePolicies(policy, timestamp); + time->QuadPart = 123; + return embeddedResult; } -static NTSTATUS CiVerifyHashInCatalog(PVOID, UINT32, ALG_ID, BOOLEAN, UINT32, UINT32, - MINCRYPT_POLICY_INFO*, PVOID, LARGE_INTEGER*, MINCRYPT_POLICY_INFO*) +static NTSTATUS CiVerifyHashInCatalog(PVOID, UINT32, ALG_ID, BOOLEAN reload, + UINT32 trusted, UINT32 mask, MINCRYPT_POLICY_INFO* policy, PVOID, + LARGE_INTEGER* time, MINCRYPT_POLICY_INFO* timestamp) { ++ciCalls; - return STATUS_SUCCESS; + Check(mask == expectedCatalogMask && trusted == expectedTrustedMarker, "explicit catalog policy forwarded"); + Check(reload == (catalogCalls != 0), "catalog reload sequence"); + Check(time->QuadPart == 0, "fresh signing time for catalog attempt"); + PopulatePolicies(policy, timestamp); + time->QuadPart = 456; + Check(catalogCalls < 2, "bounded catalog retries"); + return catalogResults[catalogCalls++]; } #define BCryptCreateHash TestCreateHash @@ -108,21 +154,32 @@ static std::vector Image(bool pe64, ULONG nt = 128, bool certificate = fa std::memcpy(b.data() + nt, &signature, sizeof(signature)); IMAGE_FILE_HEADER f = {}; f.NumberOfSections = 1; + f.Machine = pe64 ? IMAGE_FILE_MACHINE_AMD64 : IMAGE_FILE_MACHINE_I386; 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.SectionAlignment = h32.SectionAlignment = 4096; + h64.FileAlignment = h32.FileAlignment = 512; + h64.SizeOfImage = h32.SizeOfImage = 8192; + h64.Subsystem = h32.Subsystem = IMAGE_SUBSYSTEM_WINDOWS_CUI; 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)); + USHORT revision = 0x0200, type = WIN_CERT_TYPE_PKCS_SIGNED_DATA; + std::memcpy(b.data() + nt + 900, &revision, 2); + std::memcpy(b.data() + nt + 902, &type, 2); } std::memcpy(b.data() + nt + 24, pe64 ? (void*)&h64 : (void*)&h32, f.SizeOfOptionalHeader); IMAGE_SECTION_HEADER section = {}; + section.VirtualAddress = 4096; + section.Misc.VirtualSize = 512; + section.Characteristics = IMAGE_SCN_CNT_CODE | IMAGE_SCN_MEM_READ; section.PointerToRawData = nt + 384; section.SizeOfRawData = 512; std::memcpy(b.data() + nt + 24 + f.SizeOfOptionalHeader, §ion, sizeof(section)); for (ULONG i = nt + 384; i < nt + 896; ++i) b[i] = (UCHAR)(i * 31); @@ -187,10 +244,295 @@ static void GuardedInvalid(const std::vector& b, NTSTATUS expected) UCHAR* input = region + span - b.size(); std::memcpy(input, b.data(), b.size()); Digest(input, (ULONG)b.size(), expected); + ULONG strictSize = 0, securitySize = 0; + PVOID strictDigest = nullptr; + LPWIN_CERTIFICATE certificate = nullptr; + Check(!NT_SUCCESS(DomitoCalculatePortableExecutableDigestEx(input, (ULONG)b.size(), CALG_SHA256, + &strictSize, &strictDigest, &certificate, &securitySize)), "strict guard-page malformed input rejected"); + Check(!strictSize && !strictDigest && !certificate && !securitySize, "strict malformed outputs cleared"); VirtualFree(region, 0, MEM_RELEASE); } +static std::vector ExplicitDigest(const std::vector& b, UINT32 calg, + NTSTATUS expected = STATUS_SUCCESS) +{ + ULONG length = 0xdead, certSize = 0xdead; + PVOID digest = (PVOID)1; + LPWIN_CERTIFICATE cert = (LPWIN_CERTIFICATE)1; + NTSTATUS status = DomitoCalculatePortableExecutableDigestEx(b.data(), (ULONG)b.size(), + calg, &length, &digest, &cert, &certSize); + Check(status == expected, "explicit digest status"); + std::vector result; + if (NT_SUCCESS(status)) + { + Check(length == (calg == CALG_SHA1 ? 20u : calg == CALG_SHA256 ? 32u : + calg == CALG_SHA384 ? 48u : 64u), "explicit algorithm digest length"); + result.assign((UCHAR*)digest, (UCHAR*)digest + length); + DomitoFreePortableExecutableDigest(digest); + } + else Check(!length && !digest && !cert && !certSize, "explicit failure outputs cleared"); + Check(allocations == 0, "explicit digest allocation cleanup"); + return result; +} + +static std::vector SipDigest(const std::vector& b, UINT32 calg) +{ + WCHAR tempPath[MAX_PATH], tempName[MAX_PATH]; + Check(GetTempPathW(MAX_PATH, tempPath) != 0, "temp path"); + Check(GetTempFileNameW(tempPath, L"dci", 0, tempName) != 0, "temp name"); + HANDLE file = CreateFileW(tempName, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, + nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr); + Check(file != INVALID_HANDLE_VALUE, "SIP fixture file"); + DWORD written = 0; + Check(WriteFile(file, b.data(), (DWORD)b.size(), &written, nullptr) && written == b.size(), "write SIP fixture"); + GUID subject; + Check(CryptSIPRetrieveSubjectGuid(tempName, file, &subject) != FALSE, "PE SIP subject"); + SIP_SUBJECTINFO info = {}; + info.cbSize = sizeof(info); + info.pgSubjectType = &subject; + info.hFile = file; + info.pwsFileName = tempName; + info.dwEncodingType = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING; + info.DigestAlgorithm.pszObjId = const_cast(calg == CALG_SHA1 ? szOID_OIWSEC_sha1 : + calg == CALG_SHA256 ? szOID_NIST_sha256 : calg == CALG_SHA384 ? szOID_NIST_sha384 : szOID_NIST_sha512); + DWORD size = 0; + Check(CryptSIPCreateIndirectData(&info, &size, nullptr) != FALSE, "SIP size query"); + std::vector buffer(size); + auto indirect = (SIP_INDIRECT_DATA*)buffer.data(); + Check(CryptSIPCreateIndirectData(&info, &size, indirect) != FALSE, "Windows SIP digest"); + std::vector result(indirect->Digest.pbData, indirect->Digest.pbData + indirect->Digest.cbData); + CloseHandle(file); + return result; +} + +static std::vector Der(UCHAR tag, const std::vector& value) +{ + std::vector out = {tag}; + if (value.size() < 128) out.push_back((UCHAR)value.size()); + else if (value.size() < 256) { out.push_back(0x81); out.push_back((UCHAR)value.size()); } + else { out.push_back(0x82); out.push_back((UCHAR)(value.size() >> 8)); out.push_back((UCHAR)value.size()); } + out.insert(out.end(), value.begin(), value.end()); + return out; +} +static std::vector Join(std::initializer_list> values) +{ + std::vector out; + for (auto& value : values) out.insert(out.end(), value.begin(), value.end()); + return out; +} +static std::vector SignedContent(UINT32 calg, bool nullParameters = true) +{ + std::vector oid = calg == CALG_SHA1 ? std::vector{0x2B,0x0E,0x03,0x02,0x1A} : + std::vector{0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x02, + (UCHAR)(calg == CALG_SHA256 ? 1 : calg == CALG_SHA384 ? 2 : 3)}; + auto algorithm = Der(0x30, Join({Der(0x06, oid), nullParameters ? Der(0x05, {}) : std::vector{}})); + auto digest = Der(0x30, Join({algorithm, Der(0x04, std::vector( + calg == CALG_SHA1 ? 20 : calg == CALG_SHA256 ? 32 : calg == CALG_SHA384 ? 48 : 64, 0xAA))})); + // A complete SHA-1 DigestInfo decoy in the ignored data attribute must not + // override the selected SpcIndirectDataContent digest AlgorithmIdentifier. + auto data = Der(0x30, Der(0x04, {0x30,0x21,0x30,0x09,0x06,0x05,0x2B,0x0E,0x03,0x02,0x1A,0x05,0,0x04,0x14})); + auto indirect = Der(0x30, Join({data, digest})); + auto content = Der(0x30, Join({Der(0x06, {0x2B,0x06,0x01,0x04,0x01,0x82,0x37,0x02,0x01,0x04}), Der(0xA0, indirect)})); + auto signedData = Der(0x30, Join({Der(0x02, {1}), Der(0x31, algorithm), content, Der(0x31, {})})); + return Der(0x30, Join({Der(0x06, {0x2A,0x86,0x48,0x86,0xF7,0x0D,0x01,0x07,0x02}), Der(0xA0, signedData)})); +} +static void ExtendedTests() +{ + for (bool pe64 : {false, true}) + for (bool cert : {false, true}) + for (UINT32 calg : std::initializer_list{CALG_SHA1, CALG_SHA256, CALG_SHA384, CALG_SHA512}) + { + auto b = Image(pe64, 128, cert); + Check(ExplicitDigest(b, calg) == SipDigest(b, calg), "Windows SIP differential fixture"); + if (!cert) + { + b.insert(b.end(), 48, 0x42); // Non-section overlay. + Check(ExplicitDigest(b, calg) == SipDigest(b, calg), "Windows SIP overlay differential"); + } + } + for (bool pe64 : {false, true}) + for (ULONG count : {0UL, 4UL}) + { + auto absent = Image(pe64); + Set32(absent, pe64 ? 260 : 244, count); + ExplicitDigest(absent, CALG_SHA256, STATUS_NOT_SUPPORTED); + } + auto b = Image(true); + // Two contiguous sections whose headers are in reverse raw-offset order. + Set16(b, 134, 2); + IMAGE_SECTION_HEADER first = {}, second = {}; + first.PointerToRawData = 768; first.SizeOfRawData = 256; + second.PointerToRawData = 512; second.SizeOfRawData = 256; + std::memcpy(b.data() + 392, &first, sizeof(first)); + std::memcpy(b.data() + 432, &second, sizeof(second)); + Check(ExplicitDigest(b, CALG_SHA256) == SipDigest(b, CALG_SHA256), "section header order differential"); + Set32(b, 392 + 20, 800); + ExplicitDigest(b, CALG_SHA256, STATUS_INVALID_IMAGE_FORMAT); // extends beyond EOF + Set32(b, 392 + 20, 640); + ExplicitDigest(b, CALG_SHA256, STATUS_NOT_SUPPORTED); // overlap + b = Image(true); + Set32(b, 392 + 20, 528); Set32(b, 392 + 16, 496); + ExplicitDigest(b, CALG_SHA256, STATUS_NOT_SUPPORTED); // gap + b = Image(true, 128, true); + b.insert(b.end(), 8, 0); + ExplicitDigest(b, CALG_SHA256, STATUS_NOT_SUPPORTED); // nonterminal certificate + b = Image(true); + Set32(b, 392 + 20, 0xFFFFFFFF); + ExplicitDigest(b, CALG_SHA256, STATUS_INVALID_IMAGE_FORMAT); + ExplicitDigest(Image(true), 0x1234, STATUS_NOT_SUPPORTED); + b = Image(true, 128, true); + b.resize(1088); + Set32(b, 300, 64); + Set32(b, 1056, 32); Set16(b, 1060, 0x0200); Set16(b, 1062, 2); + Check(ExplicitDigest(b, CALG_SHA256) == SipDigest(b, CALG_SHA256), "multiple certificate entries excluded"); + Set32(b, 1056, 0); + ExplicitDigest(b, CALG_SHA256, STATUS_INVALID_IMAGE_FORMAT); + b = Image(true, 128, true); + Set32(b, 1024, 31); // Last entry's alignment padding is outside dwLength. + Check(ExplicitDigest(b, CALG_SHA256) == SipDigest(b, CALG_SHA256), "certificate alignment padding"); + for (int failure : {1, 2, 3, 4}) + { + failOperation = failure; + failHashCall = 1; + hashCalls = 0; + ExplicitDigest(Image(true), CALG_SHA256, + failure == 4 ? STATUS_INSUFFICIENT_RESOURCES : STATUS_UNSUCCESSFUL); + } + failOperation = 0; + for (UINT32 calg : std::initializer_list{CALG_SHA1, CALG_SHA256, CALG_SHA384, CALG_SHA512}) + { + auto content = SignedContent(calg); + auto signedImage = Image(true); + ULONG certLength = 8 + (ULONG)content.size(); + ULONG tableSize = (certLength + 7) & ~7u; + signedImage.resize(1024 + tableSize); + Set32(signedImage, 296, 1024); Set32(signedImage, 300, tableSize); + Set32(signedImage, 1024, certLength); + Set16(signedImage, 1028, 0x0200); Set16(signedImage, 1030, 2); + std::memcpy(signedImage.data() + 1032, content.data(), content.size()); + IMAGE_DATA_DIRECTORY directories[5] = {}; + directories[4] = {1024, tableSize}; + UINT32 actual = 0; + Check(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), (ULONG)signedImage.size(), + directories, &actual) == STATUS_SUCCESS && actual == calg, "bounded SPC digest algorithm, decoy ignored"); + for (ULONG cut = 0; cut < content.size(); ++cut) + { + Set32(signedImage, 1024, cut + 8); + actual = 0xDEAD; + Check(!NT_SUCCESS(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), + (ULONG)signedImage.size(), directories, &actual)) && actual == 0, "truncated DER rejected"); + } + Set32(signedImage, 1024, tableSize); + Check(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), (ULONG)signedImage.size(), + directories, &actual) == STATUS_SUCCESS && actual == calg, "bounded zero padding inside dwLength"); + if (tableSize > certLength) + { + signedImage.back() = 0x7F; + Check(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), (ULONG)signedImage.size(), + directories, &actual) == STATUS_INVALID_IMAGE_FORMAT && actual == 0, "nonzero DER trailing bytes rejected"); + signedImage.back() = 0; + } + Set32(signedImage, 1024, certLength); + signedImage[1033] = 0x80; // Indefinite length forbidden in this DER subset. + Check(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), (ULONG)signedImage.size(), + directories, &actual) == STATUS_INVALID_IMAGE_FORMAT, "indefinite DER rejected"); + } + for (bool nullParameters : {false, true}) + { + auto content = SignedContent(CALG_SHA256, nullParameters); + auto pe = Image(true); + const ULONG length = 8 + (ULONG)content.size(); + const ULONG tableSize = (length + 7) & ~7u; + pe.resize(1024 + tableSize); + Set32(pe, 1024, length); Set16(pe, 1028, 0x0200); Set16(pe, 1030, 2); + std::memcpy(pe.data() + 1032, content.data(), content.size()); + IMAGE_DATA_DIRECTORY dirs[5] = {}; + dirs[4] = {1024, tableSize}; + UINT32 algorithm = 0; + Check(DomitoGetPortableExecutableDigestKindEx(pe.data(), (ULONG)pe.size(), dirs, &algorithm) == STATUS_SUCCESS && + algorithm == CALG_SHA256, "AlgorithmIdentifier NULL optional"); + // Change both copies of the SHA-256 OID to an unsupported family member. + const UCHAR oid[] = {0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x02,0x01}; + for (size_t i = 1032; i + sizeof(oid) <= pe.size(); ++i) + if (!std::memcmp(pe.data() + i, oid, sizeof(oid))) pe[i + sizeof(oid) - 1] = 0x7F; + Check(DomitoGetPortableExecutableDigestKindEx(pe.data(), (ULONG)pe.size(), dirs, &algorithm) == STATUS_NOT_SUPPORTED && + !algorithm, "unsupported SPC algorithm does not default to SHA1"); + } + IMAGE_DATA_DIRECTORY dirs[5] = {}; + UINT32 actual = 123; + Check(DomitoGetPortableExecutableDigestKindEx(b.data(), (ULONG)b.size(), dirs, &actual) == STATUS_NOT_FOUND && + actual == 0, "absent DER does not silently select SHA1"); +} + +static void SystemImageTests() +{ + WCHAR system[MAX_PATH]; + Check(GetSystemDirectoryW(system, MAX_PATH) != 0, "system directory"); + for (const WCHAR* name : {L"ci.dll", L"ntdll.dll", L"kernel32.dll", L"notepad.exe"}) + { + std::wstring path = std::wstring(system) + L"\\" + name; + HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, 0, nullptr); + Check(file != INVALID_HANDLE_VALUE, "system PE corpus file"); + LARGE_INTEGER size; + Check(GetFileSizeEx(file, &size) && size.QuadPart > 0 && size.QuadPart < 32 * 1024 * 1024, "bounded system PE size"); + std::vector b((size_t)size.QuadPart); + DWORD read; + Check(ReadFile(file, b.data(), (DWORD)b.size(), &read, nullptr) && read == b.size(), "read system PE fixture"); + CloseHandle(file); + for (UINT32 calg : std::initializer_list{CALG_SHA1, CALG_SHA256, CALG_SHA384, CALG_SHA512}) + Check(ExplicitDigest(b, calg) == SipDigest(b, calg), "real system PE Windows SIP differential"); + IMAGE_DOS_HEADER dos; + std::memcpy(&dos, b.data(), sizeof(dos)); + USHORT magic; + const ULONG optional = (ULONG)dos.e_lfanew + 24; + std::memcpy(&magic, b.data() + optional, 2); + const ULONG directory = magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC ? + FIELD_OFFSET(IMAGE_OPTIONAL_HEADER64, DataDirectory) : FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, DataDirectory); + IMAGE_DATA_DIRECTORY dirs[5]; + std::memcpy(dirs, b.data() + optional + directory, sizeof(dirs)); + if (dirs[4].Size) + { + UINT32 actual; + Check(NT_SUCCESS(DomitoGetPortableExecutableDigestKindEx(b.data(), (ULONG)b.size(), dirs, &actual)), + "real embedded SPC digest algorithm"); + // Independent Windows ASN.1 decoder: extract signed content with + // CryptQueryObject/CryptMsg, then decode SPC_INDIRECT_DATA_OBJID. + ULONG certLength; + std::memcpy(&certLength, b.data() + dirs[4].VirtualAddress, 4); + CRYPT_DATA_BLOB blob = {certLength - 8, b.data() + dirs[4].VirtualAddress + 8}; + HCRYPTMSG message = nullptr; + Check(CryptQueryObject(CERT_QUERY_OBJECT_BLOB, &blob, CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED, + CERT_QUERY_FORMAT_FLAG_BINARY, 0, nullptr, nullptr, nullptr, nullptr, &message, nullptr) != FALSE, + "Windows decode PKCS7"); + DWORD contentSize = 0; + Check(CryptMsgGetParam(message, CMSG_CONTENT_PARAM, 0, nullptr, &contentSize) != FALSE, + "Windows SPC content size"); + std::vector content(contentSize); + Check(CryptMsgGetParam(message, CMSG_CONTENT_PARAM, 0, content.data(), &contentSize) != FALSE, + "Windows SPC content"); + SIP_INDIRECT_DATA* indirect = nullptr; + DWORD decodedSize = 0; + Check(CryptDecodeObjectEx(X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, SPC_INDIRECT_DATA_OBJID, + content.data(), contentSize, CRYPT_DECODE_ALLOC_FLAG, nullptr, &indirect, &decodedSize) != FALSE, + "Windows decode indirect data"); + const char* oid = indirect->DigestAlgorithm.pszObjId; + const UINT32 expected = !std::strcmp(oid, szOID_OIWSEC_sha1) ? CALG_SHA1 : + !std::strcmp(oid, szOID_NIST_sha256) ? CALG_SHA256 : + !std::strcmp(oid, szOID_NIST_sha384) ? CALG_SHA384 : CALG_SHA512; + Check(actual == expected, "SPC digest algorithm matches Windows ASN1 decoder"); + auto digest = ExplicitDigest(b, actual); + Check(digest.size() == indirect->Digest.cbData && + !std::memcmp(digest.data(), indirect->Digest.pbData, digest.size()), "computed digest matches real embedded SPC digest"); + LocalFree(indirect); + CryptMsgClose(message); + } + } +} + int main() { + SystemImageTests(); + ExtendedTests(); for (bool pe64 : {false, true}) { for (bool certificate : {false, true}) @@ -255,5 +597,43 @@ int main() Check(NT_SUCCESS(DomitoValidateFileLegacyMode(nullptr, hash, 20, CALG_SHA1, &directory, &policy, &signing, ×tamp)), "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); + // Failure paths must release previous attempts before embedded/catalog/reload reuse. + allocatePolicies = true; + embeddedResult = STATUS_INVALID_IMAGE_HASH; + catalogResults[0] = STATUS_INVALID_IMAGE_HASH; + catalogResults[1] = STATUS_SUCCESS; + catalogCalls = 0; + expectedCatalogMask = 0x8000; + expectedTrustedMarker = TRUE; + ULONG source = 0xDEAD; + Check(NT_SUCCESS(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA1, + &directory, &policy, &signing, ×tamp, expectedCatalogMask, TRUE, &source)), "explicit catalog fallback"); + Check(source == DOMITO_CI_SOURCE_CATALOG && catalogCalls == 2 && allocations == 2, + "final policy ownership and catalog source"); + CiFreePolicyInfo(&policy); CiFreePolicyInfo(×tamp); + Check(allocations == 0, "final results freed once"); + source = 123; + Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA256, + &directory, &policy, &signing, ×tamp, 0, FALSE, &source) == STATUS_INVALID_PARAMETER && !source, + "algorithm and length mismatch rejected"); + Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA384, + &directory, &policy, &signing, ×tamp, 0, FALSE, &source) == STATUS_NOT_SUPPORTED, + "legacy catalog algorithm capability explicit"); + observedFileSize = 1040; + Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA1, + &directory, &policy, &signing, ×tamp, 0, FALSE, &source) == STATUS_INVALID_IMAGE_FORMAT, + "certificate file range checked before allocation"); + Check(allocations == 0, "invalid file range never allocated"); + observedFileSize = 1056; + expectedCatalogMask = 0x8000; + expectedTrustedMarker = TRUE; + catalogCalls = 0; + catalogResults[1] = STATUS_INVALID_IMAGE_HASH; + Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA1, + &directory, &policy, &signing, ×tamp, expectedCatalogMask, TRUE, &source) == STATUS_INVALID_IMAGE_HASH, + "final catalog failure retained"); + Check(!source && allocations == 2, "final failed-call policies remain caller-owned"); + CiFreePolicyInfo(&policy); CiFreePolicyInfo(×tamp); + Check(allocations == 0, "failed final policies freed once"); + std::printf("PASS: %u digest/legacy checks (Windows SIP, Crypt32, guard pages, failures, ownership).\n", checks); } diff --git a/tests/mincrypt-layout-tests.cpp b/tests/mincrypt-layout-tests.cpp new file mode 100644 index 0000000..d01539b --- /dev/null +++ b/tests/mincrypt-layout-tests.cpp @@ -0,0 +1,24 @@ +// Compile with the WDK for supported 64-bit targets. No driver is linked/loaded. +#include +#include "Domito.MinCrypt.h" + +#if NTDDI_VERSION >= NTDDI_WIN10 +C_ASSERT(sizeof(MINCRYPT_POLICY_INFO) == 48); +C_ASSERT(FIELD_OFFSET(MINCRYPT_POLICY_INFO, VerificationStatus) == 4); +C_ASSERT(FIELD_OFFSET(MINCRYPT_POLICY_INFO, ChainInfo) == 16); +C_ASSERT(FIELD_OFFSET(MINCRYPT_POLICY_INFO, NotBefore) == 32); +C_ASSERT(sizeof(MINCRYPT_CHAIN_INFO) == 72); +C_ASSERT(FIELD_OFFSET(MINCRYPT_CHAIN_INFO, ChainElements) == 40); +C_ASSERT(FIELD_OFFSET(MINCRYPT_CHAIN_INFO, AuthenticodeAttributes) == 56); +#else +C_ASSERT(sizeof(MINCRYPT_POLICY_INFO) == 32); +C_ASSERT(sizeof(MINCRYPT_CHAIN_INFO) == 40); +#endif +C_ASSERT(sizeof(MINCRYPT_NAME_BLOB) == 16); +C_ASSERT(sizeof(MINCRYPT_CHAIN_ELEMENT) == 120); +C_ASSERT(FIELD_OFFSET(MINCRYPT_CHAIN_ELEMENT, Certificate) == 104); + +#if defined(_M_AMD64) +// These opt-in declarations establish observations only for the named x64 build. +#include "Domito.MinCrypt.26100.h" +#endif diff --git a/tests/run-common-tests.py b/tests/run-common-tests.py new file mode 100644 index 0000000..3867665 --- /dev/null +++ b/tests/run-common-tests.py @@ -0,0 +1,18 @@ +"""Run production export and file-read helpers with deterministic user-mode doubles.""" +from pathlib import Path +import subprocess +import tempfile +root = Path(__file__).resolve().parents[1] +source = (root / 'src/Domito.cpp').read_text(encoding='utf-8-sig') +export_start = source.index('// This helper handles directory RVAs') +export_end = source.index('_Success_', source.index('DomitoFindExportedFunctionAddress(') + 1) +read_start = source.rfind('_Success_', 0, source.index('DomitoReadFileAt(')) +read_end = source.index('_Success_', source.index('DomitoReadFile(') + 1) +with tempfile.TemporaryDirectory(prefix='domito-common-tests-') as temp: + build = Path(temp) + (build / 'common-under-test.inc').write_text(source[export_start:export_end] + '\n' + source[read_start:read_end], encoding='utf-8') + exe = build / 'common-tests.exe' + subprocess.run(['cl', '/nologo', '/EHsc', '/W4', '/WX', '/std:c++17', '/I' + str(build), + str(root / 'tests/common-tests.cpp'), '/Fe' + str(exe), + '/Fo' + str(build / 'common-tests.obj')], cwd=build, check=True) + subprocess.run([str(exe)], cwd=build, check=True) diff --git a/tests/run-digest-tests.py b/tests/run-digest-tests.py index 0a4eac9..6656d4f 100644 --- a/tests/run-digest-tests.py +++ b/tests/run-digest-tests.py @@ -30,5 +30,5 @@ with tempfile.TemporaryDirectory(prefix='domito-digest-tests-') as temp: 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) + '/Fo' + str(build/'digest-tests.obj'), '/link', 'bcrypt.lib', 'crypt32.lib'], cwd=build, check=True) subprocess.run([str(build/'digest-tests.exe')], cwd=build, check=True)