Verifying the signatures of executables and installers

An examiner is handed a setup.exe and works on a Mac. Or receives a .dmg with a Windows PC on the desk. The tools that read those signatures do not cross the border: signtool exists only on Windows, codesign and spctl only on macOS. Probatio calls none of them. It reads the file's bytes, so it can verify the signature wherever it runs — in both directions.

Three questions that should stay apart

Faced with a signed executable, you are really asking three questions, and it pays to keep them separate from the start:

  1. Who signed it? That is written in the signer's certificate, inside the signature.
  2. Has it changed since it was signed? You answer that by recomputing the content digest and comparing it with the signed one.
  3. Will the operating system accept it? That depends on rules living outside the file: Microsoft's root certificate programme, Gatekeeper and Apple's notarisation servers.

The first two can be answered from the bytes alone, and those are the ones Probatio answers, recognising the format from the content rather than the extension: an installer renamed to .txt does not hide what it is. We come back to the third at the end, because it is the one most often written up in a report with the wrong words.

Windows: the Authenticode hash is not the file hash

The signature on an .exe or a .dll — Authenticode — is not simply tacked onto the end. It lives in the Certificate Table, whose location is declared in the fifth entry (index 4) of the data directory in the PE header. Above all, it does not cover the whole file: the signed digest deliberately skips three pieces.

A PE file from start to end: what goes into the Authenticode hash hashed excluded MZ · DOS header PE · file header · optional header CheckSum · 4 bytes at PE+0x58 rewritten by the linker: signing it is fragile rest of the optional header · entries 0–3 entry 4 · Certificate Table · 8 bytes says where the signature is: unknown beforehand entries 5–15 · section table section .text section .rdata section .data bytes after the last section covered as well WIN_CERTIFICATE · PKCS#7 SignedData a signature cannot sign itself in order of position in the file, not in section-table order The SHA-256 of the whole file does not match the Authenticode hash, and it should not. Probatio shows them apart: the first identifies the exhibit, the second is compared with the signed one.
Three regions stay outside the signed digest: the CheckSum, the entry saying where the signature is, and the signature itself. Everything else, sections included, is covered.
  • the optional header's CheckSum field, 4 bytes: the linker rewrites it when it touches the file, and including it would make the signature fragile for no gain;
  • the Certificate Table's directory entry, 8 bytes: it holds the signature's position and size, which cannot be known until the signature exists;
  • the signature itself, at the end: no signature can sign itself.

Everything else goes into the hash in a precise order: the headers in pieces, then the sections in the order they sit in the file — not the order of the section table, which may differ — and finally any bytes left between the last section and the signature, which some linkers leave behind.

That is why the SHA-256 of the whole file never matches the signed digest, and is not supposed to. Probatio shows both: the digest of the examined file identifies the exhibit, the recomputed Authenticode hash is compared with the one declared in the signature.

Where the digest lives: SpcIndirectDataContent

The signature is a PKCS#7 SignedData. What gets signed is not the file but a small Microsoft structure, SpcIndirectDataContent, carrying the algorithm and the Authenticode hash. So the chain runs: file bytes → Authenticode hash → SpcIndirectDataContent → messageDigest attribute → the certificate holder's signature. Probatio checks both ends: it recomputes the hash over the file and verifies the signature cryptographically.

One detail makes hasty implementations fail silently. Modern CMS (RFC 5652) wants the content wrapped in an OCTET STRING; Authenticode, born on the previous generation of PKCS#7, puts the structure in directly. A verifier that assumes the wrapper digests the wrong bytes and declares a good signature invalid. Probatio accepts both encodings.

Dual signing: SHA-1 plus a nested SHA-256

Many executables signed during the transition years carry two signatures: a SHA-1 one for systems that knew nothing else, and a SHA-256 one tucked into the first one's unsigned attributes (OID 1.3.6.1.4.1.311.2.4.1). Showing only the outer signature describes the file as worse than it is — or better, if it is the nested one that is broken.

Probatio follows the nesting, verifies each signature on its own and recomputes the Authenticode hash with the algorithm each one declares. Nested signatures are shown as such, each with its own outcome.

Timestamps, in two forms

