`root.readonly` is silently ignored (rootfs left writable) when the container has no mount namespace
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)
- Create a bundle whose
config.jsonsets"root": {"path": "rootfs", "readonly": true}and whoselinux.namespacesomits{"type": "mount"}(keep the others as desired). runc run <id>(this is a host-mount-namespace container, so it needs privilege).- 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 isfinalizeRootfs(rootfs_linux.go:292-293).finalizeRootfsis called only fromstandard_init_linux.go:117, guarded byif 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, commitfc89fbd9ebec617475d7e7a7a38f4e4bf277cf54 - Please confirm against latest
mainand check for existing/duplicate reports before filing.
Source: opencontainers/runc