Scrolling through two security stories this morning — the second one made me stop what I was doing: PHP core has two remote code execution vulnerabilities, CVSS 9.2, no login required, a single request could punch right through. The official patched versions only cover 8.2 to 8.5, plus minimal source backports for 7.4, 8.0, and 8.1, because the blast radius is just too big.
And my server runs PHP 5.6.
Not “haven’t upgraded yet” — that generation is long EOL, not even a backport. The official mitigation advice is two lines: upgrade, or take it offline. For 5.6, those two lines are the same line.
Well, well, well. Now we’re in trouble
I know full well I’ve been running old PHP5 the whole time. I just wanted to confirm one thing: how badly am I actually affected.
Then I opened the site directory and realized I couldn’t even tell you “what I have.” Three domains sit on the same low-spec ECS — one main site, one tech blog, one family blog. Over the years I’ve piled forums, asset libraries, an ad-stats backend, a WeChat interface, product intro pages, and entire Flash-era sites on top. Anything that would run, ran. Anything that wouldn’t, nobody deleted.
So I started with an audit. I didn’t install any agent on the server — that box only has 2G of RAM, and one more long-running process is just asking for trouble. Instead I SSH’d in from my local machine and read-only confirmed three things: which PHP versions were installed, which sites had PHP parsing, and which .php files were still actually being hit.
Three commands later, the conclusion was worse than expected:
- Only one PHP version, 5.6.40, php-fpm running
- All three sites had PHP parsing enabled
- The site directory owner was the web user, permissions 755 — meaning the PHP process has write access to the entire site directory
display_errorswas on
That last one means any error anywhere dumps the server’s absolute paths to visitors. I casually hit an old endpoint and sure enough, the error carried the full path.
More destructive than the CVE
I originally thought I’d be dealing with that 9.2 memory-corruption bug. Then I checked the logs and found the real risk doesn’t even need a crafted exploit chain.
Number one: a homemade log viewer page where the parameter goes straight into file_get_contents() with no filtering. Whatever you put after ?logName= is what it reads; the same page also has a cls=true that will wipe any writable file. It’s on the public internet, no login required.
Number two: an asset proxy script, equally naked — file_get_contents($_REQUEST['url']) — usable as an arbitrary URL reader, and also for probing the internal network.
Number three is even more ridiculous. A dynamic DNS script written twelve years ago: anyone who visits it writes the visitor’s IP into three site files, then redirects to a provider’s update URL that contains a plaintext account and password. I originally went to confirm whether it was still alive — and it executed right in front of me, rewriting the production file with my exit IP. That was damage I caused myself, but it also proved how trivially it can be abused.
Number four: an old admin login page still exposed on the public internet, containing ad stats, database export, config editing.
Plus a pile of dead-but-still-reachable stuff: a game reporting endpoint, an asset library admin, a lottery demo, an NFO generator, an access-control door-opening endpoint.
Add it all up, and it’s far more real than that CVE requiring a crafted memory-corruption exploit. The CVE is someone else’s 0day; these are the backdoors I wrote myself.
LOL. 😂
Order of operations: kill the most dangerous first, worry about elegance later
My principle is simple: delete what can be deleted, cut off what can’t, and only then talk about migration for the rest.
First thing was pulling that whole WordPress site built in 2009 off the server. It was running WP 2.9 with fourteen same-era plugins, its login page facing the public internet, its backend scanned by overseas IPs every day. A site like that has no “patch” option, only “take it down.” I kept a full local backup and a database snapshot — won’t need them right away, but can’t lose them.
Second thing was static-izing the old sites. Two Flash-era sites whose pages were already static content — they were only written in PHP back then to split templates and detect mobile. Expand the templates into HTML, replace server-side UA detection with a bit of inline JS, rewrite the one thing that still needed to be dynamic — a quotes endpoint — to call a new Node service I wrote, and delete all the remaining PHP.
There was one spot I couldn’t just delete: the old site proxied an external RSS feed through a small script, to aggregate my blog and Douban on a “recent activity” page. That script was itself an arbitrary URL reader, but deleting it outright would blank the page. So I switched it to an nginx reverse proxy — not a single character of the front-end URL changed, the feature still works, and PHP can go home.
Third thing was the Flash guestbook. That site’s guestbook data was queried from the database and assembled into XML for Flash to read, and the endpoint address was hardcoded in the front-end assets — couldn’t change it. The solution: export all current messages as a static XML file, then use an nginx exact match to map the original .php address to that XML. The guestbook became read-only, but the content is still there — and nobody’s been leaving messages for a while anyway.
Fourth thing was the product intro pages. Three games, same structure: one PHP reads the browser’s language header, then requires the matching language’s HTML. Merge into one file, one container per language, use JS to detect the language and show the corresponding one. That way the URL stays the same, no redirect flicker, and with JS disabled it at least shows Chinese instead of a blank page.
Nginx location priority — a field note on the pitfalls
After static-izing, the old addresses needed to 301 to the new ones. I wrote a few regex locations in the pseudo-static config, and on testing everything was 404.
I checked the site config and figured it out: in the config the panel generates, the PHP include comes first and the pseudo-static include comes second. Nginx regex locations are matched in order of appearance, first come first served — not by specificity. So the request got picked up first by that location ~ \.php$, handed to php-fpm, the file was long gone, 404. My 301 rules never got a chance to run.
What’s funny is that in the same file, location = /xxx.php exact matches did work — because = has the highest priority, regardless of order.
The fix isn’t reordering the includes (that would mean touching the main config), but switching to a different location type: location ^~ /directory/. Once ^~ matches, it skips all regex checks — effectively short-circuiting the PHP one — and control comes back to me, then inside that location I write rewrite to do the 301.
There’s a small detail here too: the $1 in return 301 /a/$1.html is a capture group from the location regex, but ^~ is a prefix match with no capture group, so you have to use rewrite‘s own regex.
Wrapping up
The last round was cleaning up the leftovers. find . -type f -name "*.php" listed more than I expected: a few pandemic-data mini-tools from around 2020, a lottery demo, a content management config page, a debug echo script. Two were still useful — an old-site source download page and a privacy policy entry point — and I converted both to pure HTML; the rest got deleted.
Ran find again after deleting, output under the site root was empty.
Swapped out analytics while I was at it. Every page used to carry third-party analytics; now it’s uniformly one line of a self-hosted script, and the scattered duplicate analytics snippets in the old pages got cleaned up too. One page, because of the multi-language merge, originally carried six separate analytics snippets all executing in a single HTML file.
The entropy beyond the vulnerability
What makes me uncomfortable about this isn’t the vulnerability: it’s that after years of hoarding historical leftovers, I didn’t even know what I had.
The stuff on that server piled up layer by layer over a dozen-plus years: Flash sites, mobile adaptation, a mini-game backend, ad stats, a WeChat interface, asset libraries. Every layer had a reason at the time, and every layer was “deal with it later.” When the day finally comes that you have to answer “what’s running on your box,” you find the answer scattered across three domains, a dozen directories, and a pile of endpoints nobody remembers.
The reason I could clean it up in a single morning isn’t that I have a good memory — it’s that all that stuff happened to be static-izable. If even one of them had been a dynamic service still generating revenue, still writing to the database, with real users, today would have played out very differently.
The other takeaway is the auditing method. I thought “smart ops” meant installing an agent on the server, but in practice, on a 2G box, anything you install feels heavy. What actually worked better — faster and more controlled — was a local agent SSH-ing in, running a few read-only commands, and bringing the results back for analysis. Not a single write operation the whole time; the only “damage” was when I was probing a script and it executed itself. The lesson from that: before verifying a vulnerability, read the code and confirm whether it has side effects.
As for PHP 5, after this it’s officially retired here. There’s one last WordPress site to static-ize, and once that’s done I can stop php-fpm.
An unpatched vulnerability turned into a thorough house-cleaning. Not a bad outcome.