Skip to content

php:*-apache + cap-drop ALL: mod_unixd setgid() failure leaves Apache workers running as UID 0 (privilege separation silently broken) #1707

Open

Description

@srayuth089

Summary

On the php:8.4.26-apache image (also reproduced with a locally built variant), running the
container with all capabilities dropped while the Apache parent starts as root breaks Apache's
privilege separation silently from a security perspective: mod_unixd cannot setgid()
(no CAP_SETGID), logs an alert, but the worker still serves requests as UID 0 instead of
www-data.

This matters because "root parent + --cap-drop ALL" is a common hardening recipe, and the
result is the opposite of the intended outcome: a compromised worker (any RCE in a PHP app)
executes as UID 0 rather than www-data.

Reproduction

docker run -d --name t --read-only --cap-drop ALL \
  --tmpfs /var/run/apache2 \
  -p 127.0.0.1:8080:80 php:8.4.26-apache
sleep 3
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/   # 200 — service looks healthy
docker top t -o user,pid,ppid,args
docker logs t 2>&1 | grep AH02156

Observed:

$ docker top t -o user,pid,ppid,args
USER   PID    PPID   COMMAND
root   1692   1669   /usr/sbin/apache2 -DFOREGROUND
root   3261   1692   /usr/sbin/apache2 -DFOREGROUND      <- worker, UID 0

$ docker logs t | grep -m1 AH02156
[unixd:alert] [pid 36] (1)Operation not permitted: AH02156: setgid: unable to set group id to Group 33

/proc/<worker>/status confirms Uid: 0 0 0 0 (with CapEff: 0 — so this is not full
root capability-wise, but it still owns every root-owned file on the filesystem for DAC
purposes, e.g. it can read /etc/shadow via owner bits, which www-data cannot).

The alert appears once per forked worker; HTTP keeps serving 200s, so nothing in normal
operation tells the operator that privilege separation is gone.

For contrast, starting the same image properly as non-root
(--user 33:33, unprivileged port, tmpfs /var/run/apache2 owned by 33) works fine and
workers run as www-data as expected — no AH02156.

Context

We hit this while testing exploit containment for the recent PHP vulnerabilities
(CVE-2026-109618 / CVE-2026-109619, "php2shell") under hardened-container recipes. Full
write-up of the matched-pair experiment:
https://github.com/srayuth089 (lab notes) — happy to attach raw logs if useful.

Questions

  1. Is this behavior known / already being worked on (I couldn't find an existing issue)?
  2. In the team's view, does this warrant a fix in the image (e.g. fail loudly at startup if
    the parent is root but CAP_SETGID/CAP_SETUID are missing), or is it best handled as
    documentation (a short "non-root usage" section in the image README describing the
    supported pattern)?
  3. If documentation is the preferred route, we're happy to send a PR with the verified
    non-root recipe (user 33, port >1024, /etc/apache2/envvars, tmpfs ownership) — would
    that be welcome in docker-library/docs, here, or both?

Not claiming this is a CVE by itself — it's a hardening-guidance foot-gun that inverts the
user's security intent, discovered during authorized lab research.

Activity

  1. joshtrichards commented on Oct 24, 2026

    @joshtrichards

    Apache treats setgid() failing as “not handled” and ultimately returns OK, so the MPM lets the child continue startup per the source code.

    I find that a little odd... A hard error seems more logical to me, but admittedly it may be behavior from a more friendly-era.

    Back to the image: I'm not sure the image logic should try to behave differently than Apache itself, but maybe it's worth exploring whether Apache should/could handle the condition more strictly...

  2. srayuth089 commented on Oct 25, 2026

    @srayuth089
    Author

    Thanks @joshtrichards — that matches our observations exactly. For completeness, the failure mode we measured (in case it's useful for deciding where the fix belongs):

    • Worker keeps serving HTTP 200 as UID 0 (/proc/<worker>/status: Uid: 0, CapEff: 0) — only trace is the AH02156 alert per fork, so from an operator's view it is silent.
    • One deployment-relevant asymmetry worth noting: with all capabilities dropped, the resulting UID-0-no-caps worker can read root-owned files (owner bits, e.g. /etc/shadow in the container) but cannot read app-UID-owned 0600 files (no CAP_DAC_OVERRIDE), while a correct non-root startup inverts that. So the silent failure doesn't just reduce safety — it silently changes the post-compromise blast radius.
    • We verified a working non-root recipe end-to-end (user 33, unprivileged port, sourcing /etc/apache2/envvars, tmpfs /var/run/apache2 owned by the runtime UID): no AH02156, workers as www-data.

    Agreed the image shouldn't diverge from httpd behavior; raising the strictness upstream with httpd sounds like the right home for the fix. Happy to send a docs PR for the non-root recipe here or in docker-library/docs if welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions