diff --git a/Lib/Collectors/WindowsFileSystemUtils.cs b/Lib/Collectors/WindowsFileSystemUtils.cs index b2707a1a..88111450 100644 --- a/Lib/Collectors/WindowsFileSystemUtils.cs +++ b/Lib/Collectors/WindowsFileSystemUtils.cs @@ -8,6 +8,7 @@ using Serilog; using System; using System.Collections.Generic; +using System.Formats.Asn1; using System.IO; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -201,11 +202,27 @@ private static List CharacteristicsTypeToListOfCharacteristi return null; } + /// + /// Microsoft's Authenticode RFC 3161 timestamp unsigned attribute (szOID_RFC3161_counterSign). + /// This is what modern Windows binaries are timestamped with. + /// + private const string MicrosoftRfc3161TimestampOid = "1.3.6.1.4.1.311.3.3.1"; + + /// + /// The standard RFC 3161 signature timestamp unsigned attribute (id-aa-signatureTimeStampToken). + /// + private const string Rfc3161TimestampOid = "1.2.840.113549.1.9.16.2.14"; + + /// + /// The content type of the TSTInfo encapsulated in an RFC 3161 timestamp token (id-ct-TSTInfo). + /// + private const string TstInfoContentTypeOid = "1.2.840.113549.1.9.16.1.4"; + /// /// Extracts the signing timestamp from a PE file's Authenticode signature. - /// The method first attempts to use the countersigner info in the PKCS#7 data, - /// then checks for RFC3161 timestamp tokens in the signer's UnsignedAttributes, - /// and finally falls back to the signer's own SignedAttributes if needed. + /// The method first attempts to use the countersigner info in the PKCS#7 data (the legacy + /// Authenticode timestamp format), then checks for RFC 3161 timestamp tokens in the signer's + /// UnsignedAttributes, and finally falls back to the signer's own SignedAttributes if needed. /// internal static DateTime? GetSigningTime(PeFile peFile) { @@ -222,7 +239,7 @@ private static List CharacteristicsTypeToListOfCharacteristi foreach (var signerInfo in signedCms.SignerInfos) { - // Check counter-signers for the Authenticode timestamp + // Legacy Authenticode timestamps are PKCS#9 countersignatures carrying a signingTime attribute foreach (var counterSigner in signerInfo.CounterSignerInfos) { var time = GetPkcs9SigningTime(counterSigner.SignedAttributes); @@ -230,34 +247,25 @@ private static List CharacteristicsTypeToListOfCharacteristi return time; } - // Check unsigned attributes for RFC 3161 timestamp tokens + // Modern Authenticode timestamps are RFC 3161 tokens carried in an unsigned attribute foreach (var attr in signerInfo.UnsignedAttributes) { - // RFC 3161 timestamp token OID: 1.2.840.113549.1.9.16.2.14 (signatureTimeStampToken) - if (string.Equals(attr.Oid?.Value, "1.2.840.113549.1.9.16.2.14", StringComparison.Ordinal)) + if (!string.Equals(attr.Oid?.Value, MicrosoftRfc3161TimestampOid, StringComparison.Ordinal) && + !string.Equals(attr.Oid?.Value, Rfc3161TimestampOid, StringComparison.Ordinal)) { - foreach (var val in attr.Values) - { - try - { - var tokenCms = new SignedCms(); - tokenCms.Decode(val.RawData); - foreach (var tokenSigner in tokenCms.SignerInfos) - { - var time = GetPkcs9SigningTime(tokenSigner.SignedAttributes); - if (time.HasValue) - return time; - } - } - catch (CryptographicException) - { - // Not a valid CMS structure, skip - } - } + continue; + } + + foreach (var val in attr.Values) + { + var time = GetRfc3161TimestampTime(val.RawData); + if (time.HasValue) + return time; } } - // Fallback: check the signer's own signed attributes + // Fallback: check the signer's own signed attributes. This time is asserted by the + // signer rather than by a trusted timestamp authority. var signerTime = GetPkcs9SigningTime(signerInfo.SignedAttributes); if (signerTime.HasValue) return signerTime; @@ -270,6 +278,63 @@ private static List CharacteristicsTypeToListOfCharacteristi return null; } + /// + /// Extracts the trusted time from an RFC 3161 timestamp token. The authoritative value is the + /// genTime of the encapsulated TSTInfo; timestamp authorities are not required to also place a + /// PKCS#9 signingTime attribute on the token's signer, so that is only used as a fallback. + /// + private static DateTime? GetRfc3161TimestampTime(byte[] tokenData) + { + try + { + var tokenCms = new SignedCms(); + tokenCms.Decode(tokenData); + + if (string.Equals(tokenCms.ContentInfo.ContentType?.Value, TstInfoContentTypeOid, StringComparison.Ordinal)) + { + var genTime = GetTstInfoGenTime(tokenCms.ContentInfo.Content); + if (genTime.HasValue) + return genTime; + } + + foreach (var tokenSigner in tokenCms.SignerInfos) + { + var time = GetPkcs9SigningTime(tokenSigner.SignedAttributes); + if (time.HasValue) + return time; + } + } + catch (CryptographicException) + { + // Not a valid CMS structure, skip + } + catch (AsnContentException) + { + // Malformed ASN.1, skip + } + return null; + } + + /// + /// Reads the genTime field out of a DER encoded RFC 3161 TSTInfo structure. + /// + private static DateTime? GetTstInfoGenTime(byte[] tstInfo) + { + try + { + var reader = new AsnReader(tstInfo, AsnEncodingRules.BER).ReadSequence(); + reader.ReadInteger(); // version + reader.ReadObjectIdentifier(); // policy + reader.ReadSequence(); // messageImprint + reader.ReadInteger(); // serialNumber + return reader.ReadGeneralizedTime().UtcDateTime; + } + catch (AsnContentException) + { + return null; + } + } + private static DateTime? GetPkcs9SigningTime(CryptographicAttributeObjectCollection attributes) { foreach (var attr in attributes) diff --git a/Lib/Objects/Signature.cs b/Lib/Objects/Signature.cs index f1e8ee99..d7116a53 100644 --- a/Lib/Objects/Signature.cs +++ b/Lib/Objects/Signature.cs @@ -38,13 +38,24 @@ public Signature() { } + /// + /// True when the signature carries a signing time which falls inside the signing + /// certificate's validity period. A binary that was correctly signed and timestamped stays + /// valid here even after its certificate expires. False when the signing time could not be + /// determined, so callers that need to distinguish "signed outside validity" from "signing + /// time unknown" should also inspect . + /// public bool IsTimeValid { get { - if (SigningCertificate != null && SigningTime is DateTime signingTime) + if (SigningCertificate is SerializableCertificate certificate && SigningTime is DateTime signingTime) { - return signingTime >= SigningCertificate.NotBefore && signingTime <= SigningCertificate.NotAfter; + // Signing times are recovered as UTC while certificate validity comes back from + // X509Certificate2 as local time, so both sides are normalized before comparing. + var signedAtUtc = signingTime.ToUniversalTime(); + return signedAtUtc >= certificate.NotBefore.ToUniversalTime() && + signedAtUtc <= certificate.NotAfter.ToUniversalTime(); } return false; } @@ -54,6 +65,11 @@ public bool IsTimeValid public string? SignedHash { get; set; } public string? SignerSerialNumber { get; set; } public SerializableCertificate? SigningCertificate { get; set; } + + /// + /// The time the binary was signed, recovered from the Authenticode timestamp when one is + /// present. Null when the signature is absent or carries no recoverable timestamp. + /// public DateTime? SigningTime { get; set; } } -} \ No newline at end of file +} diff --git a/Tests/SignatureTests.cs b/Tests/SignatureTests.cs index bc56287a..8ec5bd1f 100644 --- a/Tests/SignatureTests.cs +++ b/Tests/SignatureTests.cs @@ -1,83 +1,96 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License. using Microsoft.CST.AttackSurfaceAnalyzer.Collectors; using Microsoft.CST.AttackSurfaceAnalyzer.Objects; +using Microsoft.CST.AttackSurfaceAnalyzer.Types; +using Microsoft.CST.AttackSurfaceAnalyzer.Utils; using Microsoft.VisualStudio.TestTools.UnitTesting; +using PeNet; using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Security.Cryptography.Pkcs; namespace Microsoft.CST.AttackSurfaceAnalyzer.Tests { [TestClass, TestCategory("PipelineSafeTests")] public class SignatureTests { + private const string OutsideValidityRule = "Binaries signed outside certificate validity period"; + private const string UndeterminableTimeRule = "Binaries with an undeterminable signing time"; + + /// + /// A binary timestamped with the legacy Authenticode format, where the signing time lives in a + /// PKCS#9 countersignature. + /// + private static readonly string LegacyTimestampedBinary = Path.Combine(AppContext.BaseDirectory, "TpmSim", "vcruntime140d.dll"); + + /// + /// An unsigned binary. + /// + private static readonly string UnsignedBinary = Path.Combine(AppContext.BaseDirectory, "TpmSim", "Simulator.exe"); + + /// + /// Assemblies shipped alongside the tests which are timestamped with the modern RFC 3161 format. + /// Any of these exercises the RFC 3161 code path; the first usable one is chosen at runtime so a + /// package update cannot silently remove coverage. + /// + private static readonly string[] Rfc3161TimestampedCandidates = + { + "Microsoft.Data.Sqlite.dll", + "Newtonsoft.Json.dll", + "Microsoft.CodeAnalysis.dll" + }; + + [ClassInitialize] + public static void ClassSetup(TestContext _) + { + Logger.Setup(false, true); + Strings.Setup(); + } + + #region IsTimeValid unit tests + [TestMethod] public void IsTimeValid_SignedDuringCertValidity_ReturnsTrue() { - var sig = new Signature() - { - SigningCertificate = new SerializableCertificate( - "thumbprint", "CN=Test", "key", - new DateTime(2025, 12, 31), // NotAfter - new DateTime(2020, 1, 1), // NotBefore - "CN=Issuer", "serial", "hash", "pkcs7"), - SigningTime = new DateTime(2023, 6, 15) // Signed within validity - }; + var sig = MakeSignature(signingTime: new DateTime(2023, 6, 15), + notBefore: new DateTime(2020, 1, 1), + notAfter: new DateTime(2025, 12, 31)); Assert.IsTrue(sig.IsTimeValid); } [TestMethod] public void IsTimeValid_SignedAfterCertExpiry_ReturnsFalse() { - var sig = new Signature() - { - SigningCertificate = new SerializableCertificate( - "thumbprint", "CN=Test", "key", - new DateTime(2022, 12, 31), // NotAfter - new DateTime(2020, 1, 1), // NotBefore - "CN=Issuer", "serial", "hash", "pkcs7"), - SigningTime = new DateTime(2023, 6, 15) // Signed after expiry - }; + var sig = MakeSignature(signingTime: new DateTime(2023, 6, 15), + notBefore: new DateTime(2020, 1, 1), + notAfter: new DateTime(2022, 12, 31)); Assert.IsFalse(sig.IsTimeValid); } [TestMethod] public void IsTimeValid_SignedBeforeCertNotBefore_ReturnsFalse() { - var sig = new Signature() - { - SigningCertificate = new SerializableCertificate( - "thumbprint", "CN=Test", "key", - new DateTime(2025, 12, 31), // NotAfter - new DateTime(2020, 1, 1), // NotBefore - "CN=Issuer", "serial", "hash", "pkcs7"), - SigningTime = new DateTime(2019, 6, 15) // Signed before NotBefore - }; + var sig = MakeSignature(signingTime: new DateTime(2019, 6, 15), + notBefore: new DateTime(2020, 1, 1), + notAfter: new DateTime(2025, 12, 31)); Assert.IsFalse(sig.IsTimeValid); } [TestMethod] public void IsTimeValid_NullSigningTime_ReturnsFalse() { - var sig = new Signature() - { - SigningCertificate = new SerializableCertificate( - "thumbprint", "CN=Test", "key", - new DateTime(2025, 12, 31), - new DateTime(2020, 1, 1), - "CN=Issuer", "serial", "hash", "pkcs7"), - SigningTime = null - }; + var sig = MakeSignature(signingTime: null, + notBefore: new DateTime(2020, 1, 1), + notAfter: new DateTime(2025, 12, 31)); Assert.IsFalse(sig.IsTimeValid); } [TestMethod] public void IsTimeValid_NullSigningCertificate_ReturnsFalse() { - var sig = new Signature() - { - SigningCertificate = null, - SigningTime = new DateTime(2023, 6, 15) - }; + var sig = new Signature() { SigningCertificate = null, SigningTime = new DateTime(2023, 6, 15) }; Assert.IsFalse(sig.IsTimeValid); } @@ -85,15 +98,9 @@ public void IsTimeValid_NullSigningCertificate_ReturnsFalse() public void IsTimeValid_CertExpiredNow_ButSignedDuringValidity_ReturnsTrue() { // This is the key scenario: cert is currently expired, but was valid when signing occurred - var sig = new Signature() - { - SigningCertificate = new SerializableCertificate( - "thumbprint", "CN=Test", "key", - new DateTime(2020, 12, 31), // NotAfter - expired now - new DateTime(2018, 1, 1), // NotBefore - "CN=Issuer", "serial", "hash", "pkcs7"), - SigningTime = new DateTime(2019, 6, 15) // Signed while cert was valid - }; + var sig = MakeSignature(signingTime: new DateTime(2019, 6, 15), + notBefore: new DateTime(2018, 1, 1), + notAfter: new DateTime(2020, 12, 31)); Assert.IsTrue(sig.IsTimeValid); } @@ -101,15 +108,7 @@ public void IsTimeValid_CertExpiredNow_ButSignedDuringValidity_ReturnsTrue() public void IsTimeValid_SignedExactlyAtNotBefore_ReturnsTrue() { var notBefore = new DateTime(2020, 1, 1); - var sig = new Signature() - { - SigningCertificate = new SerializableCertificate( - "thumbprint", "CN=Test", "key", - new DateTime(2025, 12, 31), - notBefore, - "CN=Issuer", "serial", "hash", "pkcs7"), - SigningTime = notBefore // Signed exactly at NotBefore boundary - }; + var sig = MakeSignature(signingTime: notBefore, notBefore: notBefore, notAfter: new DateTime(2025, 12, 31)); Assert.IsTrue(sig.IsTimeValid); } @@ -117,67 +116,130 @@ public void IsTimeValid_SignedExactlyAtNotBefore_ReturnsTrue() public void IsTimeValid_SignedExactlyAtNotAfter_ReturnsTrue() { var notAfter = new DateTime(2025, 12, 31); - var sig = new Signature() - { - SigningCertificate = new SerializableCertificate( - "thumbprint", "CN=Test", "key", - notAfter, - new DateTime(2020, 1, 1), - "CN=Issuer", "serial", "hash", "pkcs7"), - SigningTime = notAfter // Signed exactly at NotAfter boundary - }; + var sig = MakeSignature(signingTime: notAfter, notBefore: new DateTime(2020, 1, 1), notAfter: notAfter); Assert.IsTrue(sig.IsTimeValid); } [TestMethod] - public void GetSignatureStatus_WithSignedPeFile_PopulatesSigningTime() + public void IsTimeValid_SameInstantExpressedInDifferentKinds_AgreesOnValidity() { - // vcruntime140d.dll is a signed Microsoft PE binary already in the repo - var path = Path.Combine(AppContext.BaseDirectory, "TpmSim", "vcruntime140d.dll"); - if (!File.Exists(path)) - Assert.Inconclusive("Test binary not found at: " + path); + var notBeforeUtc = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var notAfterUtc = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var signingTimeUtc = new DateTime(2020, 6, 1, 0, 0, 0, DateTimeKind.Utc); - using var stream = File.OpenRead(path); - var sig = WindowsFileSystemUtils.GetSignatureStatus(path, stream); + var allUtc = MakeSignature(signingTimeUtc, notBeforeUtc, notAfterUtc); + var allLocal = MakeSignature(signingTimeUtc.ToLocalTime(), notBeforeUtc.ToLocalTime(), notAfterUtc.ToLocalTime()); + // Signing times are recovered as UTC while certificate validity comes back as local time, + // which is the combination produced by a real collection. + var mixed = MakeSignature(signingTimeUtc, notBeforeUtc.ToLocalTime(), notAfterUtc.ToLocalTime()); - Assert.IsNotNull(sig, "Should parse a PE file's signature"); + Assert.IsTrue(allUtc.IsTimeValid); + Assert.AreEqual(allUtc.IsTimeValid, allLocal.IsTimeValid, "Validity must not depend on how the times are expressed"); + Assert.AreEqual(allUtc.IsTimeValid, mixed.IsTimeValid, "Validity must not depend on how the times are expressed"); + } + + [TestMethod] + public void IsTimeValid_UtcSigningTimeAtLocalCertBoundary_ComparesInUtc() + { + var notBeforeLocal = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Local); + var notAfterLocal = new DateTime(2021, 1, 1, 0, 0, 0, DateTimeKind.Local); + + var atNotAfter = MakeSignature(notAfterLocal.ToUniversalTime(), notBeforeLocal, notAfterLocal); + Assert.IsTrue(atNotAfter.IsTimeValid, "Signing exactly at NotAfter is inside the validity period"); + + var atNotBefore = MakeSignature(notBeforeLocal.ToUniversalTime(), notBeforeLocal, notAfterLocal); + Assert.IsTrue(atNotBefore.IsTimeValid, "Signing exactly at NotBefore is inside the validity period"); + + var pastNotAfter = MakeSignature(notAfterLocal.ToUniversalTime().AddSeconds(1), notBeforeLocal, notAfterLocal); + Assert.IsFalse(pastNotAfter.IsTimeValid, "Signing one second after NotAfter is outside the validity period"); + + var beforeNotBefore = MakeSignature(notBeforeLocal.ToUniversalTime().AddSeconds(-1), notBeforeLocal, notAfterLocal); + Assert.IsFalse(beforeNotBefore.IsTimeValid, "Signing one second before NotBefore is outside the validity period"); + } + + #endregion + + #region Signing time extraction against real binaries + + [TestMethod] + public void GetSignatureStatus_LegacyCounterSignedBinary_PopulatesSigningTime() + { + var sig = GetSignatureOrInconclusive(LegacyTimestampedBinary); + + Assert.IsTrue(sig.IsAuthenticodeValid, "Known-signed binary should be authenticode valid"); Assert.IsNotNull(sig.SigningTime, "Signed binary should have an extracted SigningTime"); + Assert.AreEqual(DateTimeKind.Utc, sig.SigningTime.Value.Kind, "Signing times are reported in UTC"); + Assert.IsTrue(sig.SigningTime.Value > new DateTime(2000, 1, 1), "SigningTime should be a reasonable date"); + Assert.IsTrue(sig.SigningTime.Value < DateTime.UtcNow, "SigningTime should be in the past"); + } + + [TestMethod] + public void GetSignatureStatus_LegacyCounterSignedBinary_IsTimeValidReflectsSigningWindow() + { + var sig = GetSignatureOrInconclusive(LegacyTimestampedBinary); + + Assert.IsNotNull(sig.SigningTime); + Assert.IsNotNull(sig.SigningCertificate); + Assert.IsTrue(sig.IsTimeValid, "A properly timestamped binary should have IsTimeValid = true"); + } + + /// + /// Modern Authenticode signatures carry an RFC 3161 timestamp token instead of a PKCS#9 + /// countersignature, and the token's authoritative time is the genTime of its encapsulated + /// TSTInfo rather than a signingTime attribute. Without support for that shape virtually every + /// current Windows binary would report an unknown signing time. + /// + [TestMethod] + public void GetSignatureStatus_Rfc3161TimestampedBinary_PopulatesSigningTime() + { + var path = FindRfc3161TimestampedBinary(); + if (path is null) + { + Assert.Inconclusive("No RFC 3161 timestamped binary was found next to the test assembly"); + return; + } + + Assert.AreEqual(0, CountPkcs9CounterSigners(path), + $"{Path.GetFileName(path)} must have no PKCS#9 countersignature, otherwise this test does not cover the RFC 3161 path"); + + var sig = GetSignatureOrInconclusive(path); + Assert.IsTrue(sig.IsAuthenticodeValid, "Known-signed binary should be authenticode valid"); - // The signing time should be within a reasonable historical range + Assert.IsNotNull(sig.SigningTime, "An RFC 3161 timestamped binary should have an extracted SigningTime"); + Assert.AreEqual(DateTimeKind.Utc, sig.SigningTime.Value.Kind, "Signing times are reported in UTC"); Assert.IsTrue(sig.SigningTime.Value > new DateTime(2000, 1, 1), "SigningTime should be a reasonable date"); Assert.IsTrue(sig.SigningTime.Value < DateTime.UtcNow, "SigningTime should be in the past"); } [TestMethod] - public void GetSignatureStatus_WithSignedPeFile_IsTimeValidReflectsSigningWindow() + public void GetSignatureStatus_Rfc3161TimestampedBinary_IsTimeValidReflectsSigningWindow() { - var path = Path.Combine(AppContext.BaseDirectory, "TpmSim", "vcruntime140d.dll"); - if (!File.Exists(path)) - Assert.Inconclusive("Test binary not found at: " + path); + var path = FindRfc3161TimestampedBinary(); + if (path is null) + { + Assert.Inconclusive("No RFC 3161 timestamped binary was found next to the test assembly"); + return; + } - using var stream = File.OpenRead(path); - var sig = WindowsFileSystemUtils.GetSignatureStatus(path, stream); + var sig = GetSignatureOrInconclusive(path); - Assert.IsNotNull(sig); Assert.IsNotNull(sig.SigningTime); Assert.IsNotNull(sig.SigningCertificate); - // The binary was signed while the certificate was valid - Assert.IsTrue(sig.SigningTime.Value >= sig.SigningCertificate.NotBefore, - $"SigningTime {sig.SigningTime} should be >= cert NotBefore {sig.SigningCertificate.NotBefore}"); - Assert.IsTrue(sig.SigningTime.Value <= sig.SigningCertificate.NotAfter, - $"SigningTime {sig.SigningTime} should be <= cert NotAfter {sig.SigningCertificate.NotAfter}"); - Assert.IsTrue(sig.IsTimeValid, "A properly timestamped binary should have IsTimeValid = true"); + Assert.IsTrue(sig.IsTimeValid, + $"Signed at {sig.SigningTime:u}, certificate valid {sig.SigningCertificate.NotBefore:u}..{sig.SigningCertificate.NotAfter:u}"); } [TestMethod] public void GetSignatureStatus_WithUnsignedPeFile_HasNullSigningTime() { - var path = Path.Combine(AppContext.BaseDirectory, "TpmSim", "Simulator.exe"); - if (!File.Exists(path)) - Assert.Inconclusive("Test binary not found at: " + path); + if (!File.Exists(UnsignedBinary)) + { + Assert.Inconclusive("Test binary not found at: " + UnsignedBinary); + return; + } - using var stream = File.OpenRead(path); - var sig = WindowsFileSystemUtils.GetSignatureStatus(path, stream); + using var stream = File.OpenRead(UnsignedBinary); + var sig = WindowsFileSystemUtils.GetSignatureStatus(UnsignedBinary, stream); // Unsigned PE should either return null signature or have null SigningTime if (sig != null) @@ -186,5 +248,243 @@ public void GetSignatureStatus_WithUnsignedPeFile_HasNullSigningTime() Assert.IsFalse(sig.IsTimeValid, "Unsigned binary should have IsTimeValid = false"); } } + + [TestMethod] + public void GetSignatureStatus_PathAndStreamOverloads_AgreeOnSigningTime() + { + var path = FindRfc3161TimestampedBinary() ?? LegacyTimestampedBinary; + if (!File.Exists(path)) + { + Assert.Inconclusive("Test binary not found at: " + path); + return; + } + + var fromPath = WindowsFileSystemUtils.GetSignatureStatus(path); + using var stream = File.OpenRead(path); + var fromStream = WindowsFileSystemUtils.GetSignatureStatus(path, stream); + + Assert.IsNotNull(fromPath); + Assert.IsNotNull(fromStream); + Assert.AreEqual(fromPath.SigningTime, fromStream.SigningTime, "Both collection paths must report the same signing time"); + } + + #endregion + + #region Analysis rule tests + + [TestMethod] + public void AnalysisRules_SignedWithinValidity_IsNotFlagged() + { + var rules = AnalyzeFileWith(MakeSignature(signingTime: new DateTime(2019, 6, 15, 0, 0, 0, DateTimeKind.Utc), + notBefore: new DateTime(2018, 1, 1), + notAfter: new DateTime(2030, 12, 31))); + + CollectionAssert.DoesNotContain(rules, OutsideValidityRule); + CollectionAssert.DoesNotContain(rules, UndeterminableTimeRule); + } + + /// + /// The regression this whole feature exists to prevent: a binary signed and timestamped while + /// its certificate was valid must not be reported once that certificate expires. + /// + [TestMethod] + public void AnalysisRules_CertificateExpiredButSignedWhileValid_IsNotFlagged() + { + var rules = AnalyzeFileWith(MakeSignature(signingTime: new DateTime(2019, 6, 15, 0, 0, 0, DateTimeKind.Utc), + notBefore: new DateTime(2018, 1, 1), + notAfter: new DateTime(2020, 12, 31))); + + CollectionAssert.DoesNotContain(rules, OutsideValidityRule); + CollectionAssert.DoesNotContain(rules, UndeterminableTimeRule); + } + + [TestMethod] + public void AnalysisRules_SignedAfterCertificateExpiry_FlagsOutsideValidityOnly() + { + var rules = AnalyzeFileWith(MakeSignature(signingTime: new DateTime(2023, 6, 15, 0, 0, 0, DateTimeKind.Utc), + notBefore: new DateTime(2018, 1, 1), + notAfter: new DateTime(2020, 12, 31))); + + CollectionAssert.Contains(rules, OutsideValidityRule); + CollectionAssert.DoesNotContain(rules, UndeterminableTimeRule); + } + + [TestMethod] + public void AnalysisRules_SignedBeforeCertificateNotBefore_FlagsOutsideValidityOnly() + { + var rules = AnalyzeFileWith(MakeSignature(signingTime: new DateTime(2017, 6, 15, 0, 0, 0, DateTimeKind.Utc), + notBefore: new DateTime(2018, 1, 1), + notAfter: new DateTime(2020, 12, 31))); + + CollectionAssert.Contains(rules, OutsideValidityRule); + CollectionAssert.DoesNotContain(rules, UndeterminableTimeRule); + } + + [TestMethod] + public void AnalysisRules_UndeterminableSigningTime_FlagsInformationRuleOnly() + { + var rules = AnalyzeFileWith(MakeSignature(signingTime: null, + notBefore: new DateTime(2018, 1, 1), + notAfter: new DateTime(2020, 12, 31))); + + CollectionAssert.Contains(rules, UndeterminableTimeRule); + CollectionAssert.DoesNotContain(rules, OutsideValidityRule); + } + + [TestMethod] + public void AnalysisRules_UnsignedBinary_IsNotFlagged() + { + var rules = AnalyzeFileWith(MakeSignature(signingTime: null, + notBefore: new DateTime(2018, 1, 1), + notAfter: new DateTime(2020, 12, 31), + isAuthenticodeValid: false)); + + CollectionAssert.DoesNotContain(rules, OutsideValidityRule); + CollectionAssert.DoesNotContain(rules, UndeterminableTimeRule); + } + + [TestMethod] + public void AnalysisRules_TheTwoSigningTimeRulesHaveDistinctSeverities() + { + var rules = RuleFile.LoadEmbeddedFilters().Rules + .Where(x => x.Name == OutsideValidityRule || x.Name == UndeterminableTimeRule) + .OfType() + .ToList(); + + Assert.AreEqual(4, rules.Count, "Both rules should be defined for FILE and FILEMONITOR"); + Assert.IsTrue(rules.Where(x => x.Name == OutsideValidityRule).All(x => x.Flag == ANALYSIS_RESULT_TYPE.WARNING)); + Assert.IsTrue(rules.Where(x => x.Name == UndeterminableTimeRule).All(x => x.Flag == ANALYSIS_RESULT_TYPE.INFORMATION)); + } + + /// + /// Guards against the analysis regressing into flagging ordinary, correctly signed binaries. + /// + [TestMethod] + public void AnalysisRules_RealTimestampedBinaries_AreNotFlagged() + { + var paths = Rfc3161TimestampedCandidates + .Select(x => Path.Combine(AppContext.BaseDirectory, x)) + .Append(LegacyTimestampedBinary) + .Where(File.Exists) + .ToList(); + + if (paths.Count == 0) + { + Assert.Inconclusive("No signed binaries were found next to the test assembly"); + return; + } + + foreach (var path in paths) + { + var sig = WindowsFileSystemUtils.GetSignatureStatus(path); + if (sig is null || !sig.IsAuthenticodeValid) + { + continue; + } + + var rules = AnalyzeFileWith(sig, path); + CollectionAssert.DoesNotContain(rules, OutsideValidityRule, $"{Path.GetFileName(path)} was signed at {sig.SigningTime:u}"); + CollectionAssert.DoesNotContain(rules, UndeterminableTimeRule, $"{Path.GetFileName(path)} has no recoverable signing time"); + } + } + + #endregion + + #region Helpers + + private static Signature MakeSignature(DateTime? signingTime, DateTime notBefore, DateTime notAfter, bool isAuthenticodeValid = true) + { + return new Signature() + { + IsAuthenticodeValid = isAuthenticodeValid, + SigningTime = signingTime, + SigningCertificate = new SerializableCertificate( + Thumbprint: "thumbprint", + Subject: "CN=Test", + PublicKey: "key", + NotAfter: notAfter, + NotBefore: notBefore, + Issuer: "CN=Issuer", + SerialNumber: "serial", + CertHashString: "hash", + Pkcs7: "pkcs7") + }; + } + + private static Signature GetSignatureOrInconclusive(string path) + { + if (!File.Exists(path)) + { + Assert.Inconclusive("Test binary not found at: " + path); + } + + using var stream = File.OpenRead(path); + var sig = WindowsFileSystemUtils.GetSignatureStatus(path, stream); + Assert.IsNotNull(sig, "Should parse a PE file's signature"); + return sig; + } + + private static List AnalyzeFileWith(Signature signature, string path = @"C:\test\binary.dll") + { + var analyzer = new AsaAnalyzer(); + var ruleFile = RuleFile.LoadEmbeddedFilters(); + var fso = new FileSystemObject(path) { SignatureStatus = signature, IsExecutable = true }; + + return analyzer.Analyze(ruleFile.Rules, new CompareResult() { Compare = fso }) + .Select(x => x.Name) + .ToList(); + } + + /// + /// Returns the first assembly next to the test binary which is Authenticode signed with an RFC + /// 3161 timestamp and no legacy PKCS#9 countersignature, or null when none is available. + /// + private static string? FindRfc3161TimestampedBinary() + { + foreach (var candidate in Rfc3161TimestampedCandidates) + { + var path = Path.Combine(AppContext.BaseDirectory, candidate); + if (!File.Exists(path)) + { + continue; + } + + try + { + if (CountPkcs9CounterSigners(path) == 0 && HasWinCertificate(path)) + { + return path; + } + } + catch (Exception) + { + // Not usable as a fixture, try the next candidate + } + } + + return null; + } + + private static bool HasWinCertificate(string path) + { + using var stream = File.OpenRead(path); + return PeFile.IsPeFile(stream) && new PeFile(stream).WinCertificate is not null; + } + + private static int CountPkcs9CounterSigners(string path) + { + using var stream = File.OpenRead(path); + var certData = new PeFile(stream).WinCertificate?.BCertificate.ToArray(); + if (certData is null) + { + return 0; + } + + var cms = new SignedCms(); + cms.Decode(certData); + return cms.SignerInfos.Cast().Sum(x => x.CounterSignerInfos.Count); + } + + #endregion } } diff --git a/analyses.json b/analyses.json index 2fb3f207..f176ecf8 100644 --- a/analyses.json +++ b/analyses.json @@ -1556,8 +1556,8 @@ ] }, { - "Name": "Binaries with unverified signature validity", - "Description": "These binaries have signatures where the signing time could not be verified as being within the certificate's validity period. The signing time may be outside the validity period or could not be determined.", + "Name": "Binaries signed outside certificate validity period", + "Description": "These binaries carry a signing time which falls outside the signing certificate's validity period, so the signature was not applied while the certificate was valid.", "Flag": "WARNING", "ResultType": "FILE", "ChangeTypes": [ @@ -1569,6 +1569,11 @@ "Field": "SignatureStatus.IsAuthenticodeValid", "Operation": "IsTrue" }, + { + "Field": "SignatureStatus.SigningTime", + "Operation": "IsNull", + "Invert": true + }, { "Field": "SignatureStatus.IsTimeValid", "Operation": "IsTrue", @@ -1577,8 +1582,28 @@ ] }, { - "Name": "Binaries with unverified signature validity", - "Description": "These binaries have signatures where the signing time could not be verified as being within the certificate's validity period. The signing time may be outside the validity period or could not be determined.", + "Name": "Binaries with an undeterminable signing time", + "Description": "These binaries are signed but carry no recoverable signing time, so the signature cannot be confirmed as having been applied while the signing certificate was valid.", + "Flag": "INFORMATION", + "ResultType": "FILE", + "ChangeTypes": [ + "MODIFIED", + "CREATED" + ], + "Clauses": [ + { + "Field": "SignatureStatus.IsAuthenticodeValid", + "Operation": "IsTrue" + }, + { + "Field": "SignatureStatus.SigningTime", + "Operation": "IsNull" + } + ] + }, + { + "Name": "Binaries signed outside certificate validity period", + "Description": "These binaries carry a signing time which falls outside the signing certificate's validity period, so the signature was not applied while the certificate was valid.", "Flag": "WARNING", "ResultType": "FILEMONITOR", "ChangeTypes": [ @@ -1590,6 +1615,11 @@ "Field": "FileSystemObject.SignatureStatus.IsAuthenticodeValid", "Operation": "IsTrue" }, + { + "Field": "FileSystemObject.SignatureStatus.SigningTime", + "Operation": "IsNull", + "Invert": true + }, { "Field": "FileSystemObject.SignatureStatus.IsTimeValid", "Operation": "IsTrue", @@ -1597,6 +1627,26 @@ } ] }, + { + "Name": "Binaries with an undeterminable signing time", + "Description": "These binaries are signed but carry no recoverable signing time, so the signature cannot be confirmed as having been applied while the signing certificate was valid.", + "Flag": "INFORMATION", + "ResultType": "FILEMONITOR", + "ChangeTypes": [ + "MODIFIED", + "CREATED" + ], + "Clauses": [ + { + "Field": "FileSystemObject.SignatureStatus.IsAuthenticodeValid", + "Operation": "IsTrue" + }, + { + "Field": "FileSystemObject.SignatureStatus.SigningTime", + "Operation": "IsNull" + } + ] + }, { "Name": "TPM Keys", "Description": "These TPM Keys have been changed.",