Admin Intelligence
Ihre Linux Server
Administratoren

Persönlich – kompetent – schnell

Linux Server Administration Cloud & Virtualisierung Monitoring & Backup Kosteneffizienz & Transparenz Open Source Lösungen IT-Security
DACH-Region Hosting in Deutschland

PHP2Shell: Wie die PHP-Lücke ausgenutzt wurde – und wie Sie automatisiert patchen

Am 21. Oktober 2026 hat das PHP-Projekt Notfall-Updates für zwei kritische Lücken veröffentlicht, zusammen bekannt als PHP2Shell: CVE-2026-109618 und CVE-2026-109619, beide CVSS 9,2. Angreifer brauchen weder Login noch eine Schwachstelle in Ihrer Anwendung – die Lücke steckt in PHP selbst. In unserem ersten Beitrag ging es um sichere Versionen und Sofortmaßnahmen; hier geht es um die tatsächliche Ausnutzung und ein Skript, das alle PHP-Versionen eines Servers automatisch prüft und patcht.

Die zwei Lücken

CVETypKurz
CVE-2026-109618RCE via Out-of-Memory-CorruptionGeht PHP mitten in der Anfrage der Speicher aus, bleiben interne Daten halb initialisiert und werden beim Aufräumen als gültig behandelt.
CVE-2026-109619RCE via Stale Global StateZustand eines abgestürzten Requests bleibt im Worker; ein späterer Request liest ihn erneut. Betroffener Pfad: Session Upload Progress (PHP_SESSION_UPLOAD_PROGRESS).

Beide werden erreicht, während PHP die Anfrage einliest – ein einziger POST genügt. Das Abschalten von session.upload_progress betrifft nur CVE-2026-109619, nicht CVE-2026-109618.

Wie ausgenutzt wurde

  • Angriffe liefen schon vor dem Patch – erste Treffer rund fünfeinhalb Stunden vor dem Release, erste Versuche laut Amazon sogar Tage vorher.
  • Das BSI warnte mit Stufe „3 / Orange“; Behörden und Kommunen nahmen Webauftritte vom Netz.
  • Innerhalb von Stunden folgten automatisierte Massen-Scans, Overload-Requests gegen den Multipart-Parser (Bodies bis 37 MB) und das Nachladen echter Malware (u. a. Mirai-Botnetz, Go-RAT, Webshells). Ein Ablauf führte in ~81 Minuten vom Scan zur abgelegten Webshell.

Indikatoren: POSTs mit PHP_SESSION_UPLOAD_PROGRESS (Signal, kein Beweis), hunderte leere Datei-Felder, User-Agents wie php2shell-scan/109618, PHP-Worker, die in Serie mit Speicherfehlern abstürzen oder eine Shell/curl/wget starten. Standen Sie länger verwundbar im Netz: auf Kompromittierung prüfen und Zugangsdaten/API-Schlüssel rotieren.

Sichere Versionen

PHP 8.2.35 · 8.3.36 · 8.4.27 · 8.5.12. Für 8.1 und älter gibt es keinen Fix – jetzt umsteigen.

  • Ubuntu/Debian: Version bleibt oft gleich (php -v meldet weiter 8.3.6). Entscheidend ist die Paketversion: dpkg -l 'php*-common' (abgesichert z. B. ab 8.3.6-0ubuntu0.24.04.13).
  • Docker: Version im Container prüfen (docker exec <container> php -v); docker pull allein reicht nicht.

Einziger vollständiger Schutz ist das Update. Notfall-Settings (bis zum Patch, Worker neu starten): gegen 109619 session.upload_progress.enabled=0 (auf 8.5 zusätzlich fatal_error_backtraces=0), gegen 109618 memory_limit=-1.

Automatisiert patchen

Es ist wichtig zu beachten, dass vor dem Patchen UNBEDINGT eine Sicherung erstellt werden muss!

Unser Bash-Skript erkennt alle installierten PHP-Versionen, prüft den Schutzstatus, installiert ausschließlich PHP-Updates und gibt am Ende eine klare Zusammenfassung aus. Es legt seinen Ablageordner /etc/scripts selbst an. Sicherheitsnetze: nur PHP wird angefasst (sonst Überspringen), eigene php.ini/FPM-Pools bleiben erhalten, altes ppa:ondrej/php wird reversibel auf packages.sury.org umgestellt, gesperrte Pakete (apt-mark hold) bleiben unberührt. Vorher Snapshot anlegen.

Es gibt genau zwei Aufrufe – mit oder ohne --check:

1. Nur prüfen – zeigt den Status an, verändert nichts:

./php-patch.sh --check

2. Prüfen + patchen – wenn der Prüflauf passt, die Updates einspielen:

./php-patch.sh

Das vollständige Skript:

