mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
openVerified copied every plugin binary into the instance's private directory before exec, digest or not. The copy is what makes "what was checked" and "what runs" one object, and it stays for a plugin with a Sum. Without one nothing is checked, so the copy kept nothing equal: the bytes at the path when it was taken are the bytes that ran. It cost a full write of the binary on every lazy start (Hanzo's per-app plugins are 24-145 MB), on whatever disk Dir sits on, and gave every instance its own page cache. A plugin with no Sum is now opened read-only and executed through that descriptor. An embedded Bin is written once, under ForkLock (place), and executed from there; before, it was written and then copied. Benchmark_OpenBinary (the file handling alone, Apple M4 Max, APFS, go1.27.1, -count=6, median): size before after 16 MB 3.94 ms 0.026 ms 48 MB 12.11 ms 0.026 ms 144 MB 36.66 ms 0.026 ms Benchmark_ColdStart (Load lazy, first request through the host, test plugin padded to size, same host). On macOS the delta is far larger than the copy itself: exec of a just-written file costs it more than the bytes do. Expect the Linux gain to be the copy above, not this: binary before after 15.8 MB 156.8 ms 7.8 ms 47.8 MB 294.8 ms 7.1 ms 143.8 MB 767.2 ms 7.3 ms Co-authored-by: Hanzo Dev <[email protected]>
149 lines
6.0 KiB
Go
149 lines
6.0 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 plugin executable held open read-only. 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 opens the executable a plugin runs from src.
|
|
//
|
|
// With a digest (want non-empty) it copies src into priv — a directory only the
|
|
// host can write — and refuses unless the COPY's SHA-256 matches. The copy is
|
|
// the point there. 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.
|
|
//
|
|
// Without a digest nothing is checked, so there is no checked object for a copy
|
|
// to keep equal to what runs: the bytes at src when a copy would be taken are
|
|
// the bytes that run either way. src is opened read-only and executed through
|
|
// that descriptor. A start then writes nothing — where the copy wrote the whole
|
|
// binary, tens to hundreds of megabytes, every time a lazy plugin woke — and
|
|
// every instance of one binary shares its pages.
|
|
func openVerified(src, priv, name, want string) (*binary, error) {
|
|
if want == "" {
|
|
f, err := os.Open(src)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &binary{f: f, name: name}, nil
|
|
}
|
|
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
|
|
}
|
|
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()
|
|
return place(dst, in, name)
|
|
}
|
|
|
|
// place writes an executable's bytes from r to dst, a new file 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 file is written; the exec that follows runs with no writer left.
|
|
func place(dst string, r io.Reader, name string) error {
|
|
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, r); 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()
|
|
}
|