3.6 Binary Signature Verification

Binary signing is the foundation of PIP trust determination and of Library Signature Verification. The kernel verifies cryptographic signatures on executable files to establish their trust level, and signing is the only mechanism by which a binary acquires PIP protection — there is no runtime API that can confer it.

This section describes verification. The signature format itself, and what a signer has to produce, are specified in PSPK's Binary Signing and PIP chapter. The kernel only ever verifies; it holds no private key and contains no signing primitive.

3.6.1 The key table #

The kernel carries its verification keys in a dedicated data section of the kernel image, as an array of 1960-byte entries — a raw 1952-byte ML-DSA-65 public key, then a u32 little-endian PIP type, then a u32 little-endian PIP trust — terminated by an all-zero entry.

Before any cryptographic work, the whole table is walked and validated. A table with no all-zero terminator is rejected with EINVAL, and so is a table containing any entry whose tier is not exactly Protected (512) with PeiosTcb trust (8192). Both rejections fail every verification on the system, which is what makes the single-tier constraint absolute rather than conventional: a key at any other tier does not merely fail to be honoured, it disables signing entirely.

The consequence is that a verified binary is always Protected/8192. The Isolated type (1024) is reserved and unreachable, and the multi-key, multi-tier model the table layout anticipates would need a change to the validator, not merely an added key.

A build configured for KUnit compiles in a different, hard-coded key at the same tier, taken from the test vector header. Such a kernel trusts a publicly known key.

3.6.2 Finding a signature #

Verification begins by recording the file's current size. Everything that follows is bounded by that snapshot, and the size is re-read before the attempt returns — a file that changed size mid-verification invalidates the whole result.

The first four bytes decide the path. A file matching \x7fELF takes the ELF path; a file shorter than four bytes, or with different magic, goes straight to the xattr.

On the ELF path the kernel parses the header, locates the section header table and the section-name string table, and scans sections in index order for one named exactly .peios.sig. The match is over all eleven bytes including the terminating NUL, so a longer name with that prefix does not match.

Finding the section commits the ELF path. The moment a section header with that name is found, the xattr is no longer consulted — whatever happens next. A wrong section type, a size other than 3310, a range outside the file, an allocation failure, a short read, a bad version byte, or a hash failure all yield "unsigned" rather than falling back. This is deliberate: without it, an attacker could craft a malformed ELF section to force fallback to whichever path they could more easily control.

Several structural ELF failures commit the path too, before any .peios.sig section has been seen: a file shorter than an ELF header, a class other than ELFCLASS64, a byte order other than little-endian, an unexpected ELF version, a section header entry size other than 64, an absent or out-of-range section-name string table index, and a section header table or string table lying outside the recorded size. A 32-bit or big-endian ELF therefore cannot carry an xattr signature at all — it is committed to the ELF path and then fails on it. The one structural case that does not commit is e_shnum == 0, so an ELF with no section headers falls through to the xattr normally.

The xattr path reads security.peios.sig and requires exactly 3310 bytes; any other size is treated as unsigned. The read bypasses the LSM xattr hooks, so FACS does not mediate the verifier's own read of the signature.

3.6.3 Hashing and verification #

The message signed is a 32-byte SHA-256 content hash, computed differently depending on where the signature was found. For an ELF section source, the hash covers the file with the section's contents replaced by zeros — the Elf64_Shdr entry describing it is hashed verbatim, along with everything else. For an xattr source the hash covers the entire file with no exclusions, and that applies to ELF files reaching the xattr path as well as to non-ELF ones. Hashing proceeds in 4 KB chunks, with the zero run emitted in SHA256_BLOCK_SIZE pieces.

The kernel then verifies the 3309-byte signature against each key in the table, in order, returning on the first success. There is no key identifier in the blob, so key selection is exhaustive trial and the cost is one ML-DSA verification per key. The trust tier is a property of which key verified, never of anything the signer encoded.

Verification uses the kernel crypto signature API with the mldsa65 algorithm. That API exposes no context parameter, so the empty FIPS 204 context is structurally guaranteed rather than checked — a signature made under a non-empty context simply fails to verify.

3.6.3.1 When verification cannot be performed #

"Did not verify" and "could not be verified" are different answers and are kept apart. The per-key verifier is tri-state: verified, did not verify, or a negative errno meaning the check could not be made — an unavailable ML-DSA transform, or a key the transform will not accept.

A negative stops the search rather than trying the remaining keys: the failure is in the machinery, and every remaining key would meet the same one. It then propagates out of the exec path, which refuses the exec with EACCES.

That asymmetry with the ordinary unsigned path is the point. An unsigned binary runs with no integrity label, which is legitimate. Treating an unverifiable one the same way removes PIP from every process the system executes, and the result is indistinguishable from a correctly working system that has no signed binaries — so nothing surfaces it until signing is deployed, at which point it looks like the signing rollout broke something.

On the LSV path the same condition denies the mapping, which already fails closed.

3.6.3.2 The boot-time probe #

A late_initcall allocates the transform once and, if it cannot, emits pr_err and a KACS_SIGNING_CRYPTO_UNAVAILABLE KMES event carrying the errno. The condition is then visible at boot rather than inferred from every process running without an integrity label.

