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.
This commit is contained in:
2026-09-07 15:06:52 -07:00
parent 88b93b8fae
commit 731d498dad
5 changed files with 275 additions and 5 deletions
+17 -5
View File
@@ -655,11 +655,16 @@ func start(spec Plugin) (*instance, error) {
if err != nil {
return nil, err
}
bin := spec.Path
// Every source lands as a path, and then the SAME check runs over all of
// them. A binary on disk is not more trustworthy than a downloaded one; it
// is the same bytes with somebody else having done the download, so
// verifying only the network source checks the case that happens to look
// dangerous rather than the case that is.
path := spec.Path
switch {
case len(spec.Bin) > 0:
bin = filepath.Join(dir, spec.Name)
if err := os.WriteFile(bin, spec.Bin, 0o700); err != nil {
path = filepath.Join(dir, spec.Name)
if err := os.WriteFile(path, spec.Bin, 0o700); err != nil {
_ = os.RemoveAll(dir)
return nil, fmt.Errorf("write binary: %w", err)
}
@@ -667,13 +672,20 @@ func start(spec Plugin) (*instance, error) {
// Cached beside dir rather than inside it, so the download survives
// this instance and a reload or restart reuses it.
var err error
bin, err = fetch(spec)
path, err = fetch(spec)
if err != nil {
_ = os.RemoveAll(dir)
return nil, err
}
}
bin, err := openVerified(path, dir, spec.Name, spec.Sum)
if err != nil {
_ = os.RemoveAll(dir)
return nil, err
}
defer bin.Close() // the child keeps its own copy of the descriptor
sock := socketIn(dir, spec.Name)
// The child opens sock+".http" for the upgrade listener too, so that is the
// longest address in play and the one that has to fit. Checking here turns a
@@ -683,7 +695,7 @@ func start(spec Plugin) (*instance, error) {
_ = os.RemoveAll(dir)
return nil, err
}
cmd := exec.Command(bin, spec.Args...)
cmd := bin.exec(spec.Args)
cmd.Env = append(append(os.Environ(), spec.Env...), AddrEnv+"="+sock)
// The child's output is the operator's only window into it, so it goes where
// the host's own does rather than being swallowed — but tagged, because an
+119
View File
@@ -0,0 +1,119 @@
package zip
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
)
// A plugin host is the one place a service becomes an arbitrary-code-execution
// vector: whatever binary it starts verifies blocks, signs, or serves, with the
// host's authority. So the binary a host runs has to be the binary it decided
// to run, and that is two separate claims — WHICH program (identity) and WHICH
// BUILD of it (integrity) — that must not be collapsed into one value.
//
// Identity is stable across versions, because the thing referring to it does
// not get to change: a chain's vmID is written into a transaction that is
// already signed and already in the chain, so an identity derived from the
// build would stop resolving on the next rebuild.
//
// Integrity is per build, and it is what Sum states. Verifying it is worth
// almost nothing unless three things hold, which is what this file is for:
//
// 1. Every source is verified, not just the one that arrives over a network.
// 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.
// 2. The bytes verified are the bytes executed. Hashing a path and then
// executing that path leaves a window in which the file can change, and
// the window is trivially winnable because starting a plugin is an
// observable event. Holding a descriptor is NOT enough on its own: a
// write to the same inode is seen through an already-open descriptor, so
// only a rename is defended against and the cheaper attack is not. The
// binary is therefore COPIED into a directory only the host can write,
// and it is that copy which is hashed and executed.
// 3. A mismatch refuses. A host that logs and carries on has performed the
// check and kept the risk.
//
// What this file cannot decide is where a trustworthy Sum comes from. A digest
// in the same config an attacker just edited proves nothing; the anchor has to
// be something they do not control. For a public permissionless chain the
// network already agrees on things by construction, so the digest belongs in
// consensus — every validator then verifies the same statement without
// trusting an operator, a registry or a URL.
// binary is a private, verified copy of a plugin executable, held open. It is
// executed THROUGH this descriptor, so no path is resolved a second time.
type binary struct {
f *os.File
name string // for argv[0] and messages
}
// openVerified copies src into priv — a directory only the host can write —
// and, when want is non-empty, refuses unless the COPY's SHA-256 matches.
//
// The copy is the point. Verifying the original in place and then running it
// leaves whoever can write that file free to change it afterwards, and holding
// a descriptor does not close that: a write to the SAME INODE is visible
// through an already-open descriptor, so only a rename would be defended
// against and the cheaper attack would not be. Copying first moves the bytes
// somewhere the attacker has no write access, so "what was checked" and "what
// runs" are one object for the whole of its life.
func openVerified(src, priv, name, want string) (*binary, error) {
staged := filepath.Join(priv, name+".bin")
if err := stage(src, staged, name); err != nil {
return nil, err
}
// Reopened READ-ONLY, and this is the descriptor that is both hashed and
// executed. It has to be read-only: Linux refuses to execute a file that
// anyone holds open for writing, so keeping the staging handle would trade
// one failure for another.
f, err := os.Open(staged)
if err != nil {
return nil, err
}
if want != "" {
sum := sha256.New()
if _, err := io.Copy(sum, f); err != nil {
f.Close()
return nil, fmt.Errorf("plugin %s: read for verification: %w", name, err)
}
if got := hex.EncodeToString(sum.Sum(nil)); got != want {
f.Close()
return nil, fmt.Errorf("plugin %s: sha256 %s does not match Sum %s — refusing to run it", name, got, want)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
return nil, err
}
}
return &binary{f: f, name: name}, nil
}
// stage copies src to dst, which lives in a directory only the host can write.
func stage(src, dst, name string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o700)
if err != nil {
return fmt.Errorf("plugin %s: stage binary: %w", name, err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return fmt.Errorf("plugin %s: stage binary: %w", name, err)
}
return out.Close()
}
func (b *binary) Close() error {
if b == nil || b.f == nil {
return nil
}
return b.f.Close()
}
+25
View File
@@ -0,0 +1,25 @@
//go:build linux
package zip
import (
"os"
"os/exec"
)
// exec points cmd at the ALREADY-VERIFIED descriptor rather than at a path.
//
// Linux resolves /proc/self/fd/N in the child between fork and execve, so the
// image that runs is the one whose bytes were hashed — not whatever the path
// names by then. Verifying a path and then executing that path is a check an
// attacker can step around by replacing the file in between, and they get to
// choose when, because starting a plugin is an observable event.
//
// argv[0] stays the plugin's name: the descriptor is how the image is found,
// not what the program should believe it is called.
func (b *binary) exec(args []string) *exec.Cmd {
cmd := exec.Command("/proc/self/fd/3", args...)
cmd.Args = append([]string{b.name}, args...)
cmd.ExtraFiles = []*os.File{b.f} // becomes fd 3 in the child
return cmd
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !linux
package zip
import "os/exec"
// exec falls back to executing by path where a descriptor cannot be executed.
// The digest is still checked, so a binary that does not match never starts;
// what is missing is only the guarantee that nothing replaced it between the
// check and the start.
func (b *binary) exec(args []string) *exec.Cmd {
cmd := exec.Command(b.f.Name(), args...)
cmd.Args = append([]string{b.name}, args...)
return cmd
}
+99
View File
@@ -0,0 +1,99 @@
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()
}