diff --git a/README.md b/README.md index 7123638..e0f9449 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ Copy the provided `Domito.props` file into the directory of your `.vcxproj` file ``` +## Code Integrity API notes + +- [CiValidateFileObject flag 0x20: evidence, limitations, and kernel test procedure](docs/ci-validate-file-object.md) + ## Sources & 3rd party credits This library benefits from these awesome projects ❤ (appearance in no special order): diff --git a/docs/ci-validate-file-object.md b/docs/ci-validate-file-object.md new file mode 100644 index 0000000..3a982d7 --- /dev/null +++ b/docs/ci-validate-file-object.md @@ -0,0 +1,151 @@ +# CiValidateFileObject: validation flag 0x00000020 + +Issue #3 is **documented; kernel runtime verification is pending**. The probe +builds and its plumbing is covered by host tests, but those tests do not execute +Windows Code Integrity. Do not close the runtime-testing portion on that basis. + +## Meaning supported by the evidence + +`DOMITO_CI_VALIDATE_FLAG_BROADEN_ROOT_ACCEPTANCE` is Domito's descriptive name for +`0x00000020` in the second argument, `ValidationFlags`. It is not a recovered +Microsoft macro name. On the analyzed **ci.dll 10.0.26100.8328 x64**, the flag +causes `CipGetAcceptedRootKeys` to OR `0xA4FF` into its accepted-root mask in the +branch where the global at RVA `0x45A88` is zero. Other global state, flags, and +the requested signing level also affect the result. + +The assembly at preferred image base `0x180000000` establishes the operation: + +```text +0x180089e40: MOV EDX, 0xa4ff +0x180089e4c: BT ECX, EDI ; ECX = 0x8c in this branch; EDI = signing level +0x180089e4f: JC 0x180089e57 +0x180089e51: TEST SIL, 0x20 ; saved ValidationFlags +0x180089e55: JZ 0x180089e59 +0x180089e57: OR EBX, EDX ; broaden accepted-root mask +``` + +For requested level 0, flags 0, the branch above, and all other optional global +additions disabled, the mask is `0x80AA`; setting only `0x20` makes it `0xA4FF` +(additional bits `0x2455`). This is an arithmetic witness, **not a measurement of +the running OS's initialized policy**. Levels 2, 3, and 7 already trigger the OR +through `0x8C`. When the global at RVA `0x45A88` is nonzero, the helper returns +`0xA4FF` directly, so the flag need not change its result. + +Later processing can still remove bit `0x8` when the helper's third argument is +nonzero and validation flag `0x00400000` is present. The surrounding +`CipDisallowEmbeddedPicrootIfTrusted` path can add that latter flag itself. +Thus `0xA4FF` is not a promise about every final policy result. + +The widened mask includes categories associated with test roots and unresolved +chains as well as other roots. It must not be named "trust any Authenticode +signer", "require Microsoft", or "enable TESTSIGNING". It changes the acceptance +policy for this operation; it does not change the system boot setting. Further +signature, chain, signing-level, and policy checks still matter. A successful +call alone does not establish equivalence with WinVerifyTrust or the caller's +intended publisher trust policy. + +`CipGetHashAlgorithmForLegacyScenario` does not test `0x20`: the flag is not a +fixed SHA-1/SHA-256 selector. Also distinguish this **input flag** from the +unrelated `MINCRYPT_POLICY_INFO.PolicyBits` output bit `0x20`, associated with +the built-in DMD root category in this build. + +## Evidence and compatibility limits + +The [upstream report](https://github.com/Ido-Moshe-Github/CiDllDemo/issues/5#issuecomment-2725851295) +describes success with flags `0x20`, signing level 0, and TESTSIGNING off on +Windows builds 19041 and 19045. That is third-party runtime evidence, not a +reproduction by this project. Its behavior is consistent with broader root +acceptance; it does not establish identical internals across those builds. + +Our static evidence uses REA 6.8.0 / Ghidra 12.1.3 and the binary SHA-256 +`c7d23814f3202c51fb23e3ca3d4328855787383cee5d14c4a986a13ec2aeda0b`. +Public-symbol mapping uses ci.pdb GUID `1CAE574B-CF1F-3564-5F74-49567A128629`, age 1. +The companion CodeIntegrity-RE repository retains: + +- `ci-flag20-assembly-evidence.json`: assembly evidence + `ev_ea763b9be7643a6241b0c805eae5f735a4b4a0a82df1383b1e68da4207f85fb4`. +- `evidence/CipGetAcceptedRootKeys.c`: decompilation + `ev_c905f03db9470c8b2d89a6295080143f25d5780ca0909de0ca554e40d078a3f3`. +- `evidence/CipGetHashAlgorithmForLegacyScenario.c`: + `ev_addf8f1bc18da58db80db54eeab0dd78ec5ab88b219ece3e082ebd9a8e50b257`. +- `evidence/CipDisallowEmbeddedPicrootIfTrusted.c`: + `ev_2475a5804fc87683a54b06acebd3785e5e8eb31f4f542f699fc2ff414c1ab7fe`. +- `evidence/CipValidateFileObjectByFileOrPageHash.c`: capacity contract + `ev_3897aa76c17cc64a30f60e72b994e695b88c8eda2ae6f246766bdc9314f06cca`. + +No complete enumeration of downstream uses of the flag, historical Microsoft +name, or cross-version/architecture guarantee is claimed. The public declaration +keeps the existing parameter widths. Names now describe the known inputs; on +26100 x64 the third argument is consumed as a byte. Narrowing the ABI everywhere +requires separate evidence. + +## Kernel experiment prepared for the VM + +Add `tests/ci-file-object-probe.cpp` to an existing signed test driver and add +`include/` to its include path. Link the existing CI imports (`ci.lib`). Include +`ntifs.h`, `Domito.MinCrypt.h`, and `tests/ci-file-object-probe.h` at the call site. +Invoke `DomitoProbeCiValidateFileObject` at PASSIVE_LEVEL with a referenced +file object for the fixture and an array of `DOMITO_CI_PROBE_RESULT_COUNT` results. +Keep the file bytes stable and hold the object reference for the entire probe. +The surrounding driver owns opening/referencing/closing the file and exporting +the result records. The probe has no DriverEntry or installation side effects. + +It makes six calls, always at requested level 0: + +1. Flags `0`, then `0x20`, then `0x20`, then `0`, with 64-byte capacity each time. +2. Flags `0`, then `0x20`, with declared capacity 63 (64-byte backing storage). + +Each call uses fresh policies, hash capacity, algorithm, and signing-time storage. +Both policy objects are freed after capture, including on failure. The records +capture status, both policy statuses/bits, chain presence, signing time, digest +algorithm/length/bytes, and a trailing buffer guard. The digest is a **file hash**, +not a certificate thumbprint. Failed-call outputs are raw diagnostics, not valid +verification results. Chain internals are deliberately not dereferenced. + +The repeated order helps reveal cache/order effects but does not eliminate them. +Also run the reversed initial order in a fresh VM snapshot when comparing policy. +For the analyzed 26100 helper, both 63-byte-capacity calls should return +`STATUS_BUFFER_TOO_SMALL` and set length 64, regardless of the eventual algorithm. +Treat older-build differences as observations to investigate, not silently as +test success. Every guard must remain intact; successful hash sizes must be at +most 64. Inspect non-success statuses rather than treating all failures as +invalid signatures (I/O and resource errors are possible). + +Run on Windows 10 19041 and 19045 and a current Windows 11 build. The decisive +reproduction uses a driver loadable with TESTSIGNING **off**. Record that setting, +Secure Boot, HVCI/WDAC policy, OS build, CI version/hash, driver identity, fixture +SHA-256, and each complete result. Keep policy constant between paired calls. +Use these fixtures: + +- A known third-party RSA Authenticode executable (ideally the reported fixture). +- A Microsoft-signed executable and a catalog-signed file as controls. +- An unsigned executable and a copy of the signed file with a byte changed in a + hashed section, leaving its certificate table unchanged. +- A self-signed/test-root fixture to characterize the broader acceptance policy. +- If available, timestamped, expired, revoked, and ECC variants, with expectations + recorded in advance and revocation/configuration differences retained. + +Compare with `signtool verify /pa /v` as a separate user-mode policy observation, +not an equality oracle. The report is reproduced only when the same valid +third-party fixture changes from rejection at 0 to success at `0x20`, under the +reported environment, with usable outputs. Unchanged success can mean the +existing policy already accepts it. Unchanged failure is a result to investigate. +Unsigned and tampered fixtures must not become accepted as valid signatures. +Self-signed acceptance must be reported explicitly, not confused with public-CA +publisher trust. Do not use this flag as a new production default based only on +one positive fixture. + +## Checks available without the VM + +From an x64 Visual Studio developer shell: + +```powershell +python tests\run-ci-probe-tests.py +``` + +This compiles the actual probe body with mock CI calls and constants extracted +from the public header. It verifies call order, input forwarding, fresh capacity +and outputs, result capture, and freeing both policies after success and failure. +It does **not** emulate the flag's trust semantics. Kernel compilation checks +the real headers separately; neither compilation nor mock tests substitute for +the VM run above. diff --git a/include/Domito.MinCrypt.h b/include/Domito.MinCrypt.h index 4c8b32b..da97823 100644 --- a/include/Domito.MinCrypt.h +++ b/include/Domito.MinCrypt.h @@ -513,15 +513,24 @@ CiSetTrustedOriginClaimId( _In_ UINT32 ClaimId ); +// Domito descriptive name, not an official Microsoft flag name. +// Observed on CI 10.0.26100.8328 x64: broadens the accepted-root mask in +// CipGetAcceptedRootKeys. This is not a WinVerifyTrust-equivalent trust policy. +// See docs/ci-validate-file-object.md for evidence and version limitations. +#define DOMITO_CI_VALIDATE_FLAG_BROADEN_ROOT_ACCEPTANCE 0x00000020UL + /** * Given a file object, verify the signature and provide information regarding * the certificates that was used for signing (the entire certificate chain) * * @param FileObject - FileObject of the PE in question * -* @param Unkonwn1 - unknown, 0 is a valid value. (Unkonwn1 and Unkonwn2 together calculate the minimum support algorithm) +* @param ValidationFlags - validation policy flags; 0 is a valid value. +* See DOMITO_CI_VALIDATE_FLAG_BROADEN_ROOT_ACCEPTANCE and the accompanying documentation. * -* @param Unkonwn2 - unknown, 0 is a valid value. (^ the words above refer to 'CipGetHashAlgorithmForLegacyScenario') +* @param RequestedSigningLevel - requested signing level; 0 is used by the probe. +* CI 26100 x64 consumes the low byte. UINT32 is retained for ABI compatibility +* until other supported architectures/builds are verified; use values in 0..255. * * @param PolicyInfo[out] - PolicyInfo containing information about the signer certificate chain. * @@ -529,13 +538,19 @@ CiSetTrustedOriginClaimId( * * @param SigningTime[out] - when the file was signed (FILETIME format) * -* @param Hash - buffer containing the digest +* @param Hash[out] - file digest, not a certificate thumbprint. Supply at least +* MINCRYPT_MAX_HASH_LENGTH bytes (required by the observed 26100 x64 helper). * -* @param HashSize - size of the digest, e.g. 0x14(160bit) for SHA1, 0x20(256bit) for SHA256 +* @param HashSize[in,out] - buffer capacity on entry; actual digest size on success. +* Initialize to sizeof(Hash) for every call, including retries. * * @param HashAlgId - digest algorithm identifier, e.g. CALG_SHA1(0x8004), CALG_SHA_256(0x800C) * -* @return STATUS_SUCCESS if the file digest in the signature matches the given digest and the signer cetificate is verified. +* Initialize both policy outputs to empty objects and free both with CiFreePolicyInfo +* before reuse. Do not interpret failed-call outputs as a verification result. +* +* @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_IMAGE_CERT_REVOKED - the certificate used for signing the file is revoked @@ -547,8 +562,8 @@ NTSTATUS NTAPI CiValidateFileObject( _In_ FILE_OBJECT* FileObject, - _In_opt_ UINT32 Unkonwn1, - _In_opt_ UINT32 Unkonwn2, + _In_opt_ UINT32 ValidationFlags, + _In_opt_ UINT32 RequestedSigningLevel, _Out_ MINCRYPT_POLICY_INFO* PolicyInfo, _Out_ MINCRYPT_POLICY_INFO* TimeStampPolicyInfo, _Out_ LARGE_INTEGER* SigningTime, diff --git a/tests/README.md b/tests/README.md index 75d18f9..9888878 100644 --- a/tests/README.md +++ b/tests/README.md @@ -21,3 +21,10 @@ 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. + +## 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. diff --git a/tests/ci-file-object-probe-tests.cpp b/tests/ci-file-object-probe-tests.cpp new file mode 100644 index 0000000..05ecfaf --- /dev/null +++ b/tests/ci-file-object-probe-tests.cpp @@ -0,0 +1,96 @@ +// Host tests validate the probe plumbing, NOT CI trust behavior. +#define WIN32_NO_STATUS +#include +#undef WIN32_NO_STATUS +#include +#include +#include +#include +#include "ci-probe-constants.inc" +#define NT_SUCCESS(s) (((NTSTATUS)(s)) >= 0) +#define DOMITO_CI_PROBE_HOST_TEST +typedef void* PFILE_OBJECT; +struct MINCRYPT_POLICY_INFO { + ULONG Size, VerificationStatus, PolicyBits; + void* ChainInfo; +}; +static unsigned calls, frees, outstanding, checks; +static bool failureMode; +static int fileToken, chainToken; +static void Check(bool value, const char* label) +{ + ++checks; + if (!value) { std::fprintf(stderr, "FAIL: %s\n", label); std::exit(1); } +} +static NTSTATUS CiValidateFileObject(PFILE_OBJECT file, ULONG flags, ULONG level, + MINCRYPT_POLICY_INFO* policy, MINCRYPT_POLICY_INFO* timestamp, + LARGE_INTEGER* time, UCHAR* hash, UINT32* size, ALG_ID* algorithm) +{ + const ULONG expectedFlags[] = {0, 0x20, 0x20, 0, 0, 0x20}; + Check(calls < 6 && flags == expectedFlags[calls], "paired flags and order"); + Check(file == &fileToken && level == 0, "same file and level zero"); + Check(outstanding == 0, "previous outputs freed before next call"); + Check(policy->Size == sizeof(*policy) && timestamp->Size == sizeof(*timestamp), "policy sizes"); + Check(!policy->ChainInfo && !timestamp->ChainInfo && !policy->PolicyBits && + !timestamp->PolicyBits, "fresh policy objects"); + Check(*size == (calls < 4 ? 64u : 63u), "capacity reset for every call"); + Check(*algorithm == 0 && time->QuadPart == 0, "fresh scalar outputs"); + for (unsigned i = 0; i < 64; ++i) Check(hash[i] == 0, "fresh hash buffer"); + ++calls; + if (*size < 64) { *size = 64; return STATUS_BUFFER_TOO_SMALL; } + policy->ChainInfo = timestamp->ChainInfo = &chainToken; + outstanding = 2; + policy->PolicyBits = 0xA5; + timestamp->PolicyBits = 0x5A; + policy->VerificationStatus = failureMode ? STATUS_INVALID_IMAGE_HASH : STATUS_SUCCESS; + timestamp->VerificationStatus = STATUS_SUCCESS; + // Also allocate on simulated failure to exercise unconditional cleanup. + if (failureMode) return STATUS_INVALID_IMAGE_HASH; + std::memset(hash, 0xCC, 32); + *size = 32; + *algorithm = 0x800C; + time->QuadPart = 123; + return STATUS_SUCCESS; +} +static void CiFreePolicyInfo(MINCRYPT_POLICY_INFO* policy) +{ + ++frees; + if (policy->ChainInfo) { Check(outstanding > 0, "owned chain"); --outstanding; } + *policy = {}; +} +#include "ci-file-object-probe.cpp" + +int main() +{ + for (unsigned pass = 0; pass < 2; ++pass) + { + failureMode = pass != 0; + calls = frees = outstanding = 0; + DOMITO_CI_PROBE_RESULT results[DOMITO_CI_PROBE_RESULT_COUNT]; + std::memset(results, 0xFF, sizeof(results)); + DomitoProbeCiValidateFileObject(&fileToken, results); + Check(calls == 6 && frees == 12 && outstanding == 0, "all calls and frees"); + for (unsigned i = 0; i < 6; ++i) + { + const auto& r = results[i]; + Check(r.HashGuardIntact, "buffer guard"); + const NTSTATUS expected = i >= 4 ? STATUS_BUFFER_TOO_SMALL : + failureMode ? STATUS_INVALID_IMAGE_HASH : STATUS_SUCCESS; + Check(r.Status == expected, "status preserved"); + if (i < 4) { + Check(r.PolicyBits == 0xA5 && r.TimeStampPolicyBits == 0x5A, "policy capture"); + Check(r.HasChain && r.HasTimeStampChain, "chain presence captured before free"); + } + if (NT_SUCCESS(r.Status)) { + Check(r.HashSize == 32 && r.HashAlgId == 0x800C && r.SigningTime.QuadPart == 123, + "digest metadata and signing time"); + for (unsigned j = 0; j < 64; ++j) + Check(r.Hash[j] == (j < 32 ? 0xCC : 0), "digest copied and tail cleared"); + } else { + for (auto byte : r.Hash) Check(byte == 0, "no digest from failed call"); + } + if (i >= 4) Check(r.HashSize == 64, "required capacity captured"); + } + } + std::printf("PASS: %u probe checks (mock CI; no runtime trust validation)\n", checks); +} diff --git a/tests/ci-file-object-probe.cpp b/tests/ci-file-object-probe.cpp new file mode 100644 index 0000000..f9ea540 --- /dev/null +++ b/tests/ci-file-object-probe.cpp @@ -0,0 +1,52 @@ +// Compile into an existing signed test driver; this file has no DriverEntry. +#ifndef DOMITO_CI_PROBE_HOST_TEST +#include +#include "Domito.MinCrypt.h" +#endif +#include "ci-file-object-probe.h" + +_IRQL_requires_max_(PASSIVE_LEVEL) +void DomitoProbeCiValidateFileObject( + PFILE_OBJECT FileObject, DOMITO_CI_PROBE_RESULT* Results) +{ + const ULONG flags[DOMITO_CI_PROBE_RESULT_COUNT] = { + 0, DOMITO_CI_VALIDATE_FLAG_BROADEN_ROOT_ACCEPTANCE, + DOMITO_CI_VALIDATE_FLAG_BROADEN_ROOT_ACCEPTANCE, 0, + 0, DOMITO_CI_VALIDATE_FLAG_BROADEN_ROOT_ACCEPTANCE + }; + for (ULONG i = 0; i < DOMITO_CI_PROBE_RESULT_COUNT; ++i) + { + MINCRYPT_POLICY_INFO policy = {}; + MINCRYPT_POLICY_INFO timestamp = {}; + policy.Size = sizeof(policy); + timestamp.Size = sizeof(timestamp); + struct { + UCHAR Bytes[MINCRYPT_MAX_HASH_LENGTH]; + ULONG Guard; + } hash = {}; + hash.Guard = 0xD01170AB; + DOMITO_CI_PROBE_RESULT& result = Results[i]; + result = {}; + result.Flags = flags[i]; + result.InputCapacity = i < 4 ? sizeof(hash.Bytes) : sizeof(hash.Bytes) - 1; + result.HashSize = result.InputCapacity; + result.Status = CiValidateFileObject(FileObject, result.Flags, 0, + &policy, ×tamp, &result.SigningTime, hash.Bytes, + &result.HashSize, &result.HashAlgId); + result.HashGuardIntact = hash.Guard == 0xD01170AB; + // Keep failure outputs as raw observations; only successful calls have + // a usable digest. Do not dereference the incompletely mapped chains. + result.PolicyBits = policy.PolicyBits; + result.TimeStampPolicyBits = timestamp.PolicyBits; + result.VerificationStatus = (NTSTATUS)policy.VerificationStatus; + result.TimeStampVerificationStatus = (NTSTATUS)timestamp.VerificationStatus; + result.HasChain = policy.ChainInfo != nullptr; + result.HasTimeStampChain = timestamp.ChainInfo != nullptr; + if (NT_SUCCESS(result.Status) && result.HashSize <= sizeof(result.Hash)) + { + RtlCopyMemory(result.Hash, hash.Bytes, result.HashSize); + } + CiFreePolicyInfo(&policy); + CiFreePolicyInfo(×tamp); + } +} diff --git a/tests/ci-file-object-probe.h b/tests/ci-file-object-probe.h new file mode 100644 index 0000000..2ec9434 --- /dev/null +++ b/tests/ci-file-object-probe.h @@ -0,0 +1,29 @@ +#pragma once + +// Include ntifs.h and Domito.MinCrypt.h first. Test support, not a library API. +typedef struct _DOMITO_CI_PROBE_RESULT +{ + ULONG Flags; + ULONG InputCapacity; + NTSTATUS Status; + UINT32 HashSize; + ALG_ID HashAlgId; + UCHAR Hash[MINCRYPT_MAX_HASH_LENGTH]; + LARGE_INTEGER SigningTime; + ULONG PolicyBits; + ULONG TimeStampPolicyBits; + NTSTATUS VerificationStatus; + NTSTATUS TimeStampVerificationStatus; + BOOLEAN HasChain; + BOOLEAN HasTimeStampChain; + BOOLEAN HashGuardIntact; +} DOMITO_CI_PROBE_RESULT; + +#define DOMITO_CI_PROBE_RESULT_COUNT 6 + +// PASSIVE_LEVEL; referenced file object; file bytes must remain stable. +// Writes observations, not an overall trust verdict. Frees both policies per call. +_IRQL_requires_max_(PASSIVE_LEVEL) +void DomitoProbeCiValidateFileObject( + _In_ PFILE_OBJECT FileObject, + _Out_writes_(DOMITO_CI_PROBE_RESULT_COUNT) DOMITO_CI_PROBE_RESULT* Results); diff --git a/tests/run-ci-probe-tests.py b/tests/run-ci-probe-tests.py new file mode 100644 index 0000000..77dbbb9 --- /dev/null +++ b/tests/run-ci-probe-tests.py @@ -0,0 +1,20 @@ +"""Test the actual kernel probe body with mock CI; run in an x64 VS dev shell.""" +from pathlib import Path +import re +import subprocess +import tempfile + +tests = Path(__file__).resolve().parent +header = (tests.parent / 'include/Domito.MinCrypt.h').read_text(encoding='utf-8-sig') +names = ['MINCRYPT_MAX_HASH_LENGTH', 'DOMITO_CI_VALIDATE_FLAG_BROADEN_ROOT_ACCEPTANCE'] +constants = '\n'.join(re.search(r'^#define\s+' + name + r'\s+[^\r\n]+', header, re.M)[0] + for name in names) +with tempfile.TemporaryDirectory(prefix='domito-ci-probe-') as temp: + build = Path(temp) + (build / 'ci-probe-constants.inc').write_text(constants + '\n', encoding='utf-8') + exe = build / 'ci-probe-tests.exe' + subprocess.run(['cl', '/nologo', '/EHsc', '/W4', '/WX', '/std:c++17', + '/I' + str(build), str(tests / 'ci-file-object-probe-tests.cpp'), + '/Fe' + str(exe), '/Fo' + str(build / 'ci-probe-tests.obj')], + cwd=build, check=True) + subprocess.run([str(exe)], cwd=build, check=True)