It cannot refuse to start, and two things rule that out rather than one:

  • At LSM init the algorithm is not yet registered, so crypto_alloc_sig returns ENOENT on every boot. A probe there would fire always.
  • A non-zero return from an LSM's init function is only WARN'd (security/lsm_init.c, lsm_init_single). The hooks are never added, so "refuse to initialise" means running with no KACS at all — worse than the failure it would be preventing.

Enforcement therefore lives at exec, where refusing one exec is recoverable in a way losing the integrity boundary is not.

The probe is not IS_ENABLED(CONFIG_CRYPTO_MLDSA). That option is an unconditional select under SECURITY_PKM, which is a bool, so a config test would always pass and catch nothing. Only calling the allocator sees the ordering failure.

3.6.4 PIP determination at exec #

At execve() the kernel looks up and verifies the signature as above. A verified binary takes pip_type and pip_trust from the matched key; no signature, an invalid or unstable one, a bad signature, or no matching key all yield None/0.

Exec proceeds in every case where the question could be answered. PIP is additive protection, not an execution gate: it determines trust level, not permission to run. The one exception is a signature that could not be verified at all, described above, which refuses the exec — because there the question was not answered, so there is no basis on which to assign a trust level. An attacker who replaces a signed binary's signature with garbage costs it PIP protection but can still execute it, subject to FACS. The asymmetry with LSV — which does block unsigned libraries — is intentional. Exec is permissive; mmap(PROT_EXEC) is restrictive.

Determination is transactional with exec success, in three phases. The pending value is cleared at the top of every bprm_creds_from_file invocation, staged into the task's security blob, and committed to the process state only from bprm_committed_creds. An exec that fails between staging and commit leaves the process state untouched, and the stale pending value is cleared by the next exec or at task teardown.

Because the hook fires once per binfmt iteration and each iteration re-stages, a #! script's PIP comes from the interpreter — the last file processed — not from the script. A TCB-signed interpreter runs at TCB level whatever script it executes. Symlinks need no special handling either: the LSM hooks receive the already-resolved target file, so a symlink inherits its target's level by construction.

Once committed, the values are fixed for the lifetime of the process image, inherited at fork, and re-derived at the child's exec.

3.6.5 Content pinning #

A binary that verifies to a nonzero tier has its backing inode pinned as KACS-verified executable content before the exec result is committed, and LSV pins on success too, before allowing the mapping. Pinning closes the gap between verifying one byte image and modifying the same inode afterwards.

If pinning fails at exec, the PIP result is downgraded to None/0 rather than the exec being failed or the tier being kept — the process runs unprotected. Under LSV a pin failure denies the mapping instead.

A pinned inode rejects in-place content mutation even when the size would be preserved: ordinary, positioned and append writes; ftruncate() and pathname truncate(); and every fallocate mode, including allocation-only ones, because the pin check runs before and independently of the mode-support test. File ioctls that mutate content, ranges, or allocation are rejected, and so are ioctls the kernel cannot classify — unknown ioctls fail closed on a pinned inode. Mutation or removal of the security.peios.sig xattr is rejected as well.

The pin is conservative and one-way. It is set once and cleared only when the inode is allocated or freed, never while the inode is live. Updating verified executable content therefore means replacing the inode — write a new file and rename() over it — rather than modifying it in place.

Unsigned, invalid, unstable, bad-signature and no-match attempts never pin.

3.6.6 Library Signature Verification #

With the lsv mitigation enabled, mmap() with PROT_EXEC on a file-backed mapping verifies the backing file. An unsigned file, an invalid or unstable one, a bad signature, or no matching key all deny the mapping with EACCES. On success the library's tier is compared against the loading process's: the image has to dominate the process, so a Protected/PeiosTcb process can load only PeiosTcb-or-above libraries. With one key in the table this reduces to "is it signed with the TCB key?"

The hash covers the entire file, not the mapped region — the whole file is read and hashed even when only part of it is being mapped, so the signature covers code in sections this particular mapping does not touch.

mprotect() adding PROT_EXEC to a mapping that was not already executable runs the same checks in a fixed order: WXP, then TLP, then LSV. Anonymous mappings have neither a path nor a signature, so TLP and LSV skip them and only WXP applies.

Enabling lsv on a running process also re-validates its existing executable mappings before the bit is committed (§3.3.2), so the mitigation cannot be turned on over already-mapped unsigned code.

3.6.7 Revocation #

There is none. A signed binary later found to be malicious cannot be invalidated short of removing it from the filesystem or replacing the kernel image with a different key. No hash blocklist, no per-key revocation, and no revocation state of any kind exists.

3.6.8 Interaction with other mechanisms #

WXP is orthogonal: it prevents pages being writable and executable at once, while LSV prevents unsigned executable pages. A process with both can execute only signed code in read-only pages.

FACS runs independently. A signed binary in a directory the caller cannot reach is still unreachable — the open is denied before signing is consulted. Signing determines the PIP level of a binary that is already being executed; it never grants access to one.

PIP object protection consumes the result: once a process carries a tier from its binary's signature, trust labels on objects are enforced through the AccessCheck pipeline against the pip_type and pip_trust held in the PSB (§3.7).

Edit this page