A TPM bouncer in eBPF

A TPM (Trusted Platform Module) is a security chip, and in a VM usually an emulated one. It records measurements of what the machine booted in registers called PCRs, can sign a report of those values so another machine can check them (remote attestation), and has a random number generator and a small amount of storage. On Linux, programs reach all of it through /dev/tpm0 or /dev/tpmrm0.

I use the TPM to protect keys. In go-tpm-tls, a Go program signs its TLS handshakes with a key the TPM generated. The private key never exists in plaintext outside the TPM: not in the process’s memory, not in a core dump, not in a file on disk. The process gets signatures back, never the key.

That stops the key from being copied, but not from being used by another program on the same machine. Any process running as root can open the TPM devices, and so can any process in the tss group, the group Ubuntu gives access to the TPM, through /dev/tpmrm0. Unless a key is protected by its own password or policy, opening the device is all it takes to use it. Device permissions are based on users and groups, so there’s no way to say that only one particular program may use the TPM.

So I wrote tpmlsm, a small eBPF program that only lets the programs on its list open the TPM. The list is compiled into the binary, and the kernel refuses everything else, root included.

The code is at tpmlsm. Everything below ran on Ubuntu 24.04.5 with kernel 6.8.0-146-generic (arm64), Go 1.26 and cilium/ebpf v0.22.0.

hash, not PID

tpmlsm identifies a binary by its file and the SHA-256 of that file, not by its PID or name. Process IDs get reused, and a process can call itself anything. The allowlist names each binary’s path and hash, and the loader, the tpmlsm command that puts the eBPF programs into the kernel, looks up which file each path is. A hash is a fingerprint of the file’s exact bytes, so if a listed file changes, even by a single byte, it’s denied. A copy somewhere else is a different file, and it isn’t on the list at all.

three hooks

tpmlsm is built on BPF LSM. eBPF lets you load small programs into the running kernel, which checks them for safety first, so there’s no kernel module to write. LSM stands for Linux Security Modules: hooks at the points where the kernel decides whether to allow something, such as starting a program or opening a file. At each hook the kernel asks every active security module, and refuses as soon as one of them says no. On Ubuntu, AppArmor is already one of those modules. BPF LSM adds your own eBPF programs to the list, so tpmlsm runs next to AppArmor rather than replacing it, and either of them can refuse.

tpmlsm keeps one byte for each process that started one of the listed programs: 1 if the hash matched, 0 if it didn’t. Every other process has no byte, which counts as no. It lives in a task storage map, a BPF map type that attaches a value to a process inside the kernel, and the kernel frees it when the process exits. Nothing is written to disk. Three hooks work with that byte. Exec sets it, fork and clone copy it, and open reads it.

execlisted file? hash itfork / clonechild inherits itopen /dev/tpm*not allowed: EPERMsetcopycheckallowed to use the TPM? yes / no, per process
Exec decides, fork passes it on, open checks it.

Every time a process starts a program with exec, the first hook runs. Most programs aren’t on the list, so it first checks whether the new program’s file is one of the listed ones, by its inode, the number the filesystem uses to identify a file, which costs a single map lookup. If it isn’t, the hook is done. Only for a listed file does it ask IMA, the Integrity Measurement Architecture, for the SHA-256 of the file. IMA is the part of the kernel that hashes files, normally to keep a record of what has run, and the hook reuses it rather than hashing the file itself. The byte becomes 1 only if that hash is in allowed_hashes:

SEC("lsm.s/bprm_committed_creds")
int BPF_PROG(on_exec, struct linux_binprm *bprm)
{
    struct task_struct *task = bpf_get_current_task_btf();
    struct inode *inode = bprm->file->f_inode;
    struct file_id id = {
        .ino = inode->i_ino,
        .dev = inode->i_sb->s_dev,
    };

    if (!bpf_map_lookup_elem(&allowed_files, &id)) {
        // Not allowlisted: revoke any verdict inherited from the parent.
        u8 *ok = bpf_task_storage_get(&task_ok, task, 0, 0);
        if (ok)
            *ok = 0;
        return 0;
    }

    struct digest h = {};
    long algo = bpf_ima_file_hash(bprm->file, h.b, sizeof(h.b));

    u8 *ok = bpf_task_storage_get(&task_ok, task, 0,
                                  BPF_LOCAL_STORAGE_GET_F_CREATE);
    if (!ok)
        return 0;

    // Accept SHA-256 only; IMA may be configured with another algorithm.
    *ok = (algo == HASH_ALGO_SHA256 &&
           bpf_map_lookup_elem(&allowed_hashes, &h)) ? 1 : 0;
    return 0;
}

allowed_files and allowed_hashes are plain hash maps in the kernel, filled by the loader from the allowlist compiled into it:

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 64);
    __type(key, struct digest);
    __type(value, u8);
} allowed_hashes SEC(".maps");

