---
title: 'Monitoring TPM PCR State Changes'
url: 'https://willifix.net/blog/monitoring-tpm-pcr-state-changes'
markdown: 'https://willifix.net/blog/monitoring-tpm-pcr-state-changes.md'
date: '2026-09-07'
description: 'If you rely on TPM 2.0 measurement registers for secure boot, full disk encryption (such as systemd-cryptenroll or clevis), or system integrity monitoring, you know that unexpected changes in Platform Configuration Registers (PCRs) can break unsealing or signal unauthorized firmware/bootloader alte…'
---

## [Monitoring TPM PCR State Changes](https://willifix.net/blog/monitoring-tpm-pcr-state-changes)

    7th Sep 2026  

If you rely on TPM 2.0 measurement registers for secure boot, full disk encryption (such as systemd-cryptenroll or clevis), or system integrity monitoring, you know that unexpected changes in Platform Configuration Registers (PCRs) can break unsealing or signal unauthorized firmware/bootloader alterations. more informations <https://g.raffi.wtf/e7OKN>

To keep track of state changes over time and receive real-time desktop notifications when register states drift, I put together a simple, lightweight Bash utility: `pcr-watch.sh`.

#### What the Script Does

`pcr-watch.sh` automates the process of reading specified TPM 2.0 PCR registers, logging their state history, and alerting you via a GUI popup if the register values differ from the last recorded run.

Key features include:

- **Targeted Monitoring:** Defaults to sha256:7,11 (commonly used for Secure Boot state and unified kernel image/boot state), but configurable via environment variables.
- **XDG State Directory Compliance:** Stores execution logs in `${XDG_STATE_HOME:-$HOME/.local/state}/pcr-watch/pcrread.log`.
- **State Drift Detection:** Compares current tpm2\_pcrread output against the previous execution's log entry.
- **Multi-Backend GUI Alerts:** Automatically detects and fallback-renders a dialog box using yad, zenity (with Cairo rendering fallback for Wayland/GTK compatibility), or classic xmessage.
- **Automatic Log Rotation:** Keeps only the last 10 execution records using an awk-based sliding window to prevent unbounded log growth.

The Script:

```
#!/usr/bin/env bash
# Created by ♞ Raffael.Willems
set -euo pipefail

# Def
LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/pcr-watch/pcrread.log"
MAX_RECORDS=10
PCR_SPEC="${PCR_SPEC:-sha256:7,11}"
TPM_CMD=(tpm2_pcrread "$PCR_SPEC")
GTK_TITLE="TPM PCR change detected"

mkdir -p "$(dirname "$LOG_FILE")"

current_output="$(${TPM_CMD[@]} 2>&1)"
current_block="=== $(date -Is) ===\n${current_output}"

# Get last Entry from logfile
last_block=""
if [[ -f "$LOG_FILE" ]]; then
  last_block="$(awk '
    /^=== / {
      if (block != "") last = block;
      block = $0 ORS;
      next;
    }
    { block = block $0 ORS }
    END {
      if (block != "") last = block;
      printf "%s", last;
    }
  ' "$LOG_FILE")"
fi

# remove the header
last_body="$(printf '%s\n' "$last_block" | tail -n +2)"

# check against the current output and if differs inform!
if [[ -n "$last_block" && "$current_output" != "$last_body" ]]; then
  message="The TPM PCR output changed.\n\nLast recorded block:\n${last_body:0:1200}\n\nCurrent output:\n${current_output:0:1200}"

  if command -v yad >/dev/null 2>&1; then
    yad --text-info --back=black --fore=red --title="$GTK_TITLE" --width=650 --height=300 --filename=<(printf '%b' "$message") || true
  elif command -v zenity >/dev/null 2>&1; then
    GSK_RENDERER=cairo zenity --warning --title="$GTK_TITLE" --width=650 --height=300 --no-wrap --text="$message" || true
  elif command -v xmessage >/dev/null 2>&1; then
    xmessage "$message" || true
  fi
fi

printf '%b\n\n' "$current_block" >> "$LOG_FILE"

# keep only 10 Records
tmp_file="$(mktemp)"
awk -v max="$MAX_RECORDS" '
  BEGIN { count = 0 }
  /^=== / { count++ }
  { lines[count] = lines[count] $0 ORS }
  END {
    start = count - max + 1;
    if (start < 1) start = 1;
    for (i = start; i <= count; i++) printf "%s", lines[i];
  }
' "$LOG_FILE" > "$tmp_file"
mv "$tmp_file" "$LOG_FILE"
```

#### How It Works

1. **Execution & Snapshot:**The script runs `tpm2_pcrread` using the bank and registers defined in $PCR\_SPEC (default: sha256:7,11). It stamps the output with an ISO-8601 timestamp (`date -Is`).
2. **Log Parsing & Comparison:**Using awk, the script reads the existing log file and extracts the most recent block delimited by `=== <timestamp> ===`. It strips the timestamp header and compares the raw output of the last run against the output of the current run.
3. **Alerting on Drift:**If a difference is detected A notification pops up on desktop start. ![RWill_2026-09-07_09-01-29.png](https://willifix.net/images/9/f/a/3/a/9fa3a6adf9295dbac4b767f5170cbd787be23452-rwill2026-09-0709-01-29.png)
4. **Log Truncation:**After logging the new output, an awk script parses all blocks in the log file and retains only the last 10 entries (`MAX_RECORDS`), keeping disk usage minimal and history relevant.

#### Usage & Automation

- make the script executable (`chmod +x pcr-watch.sh`)
- execute it on startup of your Desktop Environment. I use sway and have added it with `exec pcr-watch.sh`
- if you run the script as user check that the user is in the needed tss group. i have also created a polkit policy

/etc/polkit-1/rules.d/49-allow-user-tpm2-pcrread.rules

```
polkit.addRule(function(action, subject) {
  if (subject.user == "bigfreak" && action.id == "org.freedesktop.policykit.exec" && action.lookup("program") == "/usr/bin/tpm2_pcrread") {
    return polkit.Result.YES;
  }
});
```

 [ Previous Post](https://willifix.net/blog/fix-long-loading-of-zathura)

---

## Navigation

- Parent: [Blog](https://willifix.net/blog.md)
- Next: [fix long loading of zathura](https://willifix.net/blog/fix-long-loading-of-zathura.md)