A code-signing certificate expires. Without a timestamp, once it has expired there is no longer any way to establish that the signature was applied while the certificate was valid, and Probatio raises this as a finding. There are two forms, and both are read:

  • the PKCS#9 counter-signature, the historical form: a second signer — the TSA — carrying a signingTime among its signed attributes. Probatio verifies its signature with the TSA certificate found in the envelope and checks the binding: the messageDigest signed by the TSA must be the hash of the primary signature value. Without that check, a genuine counter-signature lifted from another file and pasted here would give this signature a date that is not its own. The timestamp counts as verified only if both hold;
  • the RFC 3161 token, the current form, which Microsoft places in an unsigned attribute of its own. It is checked by the same engine that handles timestamps in CAdES and PAdES signatures (see the guide on RFC 3161 timestamps).

Two defects found on real files

Testing on real executables surfaced two defects in Probatio's own cryptographic engine, the one behind CAdES and PAdES signatures too, now fixed for all of them:

  • VeriSign and Symantec certificates write name and organisation as BMPString (UTF-16): a reader that does not decode it shows a valid signature with no signer, data that looks missing when it is merely unread;
  • some counter-signatures from those TSAs place the digest bare after the RSA padding, without PKCS#1's DigestInfo structure. They are now accepted without loosening anything: algorithm and digest must match; only the wrapping changes.

MSI installers

An .msi is a CFB container, a small filesystem inside a file. The signature lives in a service stream (DigitalSignature, preceded by the character 0x05) and is again a PKCS#7 with an SpcIndirectDataContent, but the digest covers the streams' contents in a set order, not a byte range. That digest comes with a limitation you will find below.

Apple: the CodeDirectory is signed, not the file

An Apple signature is not a PKCS#7 tacked onto a binary. The LC_CODE_SIGNATURE load command points to a SuperBlob, a container of blocks: the CodeDirectory, the requirements, the entitlements, the CMS envelope and, when present, the notarisation ticket.

The central piece is the CodeDirectory. It holds the identifier (usually the bundle id), the developer's Team ID, the algorithm, the flags — runtime for the hardened runtime, adhoc, library-validation and others — and above all a hash for every page of the binary, normally 4 KiB each. The CMS signature is computed over the CodeDirectory, and the hash of the CodeDirectory itself is the cdhash: the same value codesign -dv shows on a Mac.

Verifying an Apple signature therefore takes two distinct checks: that the CMS signature is valid over the CodeDirectory, and that the page hashes written in the CodeDirectory really match the file. Doing only one is the classic mistake. A perfectly valid signature over a CodeDirectory that no longer describes the binary is exactly what a tampered file looks like. Probatio does both, and stops at the first page that does not match.

An ad-hoc signature has no certificate, hence no signer. Probatio gives it a verdict of its own, «ad-hoc signature, no signer», distinct from both «valid» and «invalid»: the page hashes are still rechecked, so it attests that the binary has not changed since it was signed, not where it came from. This is the case for every arm64 binary signed only by the linker.

Universal binaries: one signature per architecture

fat header · cafebabe two architectures, two independent signatures x86_64 p0 p1 p2 p3 p4 p5 p6 p7 code pages of 4 KiB CodeDirectory identifier · Team ID · flags one hash for every page CMS signature computed over the CodeDirectory page hashes match CMS signature verified arm64 p0 p1 p2 p3 p4 p5 p6 p7 code pages of 4 KiB CodeDirectory identifier · Team ID · flags one hash for every page CMS signature computed over the CodeDirectory page p3 differs from the signed one and yet the CMS signature is valid File verdict: invalid signature. The worst one wins.
The arm64 slice's signature is cryptographically valid, but its CodeDirectory no longer describes the binary: one page has changed. This is the case that escapes anyone who checks only the signature, or only one architecture.

A universal («fat») binary contains several binaries, one per architecture, and each has its own signature. Checking one and declaring the file valid is a real mistake: the architectures may be signed by different parties, or just one may have been altered. Probatio verifies them all, and one architecture with a page that does not match or a signature that does not verify is enough for the file's verdict to be «invalid signature». An architecture with no signature at all, even when the others are intact, brings the verdict down to «partial verification»: the file is only partly signed, and nothing attests to that architecture's origin or integrity.

DMG: the koly trailer and a ticket you can read offline

A disk image has nothing at the start to identify it: it is recognised by a 512-byte trailer at the end, beginning with koly. From byte 296 of the trailer sit the position and length of the signature, a figure measured on real images. The signature is the same SuperBlob as in binaries, with one difference: the content is not split into pages, and the CodeDirectory covers every byte before the signature with a single hash.

