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

Open Beginner friendly
#5,371 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
78/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
go

Research direction

Start in libcontainer/configs/validate/validator.go, specifically security(), and compare the existing MaskPaths/ReadonlyPaths mount-namespace validation with Readonlyfs. Add the focused validator test under libcontainer/configs/validate/ and run go test ./libcontainer/configs/validate/ -run TestRUNC1 -v; done means Readonlyfs without NEWNS is refused and the existing validation suite remains green.

Written by the indexing model from the issue text.

Description

llm-generated

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.
Dominant language
Go
Stars
13.5k
Forks
2.3k
Avg merge
2d 8h
Merged PRs (30d)
30

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from opencontainers/runc

All issues in opencontainers/runc

Similar issues

More Go issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.