php-patch.sh (klicken zum Auf-/Zuklappen)
bash
#!/usr/bin/env bash
# =============================================================================
#  php-cve-update.sh  –  PHP-Sicherheitsupdate (CVE-2026-109618 / CVE-2026-109619)
# =============================================================================
#
#  WAS MACHT DAS SKRIPT?
#    1. Schaut nach, welche PHP-Versionen auf dem Server installiert sind.
#    2. Prüft für jede Version, ob sie gegen die Lücke geschützt ist.
#    3. Wenn ein Update mit dem Fix da ist, wird es installiert
#       (NUR PHP-Pakete, sonst nichts).
#    4. Zeigt am Ende eine Zusammenfassung: geschützt / nicht geschützt /
#       was du noch tun musst.
#
#  WOHER KOMMT PHP?  (das Skript erkennt das selbst)
#    - Sury (ppa:ondrej/php oder packages.sury.org):
#        Das alte Launchpad-PPA wird automatisch auf packages.sury.org
#        umgestellt, weil der Fix nur dort zuverlässig ankommt.
#    - Ubuntu / Debian (Pakete der Distribution):
#        Es wird geprüft, ob das Ubuntu-/Debian-Security-Update den Fix
#        enthält, und es wird ggf. installiert. Am Repo wird nichts geändert.
#
#  AUFRUF (als root, im Skript-Verzeichnis):
#    ./php-patch.sh --check    # NUR prüfen (verändert nichts)
#    ./php-patch.sh            # prüfen + patchen
#
#  SICHERHEITSNETZE:
#    - Vorher Snapshot machen!
#    - Es wird nur PHP aktualisiert. Würde apt irgendetwas anderes ändern
#      oder entfernen, wird diese PHP-Version übersprungen.
#    - Eigene php.ini / FPM-Pools bleiben erhalten (--force-confold).
#    - Das PPA wird nur umbenannt (*.disabled), nicht gelöscht.
#    - Klappt "apt-get update" nach der Umstellung nicht, wird alles
#      zurückgedreht und NICHTS installiert.
#    - Abgeschaltete PHP-FPM-Dienste werden NICHT wieder gestartet.
#
#  EXIT-CODE:  0 = alles geschützt   1 = Handlungsbedarf / Fehler
# =============================================================================

# ---------------------------------------------------------------------------
#  EINSTELLUNGEN  –  bei der nächsten Lücke nur diesen Block anpassen
# ---------------------------------------------------------------------------
# Erste Sury-/Upstream-Version mit Fix pro PHP-Zweig:
declare -A MIN_VERSION=(
  [8.2]="8.2.35"
  [8.3]="8.3.36"
  [8.4]="8.4.27"
  [8.5]="8.5.12"
)
# CVE-Nummern – danach wird im Changelog gesucht (Ubuntu/Debian-PHP und
# Sury-Backports für alte Versionen wie 8.1):
CVE_IDS="CVE-2026-109618 CVE-2026-109619"

KEYRING="/usr/share/keyrings/debsuryorg-archive-keyring.gpg"
KEYRING_URL="https://packages.sury.org/debsuryorg-archive-keyring.deb"
SURY_LIST="/etc/apt/sources.list.d/php.list"
LOGFILE="/var/log/php-cve-update.log"
HELD_FILE="/root/php-held-packages.txt"   # Liste gesperrter PHP-Pakete (zum Wieder-Sperren)
APT_LOCKS="/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock"
WEB_CONF_DIRS="/etc/apache2/sites-enabled /etc/apache2/conf-enabled /etc/nginx/sites-enabled /etc/nginx/conf.d /etc/caddy"
SCRIPT_DIR="/etc/scripts"                 # Ablageordner; wird vom Skript automatisch angelegt


# ===========================================================================
#  AUSGABE-HELFER (Farben nur im Terminal, im Log ohne Farben)
# ===========================================================================
C_OK="" C_WARN="" C_ERR="" C_INFO="" C_BOLD="" C_OFF=""
setup_colors() {
  if [ -t 1 ]; then
    C_OK=$'\e[32m'; C_WARN=$'\e[33m'; C_ERR=$'\e[31m'; C_INFO=$'\e[36m'
    C_BOLD=$'\e[1m'; C_OFF=$'\e[0m'
  fi
}
step() { echo; echo "${C_BOLD}------ Schritt $1: $2 ------${C_OFF}"; }
ok()   { echo "  ${C_OK}[OK]${C_OFF}       $*"; }
info() { echo "  ${C_INFO}[INFO]${C_OFF}     $*"; }
warn() { echo "  ${C_WARN}[ACHTUNG]${C_OFF}  $*"; }
err()  { echo "  ${C_ERR}[FEHLER]${C_OFF}   $*"; }
sub()  { echo "             $*"; }


