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 }