Open the access log of any PHP application on the public internet and sort by 404. Near the top, every time, you will find this:
GET /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.phpThat is CVE-2017-9841. A test-runner file that was never supposed to ship to production, which executes whatever you POST to it. The CVE is nine years old. In April 2025, GreyNoise counted more attacker IPs scanning for it than for any other PHP flaw on CISA's exploited list, narrowly ahead of a critical 2024 remote-code-execution bug.
That is the shape of PHP security in practice. Not clever exploit chains. A directory nobody meant to deploy, and a thousand hosts scanning for it continuously.
Where the scan traffic goes first
Two CVEs are worth knowing by number, because they are being used right now.
CVE-2024-4577 is argument injection in the PHP-CGI SAPI on Windows. The mechanism is a character-encoding conversion ("Best-Fit") that rewrites certain bytes into an ASCII hyphen after PHP has already decided the request is safe. So a query string containing a soft hyphen arrives at the binary as a command-line flag:
POST /index.php?%ADd+allow_url_include%3D1+%ADd+auto_prepend_file%3Dphp://inputThose are -d flags. You have just set two ini directives for that request, and auto_prepend_file=php://input means PHP will execute the request body as code before your application runs. Unauthenticated RCE from a query string.
It landed in CISA's Known Exploited Vulnerabilities catalog with ransomware use marked "Known". GreyNoise saw 1,089 unique IPs attempting it in January 2025 alone, and Cisco Talos tied it to a campaign against Japanese organisations across education, ecommerce and telecoms. Fixed in 8.1.29, 8.2.20, 8.3.8. If you are on Windows with PHP-CGI, that is your Monday.
CVE-2024-2961 is subtler and worth understanding even though it is not a PHP bug at all. It is a buffer overflow in glibc's iconv with the ISO-2022-CN-EXT charset. PHP reaches it through stream filters, which means an attacker who only has an arbitrary file read in your app can chain php://filter/convert.iconv.* conversions and escalate to code execution. It is the reason "it's only a file read" stopped being a reassuring sentence.
Injection, still, after twenty years
I wish this section were shorter. It is not, because concatenation is still how people build queries when they are in a hurry.
// Every variant of this that exists in your codebase is a vulnerability.
$sql = "SELECT * FROM users WHERE email = '" . $_GET['email'] . "'";Prepared statements are the fix and have been since PDO shipped in 2005:
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$_GET['email']]);The part people get wrong is what a placeholder can hold. It binds a value. It cannot bind a table name, a column name, or a sort direction, which is exactly where injection reappears in otherwise careful code:
// Looks parameterised. Isn't.
$sql = "SELECT * FROM leads ORDER BY {$_GET['sort']} {$_GET['dir']}";Identifiers get an allow-list, never an escape function:
$sortable = ['created_at', 'name', 'status'];
$sort = in_array($_GET['sort'] ?? '', $sortable, true) ? $_GET['sort'] : 'created_at';
$dir = ($_GET['dir'] ?? '') === 'asc' ? 'ASC' : 'DESC';Same trap in Laravel. whereRaw, selectRaw, orderByRaw and DB::statement are plain string interpolation wearing a framework's clothes. They take bindings; use them.
// Vulnerable
Lead::whereRaw("status = '{$request->status}'")->get();
// Fine
Lead::whereRaw('status = ?', [$request->status])->get();Then there is the one nobody calls injection. mb_ functions and PDO both behave differently depending on connection charset, and PDO::ATTR_EMULATE_PREPARES set to true means your "prepared" statement is being assembled by PDO in PHP and sent as one string. Turn emulation off. The behaviour you think you have should be the behaviour you have.
== is a footgun with a long history
PHP 8 fixed the worst of this, and plenty of running code predates PHP 8.
// PHP 7: true. The hash is read as scientific notation, 0 × 10^830 both sides.
md5('240610708') == md5('QNKCDZO'); // '0e462097431906509019562988736854' == '0e8396...'Any comparison that decides access control uses ===. Any comparison of a secret uses hash_equals(), which is also constant-time, so you are not leaking the answer through response timing either.
if (! hash_equals($expectedToken, $providedToken)) {
abort(403);
}And in_array($needle, $haystack) without the third argument is loose comparison applied N times. The true goes in. Always.
unserialize() on anything a user can touch
This is the one that turns a cookie into a shell.
// A gift to whoever finds it.
$prefs = unserialize($_COOKIE['prefs']);The attack does not need your code to do anything with the result. PHP instantiates the objects described in the string, and when those objects are constructed or destroyed it calls their magic methods: __wakeup, __destruct, __toString. The attacker picks classes that are already loaded in your application — yours, your framework's, your dependencies' — and arranges their properties so that a destructor which was written to clean up a temp file instead deletes something else, or so that a __toString reaches a method that writes a file.
That is a POP chain, and tools generate them automatically for common frameworks. Validating the array you got back is too late by several steps.
json_decode for untrusted data. If a serialised payload has to cross a trust boundary, sign it:
$payload = base64_encode($serialised) . '.' . hash_hmac('sha256', $serialised, $key);
// and verify with hash_equals before unserialize() sees a byteLaravel does exactly this for session and cookie data, which is why APP_KEY leaking is a critical incident and not a configuration annoyance.
Uploads that run
Give me a file write inside the document root and I have code execution. The chain is almost always the same, and each link is a thing that felt reasonable at the time.
// Four separate mistakes in three lines.
$name = $_FILES['avatar']['name'];
if ($_FILES['avatar']['type'] === 'image/png') {
move_uploaded_file($_FILES['avatar']['tmp_name'], __DIR__ . '/uploads/' . $name);
}The client sets type. The client sets name, including ../ and including shell.php. And the destination is a directory the web server will happily execute.
What I do instead, in order of how much each step buys you: store uploads outside the document root and serve them through a PHP route that sets its own Content-Type; generate the filename yourself and never reuse the client's; verify the type by reading the file (finfo, plus getimagesize for images) rather than trusting a header; and keep an extension allow-list, not a deny-list, because .phtml, .php5 and .phar all execute under default configurations somebody will enable.
Also worth knowing: an uploaded image can contain PHP in its EXIF metadata and still be a valid image. That only matters if the file can be executed, which is the whole reason the first step on that list is first.
The one I got wrong
An internal tool, admin-only, behind a VPN. It took a template parameter and did this:
include __DIR__ . '/templates/' . $_GET['template'] . '.php';I reviewed that line and approved it, because the .php suffix is appended, so what could you possibly include? A ../../ path still has to land on a real .php file on disk, and I could not think of one that mattered.
The thing I had not thought about is that include in PHP accepts stream wrappers, and that a traversal does not need to find an interesting .php file if the application also lets you upload files. It did, to a path I knew about and had considered safe for exactly the reasons in the section above. Two features, each defensible on its own, and the VPN was the only thing between them and a shell.
Nobody exploited it. We found it during an unrelated audit about five months after I approved it. The fix was eight lines and an allow-list of template names, which is what the original should have been, and the lesson I took is narrower than "review harder": a path that is built from user input is not made safe by what you append to it.
Configuration does more than code review
A short list that has caught more real issues for me than any static analysis run.
display_errors=Off in production, with log_errors=On. A stack trace in a response body is a free map of your filesystem and dependency versions.
expose_php=Off, so the X-Powered-By header stops announcing your exact patch level to the scanners in the first section.
allow_url_include=Off and allow_url_fopen=Off unless something genuinely needs them, which turns remote file inclusion from a vulnerability into an error message.
Drop /vendor from the document root entirely. The eval-stdin.php scan traffic exists because for years the standard Composer deployment left a test runner reachable over HTTP. Your public directory should contain index.php, assets, and nothing that ends in .php that you did not write deliberately.
And phar deserialisation: phar:// streams trigger unserialize on the archive's metadata, which means a file operation on an attacker-controlled path, something as mundane as file_exists(), can run a POP chain. Set phar.readonly=1 and treat any file-path parameter as the dangerous input it is.
If I had one day on a legacy PHP app
Patch first, because the two CVEs at the top of this post are being scanned by thousands of hosts and need no skill to use. Then grep for the four function names that concentrate most of the risk: unserialize, eval, extract, and any *_raw query builder in your framework. Then move uploads out of the web root.
That is a day of work and it closes the paths that real incidents actually take. Everything else on the OWASP list matters, and none of it matters as much as being current on a CVE with ransomware attached.
What I still do not have a good answer for is dependency risk in long-lived PHP projects. composer audit tells you about known advisories in direct and transitive dependencies, which is genuinely useful and completely silent about the abandoned package that has had no advisory because nobody is looking at it. I check last-commit dates by hand and I do not think that scales. If you have a better method I would like to hear it.
Sources: CISA KEV entry for CVE-2024-4577 · Greenbone on escalating CVE-2024-4577 attacks · SecurityWeek on the mass exploitation · Expel's April 2025 scan-volume figures