Understanding NvPCRs in systemd v262

Lobsters Hottest Tools

Summary

This hands-on deep dive explains NvPCRs in systemd v262, which are additional PCR-like registers in TPM's NV memory to address PCRs scarcity for security features like disk encryption and remote attestation, with a focus on the reworked secure design.

<p><a href="https://lobste.rs/s/ya3cl7/understanding_nvpcrs_systemd_v262">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 09/25/26, 09:11 AM

# Understanding NvPCRs in systemd v262 Source: [https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/) *systemd answers TPM PCR scarcity with additional PCR\-like registers allocated in the TPM’s NV memory, with an anchoring design that was reworked in v262\. In this hands\-on deep dive, we rebuild a systemd NvPCR from scratch against a software TPM, picking up the required TPM concepts along the way, and analyze why the design is secure\.* ## Why can’t systemd get enough of those PCRs? Many of systemd’s security features rely on TPM PCR measurements: Passwordless full disk encryption can unlock disks automatically if the PCR measurements are as expected, preventing credential theft while still allowing unattended reboots of remote machines with encrypted root disk\. Service credentials can be encrypted against the expected PCR state\. Boot\-phase bound credentials are also supported, allowing secrets that can only ever be decrypted in the initrd\. And with remote attestation, a machine can prove to another party what it booted and what happened since, by having the TPM sign its current PCR state \(a so\-called quote\)\. All of this is built on PCR measurements\. TPM PCRs are scarce\. On common standard\-compliant TPMs there are only 24 PCRs available\. The lower PCR indices 0\-7 are owned by the firmware and used for UEFI boot measurements\. 16 is a debug PCR that can be reset and is therefore unusable, 17\-22 are reserved for Dynamic Root of Trust for Measurements[1](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:1), and 23 is reserved for application support\. So only 8\-15 are left for systemd to do all OS\-related measurements[2](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:2)\. From a remote attestation perspective, a single measurement register can be enough to verify a system\. We can use the measurement log to replay the events and interpret what was measured to land on the final value we observe\. But PCRs are not only read by remote verifiers, they are also what local secrets are locked against, and that use needs predictable values: every event flowing into such a PCR must be known in advance, otherwise the policy breaks\. Some measurements are inherently unpredictable, depending for example on the closed source vendor firmware of the individual platform, or on the behavior of users\. We might want to include a login event in our remote attestation quote, but the root disk should still be able to unlock itself after someone logged in\. So the noisy and unpredictable event types need registers of their own: out of the way of the PCRs that locks depend on, but still measured and attestable\. And that’s where systemd was quickly hitting a hard limit: with only eight PCR slots available to the OS, there isn’t much space to give event types their own register\. This is why systemd[introduced NvPCRs in v259](https://github.com/systemd/systemd/pull/39463): additional PCR\-like registers, allocated in the TPM’s non\-volatile memory, hence the name\. They host the event types we don’t want in the real PCRs, and their values are consumed through remote attestation\. In v262, the[anchoring of NvPCRs was reworked](https://github.com/systemd/systemd/pull/42796)to increase their security\. Previously, the anchoring was based on a random secret sealed against PCR 11 and stored on disk, which an attacker could recover by booting a different OS that replays the expected PCR 11 values, or simply replace with a secret they know\. This blog post describes the reworked design that ships in v262: how it works, and why it is secure\. ## Setup for follow\-along We’ll check out the basic working principles and get a feeling for the matter by constructing our own NvPCRs against a software TPM on the command line\. If you’d rather just read, that’s fine, too, I’ll provide all important output\. As a prerequisite, install the required tools, for example with nix or dnf: ``` nix shell nixpkgs#{tpm2-tools,xxd,swtpm,openssl} ``` ``` dnf install tpm2-tools vim-common swtpm openssl ``` Create a directory you want to work in and start the software TPM: ``` mkdir state swtpm socket \ --tpm2 \ --tpmstate "dir=$PWD/state" \ --ctrl "type=tcp,port=2322" \ --server "type=tcp,port=2321" \ --flags startup-clear \ --pid "file=$PWD/swtpm.pid" \ -d ``` Export the connection details so tpm2\-tools knows where to find the TPM: ``` export TPM2TOOLS_TCTI="swtpm:host=127.0.0.1,port=2321" ``` Check the TPM is working by reading the PCRs: This should show the PCRs 0\-16 being all zero\. If not, you might be talking to the TPM of your platform and not the software TPM, check again that you exported`TPM2TOOLS\_TCTI`correctly\. This is important, as we don’t want the following experiments to mess with the sealed secrets of your platform\. ## TPM NV index as PCR replacement A TPM NV index[3](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:3)is a non\-volatile storage slot identified by a unique name\. NV indices persist across reboots and can hold user defined data: an opaque value, a counter, bitfield or similar\. The properties of an NV index define how it behaves and what it can be used for: its handle, the size of the stored data, a set of attributes controlling how the index can be manipulated or read, and an authorization policy and authorization value \(the latter being the only non\-public property\) that optionally specify under which conditions the index can be manipulated\. Each index has a*nameAlg*, the hash algorithm used to compute the unique name from the public properties of the index[4](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:4)as `Name = nameAlg \|\| H\_nameAlg\(marshal\(TPMS\_NV\_PUBLIC\)\)`\. Let’s create a PCR\-like NV index\! We are using tpm2\_nvdefine from tpm2\-tools for this\.`0x01000000`is the handle for the index that we are defining \(somewhat randomly picked\)\. The`\-\-hierarchy=o`flag selects the authorization we define the index under: NV indexes like ours live in the TPM’s owner hierarchy, and defining or undefining them requires the owner authorization value[5](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:5)\. On a typical Linux system that value is empty, so effectively anyone with access to the TPM device, usually root, holds owner authorization\. The`hash\-algorithm`flag corresponds to the previously named nameAlg and is chosen as sha256\. Then we select the attributes for our NV index:`authread\|authwrite`, in combination with an empty authorization value, allows anyone with access to the device to read and write the index\. And`nt=extend`says we want this to be extendable like a PCR\. ``` tpm2_nvdefine 0x01000000 \ --hierarchy=o \ --hash-algorithm=sha256 \ --attributes="nt=extend|authread|authwrite" ``` Take a look at the result using the following command: ``` 0x1000000: name: 000be9606b61ec27bc8deec096dd38a6f8961cb8b3ef2fe879b27de704ab2f3d44e3 hash algorithm: friendly: sha256 value: 0xB attributes: friendly: authwrite|nt=0x1|authread value: 0x40044 size: 32 ``` We can see the properties we configured[6](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:6), the size, and the name that is a hash over the public properties\. As you defined the exact same properties as I did, you will get exactly the same hash as index name\. Now we can use our NV index like a proper PCR and extend it with a measurement, for example with a login event of user Alice: ``` printf 'user-alice-logged-in' > m1.bin tpm2_nvextend 0x01000000 --input=m1.bin ``` Then read its value: ``` tpm2_nvread 0x01000000 --size=32 | xxd -p -c 64 ``` ``` bd9927a653c6c33297b7d884a8ad99df0f5b5b1c1e1e86f762b3aced8bc77f50 ``` If we now inspect the NV index again, we can observe something interesting: The index got a new attribute`written`, indicating that the index has been written one or more times\. As the set of attributes was updated, and the name of the index is a hash including the attributes, it got a new name too\! We will make use of this later\. ``` 0x1000000: name: 000b1191942a636c11a57571f0d435c290a1955788229305ce0aa8a2f393e9ed770c hash algorithm: friendly: sha256 value: 0xB attributes: friendly: authwrite|nt=0x1|authread|written value: 0x20040044 size: 32 ``` You can do another measurement if you like, measuring the login event of Bob: ``` printf 'user-bob-logged-in' > m2.bin tpm2_nvextend 0x01000000 --input=m2.bin tpm2_nvread 0x01000000 --size=32 | xxd -p -c 64 ``` ``` e758d6e2e54620fd45b9cd1fd57cbba62757f12751d549fd2fc957ed1908ed29 ``` At this point, the value of the NV index is`HASH\(HASH\(0x0 \|\| m1\) \|\| m2\)`[7](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:7)\. The index name didn’t change again with the second measurement\. So we have an NV index that is extendable in the same way a PCR is\. Can we already use it as PCR replacement? We can’t\. We are missing a fundamental property of the real PCRs: To use the measurement chain as proof for anything, it must not be resettable or replayable during the runtime of the system\! Otherwise an attacker that gained access to the system could just reset the measurement history and replay the history of an unmanipulated system, making the attack undiscoverable through remote attestation\. Sadly, this isn’t a property the TPM grants us for our PCR\-like NV index: We defined the index at system runtime, and we can undefine it again: ``` tpm2_nvundefine 0x01000000 --hierarchy=o tpm2_nvreadpublic ``` Given the two example measurements we did in this section, if Alice has malicious intentions and gains root access, and with it owner authorization, they can just undefine and redefine the index, then replay a history where Alice never logged in\. The redefined index gets the exact same name, and we couldn’t notice\. What we need is an index that anyone can extend, but that nobody can restart during the runtime of the system\. Policies are the TPM’s tool to express such conditions\. ## Exploring policies A very powerful concept the TPM interface provides is the policy[8](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:8)\. Such a policy is an opaque digest stored with the object it protects, in the`authPolicy`property we already saw in the public properties of our NV index\. To fulfill a policy, we request a policy session from the TPM\. Each session has its own context, containing a digest called`policyDigest`and a set of constraints that can be modified by executing policy assertions\. A fresh session has a`policyDigest`that is all zero\. Within a session, we can then run different policy commands against the TPM, each asserting some condition\. Some assertions are checked immediately while the command executes\. Others are deferred: the command records a constraint in the session context, and the TPM only checks it once the session is used for authorization\. Either way, each command extends the session’s digest, using the following logic: `policyDigest := H\(policyDigest\_old \|\| commandCode \|\| command\-specific args\)` That extend scheme works exactly like a PCR, even though the`policyDigest`is not backed by a real PCR\. A caller can present different types of evidence, each extending the`policyDigest`\. If the assertions add up so that the session’s`policyDigest`matches the`authPolicy`of the index, the session is authorized and the desired command can be called with it\. The author of a policy computes the expected`authPolicy`digest via a trial session in a trusted environment or through pre\-calculation offline\. A trial session runs the same digest calculation, but doesn’t verify any of the conditions, and in exchange can’t be used to authorize anything\. This is what allows an author to compute a policy for a state the machine currently isn’t in\. ### `PolicyPCR` The cool thing is that policies can make permission depend on the machine state by locking against the expected value of a PCR\. Let’s create such a policy\! First, we start a trial session to define the policy we want to set on our NV index\. This is done by invoking`tpm2\_startauthsession`without the`\-\-policy\-session`flag\. ``` tpm2_startauthsession --session=trial.ctx ``` We then call`tpm2\_policypcr`to create an assertion over the currently observed PCR value\(s\) we select via`\-\-pcr\-list=`\. For our experiment, we use PCR 15, one of the OS\-owned PCRs\. The application PCR 23 might look like the natural playground, but like the debug PCR 16 it can be reset at runtime, which is exactly the property we are trying to get rid of: ``` tpm2_policypcr --session=trial.ctx \ --pcr-list=sha256:15 \ --policy=pcr15.policy ``` ``` 7e247a603cd1052cabc095741b8ee2f7458aabeee960b8ec97d7f090171a039a ``` The resulting policy digest is printed and written to`pcr15\.policy`[9](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:9)\. Then end the session: ``` tpm2_flushcontext trial.ctx ``` Let’s recreate the NV index from before, this time we protect write access to it with the policy we just created: ``` tpm2_nvdefine 0x01000000 \ --hierarchy=o \ --hash-algorithm=sha256 \ --attributes="nt=extend|authread|policywrite" \ --policy=pcr15.policy ``` Notice we added both the`\-\-policy=`flag and the`policyWrite`attribute\. We leave the`authRead`untouched\. There is a`policyRead`too, but usually restricting who can extend the index is much more interesting\. ``` 0x1000000: name: 000b14a6915e5830ff2b83ebc87cdf5ac481fb29637c246489bdab1bba19948bb729 hash algorithm: friendly: sha256 value: 0xB attributes: friendly: policywrite|nt=0x1|authread value: 0x40048 size: 32 authorization policy: 7E247A603CD1052CABC095741B8EE2F7458AABEEE960B8EC97D7F090171A039A ``` Just trying to extend as before will now fail with an authorization error: ``` tpm2_nvextend 0x01000000 --input=m1.bin ``` ``` ERROR: Esys_NV_Extend(0x12F) - tpm:error(2.0): authValue or authPolicy is not available for selected entity ``` Instead, to write to the index, we need to start a policy session and satisfy the policy by presenting the current PCR 15 state[10](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:10)\. After that, we can do the extension, presenting the session as authorization\. And remember to flush the session context at the end\. ``` tpm2_startauthsession --session=s.ctx --policy-session tpm2_policypcr --session=s.ctx --pcr-list=sha256:15 tpm2_nvextend 0x01000000 \ --hierarchy=0x01000000 \ --auth=session:s.ctx \ --input=m1.bin tpm2_flushcontext s.ctx ``` If PCR 15 advances, the policy can’t be fulfilled anymore\. Run the following command to extend the PCR: ``` echo something | tpm2_pcrevent 15 ``` Now retry the three steps from before that unlocked the session based on the PCR\. The extend will fail with`tpm:session\(1\):a policy check failed`, as the PCR advanced and neither its current value \(nor any of its future values\!\) matches the one included in the policy anymore\. The access to the PCR\-bound index expired and can’t be regained during the runtime of the machine\. Finally, undefine the index again with: ``` tpm2_nvundefine 0x01000000 --hierarchy=o ``` Locking against a PCR with`PolicyPCR`is super cool, but it is also brittle[11](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:11): At the end of the previous section, a measurement changed, and our access expired with no way to regain it\. That is a problem, because PCR values change for legitimate reasons all the time\. Think of the passwordless disk unlock from the intro: the disk key is sealed against the PCR state of the boot chain, and the next kernel update changes exactly that state\. The disk wouldn’t unlock anymore, even though nothing bad happened, and we certainly don’t want to re\-encrypt the disk on every update\. What would be nice to have instead is a policy that stays stable while the approved state can change\. Luckily, there is another mechanism that can be used to create a policy:`PolicyAuthorize`\. It introduces a level of indirection and delegation, allowing us to create a policy from a public key instead of a system state\. To authorize, you satisfy another, concrete policy \(like a`PolicyPCR`\), then present a signature over the expected policy digest and a`policyRef`\. The concrete policy digest itself is not part of the policy\. Whoever owns the key can approve new concrete policies, offline\. The`policyRef`scopes the signatures so the signed policy can’t be used out of context\. Let’s create a RSA key pair to explore`PolicyAuthorize`: ``` openssl genrsa -out sign.key.pem 2048 openssl rsa -in sign.key.pem -pubout -out sign.pub.pem ``` Load the public key into the TPM so we can use it as part of a policy: ``` tpm2_loadexternal \ --hierarchy=o \ --key-algorithm=rsa \ --public=sign.pub.pem \ --key-context=signkey.ctx \ --name=signkey.name ``` ``` name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661 ``` The printed name is what will bind our policy to this key\. And it is computed just like the NV index names we saw earlier: as a digest over the object’s public properties, which for a key includes the public key itself\.`tpm2\_readpublic`shows these public properties: ``` tpm2_readpublic --object-context=signkey.ctx ``` ``` name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661 qualified name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661 name-alg: value: sha256 raw: 0xb attributes: value: userwithauth|decrypt|sign raw: 0x60040 type: value: rsa raw: 0x1 exponent: 65537 bits: 2048 ... rsa: ... ``` Besides the name algorithm, the object attributes and the key parameters, the public area contains the raw RSA modulus \(shortened here\)\. Any change to the public key changes the name, and with it any policy created from that name\. Now use another trial session and the key name to create a policy that can be authorized using this key: ``` printf 'demo' > policyref.bin tpm2_startauthsession --session=trial.ctx tpm2_policyauthorize --session=trial.ctx \ --name=signkey.name \ --qualification=policyref.bin \ --policy=authorized.policy tpm2_flushcontext trial.ctx tpm2_flushcontext --transient-object ``` ``` 6120c875c3afb0b2811fc7c3c045c32fb53596e8009e96855ed4aa6c332d0bfb ``` The resulting policy digest contains no PCR value at all, it only depends on the key name \(and through it on the public key\) and the`policyRef`label\. As you generated a different key pair, the policy hash you will get will differ, too\. Notice the second flush invocation with`\-\-transient\-object`: it removes the key object that`tpm2\_loadexternal`left behind in the TPM’s limited transient memory\. tpm2\-tools doesn’t flush what it loads, so we will repeat this cleanup after commands that load keys\. The printed policy hash is written to`authorized\.policy`, which we can then use to define our NV index again, similar to how we did it before: ``` tpm2_nvdefine 0x01000000 \ --hierarchy=o \ --hash-algorithm=sha256 \ --attributes="nt=extend|authread|policywrite" \ --policy=authorized.policy ``` For now, nobody can write this index, because no signature satisfying the policy exists yet\. In practice, this flow will usually have two parties: the key holder is for example a team of distro maintainers\. The individual machine is the other party that presents evidence to its TPM\. The key holder computes a concrete policy digest to approve, for example a`PolicyPCR`for a specific build, then creates a signature over that digest and the`policyRef`\. Create a new PCR policy for the updated value of PCR 15, that’s what we want to bless now\. Like in the previous section we run a trial session to get a policy digest for the observed state: ``` tpm2_startauthsession --session=trial.ctx tpm2_policypcr --session=trial.ctx \ --pcr-list=sha256:15 \ --policy=approved.policy tpm2_flushcontext trial.ctx ``` Concatenate the policy and the policyRef: ``` cat approved.policy policyref.bin > tbs.bin ``` Then sign the whole thing with the private key: ``` openssl dgst -sha256 -sign sign.key.pem -out approved.sig tbs.bin ``` The policy and its signature can then be shipped to a machine, for example as part of an image or update\. This is the artifact showing the key holder approved this concrete policy\. On the machine, we let the TPM verify the signature first: ``` tpm2_verifysignature --key-context=signkey.ctx \ --hash-algorithm=sha256 \ --message=tbs.bin \ --scheme=rsassa \ --signature=approved.sig \ --ticket=verify.tkt ``` On successful verification, the TPM will return a ticket, which is an HMAC\-stamped proof[12](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:12)\. We can then start a new policy session, satisfy the concrete policy \(the one that we created the signature over\), then present the ticket, the`policyRef`and the policy to authenticate the session\. ``` tpm2_startauthsession --session=s.ctx --policy-session tpm2_policypcr --session=s.ctx --pcr-list=sha256:15 tpm2_policyauthorize --session=s.ctx \ --input=approved.policy \ --qualification=policyref.bin \ --name=signkey.name \ --ticket=verify.tkt ``` On the`tpm2\_policyauthorize`call, the TPM ensures your session digest equals the signed`approved\.policy`, checks the ticket is valid for the given`policyRef`and key, then swaps the current session digest to the authorize policy \(`authorized\.policy`\)\. Afterwards, the session digest matches the authorize policy we set during index definition, and the session is unlocked: ``` tpm2_nvextend 0x01000000 \ --hierarchy=0x01000000 \ --auth=session:s.ctx \ --input=m1.bin tpm2_flushcontext s.ctx tpm2_flushcontext --transient-object ``` With this construct, in case the PCR 15 measurement is changed by a future update, the index is not bricked, it doesn’t even need to be changed\. The trusted key holder will just sign a new policy matching the new PCR 15 state and ship that new policy as part of the update\. On the machine, the NV index with the same policy keeps working\. This is exactly how systemd keeps TPM\-based disk unlock working across kernel updates: every UKI ships fresh signatures matching its own expected PCR 11 state\. But also keep the flip side of this mechanism in mind: if the key holder only ever signs a single state, the policy is only satisfiable while the machine is in exactly that state\. We will exploit this in a moment\. ### `PolicyOR` A policy digest commits to one exact chain of assertions\. All the elements are sequenced via AND, we must match every element in order to get the expected session hash\. Sometimes we might want to construct an alternative branch instead, for example if we know two good states, both of which we want to allow, or two different ways to authorize the same operation\. For this`PolicyOR`exists[13](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:13)\. The TPM will check if the current session’s digest is a member of the allowed list, then replace it in a similar way it does when the`PolicyAuthorize`ticket is resolved\. The policy is satisfied if one of its branches is satisfied\. ## Building a secure, policy\-based NvPCR With the previously introduced primitives, we can now take a look at how systemd constructs a secure NvPCR\. The NV index is protected by a write policy with two branches that are connected with a`PolicyOR`: A`PolicyAuthorize`branch with a public key and the`policyRef``initrd`, and a`PolicyNvWritten\(true\)`branch\. Let’s take a look at the`PolicyNvWritten\(true\)`branch first\. This assertion allows checking for the`written`attribute as part of a policy[14](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:14)\. So`PolicyNvWritten\(true\)`can be satisfied without further authorization if the NvPCR has already been extended before\. This is the branch that is used after the initial setup, during runtime\. At that point, extending the NvPCR doesn’t require additional authentication and can be done by any component with access to the device\. The other branch,`PolicyAuthorize`, can be satisfied with a signed policy\. This branch must be used for the initial extend of the NvPCR\. The concrete policy used by systemd is a PCR policy for PCR 11, matching the expected state of that PCR when running in the initrd during early boot\. systemd tracks the boot phase in PCR 11: When the initrd is started,`systemd\-pcrphase\-initrd\.service`measures the event`enter\-initrd`\. We construct the PCR policy against the expected state of PCR 11 after this event\. If the system hasn’t been tampered with up to that point and the signature is valid, the signed PCR policy is satisfied and the NvPCR can be initially extended\. When progressing to the next boot phase, the same service measures the phase event`leave\-initrd`\. This locks the`PolicyAuthorize`branch for the rest of the system lifetime\. The NvPCR can then only be extended via the`PolicyNvWritten`branch\. You might wonder why the write policy takes the indirection via`PolicyAuthorize`instead of embedding the PCR policy directly\. On a real system, PCR 11 doesn’t only contain the phase events: the UKI stub measures the kernel and initrd into it first, so the initrd state of PCR 11 changes with every update\. Embedded directly, every update would change the write policy and with it the name of the index, and the NvPCR would have to be recreated on every update\. With`PolicyAuthorize`, the write policy and the name stay stable, and only the signature shipped with the UKI changes\. Let’s construct this final NvPCR version, similar to how systemd does it\. Measure the event that marks the start of the initrd boot phase, as done by`systemd\-pcrphase\-initrd\.service`: ``` echo -n "enter-initrd" | tpm2_pcrevent 11 tpm2_pcrread sha256:11 ``` ``` sha256: 11: 0xD15B0E8E244E65C40F024E95773F2347CE4EF3FFE6B597C9A14B50BBAB6DF319 ``` Let’s author the write policy\. The key from the previous section is reused\. It takes the role of the UKI’s PCR signing key, whose public half is shipped in the`\.pcrpkey`section of the UKI\. First write the`policyRef`with the value`initrd`[15](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:15)\. Then use a trial session to create the key\-based branch of the policy that must be used for the first write from within the initrd: ``` printf 'initrd' > initrd.ref tpm2_startauthsession --session=trial.ctx tpm2_policyauthorize --session=trial.ctx \ --name=signkey.name \ --qualification=initrd.ref \ --policy=init.branch tpm2_flushcontext trial.ctx ``` Next, create the second branch of the policy, the`PolicyNvWritten\(true\)`: ``` tpm2_startauthsession --session=trial.ctx tpm2_policynvwritten --session=trial.ctx --policy=written.branch s tpm2_flushcontext trial.ctx ``` And finally combine the two policies with a`PolicyOR`: ``` tpm2_startauthsession --session=trial.ctx tpm2_policyor --session=trial.ctx \ --policy-list=sha256:init.branch,written.branch \ --policy=write.policy tpm2_flushcontext trial.ctx ``` We use that policy to define the NvPCR, nearly identical to how we did before: ``` tpm2_nvdefine 0x01D10200 \ --hierarchy=o \ --hash-algorithm=sha256 \ --attributes="nt=extend|policywrite|ownerread|authread|clear_stclear" \ --policy=write.policy tpm2_nvreadpublic 0x01D10200 ``` ``` 0x1d10200: name: 000bf2e615c91fc1738ee23d6906f3cc5da932ed3d3dd19f99de0d0e0839712d8ecf ... attributes: friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear value: 0x8060048 size: 32 authorization policy: A765636C5A04447BD790486F98DAF82CA694868DF19C1AF80632A52261E374EF ``` As the write policy depends on your generated key, your authorization policy and the index name will again differ from mine\. The only thing new here is the`clear\_stclear`attribute: It tells the TPM to clear the NV index on reboot[16](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:16)\. Similar to PCRs, the NvPCR should reset on reboot, not persist measurements of a previous boot to the next\. The`written`attribute is also cleared on reset\. `0x01D10200`is the real handle from the NV index range systemd uses\. Which NvPCRs exist on a system is defined by small JSON files in`/usr/lib/nvpcr/\*\.nvpcr`, which since v262 must be shipped as part of the UKI\. systemd currently ships four definitions:`hardware`for the product UUID,`cryptsetup`for the LUKS unlock mechanism used,`verity`for the root hashes of activated verity volumes, and`login`for user logins[17](https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/#fn:17)\. All four record events that are unpredictable or unbounded in number, exactly the kind we don’t want in the real PCRs\. Then we craft the concrete PCR policy for the expected initrd state of PCR 11 and sign it together with the`policyRef`: ``` tpm2_startauthsession --session=trial.ctx tpm2_policypcr --session=trial.ctx --pcr-list=sha256:11 --policy=initrd.policy tpm2_flushcontext trial.ctx cat initrd.policy initrd.ref > tbs.bin openssl dgst -sha256 -sign sign.key.pem -out initrd.sig tbs.bin ``` In systemd, this is done at image build time with ukify\. The tool gained a new flag`\-\-sign\-initrd\-pcrs`that precomputes the expected PCR 11 value for the`enter\-initrd`phase and embeds the signed policy in the UKI into a`\.pcrsig`section\. On each boot, systemd’s`systemd\-tpm2\-setup\-early\.service`uses the signature and the authorize policy path to initialize the NvPCR in the initrd\. First, we let the TPM verify the signature: ``` tpm2_verifysignature --key-context=signkey.ctx \ --hash-algorithm=sha256 --message=tbs.bin --scheme=rsassa \ --signature=initrd.sig --ticket=initrd.tkt ``` Then we start a policy session\. We satisfy the PCR policy with the current state of PCR 11, then call`tpm2\_policyauthorize`to present the signature over the`initrd\.policy`\. Finally, we call`tpm2\_policyor`to present both branches and match the expected auth digest of our NvPCR: ``` tpm2_startauthsession --session=s.ctx --policy-session tpm2_policypcr --session=s.ctx --pcr-list=sha256:11 tpm2_policyauthorize --session=s.ctx --input=initrd.policy \ --qualification=initrd.ref --name=signkey.name --ticket=initrd.tkt tpm2_policyor --session=s.ctx --policy-list=sha256:init.branch,written.branch ``` With this session state, the policy is satisfied and we extend the NvPCR\. systemd initializes NvPCRs with all\-zeros\. The written value doesn’t matter, the point is that the index gains the`written`attribute, and that this first extend happened under the signed policy\. ``` head -c 32 /dev/zero > zero.bin tpm2_nvextend 0x01D10200 --hierarchy=0x01D10200 --auth=session:s.ctx --input=zero.bin tpm2_flushcontext s.ctx tpm2_flushcontext --transient-object tpm2_nvread 0x01D10200 --size=32 | xxd -p -c 64 ``` ``` f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b ``` Because the extended value is all\-zeros, every freshly initialized SHA\-256 NvPCR starts out at this exact value,`SHA256\(zeros32 \|\| zeros32\)`, on every machine\. With the first write done, the index gained the`written`attribute and thereby a new name: ``` tpm2_nvreadpublic 0x01D10200 ``` ``` 0x1d10200: name: 000bf08c214c037f85d3520549dba23471a88af8aab1913bf269edfbb44b6d2de9f5 ... attributes: friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear|written value: 0x28060048 ``` Keep this name in mind, we will get back to it in the next section\. At the end of the initrd boot phase, systemd will measure the event marking the end of that phase: ``` echo -n "leave-initrd" | tpm2_pcrevent 11 ``` With this, the signed policy cannot be satisfied anymore until the next reboot\! Later at runtime, the write is authorized solely on the`written`property the NvPCR gained during its initialization in initrd\. This is what`systemd\-pcrextend`does when it records an event, and it requires no key and no secret, any component with access to the TPM can append\. That is deliberate: extends of real PCRs are unauthenticated, too, because adding history is harmless, only rewriting it must be impossible\. ``` tpm2_startauthsession --session=s.ctx --policy-session tpm2_policynvwritten --session=s.ctx s tpm2_policyor --session=s.ctx --policy-list=sha256:init.branch,written.branch tpm2_nvextend 0x01D10200 --hierarchy=0x01D10200 --auth=session:s.ctx --input=m1.bin tpm2_flushcontext s.ctx tpm2_nvread 0x01D10200 --size=32 | xxd -p -c 64 ``` ``` 5bbe1770fd46c5edfde5ef444b2d681d87925fcd71b1fe98c4f65749a6f3afb6 ``` Notice that we still need to present both branches to the session, including the`init\.branch`digest\. For that reason systemd persists it to`/run/systemd/nvpcr/<name\>\.auth`after initialization\. Like for regular PCRs, systemd records each NvPCR measurement in its userspace measurement log \(`/run/log/systemd/tpm2\-measure\.log`\), so that a verifier can later replay the events and interpret the NvPCR value\. This concludes the hands\-on part\. When you are done experimenting, shut the software TPM down: ## Security considerations of the NvPCR design Let’s take a look at the security this construction is providing us with\. The attacker we are looking at gains access during runtime of the system, after the`leave\-initrd`event is measured, and wants to rewrite the NvPCR history unnoticed, like Alice hiding her login in that naive index attack from the beginning\. They have root privilege and can access the TPM to read, write, undefine and redefine NV indexes, extend \(but not reset\) PCRs\. They can also reboot or boot a different OS\. Attacks against the TPM hardware itself are out of scope\. The TPM itself is trusted, and so is the measured boot chain up to and including the initrd, including firmware, bootloader and UKI\. The initrd is authorized to initialize NvPCRs by design, which is expressed by the signed policy over the initrd state of PCR 11\. But PCR 11 is under OS control, a different kernel could extend the expected values and reach the authorized state, too\. It takes the firmware\-controlled PCRs, which record the bootloader and UKI that actually ran, to tie the initrd state of PCR 11 to the initrd we trust\. Booting anything else is visible to the verifier\. We also have to trust the key holder, as whoever controls the PCR signing key can bless arbitrary states, and the verifier, which has to check more than just the NvPCR values, as we will see below\. The write policy and the public key are no secrets, so the attacker can undefine our NvPCR and redefine it, byte\-for\-byte identical\. To replay a history, the attacker then has to perform the first write to the fresh index, and both branches of the write policy refuse: The`PolicyNvWritten\(true\)`branch can’t authorize the write\. Nothing stops the attacker from running the policy commands, and the session digest will even match the write policy, but the deferred written\-check fails against the never\-written index, and the TPM rejects the extend\. And the`PolicyAuthorize`branch fails, too: the only signature in existence approves the initrd state of PCR 11, which the machine left when`leave\-initrd`was measured\. The session can’t reach the signed digest anymore, so the TPM rejects and the attacker isn’t able to create a forged history\. There is one more signature the attacker might try: the same key also signs the UKI’s other PCR policies, for example the ones used for disk unlock, and some of those are satisfiable in the runtime state of the machine\. This is where the`policyRef`comes in: our authorize branch only accepts signatures made for the ref`initrd`, and the other policies are signed with a different ref \(or none at all\)\. The signatures are not interchangeable\. Of course, the attacker doesn’t have to reuse our write policy\. They can redefine the index with`authwrite`, like our naive index from the beginning, and replay whatever history they like\. But remember that the name of an NV index is a hash over all of its public properties: the attributes and the write policy, which in turn commits to the vendor’s public key\. There is no way to create an index that is writable outside the initrd without ending up with a different name\. This makes the name the anchor of the whole design, and the last missing piece is making it verifiable\. After initializing an NvPCR, systemd extends the event`nvpcr\-init:<name\>:0x<handle\>:<tpm\-name\>`into PCR 9, a real, non\-resettable PCR\. It is the cheapest PCR to sacrifice: the kernel measures into it every initrd it is handed, which on a UKI boot is a concatenation of the UKI’s embedded initrd with cpio archives that systemd\-stub generates on the fly, for example for credentials, all mangled into a single value, on some setups joined by verbose bootloader records\. This makes PCR 9 hard to predict, so no unlock policy can bind to it anyway, while the measurement that matters, the UKI initrd, is already cleanly covered by PCR 11\. Once all NvPCRs are initialized,`systemd\-pcrnvdone\.service`measures a separator event`nvpcr\-separator`into PCR 9, still inside the initrd\. The NvPCR values themselves are attested with`TPM2\_NV\_Certify`, which has the TPM sign the current index contents together with the index name\. The new[systemd\-report\-sign\-tpm2](https://github.com/systemd/systemd/pull/42957)signer emits these attestations alongside regular PCR quotes\. A verifier doing remote attestation must only trust NvPCR values whose attested index name matches an`nvpcr\-init`event that appears before the separator in the PCR 9 event log\. Any recreated index fails this check: wrong name, or right name but logged after the separator\. Two capabilities of our attacker are left: rebooting and booting a different OS\. A reboot resets the NvPCRs just like the real PCRs, and the next boot re\-initializes them in the initrd\. The attacker gains nothing: the new boot is a genuine one, its history starts fresh by design, and the verifier can see that a reboot happened\. Booting a different OS doesn’t help either, as it produces different measurements in PCR 11 and the other boot chain PCRs\. The signed policy can’t be satisfied, and any quote produced from such a boot exposes the manipulated boot chain\. ## Conclusion An NvPCR in systemd v262 is an NV extend index whose first write is gated by a signed PCR policy that can only be satisfied in the initrd, while all later writes are free\. The index name, measured into PCR 9 during early boot, anchors the construction for verifiers: any index recreated with a weaker write policy carries a different name and is detected\. We reconstructed such an NvPCR on the command line and discussed why an attacker can’t forge measurements: once the initrd window has closed, no road into the write policy of a fresh index remains, and everything the attacker can still do is destructive and shows up in attestation\. A practical note to close with: The write policy is based on the UKI’s PCR signing key\. When that key rotates, systemd\-tpm2\-setup detects the name mismatch, checks that the existing index looks like an NvPCR, and recreates it with the new policy\. The same path automatically upgrades NvPCRs created by the pre\-v262 design, so the roll\-out to your systems will happen automatically with the systemd update\. The initrd\-bound signed policy turns out to be useful beyond NvPCRs, too\. With`systemd\-cryptenroll \-\-tpm2\-public\-key\-policyref=initrd`, you can enroll LUKS keyslots that can only be unlocked from the initrd\. And if you want to see all of this on a real system, boot a v262 image with a UKI built with`\-\-sign\-initrd\-pcrs`and take a look at`systemd\-analyze nvpcrs`and the PCR 9 event log\. --- ## Thanks *To my colleague at Amutable[Chris Coulson](https://mstdn.social/@chrisccoulson), who authored the new NvPCR design and gave insightful corrections and additions to this blog post\. Linux security work in the upstream projects we all rely on is at the heart of our mission at Amutable:[building new secure foundations](https://amutable.com/blog/building-new-secure-foundations)\.* ## References - [Add support for nvindex\-based additional PCRs for TPM2, aka “NvPCRs”](https://github.com/systemd/systemd/pull/39463)PR on systemd - [tpm2: Improve how NvPCR protection works](https://github.com/systemd/systemd/pull/42796)PR on systemd - [TPM2 PCR Measurements Made by systemd](https://systemd.io/TPM2_PCR_MEASUREMENTS/)documentation - [TPM 2\.0 Library](https://trustedcomputinggroup.org/resource/tpm-library-specification/)\(Section “Latest Version”\) - [tpm2\-tools man pages](https://github.com/tpm2-software/tpm2-tools/tree/master/man)

Similar Articles

Reverse Engineering the Qualcomm NPU Compiler

Lobsters Hottest

Reverse engineering the Qualcomm NPU compiler reveals undocumented VTCM memory management, MILP-based placement, automatic precision alteration, and a hidden analytical simulator (Hextimate) for edge deployment optimization.

The NX bit is not just about security

Lobsters Hottest

A developer describes debugging a complex bug in an ARM64 bare-metal hypervisor for postmarketOS, involving instruction cache coherence issues and hardware-specific behavior related to the NX bit.