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.json sets "root": {"path": "rootfs", "readonly": true}
and whose linux.namespaces omits {"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 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.
Summary
An OCI config that sets
root.readonly: truebut whoselinux.namespacesdoes notinclude a
mountnamespace is accepted by runc, and the container's root filesystem isleft writable — with no error and no warning. The sibling confinement directives
maskedPaths/readonlyPaths, which share the exact same mount-namespace requirement, areinstead 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
validatepackage, no root or container needed. Add this to
libcontainer/configs/validate/and rungo test ./libcontainer/configs/validate/ -run TestRUNC1 -v:Result on the current code:
Applying the suggested fix below makes both pass, with no regression in the existing
validatetests. (Verified on commitfc89fbd.)Steps to reproduce (end-to-end)
config.jsonsets"root": {"path": "rootfs", "readonly": true}and whose
linux.namespacesomits{"type": "mount"}(keep the others as desired).runc run <id>(this is a host-mount-namespace container, so it needs privilege).touch /should-be-readonly.Expected behavior
Consistent with
maskedPaths/readonlyPaths: runc refuses the config at creation withan 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-readonlysucceeds — therootfs is writable despite
root.readonly: true, with no error or warning.Root cause
root.readonly(config.Readonlyfs, set from the spec atlibcontainer/specconv/spec_linux.go:407) is applied only through the NEWNS-gated path:setReadonly()remounts/MS_RDONLY(libcontainer/rootfs_linux.go:1122); its onlycaller is
finalizeRootfs(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,
setReadonlyis never reached and the rootfs stays writable.(Skipping the remount is itself correct — remounting
/read-only in a shared mountnamespace would remount the host's
/read-only.)The defect is that the validator does not treat
Readonlyfslike its siblings.security()(
libcontainer/configs/validate/validator.go:130-135) fail-closesMaskPaths/ReadonlyPathswithoutNEWNS, but has no analogous check forReadonlyfs:The presence of the
!NEWNSbranch shows runc supports these configs and that the author knewthese directives require a mount namespace —
Readonlyfswas simply omitted from the check.Suggested fix
Handle
Readonlyfslike its siblings insecurity()— refuse (or at minimum warn on) theconfig when
Readonlyfs && !NEWNS:This makes the two repro tests pass and leaves the existing
validatesuite green.Version
1.5.0-rc.1+dev, commitfc89fbd9ebec617475d7e7a7a38f4e4bf277cf54mainand check for existing/duplicate reports before filing.