22/10/2026

PHP2SHELL

// elusive thoughts // appsec // php

PHP2SHELL

Every road from a PHP bug to a command prompt, and how to barricade each one.

TL;DR. The webshell one-liner is the part defenders obsess over and the part that matters least. The shell is the destination. The real work is the road: an upload that runs, an include() you control, an unserialize() on attacker bytes, or a config so broken the interpreter hands you RCE for free. Patch CVE-2024-4577, treat your upload directory as enemy territory, and stop calling unserialize() on anything a user has touched.

The one-liner is the boring part

Everybody can write the shell. <?php system($_GET['c']); ?> is thirty characters and it sits in every WAF signature on earth. If that string is the whole of your PHP threat model, you are guarding the last six inches of a very long tunnel. Attackers do not struggle to write the shell. They struggle to make PHP run it. That is the fight.

So this is about the roads, not the destination, and every road ends in defence. I have walked most of these on real Diixtra jobs and watched blue teams miss all of them, because they were grepping for system( and nothing else.

Road one: the upload that runs

The classic. An upload form that stores files under the web root and lets Apache decide what to do with them. If .php is mapped to the PHP handler, uploading evil.php means the server runs it the moment someone sends a GET. The file was never storage.

Recognise this in review:

// the whole bug in four lines
$name = $_FILES['avatar']['name'];
move_uploaded_file(
    $_FILES['avatar']['tmp_name'],
    "/var/www/html/uploads/" . $name
);

No extension allow-list, no MIME check, no magic-byte check, stored in the web root. The tester ladder, straight out of OWASP and The Hacker Recipes:

  • Drop <?php phpinfo(); ?>, request it, confirm the interpreter actually ran it. That is your proof. The live payload is a command sink.
  • Filter only checks for .php? Try .pHp, .php5, .phtml. On a default Apache mapping a multi-extension name resolves to PHP if any extension in the chain is .php, so shell.jpg.php runs.
  • MIME sniffing the bytes? Prepend GIF89a. The file passes as an image and still carries PHP underneath the magic header.
  • Can write a .htaccess? Upload one that maps a benign extension to the PHP handler, then upload your payload as that extension.
  • Can only write a .user.ini? Point auto_prepend_file at a file you control and PHP loads it on every request in that directory. SiteGuarding found exactly this in the wild: a 1.2 KB invoice.php under /wp-content/uploads/ wired in by a dropped .user.ini.

This is not a 2009 problem. Unrestricted upload to RCE was still landing critical CVEs through 2025, from CMS plugins to SAP NetWeaver Visual Composer.

Road two: include() is a loaded gun

include, require, and their _once siblings. Feed any of them a path you control and you are one hop from code execution. Old-school LFI needed the server to echo the file back, or a writable log to poison, or a session file to inject. Then two pieces of research turned LFI into a weapon with no upload at all.

// language switcher, circa every legacy app
include($_GET['page'] . ".php");

loknop showed in a 2021 CTF that you can abuse the php://filter wrapper to chain encoding filters and generate arbitrary PHP at the start of a stream. No upload, no log poisoning. You control the path, you get <?php system("id"); ?> prepended into the include. Synacktiv wrote it up and shipped php_filter_chain_generator, so it is point-and-click now.

Then hash_kitten, at DownUnderCTF 2022, turned the same wrapper into a blind file-read oracle. Chain enough encodings to blow past memory_limit, watch whether the request throws a fatal, and you have a one-bit oracle that leaks config.php one character at a time, even when the app returns nothing in the body. Synacktiv's php_filter_chains_oracle_exploit automates it. Ambionics stretched the reach further with wrapwrap.

Academic? No. CVE-2023-6553 (WordPress Backup Migration, unauthenticated RCE) and CVE-2024-29858 (arbitrary file read on MISP) both ride filter chains.

Road three: unserialize() and the phar:// trapdoor

PHP object injection is the road blue teams understand least, and the one I reach for most on grey-box work. Call unserialize() on bytes an attacker controls and you let them build arbitrary objects with arbitrary properties. On their own those objects do nothing. Chained through the app's own magic methods (__wakeup, __destruct, __toString, __call) they become a POP chain, property-oriented programming, and the far end of that chain is a dangerous sink you already shipped.

// trusting a cookie
$prefs = unserialize($_COOKIE['prefs']);

Now the trapdoor nobody remembers. You do not always need an obvious unserialize(). The phar:// stream wrapper deserialises the Phar metadata the instant almost any filesystem function touches the path: file_exists, fopen, getimagesize, is_dir. Sam Thomas demonstrated this at Black Hat USA 2018 as file-operation-induced deserialisation. An upload that accepts a crafted "image" plus a filesystem call on a path you influence is a deserialisation sink hiding in plain sight.

The fix is dull and absolute. Do not unserialize() user input. Use json_decode(). If you genuinely must deserialise, pass allowed_classes => false.

Road four: the config hands you RCE (CVE-2024-4577)

Sometimes you skip the application entirely and the platform gives you the shell. CVE-2024-4577, disclosed by DEVCORE in June 2024, CVSS 9.8. PHP in CGI mode on Windows mishandles Unicode "Best-Fit" conversion. A soft hyphen (0xAD) slips through argument escaping and PHP reads it as a real hyphen (0x2D), so you inject command-line arguments into the interpreter itself. Set auto_prepend_file to php://input and your request body becomes code. It walks straight past the fix for the ancient CVE-2012-1823.

The kicker: every default XAMPP for Windows shipped vulnerable out of the box. watchTowr caught the mood in their write-up title, No Way, PHP Strikes Again. Patched in 8.3.8, 8.2.20 and 8.1.29.

I am not pasting the weaponised request here. The full PoC is on NVD, DEVCORE and watchTowr if you are testing your own estate. What you do about it:

  • Patch PHP. Now.
  • Do not run PHP in CGI mode. Use PHP-FPM.
  • Kill XAMPP defaults before anything Windows faces the internet.
  • Block the soft-hyphen byte (0xAD) and php:// at the WAF as a stopgap, not a cure.

Road five: the command sink

The least clever and the most common. User input concatenated into a shell call.

// "diagnostics" page
system("ping -c 4 " . $_GET['host']);

Send host=8.8.8.8; id and you are done. The family to hunt: system, exec, shell_exec, passthru, popen, proc_open, and backticks. escapeshellarg() and escapeshellcmd() help but they are not magic. Argument injection survives a naive escapeshellcmd(), and plenty of CLI tools will happily run options you smuggle in as arguments. The real answer is to not shell out. If you must, call a fixed binary with an argument array and a strict allow-list on the value.

Same road, honourable mentions: eval(), assert() (callable with a string on old versions), create_function(), and preg_replace() with the /e modifier, which PHP 7 killed for exactly this reason. Template engines count too. Twig and Smarty SSTI drops you to RCE the moment user input reaches the template source.

Hunting the shell

You will not catch this grepping for system(. Shells obfuscate. Hunt the behaviour and the primitives instead.

Code-level IOCs (static)

  • Eval and execution primitives: eval, assert, create_function, call_user_func, and variable functions like $_GET['x']().
  • Command sinks: system, exec, shell_exec, passthru, popen, proc_open, backticks.
  • Obfuscation tells: base64_decode, gzinflate, gzuncompress, str_rot13 stacked together, high-entropy blobs, ${"GLOBALS"} variable-variable games.

Behavioural IOCs (where you actually win)

  • Static files answering POST. A 200 on POST /uploads/2025/09/logo.png is a webshell until proven otherwise.
  • New files appearing in writable upload dirs. A .user.ini or .htaccess turning up where one never existed.
  • Outbound connections from the web user. www-data has no business opening sockets to the internet.

Tooling that earns its place

  • YARA at scan time. The NSA publishes PHP webshell rules, emposha's PHP-shell-detector and the Panelica rule packs are solid, and the US-CERT pas_tool rule still catches that family. Run yara -r rules.yar /var/www/html on a cron.
  • Velociraptor's webshell detection artifact for fleet-wide hunts.
  • File integrity monitoring on the web root so a dropped file screams.
  • Newer work pairs YARA with CNN and LLM classifiers for the polymorphic shells signatures miss. If you own a big PHP estate, the 2024 to 2026 research is worth your time.

A grep for triage, not detection:

grep -rnE \
  "eval|assert|system|shell_exec|passthru|base64_decode|gzinflate" \
  /var/www

Shutting the roads

Defence in the only order that matters. Interpreter first, app last.

php.ini, cheap wins you are not using

allow_url_include = Off
allow_url_fopen = Off
expose_php = Off
display_errors = Off ; prod only
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
open_basedir = /var/www/html:/tmp

Uploads, treat the folder as hostile

  • Allow-list extension and MIME and magic bytes. All three.
  • Re-encode images on receipt. That destroys appended payloads.
  • Random filenames, stored outside the web root, served through a download handler.
  • Turn PHP execution off in the upload path at the web-server layer. Nginx: no PHP handler for that location. Apache: php_admin_flag engine off.
  • ClamAV on upload, plus a size cap.

Config and platform

  • Patch PHP and stay patched. CVE-2024-4577 is the current reminder.
  • PHP-FPM, never CGI. No XAMPP defaults in production.
  • Never unserialize() user input. JSON, or allowed_classes => false.
  • Run the web user with no write access to the web root. Read-only code, writable data elsewhere.
  • Egress filtering, so even a live shell cannot phone home.
  • WAF as a speed bump for php://, filter-chain encodings, the soft hyphen 0xAD, and multi-extension uploads.

Verdict

PHP does not give you a door to the shell. It gives you a corridor with a dozen doors, and half of them are propped open by config you inherited. The webshell at the end is trivia. The roads are the job. Patch the interpreter, lock the config, assume the upload folder is already compromised, and hunt the behaviour instead of the string. Do that and the one-liner has nowhere to land.


Sources and further reading

No comments:

PHP2Shell The CVE!!

// elusive thoughts // appsec // php PHP2Shell Two PHP core bugs, CVSS 9.2, unauthenticated RCE before your code runs a single line. This is...