That leaves out the trailer's 512 bytes, which describe the image's geometry. Apple protects them with a special slot in the CodeDirectory, computed over the trailer with the signature-length field zeroed, because that value cannot be known until the signature exists. Probatio checks that too: alter only the last 512 bytes of a valid DMG and the verdict becomes «invalid signature»; if the slot is missing, the report states that the trailer is not covered.

Then there is the notarisation ticket. When an image has been submitted to Apple and then stapled with stapler, the ticket is attached as a block in the SuperBlob. Probatio finds it and shows it — «notarized (ticket attached)» — on Windows too, and offline. It answers «is this DMG notarised?» for anyone without a Mac.

Two caveats, both worth putting in the report. A missing ticket does not mean the image was never notarised: it may have been, without the ticket being attached, in which case installation needs the network. And a ticket being present says Apple notarised the image at the time, not that Apple considers it valid today.

«Valid signature» does not mean «the system will accept it»

This is the easiest point to get wrong. A cryptographically valid signature over intact content says those bytes are the ones that left the certificate holder's hands. It does not say what Windows or macOS will do with the file: Windows consults its own root certificate programme, macOS consults Gatekeeper and Apple's notarisation servers.

QuestionWhere the answer livesProbatio
Who signed it?certificate inside the signaturename, organisation, issuer, validity, serial, root of the chain
Has it changed since signing?digest recomputed over the bytesyes: Authenticode hash, Mach-O pages, DMG content and trailer
When was it signed?RFC 3161 token or counter-signatureyes, with the TSA's signature and its binding to the primary signature verified
Has the certificate been revoked?OCSP responderon request, online
Will the system accept it?Microsoft or Apple roots, Gatekeeper, Apple serversno: it names the root, but does not issue this verdict

Probatio walks up the chain using the certificates inside the signature and names its root. If the top certificate is self-signed, the finding says the chain reaches the root «X», included in the signature; otherwise, that the chain contained in the signature ends with a certificate issued by «X» and that the root is not included. Next to the name it states, when it recognises it, which family it belongs to — Apple programme, Microsoft programme, known commercial CA — or that it is not among those it recognises. That is what the examiner reasons on, not a verdict: whether the target system will accept the signature depends on its root certificate programme.

Anchoring is a different matter: the chain counts as anchored only if it reaches a CA in the AgID/EU trusted list held in cache. Code-signing chains normally do not get there, and the report reads «not anchored to a root known to Probatio»: that is not a defect of the file. The same distinction between a valid signature and a trusted one is developed in the guide on verifying a digital signature.

What Probatio does not verify

  • The trust verdict of Windows and macOS: Probatio names the root and says whether it recognises it, but it does not carry Microsoft's or Apple's root certificate programmes and does not speak on their behalf.
  • Online notarisation: only the ticket's presence in the file is checked; Apple is not queried, and whether the ticket is still valid today is not verified.
  • The MSI content digest: the computation has not yet been validated against a signed reference installer. If it matches, integrity is confirmed; if it does not, Probatio does not declare tampering and stays at «partial verification», since the cause could lie in the file or in the computation. Signer, chain and timestamp are verified without reservation.
  • .app bundles as a whole: the executable is verified, not the resource seal (_CodeSignature/CodeResources). Of the CodeDirectory's «special» hashes — Info.plist, requirements, entitlements — none is recomputed, except the one covering the DMG trailer.
  • Other containers: .pkg, .cab, .cat, APPX/MSIX and signed scripts (.ps1, .vbs) are not recognised yet.
  • PE, Mach-O or DMG files over 2 GiB: they are read whole into memory, and above that threshold Probatio refuses and says why.

In practice

  1. Record the digest of the examined file. The PDF report of the Code signing module puts it at the top, with the verification record: when the check was run, not when it was printed.
  2. Look at every signature: the nested SHA-256 on an older executable, every architecture on a universal binary.
  3. On a DMG, keep apart signature, notarisation and attached ticket.
  4. Write down what you did not verify, starting with the operating system's verdict: naming the root does not replace it.

A code signature answers two questions with certainty — who, and whether the file has changed — and leaves the third to whoever will run the file. Keeping them apart is half the job of anyone who has to write about it.