php2shell-scanner.git

php2shell-scanner

~ Check PHP2Shell (CVE-2026-109618, CVE-2026-109619) from inside your server: distribution backports, missed restarts and interim mitigations, verdicts computed in the browser.
git clone https://github.com/debuman/php2shell-scanner.git

check/php2shell-selfcheck.php (18785B)

      1 <?php
      2 /*
      3  * php2shell-selfcheck.php
      4  * The PHP probe for PHP2Shell (CVE-2026-109618, CVE-2026-109619)
      5  *
      6  * Usage
      7  *   1. Set $TOKEN below to a random string of 12 or more characters. Use only letters,
      8  *      digits, '-' and '_' (A-Z a-z 0-9 - _), and keep the single quotes around it. Other
      9  *      characters change on the way through a URL ('+' becomes a space; '&', '#' and '%'
     10  *      cut or alter the value), so the probe refuses them. To make one: openssl rand -hex 16
     11  *   2. Give the file a name that is hard to guess as well, then upload it to the web root
     12  *      of the site you want to check. Save it as UTF-8 without a BOM, with nothing before
     13  *      the opening PHP tag.
     14  *   3. Open https://<site>/<file name>.php?k=<TOKEN> in a browser.
     15  *   4. Paste everything shown on the page into the check page.
     16  *
     17  * - It reads the PHP version, the OS and kernel release, the installed PHP package versions
     18  *   and a few configuration values. It sends nothing anywhere.
     19  * - The file deletes itself when it has run. If the output says it could not be deleted,
     20  *   delete it yourself.
     21  * - A 404 means that $TOKEN was not changed, is too short or has characters other than
     22  *   those above, that k is wrong, or that the file has already run and deleted itself.
     23  * - If you replace the file on the server, PHP may run a cached copy of the old one
     24  *   (OPcache). The probe clears that copy when it can and asks you to load the page again.
     25  *   If the new token still does not work, upload the file under a new name.
     26  * - It uses PHP 5.2 syntax only, so it also runs on old servers.
     27  */
     28 
     29 $TOKEN = 'CHANGE_ME';
     30 $SELF_DELETE = true;
     31 
     32 define('P2S_TOOL_VERSION', '1.0.0');
     33 
     34 // Before anything else: keep PHP warnings, which name file paths, out of the response.
     35 p2s_start();
     36 
     37 $p2s_token = p2s_token_text($TOKEN);
     38 $p2s_given = (isset($_GET['k']) && is_string($_GET['k'])) ? trim($_GET['k']) : '';
     39 if (!p2s_token_usable($p2s_token) || !p2s_equals($p2s_token, $p2s_given)) {
     40     p2s_refuse($p2s_token, $p2s_given);
     41 }
     42 
     43 if (P2S_EARLY_OUTPUT) {
     44     // Something was printed before this file started, so the headers below cannot be sent.
     45     $p2s_deleted = $SELF_DELETE ? p2s_delete() : false;
     46     p2s_respond(false, array(), 'php2shell-selfcheck ' . P2S_TOOL_VERSION . "\n"
     47         . "The probe stopped without collecting anything. Something was sent before it started, so it could not set its own headers. The usual cause is a byte order mark (BOM) or blank space before the opening PHP tag.\n"
     48         . "Save the file as UTF-8 without a BOM, with nothing before the opening PHP tag, and upload it again.\n"
     49         . p2s_delete_note($SELF_DELETE, $p2s_deleted));
     50 }
     51 
     52 $ini_names = array(
     53     'memory_limit',
     54     'post_max_size',
     55     'max_file_uploads',
     56     'max_input_vars',
     57     'max_input_nesting_level',
     58     'max_multipart_body_parts',
     59     'enable_post_data_reading',
     60     'session.upload_progress.enabled',
     61     'session.upload_progress.name',
     62     'session.auto_start',
     63     'fatal_error_backtraces',
     64     'expose_php',
     65 );
     66 $p2s_has_ext = function_exists('extension_loaded');
     67 
     68 $report = array(
     69     'tool' => 'php2shell-selfcheck',
     70     'tool_version' => P2S_TOOL_VERSION,
     71     'schema' => 1,
     72     'generated_at' => function_exists('gmdate') ? @gmdate('Y-m-d\TH:i:s\Z') : '',
     73     'php_version' => PHP_VERSION,
     74     'php_extra_version' => defined('PHP_EXTRA_VERSION') ? PHP_EXTRA_VERSION : '',
     75     'sapi' => PHP_SAPI,
     76     'php_os' => PHP_OS,
     77     'uname_release' => p2s_kernel_release(),
     78     'config_path' => defined('PHP_CONFIG_FILE_PATH') ? PHP_CONFIG_FILE_PATH : '',
     79     'server_software' => isset($_SERVER['SERVER_SOFTWARE']) ? (string)$_SERVER['SERVER_SOFTWARE'] : '',
     80     'open_basedir' => p2s_open_basedir(),
     81     'container' => p2s_container(),
     82     'os_release' => p2s_os_release(),
     83     'dpkg_packages' => p2s_dpkg_php_packages(),
     84     'extensions' => array(
     85         'session' => $p2s_has_ext ? extension_loaded('session') : null,
     86         'opcache' => $p2s_has_ext ? extension_loaded('Zend OPcache') : null,
     87     ),
     88     'ini' => p2s_ini_values($ini_names),
     89 );
     90 
     91 $deleted = $SELF_DELETE ? p2s_delete() : false;
     92 $report['self_deleted'] = $deleted;
     93 
     94 p2s_respond(false, array(
     95         'Cache-Control: no-store, max-age=0',
     96         'X-Robots-Tag: noindex, nofollow',
     97         'X-Content-Type-Options: nosniff',
     98     ),
     99     'php2shell-selfcheck ' . P2S_TOOL_VERSION . "\n"
    100     . "Paste everything below into the check page. It holds the PHP version, SAPI, OS and kernel release, PHP package versions and a few settings. No paths to your site, no secrets.\n"
    101     . p2s_delete_note($SELF_DELETE, $deleted)
    102     . "\n-----BEGIN PHP2SHELL SELFCHECK-----\n"
    103     . p2s_json($report, 0) . "\n"
    104     . "-----END PHP2SHELL SELFCHECK-----\n");
    105 
    106 
    107 // Everything below is called from above. disable_functions can remove any function, and on
    108 // PHP 8 calling a removed function is a fatal error, so every function a host might disable
    109 // is checked with function_exists() before it is called.
    110 
    111 function p2s_can($names)
    112 {
    113     foreach (explode(' ', $names) as $name) {
    114         if (!function_exists($name)) {
    115             return false;
    116         }
    117     }
    118     return true;
    119 }
    120 
    121 function p2s_start()
    122 {
    123     // display_errors may be locked by php_admin_flag, or ini_set() disabled: then silence errors instead
    124     if (!function_exists('ini_set') || @ini_set('display_errors', '0') === false) {
    125         if (function_exists('error_reporting')) {
    126             @error_reporting(0);
    127         }
    128     }
    129     // Output before this file started (a BOM, blank space, auto_prepend_file) has already sent
    130     // the headers when output buffering is off. When it is on, the bytes are still in the buffer.
    131     $early = function_exists('headers_sent') && headers_sent();
    132     $level = 0;
    133     if (p2s_can('ob_start ob_get_level ob_get_length ob_clean ob_end_clean')) {
    134         if (!$early && ob_get_level() > 0 && ob_get_length() > 0) {
    135             @ob_clean();
    136         }
    137         // A buffer of our own catches anything printed later, so it never reaches the response
    138         if (@ob_start()) {
    139             $level = ob_get_level();
    140         }
    141     }
    142     define('P2S_EARLY_OUTPUT', $early);
    143     define('P2S_OB_LEVEL', $level);
    144 }
    145 
    146 // Drop whatever was printed so far, send the headers if still possible, print $body and stop.
    147 function p2s_respond($not_found, $headers, $body)
    148 {
    149     if (P2S_OB_LEVEL > 0) {
    150         while (ob_get_level() >= P2S_OB_LEVEL) {
    151             if (!@ob_end_clean()) {
    152                 break;
    153             }
    154         }
    155     }
    156     if (p2s_can('header headers_sent') && !headers_sent()) {
    157         if ($not_found) {
    158             if (function_exists('http_response_code')) {
    159                 http_response_code(404);
    160             } else {
    161                 header('HTTP/1.0 404 Not Found');
    162             }
    163         }
    164         header('Content-Type: text/plain; charset=utf-8');
    165         foreach ($headers as $line) {
    166             header($line);
    167         }
    168     }
    169     echo $body;
    170     exit;
    171 }
    172 
    173 // A bare 404 for anyone without the token: no path, no PHP version.
    174 function p2s_not_found()
    175 {
    176     if (p2s_can('header headers_sent') && !headers_sent()) {
    177         if (function_exists('header_remove')) {
    178             @header_remove('X-Powered-By');
    179         } else {
    180             @header('X-Powered-By:');
    181         }
    182     }
    183     p2s_respond(true, array('Cache-Control: no-store'), "Not Found\n");
    184 }
    185 
    186 // The request had no usable token. If the copy of this file that ran is not the one on disk
    187 // (OPcache with opcache.validate_timestamps=0 keeps running a replaced file), drop the cached
    188 // copy so that the next request runs the file on disk. A visitor who holds the token of that
    189 // newer file is told to load the page again; everyone else gets the bare 404. Nothing is
    190 // cleared when the token on disk cannot be read: each clear wastes OPcache memory, so
    191 // clearing on every refused request would let any visitor force OPcache restarts.
    192 function p2s_refuse($token, $given)
    193 {
    194     $disk = p2s_disk_token();
    195     if ($disk !== null && $disk !== $token) {
    196         $cleared = p2s_uncache();
    197         if ($disk !== null && p2s_token_usable($disk) && p2s_equals($disk, $given)) {
    198             p2s_respond(false, array('Cache-Control: no-store', 'X-Robots-Tag: noindex, nofollow'),
    199                 'php2shell-selfcheck ' . P2S_TOOL_VERSION . "\n"
    200                 . "The file on the server has changed, but PHP ran an older cached copy of it.\n"
    201                 . ($cleared
    202                     ? "The cached copy has now been cleared. Load this page again. If this message comes back, upload the file under a new name.\n"
    203                     : "PHP did not let the probe clear the cached copy (opcache.restrict_api, or a disabled function). Upload the file under a new name.\n"));
    204         }
    205     }
    206     p2s_not_found();
    207 }
    208 
    209 // $TOKEN as a string. An unquoted number still works when it fits in an integer and has no
    210 // leading 0; anything else counts as no token.
    211 function p2s_token_text($token)
    212 {
    213     if (is_string($token)) {
    214         return $token;
    215     }
    216     return is_int($token) ? (string)$token : '';
    217 }
    218 
    219 // 12 or more of A-Z a-z 0-9 - _, the characters that pass through a URL unchanged.
    220 function p2s_token_usable($token)
    221 {
    222     $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
    223     return is_string($token) && $token !== 'CHANGE_ME' && strlen($token) >= 12
    224         && strspn($token, $chars) === strlen($token);
    225 }
    226 
    227 function p2s_equals($known, $given)
    228 {
    229     if (function_exists('hash_equals')) {
    230         return hash_equals($known, $given);
    231     }
    232     if (strlen($known) !== strlen($given)) {
    233         return false;
    234     }
    235     $diff = 0;
    236     for ($i = 0; $i < strlen($known); $i++) {
    237         $diff |= ord($known[$i]) ^ ord($given[$i]);
    238     }
    239     return $diff === 0;
    240 }
    241 
    242 // The token written in this file on disk now (quoted, or an unquoted number), or null when
    243 // the file cannot be read, the token line is not found or its number is not one PHP keeps as written.
    244 function p2s_disk_token()
    245 {
    246     $code = p2s_read(__FILE__);
    247     if (!is_string($code) || !function_exists('preg_match')) {
    248         return null;
    249     }
    250     if (preg_match('/^\s*\$TOKEN\s*=\s*(?:([\'"])([^\'"]*)\1|([0-9]+))\s*;/m', $code, $m)) {
    251         if (!isset($m[3]) || $m[3] === '') {
    252             return $m[2];
    253         }
    254         // An unquoted number counts only when PHP compiles it to the same digits. With a leading 0
    255         // it is octal, and when it is too large for an integer it is a float, which is no token.
    256         return (string)(int)$m[3] === $m[3] ? $m[3] : null;
    257     }
    258     return null;
    259 }
    260 
    261 // The contents of a file, or false.
    262 function p2s_read($path)
    263 {
    264     if (function_exists('file_get_contents')) {
    265         return @file_get_contents($path);
    266     }
    267     if (!p2s_can('fopen fread fclose')) {
    268         return false;
    269     }
    270     $fh = @fopen($path, 'rb');
    271     if (!$fh) {
    272         return false;
    273     }
    274     $data = '';
    275     while (strlen($data) < 1048576) {
    276         $chunk = @fread($fh, 8192);
    277         if ($chunk === false || $chunk === '') {
    278             break;
    279         }
    280         $data .= $chunk;
    281     }
    282     fclose($fh);
    283     return $data;
    284 }
    285 
    286 // Remove this file from the opcode cache, so that the next request compiles the file on disk.
    287 // Returns false when nothing was cleared: no opcode cache, the file was not cached, or the cache refused.
    288 function p2s_uncache()
    289 {
    290     $cleared = false;
    291     if (function_exists('opcache_invalidate') && @opcache_invalidate(__FILE__, true)) {
    292         $cleared = true;
    293     }
    294     // APC, the opcode cache of PHP 5.2 to 5.4
    295     if (function_exists('apc_delete_file') && @apc_delete_file(__FILE__)) {
    296         $cleared = true;
    297     }
    298     return $cleared;
    299 }
    300 
    301 function p2s_delete()
    302 {
    303     // Clear the cached copy first: on PHP 7.4 and older, opcache_invalidate() fails once the
    304     // file is gone, and a stale copy would then answer a new upload under the same name.
    305     p2s_uncache();
    306     return function_exists('unlink') && @unlink(__FILE__);
    307 }
    308 
    309 function p2s_delete_note($self_delete, $deleted)
    310 {
    311     if (!$self_delete) {
    312         return "Warning: self-delete is turned off. Delete this file when you have finished.\n";
    313     }
    314     if ($deleted) {
    315         return "This probe has been deleted from the server.\n";
    316     }
    317     return 'Warning: this probe could not be deleted. Delete it from the server yourself: ' . basename(__FILE__) . "\n";
    318 }
    319 
    320 // The values of the given settings, with null for a setting this PHP does not have. null
    321 // instead of the list when neither ini_get() nor ini_get_all() can be called.
    322 function p2s_ini_values($names)
    323 {
    324     $out = array();
    325     if (function_exists('ini_get')) {
    326         foreach ($names as $name) {
    327             $value = @ini_get($name);
    328             $out[$name] = ($value === false || $value === null) ? null : (string)$value;
    329         }
    330         return $out;
    331     }
    332     if (function_exists('ini_get_all')) {
    333         $all = @ini_get_all();
    334         if (is_array($all)) {
    335             foreach ($names as $name) {
    336                 if (!isset($all[$name]) || !is_array($all[$name])) {
    337                     $out[$name] = null;
    338                 } else {
    339                     // ini_get() returns '' where ini_get_all() has null
    340                     $out[$name] = isset($all[$name]['local_value']) ? (string)$all[$name]['local_value'] : '';
    341                 }
    342             }
    343             return $out;
    344         }
    345     }
    346     return null;
    347 }
    348 
    349 // Whether open_basedir limits the paths PHP may open. Only the yes or no is reported, not the paths.
    350 function p2s_open_basedir()
    351 {
    352     $ini = p2s_ini_values(array('open_basedir'));
    353     if ($ini !== null) {
    354         return is_string($ini['open_basedir']) && $ini['open_basedir'] !== '';
    355     }
    356     // The settings cannot be read
    357     return p2s_root_visible() === false;
    358 }
    359 
    360 // Whether PHP may look at the root directory: it always exists, so only open_basedir hides it.
    361 // null when neither is_dir() nor file_exists() can be called.
    362 function p2s_root_visible()
    363 {
    364     if (function_exists('is_dir')) {
    365         return @is_dir('/');
    366     }
    367     if (function_exists('file_exists')) {
    368         return @file_exists('/');
    369     }
    370     return null;
    371 }
    372 
    373 // The kernel release, as uname -r prints it, or '' when it cannot be read.
    374 function p2s_kernel_release()
    375 {
    376     if (function_exists('php_uname')) {
    377         $release = @php_uname('r');
    378         if (is_string($release) && $release !== '') {
    379             return $release;
    380         }
    381     }
    382     $release = p2s_read('/proc/sys/kernel/osrelease');
    383     if (is_string($release)) {
    384         $release = trim($release);
    385         if ($release !== '' && strlen($release) <= 200) {
    386             return $release;
    387         }
    388     }
    389     return '';
    390 }
    391 
    392 // true when a container marker exists, null when the probe cannot look for one (open_basedir
    393 // hides the root directory), false otherwise.
    394 function p2s_container()
    395 {
    396     if (!function_exists('file_exists')) {
    397         return null;
    398     }
    399     if (@file_exists('/.dockerenv') || @file_exists('/run/.containerenv')) {
    400         return true;
    401     }
    402     return p2s_root_visible() === true ? false : null;
    403 }
    404 
    405 // Fields of /etc/os-release, or of the legacy release files. null when none could be read,
    406 // usually because open_basedir blocks /etc (see open_basedir in the report).
    407 function p2s_os_release()
    408 {
    409     if (!function_exists('preg_match')) {
    410         return null;
    411     }
    412     foreach (array('/etc/os-release', '/usr/lib/os-release') as $file) {
    413         $content = p2s_read($file);
    414         if (!is_string($content) || $content === '') {
    415             continue;
    416         }
    417         $out = array();
    418         foreach (explode("\n", $content) as $line) {
    419             if (preg_match('/^(ID|ID_LIKE|VERSION_ID|VERSION_CODENAME|PRETTY_NAME)=(.*)$/', trim($line), $m)) {
    420                 $out[$m[1]] = trim($m[2], "\"'");
    421             }
    422         }
    423         if (count($out) > 0) {
    424             return $out;
    425         }
    426     }
    427     // Older distributions without os-release
    428     $out = array();
    429     foreach (array('/etc/redhat-release', '/etc/debian_version') as $file) {
    430         $content = p2s_read($file);
    431         if (is_string($content) && $content !== '') {
    432             $out['LEGACY_' . strtoupper(basename($file))] = trim($content);
    433         }
    434     }
    435     return count($out) > 0 ? $out : null;
    436 }
    437 
    438 // Exact distribution versions of the installed PHP packages (used to check for backports).
    439 // null if the package list cannot be read (open_basedir, or not a dpkg system).
    440 function p2s_dpkg_php_packages()
    441 {
    442     if (!p2s_can('fopen fgets fclose preg_match')) {
    443         return null;
    444     }
    445     $fh = @fopen('/var/lib/dpkg/status', 'r');
    446     if (!$fh) {
    447         return null;
    448     }
    449     $found = array();
    450     $cur = array();
    451     while (true) {
    452         $line = @fgets($fh);
    453         if ($line === false || trim($line) === '') {
    454             if (isset($cur['Package'], $cur['Version'], $cur['Status'])
    455                 && preg_match('/ ok installed$/', $cur['Status'])
    456                 && preg_match('/^(libapache2-mod-php[0-9.]+|php[0-9.]+-(common|fpm|cli|cgi))$/', $cur['Package'])
    457                 && count($found) < 40) {
    458                 $found[] = array(
    459                     'name' => $cur['Package'],
    460                     'version' => $cur['Version'],
    461                     'source' => isset($cur['Source']) ? $cur['Source'] : '',
    462                 );
    463             }
    464             $cur = array();
    465             if ($line === false) {
    466                 break;
    467             }
    468             continue;
    469         }
    470         if (preg_match('/^(Package|Status|Version|Source): (.*)$/', rtrim($line, "\r\n"), $m)) {
    471             $cur[$m[1]] = $m[2];
    472         }
    473     }
    474     fclose($fh);
    475     return $found;
    476 }
    477 
    478 function p2s_json($value, $depth)
    479 {
    480     if (is_null($value)) {
    481         return 'null';
    482     }
    483     if (is_bool($value)) {
    484         return $value ? 'true' : 'false';
    485     }
    486     if (is_int($value) || is_float($value)) {
    487         return (string)$value;
    488     }
    489     if (is_array($value)) {
    490         if (count($value) === 0) {
    491             return '[]';
    492         }
    493         $pad = str_repeat('  ', $depth + 1);
    494         $items = array();
    495         $is_list = array_keys($value) === range(0, count($value) - 1);
    496         foreach ($value as $k => $v) {
    497             $items[] = $pad . ($is_list ? '' : p2s_json_string((string)$k) . ': ') . p2s_json($v, $depth + 1);
    498         }
    499         $close = str_repeat('  ', $depth);
    500         return ($is_list ? '[' : '{') . "\n" . implode(",\n", $items) . "\n" . $close . ($is_list ? ']' : '}');
    501     }
    502     return p2s_json_string((string)$value);
    503 }
    504 
    505 function p2s_json_string($s)
    506 {
    507     $out = '"';
    508     $len = strlen($s);
    509     for ($i = 0; $i < $len; $i++) {
    510         $c = $s[$i];
    511         $o = ord($c);
    512         if ($c === '"') {
    513             $out .= '\\"';
    514         } elseif ($c === '\\') {
    515             $out .= '\\\\';
    516         } elseif ($o < 0x20 || $o === 0x7f) {
    517             $out .= sprintf('\\u%04x', $o);
    518         } else {
    519             $out .= $c;
    520         }
    521     }
    522     return $out . '"';
    523 }