# ===========================================================================
#  APT-HELFER
# ===========================================================================
# PIDs, die gerade einen apt/dpkg-Lock halten (leer = apt ist frei).
apt_lock_holders() {
  if command -v fuser >/dev/null; then
    # shellcheck disable=SC2086
    fuser $APT_LOCKS 2>/dev/null | tr -s ' ' '\n' | grep -E '^[0-9]+$' | sort -u
  else
    pgrep -x 'apt-get|apt|dpkg|aptitude' 2>/dev/null
  fi
}

# Wartet bis zu 10 Minuten, bis apt frei ist.
wait_for_apt() {
  local waited=0 max=600 pids
  while :; do
    pids=$(apt_lock_holders)
    [ -z "$pids" ] && return 0
    if [ "$waited" -eq 0 ]; then
      info "apt ist gerade von einem anderen Programm belegt – ich warte (max. 10 min)..."
      ps -o pid,etime,cmd -p "$(echo $pids | tr ' ' ',')" 2>/dev/null | sed 's/^/             /'
    fi
    if [ "$waited" -ge "$max" ]; then
      err "apt ist nach 10 Minuten immer noch belegt."
      return 1
    fi
    sleep 5; waited=$((waited + 5))
  done
}

# apt-get update – versucht es erneut, falls z. B. Checkmk (mk_apt) kurz den Lock hat.
# LC_ALL=C: apt-Meldungen immer auf Englisch, damit die Erkennung unten
# auch auf deutschsprachigen Servern funktioniert.
# Label-Änderung: Das alte ondrej-PPA hat sein "Label" geändert (Hinweis auf
# packages.sury.org). Das ist harmlos und wird automatisch akzeptiert.
apt_update_retry() {
  local i out rc label_ok="" HASH_TRIES=0
  for i in $(seq 1 60); do
    # shellcheck disable=SC2086
    out=$(LC_ALL=C apt-get -o DPkg::Lock::Timeout=300 $label_ok update -qq </dev/null 2>&1); rc=$?
    if [ "$rc" -eq 0 ]; then
      [ -n "$out" ] && echo "$out" | sed 's/^/             /'
      return 0
    fi
    if [ -z "$label_ok" ] && echo "$out" | grep -q "changed its 'Label' value"; then
      info "Das alte PPA hat seinen Namen geändert (Hinweis auf packages.sury.org) – wird akzeptiert."
      label_ok="-o Acquire::AllowReleaseInfoChange::Label=true"
      continue
    fi
    # Hash-Fehler = Ubuntu/Debian-Spiegelserver ist gerade mitten im Abgleich.
    # 3x mit Pause neu versuchen, danach ohne Übersetzungsdateien (i18n).
    if echo "$out" | grep -q 'Hash Sum mismatch'; then
      HASH_TRIES=$(( ${HASH_TRIES:-0} + 1 ))
      if [ "$HASH_TRIES" -le 3 ]; then
        info "Spiegelserver liefert gerade fehlerhafte Dateien (Abgleich läuft) – neuer Versuch in 30 s ($HASH_TRIES/3)..."
        rm -rf /var/lib/apt/lists/partial/* 2>/dev/null
        sleep 30; continue
      elif [[ "$label_ok" != *Languages* ]]; then
        info "Versuche es ohne Übersetzungsdateien..."
        label_ok="$label_ok -o Acquire::Languages=none"
        continue
      fi
    fi
    if echo "$out" | grep -q 'Could not get lock'; then
      [ "$i" -eq 1 ] && info "Paketlisten kurz gesperrt (z. B. Monitoring) – versuche erneut..."
      sleep 5; continue
    fi
    echo "$out" | sed 's/^/             /'
    return 1
  done
  echo "$out" | sed 's/^/             /'
  return 1
}

# Bietet packages.sury.org Pakete für diesen Codename (z. B. focal = nein)?
# Ohne curl oder bei Netzproblemen: "ja" annehmen (dann greift notfalls der Rollback).
sury_has_release() {
  command -v curl >/dev/null || return 0
  local code
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://packages.sury.org/php/dists/$1/InRelease")
  [ "$code" = "404" ] && return 1
  return 0
}

# Ist eine neuere Version im Repo als installiert?
newer_available() {   # $1=installiert $2=candidate
  [ -n "$2" ] && [ "$2" != "(none)" ] && dpkg --compare-versions "$2" gt "$1"
}


# ===========================================================================
#  PHP-HELFER
# ===========================================================================
# Kommt dieses PHP von Sury? (sonst: Ubuntu/Debian)
is_sury_version() {
  [[ "$1" == *deb.sury.org* || "$1" =~ \+0~20[0-9]{6} ]]
}

# Zählt, wie viele der CVE_IDS im Text auf stdin vorkommen.
count_cves() {
  local text id n=0
  text=$(cat)
  for id in $CVE_IDS; do
    grep -q -- "$id" <<<"$text" && n=$((n + 1))
  done
  echo "$n"
}

# Wie viele CVEs stehen im Changelog der INSTALLIERTEN Version?
cves_in_installed_changelog() {
  local V="$1" p cl
  for p in common cli fpm; do
    cl="/usr/share/doc/php$V-$p/changelog.Debian.gz"
    if [ -f "$cl" ]; then zcat "$cl" 2>/dev/null | count_cves; return; fi
  done
  echo 0
}

# Wie viele CVEs stehen im Changelog der VERFÜGBAREN Version? (-1 = nicht abrufbar)
cves_in_candidate_changelog() {
  local out
  out=$(timeout 60 apt-get changelog "php$1-common" 2>/dev/null </dev/null) || { echo -1; return; }
  [ -z "$out" ] && { echo -1; return; }
  count_cves <<<"$out"
}

# Ist PHP-Version $1 geschützt?  Ausgabe: yes / partial / no
protection_status() {
  local V="$1" inst="$2" min="${MIN_VERSION[$1]:-}" n total
  total=$(echo $CVE_IDS | wc -w)
  if is_sury_version "$inst" && [ -n "$min" ]; then
    dpkg --compare-versions "$inst" ge "$min" && { echo yes; return; }
    echo no; return
  fi
  # Ubuntu/Debian-PHP oder alte Sury-Version (8.1 & älter): Changelog entscheidet
  n=$(cves_in_installed_changelog "$V")
  if   [ "$n" -ge "$total" ]; then echo yes
  elif [ "$n" -gt 0 ];        then echo partial
  else                             echo no
  fi
}

# Wird PHP-FPM / mod_php $1 von einer Website genutzt?
used_by_web() {
  local V="$1" s socks dirs=""
  for s in $WEB_CONF_DIRS; do [ -d "$s" ] && dirs="$dirs $s"; done
  [ -e "/etc/apache2/mods-enabled/php$V.load" ] && return 0         # mod_php aktiv
  [ -z "$dirs" ] && return 1
  # shellcheck disable=SC2086
  grep -RqsF "php$V-fpm" $dirs && return 0                           # Standard-Socket
  socks=$(grep -hE '^[[:space:]]*listen[[:space:]]*=' /etc/php/"$V"/fpm/pool.d/*.conf 2>/dev/null \
          | sed 's/^[^=]*=[[:space:]]*//; s/[[:space:]]*$//')
  for s in $socks; do
    # shellcheck disable=SC2086
    grep -RqsF -- "$s" $dirs && return 0                             # eigene Pool-Sockets
  done
  return 1
}

