using System; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.Pkcs; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading.Tasks; using NLog; using VerifyingFiles.PInvoke; namespace ZitiUpdateService.Checkers.PeFile { public class SignedFileValidator { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); public const int LengthOfChecksum = 4; //the checksum is 4 bytes public const int LengthCertificateTable = 8; //the Certificate Table is 8 bytes public HashPositions ImportantHashPositions { get; private set; } public string FilePath { get; private set; } public PeType Type { get; private set; } public IMAGE_FILE_HEADER CoffHeader { get; private set; } public IMAGE_OPTIONAL_HEADER32 StandardFieldsPe32 { get; private set; } public IMAGE_OPTIONAL_HEADER64 StandardFieldsPe32Plus { get; private set; } private readonly X509Certificate2 expectedRootCa = null; private readonly X509Store empty = new X509Store(); private X509Store SystemCAs = new X509Store(); private const string oldKnownThumbprint = "39636E9F5E80308DE370C914CE8112876ECF4E0C"; public SignedFileValidator(string pathToFile) { X509Store store = new X509Store("MY", StoreLocation.CurrentUser); store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); SystemCAs.Open(OpenFlags.ReadWrite); SystemCAs.AddRange(store.Certificates); expectedRootCa = certFromResource("ZitiUpdateService.checkers.PeFile.openziti.rootCA.rsa.pem.txt"); FilePath = pathToFile; ImportantHashPositions = new HashPositions(); parse(); if (ImportantHashPositions.reorderNeeded) { ImportantHashPositions.SectionTableHeaders.Sort((x, y) => x.PointerToRawData.CompareTo(y.PointerToRawData)); } } private X509Certificate2 certFromResource(string resourceName) { var assembly = this.GetType().Assembly; using (Stream stream = assembly.GetManifestResourceStream(resourceName)) using (BinaryReader reader = new BinaryReader(stream)) { byte[] bytes = new byte[stream.Length]; stream.Read(bytes, 0, bytes.Length); return new X509Certificate2(bytes); } } // ReSharper disable once InconsistentNaming private void parse() { using (FileStream stream = new FileStream(FilePath, System.IO.FileMode.Open, System.IO.FileAccess.Read)) using (BinaryReader reader = new BinaryReader(stream)) { //seek to 0x3c to get right to the PE header location... stream.Seek(0x3c, SeekOrigin.Begin); uint startCoffHeader = BitConverter.ToUInt32(reader.ReadBytes(4), 0); ImportantHashPositions.SetStartCoffHeader(startCoffHeader); stream.Seek(startCoffHeader, SeekOrigin.Begin); //parse COFF Header (28 or 24 bytes): https://docs.microsoft.com/en-us/windows/win32/debug/pe-format#coff-file-header-object-and-image byte[] coffHeaderBytes = reader.ReadBytes(24); CoffHeader = StructHelper.FromBytes(coffHeaderBytes); byte[] magicNumberBytes = reader.ReadBytes(2); int magicNumber = BitConverter.ToInt16(magicNumberBytes, 0); Type = magicNumber == 0x10b ? PeType.Pe32 : PeType.Pe32Plus; stream.Seek(-2, SeekOrigin.Current); //skip back to before the magic number so the struct below is created properly //parse Optional Header Standard and Windows-Specific fields (66 or 88 bytes) //see: https://docs.microsoft.com/en-us/windows/win32/debug/pe-format#optional-header-standard-fields-image-only if (Type == PeType.Pe32) { byte[] headerBytes = reader.ReadBytes(Marshal.SizeOf(typeof(IMAGE_OPTIONAL_HEADER32))); StandardFieldsPe32 = StructHelper.FromBytes(headerBytes); } else { byte[] headerBytes = reader.ReadBytes(Marshal.SizeOf(typeof(IMAGE_OPTIONAL_HEADER64))); StandardFieldsPe32Plus = StructHelper.FromBytes(headerBytes); } ImageOptionalHeaderWrapper wrapper = new ImageOptionalHeaderWrapper() { h32 = StandardFieldsPe32, h64 = StandardFieldsPe32Plus, IsPe = Type == PeType.Pe32 }; ImportantHashPositions.SetCertificateDetails(Type, wrapper.SizeOfHeaders, wrapper.CertificateTableVirtualAddress, wrapper.CertificateTableSize); for (int i = 0; i < CoffHeader.NumberOfSections; i++) { byte[] nextSection = reader.ReadBytes(Marshal.SizeOf(typeof(IMAGE_SECTION_HEADER))); //should be 40 bytes... IMAGE_SECTION_HEADER sectionTableHeader = StructHelper.FromBytes(nextSection); ImportantHashPositions.AddSectionTableHeader(sectionTableHeader); } } } private void addCertToList(SignerInfo si, List list) { X509Certificate2 cert = si.Certificate; if (list.Find(x => x.Thumbprint == cert.Thumbprint) == null) { list.Add(cert); } else { Logger.Debug("Certificate with Thumbprint {0} already in list. skipping.", cert.Thumbprint); } } public List ExtractVerifiedSignatureCertificates() { List list = new List(); using (FileStream stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read)) using (BinaryReader reader = new BinaryReader(stream)) { //verify all the embedded signatures stream.Seek(this.ImportantHashPositions.CertificateTableStart, SeekOrigin.Begin); int readCertLen = reader.ReadInt32(); int readRevision = reader.ReadInt16(); int readCertType = reader.ReadInt16(); byte[] pkcs7 = reader.ReadBytes(readCertLen); SignedCms cms = new SignedCms(); cms.Decode(pkcs7); cms.CheckHash(); //shout out to Scott MG: https://social.msdn.microsoft.com/Forums/windowsdesktop/en-US/655f5c27-b049-4275-a8b3-cc1c0be2b4f2/retrieve-certificate-info-for-dualsigned-sha1sha256 //for indicating that the unsigned attributes contain the other SignerInfos foreach (var cmsSi in cms.SignerInfos) { cmsSi.CheckSignature(true); if (VerifyTrust(SystemCAs, expectedRootCa, cmsSi.Certificate).Result) { VerifyFileHash(cms, cmsSi); addCertToList(cmsSi, list); } if (cmsSi.UnsignedAttributes.Count > 0) { foreach (var unsignedAttr in cmsSi.UnsignedAttributes) { foreach (AsnEncodedData asn in unsignedAttr.Values) { SignedCms innerCms = new SignedCms(); innerCms.Decode(asn.RawData); innerCms.CheckHash(); if (innerCms.SignerInfos.Count > 0) { SignerInfo innerSignerInfo = innerCms.SignerInfos[0]; try { innerSignerInfo.CheckSignature(false); } catch (CryptographicException) { if (innerSignerInfo.Certificate.Thumbprint == oldKnownThumbprint) { //special handling for the known 'old' NetFoundry signing certificate, now expired... //TODO: remove this code after 2021 //just allow this one error Logger.Warn("Ignoring timestamp validity issue for the existing code signing certificate. Subject: {0}, Thumbprint: {1}", innerSignerInfo.Certificate.Subject, innerSignerInfo.Certificate.Thumbprint); } else { if (IsCertificateOpenZitiVerifies(innerSignerInfo.Certificate)) { Logger.Debug("Signature could not be verified. This is expected for the certificates issued by openziti/netfoundry."); } else { throw; } } } if (VerifyTrust(SystemCAs, expectedRootCa, innerSignerInfo.Certificate).Result) { VerifyFileHash(innerCms, innerSignerInfo); addCertToList(innerSignerInfo, list); } } } } } } } return list; } public bool IsCertificateOpenZitiVerifies(X509Certificate2 cert) { bool yesOrNo = cert.Subject.ToLower().Contains("netfoundry") || cert.Subject.ToLower().Contains("openziti"); if (yesOrNo) { Logger.Debug("Certificate does need verification: {0}", cert.Subject); } else { Logger.Debug("Certificate does not need verification: {0}", cert.Subject); } return yesOrNo; } public void Verify() { Logger.Debug("extracting certificates from file: {0}", FilePath); List list = ExtractVerifiedSignatureCertificates(); Logger.Info("Certificates extracted: {0}", list.Count); foreach (X509Certificate2 cert in list) { Logger.Info("Checking certificate: [{0}] {1}", cert.Thumbprint, cert); if (IsCertificateOpenZitiVerifies(cert)) { //verify this certificate was issued from the known CA try { Logger.Info("Verifying trust of certificate: [{0}] {1}", cert, cert.Thumbprint); VerifyTrust(empty, expectedRootCa, cert, true).Wait(); Logger.Info("Certificate [{0}] {1} was verified signed by [{2}] {3}", cert.Thumbprint, cert.Subject, expectedRootCa.Thumbprint, expectedRootCa.Subject); return; //yes! } catch { //empty on purpose. keep checking... } } } throw new CryptographicException("Executable not signed by an appropriate certificate"); } public void VerifyFileHash(SignedCms cms, SignerInfo signerInfo) { if (!IsCertificateOpenZitiVerifies(signerInfo.Certificate)) { return; } //calculate the hash using the first signed info var hashAlg = IncrementalHash.CreateHash(new HashAlgorithmName(signerInfo.DigestAlgorithm.FriendlyName.ToUpper())); byte[] calculatedHash = CalculatePeHashStreaming(hashAlg); string hash = BitConverter.ToString(calculatedHash).Replace("-", ""); string content = BitConverter.ToString(cms.ContentInfo.Content).Replace("-", ""); if (!content.Contains(hash)) { throw new CryptographicException("The expected hash and the actual hash did not match!"); } } public async static Task VerifyTrust(X509Store cas, X509Certificate2 trustedRootCertificateAuthority, X509Certificate2 certificate) { return await VerifyTrust(cas, trustedRootCertificateAuthority, certificate, false); } public async static Task VerifyTrust(X509Store cas, X509Certificate2 trustedRootCertificateAuthority, X509Certificate2 certificate, bool verifyTrustedRoot) { X509Chain chain = new X509Chain(); chain.ChainPolicy.ExtraStore.Add(trustedRootCertificateAuthority); chain.ChainPolicy.ExtraStore.AddRange(cas.Certificates); chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; chain.Build(new X509Certificate2(certificate)); List exceptions = new List(); foreach (X509ChainStatus status in chain.ChainStatus) { if (status.Status == X509ChainStatusFlags.NoError || status.Status == X509ChainStatusFlags.UntrustedRoot) { //X509ChainStatusFlags.UntrustedRoot simply means it was not found in the computer/user's trust store.... that's fine... } else { if (status.Status == X509ChainStatusFlags.NotTimeValid && certificate.Thumbprint == oldKnownThumbprint) { //X509ChainStatusFlags.NotTimeValid means the certificate has expired - we're allowing this for now (summer 2021) to allow older clients to update Logger.Warn("Executable is signed using the old signing certificate. Allowing this certificate to report as expired"); } else { exceptions.Add(new CryptographicException("Could not verify trust: " + status.StatusInformation)); } } } //final check - make sure the last certificate matches the expected thumbprint if (verifyTrustedRoot && chain.ChainElements[chain.ChainElements.Count - 1].Certificate.Thumbprint != trustedRootCertificateAuthority.Thumbprint) { exceptions.Add(new Exception("Could not verify trust. The expected thumbprint was not found!")); } foreach (var e in certificate.Extensions) { if (e.Oid.Value == "2.5.29.31") { //2.5.29.31 == CRL Distribution Points try { CrlDistributionPointParser t = new CrlDistributionPointParser(e.RawData); if (t != null) { foreach (string url in t.urls) { try { //good - things are going the way we want... keep going //fetch the CRL from the url provided and parse it... byte[] crlBytes = await new HttpClient().GetByteArrayAsync(url); if (crlBytes != null && crlBytes.Length > 0) { //fetch the crl Win32Crypto.CrlInfo info = Win32Crypto.FromBlob(crlBytes); if (info.RevokedSerialNumbers.Contains(certificate.SerialNumber)) { exceptions.Add(new CryptographicException("Serial number " + certificate.SerialNumber + " has been revoked.")); } } else { exceptions.Add(new CryptographicException("Could not retrieve revocation list from " + url + "- cannot verify trust")); } } catch (Exception innerException) { exceptions.Add(new CryptographicException("crl at " + url + " could not be used", innerException)); } } } } catch (Exception ex) { exceptions.Add(ex); } } } if (exceptions.Count > 0) { throw new AggregateException(exceptions); } return true; } public byte[] CalculatePeHashStreaming(IncrementalHash alg) { using (FileStream stream = new FileStream(FilePath, System.IO.FileMode.Open, System.IO.FileAccess.Read)) using (BinaryReader reader = new BinaryReader(stream)) { stream.Seek(ImportantHashPositions.Chunk1.Start, SeekOrigin.Begin); alg.AppendData(reader.ReadBytes(ImportantHashPositions.Chunk1.Length)); stream.Seek(ImportantHashPositions.Chunk2.Start, SeekOrigin.Begin); alg.AppendData(reader.ReadBytes(ImportantHashPositions.Chunk2.Length)); stream.Seek(ImportantHashPositions.Chunk3.Start, SeekOrigin.Begin); alg.AppendData(reader.ReadBytes(ImportantHashPositions.Chunk3.Length)); int blockSize = 8192; int read = 0; byte[] buf = new byte[blockSize]; foreach (IMAGE_SECTION_HEADER h in ImportantHashPositions.SectionTableHeaders) { stream.Seek(h.PointerToRawData, SeekOrigin.Begin); if (h.SizeOfRawData <= blockSize) { //just read the full block as one alg.AppendData(reader.ReadBytes((int)h.SizeOfRawData)); } else { long pos = stream.Position; long end = h.PointerToRawData + h.SizeOfRawData - blockSize; //process the data into blocks while (pos < end) { read = stream.Read(buf, 0, blockSize); alg.AppendData(buf, 0, read); pos += blockSize; } int remaining = (int)(end - pos + blockSize); if (remaining > 0) { read = stream.Read(buf, 0, remaining); alg.AppendData(buf, 0, read); } } } /* 14. Create a value called FILE_SIZE, which is not part of the signature. Set this value to the image’s file size, acquired from the underlying file system. If FILE_SIZE is greater than SUM_OF_BYTES_HASHED, the file contains extra data that must be added to the hash. This data begins at the SUM_OF_BYTES_HASHED file offset, and its length is: (File Size) – ((Size of AttributeCertificateTable) + SUM_OF_BYTES_HASHED) Note: The size of Attribute Certificate Table is specified in the second ULONG value in the Certificate Table entry (32 bit: offset 132, 64 bit: offset 148) in Optional Header Data Directories. */ long FILE_SIZE = new FileInfo(FilePath).Length; long leftoverBytesToHash = FILE_SIZE - ImportantHashPositions.SumOfBytesHashed - ImportantHashPositions.CertificateTableSize; if (leftoverBytesToHash > 0) { //HashBlock(alg, reader, SUM_OF_BYTES_HASHED + certTableSize, FILE_SIZE); stream.Seek(ImportantHashPositions.SumOfBytesHashed, SeekOrigin.Begin); byte[] leftoverBytes = reader.ReadBytes((int)leftoverBytesToHash); alg.AppendData(leftoverBytes); } else { //no more bytes to hash... } //alg.TransformFinalBlock(new byte[0], 0, 0); byte[] result = alg.GetHashAndReset(); return result; } } public class HashPositions { internal HashPositions() { Chunk1 = new Range(); Chunk2 = new Range(); Chunk3 = new Range(); SectionTableHeaders = new List(); } /* * These functions are all based from the document linked to from the bottom of: * https://docs.microsoft.com/en-us/windows/win32/debug/pe-format#optional-header-standard-fields-image-only * * Link to the document: * https://download.microsoft.com/download/9/c/5/9c5b2167-8017-4bae-9fde-d599bac8184a/Authenticode_PE.docx * * Relevant section copied from that document: * To calculate the hash value: 1. Load the image header into memory. 2. Initialize a hash algorithm context. 3. Hash the image header from its base to immediately before the start of the checksum address, as specified in Optional Header Windows-Specific Fields. 4. Skip over the checksum, which is a 4-byte field. 5. Hash everything from the end of the checksum field to immediately before the start of the Certificate Table entry, as specified in Optional Header Data Directories. 6. Get the Attribute Certificate Table address and size from the Certificate Table entry. For details, see section 5.7 of the PE/COFF specification. 7. Exclude the Certificate Table entry from the calculation and hash everything from the end of the Certificate Table entry to the end of image header, including Section Table (headers).The Certificate Table entry is 8 bytes long, as specified in Optional Header Data Directories. 8. Create a counter called SUM_OF_BYTES_HASHED, which is not part of the signature. Set this counter to the SizeOfHeaders field, as specified in Optional Header Windows-Specific Field. 9. Build a temporary table of pointers to all of the section headers in the image. The NumberOfSections field of COFF File Header indicates how big the table should be. Do not include any section headers in the table whose SizeOfRawData field is zero. 10. Using the PointerToRawData field (offset 20) in the referenced SectionHeader structure as a key, arrange the table's elements in ascending order. In other words, sort the section headers in ascending order according to the disk-file offset of the sections. 11. Walk through the sorted table, load the corresponding section into memory, and hash the entire section. Use the SizeOfRawData field in the SectionHeader structure to determine the amount of data to hash. 12. Add the section’s SizeOfRawData value to SUM_OF_BYTES_HASHED. 13. Repeat steps 11 and 12 for all of the sections in the sorted table. 14. Create a value called FILE_SIZE, which is not part of the signature. Set this value to the image’s file size, acquired from the underlying file system. If FILE_SIZE is greater than SUM_OF_BYTES_HASHED, the file contains extra data that must be added to the hash. This data begins at the SUM_OF_BYTES_HASHED file offset, and its length is: (File Size) – ((Size of AttributeCertificateTable) + SUM_OF_BYTES_HASHED) Note: The size of Attribute Certificate Table is specified in the second ULONG value in the Certificate Table entry (32 bit: offset 132, 64 bit: offset 148) in Optional Header Data Directories. 15. Finalize the hash algorithm context. Note: This procedure uses offset values from the PE/COFF specification, version 8.1 . For authoritative offset values, refer to the most recent version of the PE/COFF specification. */ public List SectionTableHeaders { get; private set; } /// /// Chunk1 Start will set the position in the file which the hashing of the file should begin from /// This is always the start of the file and thus is hardcoded to 0 and takes no parameters /// /// Chunk1 End is defined to be: "immediately before the start of the checksum address" /// /// Relevant step: /// 3. Hash the image header from its base to immediately before the start of the /// checksum address, as specified in Optional Header Windows-Specific Fields. /// public Range Chunk1 { get; private set; } /// /// Chunk2 Start is defined to be: "end of the checksum field" /// Chunk2 End is defined to be: "immediately before the start of the Certificate Table entry" /// /// Relevant step: /// 5. Hash everything from the end of the checksum field to immediately before the start of /// the Certificate Table entry, as specified in Optional Header Data Directories. /// public Range Chunk2 { get; private set; } /// /// Chunk3 Start is defined to be: "the end of the Certificate Table entry" /// Chunk3 End is defined to be: "the end of image header" /// When testing/developing this solution - this was found to be the position specified by /// the SizeOfHeaders field /// /// Relevant step: /// 7. Exclude the Certificate Table entry from the calculation and hash everything from the /// end of the Certificate Table entry to the end of image header, including Section Table /// (headers).The Certificate Table entry is 8 bytes long, as specified in Optional Header /// Data Directories. /// public Range Chunk3 { get; private set; } public uint SizeOfHeaders { get; private set; } public uint SumOfBytesHashed { get; private set; } public long CertificateTableStart { get; private set; } public long CertificateTableSize { get; private set; } internal bool reorderNeeded = false; private uint lastAddress = 0; public void AddSectionTableHeader(IMAGE_SECTION_HEADER sectionTableHeader) { if (sectionTableHeader.SizeOfRawData > 0) { SectionTableHeaders.Add(sectionTableHeader); } if (lastAddress < sectionTableHeader.PointerToRawData) { //good - it's in order... } else { reorderNeeded = true; } lastAddress = sectionTableHeader.PointerToRawData; SumOfBytesHashed += sectionTableHeader.SizeOfRawData; } public void SetStartCoffHeader(uint peHeaderStart) { Chunk1.Start = 0; uint checksumStart = peHeaderStart + 88; //88 is the size of the COFF Header (24) + Standard Headers + Windows-Specific Fields up to checksum (offset 64) Chunk1.End = checksumStart; Chunk2.Start = checksumStart + LengthOfChecksum; //and the four tables before the Certificate Table //(ExportTable, ImportTable, ResourceTable, ExceptionTable) uint certHeaderTableOffset = checksumStart + 64; Chunk2.End = certHeaderTableOffset; Chunk3.Start = certHeaderTableOffset + LengthCertificateTable; } public void SetCertificateDetails(PeType type, uint sizeOfHeaders, uint certificateTableStart, uint certificateTableSize) { if (type == PeType.Pe32Plus) { uint winSpecificExtraBytes = 16; //need to account for the extra bytes in the Windows-Specific Fields (each field has 4 extra bytes) Chunk2.End += winSpecificExtraBytes; Chunk3.Start += winSpecificExtraBytes; } Chunk3.End = sizeOfHeaders; SizeOfHeaders = sizeOfHeaders; SumOfBytesHashed = sizeOfHeaders; CertificateTableStart = certificateTableStart; CertificateTableSize = certificateTableSize; } } public class Range { public long Start { get; internal set; } public long End { get; internal set; } public int Length => (int)(End - Start); } } public enum PeType { Pe32, Pe32Plus } internal class CrlDistributionPointParser { internal int offset; internal int length; internal List tags = new List(); private CrlDistributionPointParser parent = null; private AsnTagType type = AsnTagType.NONE; internal List urls = new List(); private int consumedBytes = 0; internal CrlDistributionPointParser(byte[] asn1Data) : this(new MemoryStream(asn1Data)) { } internal CrlDistributionPointParser(MemoryStream asn1Data) : this(null, asn1Data/*new BinaryReader(asn1Data)*/, 0) { } internal CrlDistributionPointParser(CrlDistributionPointParser parent, MemoryStream memoryStream, /*BinaryReader asn1Reader,*/ int offset) { BinaryReader asn1Reader = new BinaryReader(memoryStream); this.offset = offset; type = readTag(asn1Reader); length = readTagLen(asn1Reader); this.parent = parent; int headerBytes = consumedBytes; while (this.consumedBytes < length + headerBytes) { switch (type) { case AsnTagType.SEQUENCE: case AsnTagType.ARRAY_ELEMENT: var t = new CrlDistributionPointParser(this, memoryStream, (int) memoryStream.Position); consumedBytes += t.consumedBytes; tags.Add(t); this.urls.AddRange(t.urls); break; case AsnTagType.STRING: consumedBytes += length; urls.Add(System.Text.Encoding.UTF8.GetString(asn1Reader.ReadBytes(length))); break; } } } internal AsnTagType readTag(BinaryReader asn1Data) { byte tagByte = asn1Data.ReadByte(); if (tagByte != 0x30 && tagByte != 0xA0 && tagByte != 0x86) { throw new CryptographicException("Could not parse CRL Distribution Points. First byte not asn SEQUENCE|ARRAY|STRING marker"); } consumedBytes++; if (tagByte == 0x30) return AsnTagType.SEQUENCE; if (tagByte == 0xA0) return AsnTagType.ARRAY_ELEMENT; if (tagByte == 0x86) return AsnTagType.STRING; return AsnTagType.NONE; } internal int readTagLen(BinaryReader asn1Data) { int tagLen = asn1Data.ReadByte(); consumedBytes++; if (tagLen > 128) { //means there's more bytes to read that control the overall length of the next section int moreBytes = tagLen - 128; byte[] lenBytes = new byte[4]; byte[] d = asn1Data.ReadBytes(moreBytes); int pos = 0; for (int i = d.Length - 1; i >= 0; i--) { lenBytes[pos++] = d[i]; } tagLen = BitConverter.ToInt32(lenBytes, 0); consumedBytes += moreBytes; } return tagLen; } } internal enum AsnTagType { SEQUENCE = 0x30, ARRAY_ELEMENT = 0xA0, STRING = 0x86, NONE = 0, } }