The second hook covers threads and child processes, which don’t go through exec. A new thread, or a child created with fork, is a new task in the kernel, and its task storage starts out empty. Without this hook a new thread of an allowed program would have no byte and be denied. That matters for Go programs such as go-tpm-tls, because the Go runtime spreads goroutines over several OS threads, and the thread that opens the TPM is not necessarily the one that started the program. The hook copies a 1 from the parent to the new task:

SEC("lsm/task_alloc")
int BPF_PROG(on_fork, struct task_struct *task, unsigned long clone_flags, int ret)
{
    if (ret)
        return ret;

    u8 *parent = bpf_task_storage_get(&task_ok, bpf_get_current_task_btf(), 0, 0);
    if (!parent || !*parent)
        return 0;

    u8 *child = bpf_task_storage_get(&task_ok, task, 0,
                                     BPF_LOCAL_STORAGE_GET_F_CREATE);
    if (child)
        *child = 1;
    return 0;
}

A child that then runs another program goes through the first hook again, so an allowed process that runs cat doesn’t pass its permission on to cat.

The third hook runs on every file open and ignores everything except the TPM devices. For /dev/tpm0 and /dev/tpmrm0 it allows the open only when the byte is 1. A process without a byte, such as one that was already running when tpmlsm loaded, is denied until it’s restarted:

SEC("lsm/file_open")
int BPF_PROG(tpm_open, struct file *file, int ret)
{
    if (ret)
        return ret;

    // Direct read rather than BPF_CORE_READ, which uses bpf_probe_read and is
    // rejected under lockdown=confidentiality.
    u32 dev = file->f_inode->i_rdev;
    if (!bpf_map_lookup_elem(&tpm_devs, &dev))
        return 0;

    u8 *ok = bpf_task_storage_get(&task_ok, bpf_get_current_task_btf(), 0, 0);
    int allow = ok && *ok;
    ...
    return allow ? 0 : -EPERM;
}

The part left out as ... sends an event to the loader, which logs who was allowed and who wasn’t.

The loader tells the BPF program which devices are the TPM by putting their device numbers in tpm_devs. A device number is a major and a minor number packed into one integer, and userspace and the kernel pack them differently. stat, the system call programs use to read a file’s details, reports /dev/tpm0 as 0xae0, while the kernel’s i_rdev is 0xa000e0. With the stat value in the map the lookup would never match, and the open hook would never block anything, so the loader converts to the kernel’s layout first. It does the same for the device of each allowlisted file:

func kdev(dev uint64) uint32 {
	return unix.Major(dev)<<20 | unix.Minor(dev)
}

That works on ext4, the filesystem Ubuntu uses by default, where I tested. btrfs, another common Linux filesystem, reports a different device number for each subvolume, so there nothing matches and every TPM open is denied.

the allowlist

The allowlist is compiled into the loader. allowlist.txt has one line per allowed binary, as sha256sum prints it: the hash, then the file’s path. Go’s embed puts it in the binary at build time:

//go:embed allowlist.txt
var Allowlist []byte

The list is compiled in rather than read from a file, because tpmlsm can’t protect a file. It’s there to turn away root, and root can edit any file on the machine, so a list read from disk is only as good as whoever last wrote to it. Root can replace the tpmlsm binary as well, but then there’s one file to check instead of two: whoever verifies which tpmlsm started, as at the end of this post, has verified its list with it.

It also matches how the allowed programs get shipped. Build tpmlsm in the same pipeline as the binaries it allows, and the list always matches what’s deployed; anything installed some other way has a hash tpmlsm has never seen. The cost is that every update to an allowed binary, such as a security fix to tpm2-tools, needs a new tpmlsm and a reboot before the updated binary may use the TPM again.

Compiling the list in protects it on disk, but once tpmlsm is running the list lives in kernel maps, and root can normally write to any BPF map with bpftool, the standard command-line tool for eBPF, for example to add the hash of a program of its own. So after filling the maps, the loader freezes them. A frozen map can’t be changed from outside the kernel anymore, by anyone, while the eBPF programs can still read it:

if err := objs.AllowedHashes.Freeze(); err != nil {
	return err
}

Adding an entry afterwards, as root, fails:

$ sudo bpftool map update id 22 key hex ... value hex 01
Error: update failed: Operation not permitted

loading it

The loader doesn’t have to stay running. An eBPF program is normally removed when the process that loaded it exits, so the loader pins each one, which leaves a reference in /sys/fs/bpf/tpmlsm. That directory is on bpffs, a filesystem that exists only in memory, so a reboot removes the pins and ends enforcement. tpmlsm has no command to remove itself, so a reboot is also the only way to change the rules. Closing the link after pinning it doesn’t detach it, because the pin still holds it:

