How To

Patch PHP2Shell on DirectAdmin: CustomBuild PHP Rebuild Notes

I patched a DirectAdmin server against PHP2Shell on 21 October, and the part I expected to be hard turned out to be easy. All 78 sites on that box ran PHP 8.1, which is end of life upstream and got no new PHP release for CVE-2026-109618 or CVE-2026-109619. DirectAdmin had already pulled its own backport into CustomBuild through the panel’s auto-update, so the fix was one rebuild away. What it did not do was install it.

Original content from computingforgeeks.com - post 176645

These are my notes from patching PHP2Shell on DirectAdmin: how I checked what CustomBuild knew, how I kept a rollback, what each rebuild cost in Apache downtime, how I proved the 78 sites still answered, and the PHP 8.1 build failure other admins hit that night. I ran the whole patch on 21 October 2026 on a production DirectAdmin server with Apache, PHP-FPM, ionCube and four PHP slots (8.1, 8.2, 8.3 and 8.4), and every timing below comes from that box’s logs.

Patching PHP2Shell on DirectAdmin

Before you plan your own rebuild, it helps to see the shape of mine. This is where the four PHP slots started and where they ended up, and you’ll see the same pattern on most DirectAdmin servers: one slot carries the sites and the rest sit idle:

DirectAdmin PHP slots before and after the PHP2Shell patch: 8.1.34 backport, 8.2.35, 8.3.36, 8.4.27

Only the 8.1 slot served sites, but I rebuilt all four. An unused PHP slot still has a working php-fpm service, and I’d rather not leave a vulnerable interpreter on a server just because nothing points at it today.

Checking what CustomBuild already knew

DirectAdmin updates itself, and the release that DirectAdmin staff announced on the forum bumped PHP 8.2 to 8.5 to the fixed upstream versions and added patches for 8.1, 8.0 and 7.4. A later build added patches for 7.3, 7.2, 7.1, 7.0 and 5.6 too. My server had pulled the first release about an hour before I looked, but auto-update can be switched off or lag behind, so run da update first and then ask CustomBuild what it plans to do. Every command in these notes runs as root, because CustomBuild, the PHP prefixes and the files under /root all need it, so open a root shell with sudo -i before you start:

da update
da build versions

All four PHP slots showed an update. The 8.2, 8.3 and 8.4 slots listed the new upstream versions, while 8.1 listed 8.1.34* against an installed 8.1.34. The asterisk is DirectAdmin’s marker for “same PHP version, new patches added”, which is how a backport shows up in CustomBuild. The patch itself sits in the CustomBuild tree, one file per branch covering both CVEs:

ls /usr/local/directadmin/custombuild/patches/php-8.1/ | grep cve-2026-109618

On my server that returned 114-php-8.1-cve-2026-109618.patch. The 7.4 file DirectAdmin staff quoted is 143-php-7.4-cve-2026-109618.patch. If the file is there and da build versions no longer shows an update for that slot, the slot is patched.

