Skip to content

Commit 86fcff2

Browse files
authored
Fix pause container of a privileged pod unable to start due to /sys mount option mismatch (#2760)
* Fix pause container of a privileged pod unable to start due to /sys mount option mismatch Starting with v2, containerd mounts /sys as rw on the sandbox container when the pod is privileged (1fc497218 "Fix privileged container sysfs can't be rw because pod is ro by default") instead of ro. This means that the mount list for a privileged pause container no longer matches with just data.defaultMounts and will need a special case for sysfs. Alternative options were also considered - see the comment in framework.rego. This change in GCS is necessary even though this can be fixed via a policy change, because we need to maintain compatibility with existing policies. This PR converts EnforceCreateContainerPolicy in the LCOW GCS to EnforceCreateContainerPolicyV2, in order to use the CreateContainerOptions struct to pass an additional bool indicating whether the current container is a sandbox container. This does not allow additional capabilities etc for the sandbox container, only that sysfs can now be rw. * Add tests for sandbox sysfs special-case * framework.rego: Don't require candidate_containers[_].allow_elevated for sysfs rw special case * Add tests to check that sysfs rw special case is applied even when no container in the policy is elevated Assisted-by: GitHub Copilot:claude-opus-4.7 Signed-off-by: Tingmao Wang <tingmaowang@microsoft.com>
1 parent 3a61fca commit 86fcff2

5 files changed

Lines changed: 223 additions & 11 deletions

File tree

internal/guest/runtime/hcsv2/uvm.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -656,21 +656,27 @@ func (h *Host) CreateContainer(ctx context.Context, id string, settings *prot.VM
656656
return nil, err
657657
}
658658

659-
envToKeep, capsToKeep, allowStdio, err := h.securityOptions.PolicyEnforcer.EnforceCreateContainerPolicy(
659+
privileged := isPrivilegedContainerCreationRequest(ctx, settings.OCISpecification)
660+
noNewPrivileges := settings.OCISpecification.Process.NoNewPrivileges
661+
opts := &securitypolicy.CreateContainerOptions{
662+
SandboxID: sandboxID,
663+
Privileged: &privileged,
664+
NoNewPrivileges: &noNewPrivileges,
665+
Groups: groups,
666+
Umask: umask,
667+
Capabilities: settings.OCISpecification.Process.Capabilities,
668+
SeccompProfileSHA256: seccomp,
669+
IsSandboxContainer: c.isSandbox,
670+
}
671+
envToKeep, capsToKeep, allowStdio, err := h.securityOptions.PolicyEnforcer.EnforceCreateContainerPolicyV2(
660672
ctx,
661-
sandboxID,
662673
id,
663674
settings.OCISpecification.Process.Args,
664675
settings.OCISpecification.Process.Env,
665676
settings.OCISpecification.Process.Cwd,
666677
settings.OCISpecification.Mounts,
667-
isPrivilegedContainerCreationRequest(ctx, settings.OCISpecification),
668-
settings.OCISpecification.Process.NoNewPrivileges,
669678
user,
670-
groups,
671-
umask,
672-
settings.OCISpecification.Process.Capabilities,
673-
seccomp,
679+
opts,
674680
)
675681
if err != nil {
676682
return nil, errors.Wrapf(err, "container creation denied due to policy")

pkg/securitypolicy/framework.rego

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,40 @@ mount_ok(mounts, allow_elevated, mount) {
784784
mountConstraint_ok(constraint, mount)
785785
}
786786

787+
# Special case for the pod sandbox (pause) container: starting with v2,
788+
# containerd mounts /sys as rw on the sandbox container when the pod is
789+
# privileged (1fc497218 "Fix privileged container sysfs can't be rw because pod
790+
# is ro by default") instead of ro. This means that the mount list for a
791+
# privileged pause container no longer matches with just data.defaultMounts and
792+
# will either need a special case for sysfs (which is the only mount being
793+
# treated differently), or use data.privilegedMounts. However, if we blindly
794+
# use data.privilegedMounts, this could result in the host being able to mount
795+
# "privileged" mounts on even a non-privileged container, as long as it runs as
796+
# the sandbox. Since we have no other way to determine if the sandbox should be
797+
# allowed to be privileged or not (input.privileged is set to false for the
798+
# pause container even if the pod is privileged), we just special case the sysfs
799+
# mount.
800+
#
801+
# We have to allow this special case whether or not this policy currently allows
802+
# any privileged containers at all, since a fragment that is loaded in the
803+
# future may allow privileged containers.
804+
mount_ok(mounts, allow_elevated, mount) {
805+
input.isSandboxContainer
806+
807+
# we allow allow_elevated to be false since this is what existing policies
808+
# already does, even when some workload containers can be privileged, the
809+
# sandbox container itself is not.
810+
811+
mount.type == "sysfs"
812+
mount.source == "sysfs"
813+
mount.destination == "/sys"
814+
count(mount.options) == 4
815+
"nosuid" in mount.options
816+
"noexec" in mount.options
817+
"nodev" in mount.options
818+
"rw" in mount.options
819+
}
820+
787821
mountList_ok(mounts, allow_elevated) {
788822
is_linux
789823
every mount in input.mounts {
@@ -1395,14 +1429,14 @@ filtered_registry_values(input_values, policy_values) := [input_val |
13951429
registry_changes := {"allowed": true} {
13961430
containers := data.metadata.matches[input.containerID]
13971431
container := containers[_]
1398-
1432+
13991433
# Check if container has registry_changes defined in policy
14001434
container.registry_changes
1401-
1435+
14021436
# If input has registry changes, filter to only matching ones
14031437
input.registryChanges.AddValues
14041438
matched_values := filtered_registry_values(input.registryChanges.AddValues, container.registry_changes.add_values)
1405-
1439+
14061440
# Build result with filtered AddValues
14071441
result := {
14081442
"AddValues": matched_values

pkg/securitypolicy/regopolicy_linux_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7696,3 +7696,170 @@ func substituteUVMPath(sandboxID string, m mountInternal) mountInternal {
76967696
}
76977697
return m
76987698
}
7699+
7700+
// setupSandboxSysfsTest builds a policy with exactly two containers: a
7701+
// non-elevated container that the test request will match (acting as the
7702+
// sandbox/pause container in the policy) and a separate elevated container so
7703+
// that candidate_containers has at least one container with allow_elevated set
7704+
// - this gates the sandbox sysfs carve-out in framework.rego.
7705+
//
7706+
// DefaultCRIMounts() is used as the policy's default mount set so the /sys
7707+
// "ro" mount is in data.defaultMounts.
7708+
func setupSandboxSysfsTest(t *testing.T, workloadIsPrivileged bool) (
7709+
policy *regoEnforcer,
7710+
sandboxContainer *securityPolicyContainer,
7711+
containerID string,
7712+
envList []string,
7713+
user IDName,
7714+
groups []IDName,
7715+
capabilities *oci.LinuxCapabilities,
7716+
) {
7717+
t.Helper()
7718+
7719+
gc := generateConstraints(testRand, 2)
7720+
for len(gc.containers) < 2 {
7721+
// Force exactly two containers if generator under-generated.
7722+
gc.containers = append(gc.containers, generateConstraintsContainer(testRand, 1, maxLayersInGeneratedContainer))
7723+
}
7724+
7725+
// containers[0] is the one the test will exercise: act as the pause
7726+
// container, never elevated. Strip its own mount constraints so the
7727+
// input mount list is matched purely against defaultMounts and the
7728+
// sandbox carve-out.
7729+
sandboxContainer = gc.containers[0]
7730+
sandboxContainer.AllowElevated = false
7731+
sandboxContainer.Mounts = nil
7732+
7733+
gc.containers[1].AllowElevated = workloadIsPrivileged
7734+
7735+
defaultMounts := DefaultCRIMounts()
7736+
privilegedMounts := DefaultCRIPrivilegedMounts()
7737+
7738+
var err error
7739+
policy, err = newRegoPolicy(gc.toPolicy().marshalRego(), defaultMounts, privilegedMounts, testOSType)
7740+
if err != nil {
7741+
t.Fatalf("failed to create policy: %v", err)
7742+
}
7743+
7744+
containerID, err = mountImageForContainer(policy, sandboxContainer)
7745+
if err != nil {
7746+
t.Fatalf("failed to mount image for sandbox container: %v", err)
7747+
}
7748+
7749+
envList = buildEnvironmentVariablesFromEnvRules(sandboxContainer.EnvRules, testRand)
7750+
user = buildIDNameFromConfig(sandboxContainer.User.UserIDName, testRand)
7751+
groups = buildGroupIDNamesFromUser(sandboxContainer.User, testRand)
7752+
capsExternal := copyLinuxCapabilities(sandboxContainer.Capabilities.toExternal())
7753+
capabilities = &capsExternal
7754+
return policy, sandboxContainer, containerID, envList, user, groups, capabilities
7755+
}
7756+
7757+
// sysfsMount returns a /sys sysfs mount with the given mode ("ro" or "rw"),
7758+
// matching the option set produced by containerd's CRI sandbox spec.
7759+
func sysfsMount(mode string) oci.Mount {
7760+
return oci.Mount{
7761+
Source: "sysfs",
7762+
Destination: "/sys",
7763+
Type: "sysfs",
7764+
Options: []string{"nosuid", "noexec", "nodev", mode},
7765+
}
7766+
}
7767+
7768+
func Test_Rego_SandboxSysfsCarveOut(t *testing.T) {
7769+
cases := []struct {
7770+
name string
7771+
isSandboxContainer bool
7772+
mode string
7773+
expectAllowed bool
7774+
workloadIsPrivileged bool
7775+
}{
7776+
{"sandbox_ro", true, "ro", true, true},
7777+
{"sandbox_rw", true, "rw", true, true},
7778+
// sysfs rw is allowed for sandbox containers even if no container in
7779+
// the policy is elevated (a future fragment might allow elevation).
7780+
{"sandbox_rw_no_elevated_workload", true, "rw", true, false},
7781+
{"non_sandbox_ro", false, "ro", true, false},
7782+
{"non_sandbox_rw", false, "rw", false, false},
7783+
{"non_sandbox_rw_elevated_workload", false, "rw", false, true},
7784+
}
7785+
7786+
for _, tc := range cases {
7787+
t.Run(tc.name, func(t *testing.T) {
7788+
// Each subtest gets its own policy because a successful
7789+
// create_container records the container as "started" and a
7790+
// second call for the same containerID would be denied.
7791+
policy, sandboxContainer, containerID, envList, user, groups, capabilities :=
7792+
setupSandboxSysfsTest(t, tc.workloadIsPrivileged)
7793+
noNewPriv := sandboxContainer.NoNewPrivileges
7794+
privileged := false
7795+
mounts := []oci.Mount{sysfsMount(tc.mode)}
7796+
_, _, _, err := policy.EnforceCreateContainerPolicyV2(
7797+
context.Background(),
7798+
containerID,
7799+
sandboxContainer.Command,
7800+
envList,
7801+
sandboxContainer.WorkingDir,
7802+
mounts,
7803+
user,
7804+
&CreateContainerOptions{
7805+
SandboxID: testDataGenerator.uniqueSandboxID(),
7806+
Privileged: &privileged,
7807+
NoNewPrivileges: &noNewPriv,
7808+
Groups: groups,
7809+
Umask: sandboxContainer.User.Umask,
7810+
Capabilities: capabilities,
7811+
SeccompProfileSHA256: sandboxContainer.SeccompProfileSHA256,
7812+
IsSandboxContainer: tc.isSandboxContainer,
7813+
},
7814+
)
7815+
if tc.expectAllowed {
7816+
if err != nil {
7817+
t.Errorf("expected allowed, got error: %v", err)
7818+
}
7819+
} else {
7820+
if err == nil {
7821+
t.Errorf("expected denied, got allowed")
7822+
} else {
7823+
assertDecisionJSONContains(t, err, "invalid mount list", "/sys")
7824+
}
7825+
}
7826+
})
7827+
}
7828+
}
7829+
7830+
// Test_Rego_SandboxSysfsCarveOut_PrivilegedRequestDenied verifies that the
7831+
// sysfs carve-out for the sandbox container does NOT also grant privilege:
7832+
// even with IsSandboxContainer=true and the /sys rw mount accepted, if the
7833+
// host requests Privileged=true for a sandbox container whose policy entry
7834+
// does not allow elevation, create_container must still be denied.
7835+
func Test_Rego_SandboxSysfsCarveOut_PrivilegedRequestDenied(t *testing.T) {
7836+
policy, sandboxContainer, containerID, envList, user, groups, capabilities :=
7837+
setupSandboxSysfsTest(t, false)
7838+
7839+
noNewPriv := sandboxContainer.NoNewPrivileges
7840+
privileged := true
7841+
mounts := []oci.Mount{sysfsMount("rw")}
7842+
_, _, _, err := policy.EnforceCreateContainerPolicyV2(
7843+
context.Background(),
7844+
containerID,
7845+
sandboxContainer.Command,
7846+
envList,
7847+
sandboxContainer.WorkingDir,
7848+
mounts,
7849+
user,
7850+
&CreateContainerOptions{
7851+
SandboxID: testDataGenerator.uniqueSandboxID(),
7852+
Privileged: &privileged,
7853+
NoNewPrivileges: &noNewPriv,
7854+
Groups: groups,
7855+
Umask: sandboxContainer.User.Umask,
7856+
Capabilities: capabilities,
7857+
SeccompProfileSHA256: sandboxContainer.SeccompProfileSHA256,
7858+
IsSandboxContainer: true,
7859+
},
7860+
)
7861+
if err == nil {
7862+
t.Fatal("expected create_container to be denied when Privileged=true for a non-elevated sandbox container, but it was allowed")
7863+
}
7864+
assertDecisionJSONContains(t, err, "privileged escalation not allowed")
7865+
}

pkg/securitypolicy/securitypolicyenforcer.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ type CreateContainerOptions struct {
2929
Umask string
3030
Capabilities *oci.LinuxCapabilities
3131
SeccompProfileSHA256 string
32+
// IsSandboxContainer is true when the container being created is the cri
33+
// pod sandbox container (usually it is the "pause" image).
34+
IsSandboxContainer bool
3235
}
3336
type SignalContainerOptions struct {
3437
IsInitProcess bool

pkg/securitypolicy/securitypolicyenforcer_rego.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,7 @@ func (policy *regoEnforcer) EnforceCreateContainerPolicy(
713713
Umask: umask,
714714
Capabilities: capabilities,
715715
SeccompProfileSHA256: seccompProfileSHA256,
716+
IsSandboxContainer: false,
716717
}
717718
return policy.EnforceCreateContainerPolicyV2(ctx, containerID, argList, envList, workingDir, mounts, user, opts)
718719
}
@@ -757,6 +758,7 @@ func (policy *regoEnforcer) EnforceCreateContainerPolicyV2(
757758
"umask": opts.Umask,
758759
"capabilities": mapifyCapabilities(opts.Capabilities),
759760
"seccompProfileSHA256": opts.SeccompProfileSHA256,
761+
"isSandboxContainer": opts.IsSandboxContainer,
760762
}
761763
case "windows":
762764
// Dump full interpreter metadata for debugging diagnostics.

0 commit comments

Comments
 (0)