containerd user ID handling bypass allows runAsNonRoot evasion
Description
Impact
A bug was found in containerd where containers launched with a numeric User directive that cannot be parsed as a 32-bit integer are incorrectly treated as a username. If a crafted image provides an /etc/passwd file mapping this large numeric string to root, the container ultimately runs as root (UID 0). This allows the Kubernetes runAsNonRoot restriction to be bypassed, causing unexpected behavior for environments that require containers to run as a non-root user.
Patches
This bug has been fixed in the following containerd versions:
- 2.3.1
- 2.2.4
- 2.0.9
- 1.7.32
Note: The containerd 2.1 release has reached its end of life and a fixed version is not provided.
Users should update to these versions to resolve the issue.
Workarounds
Ensure that only trusted images are used and that only trusted users have permissions to import images. Alternatively, enforcing a specific numeric runAsUser in the Kubernetes Pod securityContext overrides the USER directive in the image and prevents the bypass. Newer versions of Kubernetes, starting with 1.34, also appear to enforce runAsNonRoot properly regardless of this bug.
Credits
The containerd project would like to thank Lei Wang (@ssst0n3) for responsibly disclosing this issue in accordance with the containerd security policy.
### Resources * https://github.com/advisories/GHSA-265r-hfxg-fhmg (CVE-2024-40635)
For more information
If there are any questions or comments about this advisory:
- Open an issue in containerd
- Send an email to security@containerd.io
To report a security issue in containerd: * Report a new vulnerability * Send an email to security@containerd.io
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A flaw in containerd allows numeric User directives exceeding 32-bit to be treated as usernames, enabling runAsNonRoot bypass via crafted /etc/passwd.
Containerd incorrectly handles numeric User directives in container images that cannot be parsed as a 32-bit integer, treating them as usernames. This allows a crafted image with an /etc/passwd mapping the large number to root to bypass the runAsNonRoot restriction [1][2].
An attacker who can deploy a malicious container image can set the Dockerfile USER directive to a value like 4294967296 (which exceeds 32-bit range). The container runtime then looks up this value as a username in /etc/passwd, and if found, uses the mapped UID. By including a passwd entry mapping that string to root, the container runs as root despite runAsNonRoot [1][2].
The primary impact is bypassing Kubernetes' runAsNonRoot security constraint, causing containers that should run as non-root to run as root. This violates the security expectations of multi-tenant environments and can lead to privilege escalation within a pod or cluster [1][2].
The bug is fixed in containerd 2.3.1, 2.2.4, 2.0.9, and 1.7.32. Workarounds include using only trusted images, enforcing a numeric runAsUser in the Kubernetes securityContext, or using Kubernetes 1.34+ which enforces runAsNonRoot correctly. The containerd 2.1 release is end-of-life and no fix is provided [1][2].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2>= 2.3.0-beta.0, < 2.3.1+ 1 more
- (no CPE)range: >= 2.3.0-beta.0, < 2.3.1
- (no CPE)range: <1.7.32, <2.0.9, <2.2.4, <2.3.1
Patches
79f8f4538dea6Merge pull request #13447 from samuelkarp/oci-withuser-errrange-2.3
2 files changed · +36 −7
pkg/oci/spec_opts.go+25 −4 modified@@ -625,14 +625,25 @@ func WithUser(userstr string) SpecOpts { return nil } + isErrRange := func(err error) bool { + var numErr *strconv.NumError + return errors.As(err, &numErr) && numErr.Err == strconv.ErrRange + } + parts := strings.Split(userstr, ":") switch len(parts) { case 1: v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { - // if we cannot parse as an int32 then try to see if it is a username + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } + // Non-numeric user value; treat it as a username. return WithUsername(userstr)(ctx, client, c, s) } + if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } return WithUserID(uint32(v))(ctx, client, c, s) case 2: var ( @@ -641,14 +652,24 @@ func WithUser(userstr string) SpecOpts { ) var uid, gid uint32 v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } username = parts[0] + } else if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) } else { uid = uint32(v) } v, err = strconv.Atoi(parts[1]) - if err != nil || v < minGroupID || v > maxGroupID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) + } groupname = parts[1] + } else if v < minGroupID || v > maxGroupID { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) } else { gid = uint32(v) }
pkg/oci/spec_opts_user_test.go+11 −3 modified@@ -88,15 +88,23 @@ guest:x:100:guest }, { user: "405:2147483648", - err: "no groups found", + err: "invalid USER value \"405:2147483648\": gid out of range", }, { user: "-1000", - err: "no users found", + err: "invalid USER value \"-1000\": uid out of range", }, { user: "2147483648", - err: "no users found", + err: "invalid USER value \"2147483648\": uid out of range", + }, + { + user: "999999999999999999999999999999999999", + err: "invalid USER value \"999999999999999999999999999999999999\": uid out of range", + }, + { + user: "0:999999999999999999999999999999999999", + err: "invalid USER value \"0:999999999999999999999999999999999999\": gid out of range", }, } for _, testCase := range testCases {
a05ae7885038oci: return explicit error for out-of-range USER values
2 files changed · +36 −7
pkg/oci/spec_opts.go+25 −4 modified@@ -625,14 +625,25 @@ func WithUser(userstr string) SpecOpts { return nil } + isErrRange := func(err error) bool { + var numErr *strconv.NumError + return errors.As(err, &numErr) && numErr.Err == strconv.ErrRange + } + parts := strings.Split(userstr, ":") switch len(parts) { case 1: v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { - // if we cannot parse as an int32 then try to see if it is a username + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } + // Non-numeric user value; treat it as a username. return WithUsername(userstr)(ctx, client, c, s) } + if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } return WithUserID(uint32(v))(ctx, client, c, s) case 2: var ( @@ -641,14 +652,24 @@ func WithUser(userstr string) SpecOpts { ) var uid, gid uint32 v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } username = parts[0] + } else if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) } else { uid = uint32(v) } v, err = strconv.Atoi(parts[1]) - if err != nil || v < minGroupID || v > maxGroupID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) + } groupname = parts[1] + } else if v < minGroupID || v > maxGroupID { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) } else { gid = uint32(v) }
pkg/oci/spec_opts_user_test.go+11 −3 modified@@ -88,15 +88,23 @@ guest:x:100:guest }, { user: "405:2147483648", - err: "no groups found", + err: "invalid USER value \"405:2147483648\": gid out of range", }, { user: "-1000", - err: "no users found", + err: "invalid USER value \"-1000\": uid out of range", }, { user: "2147483648", - err: "no users found", + err: "invalid USER value \"2147483648\": uid out of range", + }, + { + user: "999999999999999999999999999999999999", + err: "invalid USER value \"999999999999999999999999999999999999\": uid out of range", + }, + { + user: "0:999999999999999999999999999999999999", + err: "invalid USER value \"0:999999999999999999999999999999999999\": gid out of range", }, } for _, testCase := range testCases {
d20c6267b88boci: return explicit error for out-of-range USER values
2 files changed · +36 −7
pkg/oci/spec_opts.go+25 −4 modified@@ -626,14 +626,25 @@ func WithUser(userstr string) SpecOpts { return nil } + isErrRange := func(err error) bool { + var numErr *strconv.NumError + return errors.As(err, &numErr) && numErr.Err == strconv.ErrRange + } + parts := strings.Split(userstr, ":") switch len(parts) { case 1: v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { - // if we cannot parse as an int32 then try to see if it is a username + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } + // Non-numeric user value; treat it as a username. return WithUsername(userstr)(ctx, client, c, s) } + if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } return WithUserID(uint32(v))(ctx, client, c, s) case 2: var ( @@ -642,14 +653,24 @@ func WithUser(userstr string) SpecOpts { ) var uid, gid uint32 v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } username = parts[0] + } else if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) } else { uid = uint32(v) } v, err = strconv.Atoi(parts[1]) - if err != nil || v < minGroupID || v > maxGroupID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) + } groupname = parts[1] + } else if v < minGroupID || v > maxGroupID { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) } else { gid = uint32(v) }
pkg/oci/spec_opts_linux_test.go+11 −3 modified@@ -94,15 +94,23 @@ guest:x:100:guest }, { user: "405:2147483648", - err: "no groups found", + err: "invalid USER value \"405:2147483648\": gid out of range", }, { user: "-1000", - err: "no users found", + err: "invalid USER value \"-1000\": uid out of range", }, { user: "2147483648", - err: "no users found", + err: "invalid USER value \"2147483648\": uid out of range", + }, + { + user: "999999999999999999999999999999999999", + err: "invalid USER value \"999999999999999999999999999999999999\": uid out of range", + }, + { + user: "0:999999999999999999999999999999999999", + err: "invalid USER value \"0:999999999999999999999999999999999999\": gid out of range", }, } for _, testCase := range testCases {
1a3d1c85e0d0oci: return explicit error for out-of-range USER values
2 files changed · +36 −7
pkg/oci/spec_opts.go+25 −4 modified@@ -622,14 +622,25 @@ func WithUser(userstr string) SpecOpts { return nil } + isErrRange := func(err error) bool { + var numErr *strconv.NumError + return errors.As(err, &numErr) && numErr.Err == strconv.ErrRange + } + parts := strings.Split(userstr, ":") switch len(parts) { case 1: v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { - // if we cannot parse as an int32 then try to see if it is a username + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } + // Non-numeric user value; treat it as a username. return WithUsername(userstr)(ctx, client, c, s) } + if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } return WithUserID(uint32(v))(ctx, client, c, s) case 2: var ( @@ -638,14 +649,24 @@ func WithUser(userstr string) SpecOpts { ) var uid, gid uint32 v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } username = parts[0] + } else if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) } else { uid = uint32(v) } v, err = strconv.Atoi(parts[1]) - if err != nil || v < minGroupID || v > maxGroupID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) + } groupname = parts[1] + } else if v < minGroupID || v > maxGroupID { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) } else { gid = uint32(v) }
pkg/oci/spec_opts_linux_test.go+11 −3 modified@@ -93,15 +93,23 @@ guest:x:100:guest }, { user: "405:2147483648", - err: "no groups found", + err: "invalid USER value \"405:2147483648\": gid out of range", }, { user: "-1000", - err: "no users found", + err: "invalid USER value \"-1000\": uid out of range", }, { user: "2147483648", - err: "no users found", + err: "invalid USER value \"2147483648\": uid out of range", + }, + { + user: "999999999999999999999999999999999999", + err: "invalid USER value \"999999999999999999999999999999999999\": uid out of range", + }, + { + user: "0:999999999999999999999999999999999999", + err: "invalid USER value \"0:999999999999999999999999999999999999\": gid out of range", }, } for _, testCase := range testCases {
503f479466b4oci: return explicit error for out-of-range USER values
2 files changed · +36 −7
oci/spec_opts.go+25 −4 modified@@ -623,14 +623,25 @@ func WithUser(userstr string) SpecOpts { return nil } + isErrRange := func(err error) bool { + var numErr *strconv.NumError + return errors.As(err, &numErr) && numErr.Err == strconv.ErrRange + } + parts := strings.Split(userstr, ":") switch len(parts) { case 1: v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { - // if we cannot parse as an int32 then try to see if it is a username + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } + // Non-numeric user value; treat it as a username. return WithUsername(userstr)(ctx, client, c, s) } + if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } return WithUserID(uint32(v))(ctx, client, c, s) case 2: var ( @@ -639,14 +650,24 @@ func WithUser(userstr string) SpecOpts { ) var uid, gid uint32 v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } username = parts[0] + } else if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) } else { uid = uint32(v) } v, err = strconv.Atoi(parts[1]) - if err != nil || v < minGroupID || v > maxGroupID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) + } groupname = parts[1] + } else if v < minGroupID || v > maxGroupID { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) } else { gid = uint32(v) }
oci/spec_opts_linux_test.go+11 −3 modified@@ -93,15 +93,23 @@ guest:x:100:guest }, { user: "405:2147483648", - err: "no groups found", + err: "invalid USER value \"405:2147483648\": gid out of range", }, { user: "-1000", - err: "no users found", + err: "invalid USER value \"-1000\": uid out of range", }, { user: "2147483648", - err: "no users found", + err: "invalid USER value \"2147483648\": uid out of range", + }, + { + user: "999999999999999999999999999999999999", + err: "invalid USER value \"999999999999999999999999999999999999\": uid out of range", + }, + { + user: "0:999999999999999999999999999999999999", + err: "invalid USER value \"0:999999999999999999999999999999999999\": gid out of range", }, } for _, testCase := range testCases {
503f479466430a8f65bef19bMerge pull request #13448 from samuelkarp/oci-withuser-errrange-2.2
2 files changed · +36 −7
pkg/oci/spec_opts.go+25 −4 modified@@ -626,14 +626,25 @@ func WithUser(userstr string) SpecOpts { return nil } + isErrRange := func(err error) bool { + var numErr *strconv.NumError + return errors.As(err, &numErr) && numErr.Err == strconv.ErrRange + } + parts := strings.Split(userstr, ":") switch len(parts) { case 1: v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { - // if we cannot parse as an int32 then try to see if it is a username + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } + // Non-numeric user value; treat it as a username. return WithUsername(userstr)(ctx, client, c, s) } + if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } return WithUserID(uint32(v))(ctx, client, c, s) case 2: var ( @@ -642,14 +653,24 @@ func WithUser(userstr string) SpecOpts { ) var uid, gid uint32 v, err := strconv.Atoi(parts[0]) - if err != nil || v < minUserID || v > maxUserID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) + } username = parts[0] + } else if v < minUserID || v > maxUserID { + return fmt.Errorf("invalid USER value %q: uid out of range", userstr) } else { uid = uint32(v) } v, err = strconv.Atoi(parts[1]) - if err != nil || v < minGroupID || v > maxGroupID { + if err != nil { + if isErrRange(err) { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) + } groupname = parts[1] + } else if v < minGroupID || v > maxGroupID { + return fmt.Errorf("invalid USER value %q: gid out of range", userstr) } else { gid = uint32(v) }
pkg/oci/spec_opts_linux_test.go+11 −3 modified@@ -94,15 +94,23 @@ guest:x:100:guest }, { user: "405:2147483648", - err: "no groups found", + err: "invalid USER value \"405:2147483648\": gid out of range", }, { user: "-1000", - err: "no users found", + err: "invalid USER value \"-1000\": uid out of range", }, { user: "2147483648", - err: "no users found", + err: "invalid USER value \"2147483648\": uid out of range", + }, + { + user: "999999999999999999999999999999999999", + err: "invalid USER value \"999999999999999999999999999999999999\": uid out of range", + }, + { + user: "0:999999999999999999999999999999999999", + err: "invalid USER value \"0:999999999999999999999999999999999999\": gid out of range", }, } for _, testCase := range testCases {
Vulnerability mechanics
Root cause
"In `WithUser` in `pkg/oci/spec_opts.go`, a numeric `USER` value that overflows `strconv.Atoi` (e.g., a value larger than max int) is treated as a username instead of being rejected, allowing a crafted `/etc/passwd` to map that string to UID 0."
Attack vector
An attacker builds a container image whose Dockerfile `USER` directive is set to a very large numeric string (e.g., `999999999999999999999999999999999999`) that cannot be parsed as a 32-bit integer. The image also includes a crafted `/etc/passwd` file that maps that same large string to the root user (UID 0). When containerd processes the `USER` directive, `strconv.Atoi` returns a `strconv.ErrRange` error, and the old code falls back to `WithUsername`, which looks up the string in `/etc/passwd` and resolves it to UID 0. This bypasses Kubernetes `runAsNonRoot` enforcement, causing the container to run as root despite the policy.
Affected code
The vulnerable function is `WithUser` in `pkg/oci/spec_opts.go`. The bug is in the `strconv.Atoi` error handling: when `Atoi` returns an error (including `ErrRange` for overflow), the code unconditionally falls back to `WithUsername`/username lookup instead of distinguishing between a non-numeric string and an out-of-range numeric value.
What the fix does
The patch adds an `isErrRange` helper that detects `strconv.ErrRange` errors from `strconv.Atoi`. When an out-of-range numeric value is encountered, the code now returns an explicit error (e.g., `"invalid USER value ...: uid out of range"`) instead of falling through to the username lookup path. The same logic is applied to both the single-value (UID) and colon-separated (UID:GID) cases. This ensures that any numeric `USER` value that cannot be represented as a valid 32-bit integer is rejected outright, preventing the username-based bypass.
Preconditions
- inputAttacker must be able to build and push a container image with a crafted USER directive and /etc/passwd file
- configThe container runtime must be containerd (versions before the fix)
- configKubernetes cluster must have runAsNonRoot enforcement enabled
Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.