Skip to content

root.readonly is silently ignored (rootfs left writable) when the container has no mount namespace #5371

Description

@logical-misha

Summary

An OCI config that sets root.readonly: true but whose linux.namespaces does not
include a mount namespace is accepted by runc, and the container's root filesystem is
left writable — with no error and no warning. The sibling confinement directives
maskedPaths / readonlyPaths, which share the exact same mount-namespace requirement, are
instead refused for the same config. An operator hardening a container with a read-only
rootfs is silently left unprotected.

This is a correctness / consistency (defense-in-depth) issue at the validation layer, not a
container escape or privilege escalation
— so a public issue (rather than the private
advisory process) is appropriate.

Reproduction (validator unit test — confirmed)

The asymmetry is at the validation layer and reproduces directly against the validate
package, no root or container needed. Add this to libcontainer/configs/validate/ and run
go test ./libcontainer/configs/validate/ -run TestRUNC1 -v:

// Sibling directive is correctly REFUSED without a mount namespace.
func TestRUNC1_MaskPathsWithoutMountNS_Refused(t *testing.T) {
    config := &configs.Config{
        Rootfs:     "/var",
        Namespaces: []configs.Namespace{}, // no NEWNS
        MaskPaths:  []string{"/proc/kcore"},
    }
    if err := Validate(config); err == nil {
        t.Fatalf("expected MaskPaths without a mount namespace to be refused, got nil")
    }
}

// Readonlyfs with the SAME (no-NEWNS) config is silently ACCEPTED -- the bug.
func TestRUNC1_ReadonlyfsWithoutMountNS_SilentlyAccepted(t *testing.T) {
    config := &configs.Config{
        Rootfs:     "/var",
        Namespaces: []configs.Namespace{}, // no NEWNS
        Readonlyfs: true,
    }
    if err := Validate(config); err != nil {
        t.Logf("Readonlyfs without a mount namespace was refused (fixed): %v", err)
        return
    }
    t.Fatalf("RUNC-1: Readonlyfs=true without a mount namespace was ACCEPTED " +
        "(rootfs left writable, silently), while MaskPaths in the same situation is refused")
}

Result on the current code:

--- PASS: TestRUNC1_MaskPathsWithoutMountNS_Refused
--- FAIL: TestRUNC1_ReadonlyfsWithoutMountNS_SilentlyAccepted
    RUNC-1: Readonlyfs=true without a mount namespace was ACCEPTED ...

Applying the suggested fix below makes both pass, with no regression in the existing
validate tests. (Verified on commit fc89fbd.)

Steps to reproduce (end-to-end)

  1. Create a bundle whose config.json sets "root": {"path": "rootfs", "readonly": true}
    and whose linux.namespaces omits {"type": "mount"} (keep the others as desired).
  2. runc run <id> (this is a host-mount-namespace container, so it needs privilege).
  3. From inside the container: touch /should-be-readonly.

Expected behavior

Consistent with maskedPaths / readonlyPaths: runc refuses the config at creation with
an error like cannot make rootfs read-only without a private MNT namespace (or, at minimum,
emits a warning). The operator learns their hardening directive cannot be honored.

Actual behavior

The config is accepted, the container runs, and touch /should-be-readonly succeeds — the
rootfs is writable despite root.readonly: true, with no error or warning.

Root cause

root.readonly (config.Readonlyfs, set from the spec at
libcontainer/specconv/spec_linux.go:407) is applied only through the NEWNS-gated path:

  • setReadonly() remounts / MS_RDONLY (libcontainer/rootfs_linux.go:1122); its only
    caller is finalizeRootfs (rootfs_linux.go:292-293).
  • finalizeRootfs is called only from standard_init_linux.go:117, guarded by
    if l.config.Config.Namespaces.Contains(configs.NEWNS) (standard_init_linux.go:116-120).

So without a mount namespace, setReadonly is never reached and the rootfs stays writable.
(Skipping the remount is itself correct — remounting / read-only in a shared mount
namespace would remount the host's / read-only.)

The defect is that the validator does not treat Readonlyfs like its siblings. security()
(libcontainer/configs/validate/validator.go:130-135) fail-closes MaskPaths /
ReadonlyPaths without NEWNS, but has no analogous check for Readonlyfs:

func security(config *configs.Config) error {
    // restrict sys without mount namespace
    if (len(config.MaskPaths) > 0 || len(config.ReadonlyPaths) > 0) &&
        !config.Namespaces.Contains(configs.NEWNS) {
        return errors.New("unable to restrict sys entries without a private MNT namespace")
    }
    // ... no check for config.Readonlyfs ...
}

The presence of the !NEWNS branch shows runc supports these configs and that the author knew
these directives require a mount namespace — Readonlyfs was simply omitted from the check.

Suggested fix

Handle Readonlyfs like its siblings in security() — refuse (or at minimum warn on) the
config when Readonlyfs && !NEWNS:

if config.Readonlyfs && !config.Namespaces.Contains(configs.NEWNS) {
    return errors.New("cannot make rootfs read-only without a private MNT namespace")
}

This makes the two repro tests pass and leaves the existing validate suite green.

Version

  • runc 1.5.0-rc.1+dev, commit fc89fbd9ebec617475d7e7a7a38f4e4bf277cf54
  • Please confirm against latest main and check for existing/duplicate reports before filing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    llm-generatedUsed to tag LLM-generated issues or PRs, which some maintainers may choose to de-prioritise.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions