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 Measurements1, and 23 is reserved for application support. So only 8-15 are left for systemd to do all OS-related measurements2.
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: 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 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:
tpm2_pcrread sha256
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 index3 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 index4 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 value5. 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:
tpm2_nvreadpublic
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 configured6, 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.
tpm2_nvreadpublic
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.
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 policy8.
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.policy9.
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.
tpm2_nvreadpublic
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 state10. 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
PolicyAuthorize
Locking against a PCR with PolicyPCR is super cool, but it is also brittle11: 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
proof12. 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 exists13. 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 policy14. 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 initrd15. 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 reboot16. 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 logins17. 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:
kill "$(cat swtpm.pid)"
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 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, 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.
References
- Add support for nvindex-based additional PCRs for TPM2, aka “NvPCRs” PR on systemd
- tpm2: Improve how NvPCR protection works PR on systemd
- TPM2 PCR Measurements Made by systemd documentation
- TPM 2.0 Library (Section “Latest Version”)
- tpm2-tools man pages
Dynamic Root of Trust for Measurements is a mechanism to establish a new, verifiable chain of trust at runtime, usually supported by the platform through something like Intel TXT or AMD SVM. ↩︎
The UAPI.7 spec documents how firmware and OS PCRs are commonly used. ↩︎
See Section “34.2 NV Indices” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture ↩︎
Section “13 Names” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture lists the name equations for all entity types (Table 9), and explicitly describes how the name of an NV index changes when the
writtenattribute is set. ↩︎Hierarchies and their authorizations are introduced in Section “10 TPM Control Domains” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture in case you want to dive into it, but not super important for what we are doing here. ↩︎
Notice the
nt=0x1in the output is a bug in tpm2-tools.TPM_NT_EXTENDis 0x4 according to spec, and the raw value correctly contains that. I’ve opened an upstream PR to fix this. ↩︎Notice that the
TPM2_NV_Extendcall used by tpm2_nvextend uses the arbitrary bytes provided directly in the hash function without pre-hashing:NV := H_nameAlg(NV_old ‖ input).TPM2_PCR_Extendon the other hand requires a fixed size digest to be passed (PCR := H(PCR ‖ digest_in)), andTPM2_PCR_Eventdoes hashing of the handed event itself (PCR := H(PCR ‖ H(event))). ↩︎Policies are specified in Section “16.7 Enhanced Authorization” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture, which is a surprisingly readable introduction to the topic. Trial sessions are covered in Section “16.7.10 Trial Policy”. ↩︎
Reproducing the hash with some constants from the spec:
H(zeros32 || 0000017f || 00000001000b03008000 || H(pcr15_value))On the terminal:
↩︎pcrdigest=$(head -c 32 /dev/zero | sha256sum | cut -d' ' -f1) printf '%064d0000017f00000001000b03008000%s' 0 "$pcrdigest" \ | xxd -r -p | sha256sumPolicyPCRcan be immediate and deferred at the same time, depending on its parameters: the caller may pass the PCR digest they expect, which the TPM then checks against the selected PCRs right away. Called without a digest, like tpm2_policypcr does here, the TPM just folds the current PCR values into thepolicyDigest, and whether they were the right ones only shows in the final comparison againstauthPolicy. In both variants, the TPM additionally records the current PCR update counter in the session as a deferred constraint: if PCRs are updated after the assertion, the session can no longer authorize anything. ↩︎The spec discusses the problem of “brittleness” in Section “16.7.11 Modification of Policies” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture, and introduces a similar construction as we are about to build. ↩︎
Tickets are HMACs keyed with an internal TPM proof value, enabling the TPM to re-verify a signature later without loading the asymmetric key again, see “8.4.6.3 Tickets” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture ↩︎
The digest replacement is illustrated in Section “16.7.4 Policy OR” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture. ↩︎
While the policy commands run, the session isn’t tied to any NV index yet, so the written property can’t be checked immediately.
TPM2_PolicyNvWrittenextends thepolicyDigestlike any other assertion, but additionally records the claimed written state in the session, as a deferred check. Only when the session is used to authorize a command does the TPM compare it against the actual attribute of the accessed index, and reject the command on mismatch. ↩︎The real systemd implementation doesn’t use the raw string as
policyRef, butSHA256("initrd"), as the size of a policy ref is limited. The semantics are the same. ↩︎More precisely,
clear_stclearmeans the index is cleared on anyTPM2_Startup(CLEAR), which the platform issues on a TPM reset or TPM restart. Besides the reboot, that also covers resume from hibernation (not that Linux systems really hibernate with secure boot enabled). Resume from suspend on the other hand corresponds toTPM2_Startup(STATE), a TPM resume, which preserves the index. ↩︎If you inspect these indices on a real system, all but
hardwarewill show one more attribute:orderly. An orderly NV index is backed by TPM RAM and only flushed to NVRAM on clean shutdown, sparing the NVRAM from wear on frequently written indices likelogin. TPM RAM is even scarcer than NVRAM, though, so thehardwareNvPCR, written only once per boot, isn’t using it. And asorderlyis an attribute, it is part of the index name. ↩︎