# Aktualisiert NUR die installierten PHP-Pakete einer Version.
# Bricht ab, wenn apt dabei Nicht-PHP-Pakete ändern oder etwas entfernen würde.
upgrade_php() {
  local V="$1" PKGS SIM FOREIGN
  PKGS=$(dpkg -l "php$V-*" "libapache2-mod-php$V" 2>/dev/null | awk '/^[hi]i/{print $2}')
  # shellcheck disable=SC2086
  SIM=$(LC_ALL=C apt-get -s install --only-upgrade $PKGS </dev/null 2>/dev/null | awk '/^(Inst|Remv) /{print $1" "$2}')
  FOREIGN=$(echo "$SIM" | awk 'NF && ($1=="Remv" || $2 !~ /^(php|libapache2-mod-php)/)')
  if [ -n "$FOREIGN" ]; then
    warn "apt würde dabei auch andere Pakete ändern/entfernen – PHP $V wird NICHT angefasst:"
    echo "$FOREIGN" | sed 's/^/               /'
    sub "Bitte manuell ansehen: apt-get -s install --only-upgrade 'php$V-*'"
    return 1
  fi
  if [ -z "$SIM" ]; then
    info "Nichts zu aktualisieren."
    return 0
  fi
  info "Installiere Update für $(echo "$SIM" | grep -c '^Inst') PHP-$V-Pakete ..."
  # shellcheck disable=SC2086
  if ! DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y -qq --only-upgrade \
        -o Dpkg::Options::=--force-confold -o Dpkg::Options::=--force-confdef \
        $PKGS </dev/null >/tmp/php-cve-update.apt.log 2>&1; then
    err "Installation fehlgeschlagen. Details: /tmp/php-cve-update.apt.log"
    return 1
  fi
  ok "Update installiert."
  # Nur neu starten, was vorher lief – abgeschaltete Dienste bleiben aus.
  if systemctl is-active --quiet "php$V-fpm" 2>/dev/null; then
    systemctl restart "php$V-fpm" && ok "php$V-fpm neu gestartet."
  fi
  [ -e "/etc/apache2/mods-enabled/php$V.load" ] && RESTART_APACHE=1
  return 0
}


