Document CI validation flag 0x20 and add kernel probe

This commit is contained in:
nefarius committed 2026-10-26 15:30:03 +01:00
1 parent 195b9fccf3
commit 1026c48324
8 files changed
+381 -7

No files matched your search

+4
View File
@@ -71,6 +71,10 @@ Copy the provided `Domito.props` file into the directory of your `.vcxproj` file
<!-- The rest of the project file --> <!-- The rest of the project file -->
``` ```
## Code Integrity API notes
- [CiValidateFileObject flag 0x20: evidence, limitations, and kernel test procedure](docs/ci-validate-file-object.md)
## Sources & 3rd party credits ## Sources & 3rd party credits
This library benefits from these awesome projects ❤ (appearance in no special order): This library benefits from these awesome projects ❤ (appearance in no special order):
+151
View File
@@ -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.
+22 -7
View File
@@ -513,15 +513,24 @@ CiSetTrustedOriginClaimId(
_In_ UINT32 ClaimId _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 * Given a file object, verify the signature and provide information regarding
* the certificates that was used for signing (the entire certificate chain) * the certificates that was used for signing (the entire certificate chain)
* *
* @param FileObject - FileObject of the PE in question * @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. * @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 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) * @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: * Various error values otherwise, for example:
* STATUS_INVALID_IMAGE_HASH - the digest does not match the digest in the signature * 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 * STATUS_IMAGE_CERT_REVOKED - the certificate used for signing the file is revoked
@@ -547,8 +562,8 @@ NTSTATUS
NTAPI NTAPI
CiValidateFileObject( CiValidateFileObject(
_In_ FILE_OBJECT* FileObject, _In_ FILE_OBJECT* FileObject,
_In_opt_ UINT32 Unkonwn1, _In_opt_ UINT32 ValidationFlags,
_In_opt_ UINT32 Unkonwn2, _In_opt_ UINT32 RequestedSigningLevel,
_Out_ MINCRYPT_POLICY_INFO* PolicyInfo, _Out_ MINCRYPT_POLICY_INFO* PolicyInfo,
_Out_ MINCRYPT_POLICY_INFO* TimeStampPolicyInfo, _Out_ MINCRYPT_POLICY_INFO* TimeStampPolicyInfo,
_Out_ LARGE_INTEGER* SigningTime, _Out_ LARGE_INTEGER* SigningTime,
+7
View File
@@ -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 general Authenticode hashing of unusual section layouts. The digest implementation
still uses its existing linear byte-order convention and heuristic signature still uses its existing linear byte-order convention and heuristic signature
algorithm detection; replacing those requires a separate compatibility corpus. 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.
+96
View File
@@ -0,0 +1,96 @@
// Host tests validate the probe plumbing, NOT CI trust behavior.
#define WIN32_NO_STATUS
#include <windows.h>
#undef WIN32_NO_STATUS
#include <ntstatus.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#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);
}
+52
View File
@@ -0,0 +1,52 @@
// Compile into an existing signed test driver; this file has no DriverEntry.
#ifndef DOMITO_CI_PROBE_HOST_TEST
#include <ntifs.h>
#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, &timestamp, &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(&timestamp);
}
}
+29
View File
@@ -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);
+20
View File
@@ -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)