systemd v262 の NvPCR を理解する
systemd は TPM の不揮発メモリに PCR 類似のレジスタを置いて PCR 不足を補い、v262 でアンカリング設計を作り直した。ソフトウェア TPM 上で NvPCR をゼロから組み直しながら、必要な TPM の概念を解説する。
日本語
コピー
systemd は PCR の数が足りないという問題に、TPM の NV メモリ上に確保した PCR 類似のレジスタで対処している。 そのアンカー設計は v262 で作り直された。 このハンズオンでは、ソフトウェア TPM に対して systemd の NvPCR をゼロから作り、必要な TPM の概念も押さえ、この設計がなぜ安全なのかを分析する。
systemd はなぜ PCR が足りないと感じるのか#
systemd のセキュリティ機能の多くは TPM PCR の測定に依存している。パスワードなしの全ディスク暗号化は、PCR の測定値が想定どおりならディスクを自動でアンロックする。これにより資格情報の窃取を防ぎつつ、root ディスクを暗号化したリモートマシンを無人で再起動できる。サービスの資格情報は、想定される PCR の状態に暗号化して結び付けられる。起動段階に結び付けた資格情報にも対応しており、特定の機密を initrd の中でだけ復号できる。さらにリモートアテステーションを使えば、マシンは TPM に現在の PCR 状態へ署名させ(いわゆる quote)、自分が何を起動し、その後何が起きたかを相手に証明できる。これらはすべて PCR の測定の上に成り立っている。TPM の PCR は乏しい資源だ。標準に準拠した一般的な TPM で使える PCR は 24 個しかない。 番号の小さい PCR 0-7 はファームウェアが所有し、UEFI の起動測定に使われる。 16 はデバッグ用の PCR で、リセットできるため使えない。17-22 は Dynamic Root of Trust for Measurements1 用に、23 はアプリケーションサポート用に予約されている。 すると systemd が OS 関連の測定すべてに使えるのは 8-15 だけになる2。リモートアテステーションの観点では、測定レジスタは 1 つあればシステムを検証するのに足りる。測定ログからイベントを再生し、測定された内容を読み解けば、観測された最終値が得られる。だが PCR を読むのはリモートの検証者だけではない。ローカルの機密も PCR にロックされる。この用途には予測可能な値が要る。ある PCR に流れ込むイベントはすべて事前に分かっていなければならず、そうでなければポリシーが破綻する。測定のなかには本質的に予測できないものがある。各プラットフォームのクローズドソースなベンダーファームウェアに依存するものや、ユーザーの行動に依存するものだ。ログインイベントをリモートアテステーションの quote に含めたいことはあるだろうが、root ディスクは誰かがログインした後も自力でアンロックできるべきだ。だから雑多で予測不能な種類のイベントには専用のレジスタが要る。ロックが依存する PCR を避けつつ、測定はされ、証明もできるようにする。ここで systemd がほどなくぶつかったのが厳しい制約だ。OS 用の PCR スロットは 8 つしかなく、イベントの種類ごとにレジスタを割り当てる余裕はほとんどない。そこで systemd は v259 で NvPCR を導入した。TPM の不揮発性メモリに確保する、PCR に似た追加のレジスタで、名前もそこから来ている。本物の PCR には入れたくない種類のイベントを引き受け、その値はリモートアテステーションを通じて消費される。v262 では、安全性を高めるために NvPCR のアンカーの仕組みが作り直された。以前のアンカーは、PCR 11 に封をしてディスクに保存したランダム鍵に基づいていた。攻撃者は別の OS を起動して想定どおりの PCR 11 の値を再生すればこの鍵を復元できるし、自分が知っている鍵に差し替えることもできた。この記事では v262 でリリースされた作り直し後の設計を紹介する。どう動くのか、そしてなぜ安全なのか。
一緒に進めるための準備#
コマンドラインからソフトウェア TPM に対して自分の NvPCR を構築し、基本的な仕組みを理解して感覚をつかむ。読むだけで手は動かさないという人も構わない。重要な出力はすべて貼っておく。前提として必要なツールを入れておく。nix や dnf を使うならこうだ。
nix shell nixpkgs#{tpm2-tools,xxd,swtpm,openssl}
dnf install tpm2-tools vim-common swtpm openssl
作業ディレクトリを作り、ソフトウェア 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
接続情報をエクスポートして、tpm2-tools がこの TPM の場所を分かるようにする。
export TPM2TOOLS_TCTI="swtpm:host=127.0.0.1,port=2321"
PCR を読んで、TPM が正常に動いていることを確認する。
tpm2_pcrread sha256
この時点で PCR 0-16 がすべてゼロに見えるはずだ。そうでなければ、接続しているのはソフトウェア TPM ではなくプラットフォームの TPM かもしれない。TPM2TOOLS_TCTI が正しくエクスポートされているか確認し直そう。これは重要だ。この先の実験で、プラットフォームに封をした鍵に触れてほしくないからだ。
PCR の代わりに TPM NV index を使う#
TPM NV index3 は、一意な名前で識別される不揮発性の記憶スロットだ。NV index は再起動をまたいで残り、ユーザー定義のデータを置ける。不透明な値、カウンタ、ビットフィールドなどだ。NV index の属性がその振る舞いと用途を決める。ハンドル、格納するデータのサイズ、この index をどう操作・読み出せるかを制御する属性の集合、そして認可ポリシーと認可値(後者は唯一公開されない属性)で、どのような条件ならこの index を操作できるかを任意で指定する。各 index には nameAlg がある。index の公開属性から一意な名前を計算するハッシュアルゴリズム4で、計算方法は
Name = nameAlg || H_nameAlg(marshal(TPMS_NV_PUBLIC)) だ。
PCR に似た NV index を作ってみよう。ここでは tpm2-tools の tpm2_nvdefine を使う。
0x01000000 は定義する index のハンドル(適当に選んだ)。--hierarchy=o フラグはこの index を定義するときに使う認可を選ぶ。私たちのような NV index は TPM の owner hierarchy にあり、定義や定義解除には owner 認可値が要る5。一般的な Linux システムではこの値は空なので、実際には TPM デバイスにアクセスできる者(通常は root)が owner 認可を持っていることになる。hash-algorithm フラグは先ほど触れた nameAlg に対応し、ここでは sha256 を選ぶ。続いて NV index の属性を選ぶ。authread|authwrite は空の認可値と組み合わせて、デバイスにアクセスできる者なら誰でもこの index を読み書きできるようにする。nt=extend は PCR のように extend できるようにしたいという意味だ。
tpm2_nvdefine 0x01000000 \
--hierarchy=o \
--hash-algorithm=sha256 \
--attributes="nt=extend|authread|authwrite"
結果は次のコマンドで確認できる:
tpm2_nvreadpublic
0x1000000:
name: 000be9606b61ec27bc8deec096dd38a6f8961cb8b3ef2fe879b27de704ab2f3d44e3
hash algorithm:
friendly: sha256
value: 0xB
attributes:
friendly: authwrite|nt=0x1|authread
value: 0x40044
size: 32
設定した属性6とサイズ、そして公開属性から計算されたハッシュ名が見えるはずだ。定義した属性がまったく同じなら、得られるインデックス名のハッシュも完全に一致する。
このNVインデックスは、本物のPCRと同じように使える。1回の測定で拡張してみよう。たとえばユーザーAliceのログインイベントを記録する:
printf 'user-alice-logged-in' > m1.bin
tpm2_nvextend 0x01000000 --input=m1.bin
そして値を読む:
tpm2_nvread 0x01000000 --size=32 | xxd -p -c 64
bd9927a653c6c33297b7d884a8ad99df0f5b5b1c1e1e86f762b3aced8bc77f50
ここで改めてこのNVインデックスを見ると、興味深いことが起きている。新しい属性writtenが増えており、1回以上書き込まれたことがわかる。属性集合が変われば、インデックス名は属性を含むハッシュなので、インデックス名も変わる。この点は後で使う。
tpm2_nvreadpublic
0x1000000:
name: 000b1191942a636c11a57571f0d435c290a1955788229305ce0aa8a2f393e9ed770c
hash algorithm:
friendly: sha256
value: 0xB
attributes:
friendly: authwrite|nt=0x1|authread|written
value: 0x20040044
size: 32
もう一度測定して、Bobのログインイベントを記録してもよい:
printf 'user-bob-logged-in' > m2.bin
tpm2_nvextend 0x01000000 --input=m2.bin
tpm2_nvread 0x01000000 --size=32 | xxd -p -c 64
e758d6e2e54620fd45b9cd1fd57cbba62757f12751d549fd2fc957ed1908ed29
このときNVインデックスの値はHASH(HASH(0x0 || m1) || m2)7になる。2回目の測定後も、インデックス名は変わらない。
これでPCRのように拡張できるNVインデックスが手に入った。ではそのままPCRの代わりになるかというと、まだならない。本物のPCRが持つ根本的な性質が欠けている。測定チェーンを何かの証明として扱うには、システムの稼働中にリセットもリプレイもできないことが必要だ。そうでなければ、システムを破った攻撃者が測定履歴を消し、改ざんされていない履歴をリプレイするだけで、リモート証明はこの攻撃を見逃してしまう。
残念ながら、TPMはこのPCRもどきのNVインデックスにその性質を与えてくれない。インデックスはシステムの稼働中に定義でき、また定義を解除できる:
tpm2_nvundefine 0x01000000 --hierarchy=o
tpm2_nvreadpublic
この節で行った2回の測定と合わせて考えると、Aliceが悪意を持ちroot権限を取った場合、owner認可も手に入る。彼女はこのインデックスを定義解除して再定義し、Aliceが一度もログインしていない履歴をリプレイするだけで済む。再定義後のインデックス名はまったく同じなので、こちらは気づけない。
必要なのは、誰でも拡張できるが、システムの稼働中は誰も再起動できないインデックスだ。ポリシー(policy)は、こうした条件をTPMに表現させるための仕組みである。
ポリシーを探る#
TPMインターフェースが提供するきわめて強力な概念のひとつがポリシー8だ。ポリシーは不透明なダイジェストで、保護対象とともに保存される。以前NVインデックスの公開属性で見たauthPolicy属性がそれにあたる。ポリシーを満たすには、TPMにポリシーセッションを要求する。各セッションは独自のコンテキストを持ち、そこにはpolicyDigestと呼ばれるダイジェストと、ポリシーアサーションの実行によって変更できる一連の制約が含まれる。新しいセッションのpolicyDigestはすべてゼロだ。セッション内では、TPMに対してさまざまなポリシーコマンドを実行でき、各コマンドは何らかの条件をアサートする。コマンド実行時に即座に検査されるアサーションもあれば、先送りされるものもある。後者の場合、コマンドはセッションコンテキストに制約を記録するだけで、TPMがそれを検査するのはセッションを認可に使うときだ。いずれにせよ、各コマンドは次のロジックでセッションのダイジェストを拡張する:
policyDigest := H(policyDigest_old || commandCode || command-specific args)
拡張スキームは PCR とまったく同じように動作する。たとえ policyDigest の背後に本物の PCR が存在しなくても。
呼び出し側はさまざまな種類の証拠を提出でき、それぞれが policyDigest を拡張する。
それらのアサーションが積み重なって、セッションの policyDigest がインデックスの authPolicy と一致すれば、
そのセッションは認可され、それを使って目的のコマンドを呼び出せるようになる。
ポリシー作成者は、信頼できる環境で試験セッションを1回走らせるか、オフラインで事前計算して、
期待される authPolicy ダイジェストを求める。試験セッションは同じダイジェスト計算を実行するが、
条件の検証は一切行わない。その代わり、そのセッションで何かを認可することはできない。だからこそ、
作成者はマシンが現在置かれていない状態に対するポリシーを計算できる。
PolicyPCR#
面白いのは、ポリシーが PCR の期待値を固定することで、権限をマシンの状態に依存させられる点だ。
そんなポリシーを作ってみよう。まず試験セッションを開始し、NV インデックスに設定したいポリシーを定義する。
--policy-session フラグを付けずに tpm2_startauthsession を呼び出す。
tpm2_startauthsession --session=trial.ctx
次に tpm2_policypcr を呼び出し、--pcr-list= で選んだ現在観測されている PCR 値に対するアサーションを作成する。
この実験では PCR 15 を使う。OS が所有する PCR のひとつだ。PCR 23 の方が自然な実験場に見えるが、
デバッグ用の PCR 16 と同様、実行時にリセットできてしまう。それこそがまさに避けたい性質だ。
tpm2_policypcr --session=trial.ctx \
--pcr-list=sha256:15 \
--policy=pcr15.policy
7e247a603cd1052cabc095741b8ee2f7458aabeee960b8ec97d7f090171a039a
生成されたポリシーダイジェストが表示され、pcr15.policy9 に書き込まれる。
そしてセッションを終了する。
tpm2_flushcontext trial.ctx
先ほどの NV インデックスを再作成する。今度は書き込みアクセスを、たった今作ったポリシーで保護する。
tpm2_nvdefine 0x01000000 \
--hierarchy=o \
--hash-algorithm=sha256 \
--attributes="nt=extend|authread|policywrite" \
--policy=pcr15.policy
--policy= フラグと policyWrite 属性の両方を付けている点に注意。
authRead はそのまま。policyRead もあるが、通常は
インデックスを拡張できる相手を制限する方がはるかに面白い。
tpm2_nvreadpublic
0x1000000:
name: 000b14a6915e5830ff2b83ebc87cdf5ac481fb29637c246489bdab1bba19948bb729
hash algorithm:
friendly: sha256
value: 0xB
attributes:
friendly: policywrite|nt=0x1|authread
value: 0x40048
size: 32
authorization policy: 7E247A603CD1052CABC095741B8EE2F7458AABEEE960B8EC97D7F090171A039A
この状態で以前のように直接拡張しようとすると、認可エラーで失敗する。
tpm2_nvextend 0x01000000 --input=m1.bin
ERROR: Esys_NV_Extend(0x12F) - tpm:error(2.0): authValue or authPolicy is not
available for selected entity
インデックスに書き込むには、ポリシーセッションを開始し、現在の PCR 15 の状態10 を提出してポリシーを満たす必要がある。 その後で拡張を実行し、そのセッションを認可として提出する。最後にセッションコンテキストをフラッシュするのを忘れずに。
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
PCR 15 が変化すれば、このポリシーはもう満たせない。次のコマンドでその PCR を拡張する。
echo something | tpm2_pcrevent 15
ここで、先ほどその PCR に基づいてセッションを解錠した3ステップを再試行する。
PCR が変化したため、拡張は tpm:session(1):a policy check failed を返して失敗する。
現在の値も、将来のどんな値も、ポリシーに含まれる値とは一致しない。
その PCR にバインドされたインデックスへのアクセスは失効し、マシンの稼働中に取り戻すことはできない。
最後に、このインデックスを再度未定義にする。
tpm2_nvundefine 0x01000000 --hierarchy=o
PolicyAuthorize#
PolicyPCR で PCR にロックするのは非常にクールだが、脆くもある11。前節の終わりでは、ある測定値が変化しただけでアクセスが失効し、取り戻せなくなった。これは問題だ。PCR の値は正当な理由でいつでも変わりうるからだ。冒頭のパスワードなしのディスク解錠を思い出してほしい。ディスク鍵は起動チェーンの PCR 状態に封印されており、次のカーネル更新がまさにその状態を変えてしまう。何も悪いことは起きていないのにディスクは解錠できなくなり、更新のたびにディスクを再暗号化するのはもちろん御免だ。
より良い方法は、ポリシーを安定させたまま、承認済みの状態が変化できるようにすることだ。幸い、ポリシーを作る別の仕組みがある。PolicyAuthorize だ。これは間接参照と委譲の層を導入し、システムの状態ではなく公開鍵に基づいてポリシーを作れるようにする。認可するには、別の具体的なポリシー(たとえば PolicyPCR)を満たし、そのうえで期待されるポリシーダイジェストに対する署名と policyRef を提示する。具体的なポリシーのダイジェスト自体は、そのポリシーの一部ではない。この鍵を持つ者なら誰でも、新しい具体的なポリシーをオフラインで承認できる。policyRef は署名の適用範囲を限定し、署名済みのポリシーが別の文脈で使われないようにする。
RSA 鍵ペアを作成して PolicyAuthorize を試してみよう。
openssl genrsa -out sign.key.pem 2048
openssl rsa -in sign.key.pem -pubout -out sign.pub.pem
公開鍵を TPM にロードし、ポリシーの一部として使えるようにする。
tpm2_loadexternal \
--hierarchy=o \
--key-algorithm=rsa \
--public=sign.pub.pem \
--key-context=signkey.ctx \
--name=signkey.name
name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661
表示される名前が、ポリシーをこの鍵に結び付ける。計算方法は先に見た NV インデックスの名前とまったく同じで、オブジェクトの公開属性をダイジェストする。鍵の場合、その属性には公開鍵そのものが含まれる。tpm2_readpublic に公開属性が表示される。
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: ...
名前アルゴリズム、オブジェクト属性、鍵パラメータに加えて、公開領域には生の RSA モジュラスも含まれる(ここでは短縮している)。公開鍵が少しでも変われば名前が変わり、その名前で作られたポリシーも変わる。
次に、別の試用セッションと鍵の名前を使って、この鍵で認可できるポリシーを作成する。
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
生成されたポリシーダイジェストには PCR 値が一切含まれない。依存するのは鍵の名前(そして鍵の名前を通じて間接的に公開鍵)と policyRef タグだけだ。別の鍵ペアを生成すれば、得られるポリシーハッシュも変わる。2回目の flush 呼び出しに --transient-object が渡されている点に注意。これは tpm2_loadexternal が TPM の限られた過渡メモリに残した鍵オブジェクトを消去する。tpm2-tools はロードしたオブジェクトを消去しないので、鍵をロードするコマンドの後でこのクリーンアップを繰り返す。
表示されたポリシーハッシュは authorized.policy に書き込まれ、先ほどと同じようにこれを使って NV インデックスを再度定義できる。
tpm2_nvdefine 0x01000000 \
--hierarchy=o \
--hash-algorithm=sha256 \
--attributes="nt=extend|authread|policywrite" \
--policy=authorized.policy
このインデックスに書き込める者はまだ誰もいない。ポリシーを満たす署名が存在しないからだ。
実際には、この流れは通常2者間で進む。鍵の保有者、たとえばディストリビューションのメンテナンスチームと、自分の TPM に証拠を提示する1台のマシンだ。鍵の保有者は、承認したい具体的なポリシーダイジェスト、たとえば特定のビルドの PolicyPCR に対するものを計算し、そのダイジェストと policyRef に対して署名を作成する。
PCR 15 の更新値に対する新しい PCR ポリシーを作る。これが今回認めたいものだ。前節と同じく、トライアルセッションを実行して、観測された状態に対するポリシーダイジェストを取得する:
tpm2_startauthsession --session=trial.ctx
tpm2_policypcr --session=trial.ctx \
--pcr-list=sha256:15 \
--policy=approved.policy
tpm2_flushcontext trial.ctx
ポリシーと policyRef を連結する:
cat approved.policy policyref.bin > tbs.bin
それを秘密鍵でまるごと署名する:
openssl dgst -sha256 -sign sign.key.pem -out approved.sig tbs.bin
あとはポリシーと署名を、たとえばイメージや更新の一部として対象マシンに送ればよい。この成果物は、鍵の持ち主がその具体的なポリシーを承認したことを示す。
マシン側では、まず TPM に署名を検証させる:
tpm2_verifysignature --key-context=signkey.ctx \
--hash-algorithm=sha256 \
--message=tbs.bin \
--scheme=rsassa \
--signature=approved.sig \
--ticket=verify.tkt
検証が通ると、TPM は ticket、つまり HMAC でタグ付けされた証明12を返す。次に新しいポリシーセッションを開始し、その具体的なポリシー(署名を作った当のポリシー)を満たしたうえで、ticket、policyRef、ポリシーを提示してセッションを認証する。
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
tpm2_policyauthorize の呼び出しで、TPM は自分のセッションダイジェストが署名済みの approved.policy と等しいことを確認し、ticket が与えられた policyRef と鍵に対して有効かどうかを検査し、そして現在のセッションダイジェストを authorize ポリシー(authorized.policy)に切り替える。
以降、セッションダイジェストはインデックスを定義したときに設定した authorize ポリシーと一致し、セッションは解錠される:
tpm2_nvextend 0x01000000 \
--hierarchy=0x01000000 \
--auth=session:s.ctx \
--input=m1.bin
tpm2_flushcontext s.ctx
tpm2_flushcontext --transient-object
この仕組みがあれば、将来の更新で PCR 15 の測定値が変わっても、インデックスが使えなくなることはない。変更すら不要だ。信頼できる鍵の持ち主が新しい PCR 15 の状態に対して新しいポリシーを署名し、更新の一部として配布するだけでよい。マシン上では、同じポリシーを使う NV インデックスがそのまま動く。systemd が TPM ベースのディスク解錠をカーネル更新をまたいで維持できるのは、まさにこの仕組みのおかげだ。各 UKI が、自身の期待する PCR 11 の状態に合った新しい署名を携えてくる。ただしこの仕組みの裏側も覚えておこう。鍵の持ち主がひとつの状態しか署名していなければ、マシンがちょうどその状態にあるときしかポリシーは満たせない。この性質は後で利用する。
PolicyOR#
ポリシーダイジェストはひとつのアサーション列にしか対応しない。すべての要素は AND で連結され、期待するセッションハッシュを得るには順番どおりに各要素を満たさなければならない。ときに、別の分岐を組みたいことがある。たとえば信頼できる状態を2つ知っていて両方を許可したい場合や、同じ操作に2通りの認可経路を持たせたい場合だ。そのために PolicyOR13 がある。TPM は現在のセッションのダイジェストが許可リストに含まれるかを確認し、PolicyAuthorize ticket と同様のやり方で置き換える。分岐のどれかひとつでも満たされれば、ポリシーは満たされたことになる。
安全なポリシーベースの NvPCR を構築する#
ここまでに紹介したプリミティブを踏まえて、systemd がどうやって安全な NvPCR を構築しているかを見ていこう。NV インデックスは書き込みポリシーで保護され、そのポリシーは PolicyOR で連結された2つの分岐を含む。公開鍵と policyRef initrd を伴う PolicyAuthorize 分岐と、PolicyNvWritten(true) 分岐だ。
まず PolicyNvWritten(true) 分岐。このアサーションは written 属性をポリシーの一部として検査することを許す14。したがって、NvPCR が以前に一度でも拡張されていれば、PolicyNvWritten(true) はそれ以上の認可なしに満たされる。初期セットアップが済んだあと、実行時に使われるのはこの分岐だ。このとき NvPCR の拡張に追加の認証は要らず、デバイスにアクセスできるコンポーネントなら何でも実行できる。
もう一方の PolicyAuthorize 分岐は、署名ポリシーで満たせる。NvPCR の最初の拡張は必ずこの分岐を通らなければならない。systemd が実際に採用しているのは PCR 11 に対する PCR ポリシーで、初期起動時に initrd 内でその PCR が取るべき期待状態に一致する。systemd は PCR 11 で起動段階を追跡する。initrd が起動すると、systemd-pcrphase-initrd.service 測定イベント enter-initrd が起きる。このイベントの後の PCR 11 の期待状態に対して PCR ポリシーを構築する。それ以前にシステムが改ざんされておらず、署名が有効なら、署名付き PCR ポリシーが満たされ、NvPCR は最初の拡張を完了できる。次の起動段階に進むと、同じサービスが段階イベント leave-initrd を測定する。これにより、以降のシステムのライフサイクル全体にわたって PolicyAuthorize 分岐が閉ざされる。以後 NvPCR は PolicyNvWritten 分岐を通してしか拡張できない。
なぜ書き込みポリシーで PolicyAuthorize を経由するのか、PCR ポリシーを直接埋め込まないのか、と思うかもしれない。実システムでは PCR 11 に入るのは段階イベントだけではない。UKI stub がまずカーネルと initrd を測定するので、更新のたびに initrd 内の PCR 11 の状態が変わる。直接埋め込むと、更新のたびに書き込みポリシーが変わり、ひいてはインデックス名も変わるので、NvPCR を毎回作り直す羽目になる。PolicyAuthorize を使えば書き込みポリシーと名前は安定したままで、変わるのは UKI と一緒に配布される署名だけだ。
それでは最終的な NvPCR を、systemd と同様のやり方で構築しよう。まず initrd の起動段階の開始を印付けるイベントを測定する。これは systemd-pcrphase-initrd.service が行う:
echo -n "enter-initrd" | tpm2_pcrevent 11
tpm2_pcrread sha256:11
sha256:
11: 0xD15B0E8E244E65C40F024E95773F2347CE4EF3FFE6B597C9A14B50BBAB6DF319
続いて書き込みポリシーを書く。前節の鍵をそのまま再利用する。これは UKI の PCR 署名鍵の役割を果たし、その公開鍵部分は UKI の .pcrpkey セクションと一緒に配布される。まず policyRef を値 initrd15 で書き込む。次にトライアルセッションでポリシーの鍵ベースの分岐を作る。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
続いてポリシーの2つ目の分岐、すなわち PolicyNvWritten(true) を作る:
tpm2_startauthsession --session=trial.ctx
tpm2_policynvwritten --session=trial.ctx --policy=written.branch s
tpm2_flushcontext trial.ctx
最後に PolicyOR でこの2つのポリシーを組み合わせる:
tpm2_startauthsession --session=trial.ctx
tpm2_policyor --session=trial.ctx \
--policy-list=sha256:init.branch,written.branch \
--policy=write.policy
tpm2_flushcontext trial.ctx
この戦略で NvPCR を定義する。やり方は前とほぼ同じだ:
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
書き込みポリシーは生成した鍵に依存するので、認可ポリシーもインデックス名も私のものとは違ってくる。ここで新しいのは clear_stclear 属性だけだ。これは、再起動時にこの NV インデックスを消去するよう TPM に指示する16。PCR と同じく、NvPCR も再起動時にリセットされるべきであり、前回の起動の測定値を次の起動まで持ち越してはならない。written 属性もリセット時に消去される。
0x01D10200 は systemd が使う NV インデックス範囲の実際のハンドルだ。システム上にどの NvPCR が存在するかは /usr/lib/nvpcr/*.nvpcr 内の小さな JSON ファイルで定義され、v262 以降、これらのファイルは UKI の一部としてイメージに同梱しなければならない。systemd は現在 4 つの定義を同梱している。hardware は製品 UUID、cryptsetup は使用する LUKS 解除機構、verity はアクティブな verity ボリュームのルートハッシュ、login はユーザーログインに対応する17。これら 4 つが記録するイベントは、予測不能か、あるいは数に上限がない。まさに本物の PCR に入れたくない類のものだ。
次に、PCR 11 について想定される initrd の状態に対する具体的な PCR ポリシーを構築し、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
systemd では、この手順はイメージビルド時に ukify が行う。このツールには --sign-initrd-pcrs フラグが追加され、enter-initrd 段階で想定される PCR 11 の値を事前計算し、署名済みポリシーを .pcrsig セクションとして UKI に埋め込む。
起動のたびに、systemd の systemd-tpm2-setup-early.service がその署名と認可ポリシーのパスを使って initrd 内で NvPCR を初期化する。まず TPM に署名を検証させる:
tpm2_verifysignature --key-context=signkey.ctx \
--hash-algorithm=sha256 --message=tbs.bin --scheme=rsassa \
--signature=initrd.sig --ticket=initrd.tkt
続いてポリシーセッションを開く。PCR 11 の現在の状態で PCR ポリシーを満たし、tpm2_policyauthorize を呼び出して initrd.policy に対する署名を投入する。最後に tpm2_policyor を呼び出して 2 つの分岐を投入し、我々の NvPCR の想定 auth digest と一致させる:
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
このセッション状態が得られればポリシーは満たされ、そのまま NvPCR を拡張する。systemd は NvPCR をすべてゼロで初期化する。書き込む値はどうでもよく、重要なのはこのインデックスが written 属性を獲得すること、そしてこの最初の拡張が署名済みポリシーの下で行われることだ。
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
拡張値はすべてゼロなので、新しく初期化された SHA-256 NvPCR はどのマシンでもこの正確な値 SHA256(zeros32 || zeros32) から始まる。最初の書き込みが済むと、このインデックスは written 属性を獲得し、それに伴って新しい名前を持つ:
tpm2_nvreadpublic 0x01D10200
0x1d10200:
name: 000bf08c214c037f85d3520549dba23471a88af8aab1913bf269edfbb44b6d2de9f5
...
attributes:
friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear|written
value: 0x28060048
この名前は覚えておいてほしい。次の節でも使う。
initrd の起動段階の終わりに、systemd はその段階の終了を示すイベントを測定する:
echo -n "leave-initrd" | tpm2_pcrevent 11
こうして、次に再起動するまで、署名済みポリシーは二度と満たせなくなる。
その後、実行時には、initrd で NvPCR を初期化したときに得た written 属性だけで書き込みが認可される。systemd-pcrextend がイベントを記録するときに行っているのはこれで、鍵も秘密も要らず、TPM にアクセスできるコンポーネントなら何でも追記できる。これは意図的な設計だ。本物の PCR への拡張も同様に認証されていない。履歴の追加は無害であり、不可能でなければならないのは履歴の書き換えだけだからだ。
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
なお、セッションには init.branch ダイジェストを含む 2 つの分岐を引き続き提示する必要がある。そのため systemd は初期化後にこれを /run/systemd/nvpcr/.auth へ永続化する。
通常の PCR と同様、systemd は NvPCR の測定のたびにユーザー空間の測定ログ(/run/log/systemd/tpm2-measure.log)に記録する。これにより検証者は後からイベントを再生し、NvPCR の値を解釈できる。
実践パートはここまで。実験が終わったらソフトウェア TPM を停止する:
kill "$(cat swtpm.pid)"
NvPCR 設計のセキュリティ考察#
この構成がどんな安全性をもたらすか見ていこう。想定する攻撃者は、システムの実行中、leave-initrd イベントが測定された後にアクセス権を得た者であり、冒頭の素朴なインデックス攻撃で Alice が自分のログインを隠したのと同じように、NvPCR の履歴を気付かれずに書き換えたいと考えている。攻撃者は root 権限を持ち、TPM にアクセスして NV インデックスの読み取り、書き込み、未定義化、再定義ができ、PCR を拡張できる(ただしリセットはできない)。再起動したり、別の OS を起動したりもできる。TPM ハードウェアそのものへの攻撃は対象外とする。
TPM 自体は信頼され、initrd までも含む測定起動チェーンも信頼される。これにはファームウェア、bootloader、UKI が含まれる。設計上、initrd には NvPCR を初期化する権限があり、それは PCR 11 の initrd 状態に対する署名済みポリシーとして表現される。だが PCR 11 は OS の制御下にあり、別のカーネルでも想定値を拡張して認可状態に到達できてしまう。PCR 11 の initrd 状態を我々が信頼する initrd に結び付けられるのは、ファームウェアが制御する PCR だけだ。それらは実際に動作した bootloader と UKI を記録しているからである。何か別のものを起動すれば、検証者に見えてしまう。また、鍵の保持者も信頼しなければならない。PCR 署名鍵を握る者は任意の状態に保証を与えられるからだ。検証者も信頼する必要がある。検証者は NvPCR の値だけでなく、これから見るようにそれ以外のものも確認するからである。
書き込みポリシーも公開鍵も秘密ではない。だから攻撃者は、我々の NvPCR を未定義化し、バイト単位で完全に一致するよう再定義できる。履歴を再生するには、そのあと攻撃者がまっさらなインデックスに対して最初の書き込みを行わなければならないが、書き込みポリシーのどちらの分岐もこれを拒否する。PolicyNvWritten(true) の分岐はこの書き込みを認可できない。攻撃者がポリシーコマンドを実行すること自体は止められず、セッションダイジェストが書き込みポリシーと一致すらする。しかし、一度も書き込まれたことのないインデックスに対する遅延書き込みチェックは失敗し、TPM はこの extend を拒否する。PolicyAuthorize の分岐も同様に失敗する。現存する唯一の署名が承認しているのは PCR 11 の initrd 状態、つまりマシンが leave-initrd を測定した時点の状態だけだ。セッションがその署名済みダイジェストを再び得ることはもうできない。ゆえに TPM は拒否し、攻撃者は履歴を偽造できない。
攻撃者が試しうる署名攻撃がもう一つある。同じ鍵は UKI の他の PCR ポリシー、たとえばディスクのロック解除に使うものにも署名しており、そのいくつかはマシンの実行時状態で満たせる。ここで policyRef が効いてくる。我々の認可分岐は ref initrd 向けに生成された署名しか受け付けず、他のポリシーは別の ref(あるいは ref なし)で署名されている。署名同士は互換ではない。
もちろん、攻撃者は我々の書き込みポリシーを再利用する必要などない。authwrite でインデックスを再定義すれば、最初の素朴なインデックスと同じようになり、あとは好きな履歴をいくらでも再生できる。だが忘れてはならない。NV インデックスの名前は、その公開属性すべてのハッシュだ。属性、書き込みポリシー、そして書き込みポリシーはベンダーの公開鍵をコミットしている。インデックスを initrd の外から書き込み可能にしようとすれば、別の名前にならざるを得ない。
こうして名前が設計全体のアンカーになる。最後に欠けているのは、それを検証可能にすることだ。NvPCR を初期化すると、systemd はイベント nvpcr-init::0x: を PCR 9 に extend する。PCR 9 は本物で、リセットできない。最も安価な犠牲者でもある。カーネルは渡された initrd をすべて測定し、UKI 起動時には UKI 内蔵の initrd と systemd-stub がその場で生成する cpio アーカイブ(credentials などに使う)の連結が、ひとつの値にまとめて混ぜ込まれる。構成によっては冗長な bootloader の記録も加わる。これでは PCR 9 は予測不能で、どのロック解除ポリシーもそもそも束縛できない。本当に重要な測定、つまり UKI initrd は PCR 11 がきれいに覆っている。NvPCR の初期化がすべて終わると、systemd-pcrnvdone.service が区切りイベント nvpcr-separator を PCR 9 に測定する。この時点でもまだ initrd の中だ。NvPCR の値そのものは TPM2_NV_Certify で証明され、TPM が現在のインデックス内容をインデックス名とともに署名する。新しい systemd-report-sign-tpm2 signer は、通常の PCR quote に加えてこの証明も出力する。リモート証明を行う検証者は、証明されたインデックス名が PCR 9 のイベントログで区切り文字より前に現れる nvpcr-init イベントと一致する NvPCR の値だけを信頼してよい。再構築されたインデックスはどれもこの関門を通れない。名前が違うか、名前が合っていても区切りの後に記録されているかのどちらかだ。
攻撃者に残された手段は二つ、再起動と別の OS の起動だ。再起動は本物の PCR と同じように NvPCR をリセットし、次の起動が initrd の中で再初期化する。攻撃者は何も得られない。新しい起動は本物で、その履歴は設計どおり最初から始まり、検証者には再起動が起きたことも見える。別の OS の起動も役に立たない。PCR 11 や他の起動チェーン PCR に異なる測定値が生じるからだ。署名ポリシーは満たせず、その起動から得られる quote は改ざんされた起動チェーンを露呈する。
結論#
systemd v262 の NvPCR は NV extend インデックスであり、最初の書き込みは署名 PCR ポリシーに縛られ、そのポリシーは initrd の中でしか満たせない。以降の書き込みは制限されない。インデックス名は起動の初期に PCR 9 へ測定され、この構造を検証者に対して固定する。より緩い書き込みポリシーで再構築されたインデックスは必ず別の名前を持ち、検出される。我々はコマンドラインでそのような NvPCR を再構築し、攻撃者がなぜ測定値を偽造できないかを論じた。initrd の窓が閉じたあと、新しいインデックスの書き込みポリシーに入り込む経路は存在せず、攻撃者にまだできることはすべて破壊的で、証明の中で露見する。
最後に実用的なヒントを一つ。書き込みポリシーは UKI の PCR 署名鍵に基づいている。この鍵がローテーションされると、systemd-tpm2-setup が名前の不一致を検出し、既存のインデックスが NvPCR らしいか確認したうえで、新しいポリシーで再構築する。同じ経路が v262 以前の設計で作られた NvPCR も自動的にアップグレードするので、systemd を更新すればシステム上の移行は自動で完了する。
この initrd に縛られた署名ポリシーの用途は NvPCR に限らない。systemd-cryptenroll --tpm2-public-key-policyref=initrd を使えば、initrd の中でしかロック解除できない LUKS keyslot を登録できる。実際に動かして確かめたいなら、UKI を --sign-initrd-pcrs でビルドした v262 のイメージを起動し、systemd-analyze nvpcrs と PCR 9 のイベントログを見てほしい。
謝辞#
Amutable の同僚 Chris Coulson に感謝する。新しい NvPCR スキームを設計し、この記事に洞察に満ちた修正と補足をくれた。我々がともに依存するアップストリームプロジェクトで Linux セキュリティに取り組むことは、Amutable の使命そのものだ。新しいセキュリティ基盤をつくること。
参考資料#
- TPM2 に nvindex ベースの追加 PCR(いわゆる「NvPCR」)サポートを追加する systemd の PR
- NvPCR の保護機構を改善する systemd の PR
- systemd が行う TPM2 PCR 測定のドキュメント
- TPM 2.0 ライブラリ(「最新バージョン」の節)
- tpm2-tools の man ページ
- Dynamic Root of Trust for Measurements は、実行時に新たな検証可能な信頼チェーンを確立する仕組みで、通常は Intel TXT や AMD SVM といった技術を通じてプラットフォームが提供する。 ↩︎
- UAPI.7 仕様には、ファームウェアと OS の PCR の一般的な用法が記されている。 ↩︎
- TCG TPM 2.0 ライブラリ仕様、バージョン 185、パート 1: アーキテクチャ、「34.2 NV Indices」の節を参照。 ↩︎
- TCG TPM 2.0 ライブラリ仕様、バージョン 185、パート 1: アーキテクチャ、「13 Names」の節にすべてのエンティティ型の名前の計算式(表 9)があり、
written属性が設定されたときに NV インデックスの名前がどう変わるかも明記されている。 ↩︎ - 階層とその認可については TCG TPM 2.0 ライブラリ仕様、バージョン 185、パート 1: アーキテクチャ、「10 TPM Control Domains」の節で説明されている。深く知りたい人はそちらを読むとよいが、ここでやろうとしていることにはあまり関係ない。 ↩︎
- 出力中の
nt=0x1は tpm2-tools のバグである。仕様によればTPM_NT_EXTENDは 0x4 であり、生の値には正しく含まれている。この問題を修正する PR はすでにアップストリームに送った。 ↩︎ - tpm2_nvextend が使う
TPM2_NV_Extend呼び出しは、渡された任意のバイト列を事前ハッシュせずそのままハッシュ関数に投入する点に注意:NV := H_nameAlg(NV_old ‖ input)。一方TPM2_PCR_Extendは固定サイズのダイジェスト(PCR := H(PCR ‖ digest_in))を要求し、TPM2_PCR_Eventは渡されたイベント自体をハッシュする(PCR := H(PCR ‖ H(event)))。 ↩︎ - ポリシーは TCG TPM 2.0 ライブラリ仕様、バージョン 185、パート 1: アーキテクチャ、「16.7 Enhanced Authorization」の節で規定されている。意外なほど読みやすい入門資料である。Trial セッションは「16.7.10 Trial Policy」の節で説明されている。 ↩︎
- 仕様にあるいくつかの定数でこのハッシュを再現する:
H(zeros32 || 0000017f || 00000001000b03008000 || H(pcr15_value))を端末で:
pcrdigest=$(head -c 32 /dev/zero | sha256sum | cut -d' ' -f1)
printf '%064d0000017f00000001000b03008000%s' 0 "$pcrdigest" \
| xxd -r -p | sha256sum
↩︎
10. PolicyPCR は引数次第で即時にも遅延にもなる。呼び出し側が期待する PCR ダイジェストを渡せば、TPM はその場で選択された PCR を検査する。ダイジェストを渡さなければ、ここでの tpm2_policypcr のように、TPM は現在の PCR 値を policyDigest に畳み込むだけで、それが正しいかどうかは最終的に authPolicy と比較するまで分からない。どちらの場合でも TPM は現在の PCR 更新カウンタを遅延制約としてセッションに記録する。アサート後に PCR が更新されると、そのセッションはもう何も認可できなくなる。 ↩︎
11. 仕様は「16.7.11 Modification of Policies」の節(TCG TPM 2.0 ライブラリ仕様、バージョン 185、パート 1: アーキテクチャ)で「脆弱性」の問題を論じ、これから作るものに似た構造を導入している。 ↩︎
12. Ticket は TPM 内部の証明値を鍵とする HMAC で、TPM が後から非対称鍵を再ロードせずに署名を再検証できるようにする。TCG TPM 2.0 ライブラリ仕様、バージョン 185、パート 1: アーキテクチャ、「8.4.6.3 Tickets」を参照。 ↩︎
13. ダイジェストの置き換えについては TCG TPM 2.0 ライブラリ仕様、バージョン 185、パート 1: アーキテクチャ、「16.7.4 Policy OR」の節に説明がある。 ↩︎
14. ポリシーコマンドの実行中、セッションはまだどの NV インデックスにも束縛されていないため、書き込まれた属性をその場で検査できない。TPM2_PolicyNvWritten は他のアサーションと同様に policyDigest を拡張するが、さらに主張された書き込み状態を遅延チェックとしてセッションに記録する。TPM がセッションをコマンドの認可に使うときに初めて、アクセスされるインデックスの実際の属性と比較し、一致しなければそのコマンドを拒否する。 ↩︎
15. systemd の実際の実装は生の文字列を policyRef として使わず、policy ref のサイズが限られているため SHA256("initrd") を使う。意味は同じである。 ↩︎
16. より正確には、clear_stclear は任意の TPM2_Startup(CLEAR) でそのインデックスが消去されることを意味し、プラットフォームは TPM リセットまたは TPM 再起動の際にこのコマンドを発行する。再起動に加えてハイバネートからの復帰も含まれる(ただし Secure Boot を有効にした Linux は実際にはハイバネートしない)。サスペンドからの復帰に対応するのは TPM2_Startup(STATE)、つまり TPM resume で、こちらはインデックスを保持する。 ↩︎
17. 実機でこれらのインデックスを見ると、hardware を除いてすべてにもう一つ属性が付いている: orderly。オーダード NV インデックスは TPM RAM に支えられ、正常なシャットダウン時にのみ NVRAM に書き出される。これにより login のような頻繁に書き込まれるインデックスが NVRAM を摩耗させるのを避けられる。ただし TPM RAM は NVRAM より乏しいため、起動ごとに一度しか書かれない hardware NvPCR はそれを使っていない。また orderly は属性なので、インデックス名の一部でもある。 ↩︎