From 0cf21b886bdbd830bb2eef119b91ebf9677ffbe0 Mon Sep 17 00:00:00 2001 From: omer Date: Thu, 22 Oct 2026 20:36:51 +0300 Subject: [PATCH] Add Docker support: scan/protect/hunt --docker and --container Run scan, protect and hunt against running containers through docker exec/cp, alongside the existing host mode. Adds unit tests and a CI docker e2e job. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 + CHANGELOG.md | 18 + README.md | 67 +- php2shell-guard.py | 1792 +++++++++++++++++++++++++++++--------- quickcheck.sh | 11 +- tests/test_guard.py | 232 +++++ 6 files changed, 1715 insertions(+), 431 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fa3bb1..9cd47ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,3 +73,29 @@ jobs: ! grep -rl php2shell-guard /etc/php /etc/apache2 python3 /g/php2shell-guard.py --no-color hunt --days 3 || true ' + + docker: + # --docker end to end against official images: no Python inside the containers. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: scan / protect / rollback / hunt through docker exec + run: | + set -e + docker run -d --name fixed php:8.3-fpm-alpine + docker run -d --name old php:8.3.20-fpm + docker run -d --name web nginx:alpine + sleep 3 + python3 php2shell-guard.py --no-color scan --docker || true + python3 php2shell-guard.py --no-color scan --container fixed --json | python3 -c "import sys,json; d=json.load(sys.stdin); assert [x['verdict'] for x in d['docker']]==['PATCHED'], d['docker']" + python3 php2shell-guard.py --no-color scan --container old --json | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['verdict']=='VULNERABLE', d['verdict']" + python3 php2shell-guard.py --no-color protect --container old --apply --post-max 32M --restart | tee protect.log + grep -q "workers replaced" protect.log + docker exec old php-fpm -i | grep -E "^session.upload_progress.enabled => Off" + docker exec old php-fpm -tt 2>&1 | grep -q "php_admin_value\[post_max_size\] = 32M" + python3 php2shell-guard.py --no-color protect --container old --rollback --apply --restart + if docker exec old sh -c 'grep -rq "BEGIN php2shell-guard" /usr/local/etc || [ -e /usr/local/etc/php/conf.d/zz-php2shell-guard.ini ]'; then + echo "rollback left php2shell-guard config behind"; exit 1 + fi + docker exec old php-fpm -i | grep -E "^session.upload_progress.enabled => On" + python3 php2shell-guard.py --no-color hunt --docker --days 1 || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fbffca..0141843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased + +- Docker containers are first-class targets. `scan --docker` runs the full host + assessment inside every running container through `docker exec` (binaries, patch + markers, running/stale workers, effective ini, FPM pools, packages, Apache + directives) with Compose-aware rebuild hints; `--container NAME` narrows it. +- `protect --docker` writes the drop-in, FPM pool blocks and Apache conf inside + containers, reloads FPM/Apache gracefully with `--restart` (verified by worker PIDs), + supports `--rollback`, and explains how to persist the change past a recreate. +- `hunt --docker`: `docker logs`, in-container or bind-mounted error logs, session + files and webroots (streamed with `docker cp`, or walked on the host for bind mounts), + shells spawned by web workers, and `docker diff` checks for PHP and persistence files + in the writable layer. +- `hunt`: the shell-family webshell signature no longer matches bare `c99`/`wso` + substrings (hashes in compiled Blade views, identifiers such as `shouldShowSocialIds`). +- `hunt`: shells spawned by Debian-named workers (`php-fpm8.3`) are now recognised. +- `scan` without `--docker` points at it when the host has no PHP but Docker is installed. + ## 1.0.0 - 2026-10-22 Initial release, one day after the php.net fixed releases. diff --git a/README.md b/README.md index 0c68012..826f590 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ inert WAF coverage probe. sudo python3 php2shell-guard.py scan # am I patched? (binaries, packages, workers, ini, FPM pools, Docker) sudo python3 php2shell-guard.py protect --apply # php.net interim mitigations, with --rollback sudo python3 php2shell-guard.py hunt # was I hit? (logs, session files, webroots, worker children) +python3 php2shell-guard.py scan --docker # the same for PHP inside running containers (no root needed) python3 php2shell-guard.py remote -f hosts.txt # fleet banner check (your own hosts) python3 php2shell-guard.py rules modsecurity # WAF / proxy / IDS snippets sh quickcheck.sh # no python? 10-second answer @@ -99,12 +100,13 @@ What it looks at: plus FPM pool `php_admin_*` overrides and `pm.max_requests`; * Apache `php_value`/`php_admin_value`/`RLimitMEM` directives (php.net warns against `RLIMIT_AS`/`RLIMIT_DATA`: they cause exactly the allocation failure the bug needs); -* with `--docker`, every running container: PHP version and patch markers inside it. +* with `--docker`, the same checks inside every running container (see + [Docker containers](#docker-containers)). Severity drops automatically once a runtime is confirmed patched (e.g. upload progress still enabled becomes LOW, as php.net suggests keeping it off anyway). -Options: `--json`, `-o report.json`, `--docker`, `--no-config` (faster). Exit code 0 +Options: `--json`, `-o report.json`, `--docker`, `--container NAME`, `--no-config` (faster). Exit code 0 patched, 1 vulnerable, 2 undetermined. ### `protect` — interim mitigations with rollback @@ -162,6 +164,61 @@ Exit code 1 when a CRITICAL/HIGH indicator was found. Amazon observed the first exploitation attempt on **2026-10-17**, four days before disclosure; use a window that reaches back at least that far. +### Docker containers + +Official `php:*` images have no Python and no `ps`, and the host's `PATH` and `/proc` +walk never see the PHP inside them. With `--docker` every command runs its checks +inside the running containers from the host: probes go through `docker exec` (as root +in the container), file trees are streamed out with `docker cp`, and bind mounts from +`docker inspect` are read on the host directly. Docker access is enough, no `sudo`. + +```bash +python3 php2shell-guard.py scan --docker # host + every running container +python3 php2shell-guard.py scan --container php74 --json # one container (name or id prefix, repeatable) +python3 php2shell-guard.py protect --docker # dry run inside every PHP container +python3 php2shell-guard.py protect --docker --apply --restart # write + graceful FPM/Apache reload +python3 php2shell-guard.py protect --docker --rollback --apply --restart +python3 php2shell-guard.py hunt --docker --days 14 +``` + +``` +== Docker containers with PHP (2) == + PATCHED php83 image=docker-multi-php-php83 compose docker-multi-php/php83 + PATCHED /usr/local/sbin/php-fpm + kind=fpm version=8.3.36 (-v) status=patched: fixed release + patch-markers=memory_hard_limit_percent running pid 1 found-via=PATH,path-glob,process + VULNERABLE php74 image=docker-multi-php-php74 compose docker-multi-php/php74 + ... + [CRITICAL] php74:/usr/local/sbin/php-fpm (fpm) is unpatched (PHP 7.4.33, branch 7.4 has no upstream fix ...) + -> rebuild on PHP 8.2+ (e.g. php:8.3-fpm; ...): update FROM in the service's Dockerfile, then + docker compose -f .../docker-compose.yml build --pull php74 && docker compose -f ... up -d php74 +``` + +| Command | Inside each container | +|---|---| +| `scan` | every PHP binary and `libphp*.so` (PATH, known layouts, process exes and maps), version, patch markers, running and stale workers, effective ini per binary, FPM pool dump, dpkg/rpm/apk/pacman packages, Apache directives; fix hints use the Compose service and project files when the container came from Compose | +| `protect` | the same drop-in / pool block / Apache conf as on a host, written with `docker exec`; `--restart` sends `USR2` to each FPM master and `USR1` to Apache (graceful) and checks that the workers were replaced, falling back to `docker restart` only when neither is found | +| `hunt` | `docker logs` for the window, error logs inside the container (or on the host when bind-mounted), session files, webroots (bind mounts are walked on the host once even when several services share them), shells spawned by web workers, and `docker diff`: PHP files and cron / ssh / passwd / `ld.so.preload` changes in the container's writable layer | + +With `--docker`, `protect` and `hunt` work on the containers instead of the host, and +`hunt --logs/--session-dir/--webroot` name paths inside the containers. + +Changes `protect` makes inside a container live in its writable layer: they survive +`docker restart` but are gone after `docker compose up --build` / `--force-recreate` or +an image update. Files on a bind mount are changed on the host and persist. After +`--apply` it prints the `docker cp` command and the read-only volume line that keep the +drop-in, for example: + +```yaml +services: + php74: + volumes: + - ./zz-php2shell-guard.ini:/usr/local/etc/php/conf.d/zz-php2shell-guard.ini:ro +``` + +The real fix for a container is a rebuild on a fixed base image (`docker compose build +--pull` only helps when the Dockerfile's `FROM` already points at a fixed branch). + ### `remote` — fleet banner check (your own hosts) ```bash @@ -260,7 +317,7 @@ If your layout is missing, open an issue with `php --ini` output. | `scan` | all patched / no PHP | something vulnerable | undetermined | usage | | `hunt` | no CRITICAL/HIGH indicator | indicator found | | usage | | `remote` | nothing likely vulnerable | likely vulnerable target | all unreachable / no banner | usage | -| `protect` | done / dry run | | | needs root / bad args | +| `protect` | done / dry run | | | needs root / bad args / Docker unreachable | ## Limitations @@ -273,6 +330,10 @@ If your layout is missing, open an issue with `php --ini` output. HTTP 200 with no error logged at all. * `remote` cannot see configuration or backports and is deliberately passive. * `protect` edits configuration; read the dry run first and keep the `.bak` files. +* `--docker` sees running containers only; stopped containers and images are not + inspected. Containers without `sh` (distroless) are skipped. Bind mounts are only read + on the host when the Docker daemon shares this machine's filesystem (Docker Desktop, + OrbStack, Linux); with a remote `DOCKER_HOST` everything goes through the container. ## References diff --git a/php2shell-guard.py b/php2shell-guard.py index 8a19ab1..3396198 100755 --- a/php2shell-guard.py +++ b/php2shell-guard.py @@ -7,9 +7,11 @@ scan audit this host: every PHP binary, running worker, package and effective configuration, with a per-install verdict + (--docker: the same inside every running container) protect apply (or roll back) the php.net-advised mitigations as drop-ins + (--docker: inside running containers) hunt look for exploitation traces (logs, session files, webroots, - web-worker child processes, cron) + web-worker child processes, cron; --docker: in containers) remote passive banner scan of your own hosts, optional WAF coverage check rules print ModSecurity / nginx / Apache / HAProxy / Suricata snippets @@ -23,15 +25,18 @@ import argparse import concurrent.futures +import contextlib import datetime import glob import gzip import hashlib import io +import itertools import json import mmap import os import platform +import posixpath import re import shutil import socket @@ -39,6 +44,7 @@ import stat import subprocess import sys +import tarfile import time import urllib.error import urllib.parse @@ -195,6 +201,11 @@ "/etc/apache2", "/etc/httpd", "/usr/local/apache/conf", "/usr/local/etc/apache24", "/etc/apache24", "/opt/homebrew/etc/httpd", "/usr/local/etc/httpd", "/etc/httpd24", ] +# Directories Apache includes automatically (protect writes its conf into the first one found). +APACHE_INCLUDE_DIRS = ( + "/etc/apache2/conf-available", "/etc/httpd/conf.d", "/usr/local/apache/conf/includes", + "/usr/local/etc/apache24/Includes", "/etc/apache24/Includes", "/opt/homebrew/etc/httpd/extra", +) SESSION_DIR_CANDIDATES = [ "/var/lib/php/sessions", "/var/lib/php/session", "/var/lib/php*/sessions", "/var/lib/php*/session", "/var/lib/php/*/sessions", "/var/opt/remi/php*/lib/php/session", @@ -250,8 +261,9 @@ def is_root(): return False -def run(cmd, timeout=20, env_extra=None, stdin_data=None): - """Run a command, return (rc, stdout, stderr). Never raises.""" +def run(cmd, timeout=20, env_extra=None, stdin_data=None, binary=False): + """Run a command, return (rc, stdout, stderr). Never raises. + stdout stays bytes when binary is set.""" env = dict(os.environ) env.setdefault("LC_ALL", "C") if env_extra: @@ -259,9 +271,10 @@ def run(cmd, timeout=20, env_extra=None, stdin_data=None): try: p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, env=env, input=stdin_data) - return p.returncode, p.stdout.decode("utf-8", "replace"), p.stderr.decode("utf-8", "replace") + out = p.stdout if binary else p.stdout.decode("utf-8", "replace") + return p.returncode, out, p.stderr.decode("utf-8", "replace") except (OSError, subprocess.SubprocessError) as e: - return -1, "", str(e) + return -1, b"" if binary else "", str(e) def which(name): @@ -275,6 +288,75 @@ def expand_globs(patterns): return out +class HostContext(object): + """Commands and files on this machine. Container (see the Docker section) + offers the same methods through docker exec, so the scan / protect code + below works on either.""" + name = None + join = staticmethod(os.path.join) + + def run(self, cmd, timeout=20, stdin_data=None, binary=False): + return run(cmd, timeout=timeout, stdin_data=stdin_data, binary=binary) + + def which(self, name): + return which(name) + + def isfile(self, path): + return os.path.isfile(path) + + def isdir(self, path): + return os.path.isdir(path) + + def exists(self, path): + return os.path.exists(path) + + def glob(self, pattern): + return glob.glob(pattern) + + def existing(self, patterns, kind="f"): + test = os.path.isdir if kind == "d" else os.path.isfile + return sorted(set(p for p in expand_globs(patterns) if test(p))) + + def read_text(self, path): + try: + with open(path, "r", errors="replace") as fh: + return fh.read() + except OSError: + return None + + def read_bytes(self, path, limit): + try: + with open(path, "rb") as fh: + return fh.read(limit) + except OSError: + return None + + def write_text(self, path, text, mode=None): + with open(path, "w") as fh: + fh.write(text) + if mode is not None: + os.chmod(path, mode) + + def backup(self, path, bak): + if not os.path.exists(bak): + shutil.copy2(path, bak) + + def remove(self, path): + os.remove(path) + + def markers(self, path): + return read_marker_hits(path) + + def bytes_version(self, path): + return version_from_bytes(path) + + def where(self, path): + return path + + +HOST = HostContext() + + # -------------------------------------------------------------------------- # Version logic # -------------------------------------------------------------------------- @@ -530,19 +612,20 @@ def binary_kind(path): return "cli" -def php_version_of(path, kind): +def php_version_of(path, kind, ctx=None): + ctx = ctx or HOST if kind == "module": - return version_from_bytes(path), "bytes" + return ctx.bytes_version(path), "bytes" if kind == "frankenphp": - rc, out, err = run([path, "version"], timeout=15) + rc, out, err = ctx.run([path, "version"], timeout=15) m = re.search(r"PHP (\d+\.\d+\.\d+\S*)", out + err) if m: return m.group(1), "frankenphp version" - rc, out, err = run([path, "-n", "-v"], timeout=15) + rc, out, err = ctx.run([path, "-n", "-v"], timeout=15) m = re.search(r"PHP (\d+\.\d+\.\d+[^\s]*)", out + err) if m: return m.group(1).rstrip(","), "-v" - v = version_from_bytes(path) + v = ctx.bytes_version(path) return v, "bytes" if v else "none" @@ -711,24 +794,28 @@ def running_processes(): # Package managers # -------------------------------------------------------------------------- -def changelog_mentions_cve(paths): +def changelog_mentions_cve(paths, ctx=None): + ctx = ctx or HOST for p in paths: - try: - opener = gzip.open if p.endswith(".gz") else open - with opener(p, "rb") as fh: - data = fh.read(400000) - if re.search(rb"CVE-2026-10961[89]|php2shell", data, re.I): - return p - except OSError: + data = ctx.read_bytes(p, 8 * 1024 * 1024) + if not data: continue + if p.endswith(".gz"): + try: + data = gzip.GzipFile(fileobj=io.BytesIO(data)).read(400000) + except (OSError, EOFError): + continue + if re.search(rb"CVE-2026-10961[89]|php2shell", data[:400000], re.I): + return p return None -def check_dpkg(): - if not which("dpkg-query"): +def check_dpkg(ctx=None): + ctx = ctx or HOST + if not ctx.which("dpkg-query"): return [] - rc, out, _ = run(["dpkg-query", "-W", "-f=${Package}\t${Version}\t${db:Status-Status}\t${Source}\n", "php*", - "libapache2-mod-php*"], timeout=30) + rc, out, _ = ctx.run(["dpkg-query", "-W", "-f=${Package}\t${Version}\t${db:Status-Status}\t${Source}\n", "php*", + "libapache2-mod-php*"], timeout=30) if rc != 0: return [] results = [] @@ -762,18 +849,19 @@ def check_dpkg(): status, how = "vulnerable", "upstream version %s predates the fix and no known backport matches" % up if status != "patched": cl = changelog_mentions_cve(["/usr/share/doc/%s/changelog.Debian.gz" % pkg, - "/usr/share/doc/%s/changelog.gz" % pkg]) + "/usr/share/doc/%s/changelog.gz" % pkg], ctx) if cl: status, how = "patched", "changelog references the CVE (%s)" % cl results.append({"manager": "dpkg", "package": pkg, "version": ver, "status": status, "detail": how}) return results -def check_rpm(): - if not which("rpm"): +def check_rpm(ctx=None): + ctx = ctx or HOST + if not ctx.which("rpm"): return [] - rc, out, _ = run(["rpm", "-qa", "--qf", "%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\n", - "php*", "*-php", "*-php-common", "*-php-fpm", "*-php-cli", "plesk-php*", "ea-php*"], timeout=40) + rc, out, _ = ctx.run(["rpm", "-qa", "--qf", "%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\n", + "php*", "*-php", "*-php-common", "*-php-fpm", "*-php-cli", "plesk-php*", "ea-php*"], timeout=40) if rc != 0: return [] results, seen = [], set() @@ -789,7 +877,7 @@ def check_rpm(): continue seen.add(key) status, how = "unknown", "build not in the known-fixed table" - rc2, cl, _ = run(["rpm", "-q", "--changelog", name], timeout=30) + rc2, cl, _ = ctx.run(["rpm", "-q", "--changelog", name], timeout=30) if rc2 == 0 and re.search(r"CVE-2026-10961[89]|php2shell", cl, re.I): status, how = "patched", "rpm changelog references the CVE" else: @@ -809,10 +897,11 @@ def check_rpm(): return results -def check_apk(): - if not which("apk"): +def check_apk(ctx=None): + ctx = ctx or HOST + if not ctx.which("apk"): return [] - rc, out, _ = run(["apk", "info", "-v"], timeout=30) + rc, out, _ = ctx.run(["apk", "info", "-v"], timeout=30) results = [] for line in out.splitlines(): m = re.match(r"^(php\d\d(?:-(?:fpm|cgi|common|phpdbg))?)-(\d+\.\d+\.\d+)(-r\d+)$", line.strip()) @@ -825,10 +914,11 @@ def check_apk(): return results -def check_pacman(): - if not which("pacman"): +def check_pacman(ctx=None): + ctx = ctx or HOST + if not ctx.which("pacman"): return [] - rc, out, _ = run(["pacman", "-Q"], timeout=30) + rc, out, _ = ctx.run(["pacman", "-Q"], timeout=30) results = [] for line in out.splitlines(): m = re.match(r"^(php(?:-legacy|\d\d)?(?:-fpm|-cgi)?)\s+(\S+)$", line.strip()) @@ -855,11 +945,13 @@ def check_brew(): return results -def check_packages(): +def check_packages(ctx=None): + ctx = ctx or HOST out = [] - for fn in (check_dpkg, check_rpm, check_apk, check_pacman, check_brew): + checks = (check_dpkg, check_rpm, check_apk, check_pacman) + ((check_brew,) if ctx is HOST else ()) + for fn in checks: try: - out.extend(fn()) + out.extend(fn() if fn is check_brew else fn(ctx)) except Exception as e: # pragma: no cover - defensive out.append({"manager": fn.__name__, "package": "?", "version": "?", "status": "unknown", "detail": "error: %s" % e}) return out @@ -916,66 +1008,67 @@ def parse_phpinfo_text(text): return conf, info -def effective_ini_from_binary(path, kind): +def effective_ini_from_binary(path, kind, ctx=None): """Ask the binary itself for its effective configuration (`-i`).""" + ctx = ctx or HOST if kind == "module": return None, None, "module (no executable)" if kind == "frankenphp": - rc, out, err = run([path, "php-cli", "-i"], timeout=20) + rc, out, err = ctx.run([path, "php-cli", "-i"], timeout=20) else: - rc, out, err = run([path, "-i"], timeout=20) + rc, out, err = ctx.run([path, "-i"], timeout=20) if rc != 0 and "=>" not in out: return None, None, (err.strip().splitlines() or ["-i failed"])[-1] conf, info = parse_phpinfo_text(out) return conf, info, None -def parse_ini_file(path): +def parse_ini_text(text): vals = {} - try: - with open(path, "r", errors="replace") as fh: - for raw in fh: - line = raw.strip() - if not line or line[0] in ";#[": - continue - m = re.match(r"^([\w.]+)\s*=\s*(.*?)\s*(?:;.*)?$", line) - if m: - vals[m.group(1)] = m.group(2).strip().strip('"') - except OSError: - pass + for raw in (text or "").splitlines(): + line = raw.strip() + if not line or line[0] in ";#[": + continue + m = re.match(r"^([\w.]+)\s*=\s*(.*?)\s*(?:;.*)?$", line) + if m: + vals[m.group(1)] = m.group(2).strip().strip('"') return vals -def resolve_static_ini(ini_file, scan_dir): +def parse_ini_file(path, ctx=None): + return parse_ini_text((ctx or HOST).read_text(path)) + + +def resolve_static_ini(ini_file, scan_dir, ctx=None): """Resolve php.ini + scan dir drop-ins the way PHP does (alphabetical).""" + ctx = ctx or HOST vals, files = {}, [] - if ini_file and os.path.isfile(ini_file): - vals.update(parse_ini_file(ini_file)) + if ini_file and ctx.isfile(ini_file): + vals.update(parse_ini_file(ini_file, ctx)) files.append(ini_file) - if scan_dir and os.path.isdir(scan_dir): - for f in sorted(glob.glob(os.path.join(scan_dir, "*.ini"))): - vals.update(parse_ini_file(f)) + if scan_dir and ctx.isdir(scan_dir): + for f in sorted(ctx.glob(ctx.join(scan_dir, "*.ini"))): + vals.update(parse_ini_file(f, ctx)) files.append(f) conf = {k: (v, v) for k, v in vals.items() if k in INI_KEYS} return conf, files -def sapi_ini_dirs_for(path): +def sapi_ini_dirs_for(path, ctx=None): """For a binary, guess sibling per-SAPI ini dirs (Debian layout).""" + ctx = ctx or HOST out = [] m = re.search(r"/etc/php/(\d\.\d)/", path) if m: for s in ("apache2", "fpm", "cgi", "litespeed", "embed"): d = "/etc/php/%s/%s" % (m.group(1), s) - if os.path.isdir(d): - out.append((s, os.path.join(d, "php.ini"), os.path.join(d, "conf.d"))) + if ctx.isdir(d): + out.append((s, ctx.join(d, "php.ini"), ctx.join(d, "conf.d"))) return out -def fpm_pool_overrides(fpm_bin): - """Dump FPM config with -tt and collect per-pool overrides.""" - rc, out, err = run([fpm_bin, "-tt"], timeout=20) - text = out + err +def parse_fpm_tt(text): + """Per-pool php_* overrides and pm settings from `php-fpm -tt` output.""" pools, cur = {}, None for line in text.splitlines(): m = re.search(r"NOTICE: \[([^\]]+)\]\s*$", line) @@ -992,10 +1085,38 @@ def fpm_pool_overrides(fpm_bin): m = re.search(r"NOTICE:\s+(pm\.max_requests|pm|pm\.max_children|listen|user)\s*=\s*(.*)$", line) if m: pools[cur][m.group(1)] = ("setting", m.group(2).strip()) + return pools + + +def fpm_pool_overrides(fpm_bin, ctx=None): + """Dump FPM config with -tt and collect per-pool overrides.""" + rc, out, err = (ctx or HOST).run([fpm_bin, "-tt"], timeout=20) + text = out + err + pools = parse_fpm_tt(text) ok = rc == 0 and ("test is successful" in text or pools) return pools, ok, (text.strip().splitlines() or [""])[-1] +APACHE_PHP_DIRECTIVE_RE = re.compile(r"(php_(?:admin_)?(?:value|flag))\s+(session\.upload_progress\.enabled|memory_limit|post_max_size|upload_max_filesize|fatal_error_backtraces|max_input_nesting_level)\s+(\S+)", re.I) + + +def apache_directive(line): + """(kind, key, value) for an Apache line this tool cares about, else None.""" + s = line.strip() + if not s or s.startswith("#"): + return None + m = APACHE_PHP_DIRECTIVE_RE.match(s) + if m: + return m.group(1).lower(), m.group(2).lower(), m.group(3) + m = re.match(r"(RLimitMEM)\s+(.*)", s, re.I) + if m: + return "rlimitmem", "RLimitMEM", m.group(2) + m = re.match(r"(LimitRequestBody)\s+(\d+)", s, re.I) + if m: + return "limitrequestbody", "LimitRequestBody", m.group(2) + return None + + def scan_apache_configs(): findings, seen_files, seen_keys = [], set(), set() for conf in iter_conf_files(APACHE_CONF_DIRS, exts=(".conf", ".htaccess")): @@ -1006,22 +1127,15 @@ def scan_apache_configs(): try: with open(conf, "r", errors="replace") as fh: for n, line in enumerate(fh, 1): - s = line.strip() - if not s or s.startswith("#"): + d = apache_directive(line) + if not d: continue - m = re.match(r"(php_(?:admin_)?(?:value|flag))\s+(session\.upload_progress\.enabled|memory_limit|post_max_size|upload_max_filesize|fatal_error_backtraces|max_input_nesting_level)\s+(\S+)", s, re.I) - if m: - key = (real, m.group(1).lower(), m.group(2).lower(), m.group(3).lower()) + if d[0].startswith("php_"): + key = (real, d[0], d[1], d[2].lower()) if key in seen_keys: continue seen_keys.add(key) - findings.append({"file": conf, "line": n, "kind": m.group(1).lower(), "key": m.group(2).lower(), "value": m.group(3)}) - m = re.match(r"(RLimitMEM)\s+(.*)", s, re.I) - if m: - findings.append({"file": conf, "line": n, "kind": "rlimitmem", "key": "RLimitMEM", "value": m.group(2)}) - m = re.match(r"(LimitRequestBody)\s+(\d+)", s, re.I) - if m: - findings.append({"file": conf, "line": n, "kind": "limitrequestbody", "key": "LimitRequestBody", "value": m.group(2)}) + findings.append({"file": conf, "line": n, "kind": d[0], "key": d[1], "value": d[2]}) except OSError: continue return findings @@ -1031,7 +1145,8 @@ def scan_apache_configs(): # scan # -------------------------------------------------------------------------- -def assess_install(entry, procs, pkgs, no_config=False): +def assess_install(entry, procs, pkgs, no_config=False, ctx=None): + ctx = ctx or HOST path = entry["path"] kind = binary_kind(path) res = {"path": path, "kind": kind, "sources": sorted(entry["sources"]), "version": None, @@ -1042,11 +1157,11 @@ def assess_install(entry, procs, pkgs, no_config=False): res["verdict"] = "UNKNOWN" res["findings"].append(("INFO", "binary not readable (run as root for a full check)", "")) return res - ver, how = php_version_of(path, kind) + ver, how = php_version_of(path, kind, ctx) res["version"], res["version_source"] = ver, how st, why = classify_version(ver or "") res["version_status"], res["version_reason"] = st, why - res["fingerprint"] = sorted(read_marker_hits(path)) + res["fingerprint"] = sorted(ctx.markers(path)) for p in procs: if p["exe"] == path or path in p.get("php_modules", []): @@ -1056,7 +1171,7 @@ def assess_install(entry, procs, pkgs, no_config=False): # effective config if not no_config: - conf, info, note = effective_ini_from_binary(path, kind) + conf, info, note = effective_ini_from_binary(path, kind, ctx) res["config_note"] = note if conf is not None: res["config"] = {k: v[0] for k, v in conf.items()} @@ -1068,12 +1183,15 @@ def assess_install(entry, procs, pkgs, no_config=False): if m: cands.append(("apache2", "/etc/php/%s/apache2/php.ini" % m.group(1), "/etc/php/%s/apache2/conf.d" % m.group(1))) for pat in ("/etc/php/*/apache2",): - for d in glob.glob(pat): + for d in ctx.glob(pat): cands.append(("apache2", d + "/php.ini", d + "/conf.d")) - if os.path.isfile("/etc/php.ini"): + if ctx.isfile("/etc/php.ini"): cands.append(("apache2", "/etc/php.ini", "/etc/php.d")) + if ctx.isdir("/usr/local/etc/php"): + # Docker official php:*-apache images + cands.append(("apache2", "/usr/local/etc/php/php.ini", "/usr/local/etc/php/conf.d")) for sapi, ini, scan in cands: - c_, files = resolve_static_ini(ini, scan) + c_, files = resolve_static_ini(ini, scan, ctx) if files: res["config"] = {k: v[0] for k, v in c_.items()} # PHP defaults for directives the ini files leave commented out @@ -1082,12 +1200,12 @@ def assess_install(entry, procs, pkgs, no_config=False): res["config_note"] = "static parse of %s (+%d drop-ins)" % (ini, len(files) - 1) break if kind == "cli": - for sapi, ini, scan in sapi_ini_dirs_for(res.get("config_files", {}).get("ini_file") or ""): - c_, files = resolve_static_ini(ini, scan) + for sapi, ini, scan in sapi_ini_dirs_for((res.get("config_files") or {}).get("ini_file") or "", ctx): + c_, files = resolve_static_ini(ini, scan, ctx) if files: res["sapi_configs"].append({"sapi": sapi, "ini_file": ini, "config": {k: v[0] for k, v in c_.items()}}) if kind == "fpm": - pools, ok, last = fpm_pool_overrides(path) + pools, ok, last = fpm_pool_overrides(path, ctx) res["fpm_pools"] = {"pools": pools, "ok": ok, "note": last} # verdict @@ -1155,11 +1273,85 @@ def config_findings(conf, context, verdict): return out +HOST_UNPATCHED_FIX = "update to the fixed release for the branch and restart every worker" + + +def install_findings(inst, prefix="", unpatched_fix=HOST_UNPATCHED_FIX): + """Findings for one assessed install. prefix names the container, if any.""" + where = (prefix + ":" if prefix else "") + inst["path"] + label = "%s (%s)" % (where, inst["kind"]) + pool_prefix = prefix + ": " if prefix else "" + out = [] + for f in inst["findings"]: + out.append((f[0], label + ": " + f[1], f[2])) + if inst["verdict"] == "VULNERABLE": + out.append(("CRITICAL", "%s is unpatched (PHP %s, %s)" % (label, inst["version"], inst["version_reason"]), unpatched_fix)) + elif inst["verdict"] == "UNKNOWN": + out.append(("MEDIUM", "%s: could not determine patch status" % label, "check manually")) + out.extend(config_findings(inst.get("config"), label, inst["verdict"])) + for sc in inst.get("sapi_configs", []): + out.extend(config_findings(sc["config"], "%s [%s sapi: %s]" % (where, sc["sapi"], sc["ini_file"]), inst["verdict"])) + fp = inst.get("fpm_pools") + if fp and fp["pools"]: + for pool, vals in fp["pools"].items(): + if pool.lower() in ("general", "global"): + continue + v = vals.get("session.upload_progress.enabled") + if v and ini_bool(v[1]) is False: + out.append(("OK", "%sFPM pool [%s] enforces session.upload_progress.enabled=off via %s" % (pool_prefix, pool, v[0]), "")) + mlv = vals.get("memory_limit") + if mlv and inst["verdict"] != "PATCHED" and mlv[1].strip() != "-1": + out.append(("LOW", "%sFPM pool [%s] sets memory_limit=%s (%s)" % (pool_prefix, pool, mlv[1], mlv[0]), + "a pool-level finite limit overrides any php.ini memory_limit=-1 mitigation")) + mr = vals.get("pm.max_requests") + if mr and inst["verdict"] != "PATCHED" and mr[1].strip() == "0": + out.append(("LOW", "%sFPM pool [%s] pm.max_requests=0 (workers never recycle)" % (pool_prefix, pool), + "a low pm.max_requests shortens how long stale state can persist in a worker")) + elif fp and not fp["ok"]: + out.append(("INFO", "%s: could not dump FPM pools (%s)" % (where, fp["note"]), "run as root")) + return out + + +def package_findings(pkgs, prefix=""): + pre = prefix + ": " if prefix else "" + out = [] + for pk in pkgs: + if pk["status"] == "vulnerable": + out.append(("CRITICAL", "%spackage %s %s is unpatched (%s)" % (pre, pk["package"], pk["version"], pk["detail"]), + "apply the vendor update" + (" and rebuild the image" if prefix else ""))) + elif pk["status"] == "unknown": + out.append(("MEDIUM", "%spackage %s %s: patch status not confirmed (%s)" % (pre, pk["package"], pk["version"], pk["detail"]), + "compare with your vendor advisory")) + return out + + +def apache_findings(directives, prefix=""): + pre = prefix + ":" if prefix else "" + out = [] + for a in directives: + loc = "%s%s:%d" % (pre, a["file"], a["line"]) + if a["kind"] == "rlimitmem": + out.append(("MEDIUM", "Apache RLimitMEM in %s" % loc, + "php.net advises against RLIMIT_AS/RLIMIT_DATA style limits: they can cause the allocation failure the bug depends on")) + elif a["key"] == "session.upload_progress.enabled" and ini_bool(a["value"]) is False: + out.append(("OK", "Apache %s disables session.upload_progress in %s" % (a["kind"], loc), "")) + elif a["key"] == "session.upload_progress.enabled": + out.append(("HIGH", "Apache %s re-enables session.upload_progress in %s" % (a["kind"], loc), "remove it")) + elif a["key"] == "memory_limit" and a["kind"] in ("php_value", "php_admin_value"): + if a["value"].strip() == "-1": + out.append(("OK", "Apache %s memory_limit -1 in %s" % (a["kind"], loc), "")) + else: + out.append(("INFO", "Apache %s memory_limit %s in %s" % (a["kind"], a["value"], loc), + "per-directory limits override a global memory_limit=-1 mitigation")) + return out + + def cmd_scan(args): t0 = time.time() + docker = args.docker or bool(args.container) report = {"tool": TOOL, "version": __version__, "host": socket.gethostname(), "time": datetime.datetime.now().isoformat(), "platform": platform.platform(), "root": is_root(), "installs": [], "packages": [], "apache": [], - "docker": [], "findings": [], "verdict": None} + "docker": [], "docker_error": None, "findings": [], "verdict": None} procs = running_processes() pkgs = check_packages() report["packages"] = pkgs @@ -1169,80 +1361,38 @@ def cmd_scan(args): # Apache directives report["apache"] = scan_apache_configs() - if args.docker: - report["docker"] = scan_docker() + if docker: + report["docker"], report["docker_error"] = scan_docker(args.container, no_config=args.no_config) # aggregate findings findings = [] for inst in report["installs"]: - ctx = "%s (%s)" % (inst["path"], inst["kind"]) - for f in inst["findings"]: - findings.append((f[0], ctx + ": " + f[1], f[2])) - if inst["verdict"] == "VULNERABLE": - findings.append(("CRITICAL", "%s is unpatched (PHP %s, %s)" % (ctx, inst["version"], inst["version_reason"]), - "update to the fixed release for the branch and restart every worker")) - elif inst["verdict"] == "UNKNOWN": - findings.append(("MEDIUM", "%s: could not determine patch status" % ctx, "check manually")) - findings.extend(config_findings(inst.get("config"), ctx, inst["verdict"])) - for sc in inst.get("sapi_configs", []): - findings.extend(config_findings(sc["config"], "%s [%s sapi: %s]" % (inst["path"], sc["sapi"], sc["ini_file"]), inst["verdict"])) - fp = inst.get("fpm_pools") - if fp and fp["pools"]: - for pool, vals in fp["pools"].items(): - if pool.lower() in ("general", "global"): - continue - v = vals.get("session.upload_progress.enabled") - if v and ini_bool(v[1]) is False: - findings.append(("OK", "FPM pool [%s] enforces session.upload_progress.enabled=off via %s" % (pool, v[0]), "")) - mlv = vals.get("memory_limit") - if mlv and inst["verdict"] != "PATCHED" and mlv[1].strip() != "-1": - findings.append(("LOW", "FPM pool [%s] sets memory_limit=%s (%s)" % (pool, mlv[1], mlv[0]), - "a pool-level finite limit overrides any php.ini memory_limit=-1 mitigation")) - mr = vals.get("pm.max_requests") - if mr and inst["verdict"] != "PATCHED" and mr[1].strip() == "0": - findings.append(("LOW", "FPM pool [%s] pm.max_requests=0 (workers never recycle)" % pool, - "a low pm.max_requests shortens how long stale state can persist in a worker")) - elif fp and not fp["ok"]: - findings.append(("INFO", "%s: could not dump FPM pools (%s)" % (inst["path"], fp["note"]), "run as root")) - for pk in pkgs: - if pk["status"] == "vulnerable": - findings.append(("CRITICAL", "package %s %s is unpatched (%s)" % (pk["package"], pk["version"], pk["detail"]), - "apply the vendor update")) - elif pk["status"] == "unknown": - findings.append(("MEDIUM", "package %s %s: patch status not confirmed (%s)" % (pk["package"], pk["version"], pk["detail"]), - "compare with your vendor advisory")) - for a in report["apache"]: - if a["kind"] == "rlimitmem": - findings.append(("MEDIUM", "Apache RLimitMEM in %s:%d" % (a["file"], a["line"]), - "php.net advises against RLIMIT_AS/RLIMIT_DATA style limits: they can cause the allocation failure the bug depends on")) - elif a["key"] == "session.upload_progress.enabled" and ini_bool(a["value"]) is False: - findings.append(("OK", "Apache %s disables session.upload_progress in %s:%d" % (a["kind"], a["file"], a["line"]), "")) - elif a["key"] == "session.upload_progress.enabled": - findings.append(("HIGH", "Apache %s re-enables session.upload_progress in %s:%d" % (a["kind"], a["file"], a["line"]), "remove it")) - elif a["key"] == "memory_limit" and a["kind"] in ("php_value", "php_admin_value"): - if a["value"].strip() == "-1": - findings.append(("OK", "Apache %s memory_limit -1 in %s:%d" % (a["kind"], a["file"], a["line"]), "")) - else: - findings.append(("INFO", "Apache %s memory_limit %s in %s:%d" % (a["kind"], a["value"], a["file"], a["line"]), - "per-directory limits override a global memory_limit=-1 mitigation")) + findings.extend(install_findings(inst)) + findings.extend(package_findings(pkgs)) + findings.extend(apache_findings(report["apache"])) for d in report["docker"]: - if d["verdict"] == "VULNERABLE": - findings.append(("CRITICAL", "container %s (%s) runs unpatched PHP %s" % (d["name"], d["image"], d["version"]), - "rebuild with a fixed base image and recreate the container")) - elif d["verdict"] == "UNKNOWN": - findings.append(("MEDIUM", "container %s (%s): PHP patch status unknown" % (d["name"], d["image"]), "")) + findings.extend(container_findings(d)) + if report["docker_error"]: + findings.append(("MEDIUM", "Docker containers not (fully) inspected: %s" % report["docker_error"], + "check `docker ps` works for this user")) if not report["installs"] and not report["docker"]: - findings.append(("INFO", "no PHP binaries or Apache PHP modules found on this host", "")) + if not docker and which("docker"): + findings.append(("INFO", "no PHP binaries or Apache PHP modules found on this host itself", + "Docker is installed: add --docker to inspect PHP inside running containers")) + else: + findings.append(("INFO", "no PHP binaries or Apache PHP modules found on this host", "")) findings.sort(key=lambda f: SEV_RANK.get(f[0], 9)) report["findings"] = [{"severity": s, "title": t, "fix": x} for s, t, x in findings] - if any(i["verdict"] == "VULNERABLE" for i in report["installs"]) or any(d["verdict"] == "VULNERABLE" for d in report["docker"]) \ - or any(p["status"] == "vulnerable" for p in pkgs): + units = report["installs"] + report["docker"] + if any(u["verdict"] == "VULNERABLE" for u in units) or any(p["status"] == "vulnerable" for p in pkgs): report["verdict"] = "VULNERABLE" - elif any(i["verdict"] == "UNKNOWN" for i in report["installs"]) or any(p["status"] == "unknown" for p in pkgs): - report["verdict"] = "PATCHED" if (report["installs"] and all(i["verdict"] == "PATCHED" for i in report["installs"])) else "UNKNOWN" - elif report["installs"] or report["docker"]: + elif any(u["verdict"] == "UNKNOWN" for u in units) or (docker and report["docker_error"]): + report["verdict"] = "UNKNOWN" + elif units: report["verdict"] = "PATCHED" + elif any(p["status"] == "unknown" for p in pkgs): + report["verdict"] = "UNKNOWN" else: report["verdict"] = "NO_PHP" report["elapsed"] = round(time.time() - t0, 2) @@ -1257,61 +1407,78 @@ def cmd_scan(args): return {"VULNERABLE": 1, "UNKNOWN": 2, "PATCHED": 0, "NO_PHP": 0}[report["verdict"]] +def print_install(i, pad=" "): + col = {"PATCHED": "green", "VULNERABLE": "red"}.get(i["verdict"], "yellow") + fp = ",".join(i["fingerprint"]) if i["fingerprint"] else "none" + running = ("running pid " + ",".join(map(str, i["running"][:6])) + (" ..." if len(i["running"]) > 6 else "")) if i["running"] else "not running" + if i["stale_running"]: + running += c(" STALE BINARY", "red") + print("%s%s %s" % (pad, c("%-10s" % i["verdict"], col), i["path"])) + if i["verdict"] == "PATCHED" and i["version_status"] in ("vulnerable", "eol") and i["fingerprint"]: + status_line = "status=backported (version string predates the fix; patch markers confirm the build)" + else: + status_line = "status=%s: %s" % (i["version_status"], i["version_reason"]) + pad2 = pad + " " + print("%skind=%s version=%s (%s) %s" % (pad2, i["kind"], i["version"], i["version_source"], status_line)) + print("%spatch-markers=%s %s found-via=%s" % (pad2, fp, running, ",".join(i["sources"]))) + conf = i.get("config") + if conf: + keys = [("session.upload_progress.enabled", "upload_progress"), ("fatal_error_backtraces", "fatal_error_backtraces"), + ("memory_limit", "memory_limit"), ("memory_hard_limit_percent", "hard_limit%"), ("post_max_size", "post_max_size"), + ("upload_max_filesize", "upload_max_filesize"), ("max_input_nesting_level", "nesting")] + print(pad2 + "ini: " + " ".join("%s=%s" % (label, conf[k]) for k, label in keys if k in conf and conf[k] != "no value")) + cf = i.get("config_files") or {} + if cf.get("ini_file") or cf.get("scan_dir"): + print("%sini-files: %s scan-dir: %s" % (pad2, cf.get("ini_file"), cf.get("scan_dir"))) + elif i.get("config_note"): + print("%sini: %s" % (pad2, i["config_note"])) + for sc in i.get("sapi_configs", []): + print("%s[%s sapi] upload_progress=%s memory_limit=%s post_max_size=%s (%s)" % ( + pad2, sc["sapi"], sc["config"].get("session.upload_progress.enabled", "?"), sc["config"].get("memory_limit", "?"), + sc["config"].get("post_max_size", "?"), sc["ini_file"])) + fp_ = i.get("fpm_pools") + if fp_ and fp_["pools"]: + for pool, vals in fp_["pools"].items(): + if pool.lower() in ("general", "global"): + continue + ov = ["%s[%s]=%s" % (v[0], k, v[1]) for k, v in vals.items() if v[0] != "setting"] + pm = vals.get("pm.max_requests", ("", "?"))[1] + print("%sfpm pool [%s]: pm.max_requests=%s %s" % (pad2, pool, pm, " ".join(ov) if ov else "(no php_* overrides)")) + + +def print_packages(pkgs, pad=" "): + for p in pkgs: + col = {"patched": "green", "vulnerable": "red"}.get(p["status"], "yellow") + print("%s%s %-6s %-28s %-40s %s" % (pad, c("%-10s" % p["status"].upper(), col), p["manager"], p["package"], p["version"], p["detail"])) + + def print_scan_report(r): banner("%s %s - local audit of %s" % (TOOL, __version__, r["host"])) print(" %s | root=%s | %s" % (r["platform"], r["root"], r["time"])) - if not r["root"]: + if not r["root"] and (r["installs"] or not r["docker"]): print(c(" not running as root: process maps, FPM pool dumps and some binaries may be unreadable", "yellow")) print() - banner("PHP runtimes (%d)" % len(r["installs"])) + banner("PHP runtimes on this host (%d)" % len(r["installs"])) for i in r["installs"]: - col = {"PATCHED": "green", "VULNERABLE": "red"}.get(i["verdict"], "yellow") - fp = ",".join(i["fingerprint"]) if i["fingerprint"] else "none" - running = ("running pid " + ",".join(map(str, i["running"][:6])) + (" ..." if len(i["running"]) > 6 else "")) if i["running"] else "not running" - if i["stale_running"]: - running += c(" STALE BINARY", "red") - print(" %s %s" % (c("%-10s" % i["verdict"], col), i["path"])) - if i["verdict"] == "PATCHED" and i["version_status"] in ("vulnerable", "eol") and i["fingerprint"]: - status_line = "status=backported (version string predates the fix; patch markers confirm the build)" - else: - status_line = "status=%s: %s" % (i["version_status"], i["version_reason"]) - print(" kind=%s version=%s (%s) %s" % (i["kind"], i["version"], i["version_source"], status_line)) - print(" patch-markers=%s %s found-via=%s" % (fp, running, ",".join(i["sources"]))) - conf = i.get("config") - if conf: - keys = [("session.upload_progress.enabled", "upload_progress"), ("fatal_error_backtraces", "fatal_error_backtraces"), - ("memory_limit", "memory_limit"), ("memory_hard_limit_percent", "hard_limit%"), ("post_max_size", "post_max_size"), - ("upload_max_filesize", "upload_max_filesize"), ("max_input_nesting_level", "nesting")] - print(" ini: " + " ".join("%s=%s" % (label, conf[k]) for k, label in keys if k in conf and conf[k] != "no value")) - cf = i.get("config_files") or {} - if cf.get("ini_file") or cf.get("scan_dir"): - print(" ini-files: %s scan-dir: %s" % (cf.get("ini_file"), cf.get("scan_dir"))) - elif i.get("config_note"): - print(" ini: %s" % i["config_note"]) - for sc in i.get("sapi_configs", []): - print(" [%s sapi] upload_progress=%s memory_limit=%s post_max_size=%s (%s)" % ( - sc["sapi"], sc["config"].get("session.upload_progress.enabled", "?"), sc["config"].get("memory_limit", "?"), - sc["config"].get("post_max_size", "?"), sc["ini_file"])) - fp_ = i.get("fpm_pools") - if fp_ and fp_["pools"]: - for pool, vals in fp_["pools"].items(): - if pool.lower() in ("general", "global"): - continue - ov = ["%s[%s]=%s" % (v[0], k, v[1]) for k, v in vals.items() if v[0] != "setting"] - pm = vals.get("pm.max_requests", ("", "?"))[1] - print(" fpm pool [%s]: pm.max_requests=%s %s" % (pool, pm, " ".join(ov) if ov else "(no php_* overrides)")) + print_install(i) if r["packages"]: print() banner("Packages (%d)" % len(r["packages"])) - for p in r["packages"]: - col = {"patched": "green", "vulnerable": "red"}.get(p["status"], "yellow") - print(" %s %-6s %-28s %-40s %s" % (c("%-10s" % p["status"].upper(), col), p["manager"], p["package"], p["version"], p["detail"])) - if r["docker"]: + print_packages(r["packages"]) + if r["docker"] or r.get("docker_error"): print() - banner("Docker containers (%d)" % len(r["docker"])) + banner("Docker containers with PHP (%d)" % len(r["docker"])) + if r.get("docker_error"): + print(c(" %s" % r["docker_error"], "yellow")) for d in r["docker"]: col = {"PATCHED": "green", "VULNERABLE": "red"}.get(d["verdict"], "yellow") - print(" %s %-24s %-40s php=%s markers=%s %s" % (c("%-10s" % d["verdict"], col), d["name"][:24], d["image"][:40], d["version"], d["fingerprint"], d["note"])) + comp = d.get("compose") + where = " compose %s/%s" % (comp["project"], comp["service"]) if comp else "" + print(" %s %s image=%s%s" % (c("%-10s" % d["verdict"], col), c(d["name"], "bold"), d["image"], where)) + for i in d["installs"]: + print_install(i, pad=" ") + if d["packages"]: + print_packages(d["packages"], pad=" ") print() banner("Findings (%d)" % len(r["findings"])) if not r["findings"]: @@ -1325,52 +1492,515 @@ def print_scan_report(r): print(c("HOST VERDICT: %s" % r["verdict"], col) + " (%.1fs)" % r["elapsed"]) if r["verdict"] == "VULNERABLE": print(" Update PHP to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 (or your vendor backport) and restart every worker.") - print(" Until then: `%s protect --apply` applies the php.net interim mitigations. See %s" % (sys.argv[0], ADVISORY_URL)) + if any(d["verdict"] == "VULNERABLE" for d in r["docker"]): + print(" Containers: rebuild from a fixed image. Until then `%s protect --docker --apply --restart`" % sys.argv[0]) + print(" applies the interim mitigations inside them. See %s" % ADVISORY_URL) + else: + print(" Until then: `%s protect --apply` applies the php.net interim mitigations. See %s" % (sys.argv[0], ADVISORY_URL)) -def scan_docker(): +# -------------------------------------------------------------------------- +# Docker containers +# -------------------------------------------------------------------------- +# Official php:* images ship no Python and no ps, so containers are driven +# from the host: docker exec runs the same probes the host scan runs, +# docker cp streams file trees for hunt, and the bind mounts reported by +# docker inspect let files that really live on the host be read in place. +# Paths that come from inside a container are only ever passed to sh as +# arguments, never spliced into a script. + +# One line per process: Pstatexecmdline, plus Mpidmodule +# for PHP modules mapped into Apache / LiteSpeed processes. +CONTAINER_PROC_SH = r""" +for p in /proc/[0-9]*; do + s=$(cat "$p/stat" 2>/dev/null) || continue + printf 'P\037%s\037%s\037%s\n' "$s" "$(readlink "$p/exe" 2>/dev/null)" "$(tr '\000' ' ' < "$p/cmdline" 2>/dev/null)" + case "$s" in + *"(apache2)"*|*"(httpd)"*|*"(litespeed)"*|*"(lshttpd)"*) + grep -E -o '/[^ ]*(libphp|mod_php)[^ ]*\.so( \(deleted\))?' "$p/maps" 2>/dev/null | sort -u | + while read -r m; do printf 'M\037%s\037%s\n' "${p#/proc/}" "$m"; done;; + esac +done +""" +# $1 = test flag (f, d, e), then glob patterns; prints the matches that pass. +CONTAINER_LIST_SH = 'k=$1; shift; for pat in "$@"; do for p in $pat; do [ -$k "$p" ] && echo "$p"; done; done' +# Every file on PATH (filtered with BIN_NAME_RE on the host). +CONTAINER_PATH_SH = 'IFS=:; for d in $PATH; do for f in "$d"/*; do [ -f "$f" ] && echo "$f"; done; done' +CONTAINER_REALPATH_SH = 'for f in "$@"; do r=$(readlink -f "$f" 2>/dev/null) || r=$f; [ -f "$r" ] && printf "%s\\t%s\\n" "$f" "$r"; done' +# grep -a is missing from some busybox builds (exit 2); fall back to tr. The +# octal range is deliberate: busybox tr -c does not understand [:print:]. +CONTAINER_MARKERS_SH = r"""f=$1; shift +for m in "$@"; do + grep -a -q -F "$m" "$f" 2>/dev/null; r=$? + if [ "$r" -eq 0 ]; then echo "$m" + elif [ "$r" -ge 2 ] && tr -c '\040-\176' '\n' < "$f" 2>/dev/null | grep -q -F "$m"; then echo "$m"; fi +done""" +CONTAINER_BYTES_VERSION_SH = r"""for re in 'X-Powered-By: PHP/[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9~-]*' 'PHP/[0-9]+\.[0-9]+\.[0-9]+'; do + v=$(grep -a -o -E "$re" "$1" 2>/dev/null | head -n 1) + [ -n "$v" ] || v=$(tr -c '\040-\176' '\n' < "$1" 2>/dev/null | grep -o -E "$re" | head -n 1) + [ -n "$v" ] && break +done +echo "$v" +""" +CONTAINER_STAT_SH = r"""for pat in "$@"; do for p in $pat; do [ -f "$p" ] && printf '%s\t%s\t%s\n' "$p" "$(stat -c %Y "$p" 2>/dev/null)" "$(stat -c %s "$p" 2>/dev/null)"; done; done""" +CONTAINER_APACHE_GREP_SH = r"""for d in "$@"; do [ -d "$d" ] && grep -r -n -i -E '^[[:space:]]*(php_(admin_)?(value|flag)|RLimitMEM|LimitRequestBody)[[:space:]]' "$d" 2>/dev/null; done""" + +DOCKER_WEB_RE = re.compile(r"nginx|httpd|apache|caddy|openresty|litespeed|lsws|frankenphp", re.I) +DOCKER_WEBROOT_CANDIDATES = ("/var/www", "/app", "/srv", "/usr/share/nginx/html", "/usr/local/apache2/htdocs") +# Mount destinations that hold configuration, logs or data, not code. +DOCKER_NON_CODE_MOUNTS = ("/etc", "/usr/local/etc", "/var/log", "/var/lib/mysql", "/var/lib/postgresql", "/var/lib/redis", + "/data", "/proc", "/sys", "/dev", "/run", "/var/run", "/tmp") + + +def parse_proc_snapshot(text): + """Process list in running_processes() format from CONTAINER_PROC_SH output.""" + procs, by_pid = [], {} + for line in text.splitlines(): + parts = line.split("\x1f") + if parts[0] == "P" and len(parts) >= 4: + st = parts[1] + try: + pid = int(st.split(" ", 1)[0]) + comm = st[st.index("(") + 1:st.rindex(")")] + ppid = int(st.rsplit(")", 1)[1].split()[1]) + except (ValueError, IndexError): + continue + exe, deleted = parts[2].strip() or None, False + if exe and exe.endswith(" (deleted)"): + exe, deleted = exe[:-10], True + proc = {"pid": pid, "ppid": ppid, "comm": comm, "exe": exe, "deleted": deleted, + "cmdline": parts[3].strip(), "php_modules": [], "module_deleted": False, "user": None} + procs.append(proc) + by_pid[pid] = proc + elif parts[0] == "M" and len(parts) >= 3: + try: + proc = by_pid[int(parts[1])] + except (ValueError, KeyError): + continue + mp = parts[2].strip() + if mp.endswith(" (deleted)"): + proc["module_deleted"] = True + mp = mp[:-10] + if mp not in proc["php_modules"]: + proc["php_modules"].append(mp) + return procs + + +class Container(object): + """A running container, with HostContext's interface implemented through + docker exec (as root inside the container, like a root scan on a host).""" + + join = staticmethod(posixpath.join) + + def __init__(self, info): + self.full_id = info.get("Id") or "" + self.id = self.full_id[:12] + self.name = (info.get("Name") or self.id).lstrip("/") + cfg = info.get("Config") or {} + self.image = cfg.get("Image") or info.get("Image") or "?" + labels = cfg.get("Labels") or {} + self.compose = None + # compose build copies project/service labels into the image, so only + # config-hash proves compose created this container + if labels.get("com.docker.compose.project") and labels.get("com.docker.compose.config-hash"): + self.compose = {"project": labels["com.docker.compose.project"], + "service": labels.get("com.docker.compose.service") or "", + "working_dir": labels.get("com.docker.compose.project.working_dir") or "", + "config_files": labels.get("com.docker.compose.project.config_files") or ""} + self.mounts = [{"type": m.get("Type"), "source": m.get("Source") or "", "destination": m["Destination"], + "rw": m.get("RW", True)} for m in info.get("Mounts") or [] if m.get("Destination")] + self._procs = None + self._bins = None + + def run(self, cmd, timeout=20, stdin_data=None, binary=False): + argv = ["docker", "exec", "-u", "0"] + (["-i"] if stdin_data is not None else []) + [self.full_id] + list(cmd) + return run(argv, timeout=timeout, stdin_data=stdin_data, binary=binary) + + def sh(self, script, *args, **kw): + return self.run(["sh", "-c", script, "sh"] + list(args), **kw) + + def which(self, name): + rc, out, _ = self.sh('command -v "$1"', name) + out = out.strip() + return out if rc == 0 and out.startswith("/") else None + + def isfile(self, path): + return self.sh('[ -f "$1" ]', path)[0] == 0 + + def isdir(self, path): + return self.sh('[ -d "$1" ]', path)[0] == 0 + + def exists(self, path): + return self.sh('[ -e "$1" ]', path)[0] == 0 + + def glob(self, pattern): + return self.existing([pattern], "e") + + def existing(self, patterns, kind="f"): + rc, out, _ = self.sh(CONTAINER_LIST_SH, kind, *patterns, timeout=30) + return sorted(set(l for l in out.splitlines() if l.startswith("/"))) + + def stat_files(self, patterns): + """[(path, mtime, size)] for regular files matching the globs.""" + rc, out, _ = self.sh(CONTAINER_STAT_SH, *patterns, timeout=30) + res = [] + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) == 3 and parts[1].isdigit() and parts[2].isdigit(): + res.append((parts[0], int(parts[1]), int(parts[2]))) + return res + + def read_text(self, path): + rc, out, _ = self.run(["cat", path], timeout=30) + return out if rc == 0 else None + + def read_bytes(self, path, limit): + rc, out, _ = self.run(["head", "-c", str(limit), path], timeout=30, binary=True) + return out if rc == 0 else None + + def write_text(self, path, text, mode=None): + script = 'cat > "$1"' + (' && chmod %o "$1"' % mode if mode is not None else "") + rc, out, err = self.sh(script, path, stdin_data=text.encode("utf-8")) + if rc != 0: + raise OSError((err or out).strip() or "write failed") + + def backup(self, path, bak): + rc, out, err = self.sh('[ -e "$2" ] || cp -p "$1" "$2"', path, bak) + if rc != 0: + raise OSError((err or out).strip() or "backup failed") + + def remove(self, path): + rc, out, err = self.run(["rm", "-f", path]) + if rc != 0: + raise OSError((err or out).strip() or "remove failed") + + def markers(self, path): + rc, out, _ = self.sh(CONTAINER_MARKERS_SH, path, *[m.decode() for m in PATCH_MARKERS], timeout=90) + return set(l.strip() for l in out.splitlines() if l.strip()) + + def bytes_version(self, path): + rc, out, _ = self.sh(CONTAINER_BYTES_VERSION_SH, path, timeout=90) + m = re.search(r"PHP/(\d+\.\d+\.\d+(?:[-~]?(?:RC|rc|alpha|beta|dev)\d*)?)", out) + return m.group(1) if m else None + + def host_path(self, path): + """The host file behind a bind mount, or None when path lives in the + container (or the daemon's filesystem is not this machine's).""" + best = None + for m in self.mounts: + d = m["destination"].rstrip("/") + if m["type"] != "bind" or not m["source"] or not d: + continue + if path == d or path.startswith(d + "/"): + if best is None or len(d) > len(best[0]): + best = (d, m["source"]) + if not best or not os.path.exists(best[1]): + return None + hp = best[1] if path == best[0] else os.path.join(best[1], *path[len(best[0]) + 1:].split("/")) + src, real = os.path.realpath(best[1]), os.path.realpath(hp) + if real != src and not real.startswith(src.rstrip(os.sep) + os.sep): + return None # a symlink inside the mount points elsewhere on the host + return hp + + def where(self, path): + hp = self.host_path(path) + return "%s:%s%s" % (self.name, path, " (bind mount of %s)" % hp if hp else "") + + def processes(self, refresh=False): + if self._procs is None or refresh: + rc, out, _ = self.sh(CONTAINER_PROC_SH, timeout=30) + self._procs = parse_proc_snapshot(out) + return self._procs + + def php_binaries(self): + """PHP executables and Apache PHP modules inside the container, as + find_php_binaries() entries (real paths).""" + if self._bins is not None: + return self._bins + cands = [] + rc, out, _ = self.sh(CONTAINER_PATH_SH, timeout=30) + cands += [(l, "PATH") for l in out.splitlines() if BIN_NAME_RE.match(posixpath.basename(l))] + for p in self.existing(BIN_GLOBS + MODULE_GLOBS, "f"): + if p.endswith(".so"): + cands.append((p, "module-glob")) + elif BIN_NAME_RE.match(posixpath.basename(p)): + cands.append((p, "path-glob")) + for proc in self.processes(): + exe = proc["exe"] + if exe and (BIN_NAME_RE.match(posixpath.basename(exe)) or re.search(r"(libphp|mod_php)", exe)): + cands.append((exe, "process")) + for mod in proc["php_modules"]: + cands.append((mod, "process-maps")) + found = {} + if cands: + rc, out, _ = self.sh(CONTAINER_REALPATH_SH, *sorted(set(p for p, _ in cands)), timeout=30) + real = dict(line.split("\t", 1) for line in out.splitlines() if "\t" in line) + for p, src in cands: + if p in real: + found.setdefault(real[p], {"path": real[p], "sources": set()})["sources"].add(src) + self._bins = sorted(found.values(), key=lambda e: e["path"]) + return self._bins + + +def docker_containers(names=None): + """Running containers, optionally only those named (or id-prefixed). + Returns (containers, error or None).""" if not which("docker"): - return [] - rc, out, err = run(["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}"], timeout=30) + return [], "docker CLI not found" + rc, out, err = run(["docker", "ps", "-q", "--no-trunc"], timeout=30) if rc != 0: - return [{"name": "-", "image": "-", "version": None, "fingerprint": None, "verdict": "UNKNOWN", "note": "docker ps failed: %s" % err.strip()[:120]}] - results = [] + return [], "docker ps failed: %s" % ((err or out).strip().splitlines() or ["?"])[-1][:200] + cts = [] + ids = out.split() + if ids: + rc, out, err = run(["docker", "inspect"] + ids, timeout=60) + try: + cts = [Container(d) for d in json.loads(out or "[]") if (d.get("State") or {}).get("Running")] + except ValueError: + return [], "docker inspect failed: %s" % ((err or out).strip().splitlines() or ["?"])[-1][:200] + if not names: + return cts, None + sel, missing = [], [] + for n in names: + hit = [ct for ct in cts if ct.name == n or ct.full_id.startswith(n)] + if not hit: + missing.append(n) + sel.extend(h for h in hit if h not in sel) + return sel, ("not running: %s" % ", ".join(missing)) if missing else None + + +def docker_php_targets(names=None): + """[(container, php binaries)] for running containers that have PHP, + plus the names of those without, and an error string.""" + cts, err = docker_containers(names) + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, min(8, len(cts)))) as ex: + bins = list(ex.map(lambda ct: ct.php_binaries(), cts)) + targets = [(ct, b) for ct, b in zip(cts, bins) if b] + without = [ct.name for ct, b in zip(cts, bins) if not b] + return targets, without, err + + +def scan_apache_configs_in(ct): + rc, out, _ = ct.sh(CONTAINER_APACHE_GREP_SH, *(APACHE_CONF_DIRS + ["/usr/local/apache2/conf"]), timeout=30) + findings, seen = [], set() for line in out.splitlines(): - parts = line.split("\t") - if len(parts) != 3: + parts = line.split(":", 2) + if len(parts) != 3 or not parts[1].isdigit(): continue - cid, name, image = parts - rc, bins, _ = run(["docker", "exec", cid, "sh", "-c", - "for b in php php-fpm php-cgi frankenphp; do command -v $b 2>/dev/null; done; ls /usr/local/sbin/php-fpm /usr/sbin/php-fpm* 2>/dev/null"], timeout=20) - bins = sorted(set(b.strip() for b in bins.splitlines() if b.strip())) - if not bins: + f, n, text = parts + if not (f.endswith(".conf") or posixpath.basename(f) == ".htaccess"): continue - version, markers, note = None, 0, "" - for b in bins: - rc, v, e = run(["docker", "exec", cid, b, "-n", "-v"], timeout=20) - m = re.search(r"PHP (\d+\.\d+\.\d+\S*)", v + e) - if m: - version = m.group(1) - rc, g, _ = run(["docker", "exec", cid, "sh", "-c", - "grep -c -a -e memory_hard_limit_percent -e zend_mm_safe_error_ex %s 2>/dev/null || (tr -c '[:print:]' '\\n' < %s | grep -c -e memory_hard_limit_percent -e zend_mm_safe_error_ex)" % (b, b)], timeout=30) - try: - markers = max(markers, int(g.strip().splitlines()[-1])) - except (ValueError, IndexError): - pass - if version: - break - st, why = classify_version(version or "") - if markers > 0 or st == "patched": - verdict = "PATCHED" - elif st in ("vulnerable", "eol"): - verdict = "VULNERABLE" - note = why - else: - verdict = "UNKNOWN" - note = why - results.append({"id": cid, "name": name, "image": image, "version": version, "fingerprint": markers > 0, - "verdict": verdict, "note": note, "binaries": bins}) - return results + d = apache_directive(text) + if not d: + continue + key = (f, d[0], d[1], d[2].lower()) + if key in seen: + continue + seen.add(key) + findings.append({"file": f, "line": int(n), "kind": d[0], "key": d[1], "value": d[2]}) + return findings + + +def scan_container(ct, bins, no_config=False): + procs = ct.processes() + pkgs = check_packages(ct) + installs = [assess_install(b, procs, pkgs, no_config=no_config, ctx=ct) for b in bins] + if any(i["verdict"] == "VULNERABLE" for i in installs) or any(p["status"] == "vulnerable" for p in pkgs): + verdict = "VULNERABLE" + elif any(i["verdict"] == "UNKNOWN" for i in installs): + verdict = "UNKNOWN" + else: + verdict = "PATCHED" + first = next((i for i in installs if i["version"]), installs[0]) + bad = next((i for i in installs if i["verdict"] == "VULNERABLE"), None) + return {"id": ct.id, "name": ct.name, "image": ct.image, "compose": ct.compose, "mounts": ct.mounts, + "installs": installs, "packages": pkgs, "apache": scan_apache_configs_in(ct), "verdict": verdict, + "version": first["version"], "fingerprint": any(i["fingerprint"] for i in installs), + "note": bad["version_reason"] if bad else "", "binaries": [i["path"] for i in installs]} + + +def scan_docker(names=None, no_config=False): + """Assess every running container that has PHP. Returns (results, error).""" + targets, without, err = docker_php_targets(names) + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, min(8, len(targets)))) as ex: + results = list(ex.map(lambda t: scan_container(t[0], t[1], no_config), targets)) + if names and without: + msg = "no PHP found in: %s" % ", ".join(without) + err = "%s; %s" % (err, msg) if err else msg + return results, err + + +def container_fix(d, inst): + """How to replace an unpatched runtime in this container.""" + v = parse_version(inst.get("version") or "") + flavour = {"fpm": "-fpm", "module": "-apache"}.get(inst["kind"], "-cli") + if v and v[:2] in FIXED: + target = "PHP %s+ (e.g. a freshly pulled php:%d.%d%s)" % (".".join(map(str, FIXED[v[:2]])), v[0], v[1], flavour) + elif v and v[:2] < (8, 2): + target = "PHP 8.2+ (e.g. php:8.3%s; %d.%d has no upstream fix) or a vendor-backported build" % (flavour, v[0], v[1]) + else: + target = "a fixed PHP build" + comp = d.get("compose") + if comp and comp.get("service"): + files = "".join(" -f %s" % f for f in comp["config_files"].split(",") if f) + how = "docker compose%s build --pull %s && docker compose%s up -d %s" % (files, comp["service"], files, comp["service"]) + if re.match(re.escape(comp["project"]) + r"[-_]", d["image"]): + how = "update FROM in the service's Dockerfile, then " + how + else: + how = "pull or rebuild %s and recreate the container" % d["image"] + return "rebuild on %s: %s" % (target, how) + + +def container_findings(d): + out = [] + for inst in d["installs"]: + out.extend(install_findings(inst, d["name"], container_fix(d, inst))) + out.extend(package_findings(d["packages"], d["name"])) + out.extend(apache_findings(d["apache"], d["name"])) + return out + + +def _container_workers(ct): + procs = ct.processes(refresh=True) + masters = [p for p in procs if re.match(r"php-?fpm", p["comm"]) and "master process" in p["cmdline"]] + apache_pids = set(p["pid"] for p in procs if p["comm"] in ("apache2", "httpd")) + apache_roots = [p for p in procs if p["pid"] in apache_pids and p["ppid"] not in apache_pids] + roots = set(p["pid"] for p in masters + apache_roots) + workers = set(p["pid"] for p in procs if p["ppid"] in roots) + return masters, apache_roots, workers + + +def reload_container(ct, dry): + """Have PHP-FPM / Apache inside the container re-read their configuration + (graceful); restart the container when neither is found.""" + masters, apache_roots, before = _container_workers(ct) + # kill through sh: slim images have no procps, only the shell builtin + cmds = [] + if masters: + cmds.append(["-USR2"] + [str(p["pid"]) for p in masters]) + if apache_roots: + cmds.append(["-USR1"] + [str(p["pid"]) for p in apache_roots]) + if not cmds: + if dry: + print(" would run: docker restart %s (no PHP-FPM master or Apache found to reload)" % ct.name) + return + rc, out, err = run(["docker", "restart", ct.full_id], timeout=180) + print(" %s docker restart %s" % (c("ok ", "green") if rc == 0 else c("FAIL", "red"), ct.name)) + return + for cmd in cmds: + shown = "kill " + " ".join(cmd) + if dry: + print(" would run: docker exec %s %s" % (ct.name, shown)) + continue + rc, out, err = ct.sh('kill "$@"', *cmd, timeout=30) + print(" %s docker exec %s %s%s" % (c("ok ", "green") if rc == 0 else c("FAIL", "red"), ct.name, shown, + "" if rc == 0 else " (%s)" % (err or out).strip()[:120])) + if dry: + return + time.sleep(2) + _, _, after = _container_workers(ct) + if before and not (before - after): + print(c(" WARNING: no worker process was replaced (pids %s); restart the container by hand." % + ",".join(map(str, sorted(before)[:8])), "yellow")) + else: + print(" workers replaced (%d before, %d after, %d survived)" % (len(before), len(after), len(before & after))) + + +def container_persistence_note(ct, paths): + """Explain where container changes live and how to keep them.""" + layer = [p for p in paths if not ct.host_path(p)] + for p in paths: + hp = ct.host_path(p) + if hp: + print(" %s is a bind mount: the host file %s was changed and the change persists" % (p, hp)) + if not layer: + return + print(c(" %s in the container's writable layer: they survive `docker restart` but are" % + ("The other changes are" if len(layer) < len(paths) else "These changes are"), "yellow")) + print(c(" lost when the container is recreated (compose up --build / --force-recreate, image updates).", "yellow")) + dropin = next((p for p in layer if p.endswith("/" + DROPIN_NAME)), None) + if not dropin: + return + comp = ct.compose or {} + dest = comp.get("working_dir") or "." + print(" To keep the drop-in, copy it out and mount it read-only (or COPY it in the Dockerfile):") + print(" docker cp %s:%s %s/%s" % (ct.name, dropin, dest, DROPIN_NAME)) + if comp.get("service"): + print(" # docker-compose.yml, service %s:" % comp["service"]) + print(" volumes:") + print(" - ./%s:%s:ro" % (DROPIN_NAME, dropin)) + + +def stream_lines(cmd, merge_stderr=False, gz=False, max_lines=2000000): + """Yield the decoded output lines of a command without buffering it all.""" + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT if merge_stderr else subprocess.DEVNULL) + except OSError: + return + try: + src = gzip.GzipFile(fileobj=p.stdout) if gz else p.stdout + for raw in itertools.islice(src, max_lines): + yield raw.decode("utf-8", "replace") + except (OSError, EOFError): + pass + finally: + if p.poll() is None: + p.kill() + p.stdout.close() + p.wait() + + +def docker_tar_members(ct, path, want, max_bytes=2 * 1024 ** 3): + """Stream `docker cp ct:path -` and yield (container path, mtime, size, fileobj) + for regular files accepted by want(container path, size). Needs nothing + inside the container. The fileobj must be read before the next item.""" + base = posixpath.dirname(path.rstrip("/")) or "/" + try: + p = subprocess.Popen(["docker", "cp", "%s:%s" % (ct.full_id, path), "-"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + except OSError: + return + total = 0 + try: + with tarfile.open(fileobj=p.stdout, mode="r|") as tf: + for m in tf: + total += m.size + if total > max_bytes: + break + if not m.isreg(): + continue + cpath = posixpath.join(base, m.name) + if want(cpath, m.size): + yield cpath, m.mtime, m.size, tf.extractfile(m) + except (tarfile.TarError, OSError, EOFError): + pass + finally: + if p.poll() is None: + p.kill() + p.stdout.close() + p.wait() + + +LAYER_PERSISTENCE_RE = re.compile(r"^/(?:etc/cron|var/spool/cron|etc/systemd/system/|etc/rc\.local$|etc/ld\.so\.preload$|" + r"root/\.ssh/|home/[^/]+/\.ssh/|var/www/\.ssh/|etc/passwd$|etc/shadow$|etc/sudoers|etc/profile\.d/)") + + +def classify_layer_changes(lines): + """PHP files and persistence-related paths added or changed in a + container's writable layer (`docker diff` output).""" + php, persist = [], [] + for line in lines: + m = re.match(r"^([AC])\s+(/.*)$", line.strip()) + if not m: + continue + path = m.group(2) + low = posixpath.basename(path).lower() + if low.endswith(WEB_EXTS) or ".php." in low: + php.append(path) + elif LAYER_PERSISTENCE_RE.match(path): + persist.append(path) + return php, persist # -------------------------------------------------------------------------- @@ -1440,15 +2070,16 @@ def apache_conf_text(args, has_85): return "\n".join(body) + "\n" -def discover_confd_dirs(): - dirs = set(d for d in expand_globs(CONFD_GLOBS) if os.path.isdir(d)) +def discover_confd_dirs(ctx=None, bins=None): + ctx = ctx or HOST + dirs = set(ctx.existing(CONFD_GLOBS, "d")) # ask each binary where it scans - for b in find_php_binaries(): + for b in (find_php_binaries() if bins is None else bins): kind = binary_kind(b["path"]) if kind == "module" or b.get("unreadable"): continue - conf, info, note = effective_ini_from_binary(b["path"], kind) - if info and info.get("scan_dir") and os.path.isdir(info["scan_dir"]): + conf, info, note = effective_ini_from_binary(b["path"], kind, ctx) + if info and info.get("scan_dir") and ctx.isdir(info["scan_dir"]): dirs.add(info["scan_dir"]) elif info and info.get("ini_file"): # no scan dir: fall back to appending to php.ini is too invasive; report it @@ -1456,16 +2087,13 @@ def discover_confd_dirs(): return sorted(dirs) -def discover_fpm_pools(): - return sorted(set(p for p in expand_globs(FPM_POOL_GLOBS) if os.path.isfile(p))) +def discover_fpm_pools(ctx=None): + return (ctx or HOST).existing(FPM_POOL_GLOBS, "f") -def discover_apache_dir(): - for d in ("/etc/apache2/conf-available", "/etc/httpd/conf.d", "/usr/local/apache/conf/includes", - "/usr/local/etc/apache24/Includes", "/etc/apache24/Includes", "/opt/homebrew/etc/httpd/extra"): - if os.path.isdir(d): - return d - return None +def discover_apache_dir(ctx=None): + found = set((ctx or HOST).existing(list(APACHE_INCLUDE_DIRS), "d")) + return next((d for d in APACHE_INCLUDE_DIRS if d in found), None) def php_services(): @@ -1562,96 +2190,111 @@ def restart_services(dry): print(" (masters started outside the service manager are not restarted by it; stop them by hand)") -def cmd_protect(args): - apply_ = args.apply - if args.rollback: - return protect_rollback(args) - if not args.post_max and args.upload_max: - print("--upload-max requires --post-max") - return 3 - if args.post_max and not args.upload_max: - pm = ini_bytes(args.post_max) or 0 - args.upload_max = "%dM" % max(1, (pm // (1024 ** 2)) - 2) if pm >= 3 * 1024 ** 2 else args.post_max - if apply_ and not is_root() and os.name != "nt": - print(c("protect --apply needs root to write configuration and restart services", "red")) - return 3 - banner("%s protect (%s)" % (TOOL, "APPLY" if apply_ else "DRY RUN - add --apply to write")) - print(" Interim mitigations from the php.net advisory. They reduce exposure; only updating PHP is complete.\n") - - confd = discover_confd_dirs() - pools = discover_fpm_pools() - adir = discover_apache_dir() - has_85 = any((parse_version(php_version_of(b["path"], binary_kind(b["path"]))[0] or "") or (0, 0))[:2] == (8, 5) - for b in find_php_binaries() if not b.get("unreadable")) - changes = [] +def protect_target(args, ctx, bins, apply_): + """Show (dry run) or write the mitigations for one target. + Returns (paths planned, paths changed).""" + confd = discover_confd_dirs(ctx, bins) + pools = discover_fpm_pools(ctx) + adir = discover_apache_dir(ctx) + has_85 = any((parse_version(php_version_of(b["path"], binary_kind(b["path"]), ctx)[0] or "") or (0, 0))[:2] == (8, 5) + for b in bins if not b.get("unreadable")) + has_module = any(binary_kind(b["path"]) == "module" for b in bins) + planned, changes = [], [] + + def write(path, text, mode=None, backup=False): + planned.append(path) + if not apply_: + return True + try: + if backup: + ctx.backup(path, path + ".php2shell-guard.bak") + ctx.write_text(path, text, mode) + except (OSError, IOError) as e: + print(" %s %s" % (c("FAIL", "red"), e)) + return False + changes.append(path) + return True text = dropin_text(args) print("php.ini drop-in (%s):" % DROPIN_NAME) print("".join(" " + l + "\n" for l in text.splitlines())) for d in confd: if d.startswith("!"): - print(" %s %s has no scan dir; add the directives to it by hand" % (sev("MEDIUM"), d[1:])) + print(" %s %s has no scan dir; add the directives to it by hand" % (sev("MEDIUM"), ctx.where(d[1:]))) continue - target = os.path.join(d, DROPIN_NAME) - exists = os.path.exists(target) - print(" %s %s" % ("update " if exists else "write ", target)) - if apply_: - with open(target, "w") as fh: - fh.write(text) - os.chmod(target, 0o644) - changes.append(target) + target = ctx.join(d, DROPIN_NAME) + print(" %s %s" % ("update " if ctx.exists(target) else "write ", ctx.where(target))) + write(target, text, mode=0o644) if not confd: print(" no conf.d directories found") print("\nFPM pools (php_admin_* lines inserted under each [pool] header):") for p in pools: - try: - with open(p, "r", errors="replace") as fh: - content = fh.read() - except OSError as e: - print(" skip %s (%s)" % (p, e)) + content = ctx.read_text(p) + if content is None: + print(" skip %s (unreadable)" % ctx.where(p)) continue - is85 = has_85 and ("8.5" in p) + # one PHP per container; on a host only versioned pool paths can be told apart + is85 = has_85 and ("8.5" in p or ctx is not HOST) block = fpm_block_lines(args, is85) new = insert_fpm_block(content, block) if new == content: - print(" unchanged %s" % p) + print(" unchanged %s" % ctx.where(p)) continue - print(" %s %s" % ("patch " if not args.no_fpm_pools else "skip ", p)) - if apply_ and not args.no_fpm_pools: - bak = p + ".php2shell-guard.bak" - if not os.path.exists(bak): - shutil.copy2(p, bak) - with open(p, "w") as fh: - fh.write(new) - changes.append(p) + print(" %s %s" % ("patch " if not args.no_fpm_pools else "skip ", ctx.where(p))) + if not args.no_fpm_pools: + write(p, new, backup=True) if not pools: print(" no pool files found") print("\nApache (mod_php) config:") - if adir and not args.no_apache: - target = os.path.join(adir, APACHE_CONF_NAME) - print(" write %s" % target) + if not adir or args.no_apache: + print(" no Apache config dir found (or --no-apache)") + elif ctx is not HOST and not has_module: + print(" skipped: no mod_php in this container") + else: + target = ctx.join(adir, APACHE_CONF_NAME) + print(" write %s" % ctx.where(target)) print("".join(" " + l + "\n" for l in apache_conf_text(args, has_85).splitlines())) - if apply_: - with open(target, "w") as fh: - fh.write(apache_conf_text(args, has_85)) - changes.append(target) - if adir == "/etc/apache2/conf-available" and which("a2enconf"): - run(["a2enconf", APACHE_CONF_NAME[:-5]], timeout=30) + if write(target, apache_conf_text(args, has_85)) and apply_: + if adir == "/etc/apache2/conf-available" and ctx.which("a2enconf"): + ctx.run(["a2enconf", APACHE_CONF_NAME[:-5]], timeout=30) for tester in (("apachectl", "-t"), ("apache2ctl", "-t"), ("httpd", "-t")): - if which(tester[0]): - rc, out, err = run(list(tester), timeout=30) + if ctx.which(tester[0]): + rc, out, err = ctx.run(list(tester), timeout=30) ok = rc == 0 print(" %s %s -t: %s" % (c("ok ", "green") if ok else c("FAIL", "red"), tester[0], (err or out).strip().splitlines()[-1] if (err or out).strip() else "")) if not ok: print(c(" Apache config test failed: removing %s" % target, "red")) - os.remove(target) - if which("a2disconf"): - run(["a2disconf", APACHE_CONF_NAME[:-5]], timeout=30) + ctx.remove(target) + changes.remove(target) + if ctx.which("a2disconf"): + ctx.run(["a2disconf", APACHE_CONF_NAME[:-5]], timeout=30) break - else: - print(" no Apache config dir found (or --no-apache)") + return planned, changes + + +def cmd_protect(args): + apply_ = args.apply + docker = args.docker or bool(args.container) + if args.rollback: + return protect_rollback(args) + if not args.post_max and args.upload_max: + print("--upload-max requires --post-max") + return 3 + if args.post_max and not args.upload_max: + pm = ini_bytes(args.post_max) or 0 + args.upload_max = "%dM" % max(1, (pm // (1024 ** 2)) - 2) if pm >= 3 * 1024 ** 2 else args.post_max + if apply_ and not docker and not is_root() and os.name != "nt": + print(c("protect --apply needs root to write configuration and restart services", "red")) + return 3 + banner("%s protect%s (%s)" % (TOOL, " --docker" if docker else "", "APPLY" if apply_ else "DRY RUN - add --apply to write")) + print(" Interim mitigations from the php.net advisory. They reduce exposure; only updating PHP is complete.\n") + + if docker: + return protect_docker(args, apply_) + + planned, changes = protect_target(args, HOST, find_php_binaries(), apply_) print("\nServices:") svcs = php_services() @@ -1675,6 +2318,41 @@ def cmd_protect(args): return 0 +def protect_docker(args, apply_): + targets, without, err = docker_php_targets(args.container) + if err: + print(c(" %s" % err, "yellow")) + if without and not args.container: + print(" no PHP in: %s" % ", ".join(without)) + if not targets: + print(" no running container with PHP found") + return 3 if err else 0 + changes = [] + for ct, bins in targets: + print() + banner("container %s (%s)" % (ct.name, ct.image)) + planned, ch = protect_target(args, ct, bins, apply_) + changes += ch + print("\nWorkers:") + if args.restart: + reload_container(ct, dry=not apply_) + else: + print(" (add --restart to reload PHP-FPM / Apache inside the container)") + if apply_ and ch: + print("\nPersistence:") + container_persistence_note(ct, ch) + print() + if apply_: + print(c("Applied %d change(s) in %d container(s)." % (len(changes), len(targets)), "green")) + print("Verify with: %s scan --docker" % sys.argv[0]) + if not args.restart: + print(c("Workers still run the old configuration until reloaded (--restart).", "yellow")) + else: + print("Dry run complete. Re-run with --apply to write these changes (docker access is enough, no root).") + print("Changes made inside a container last until it is recreated; --apply explains how to keep them.") + return 0 + + def insert_fpm_block(content, block): lines = content.splitlines() out, changed = [], False @@ -1700,47 +2378,71 @@ def insert_fpm_block(content, block): return "\n".join(out) + "\n" -def protect_rollback(args): - apply_ = args.apply - banner("%s protect --rollback (%s)" % (TOOL, "APPLY" if apply_ else "DRY RUN")) - if apply_ and not is_root() and os.name != "nt": - print(c("needs root", "red")) - return 3 +def rollback_target(ctx, bins, apply_): + """Remove (or list) everything protect wrote on one target; returns the count.""" n = 0 - for d in discover_confd_dirs(): + + def act(fn, *a): + if not apply_: + return + try: + fn(*a) + except (OSError, IOError) as e: + print(" %s %s" % (c("FAIL", "red"), e)) + + for d in discover_confd_dirs(ctx, bins): if d.startswith("!"): continue - t = os.path.join(d, DROPIN_NAME) - if os.path.exists(t): - print(" remove %s" % t) + t = ctx.join(d, DROPIN_NAME) + if ctx.exists(t): + print(" remove %s" % ctx.where(t)) n += 1 - if apply_: - os.remove(t) - for p in discover_fpm_pools(): - try: - with open(p, "r", errors="replace") as fh: - content = fh.read() - except OSError: - continue - if MARK_BEGIN not in content: + act(ctx.remove, t) + for p in discover_fpm_pools(ctx): + content = ctx.read_text(p) + if content is None or MARK_BEGIN not in content: continue - new = insert_fpm_block(content, []) # insert_fpm_block with empty block re-adds nothing but strips the old block - print(" strip %s" % p) + print(" strip %s" % ctx.where(p)) n += 1 - if apply_: - with open(p, "w") as fh: - fh.write(new) - adir = discover_apache_dir() + act(ctx.write_text, p, insert_fpm_block(content, [])) + adir = discover_apache_dir(ctx) if adir: - t = os.path.join(adir, APACHE_CONF_NAME) - if os.path.exists(t): - print(" remove %s" % t) + t = ctx.join(adir, APACHE_CONF_NAME) + if ctx.exists(t): + print(" remove %s" % ctx.where(t)) n += 1 - if apply_: - if which("a2disconf"): - run(["a2disconf", APACHE_CONF_NAME[:-5]], timeout=30) - os.remove(t) + if apply_ and ctx.which("a2disconf"): + ctx.run(["a2disconf", APACHE_CONF_NAME[:-5]], timeout=30) + act(ctx.remove, t) + return n + + +def protect_rollback(args): + apply_ = args.apply + docker = args.docker or bool(args.container) + banner("%s protect --rollback%s (%s)" % (TOOL, " --docker" if docker else "", "APPLY" if apply_ else "DRY RUN")) + if apply_ and not docker and not is_root() and os.name != "nt": + print(c("needs root", "red")) + return 3 + if docker: + targets, without, err = docker_php_targets(args.container) + if err: + print(c(" %s" % err, "yellow")) + n = 0 + for ct, bins in targets: + print(c("container %s (%s)" % (ct.name, ct.image), "bold")) + k = rollback_target(ct, bins, apply_) + n += k + if not k: + print(" nothing to remove") + if args.restart: + reload_container(ct, dry=not apply_) + elif apply_ and k: + print(c(" reload with --restart so workers drop the mitigation config", "yellow")) + print("\n%d item(s) %s." % (n, "removed" if apply_ else "would be removed")) + return 0 + n = rollback_target(HOST, find_php_binaries(), apply_) print("\n%d item(s) %s." % (n, "removed" if apply_ else "would be removed")) if args.restart: print("Services:") @@ -1771,19 +2473,45 @@ def protect_rollback(args): re.compile(rb"preg_replace\s*\(\s*['\"][^'\"]*/e['\"]", re.I), re.compile(rb"(?:create_function|array_map|call_user_func(?:_array)?)\s*\(\s*(?:\$_(?:GET|POST|REQUEST)|['\"](?:system|exec|shell_exec|passthru)['\"])", re.I), re.compile(rb"\bmove_uploaded_file\s*\(\s*\$_FILES\[[^\]]+\]\[.tmp_name.\]\s*,\s*\$_(?:GET|POST|REQUEST)", re.I), - re.compile(rb"(?:c99|r57|wso|b374k|FilesMan|WebShell|phpspy|uname -a;)", re.I), + # known kits by their own identifiers; bare "c99"/"wso" also hit hashes and names like shouldShowSocial + re.compile(rb"(?:\bc99(?:shell|sh_|_buff)|\br57(?:shell|_)|\bWSO(?:setcookie|_version|\s?\d\.\d)|\bb374k\b|\bFilesMan\b|\bphpspy\b|uname -a;)", re.I), ] SESSION_INJECT_RE = re.compile(rb"<\?php|<\?=|