func attach(name string, prog *ebpf.Program) error {
	l, err := link.AttachLSM(link.LSMOptions{Program: prog})
	if err != nil {
		return err
	}
	defer l.Close()
	return l.Pin(filepath.Join(pinDir, name))
}

BPF LSM has to be enabled when the kernel boots, and on stock Ubuntu it isn’t, because /sys/kernel/security/lsm doesn’t include bpf. In that state the programs attach without any error and then never run, so nothing is enforced. tpmlsm reads that file first and refuses to start:

BPF LSM is not enabled (active: lockdown,capability,landlock,yama,apparmor); add bpf to lsm= on the kernel command line

To enable it, take the list from that file, add bpf at the end and pass it to the kernel as lsm=. On Ubuntu that means a file in /etc/default/grub.d/, then sudo update-grub and a reboot:

GRUB_CMDLINE_LINUX_DEFAULT="$GRUB_CMDLINE_LINUX_DEFAULT lsm=lockdown,capability,landlock,yama,apparmor,bpf ima_hash=sha256"

After the reboot, /sys/kernel/security/lsm should end in bpf.

The hash algorithm matters as well. The first hook only accepts a SHA-256 from IMA, and the ima_hash boot option chooses which algorithm IMA uses. Booted with ima_hash=sha1, even the allowed program was denied. Ubuntu’s kernel defaults to SHA-256, so the ima_hash=sha256 in the line above only makes that explicit.

build it yourself

You need a Linux machine or VM with a TPM (a software one is fine) and BPF LSM enabled as described above. The compiled eBPF program is checked into the repo, so building only takes Go and make:

sudo apt-get install -y golang-go make tpm2-tools

Ubuntu ships Go 1.22. The repo requires 1.26, which go downloads automatically on the first build.

Clone the repository:

git clone https://github.com/bschaatsbergen/tpmlsm && cd tpmlsm

Add the program you want to allow, as sha256sum prints it with the file’s real path, and build:

sha256sum "$(readlink -f /usr/bin/tpm2_getrandom)" >> allowlist.txt
make

On its own, tpmlsm loads, pins and exits. With -watch it stays in the foreground and logs every attempt to open the TPM:

sudo ./tpmlsm -watch

In a second shell, run the allowed program and one that isn’t, both as root. tpm2_getrandom gets its random bytes and cat is refused the TPM device. cat still works on every other file; tpmlsm only refuses opens of /dev/tpm0 and /dev/tpmrm0:

$ sudo tpm2_getrandom --hex 8 -T device:/dev/tpmrm0
86a32756828e8001
$ sudo cat /dev/tpm0
cat: /dev/tpm0: Operation not permitted

The loader logs each attempt, an ALLOW for tpm2_getrandom and a DENY for cat:

2026/10/04 22:35:32 allow sha256=97e1fc0f22d92de63204eec74076a003d1ca820d1d6db3c8fde3227f1ca7f4fc /usr/bin/tpm2
2026/10/04 22:35:33 enforcing until reboot; pinned to /sys/fs/bpf/tpmlsm
2026/10/04 22:35:33 watching, Ctrl-C to stop (enforcement stays)
2026/10/04 22:35:34 ALLOW pid=1130 comm=tpm2_getrandom dev=252:65536
2026/10/04 22:35:34 DENY  pid=1132 comm=cat dev=10:224

Ctrl-C stops the logging, not the enforcement, which lasts until the next reboot.

what it doesn’t stop

Allowing a binary allows everything it can do. Allow python3 and every Python script can use the TPM. On Ubuntu every tpm2_* command is a symlink, a file that points to another file, to a single tpm2 binary that picks its behaviour from the name it was started with, so allowing tpm2_getrandom allows all of them; tpm2_readclock got through in testing.

tpmlsm checks the binary’s own file, not the shared libraries it loads, and anyone who can start an allowed program can make it load extra code. The LD_PRELOAD environment variable tells Linux to load an extra library into a program when it starts. Starting the allowed tpm2_getrandom with it pointing at a small library of my own ran that library inside the allowed process, and the library could open the TPM. Root can do the same to every program at once by listing a library in /etc/ld.so.preload. A statically linked binary has everything it needs built in and loads no shared libraries, so static binaries, such as a Go program built with CGO_ENABLED=0, are the ones worth allowing.

Whoever controls the kernel can switch tpmlsm off, by booting another kernel, loading a module or deleting the pins. Secure Boot, which only boots a signed kernel, and kernel lockdown, which stops even root from changing the running kernel, make that harder, and tpmlsm loads under both lockdown modes.

Secure Boot and lockdown don’t stop root from deleting the pins, though. The kernel can close that too, with more hooks that refuse to delete the pins, unmount bpffs or detach the programs, so that a reboot is the only way to remove tpmlsm. That’s what you should add to properly harden it.

Working notes. If something here is wrong, tell me.