`root.readonly` is silently ignored (rootfs left writable) when the container has no mount namespace
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
- Domain
- operating-systems, security
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
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 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, commitfc89fbd9ebec617475d7e7a7a38f4e4bf277cf54 - Please confirm against latest
mainand 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from opencontainers/runc
-
llm-generated
Difficulty 2/5 1-3 hours Newbie friendliness 90/100
opencontainers/runc#5370 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
opencontainers/runc#2214 · 3 comments ·
-
Difficulty 1/5 Under an hour Newbie friendliness 68/100
opencontainers/runc#1679 · 1 comment ·
-
Difficulty 3/5 1-2 days Newbie friendliness 68/100
opencontainers/runc#5474 ·
-
llm-generated
Difficulty 4/5 3-5 days Newbie friendliness 50/100
opencontainers/runc#5390 · 3 comments ·
All issues in opencontainers/runc
Similar issues
-
optimization optimization:agents-md-curator
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
githubnext/gh-aw-cao#13143 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
blinklabs-io/bursa#904 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
yanet-platform/ipfw-go#129 ·
-
bug confmap/provider/googlesecretmanagerprovider needs triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
open-telemetry/opentelemetry-collector-contrib#51273 · 2 comments ·
-
bug: AI Gateway client filter lists "Unknown" twice when NULL and literal Unknown clients coexist Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 90/100