#php

2 posts.

Exploring and Migrating to OpenResty

/
OpenResty using a Lua routing table to send requests to PHP-FPM sockets for different PHP versions

I have an old CyberPanel instance that still hosts live sites. Some have been running for more than ten years. They’re non-critical, and the server kept working, so I kept putting off maintaining it.

Then php2shell happened. After dealing with it at work, I needed to check my own server too. Most of the sites were on PHP versions that no longer receive security fixes.

I also wanted to try something. A friend mentioned using OpenResty with Lua to route requests to PHP-FPM sockets. I didn’t know the details of their setup, but I was curious if I could do something similar.

Getting the sites working first

I upgraded the sites while they were still on CyberPanel and LiteSpeed. That way, if something broke later, I would have fewer things to check.

Old WordPress installs needed core and plugin updates before they could run on PHP 8. A custom theme had an unquoted constant, a plugin used create_function(), and a small Laravel app needed a framework upgrade.

I also found web shells, a backdoor plugin, and forgotten copies of old sites inside live web roots. The sites were responding fine, but there was a lot I hadn’t been seeing.

One config for the sites

OpenResty is nginx with Lua support. The idea is to let Lua look up the hostname and tell nginx which directory and PHP-FPM socket to use.

Each site has an entry in sites.json:

{
  "example.com": {
    "root": "/home/example.com/public_html",
    "user": "example",
    "kind": "php",
    "cert": "example.com",
    "https": true,
    "php": "8.4"
  }
}

Lua keeps the routes in memory. It selects the certificate during the TLS handshake, then uses the request’s hostname to choose the site. nginx serves static files directly and passes PHP requests to the right FPM socket.

Request lifecycle from the browser through OpenResty and back

Each Linux account gets its own PHP-FPM pool. Sites under the same account still share permissions, so I moved the contracts app into its own user and pool. The pools use ondemand so workers stop when idle. Useful on a small VPS.

The config also supports choosing a PHP version per site, though I’ve only installed and tested 8.4 so far. Databases mostly stayed as they were: local MariaDB, and SQLite for the contracts app.

CyberPanel and LiteSpeed are gone now. Lua looks up the hostname, nginx serves the files, and PHP-FPM runs the application. I can follow a request from its hostname to a directory and a PHP socket. Pretty happy to have this working.

Live configuration updates

I wanted to add sites or change their PHP sockets without reloading OpenResty.

A small root-owned publisher checks sites.json and the certificate files, validates the changes, and writes a complete snapshot. The Lua workers pick it up and replace their in-memory routes and certificates together. The snapshot is private because it contains TLS keys; the PHP accounts can’t read it.

This implementation uses one-second polling in the publisher and workers, so updates normally take about two seconds. Requests use the in-memory tables. If an update is invalid, the last valid configuration keeps working.

I tested adding and removing a hostname, switching PHP sockets, and replacing a certificate. The OpenResty master and worker process IDs stayed the same throughout.

Next steps

  • Try Linux inotify events instead of polling, so configuration changes can notify the workers directly.
  • Use OpenResty as a reverse proxy across my homelab sites, with a primary and secondary site. Start with manual failover.
  • Move files and uploads to NFS shares with replication between sites.
  • Add MySQL primary/replica replication.
  • Keep PHP-FPM workers warm while retaining separate pools per account. More RAM, but less startup delay.

For next time!

Dealing with php2shell

/
Requests passing through a WAF to PHP, with exploit attempts blocked

When John Blackbourn alerted the team about the leaked PHP vulnerability, I knew where to make the recommended configuration change. But I also knew that rolling it out across our infrastructure would take time.

That was on October 21, 2026. The vulnerability could allow remote code execution without authentication. It was later disclosed as CVE-2026-109618 and CVE-2026-109619, and became known as php2shell. The disclosure had been moved forward by a day, and a proof of concept was already circulating.

We didn’t have the complete picture yet.

Working with incomplete information

The leak pointed to PHP’s session upload-progress feature. One of the recommendations was to disable session.upload_progress.enabled.

The official PHP security releases weren’t scheduled until 14:00 UTC, more than ten hours away. Upgrading wasn’t an option yet. We needed to reduce our exposure until then.

Our infrastructure uses shared base images and a controlled release process, so even a small config change goes through several steps before it reaches production. I estimated around two hours (took longer).

With a proof of concept already out there, I started thinking about what else we could do in the meantime.

A faster mitigation at the WAF level

I suggested a platform-wide WAF rule to block requests matching the known exploit pattern. A WAF rule can be deployed without touching the application runtime, so we wouldn’t have to wait for every environment to get the PHP change.

This was not a small change. A WAF rule at the platform level sits in front of every site we host. If I got it wrong, it wouldn’t break one site. It could break all of them at once.

So instead of blocking a whole class of requests, which could break normal WordPress functionality, we targeted the specific pattern we understood.

I tested it in staging. It blocked the exploit requests while file uploads and editor saves kept working. But staging is not production. I tested as much as I could, and there was still no way to be sure it wouldn’t block legitimate traffic.

I also knew it could be bypassed. It was never meant to be the fix, just something to reduce exposure while we waited.

Waiting felt riskier than shipping, so I made the call and rolled it out to production.

Watching the traffic

I checked the WAF logs every few minutes for blocked requests and possible false positives. My own test requests were blocked first. Later, real traffic matching the pattern started showing up, and the WAF was blocking it.

That was encouraging, but a blocked request doesn’t mean an exploit was prevented. It only meant requests matching our rule were stopped. At that point, I could say our mitigation was working against the pattern we knew about. I couldn’t say the platform was fully protected.

Meanwhile, the PHP change

The image builds ran into a problem I didn’t expect. An external service we use to install PHP extensions was having availability issues, and builds failed intermittently. A simple config change, delayed by something outside our control.

After retries, the builds succeeded and we verified the setting:

session.upload_progress.enabled => Off

Then it rolled out through our normal deployment process. Unlike the WAF rule, this disabled the affected feature itself.

It also made me think about our release process. Pinning dependencies and runtime versions makes deployments reproducible, which is usually a good thing. But during an incident, every extra step is time between a fix existing and that fix reaching production.

What the advisory revealed

At around 14:00 UTC, the PHP team published the full disclosure and fixes. There were two critical vulnerabilities, both rated 9.2 under CVSS v4.0:

  • CVE-2026-109619: RCE involving stale global state. Disabling session.upload_progress.enabled was one of the recommended temporary mitigations.
  • CVE-2026-109618: RCE involving out-of-memory corruption. We didn’t know about this one from the leak, and nothing we had done addressed it.

The advisory was clear:

There are no complete mitigations, updating is the only safe choice.

The fixes shipped in PHP 8.5.12, 8.4.27, 8.3.36, and 8.2.35, and the team upgraded PHP across all customer environments.

We acted on what we knew, but it turned out we only knew half of it. I’m glad we had a way to add a layer of protection fast, and that we never treated it as the fix.