Which domains use which slot? In my case every domain ran the default slot 1. If yours are mixed, the slot numbers live in /usr/local/directadmin/custombuild/options.conf (php1_release, php2_release and so on), and each domain’s choice is the php1_select line in its file under /usr/local/directadmin/data/users/*/domains/. A domain without that line uses slot 1.

Recording a baseline before touching anything

There was no VM snapshot available for this change, so I needed two things before rebuilding: a way back, and a way to tell whether the rebuild broke a site. I started with the second one, a status code for every domain on the server, taken from the list Exim already keeps. DirectAdmin binds every user’s virtual host to the server’s IP address, not to 127.0.0.1, so the check has to connect to that IP. A request to localhost lands on Apache’s default host and returns the same answer for every domain, which would make the before and after files match even with broken sites. Take the IP from DirectAdmin’s own list:

IP=$(head -1 /usr/local/directadmin/data/admin/ip.list)
[ -n "$IP" ] && echo "checking against $IP" || echo "no IP found in ip.list"
sort -u /etc/virtual/domains > /root/domains.txt

Stop if the second line says no IP was found, because every request in the loop would then go nowhere and the baseline would be useless. Then record the status code and the X-Powered-By header for each domain. The header is the useful extra, because it names the PHP version that actually answered, so a site on an unexpected slot stands out:

while read -r d; do printf "%s %s %s\n" "$d" $(curl -sk -m15 -o /dev/null -D - -w "CODE=%{http_code}\n" --resolve "$d:443:$IP" "https://$d/" | awk -F': ' 'tolower($1)=="x-powered-by"{p=$2} /^CODE=/{c=substr($0,6)} END{gsub("\r","",p); print c, (p?p:"-")}'); done < /root/domains.txt > /root/http-before.txt

Each line reads like example.com 200 PHP/8.1.34, with a dash where expose_php is off or the page isn’t PHP. On a server with more than one IP, a domain bound to a second address needs that address instead (the ip= line in the domain’s .conf under /usr/local/directadmin/data/users/*/domains/). The loop only checks HTTPS on port 443, so a site without a certificate records whatever Apache’s default SSL host answers, or 000 if nothing answers. Either is fine as long as it shows the same result after the rebuild, but it says nothing about that site’s PHP. --resolve sends each request to the server’s IP without touching DNS, so the check measures this box and not a CDN in front of it. My baseline came back as 52 sites answering 200, 2 answering 301 and 24 answering 403 (suspended or empty accounts). Those numbers aren’t good or bad. They’re the thing that must not change, and you’ll compare against them after every rebuild.

Keeping a rollback copy of each PHP slot

CustomBuild installs each PHP slot under its own prefix, so a plain directory copy is a complete rollback for that slot’s PHP. The 8.1 copy took 132 MB:

cp -a /usr/local/php81 /usr/local/php81.pre-20261021

If the rebuilt PHP misbehaves, stopping its FPM service and swapping the directories back restores the old build in seconds:

systemctl stop php-fpm81
mv /usr/local/php81 /usr/local/php81.patched
mv /usr/local/php81.pre-20261021 /usr/local/php81
systemctl start php-fpm81

I haven’t needed it, and I’ll delete the copies once the patched build has run cleanly for a few days. I’d still take them every time. A failed compile is not what they protect against, because CustomBuild only replaces the files when the build succeeds. The risk is a build that succeeds and then misbehaves, such as an extension or ionCube loader that no longer loads, and the copy turns that into a one-minute rollback.

Rebuilding PHP 8.1 first

DirectAdmin’s own instructions were da update followed by da build php, which rebuilds every configured PHP version in one go. I wanted to see one slot succeed before risking the rest, so I rebuilt the one that served all 78 sites on its own. Keep the output in a file, because it is the proof that the patch applied:

da build php 8.1 2>&1 | tee /root/build-php81.log

The build took about four minutes and ended with “PHP 8.1.34 Installed”. Its patch step applies every patch CustomBuild keeps for the 8.1 tree, not only the new one, and it reported 205 files patched with no FAILED hunks and no .rej files. My change notes list the files PHP’s own minimal end-of-life fix touches among them: Zend/zend_alloc.c, main/rfc1867.c, main/SAPI.c, ext/session/session.c and the OPcache sources. Then CustomBuild did two restarts on its own:

  1. a full Apache restart, which took the sites down for about 5 seconds
  2. a graceful Apache reload about 50 seconds later, after it rewrote the users’ httpd.conf files

It also restarted php-fpm81, so there was nothing left for me to restart by hand. That full restart is the cost to plan for. Five seconds of failed requests is nothing on a quiet evening and a lot during a shop’s busiest hour.

Proving the rebuild took

CustomBuild’s slot binaries live under each prefix. The backport keeps the 8.1.34 version string, so php -v can only show that a new build is in place and that its extensions still load:

/usr/local/php81/bin/php -v

Mine reported 8.1.34 built at 22:34 that evening, with ionCube and OPcache still loaded, which mattered because several sites ran encoded code. A fresh build date shows the rebuild ran, not that the patch is in it. The proof is two things together: da build versions no longer reports an 8.1 update, and the build output shows the patches applying. CustomBuild names a patch file only when it fails to apply, as the failed builds in the forum thread show, so search the log you saved for the files the CVE patch touches, and for any failure:

