Files
zip/verify.go
T
zeekayandHanzo Dev a99640c25d Stage a plugin binary under ForkLock, so starts at once all exec
A child forked by one start holds a copy of every descriptor the host has
open until it execs, including the write handle another start is copying
its binary through. Linux refuses to exec a file anyone holds open for
writing, so plugins started together failed each other with "text file
busy": 2 of 16 in TestStart_TogetherAllRun without this, none with it.

Holding the read side of syscall.ForkLock while the copy is written keeps
every fork out of that window.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-29 13:39:03 -07:00

130 lines
5.2 KiB
Go

package zip
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
)
// 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.
//
// The write handle is open under syscall.ForkLock. A child forked by another
// goroutine holds a copy of every descriptor this process has open until it
// execs, O_CLOEXEC or not, and Linux refuses to exec a file anyone holds open for
// writing. So two plugins starting at once could each fail the other's exec with
// "text file busy". Holding the read side of ForkLock keeps any fork out while
// the copy is written; the exec that follows runs with no writer left.
func stage(src, dst, name string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
syscall.ForkLock.RLock()
defer syscall.ForkLock.RUnlock()
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()
}