diff --git a/.changes/unreleased/+mapped-code-proof.yaml b/.changes/unreleased/+mapped-code-proof.yaml new file mode 100644 index 00000000..e6ce3c3a --- /dev/null +++ b/.changes/unreleased/+mapped-code-proof.yaml @@ -0,0 +1,2 @@ +kind: Security +body: Require exact protected-code and mount evidence before internal mapped startup release. Public mapped execution remains gated on production admission and runtime qualification. diff --git a/internal/dockerdeploy/mapped_sandbox_proof.go b/internal/dockerdeploy/mapped_sandbox_proof.go new file mode 100644 index 00000000..c61cd7eb --- /dev/null +++ b/internal/dockerdeploy/mapped_sandbox_proof.go @@ -0,0 +1,132 @@ +package dockerdeploy + +import ( + "context" + "fmt" + "path" + "slices" + "strconv" + "strings" + "unicode/utf8" + + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/mappedlaunch" +) + +// The trusted F effect owner supplies the immutable-image code closure and +// fresh observations of the inhibited instance. This is not operator input or +// a qualification flag. No production observation provider is supplied by E. +// The call runs inside the existing admission writer: it must not reacquire +// OperationLock. Build-lock reads and expected-plan construction precede it. +type mappedSandboxEvidenceV1 interface { + MappedSandbox(context.Context, deploy.StateV1, mappedSandboxRequestV1) (mappedSandboxProofV1, error) +} + +type mappedSandboxRequestV1 struct { + Binding mappedlaunch.Binding `json:"binding"` + Container string `json:"container"` + PID string `json:"pid"` + RuntimePolicy canonical.Digest `json:"runtime_policy"` + NetworkPolicy canonical.Digest `json:"network_policy"` + RequiredCode []string `json:"required_code"` +} + +type mappedCodePathV1 struct { + Path, Resolved string +} + +type mappedSandboxMountV1 struct { + Target, Resolved string +} + +type mappedSandboxProofV1 struct { + Request canonical.Digest + NetworkPolicy canonical.Digest + ReadOnlyRoot bool + // Code is F's complete closure from the pinned image, including loaders, + // interpreters and dependency search paths. ObservedCode is its live view. + Code, ObservedCode []mappedCodePathV1 + // Complete non-root mounts, including resolved symlink destinations. + Mounts []mappedSandboxMountV1 +} + +func (a *ownedMappedWaiterV1) sandboxRequest() (mappedSandboxRequestV1, error) { + p := a.prepared + if p.ops.Operation == nil || p.ops.ValidateProfileOwner == nil || p.state.Current == nil { + return mappedSandboxRequestV1{}, fmt.Errorf("mapped sandbox requires selected build evidence") + } + lock, found, err := p.ops.Operation.ReadBuildLock(p.state.Current.BuildLockDigest, p.ops.ValidateProfileOwner) + if err != nil || !found { + return mappedSandboxRequestV1{}, fmt.Errorf("mapped sandbox build evidence unavailable: %v", err) + } + policy, err := deploy.RuntimePolicyDigestV1(lock.RuntimePolicy) + if err != nil || policy != p.state.Current.RuntimePolicyDigest || lock.FinalImage.ConfigDigest != a.binding.Image || + p.state.Current.BuildLockDigest != a.binding.Generation || lock.FinalImage.Digest != p.state.Current.ImageDigest || + lock.FinalImage.RootFSSubject != p.state.Current.RootFSSubject { + return mappedSandboxRequestV1{}, fmt.Errorf("mapped sandbox generation or image changed") + } + body, _, err := podmanApplicationCreateRequest(p.spec, p.namespace) + separator := slices.Index(body.Cmd, "--") + if err != nil || separator < 0 || separator+1 >= len(body.Cmd) || !mappedCodeAbsoluteV1(body.Cmd[separator+1]) { + return mappedSandboxRequestV1{}, fmt.Errorf("mapped sandbox executable is unavailable") + } + required := []string{deploy.ApplicationStartupVerifierPathV1, body.Cmd[separator+1]} + for _, protected := range lock.RuntimePolicy.ProtectedPaths { + required = append(required, protected.Path) + } + slices.Sort(required) + // ReadBuildLock already validated the closed string-only network policy. + network, _ := canonical.Sum("mapped-network", "v1", lock.RuntimePolicy.Network) + return mappedSandboxRequestV1{a.binding, a.container, strconv.Itoa(a.pid), policy, network, slices.Compact(required)}, nil +} + +func verifyMappedSandboxV1(ctx context.Context, state deploy.StateV1, evidence deploy.IdentityMappingEvidenceV1, request mappedSandboxRequestV1) error { + owner, ok := evidence.(mappedSandboxEvidenceV1) + if !ok || ctx.Err() != nil { + return fmt.Errorf("mapped sandbox observation provider is unavailable") + } + digest, err := canonical.Sum("mapped-sandbox", "v1", request) + if err != nil { + return err + } + copy := request + copy.RequiredCode = slices.Clone(request.RequiredCode) + proof, err := owner.MappedSandbox(ctx, state, copy) + if err != nil { + return fmt.Errorf("mapped sandbox observation failed: %w", err) + } + if ctx.Err() != nil || proof.Request != digest || proof.NetworkPolicy != request.NetworkPolicy || !proof.ReadOnlyRoot || + len(proof.Code) == 0 || !slices.Equal(proof.Code, proof.ObservedCode) || proof.Mounts == nil { + return fmt.Errorf("mapped sandbox proof is missing, stale or substituted") + } + paths := make(map[string]bool, len(proof.Code)) + for index, code := range proof.Code { + if !mappedCodeAbsoluteV1(code.Path) || !mappedCodeAbsoluteV1(code.Resolved) || index > 0 && proof.Code[index-1].Path >= code.Path { + return fmt.Errorf("mapped code closure must contain sorted unique absolute paths") + } + paths[code.Path] = true + } + for _, required := range request.RequiredCode { + if !paths[required] { + return fmt.Errorf("mapped code closure omits required path %q", required) + } + } + for _, mount := range proof.Mounts { + if !mappedCodeAbsoluteV1(mount.Target) || !mappedCodeAbsoluteV1(mount.Resolved) { + return fmt.Errorf("mapped mount observation has an invalid destination") + } + for _, code := range proof.Code { + for _, destination := range []string{mount.Target, mount.Resolved} { + if runtimePolicyPathsOverlap(destination, code.Path) || runtimePolicyPathsOverlap(destination, code.Resolved) { + return fmt.Errorf("mapped mount shadows protected code %q", code.Path) + } + } + } + } + return nil +} + +func mappedCodeAbsoluteV1(value string) bool { + return value != "/" && utf8.ValidString(value) && path.IsAbs(value) && path.Clean(value) == value && !strings.ContainsAny(value, "\x00\r\n") +} diff --git a/internal/dockerdeploy/mapped_sandbox_proof_test.go b/internal/dockerdeploy/mapped_sandbox_proof_test.go new file mode 100644 index 00000000..175c3df0 --- /dev/null +++ b/internal/dockerdeploy/mapped_sandbox_proof_test.go @@ -0,0 +1,214 @@ +package dockerdeploy + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/providers/registry" +) + +// Embedding only the old interface deliberately omits the new F observation +// contract. An existing generic Verify success cannot grant code authority. +type withoutMappedSandboxV1 struct { + deploy.IdentityMappingEvidenceV1 +} + +func TestMappedSandboxRetainsCompiledProtectedRootsV1(t *testing.T) { + f := newWaiterFixtureV1(t) + a := f.start(t) + lock, found, err := a.prepared.ops.Operation.ReadBuildLock(a.binding.Generation, registry.ValidateRequirementProfileV1) + if err != nil || !found { + t.Fatal(err) + } + lock.RuntimePolicy.ProtectedPaths = []deploy.ProtectedPathV1{{Path: "/opt/reploy", Kind: deploy.ProtectedPathReployRoot, Owner: "reploy"}} + digest, err := a.prepared.ops.Operation.PublishBuildLock(lock, registry.ValidateRequirementProfileV1) + if err != nil { + t.Fatal(err) + } + a.prepared.state.Current.BuildLockDigest = digest + a.prepared.state.Current.RuntimePolicyDigest, err = deploy.RuntimePolicyDigestV1(lock.RuntimePolicy) + if err != nil { + t.Fatal(err) + } + a.binding.Generation = digest + request, err := a.sandboxRequest() + if err != nil || !slices.Contains(request.RequiredCode, "/opt/reploy") { + t.Fatal("compiled protected root was lost", request, err) + } + f.effect.sandboxChange = func(proof *mappedSandboxProofV1) { + proof.Code = slices.DeleteFunc(proof.Code, func(code mappedCodePathV1) bool { return code.Path == "/opt/reploy" }) + proof.ObservedCode = slices.Clone(proof.Code) + } + if err := verifyMappedSandboxV1(f.ctx, a.prepared.state, f.effect, request); err == nil { + t.Fatal("observation omitted a compiled protected root") + } +} + +func TestMappedSandboxProofV1(t *testing.T) { + f := newWaiterFixtureV1(t) + a := f.start(t) + request, err := a.sandboxRequest() + if err != nil { + t.Fatal(err) + } + if request.Binding != f.binding || request.Container != f.id || request.PID != "1234" || + !slices.Contains(request.RequiredCode, "/reploy-probe") || len(request.RequiredCode) < 2 { + t.Fatalf("incomplete exact plan: %+v", request) + } + if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, request); err != nil { + t.Fatal(err) + } + if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, withoutMappedSandboxV1{f.effect}, request); err == nil { + t.Fatal("generic effect proof substituted for missing code proof") + } + ctx, cancel := context.WithCancel(f.ctx) + cancel() + if err := verifyMappedSandboxV1(ctx, f.prepared.state, f.effect, request); err == nil { + t.Fatal("cancelled proof accepted") + } + f.effect.sandboxFailure = errors.New("observation unavailable") + if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, request); err == nil { + t.Fatal("provider failure accepted") + } + f.effect.sandboxFailure = nil + invalidRequest := request + invalidRequest.RequiredCode = []string{"/invalid-\xff"} + if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, invalidRequest); err == nil { + t.Fatal("uncanonical request accepted") + } + finishing, finish := context.WithCancel(f.ctx) + f.effect.sandboxChange = func(*mappedSandboxProofV1) { finish() } + if err := verifyMappedSandboxV1(finishing, f.prepared.state, f.effect, request); err == nil { + t.Fatal("proof completing after cancellation accepted") + } + for _, test := range []struct { + name string + change func(*mappedSandboxProofV1) + }{ + {"wrong request", func(p *mappedSandboxProofV1) { p.Request = rendererDigest("9") }}, + {"wrong network", func(p *mappedSandboxProofV1) { p.NetworkPolicy = rendererDigest("9") }}, + {"writable root", func(p *mappedSandboxProofV1) { p.ReadOnlyRoot = false }}, + {"missing closure", func(p *mappedSandboxProofV1) { p.Code = nil; p.ObservedCode = nil }}, + {"missing observation", func(p *mappedSandboxProofV1) { p.ObservedCode = nil }}, + {"substituted dependency", func(p *mappedSandboxProofV1) { p.ObservedCode[0].Resolved = "/tmp/replacement" }}, + {"missing mount inventory", func(p *mappedSandboxProofV1) { p.Mounts = nil }}, + {"omitted required code", func(p *mappedSandboxProofV1) { p.Code = p.Code[1:]; p.ObservedCode = slices.Clone(p.Code) }}, + {"relative code", func(p *mappedSandboxProofV1) { p.Code[0].Path = "relative"; p.ObservedCode = slices.Clone(p.Code) }}, + {"unclean code", func(p *mappedSandboxProofV1) { p.Code[0].Path = "/app/../app"; p.ObservedCode = slices.Clone(p.Code) }}, + {"root closure shortcut", func(p *mappedSandboxProofV1) { p.Code[0].Path = "/"; p.ObservedCode = slices.Clone(p.Code) }}, + {"invalid code bytes", func(p *mappedSandboxProofV1) { p.Code[0].Path = "/bad\x00path"; p.ObservedCode = slices.Clone(p.Code) }}, + {"relative resolution", func(p *mappedSandboxProofV1) { p.Code[0].Resolved = "relative"; p.ObservedCode = slices.Clone(p.Code) }}, + {"invalid code UTF8", func(p *mappedSandboxProofV1) { p.Code[0].Path = "/bad-\xff"; p.ObservedCode = slices.Clone(p.Code) }}, + {"duplicate closure", func(p *mappedSandboxProofV1) { + p.Code = append(p.Code, p.Code[0]) + p.ObservedCode = slices.Clone(p.Code) + }}, + {"unsorted closure", func(p *mappedSandboxProofV1) { slices.Reverse(p.Code); p.ObservedCode = slices.Clone(p.Code) }}, + {"relative mount", func(p *mappedSandboxProofV1) { p.Mounts = []mappedSandboxMountV1{{"relative", "/data"}} }}, + {"relative mount resolution", func(p *mappedSandboxProofV1) { p.Mounts = []mappedSandboxMountV1{{"/data", "relative"}} }}, + {"direct code shadow", func(p *mappedSandboxProofV1) { p.Mounts = []mappedSandboxMountV1{{p.Code[0].Path, "/data"}} }}, + {"resolved mount shadow", func(p *mappedSandboxProofV1) { p.Mounts = []mappedSandboxMountV1{{"/data", p.Code[0].Path}} }}, + {"code symlink shadow", func(p *mappedSandboxProofV1) { + p.Code[0].Resolved = "/private/code" + p.ObservedCode = slices.Clone(p.Code) + p.Mounts = []mappedSandboxMountV1{{"/private", "/private"}} + }}, + } { + t.Run(test.name, func(t *testing.T) { + f.effect.sandboxChange = test.change + if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, request); err == nil { + t.Fatal("invalid proof accepted") + } + }) + } + f.effect.sandboxChange = func(p *mappedSandboxProofV1) { + p.Mounts = []mappedSandboxMountV1{{"/unrelated-data", "/unrelated-data"}} + } + if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, request); err != nil { + t.Fatal("unrelated data mount refused", err) + } + f.effect.sandboxChange = nil + for _, change := range []func(*mappedSandboxRequestV1){ + func(r *mappedSandboxRequestV1) { r.Binding.Generation = rendererDigest("9") }, + func(r *mappedSandboxRequestV1) { r.Binding.Image = rendererDigest("9") }, + func(r *mappedSandboxRequestV1) { r.Binding.Revision = "99" }, + func(r *mappedSandboxRequestV1) { r.Binding.Role = "other-role" }, + func(r *mappedSandboxRequestV1) { r.Binding.Plan = rendererDigest("9") }, + func(r *mappedSandboxRequestV1) { r.Container = "another-container" }, + func(r *mappedSandboxRequestV1) { r.PID = "999" }, + func(r *mappedSandboxRequestV1) { r.RuntimePolicy = rendererDigest("9") }, + } { + changed := request + change(&changed) + original, _ := canonical.Sum("mapped-sandbox", "v1", request) + f.effect.sandboxChange = func(p *mappedSandboxProofV1) { p.Request = original } + if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, changed); err == nil { + t.Fatal("proof replayed for a different owner or plan") + } + } +} + +func TestOwnedMappedWaiterRequiresSandboxProofV1(t *testing.T) { + for _, mode := range []string{"stale", "missing", "missing-build-authority"} { + t.Run(mode, func(t *testing.T) { + f := newWaiterFixtureV1(t) + a := f.start(t) + if mode == "missing-build-authority" { + a.prepared.ops.ValidateProfileOwner = nil + } else if mode == "missing" { + a.prepared.ops.Evidence = withoutMappedSandboxV1{f.effect} + } else { + f.effect.sandboxChange = func(p *mappedSandboxProofV1) { p.Request = rendererDigest("9") } + } + if _, err := a.release(); err == nil { + t.Fatal("unproved sandbox released") + } + if f.admissions != 0 || f.application.Load() != 0 || f.removed != 1 { + t.Fatal("refusal changed admission, exposed stdin or lost owned cleanup") + } + if _, err := a.release(); err == nil || f.removed != 1 { + t.Fatal("failed sandbox attempt retried release or cleanup") + } + }) + } +} + +func TestMappedSandboxSelectedPlanRefusalsV1(t *testing.T) { + for _, test := range []struct { + name string + change func(*ownedMappedWaiterV1) + }{ + {"no operation", func(a *ownedMappedWaiterV1) { a.prepared.ops.Operation = nil }}, + {"no profile authority", func(a *ownedMappedWaiterV1) { a.prepared.ops.ValidateProfileOwner = nil }}, + {"no generation", func(a *ownedMappedWaiterV1) { a.prepared.state.Current = nil }}, + {"missing lock", func(a *ownedMappedWaiterV1) { a.prepared.state.Current.BuildLockDigest = rendererDigest("9") }}, + {"changed policy", func(a *ownedMappedWaiterV1) { a.prepared.state.Current.RuntimePolicyDigest = rendererDigest("9") }}, + {"changed config", func(a *ownedMappedWaiterV1) { a.binding.Image = rendererDigest("9") }}, + {"changed generation", func(a *ownedMappedWaiterV1) { a.binding.Generation = rendererDigest("9") }}, + {"changed image", func(a *ownedMappedWaiterV1) { a.prepared.state.Current.ImageDigest = rendererDigest("9") }}, + {"changed rootfs", func(a *ownedMappedWaiterV1) { a.prepared.state.Current.RootFSSubject = rendererDigest("9") }}, + {"invalid create", func(a *ownedMappedWaiterV1) { a.prepared.spec.Args = nil }}, + {"missing separator", func(a *ownedMappedWaiterV1) { + a.prepared.spec.Args = slices.DeleteFunc(a.prepared.spec.Args, func(s string) bool { return s == "--" }) + }}, + {"missing executable", func(a *ownedMappedWaiterV1) { + a.prepared.spec.Args = a.prepared.spec.Args[:slices.Index(a.prepared.spec.Args, "--")+1] + }}, + {"relative executable", func(a *ownedMappedWaiterV1) { + a.prepared.spec.Args[slices.Index(a.prepared.spec.Args, "--")+1] = "relative" + }}, + } { + t.Run(test.name, func(t *testing.T) { + f := newWaiterFixtureV1(t) + a := f.start(t) + test.change(a) + if _, err := a.sandboxRequest(); err == nil { + t.Fatal("stale or incomplete selected plan accepted") + } + }) + } +} diff --git a/internal/dockerdeploy/podman_mapped_waiter.go b/internal/dockerdeploy/podman_mapped_waiter.go index c65270ff..54014527 100644 --- a/internal/dockerdeploy/podman_mapped_waiter.go +++ b/internal/dockerdeploy/podman_mapped_waiter.go @@ -222,7 +222,16 @@ func (a *ownedMappedWaiterV1) release() (acceptedState deploy.StateV1, failure e if err != nil || image != string(a.binding.Image) || namespace != p.namespace || client.version != p.version { return deploy.StateV1{}, fmt.Errorf("mapped release image or engine changed: %v", err) } + // Read the selected immutable plan outside the admission writer. Fresh F + // observations and the final process fence run inside its existing proof. + sandbox, err := a.sandboxRequest() + if err != nil { + return deploy.StateV1{}, err + } verify := func(ctx context.Context) error { + if err := verifyMappedSandboxV1(ctx, p.state, p.ops.Evidence, sandbox); err != nil { + return err + } pid, err := a.observeContainer(ctx, client) if err != nil || pid != a.pid { return fmt.Errorf("mapped waiter is no longer the pinned process: %v", err) diff --git a/internal/dockerdeploy/podman_mapped_waiter_test.go b/internal/dockerdeploy/podman_mapped_waiter_test.go index 8165b789..b3e5d9d4 100644 --- a/internal/dockerdeploy/podman_mapped_waiter_test.go +++ b/internal/dockerdeploy/podman_mapped_waiter_test.go @@ -18,15 +18,36 @@ import ( "testing" "time" + "github.com/omry/reploy/internal/canonical" "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/identitymapping" "github.com/omry/reploy/internal/mappedlaunch" "github.com/omry/reploy/internal/providers" + "github.com/omry/reploy/internal/providers/registry" ) type waiterEffectFixtureV1 struct { - fail bool - calls int + fail bool + calls int + sandboxFailure error + sandboxChange func(*mappedSandboxProofV1) +} + +func (e *waiterEffectFixtureV1) MappedSandbox(_ context.Context, _ deploy.StateV1, request mappedSandboxRequestV1) (mappedSandboxProofV1, error) { + digest, err := canonical.Sum("mapped-sandbox", "v1", request) + if err != nil { + return mappedSandboxProofV1{}, err + } + code := make([]mappedCodePathV1, len(request.RequiredCode)) + for i, name := range request.RequiredCode { + code[i] = mappedCodePathV1{name, name} + } + proof := mappedSandboxProofV1{Request: digest, NetworkPolicy: request.NetworkPolicy, ReadOnlyRoot: true, + Code: code, ObservedCode: slices.Clone(code), Mounts: []mappedSandboxMountV1{}} + if e.sandboxChange != nil { + e.sandboxChange(&proof) + } + return proof, e.sandboxFailure } func (*waiterEffectFixtureV1) Plan(context.Context, deploy.StateV1, identitymapping.Geometry) ([]string, error) { @@ -134,7 +155,7 @@ func newWaiterFixtureV1(t *testing.T) *waiterFixtureV1 { if err := os.WriteFile(filepath.Join(filepath.Dir(operation.Path()), "state.json"), encoded, 0600); err != nil { t.Fatal(err) } - f.prepared = &preparedMappedPodmanCreateV1{ops: deploy.IdentityMappingOperationsV1{Operation: operation, Evidence: f.effect}, state: state, active: identitymapping.Geometry{Profile: "exact", UIDs: []identitymapping.Interval{{HostStart: 100000, Count: 1}}, GIDs: []identitymapping.Interval{{HostStart: 100000, Count: 1}}}, operation: "operation", spec: spec, endpoint: f.client.endpoint, version: f.client.version, namespace: "fixture-namespace"} + f.prepared = &preparedMappedPodmanCreateV1{ops: deploy.IdentityMappingOperationsV1{Operation: operation, Evidence: f.effect, ValidateProfileOwner: registry.ValidateRequirementProfileV1}, state: state, active: identitymapping.Geometry{Profile: "exact", UIDs: []identitymapping.Interval{{HostStart: 100000, Count: 1}}, GIDs: []identitymapping.Interval{{HostStart: 100000, Count: 1}}}, operation: "operation", spec: spec, endpoint: f.client.endpoint, version: f.client.version, namespace: "fixture-namespace"} f.binding = mappedlaunch.Binding{Schema: "mapped-release-v1", Installation: "installation", Operation: "operation", Generation: state.Current.BuildLockDigest, Revision: "1", Image: lock.FinalImage.ConfigDigest, Role: "app", Plan: rendererDigest("c")} ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) t.Cleanup(cancel) diff --git a/internal/probe/mapped_exec_linux_test.go b/internal/probe/mapped_exec_linux_test.go index 3b32bb2f..3bcdf833 100644 --- a/internal/probe/mapped_exec_linux_test.go +++ b/internal/probe/mapped_exec_linux_test.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "errors" + "flag" "fmt" "io" "math" @@ -15,8 +16,11 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strconv" "strings" + "sync/atomic" + "syscall" "testing" "time" "unsafe" @@ -207,6 +211,12 @@ func mappedProductionBinaryV1(t *testing.T, directory string) string { if output, err := command.CombinedOutput(); err != nil { t.Fatalf("build exact production probe: %v: %s", err, output) } + mappedVerifyExecTailV1(t, probe) + return probe +} + +func mappedVerifyExecTailV1(t *testing.T, probe string) { + t.Helper() disassembly, err := exec.Command("go", "tool", "objdump", "-s", "github.com/omry/reploy/internal/probe.mappedExecTailV1", probe).CombinedOutput() if err != nil || !bytes.Contains(disassembly, []byte("TEXT ")) { t.Fatalf("inspect exact production tail: %v: %s", err, disassembly) @@ -223,7 +233,6 @@ func mappedProductionBinaryV1(t *testing.T, directory string) string { if calls != 3 || strings.Contains(string(disassembly), "runtime.") { t.Fatalf("unexpected raw exec-or-exit tail: %s", disassembly) } - return probe } func TestMappedProductionExecTailV1(t *testing.T) { @@ -318,6 +327,32 @@ func TestMappedProductionStartupV1(t *testing.T) { mappedProductionCaseV1(t, bootstrap, probe, payload, directory, profile, uidBase, gidBase, 0, 0, nil, 0, "no-filter", 122) }) } + // Link the same production package with one test-only concurrent opener. + // No test command, callback or hook is added to the shipped launcher. + testProbe := filepath.Join(directory, "descriptor-test-probe") + build := exec.Command("go", "test", "-c", "-buildvcs=false", "-o", testProbe, ".") + build.Env = append(os.Environ(), "CGO_ENABLED=0") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build descriptor adversary: %v: %s", err, output) + } + mappedVerifyExecTailV1(t, testProbe) + for _, profile := range []string{"exact", "bounded"} { + for _, mask := range []uint64{0, 0xc4, 0x400c1} { + for repetition := range 3 { + t.Run(fmt.Sprintf("%s/descriptor-race/mask%x/%d", profile, mask, repetition), func(t *testing.T) { + mappedProductionCaseV1(t, bootstrap, testProbe, payload, directory, profile, uidBase, gidBase, 0, 0, nil, mask, "descriptor-race", 0) + }) + } + } + t.Run(profile+"/descriptor-no-close-control", func(t *testing.T) { + mappedProductionCaseV1(t, bootstrap, testProbe, payload, directory, profile, uidBase, gidBase, 0, 0, nil, 0, "descriptor-no-close", 0) + }) + for _, mode := range []string{"descriptor-cloexec", "descriptor-shared-close"} { + t.Run(profile+"/"+mode+"-control", func(t *testing.T) { + mappedProductionCaseV1(t, bootstrap, testProbe, payload, directory, profile, uidBase, gidBase, 0, 0, nil, 0, mode, 0) + }) + } + } } func mappedProductionCaseV1(t *testing.T, bootstrap, probe, payload, directory, profile string, uidBase, gidBase uint64, uid, gid uint32, groups []uint32, mask uint64, mode string, expectedExit int) { @@ -342,13 +377,23 @@ func mappedProductionCaseV1(t *testing.T, bootstrap, probe, payload, directory, t.Fatal(err) } defer canary.Close() + if err := canary.Chmod(0o644); err != nil { + t.Fatal(err) // Public dummy data, readable by disposable namespace IDs. + } + private, identities := mappedPrivateDescriptorsV1(t, canary, directory) executable := payload if mode == "bad-exec" { executable = filepath.Join(directory, "missing-executable") } - args := []string{probe, fmt.Sprint(mask), mode, "/reploy-probe", mappedlaunch.Command, string(encoded), time.Now().Add(15 * time.Second).UTC().Format(time.RFC3339Nano), "sandbox-exec", fmt.Sprintf("%s%016x", mappedCapabilityArgumentV1, mask), "--uid", fmt.Sprint(uid), "--gid", fmt.Sprint(gid), "--groups", strings.Join(groupText, ","), "--public", "deny", "--local", "deny", "--ambiguous", "require-both", "--", executable, "3"} + args := []string{probe, fmt.Sprint(mask), mode, "/reploy-probe", mappedlaunch.Command, string(encoded), time.Now().Add(15 * time.Second).UTC().Format(time.RFC3339Nano), "sandbox-exec", fmt.Sprintf("%s%016x", mappedCapabilityArgumentV1, mask), "--uid", fmt.Sprint(uid), "--gid", fmt.Sprint(gid), "--groups", strings.Join(groupText, ","), "--public", "deny", "--local", "deny", "--ambiguous", "require-both", "--", executable} + args = append(args, identities...) + if strings.HasPrefix(mode, "descriptor-") { + prefix := append([]string{}, args[:4]...) + prefix = append(prefix, "-test.run=^TestMappedDescriptorRaceHelperV1$", "--", mode, canary.Name()) + args = append(prefix, args[4:]...) + } command := exec.CommandContext(ctx, bootstrap, args...) - command.ExtraFiles = []*os.File{canary} + command.ExtraFiles = private input, err := command.StdinPipe() if err != nil { t.Fatal(err) @@ -444,6 +489,12 @@ func mappedProductionCaseV1(t *testing.T, bootstrap, probe, payload, directory, } return } + if mode == "descriptor-shared-close" { + if string(stdout) != "shared-table-damaged\n" { + t.Fatal("shared-close control did not observe old-table damage", string(stdout)) + } + return + } var observed struct { PID int `json:"pid"` Threads []map[string]string `json:"threads"` @@ -455,6 +506,20 @@ func mappedProductionCaseV1(t *testing.T, bootstrap, probe, payload, directory, if err := json.Unmarshal(stdout, &observed); err != nil { t.Fatal(err, string(stdout), stderr.String()) } + if mode == "descriptor-no-close" { + for _, identity := range identities { + if !slices.Contains(observed.PrivateLeaks, identity) { + t.Fatalf("negative control did not detect inherited descriptor identity %s: %v", identity, observed.PrivateLeaks) + } + } + return + } + if mode == "descriptor-cloexec" { + if !slices.Contains(observed.PrivateLeaks, identities[0]) { + t.Fatal("CLOEXEC-only control did not detect racing private descriptor") + } + return + } plan := sandboxExecPlanV1{UID: uid, GID: gid, Groups: groups, MappedCapabilities: &mask} for _, thread := range observed.Threads { var status strings.Builder @@ -478,3 +543,122 @@ func mappedProductionCaseV1(t *testing.T, bootstrap, probe, payload, directory, } } } + +func mappedPrivateDescriptorsV1(t *testing.T, canary *os.File, directory string) ([]*os.File, []string) { + t.Helper() + private := []*os.File{canary} + for _, name := range []string{directory, filepath.Dir(directory)} { + file, err := os.Open(name) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Close() }) + private = append(private, file) + } + sockets, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + t.Fatal(err) + } + for _, fd := range sockets { + file := os.NewFile(uintptr(fd), "private-socket") + t.Cleanup(func() { _ = file.Close() }) + private = append(private, file) + } + pidfd, err := unix.PidfdOpen(os.Getpid(), 0) + if err != nil { + t.Fatalf("descriptor qualification requires pidfd support: %v", err) + } + file := os.NewFile(uintptr(pidfd), "private-pidfd") + t.Cleanup(func() { _ = file.Close() }) + private = append(private, file) + identities := make([]string, 0, len(private)) + for _, file := range private { + var status unix.Stat_t + if err := unix.Fstat(int(file.Fd()), &status); err != nil { + t.Fatal(err) + } + identities = append(identities, fmt.Sprintf("%d:%d", status.Dev, status.Ino)) + } + return private, identities +} + +// This entry exists only in the test binary. The actual Main, waiter, network +// setup and production exec transition run unchanged with a racing old-table +// opener. Shipped-binary cases above separately bind the production image. +func TestMappedDescriptorRaceHelperV1(t *testing.T) { + args := flag.Args() + if len(args) < 3 || !strings.HasPrefix(args[0], "descriptor-") { + t.Skip("disposable descriptor helper only") + } + ready := make(chan struct{}) + var damaged, opened uint32 + go func() { + runtime.LockOSThread() + fd, err := unix.Open(args[1], unix.O_RDONLY, 0) + if err != nil { + panic(err) + } + close(ready) + for { + if _, _, errno := syscall.RawSyscall(unix.SYS_FCNTL, 4096, unix.F_GETFD, 0); errno == unix.EBADF { + atomic.StoreUint32(&damaged, 1) + for { + } + } + next, err := unix.Open(args[1], unix.O_RDONLY, 0) + if err != nil { + panic(err) + } + _ = unix.Close(fd) + fd = next + atomic.AddUint32(&opened, 1) + } + }() + <-ready + if args[0] != "descriptor-race" { + binding, err := mappedlaunch.DecodeBinding([]byte(args[3])) + if err != nil { + t.Fatal(err) + } + if _, err := mappedlaunch.Read(os.Stdin, binding); err != nil { + t.Fatal(err) + } + if args[0] == "descriptor-shared-close" { + marker := []byte("shared-table-damaged\n") + mappedSharedCloseControlV1(&damaged, &marker[0]) + } + if args[0] == "descriptor-cloexec" { + if err := unix.CloseRange(3, uint(^uint32(0)), unix.CLOSE_RANGE_CLOEXEC); err != nil { + t.Fatal(err) + } + before := atomic.LoadUint32(&opened) + for atomic.LoadUint32(&opened)-before < 2 { + runtime.Gosched() + } + } + separator := slices.Index(args[2:], "--") + 2 + payload := args[separator+1:] + if err := syscall.Exec(payload[0], payload, os.Environ()); err != nil { + t.Fatal(err) + } + } + os.Exit(Main(args[2:], os.Stdin, os.Stdout, os.Stderr)) +} + +// Deliberately wrong test-only closer. A peer detects the shared table's lost +// high fd. Stay in a raw tail so a runtime poller failure cannot masquerade as +// that specific independent observation. The parent bounds/reaps the process. +// +//go:nosplit +//go:norace +func mappedSharedCloseControlV1(damaged *uint32, marker *byte) { + _, _, errno := syscall.RawSyscall6(unix.SYS_CLOSE_RANGE, 3, uintptr(^uint32(0)), 0, 0, 0, 0) + if errno == 0 { + for atomic.LoadUint32(damaged) == 0 { + } + syscall.RawSyscall(unix.SYS_WRITE, 1, uintptr(unsafe.Pointer(marker)), uintptr(len("shared-table-damaged\n"))) + } + syscall.RawSyscall(unix.SYS_EXIT_GROUP, 0, 0, 0) + for { + } +} diff --git a/internal/probe/testdata/mapped_startup_bootstrap.c b/internal/probe/testdata/mapped_startup_bootstrap.c index 4559d876..bdc07ac8 100644 --- a/internal/probe/testdata/mapped_startup_bootstrap.c +++ b/internal/probe/testdata/mapped_startup_bootstrap.c @@ -29,6 +29,8 @@ int main(int argc, char **argv) { unshare(CLONE_NEWNET)) die("prepare namespace authority"); gid_t initial_groups[] = {100, 201}; if (setgroups(2, initial_groups)) die("initial supplementary groups"); + /* A non-CLOEXEC high-numbered duplicate must disappear too. */ + if (dup2(3, 4096) != 4096) die("high private descriptor"); unsigned long long ceiling = strtoull(argv[2], NULL, 0) | 0x11c0ULL; if (!strcmp(argv[3], "ambient")) ceiling |= 1; FILE *limit = fopen("/proc/sys/kernel/cap_last_cap", "r"); diff --git a/internal/probe/testdata/mapped_startup_payload.go b/internal/probe/testdata/mapped_startup_payload.go index 9a148948..9de950a6 100644 --- a/internal/probe/testdata/mapped_startup_payload.go +++ b/internal/probe/testdata/mapped_startup_payload.go @@ -46,16 +46,28 @@ func main() { result.Threads = append(result.Threads, fields) } result.Securebits, _, _ = syscall.RawSyscall6(syscall.SYS_PRCTL, 27, 0, 0, 0, 0, 0) - for _, fd := range os.Args[1:] { - value, err := strconv.Atoi(fd) + // Numbers can be reused by the new Go runtime. Compare kernel identities + // across every live fd instead: this detects files, directories, sockets, + // pidfds and racing duplicates without confusing a reused fd with a leak. + private := map[string]bool{} + for _, identity := range os.Args[1:] { + private[identity] = true + } + fds, err := os.ReadDir("/proc/self/fd") + if err != nil { + panic(err) + } + for _, entry := range fds { + fd, err := strconv.Atoi(entry.Name()) if err != nil { panic(err) } - // The fixture gives each inherited private handle a unique inode. - // Runtime-created descriptors can reuse its number after exec. - link, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", value)) - if err == nil && strings.Contains(link, "mapped-private-canary") { - result.PrivateLeaks = append(result.PrivateLeaks, link) + var status syscall.Stat_t + if syscall.Fstat(fd, &status) == nil { + identity := fmt.Sprintf("%d:%d", status.Dev, status.Ino) + if private[identity] { + result.PrivateLeaks = append(result.PrivateLeaks, identity) + } } } input, err := io.ReadAll(os.Stdin)