
Table of Contents
On 2026-10-21 the PHP project released fixes for two critical vulnerabilities nicknamed php2shell (CVE-2026-109618 and CVE-2026-109619, both CVSS 9.2). Every PHP version since PHP 5 is affected. The fixed releases are 8.5.12, 8.4.27, 8.3.36 and 8.2.35.
My jail host runs FreeBSD 15.1 on arm64, and three of its Bastille jails run PHP: a looking glass for my network, a DokuWiki and a Nextcloud. I stopped all three the same day and disabled their autostart. The plan was to wait for the fixed packages.
The FreeBSD side was quick. lang/php85 was updated to 8.5.12 in the ports tree within a day, in main as well as in the 2026Q4 quarterly branch. But a fix in the ports tree is not a package yet. The package builders had only published fixed packages for some CPU architectures so far, and a day later pkg on my arm64 host still offered 8.5.11.
So I built the packages myself. Without Poudriere.
The short version: a throwaway thin jail with the same release as the target jails, a fresh 2026Q4 ports checkout inside it, make package for PHP and every core extension the jails use, pkg repo over the results, and a local repository with a higher priority inside each target jail. Two of the three jails were back online the same evening.
Why Not Just Use Poudriere?
Poudriere is the right tool for building FreeBSD packages. If I built packages regularly, I would use it, and I would not be writing this article.
For a one-off job it is a lot of machinery: its own ZFS datasets, a build jail, a ports tree it manages itself, configuration, and by default it builds the entire dependency tree from source. I needed 32 small packages, once, and I wanted to get rid of everything afterwards.
The other options I considered:
- Keep waiting. That was my first decision. But three services were offline, and I did not know when the arm64 builders would catch up.
- Build on the jail host itself. The ports tree on my host turned out to be a tarball from June 2025, not a git checkout, and it did not even know
lang/php85. Building there would also have pulled a pile of build dependencies onto a host that is otherwise managed with pkgbase and has very few packages installed. I like it that way. - Build inside each target jail with the ports tree. That puts compilers and build dependencies into internet-facing web jails and builds the same thing three times.
A throwaway jail keeps everything that is needed for the build in one place I can delete with a single command.
The Build Jail
The target jails are Bastille thin jails on 15.1-RELEASE, so the build jail is one too. Same release, same base system, same ABI:
# host
bastille create -B phpbuild 15.1-RELEASE 192.0.2.99/24 bastille0
bastille config phpbuild set boot off
-B attaches the jail to the existing bastille0 bridge like all my other jails. The jail network is NATed as a whole, so a free address is all the jail needs to reach the internet. The default route comes from bastille_network_gateway in my bastille.conf. Without that setting, add -g with your gateway to the create command. boot off makes sure it does not come back after a reboot by accident.
One small trap: Bastille copies the host’s /etc/resolv.conf into the jail. My host only has IPv6 resolvers, and the build jail only has IPv4. So it gets its own resolver:
# host
echo "nameserver 9.9.9.9" > /usr/local/bastille/jails/phpbuild/root/etc/resolv.conf
A Ports Tree on the Right Branch
This is the step that matters most. Check which branch your jails get their packages from:
# in one of the target jails
pkg -vv | grep -A1 'FreeBSD-ports:'
FreeBSD-ports: {
url : "pkg+https://pkg.FreeBSD.org/FreeBSD:15:aarch64/quarterly",
My jails use quarterly, so the ports tree has to be the matching quarterly branch, 2026Q4. The ports tree decides which dependencies a port needs and in which versions, while the binary dependencies come from the quarterly repository. A main checkout can require newer dependency versions than the quarterly repository has, and packages built from it can end up depending on versions the target jails cannot get from their repository. Matching the branches avoids that mismatch.
From here on, the commands run inside the build jail until I say otherwise:
# host
bastille console phpbuild
# build jail
env ASSUME_ALWAYS_YES=yes pkg bootstrap -f
pkg install -y git-tiny
git clone --depth 1 -b 2026Q4 https://git.FreeBSD.org/ports.git /usr/ports
Check that the fix is really there:
# build jail
make -C /usr/ports/lang/php85 -V PKGNAME
php85-8.5.12_1
And compare the checksum in distinfo with the one php.net publishes. I want to know that the tarball the port will fetch is the one the PHP project released:
# build jail
cat /usr/ports/lang/php85/distinfo
fetch -qo - 'https://www.php.net/releases/index.php?json&version=8.5.12'
Both said 08bc2eba2a588654ade1357174aa646b761556c862dda0678345ee29ed481209 for php-8.5.12.tar.xz.
Which Ports, Which Options?
PHP on FreeBSD is split into many packages. lang/php85 is the interpreter, and every core extension (php85-mbstring, php85-gd, php85-pdo_pgsql, …) is its own port built from the same PHP tarball. All of them should go to 8.5.12, not just the interpreter.
PECL extensions such as APCu or imagick have their own version numbers and do not need to be rebuilt. The same goes for the php85-extensions meta port and applications like dokuwiki-php85.
To get the list, I went back to the host and asked the stopped jails directly. pkg -r works on a jail’s root directory without starting it:
# host
for j in lg nextcloud wiki; do
pkg -r /usr/local/bastille/jails/$j/root query -g '%o %v' 'php85*'
done | awk '$2 ~ /^8\.5\.11/ {print $1}' | sort -u \
> /usr/local/bastille/jails/phpbuild/root/tmp/ports.txt
Writing it straight into the build jail’s /tmp saves a copy step later. That gave me lang/php85 plus 31 core extensions. The list is sorted alphabetically, which is not the order they can be built in, so I used it for the checks below and ordered the build by hand.
Next, the options. My jails run the official packages, which are built with the port defaults, so the defaults should match. I checked anyway by comparing the options recorded in the installed packages with what the port would build:
# host: options that are ON in the installed packages
for j in lg nextcloud wiki; do
pkg -r /usr/local/bastille/jails/$j/root query -g '%n %Ok %Ov' 'php85*'
done | awk '$3 == "on" {print $1, $2}' | sort -u \
> /usr/local/bastille/jails/phpbuild/root/tmp/opts-installed.txt
# build jail: options the port would select
while read origin; do
name=$(make -C /usr/ports/$origin -V PKGBASE)
for o in $(make -C /usr/ports/$origin -V SELECTED_OPTIONS); do
echo "$name $o"
done
done < /tmp/ports.txt | sort -u > /tmp/opts-port.txt
diff /tmp/opts-installed.txt /tmp/opts-port.txt
The only difference was the php85-extensions meta port, which I was not rebuilding anyway. So: no make config, just BATCH=yes and the defaults.
Building the Packages
First create the packages directory. Do not skip this. More on that in a moment:
# build jail
mkdir -p /usr/ports/packages
Then a small script, saved as /root/build-php.sh in the build jail. It builds the interpreter first, then the extensions that others need at build time (pdo for the pdo_* drivers, dom and xml), then the rest:
#!/bin/sh
set -e
export BATCH=yes
P=/usr/ports
for o in lang/php85 databases/php85-pdo textproc/php85-dom textproc/php85-xml \
archivers/php85-phar archivers/php85-zip archivers/php85-zlib \
converters/php85-iconv converters/php85-mbstring \
databases/php85-pdo_mysql databases/php85-pdo_pgsql databases/php85-pdo_sqlite databases/php85-sqlite3 \
devel/php85-intl devel/php85-pcntl devel/php85-sysvsem devel/php85-tokenizer \
ftp/php85-curl graphics/php85-exif graphics/php85-gd math/php85-bcmath math/php85-gmp \
net/php85-ldap security/php85-filter security/php85-sodium \
sysutils/php85-fileinfo sysutils/php85-posix textproc/php85-ctype textproc/php85-simplexml \
textproc/php85-xmlreader textproc/php85-xmlwriter www/php85-session; do
echo "=== $o"
missing=$(make -C $P/$o missing)
deps=$(printf '%s\n' "$missing" | grep -v php85 || true)
if [ -n "$deps" ]; then
pkg install -y $deps
fi
make -C $P/$o clean package install clean
done
# build jail
set -o pipefail
sh /root/build-php.sh 2>&1 | tee /root/build.log
echo "exit status: $?"
Without pipefail, the pipeline reports the exit status of tee, which is almost always 0, even if the script stopped halfway. The script and this invocation are slightly hardened compared to what I actually ran: my original version piped make missing straight into grep, which would have hidden a failing make missing, and I ran it without pipefail. Neither caused a problem this time, but both could have hidden one.
Three details are doing the heavy lifting here:
make missingpluspkg install.make missinglists every dependency that is not installed yet. Instead of building those from source, the script installs them as binary packages from the quarterly repository. The only things compiled are PHP and its extensions. This is the shortcut that makes Poudriere unnecessary for this job.grep -v php85. Without it,pkg installwould happily pullphp85-pdo-8.5.11from the official repository as a dependency, together with the vulnerable interpreter. With the filter, a missing PHP dependency is not installed from the official repository. The build order makes sure there is none: by the time an extension is built, the PHP components it needs have already been built and installed by an earlier iteration of the loop. If the order were wrong, the ports framework would build the missing PHP dependency on its own, from the same2026Q4tree, as part of the dependent port’s build.installafterpackage. The extensions are built withphpizeagainst the installed PHP headers, so every freshly built package also gets installed in the build jail.
Because the build jail is brand new, there is no old PHP to uninstall first. The kernel-error.de article listed in the references describes what happens when you do deinstall install on a live system: the port’s packing list step calls php from the PATH, which you have just removed. A clean jail avoids that entirely.
On my arm64 host with 6 cores and 8 GB of RAM, the whole run took just under half an hour. The interpreter itself was done after five minutes, and the extensions took the rest.
At the end:
# build jail
ls /usr/ports/packages/All/php85-* | wc -l
32
The Mistake: Where Did My Packages Go?
On my first run, that ls said No such file or directory. The build log looked perfect: every port ended with Building packages for php85-... and Installing for php85-....
The reason is how the ports framework handles a missing packages directory, and I had simply not thought of it: make package only writes to /usr/ports/packages/All/ if /usr/ports/packages already exists. Otherwise, the package ends up in the port’s work directory. The final clean in my loop then deleted the work directory, together with the package.
The good news: every package was still installed in the build jail. pkg create can rebuild package files from installed packages:
# build jail
mkdir -p /usr/ports/packages/All
pkg create -o /usr/ports/packages/All -g 'php85*'
Thirty-two packages, no second build. Still, the mkdir at the top of the build section is the better way.
A Local Repository
Copying .pkg files into a jail and running pkg add works, but it ignores repository priorities and makes it easy to end up with a half-upgraded set. A repository is one command. Back on the host:
# host
mkdir -p /root/php85-8.5.12
cp /usr/local/bastille/jails/phpbuild/root/usr/ports/packages/All/php85-*.pkg /root/php85-8.5.12/
pkg repo /root/php85-8.5.12/
pkg repo adds meta.conf, data.pkg, packagesite.pkg and meta next to the packages. That is the catalogue pkg reads.
Upgrading the Target Jails
For every target jail, the same procedure. I started with the looking glass, the smallest one with only four PHP packages, as a dry run for the others. Everything in this section runs on the host.
# host
j=lg; J=/usr/local/bastille/jails/$j/root
Copy the repository into the jail and register it with a higher priority than the official one:
# host
mkdir -p $J/var/tmp/php85-local $J/usr/local/etc/pkg/repos
cp /root/php85-8.5.12/* $J/var/tmp/php85-local/
cat > $J/usr/local/etc/pkg/repos/php85-local.conf <<'EOF'
php85-local: {
url: "file:///var/tmp/php85-local",
signature_type: "none",
priority: 10,
enabled: yes
}
EOF
signature_type: "none" is acceptable here because the repository is a directory inside the jail, filled by root on the host. Do not copy this line for anything that travels over a network.
The jail has to be running for pkg to do its job properly, but it should not start the vulnerable PHP and expose it while I am upgrading it. So I disable the web services before starting the jail:
# host
sysrc -f $J/etc/rc.conf nginx_enable=NO php_fpm_enable=NO
bastille start $j
Then the upgrade, limited to PHP:
# host
bastille cmd $j pkg update
bastille cmd $j pkg upgrade -g 'php85*'
Installed packages to be UPGRADED:
php85: 8.5.11 -> 8.5.12_1 [php85-local]
php85-filter: 8.5.11 -> 8.5.12_1 [php85-local]
php85-mbstring: 8.5.11 -> 8.5.12_1 [php85-local]
php85-session: 8.5.11 -> 8.5.12_1 [php85-local]
Read that list before you type y. Every PHP package should come from php85-local.
At the end, pkg prints a note that you may need to remove /usr/local/etc/php-fpm.d/www.conf manually. pkg keeps a configuration file that differs from its sample, here www.conf.default, instead of deleting it. Mine differs on purpose, so I kept it. It is still worth comparing it with the new sample to see whether the update changed anything relevant:
# host
bastille cmd $j diff -u /usr/local/etc/php-fpm.d/www.conf.default /usr/local/etc/php-fpm.d/www.conf
Check the version, turn the services back on, restart the jail and re-enable autostart:
# host
bastille cmd $j php -v
sysrc -f $J/etc/rc.conf nginx_enable=YES php_fpm_enable=YES
bastille restart $j
bastille config $j set boot on
bastille cmd $j sh -c 'php-fpm -t; nginx -t; service php_fpm status; service nginx status'
PHP 8.5.12 (cli) (built: Oct 22 2026 20:24:02) (NTS)
Then open the application in a browser and actually use it. php -m and php-fpm -t being happy does not prove much.
Surprise Guests in the Second Jail
The DokuWiki jail had 13 PHP packages to upgrade, and pkg wanted to touch 16:
Installed packages to be UPGRADED:
fontconfig: 2.17.1,1 -> 2.18.3,1 [FreeBSD-ports]
php85: 8.5.11 -> 8.5.12_1 [php85-local]
...
png: 1.6.58 -> 1.6.59_1 [FreeBSD-ports]
Installed packages to be REINSTALLED:
cyrus-sasl-2.1.28_6 [FreeBSD-ports] (option changed: LMDB0 (added))
This is the quarterly branch doing its job, not a problem. The build jail had installed the current quarterly versions of libpng and fontconfig as build dependencies, so the new php85-gd depends on them. The wiki jail had not been updated in a while and still had the older ones, so pkg upgraded them from the official repository. cyrus-sasl (pulled in through the LDAP extension) is reinstalled because the official package has gained an option since. Everything that does not come from php85-local comes from the official repository, which is exactly what I want.
Switching Back to Official Packages
Not tested yet. At the time of writing, the official arm64 repository still offers 8.5.11, so this section is my plan, not something I have run.
The local repository is a stopgap. Once the official builders have caught up for arm64, I want the jails back on regular packages. First, check that the official repository has every PHP package the jail uses, not just the interpreter. pkg -j runs against a running jail from the host:
# host, with j and J set per jail as above
pkg -j $j rquery -r FreeBSD-ports '%n %v' $(pkg -j $j query -g '%n' 'php85*')
php85 8.5.11
php85-ctype 8.5.11
...
php85-zlib 8.5.11
Once every line says 8.5.12_1 or newer, remove the local repository and do a dry run of the forced reinstall:
# host
rm $J/usr/local/etc/pkg/repos/php85-local.conf
rm -r $J/var/tmp/php85-local
bastille cmd $j pkg upgrade -n -f -g 'php85*'
Every package in that list should be marked [FreeBSD-ports]. If it is, run the same command without -n:
# host
bastille cmd $j pkg upgrade -f -g 'php85*'
The -f matters. Same version, same options, so without it pkg would see nothing to do and keep my self-built packages.
And the build jail goes away with:
# host
bastille stop phpbuild
bastille destroy -f phpbuild
I am keeping mine for now, stopped. The third jail stays offline for a while for unrelated reasons, and the packages in /root/php85-8.5.12 will still be there when it is ready.
Pitfalls: The Checklist
- Match the branch. Ports tree branch = package repository branch.
quarterlymeans20xxQy,latestmeansmain. mkdir -p /usr/ports/packagesbeforemake package. Otherwisemake cleanthrows away your packages.- Install dependencies as binary packages, but keep PHP itself out of that list.
- Rebuild all core extensions, not just
lang/php85. Leave PECL extensions and applications alone. - Build in a fresh jail, not on a system where the old PHP is still installed.
- Check
/etc/make.conf. The kernel-error.de article ran into a broken CA bundle because theirmake.confbuilt PHP against OpenSSL from ports. Mine has nomake.confat all, so the port defaults match the official packages. - Do not start the vulnerable service while you upgrade it. Disable it in
rc.conf, start the jail, upgrade, enable, restart. - Read the
pkg upgradelist before confirming. Every PHP package must come from your local repository. - Test the application, not just the PHP binary.
Conclusion
Poudriere is the right answer when building packages is part of your routine. For a single security fix on an architecture the official builders have not reached yet, the ports framework, a throwaway jail and pkg repo are enough. Once the build jail is destroyed, the only thing left on the host is a directory with 32 packages.
What I like most about this approach is that nothing about it is clever. The jail is a normal Bastille jail, the packages are normal packages, and the repository is a normal pkg repository. When the official packages arrive, two rm commands and a forced upgrade return everything to its usual state.
References
- php2shell: PHP aus den FreeBSD-Ports gepatcht (kernel-error.de, German), the article that got me started, including the
deinstall installand CA bundle pitfalls - heise online on php2shell (German)
- lang/php85 history on the 2026Q4 branch
- PHP 8.5.12 release data
- pkg-repo(8), pkg-create(8), pkg.conf(5)
- FreeBSD Porter’s Handbook
- Bastille documentation
Comments
You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.
Search for the copied link on your Mastodon instance to reply.
Loading comments...