Files
zip/verify_test.go
T
zeekay 731d498dad Verify every plugin binary, and run the bytes that were verified
A plugin host is where a service becomes an arbitrary-code-execution vector:
whatever it starts runs with the host's authority — verifying blocks, signing,
serving. Sum existed but only gated URL, so the case that looked dangerous was
checked and the case that is was not. A binary read off disk is not more
trustworthy than a downloaded one; it is the same bytes with the download
already done by somebody else.

Now every source takes the same path, and the check is worth something because
what runs is what was checked:

  - The binary is COPIED into a directory only the host can write, and the copy
    is hashed and executed. Verifying in place and then running leaves whoever
    can write that file free to change it afterwards, and starting a plugin is
    an observable event, so they get to choose when.

  - Holding a descriptor to the original would not have closed that, which the
    test proves rather than assumes: a rename swaps the inode, but a WRITE keeps
    it, and a write is visible through an already-open descriptor. Only the copy
    puts the bytes out of reach.

  - The copy is executed through its own descriptor (/proc/self/fd on Linux), so
    no path is resolved a second time. It is reopened read-only first — Linux
    refuses to execute a file anyone holds open for writing.

  - A mismatch refuses. A host that logs and carries on has performed the check
    and kept the risk.

What this cannot decide is where a trustworthy Sum comes from. A digest in the
same config an attacker just edited proves nothing, so the anchor has to be
something they do not control. For a network that already agrees on things by
construction, that is consensus.
2026-09-07 15:06:52 -07:00

100 lines
3.2 KiB
Go

package zip
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func writeBin(t *testing.T, dir, name, body string) (string, string) {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte(body))
return p, hex.EncodeToString(sum[:])
}
// A binary read off disk gets the same check as one off a network. Verifying
// only the download checks the case that looks dangerous rather than the one
// that is: an attacker who can write the plugin directory has already done the
// download for you.
func TestDiskBinaryIsVerified(t *testing.T) {
dir := t.TempDir()
path, sum := writeBin(t, dir, "svc", "#!/bin/true\n")
b, err := openVerified(path, t.TempDir(), "svc", sum)
if err != nil {
t.Fatalf("matching digest must be accepted: %v", err)
}
b.Close()
if _, err := openVerified(path, t.TempDir(), "svc", strings.Repeat("0", 64)); err == nil {
t.Fatal("a binary whose digest does not match Sum must be refused, not logged")
} else if !strings.Contains(err.Error(), "refusing to run it") {
t.Fatalf("refusal must say so plainly: %v", err)
}
}
// Changing the original after it is verified must not change what runs — and
// the two ways to change it are different. A rename swaps the inode; a write
// keeps it, and a write IS visible through an already-open descriptor. Holding
// a descriptor to the original would defend against the first and not the
// second, which is why the bytes are copied out of reach first.
func TestSwapAfterVerifyDoesNotChangeWhatRuns(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("executing a descriptor is Linux-only; elsewhere the digest still gates the start")
}
dir := t.TempDir()
path, sum := writeBin(t, dir, "svc", "original")
b, err := openVerified(path, t.TempDir(), "svc", sum)
if err != nil {
t.Fatal(err)
}
defer b.Close()
// In place, same inode: the cheap attack, and the one a held descriptor
// on the ORIGINAL would not have caught.
if err := os.WriteFile(path, []byte("substituted"), 0o700); err != nil {
t.Fatal(err)
}
got := make([]byte, len("original"))
if _, err := b.f.ReadAt(got, 0); err != nil {
t.Fatal(err)
}
if string(got) != "original" {
t.Fatalf("the descriptor read %q; it must still see the verified bytes", got)
}
cmd := b.exec(nil)
if cmd.Path != "/proc/self/fd/3" {
t.Fatalf("cmd.Path = %q, want the verified descriptor", cmd.Path)
}
if len(cmd.ExtraFiles) != 1 || cmd.ExtraFiles[0] != b.f {
t.Fatal("the verified descriptor must be the one passed to the child")
}
if len(cmd.Args) == 0 || cmd.Args[0] != "svc" {
t.Fatalf("argv[0] = %v, want the plugin's name — the descriptor is how the image is found, not what it is called", cmd.Args)
}
}
// An unset Sum still loads: a host that has not been given a digest is not
// told a lie about having checked one. What must never happen is a Sum that is
// set and not enforced.
func TestNoSumStillLoads(t *testing.T) {
dir := t.TempDir()
path, _ := writeBin(t, dir, "svc", "whatever")
b, err := openVerified(path, t.TempDir(), "svc", "")
if err != nil {
t.Fatalf("a plugin with no declared digest must still start: %v", err)
}
b.Close()
}