grep -E 'patching file (Zend/zend_alloc.c|main/rfc1867.c|ext/session/session.c)|FAILED|failed to apply' /root/build-php81.log

The patching file lines only show that the patch step ran. Several patches in the tree touch the same files, so they can’t single out the CVE patch. The proof is the absence of failures: no FAILED or failed to apply line, together with “PHP 8.1.34 Installed” at the end, means every patch in patches/php-8.1/ applied, including the 114-php-8.1-cve-2026-109618.patch file from the earlier ls.

Then I ran the baseline loop again into /root/http-after.txt and compared the two files. If you are in a new shell by then, re-run the IP= line first:

diff /root/http-before.txt /root/http-after.txt && echo "no changes"

All 78 domains returned the same status code as before. I also grepped the Apache error log for AH03490, the “scoreboard is full” warning that a restart under load can produce, and found none. All 79 php81 pool sockets came back, one per DirectAdmin user rather than per domain.

Rebuilding 8.2, 8.3 and 8.4

With 8.1 proven, I took the same rollback copies of the other three slots:

for v in 82 83 84; do cp -a /usr/local/php$v /usr/local/php$v.pre-$(date +%Y%m%d); done

Then I chained their rebuilds, so the first failure would stop the rest:

da build php 8.2 && da build php 8.3 && da build php 8.4

The three builds took twelve minutes together and landed on 8.2.35, 8.3.36 and 8.4.27, again with no patch failures. Each one restarted Apache fully, and the logs showed how uneven those restarts are:

  • 8.2: 2.5 seconds
  • 8.3: 5.7 seconds
  • 8.4: 5.5 seconds

Each was followed by a graceful reload after the config rewrite. That’s three more short outages for slots nobody was using, which is the argument for doing them in a quiet window rather than straight after the slot that mattered. The site check matched the baseline again, and all four slots still had ionCube and OPcache.

When the PHP 8.1 build fails

My 8.1 build went through cleanly, but DirectAdmin staff warned on the forum that night that 8.1 “does not build cleanly on some systems with the latest patch”. One admin posted the error below, and another reported a Zend build problem on AlmaLinux 10. If you see these lines, the compile failed and your old PHP is still installed and running:

PHP Warning:  Undefined array key "ZEND_VM_KIND_HYBRID" in .../Zend/zend_vm_gen.php on line 2364
Zend/zend_vm_opcodes.h:32:19: error: operator '==' has no left operand
install_php: failed to compile '/usr/local/directadmin/custombuild/cache/php-8.1.34.tar.gz'

DirectAdmin shipped a fix in its next build, described as “a small fix for the PHP 8.1 compilation”, and an admin who had hit the error confirmed it built afterwards. So the cure is to update DirectAdmin and run the build again:

da update
da build php 8.1

A different failure hit servers still building an old point release such as 8.1.25 or 8.0.22: older patches in the tree no longer applied to those tarballs, and the build stopped on a FAILED hunk. If CustomBuild builds an older release than versions.txt names, rule out a pin first. DirectAdmin’s documentation describes custom_versions.txt as the file that overrides versions.txt, so a stale line there wins:

grep -E '^php[0-9]+:' /usr/local/directadmin/custombuild/custom_versions.txt

If it prints a line such as php80:8.0.22:, remove that line, run da update, and check da build versions again before rebuilding. No output, or “No such file”, means nothing is pinned and the cause is elsewhere. That was the case for one admin stuck on 8.0.22: da build clean and da build update changed nothing, and the next build downloaded 8.0.22 again even though versions.txt listed 8.0.30. What finally worked for them was odd. They edited the php80 line in versions.txt to 8.0.22, ran a build that failed, set it back to 8.0.30, and the next build went through. They had no explanation for it, so treat it as an unexplained workaround from one report. The 8.1.25 case, where 001-libxml2-with-pear-compat.patch failed with “Hunk #1 FAILED”, had no confirmed fix in the thread at all.

