A client came to us after we found a web shell: PHP 7.4 on Debian, with no security patches. We couldn't jump straight to 8.x without testing the app, but the RCE had to be closed NOW. The solution: the Sury repo keeps patched builds of the same 7.4 series — so we applied the security fixes without changing the version and without breaking the app. Here is the exact script.
The context: unpatched PHP 7.4, exposed to RCE
Debian 11 (bullseye) ships PHP 7.4, which no longer receives security patches from the official packages. Any new RCE (remote code execution) vulnerability stays open — exactly what happened: the attacker uploaded a PHP web shell and had access to the server.
In short: an unpatched PHP is an open door; but you can't change the version overnight on a production server — you break the app.
The fix that does NOT break the app: Sury's same-series patch
This is the key: the Sury repo (packages.sury.org, maintained by a Debian developer) provides patched builds of the same series. You go from 7.4.33 (Debian, unpatched) to 7.4.33-xx+sury (same 7.4 series, but with the security fixes). The version stays 7.4 — the app feels no difference, but the security hole closes. Moving to 8.x is a separate step, done when you have time to test.
In short: you get the security patches on PHP 7.4 WITHOUT changing the version — the app runs exactly the same, but is no longer vulnerable.
The script we used
The script below adds the Sury repo and upgrades each PHP package to the patched build of the same series. It's idempotent (run it as often as you like), auto-detects the Debian codename and any apt proxy, and upgrades only packages that have a Sury candidate (skips orphans). It does a dry run and asks for confirmation before applying.
#!/bin/bash
# Add the sury.org repo + upgrade PHP to the PATCHED build of the SAME series
# (e.g. 7.4.33 Debian -> 7.4.33-xx+sury). Idempotent. Run as root.
# Works on Debian bullseye/bookworm/trixie (codename auto-detected).
set -e
export DEBIAN_FRONTEND=noninteractive
. /etc/os-release
CODENAME=${VERSION_CODENAME:-bullseye}
echo "=== $(hostname) / Debian $CODENAME ==="
# detect apt proxy (some boxes only have egress via a proxy)
PROXY=$(grep -rhoiE 'Proxy[[:space:]]+"https?://[^"]+"' /etc/apt/apt.conf.d/ /etc/apt/apt.conf 2>/dev/null | grep -oE 'https?://[^"]+' | head -1)
if [ -n "$PROXY" ]; then
echo "apt proxy detected: $PROXY (used for key + https)"
export http_proxy="$PROXY" https_proxy="$PROXY"
echo "Acquire::https::Proxy \"${PROXY}\";" > /etc/apt/apt.conf.d/00sury-proxy
fi
apt-get install -y -q apt-transport-https ca-certificates curl gnupg >/dev/null 2>&1 || true
curl -fsSL ${PROXY:+--proxy "$PROXY"} https://packages.sury.org/php/apt.gpg -o /usr/share/keyrings/deb.sury.org-php.gpg
echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ ${CODENAME} main" > /etc/apt/sources.list.d/php-sury.list
echo "--- apt update ---"
apt-get update 2>&1 | grep -iE "sury|^Err|Failed" || true
# installed PHP packages
PKGS=$(dpkg-query -W -f='${Package}\n' 2>/dev/null | grep -E '^php[0-9]+\.[0-9]+(-|$)|^libapache2-mod-php[0-9]' | tr '\n' ' ')
echo "--- installed PHP packages: ${PKGS} ---"
echo "BEFORE: $(php -v 2>/dev/null | head -1)"
# keep only packages that have a sury candidate (skip orphans without one, e.g. php5)
UPG=""
for p in ${PKGS}; do
cand=$(apt-cache policy "$p" 2>/dev/null | awk '/Candidate:/{print $2}')
inst=$(apt-cache policy "$p" 2>/dev/null | awk '/Installed:/{print $2}')
if [ -n "$cand" ] && [ "$cand" != "(none)" ] && [ "$cand" != "$inst" ]; then UPG="$UPG $p"; fi
done
echo "--- to upgrade (sury candidate): ${UPG:-NONE} ---"
if [ -z "$UPG" ]; then
echo "nothing to upgrade (already current or no candidate)"
else
echo "--- dry run ---"
apt-get install --only-upgrade -s ${UPG} 2>&1 | grep -E "upgraded,|^Remv" | tail -3
read -p "Enter = continue, Ctrl+C = abort: " x
echo "--- upgrade (confold) ---"
apt-get install --only-upgrade -y \
-o Dpkg::Options::=--force-confold -o Dpkg::Options::=--force-confdef ${UPG} 2>&1 | tail -4
fi
systemctl reload apache2 2>/dev/null || true
for f in $(systemctl list-units --type=service 2>/dev/null | grep -oE 'php[0-9.]+-fpm'); do
systemctl reload "$f" 2>/dev/null || true
done
echo "AFTER: $(php -v 2>/dev/null | head -1)"
echo "--- build source (should show +sury / +deb...~1.gbp) ---"
dpkg-query -W -f='${Package} ${Version}\n' $(echo "$PKGS" | awk '{print $1}') 2>/dev/null
echo "apache: $(systemctl is-active apache2 2>/dev/null)"
echo SURY_DONEAt the end you check that the version shows the +sury suffix — proof you're running the patched build, not the old Debian one.
Optional, later: migrate to PHP 8.x
The patch above closes the security hole immediately. Moving to a new series (8.2/8.3) is a separate, optional step, done calmly after you test the app — also from the Sury repo:
# OPTIONAL — only after you test the app on the new series
apt -y install php8.2-fpm php8.2-cli php8.2-mysql php8.2-curl php8.2-mbstring php8.2-xml php8.2-zip
update-alternatives --set php /usr/bin/php8.2
systemctl enable --now php8.2-fpm
# switch the pool to 8.2, test, and only then remove the old series:
apt -y purge 'php7.4*' && apt -y autoremove
Automatic patching, so it doesn't happen again
The root problem was that nobody applied updates. We set unattended-upgrades to automatically install security fixes (including Sury's) and needrestart to restart affected services.
apt -y install unattended-upgrades needrestart
dpkg-reconfigure -plow unattended-upgrades # enable
# preview what it would install, without applying:
unattended-upgrade --dry-run -d 2>&1 | tailIn short: from now on the server pulls its own security patches every night; you just get the report.
Result
The server now runs patched PHP 7.4 (Sury build), the web shell and access paths were removed, and security patches apply automatically — without changing the version and without risking the app. Moving to 8.x remains an optional step, done calmly. We also put the server under monitoring, to immediately spot any new file in the web root.