# ===========================================================================
#  HAUPTPROGRAMM
#  (alles in main(), damit das Skript komplett geladen ist, bevor es startet)
# ===========================================================================
main() {
  local CHECK_ONLY="" CODENAME DISABLED="" CREATED="" RESTART_APACHE="" USES_PPA="" NO_SURY=""
  local V VERS INST CAND SRC STATUS N f TOTAL_CVES HELD
  declare -A R_STATUS R_VERSION R_SRC R_FPM R_USED R_UPD R_WEBSAPI R_HELD
  local TODO=()

  case "${1:-}" in
    --check) CHECK_ONLY=1 ;;
    "") ;;
    -h|--help) sed -n '2,40p' "$0" 2>/dev/null || echo "Optionen: --check"; return 0 ;;
    *) echo "Unbekannte Option: $1  (erlaubt: --check)"; return 1 ;;
  esac
  [ "$(id -u)" -eq 0 ] || { echo "Bitte als root starten."; return 1; }
  command -v apt-get >/dev/null || { echo "Kein Debian/Ubuntu-System (apt fehlt)."; return 1; }

  # Ablageordner automatisch anlegen; bei Datei-Ausfuehrung eine Kopie dorthin legen
  mkdir -p "$SCRIPT_DIR" 2>/dev/null
  if [ -f "$0" ] && [ "$(cd "$(dirname "$0")" 2>/dev/null && pwd)" != "$SCRIPT_DIR" ]; then
    cp -f "$0" "$SCRIPT_DIR/php-cve-update.sh" 2>/dev/null && chmod +x "$SCRIPT_DIR/php-cve-update.sh" 2>/dev/null
  fi

  setup_colors
  # Ausgabe auf Bildschirm UND ins Log (im Log ohne Farbcodes)
  exec > >(tee >(sed -u 's/\x1b\[[0-9;]*m//g' >>"$LOGFILE")) 2>&1

  # shellcheck disable=SC1091
  . /etc/os-release
  CODENAME="${VERSION_CODENAME:-}"
  TOTAL_CVES=$(echo $CVE_IDS | wc -w)

  echo "${C_BOLD}=================================================================${C_OFF}"
  echo "${C_BOLD} PHP-Sicherheitsupdate   $(date '+%d.%m.%Y %H:%M')   Server: $(hostname)${C_OFF}"
  echo " System: ${PRETTY_NAME:-unbekannt}"
  echo " Lücken: $CVE_IDS"
  [ -n "$CHECK_ONLY" ] && echo " ${C_WARN}Modus:  NUR PRÜFEN – es wird nichts verändert${C_OFF}"
  echo "${C_BOLD}=================================================================${C_OFF}"

  VERS=$(dpkg -l 'php*-common' 2>/dev/null | awk '/^[hi]i/{print $2}' | sed -n 's/^php\([0-9]\.[0-9]\)-common$/\1/p')
  if [ -z "$VERS" ]; then
    echo; ok "Auf diesem Server ist kein PHP installiert – nichts zu tun."
    return 0
  fi

  # ---------------------------------------------------------------- Schritt 1
  step 1 "Warten, bis apt frei ist"
  wait_for_apt || { err "Abbruch – es wurde nichts verändert. Später nochmal starten."; return 1; }
  ok "apt ist frei."

  # ---------------------------------------------------------------- Schritt 2
  step 2 "Paketquellen prüfen"
  grep -rqsE 'ppa\.launchpad(content)?\.net/ondrej/php' /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources /etc/apt/sources.list && USES_PPA=1

  if ! dpkg-query -W -f='${Version}\n' 'php*-common' 2>/dev/null | grep -qE 'deb\.sury\.org|\+0~20[0-9]{6}'; then
    ok "PHP kommt von ${NAME:-der Distribution} (offizielle Pakete) – Paketquellen bleiben unverändert."
  elif [ -z "$USES_PPA" ]; then
    ok "PHP kommt von Sury (packages.sury.org) – passt."
  elif ! sury_has_release "$CODENAME"; then
    NO_SURY=1
    USES_PPA=""
    warn "Server nutzt noch das alte Launchpad-PPA, aber packages.sury.org bietet"
    sub "für ${PRETTY_NAME:-dieses System} ($CODENAME) KEINE Pakete an."
    sub "-> Für dieses Betriebssystem kommen von Sury keine PHP-Updates mehr."
    sub "   Paketquellen bleiben unverändert."
  elif [ -n "$CHECK_ONLY" ]; then
    warn "Server nutzt noch das alte Launchpad-PPA (ondrej/php)."
    sub "Dort kommt der Fix später oder gar nicht an. Ohne --check stellt das"
    sub "Skript automatisch auf packages.sury.org um."
  else
    info "Server nutzt noch das alte Launchpad-PPA -> stelle auf packages.sury.org um."
    if [ ! -f "$KEYRING" ]; then
      dpkg -s ca-certificates >/dev/null 2>&1 || apt-get -o DPkg::Lock::Timeout=300 -y -qq install ca-certificates </dev/null >/dev/null
      command -v curl >/dev/null || apt-get -o DPkg::Lock::Timeout=300 -y -qq install curl </dev/null >/dev/null
      curl -fsSLo /tmp/debsuryorg-archive-keyring.deb "$KEYRING_URL" \
        && dpkg -i /tmp/debsuryorg-archive-keyring.deb </dev/null >/dev/null
    fi
    if [ ! -f "$KEYRING" ]; then
      err "Sury-Schlüssel konnte nicht installiert werden – Paketquellen bleiben unverändert."
    else
      for f in $(grep -rlE 'ppa\.launchpad(content)?\.net/ondrej/php' /etc/apt/sources.list.d/ 2>/dev/null | grep -v '\.disabled$'); do
        mv -n "$f" "$f.disabled" && DISABLED="$DISABLED $f" && info "PPA deaktiviert: $(basename "$f")"
      done
      if ! grep -rqs 'packages.sury.org/php' /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; then
        echo "deb [signed-by=$KEYRING] https://packages.sury.org/php/ $CODENAME main" > "$SURY_LIST"
        CREATED=1
        info "packages.sury.org eingetragen ($CODENAME)."
      fi
    fi
  fi

  # ---------------------------------------------------------------- Schritt 3
  step 3 "Paketlisten aktualisieren (apt-get update)"
  info "Läuft ... das kann 1–3 Minuten dauern, bitte NICHT abbrechen."
  # Falls doch jemand Strg+C drückt: Umstellung sauber zurückdrehen.
  if [ -n "$DISABLED$CREATED" ]; then
    trap 'echo; warn "Abgebrochen – mache die Umstellung rückgängig ..."
          for f in $DISABLED; do mv -n "$f.disabled" "$f"; done
          [ -n "$CREATED" ] && mv -f "$SURY_LIST" "$SURY_LIST.failed"
          info "Paketquellen sind wieder wie vorher. Es wurde nichts installiert."
          exit 130' INT TERM
  fi
  if apt_update_retry; then
    trap - INT TERM
    ok "Paketlisten aktuell."
    [ -n "$DISABLED$CREATED" ] && ok "Umstellung auf packages.sury.org erfolgreich."
    rm -f "$SURY_LIST.failed"   # Rest eines früheren Fehlversuchs (eigene Datei)
  else
    trap - INT TERM
    err "apt-get update ist fehlgeschlagen."
    if [ -n "$DISABLED$CREATED" ]; then
      info "Mache die Umstellung rückgängig – es wird NICHTS installiert."
      for f in $DISABLED; do mv -n "$f.disabled" "$f"; done
      [ -n "$CREATED" ] && mv -f "$SURY_LIST" "$SURY_LIST.failed"
      apt_update_retry >/dev/null
    fi
    return 1
  fi

  # ---------------------------------------------------------------- Schritt 4
  step 4 "PHP-Versionen prüfen und patchen"
  for V in $VERS; do
    INST=$(dpkg-query -W -f='${Version}' "php$V-common")
    CAND=$(LC_ALL=C apt-cache policy "php$V-common" | awk '/Candidate:/{print $2}')
    if is_sury_version "$INST"; then SRC="Sury"; else SRC="${NAME:-Distribution}"; fi
    echo
    echo "  ${C_BOLD}PHP $V${C_OFF}  (Quelle: $SRC)"
    sub "installiert: $INST"
    [ "$CAND" != "$INST" ] && sub "verfügbar:   $CAND"

    STATUS=$(protection_status "$V" "$INST")

    # Gesperrte Pakete (apt-mark hold) werden bewusst NICHT angefasst.
    HELD=$(dpkg -l "php$V-*" "libapache2-mod-php$V" 2>/dev/null | awk '/^hi/{print $2}')
    if [ -n "$HELD" ]; then
      R_HELD[$V]=1
      echo "$HELD" >> "$HELD_FILE"
      sort -u -o "$HELD_FILE" "$HELD_FILE"
      warn "$(echo "$HELD" | wc -l) PHP-$V-Pakete sind gegen Updates gesperrt (apt-mark hold)."
      [ "$STATUS" != "yes" ] && sub "Werden NICHT automatisch aktualisiert – siehe Zusammenfassung."
    fi

    if [ "$STATUS" != "yes" ] && [ -z "$HELD" ] && newer_available "$INST" "$CAND"; then
      if [ -n "$CHECK_ONLY" ]; then
        R_UPD[$V]=1
        if is_sury_version "$CAND" && [ -n "${MIN_VERSION[$V]:-}" ]; then
          if dpkg --compare-versions "$CAND" ge "${MIN_VERSION[$V]}"; then
            info "Update mit Fix ist verfügbar – wird im Prüf-Modus NICHT installiert."
          else
            info "Update verfügbar, enthält den Fix aber noch nicht (braucht ${MIN_VERSION[$V]})."
          fi
        else
          N=$(cves_in_candidate_changelog "$V")
          if   [ "$N" -ge "$TOTAL_CVES" ]; then info "Update mit Fix ist verfügbar – wird im Prüf-Modus NICHT installiert."
          elif [ "$N" -gt 0 ];              then info "Update verfügbar, enthält aber nur einen Teil der Fixes."
          elif [ "$N" -eq 0 ];              then info "Update verfügbar, enthält den Fix laut Changelog aber noch nicht."
          else                                   info "Update verfügbar – ob der Fix drin ist, lässt sich vorab nicht prüfen."
          fi
        fi
      else
        upgrade_php "$V"
        INST=$(dpkg-query -W -f='${Version}' "php$V-common")
        STATUS=$(protection_status "$V" "$INST")
      fi
    fi

    case "$STATUS" in
      yes)     ok "GESCHÜTZT" ;;
      partial) warn "NUR TEILWEISE GESCHÜTZT (nicht alle CVEs im Changelog)" ;;
      *)       warn "NICHT GESCHÜTZT" ;;
    esac
    if [ "$SRC" = "Sury" ] && [ -z "${MIN_VERSION[$V]:-}" ]; then
      warn "PHP $V bekommt offiziell keine Updates mehr – Umstieg auf PHP 8.3+ planen."
    fi

    # Für die Zusammenfassung merken
    R_STATUS[$V]=$STATUS
    R_VERSION[$V]=$INST
    R_SRC[$V]=$SRC
    if systemctl list-unit-files "php$V-fpm.service" 2>/dev/null | grep -q "php$V-fpm"; then
      R_FPM[$V]=$(systemctl is-active "php$V-fpm" 2>/dev/null)
    else
      R_FPM[$V]="-"
    fi
    if used_by_web "$V"; then R_USED[$V]="ja"; else R_USED[$V]="nein"; fi
    # Gibt es überhaupt eine Web-Schnittstelle (FPM, mod_php, CGI)? Reines CLI ist nicht betroffen.
    if [ "${R_FPM[$V]}" != "-" ] || dpkg -l "libapache2-mod-php$V" "php$V-cgi" 2>/dev/null | grep -q '^ii'; then
      R_WEBSAPI[$V]=1
    fi
  done

  if [ -n "$RESTART_APACHE" ] && systemctl is-active --quiet apache2; then
    systemctl restart apache2 && ok "apache2 neu gestartet (mod_php)."
  fi

  # ---------------------------------------------------------------- Zusammenfassung
  echo
  echo "${C_BOLD}=================================================================${C_OFF}"
  echo "${C_BOLD} ZUSAMMENFASSUNG  –  $(hostname)${C_OFF}"
  echo "${C_BOLD}=================================================================${C_OFF}"
  printf "  %-8s %-20s %-10s %-10s %s\n" "PHP" "Status" "FPM" "Website?" "Version"
  printf "  %-8s %-20s %-10s %-10s %s\n" "---" "------" "---" "--------" "-------"
  for V in $VERS; do
    case "${R_STATUS[$V]}" in
      yes)     S="${C_OK}GESCHÜTZT${C_OFF}           " ;;
      partial) S="${C_WARN}TEILWEISE${C_OFF}           " ;;
      *)       S="${C_ERR}NICHT GESCHÜTZT${C_OFF}     " ;;
    esac
    printf "  %-8s %s %-10s %-10s %s\n" "$V" "$S" "${R_FPM[$V]}" "${R_USED[$V]}" "${R_VERSION[$V]}"

    # Handlungsempfehlungen
    if [ "${R_STATUS[$V]}" != "yes" ] && [ -n "${R_HELD[$V]:-}" ]; then
      TODO+=("PHP $V ist ungeschützt, weil die Pakete gesperrt sind (apt-mark hold). Erst klären, warum gesperrt
       (z. B. Nextcloud-Setup), dann: freigeben, Skript erneut starten, wieder sperren:
       apt-mark unhold \$(cat $HELD_FILE)
       <Skript erneut starten>
       apt-mark hold \$(cat $HELD_FILE)")
    elif [ "${R_STATUS[$V]}" != "yes" ] && [ -n "${R_UPD[$V]:-}" ]; then
      TODO+=("Für PHP $V ist ein Update verfügbar -> Skript OHNE --check starten.")
    elif [ "${R_STATUS[$V]}" != "yes" ] && [ -z "${R_WEBSAPI[$V]:-}" ]; then
      TODO+=("PHP $V ist nur als Kommandozeile (CLI) installiert – die Lücke betrifft nur Web-PHP (FPM/mod_php/CGI).
       Kein akutes Risiko. Update einspielen, sobald verfügbar.")
    elif [ "${R_STATUS[$V]}" != "yes" ]; then
      if [ "${R_FPM[$V]}" = "active" ] && [ "${R_USED[$V]}" = "nein" ]; then
        TODO+=("PHP $V ist ungeschützt, läuft aber, obwohl es anscheinend keine Website nutzt.
       Kurz selbst prüfen, dann abschalten:  systemctl disable --now php$V-fpm")
      elif [ "${R_FPM[$V]}" = "active" ] || [ "${R_USED[$V]}" = "ja" ]; then
        local MIT=""
        if [ "${R_FPM[$V]}" = "active" ]; then
          MIT="echo 'session.upload_progress.enabled = 0' > /etc/php/$V/fpm/conf.d/99-cve-2026-109619.ini && systemctl restart php$V-fpm"
        fi
        if [ -e "/etc/apache2/mods-enabled/php$V.load" ]; then
          [ -n "$MIT" ] && MIT="$MIT
       "
          MIT="${MIT}echo 'session.upload_progress.enabled = 0' > /etc/php/$V/apache2/conf.d/99-cve-2026-109619.ini && systemctl restart apache2"
        fi
        [ -z "$MIT" ] && MIT="echo 'session.upload_progress.enabled = 0' > /etc/php/$V/fpm/conf.d/99-cve-2026-109619.ini && systemctl restart php$V-fpm"
        TODO+=("PHP $V ist ungeschützt und in Benutzung. Notlösung bis zum Update (schützt nur gegen CVE-2026-109619):
       $MIT
       Skript später erneut starten, sobald ${R_SRC[$V]} das Update veröffentlicht hat.")
      else
        TODO+=("PHP $V ist ungeschützt, läuft aber nicht – kein akutes Risiko. Bei Gelegenheit deinstallieren.")
      fi
    elif [ "${R_USED[$V]}" = "nein" ] && [ "${R_FPM[$V]}" = "active" ]; then
      TODO+=("PHP $V ist gepatcht, wird aber anscheinend nicht genutzt. Kurz prüfen, dann abschalten:
       systemctl disable --now php$V-fpm")
    fi
  done
  [ -n "$USES_PPA" ] && [ -n "$CHECK_ONLY" ] && \
    TODO+=("Skript OHNE --check starten, damit das alte PPA auf packages.sury.org umgestellt wird.")
  [ -n "$NO_SURY" ] && \
    TODO+=("WICHTIG: ${PRETTY_NAME:-Dieses System} wird von Sury nicht mehr unterstützt – PHP bekommt hier
       keine Updates mehr. Lösung: Server auf ein aktuelles Ubuntu/Debian upgraden oder Websites
       auf einen neuen Server umziehen. Bis dahin Notlösung setzen und ungenutzte PHP-Versionen abschalten.")

  echo
  if [ "${#TODO[@]}" -eq 0 ]; then
    echo "  ${C_OK}${C_BOLD}ALLES ERLEDIGT – kein Handlungsbedarf.${C_OFF}"
  else
    echo "  ${C_WARN}${C_BOLD}WAS DU JETZT NOCH TUN SOLLTEST:${C_OFF}"
    N=1
    for f in "${TODO[@]}"; do
      echo "   $N. $f"
      N=$((N + 1))
    done
  fi
  echo
  echo "  \"Website?\" = in Apache/Nginx/Caddy-Config gefunden (bitte im Zweifel selbst prüfen)"
  echo "  Log: $LOGFILE"
  echo "${C_BOLD}=================================================================${C_OFF}"

  for V in $VERS; do [ "${R_STATUS[$V]}" = "yes" ] || return 1; done
  return 0
}

main "$@"

Die nächste Lücke kommt bestimmt

PHP2Shell hat gezeigt, wie schnell aus einem Leak eine weltweite Angriffswelle wird. Entscheidend ist, wie schnell die nächste Lücke auf Ihren Systemen geschlossen wird. Mit einem Wartungsvertrag übernehmen wir das für Sie – Managed Webhosting, WordPress Wartung & Support, Hetzner Systemadministration, Checkmk Monitoring und OpenVAS Schwachstellenscan.

Sie sind unsicher, ob Ihr Server abgesichert ist? Schreiben Sie uns über das Kontaktformular oder rufen Sie an: +49 731 388 62810 (Mo–Fr, 08:30–17:30 Uhr). Wir prüfen Ihre Systeme und bringen sie auf den aktuellen Stand.