Fix Authenticode signing time extraction and split signature validity rules - #769
Fix Authenticode signing time extraction and split signature validity rules#769Giulia Stocco (gfs) wants to merge 1 commit into
Conversation
… rules PR #763 changed signature expiry detection to compare the signing time against the certificate validity period instead of comparing "now" against NotAfter, but the signing time was never actually recovered for modern binaries. Microsoft Authenticode RFC 3161 timestamps are carried in unsigned attribute 1.3.6.1.4.1.311.3.3.1 (szOID_RFC3161_counterSign) rather than the standard 1.2.840.113549.1.9.16.2.14, and those tokens carry no PKCS#9 signingTime -- the authoritative value is the genTime of the encapsulated TSTInfo. Only legacy PKCS#9 countersigned binaries were being handled, so SigningTime came back null for nearly every signed binary and, since IsTimeValid is false when the signing time is unknown, each one was reported as a warning. Over a 505 file sweep of System32, dotnet and PowerShell 7 this produced 278 net new false positives; signing times are now recovered for all 323 authenticode valid files and the false positives are gone. IsTimeValid also compared mixed time bases: signing times are recovered as UTC while certificate validity comes back from X509Certificate2 as local time, so results shifted by the machine's UTC offset. All three operands are now normalized to UTC before comparison. The single "unverified signature validity" rule is split into two rules with distinct severities so the two very different situations are distinguishable: binaries genuinely signed outside their certificate validity period stay a WARNING, while binaries with no recoverable signing time (expected for catalog signed and unusual signers) are reported as INFORMATION. The existing tests only exercised the legacy countersigner path, which is why they passed while the feature was broken. SignatureTests grows from 11 to 24 tests covering RFC 3161 timestamped binaries, DateTime.Kind normalization, and rule routing through the real analyzer and embedded rule set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434cb92a-d1ac-4a96-bfc8-564342e8f4c4
|
Note This is automated output from the COMPASS SDL Security Review tooling, posted for the record. It is not a personal review opinion, and the findings have not been independently triaged yet. SDL Security ReviewScope reviewed: 🟡
|
Why
PR #763 reworked binary signature expiry detection to compare the signing time against the certificate's validity period, rather than comparing "now" against
NotAfter. That is the correct model: a binary that was properly signed and timestamped should stay valid after its signing certificate expires.The intent was right, but the implementation never actually recovered a signing time for modern binaries, so the new check inverted into a false-positive generator. PR #763 could not be tested on Windows at the time it was written. This PR fixes it, and adds the test coverage that would have caught it.
What was broken
Signing time was never extracted for modern Authenticode signatures. Microsoft's Authenticode RFC 3161 timestamps are carried in the unsigned attribute
1.3.6.1.4.1.311.3.3.1(szOID_RFC3161_counterSign), not the standard1.2.840.113549.1.9.16.2.14that was being checked. Those tokens also carry no PKCS#9signingTimeattribute at all; the authoritative value is thegenTimeof the encapsulatedTSTInfo. Only legacy PKCS#9 countersigned binaries were handled, soSigningTimecame back null for nearly every signed binary. BecauseIsTimeValidreturns false when the signing time is unknown, each of those binaries was reported as a warning.Measured over a 505 file sweep of System32, dotnet, and PowerShell 7: signing times were recovered for only 2 of 323 authenticode-valid files, and the change produced 278 net new false positives.
IsTimeValidcompared mixed time bases. Signing times are recovered asKind=Utc, while certificate validity comes back fromX509Certificate2asKind=Local. The comparison silently shifted by the machine's UTC offset, so results varied by timezone.The tests masked both bugs. All 11 tests added by #763 used
vcruntime140d.dll, the single legacy-countersigned fixture in the repo, which happens to be the one code path that worked. That is why the suite was green while the feature was broken.Approach
WindowsFileSystemUtilsnow matches both timestamp OIDs and parsesTSTInfo.genTimeout of the RFC 3161 token usingAsnReader, falling back to a PKCS#9 time on the token signer if a TSA does supply one. The legacy countersigner path is preserved and still tried first, so nothing regresses for older binaries.Signature.IsTimeValidnormalizes all three operands to UTC before comparing.analyses.jsonsplits the single ambiguous rule into two rules with distinct severities, since the two situations it conflated are very different in practice:WARNING. This is genuinely suspicious.INFORMATION. This is expected for catalog-signed binaries and unusual signers, and is not by itself an integrity problem.Both variants are defined for
FILEandFILEMONITOR. The discriminator is aSigningTime IsNullclause (inverted for the warning rule), which was verified to route correctly through OAT.SignatureTestsgrows from 11 to 24 tests, covering RFC 3161 timestamped binaries,DateTime.Kindnormalization, and rule routing through the real analyzer against the embedded rule set. The RFC 3161 test asserts that its fixture has zero PKCS#9 countersigners, so it cannot silently stop covering the new path if the fixture is ever swapped.Validation
All run on Windows.
SignatureTestsTestCategory=PipelineSafeTestsEnumerateRuleIssueson embedded rulesasa collect+export-collectSigningTimeround-trips through SQLite and JSON exportEnd-to-end spot check:
Microsoft.Data.Sqlite.dll(certificate expired 2026-06-17, signed 2025-09-26) andvcruntime140d.dll(certificate expired 2020, signed 2019) both now resolve toIsTimeValid=Truewith zero rules flagged, which is exactly the scenario #763 set out to fix. Unsigned binaries are still correctly caught by the pre-existing "Unsigned binaries" rule.Notes for reviewers
Runs collected before this change have no
SigningTimestored, so comparing a new run against an older baseline will match every previously-collected signed binary against the "undeterminable signing time" rule. This is inherent to adding a new collected field. The severity split keeps that noise atINFORMATIONinstead ofWARNING, which was part of the motivation for splitting the rules.Tests/TestData/ExportTests/TestGenerateSarifLog/rules.jsonstill contains the original pre-#763 rule name. It is a frozen SARIF input snapshot that is deliberately decoupled fromanalyses.json, so it is intentionally left unchanged.The ASN.1 reader uses BER rather than DER and deliberately does not call
ThrowIfNotEmpty, because the trailingTSTInfofields aftergenTimeare optional.