One more report matters only on Apache and nginx_apache setups, and only if your webapps run on a different PHP from your default slot. Roundcube and phpMyAdmin are served through httpd-hostname.conf, and one admin found it still pointing at an old PHP 8.1 after the update. Check which PHP it names:

grep -i php /etc/httpd/conf/extra/httpd-hostname.conf

Look for the /usr/local/phpXX/sockets/ path in the output and compare it with php1_release in /usr/local/directadmin/custombuild/options.conf: a php1_release=8.1 matches php81. If they differ, da build rewrite_confs regenerates the webapp configs onto the default slot, which is what fixed it for that admin. If they match, you don’t need it.

Letting CustomBuild install the next fix itself

The part of this incident I’d change is the gap between DirectAdmin downloading the fix at 21:12 and me installing it at 22:32. DirectAdmin’s staff put it plainly: the panel updates itself, but PHP rebuilds wait for a human unless the CustomBuild cron is enabled. DirectAdmin’s documentation gives two setups, and the first one only checks and emails. Use your own address in the third line:

da build set cron yes
da build set cron_frequency daily
da build set email [email protected]
da build set notifications yes
da build cron

DirectAdmin’s example uses weekly, but a weekly check could have left this fix sitting for days, so I’d use daily on a web server. That email reports pending CustomBuild updates, including a PHP rebuild like this one, not PHP advisories as such. The second setup lets the cron install updates as well:

da build set updates yes
da build cron

Automatic rebuilds mean automatic Apache restarts at whatever time the cron runs, which is why I won’t turn that one on for this server. The notification cron is the next change I’m making on it, so the next PHP rebuild reaches my inbox instead of waiting for me to read a forum thread.

What CustomBuild doesn’t cover

CloudLinux’s PHP Selector builds live under /opt/alt/phpXX, and CustomBuild doesn’t manage them, so a DirectAdmin server on CloudLinux has two PHP stacks to patch. CloudLinux shipped its alt-php fixes separately, keeping the old version numbers. On 21 October, CloudLinux 7, 8 and 9 servers could only get them from the TuxCare ELS alt-php repository, which is why admins in the DirectAdmin thread saw nothing from a plain yum update. Since early on 22 October they are in the CloudLinux stable repositories, so a regular dnf upgrade 'alt-php*' (yum on CloudLinux 7) installs them. Check those packages’ changelogs as described in the cPanel and CloudLinux guide.

LiteSpeed servers had their own problem. DirectAdmin later pushed a build to its legacy rhel7 and debian10 channels that fixed the patching order for lsphp, where patches had failed to apply. One LiteSpeed user also reported a link error building the 8.2.35 LiteSpeed SAPI with no direct reply, so on LiteSpeed run da update and rebuild before assuming the patch is in.

What I’d do differently

The rebuild itself was uneventful, so my changes are all about the hour around it:

  1. Take a VM snapshot as well as the directory copies, because the copies don’t cover a CustomBuild run that also updates Apache.
  2. Rebuild unused PHP slots in a separate window from the live one.
  3. Enable the CustomBuild notification cron on every DirectAdmin server.
  4. Keep the domain baseline script on the server, so the before and after check takes one command.

If you also run a WHMCS install next to DirectAdmin, it probably sits on a PHP 8.1 that CustomBuild doesn’t manage. The end-of-life PHP guide covers that case, and the PHP2Shell checker lists every PHP binary on a host so a forgotten one doesn’t slip through. For a fresh panel, the DirectAdmin install guide sets up the same CustomBuild layout used here.

Keep reading

UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Php PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Best Phone for GrapheneOS in 2026 (and Why to Skip the Pixel 11) Security Best Phone for GrapheneOS in 2026 (and Why to Skip the Pixel 11) Best PC for Blue Iris: Quick Sync, AI GPU and Drives by Camera Count Security Best PC for Blue Iris: Quick Sync, AI GPU and Drives by Camera Count Solve Error: Package: docker-ce-xxx (docker-ce-stable) Requires: container-selinux >= Containers Solve Error: Package: docker-ce-xxx (docker-ce-stable) Requires: container-selinux >=

Leave a Comment

Press ESC to close