A white-box capstone: pick one real open-source PHP application, review its source using the course's source-to-sink methodology, and deliver a code-review report in which every finding is anchored to a file and a line number.
[!warning] Authorized use only Reading published source is lawful. Running the application to confirm a finding is not lawful against anybody's deployment but your own — stand up your own instance on an isolated host, bound to
127.0.0.1. If your review turns up a genuine flaw in a maintained project, you are now in responsible-disclosure territory: contact the maintainer privately and do not publish a working exploit before they have had a reasonable chance to fix it.
Type: Capstone · Target: one reader-selected open-source PHP application · Difficulty: Advanced · Time: ~14–18 h · OWASP: A01, A03, A07, A08:2021
Black-box testing answers "does this payload work?". Source review answers "why does it work, what else does, and what did I never have a chance of reaching from outside?". That last part is the reason white-box review finds more: forgotten routes, admin-only code paths, second-order flows where the payload is stored in one request and executed in another, and logic that is only reachable when a configuration flag is set. None of those are discoverable by fuzzing.
This capstone is deliberately unguided in the one dimension that matters — you pick the application, and nobody has told you whether it contains any bugs at all. That uncertainty is the skill. Reviewers who have only worked deliberately-vulnerable code learn to expect a finding on every page and get lost when the code is merely ordinary.
A code review's threat model is a statement about trust boundaries in the source, not about the deployment. Write these five before you read any code, and use them to decide which files matter.
| Boundary | Question | Where the answer lives in a PHP codebase |
|---|---|---|
| Request → application | Which superglobals are read, where, and is anything normalised or validated centrally? | $_GET, $_POST, $_REQUEST, $_COOKIE, $_FILES, $_SERVER['HTTP_*']; any front controller or bootstrap file. |
| Application → database | Is every query parameterised, or does the codebase build SQL by concatenation? | mysqli_query(), $pdo->query(), $pdo->exec(), ORM raw-query escapes. |
| Application → interpreter | Does any user-influenced string reach a place that executes, includes, or deserialises? | eval(), include/require, system() family, unserialize(), template render calls. |
| Application → response | Is output encoded at the point of output, or is encoding assumed to have happened at input? | echo, print, printf, template files, JSON responses that later reach innerHTML. |
| Unauthenticated → authenticated | Which files enforce the session check, and — critically — which ones simply do not include the file that enforces it? | The auth include or middleware, and every file that omits it. |
That last row is where the highest-severity finding in a small PHP application usually hides. In codebases built from standalone .php files rather than a front controller, access control is a require 'auth.php'; at the top of each page, and it is missing from at least one of them. A grep for files that lack that line is a two-minute test with a serious payoff — and it is precisely the kind of finding black-box testing only stumbles on by luck.
Pick an application in the 5,000–50,000 line range. Smaller than that and there is nothing to find; larger and you will spend the whole time box orienting.
The four applications documented in PHP Open Source Projects are the intended targets, and the reason they are a good fit is stated plainly in that note: they are ordinary PHP/MySQL business applications published as free downloads, written to work rather than to withstand attack, and not advertised as deliberately vulnerable. There is no hint text and no scoreboard. Any of the four will do:
- Employee Leaves Management System (ELMS) — approval workflows, so business-logic and access-control surface.
- Student Management System (SMS) — record management with separate student and admin panels.
- Gym Management System — membership records and profile-photo uploads, so a file-handling surface.
- User Registration and User Management — the densest authentication surface of the four.
You may substitute any other open-source PHP application, with two conditions: you must be able to run it locally to confirm findings, and if it is actively maintained you must commit to responsible disclosure before you start.
[!important] These are third-party archives, not curated exercises The install notes were written against specific downloads. Vendors republish, and the archive you extract may differ from what any note describes. Read what you actually extracted. Equally, do not treat a documented default credential as your finding — it is the starting position.
# Snapshot the pristine source before you touch anything. Even for an unversioned zip:
mkdir -p ~/review/<app> && cd ~/review/<app>
unzip ~/Downloads/<app>.zip -d src/
cd src && git init -q && git add -A && git commit -qm "vendor baseline, unmodified"That baseline commit is not ceremony. It gives you git diff to prove you have not accidentally modified the code under review, and it gives you a place to commit annotations without losing the original.
Stand the running instance up separately, bound to loopback, so you can confirm findings dynamically. The DVWA pattern from the rest of this course applies to your port choice too:
# 8080 was occupied on the authoring host, so the reference DVWA instance uses 8081.
# Check before you bind anything, and record the port you actually used in your report:
ss -ltnp | grep -E ':(8080|8081|8082)\b' || echo "free"| Item | Value for this capstone |
|---|---|
| In scope | The application source in your local copy, and your own running instance on 127.0.0.1. |
| Out of scope | The vendor's website, any public deployment of the same application, the host OS. |
| Permitted | Reading source, grep/ripgrep, git, php -l, a local IDE, and dynamic confirmation against your own instance only. |
| Prohibited | Testing any deployment you do not own; publishing a working exploit for a maintained project before disclosure. |
| Confirmation | Every finding must be confirmed dynamically against your local instance, or explicitly marked unconfirmed — code-review only with the reason it could not be reached. |
| Time box | 14–18 hours across several sittings. |
| Reset point | Re-import the vendor SQL schema; git checkout . in the source copy. |
[!note] Tooling reality
semgrepandcodeqlare not installed on the authoring host, and neither issqlmap. This capstone therefore runs ongrep/ripgrep,git,php -land reading — which is genuinely how a large share of professional PHP review is done, because the sink list is short and greppable. Where a static-analysis tool is the better route, this note names it and points at its own documentation (Semgrep, PHPStan, Psalm) rather than inventing flags it has not run. If you install one, treat its output as a lead list, never as a findings list.
Follow [[Secure-Code-Review/Source-Code-Review-Methodology|Source Code Review Methodology]] as the master process. The six phases below are that process scoped to a PHP application and to this deliverable.
Do not open a file at random. Build a map first.
# Size and shape
find . -name '*.php' | wc -l
find . -name '*.php' -exec wc -l {} + | sort -rn | head -20 # the biggest files
ls -la # composer.json? a framework? a front controller?
cat composer.json 2>/dev/null# Every entry point, in one pass. In flat PHP applications, each .php file IS a route.
find . -name '*.php' | sed 's|^\./||' | sort > inventory/routes.txt
wc -l inventory/routes.txt# Where does the database connection live, and how many connection files are there?
grep -rn --include='*.php' -E 'mysqli_connect|new mysqli|new PDO' . | tee inventory/db-connections.txtNote that these applications commonly ship a separate configuration for the admin panel — so expect more than one connection file, and check whether they use different credentials or the same. Record the stack, the PHP version the code appears to target, the routing style (front controller versus flat files), the session mechanism, and the roles the application recognises.
Exit condition: a route inventory file, a role list, and a one-paragraph description of how the application decides who you are.
Find every point where untrusted data enters.
grep -rn --include='*.php' -E '\$_(GET|POST|REQUEST|COOKIE|FILES|SERVER)\b' . \
| tee inventory/sources.txt | wc -l# Header-derived input is the subtle one: Host, X-Forwarded-For, Referer, User-Agent.
grep -rn --include='*.php' -E "\\\$_SERVER\['HTTP_[A-Z_]+'\]" . | tee inventory/header-sources.txtAdd the second-order sources too — values read back out of the database (fetch_assoc, fetchAll) and then used. The payload and the sink sit in different requests, which is exactly why black-box testing misses them.
Work the PHP sink list from [[Secure-Code-Review/PHP-Sink-Taxonomy|PHP Sink Taxonomy]]. Grep each family into its own file so that Phase 4 has a worklist.
mkdir -p inventory/sinks
# SQL execution
grep -rn --include='*.php' -E 'mysqli_query|->query\(|->exec\(|mysql_query' . \
> inventory/sinks/sql.txt
# Command execution
grep -rn --include='*.php' -E '\b(system|exec|shell_exec|passthru|popen|proc_open)\s*\(' . \
> inventory/sinks/command.txt
# Dynamic include — the file-inclusion surface
grep -rn --include='*.php' -E '\b(include|include_once|require|require_once)\s*[\(\s]*\$' . \
> inventory/sinks/include.txt
# Deserialization
grep -rn --include='*.php' -E '\bunserialize\s*\(' . > inventory/sinks/deserialize.txt
# Code evaluation, including the legacy forms
grep -rn --include='*.php' -E '\b(eval|assert|create_function)\s*\(' . \
> inventory/sinks/eval.txt
# Filesystem operations driven by a variable
grep -rn --include='*.php' -E '\b(file_get_contents|file_put_contents|fopen|readfile|unlink|move_uploaded_file)\s*\(' . \
> inventory/sinks/file.txt
# Output — the XSS surface
grep -rn --include='*.php' -E 'echo\s+\$|print\s+\$|printf\(' . > inventory/sinks/output.txt
wc -l inventory/sinks/*Three notes on the legacy entries, all of which you will meet in this class of application:
create_function()— the PHP manual states it "has been DEPRECATED as of PHP 7.2.0, and REMOVED as of PHP 8.0.0". It internally performs aneval(), so any use with attacker-influenced input is code execution on a PHP 7.x deployment, and a fatal error on PHP 8. Both outcomes belong in the report, with the version dependency stated.preg_replace()with the/emodifier — current PHP documentation states that using theemodifier "is an error; anE_WARNINGis emitted in this case". On a modern runtime it therefore does not evaluate, but it is a reliable marker that the code was written against a much older PHP and probably carries siblings that are still exploitable.unserialize()— the manual's signature isunserialize(string $data, array $options = []): mixed, withallowed_classesavailable since PHP 7.0.0 andmax_depthsince PHP 7.4.0. Note carefully what the manual actually says about the mitigation: "Do not pass untrusted user input to unserialize() regardless of theoptionsvalue ofallowed_classes." Soallowed_classes => falseis not a complete fix, and if you find it in the code you should still report the design, recommendingjson_decode()instead. Chain detail is in [[Serialization-and-Templating/Insecure-Deserialization/Insecure-Deserialization-Chains|Insecure Deserialization Chains]].
This is the review. For each sink from Phase 3, trace backwards until you reach a source or prove you cannot.
For each candidate, record five things: the source, the path (every assignment and function call between), the sink, the sanitisation encountered, and your verdict. A finding is a path where untrusted data reaches a dangerous sink with sanitisation that is absent, wrong for the context, or bypassable.
# Trace one variable through the codebase, both directions.
V='$empid'
grep -rn --include='*.php' -F "$V" . | tee /tmp/trace.txt | head -40Judge each sanitiser rather than accepting its presence:
| What you find | Whether it actually helps |
|---|---|
mysqli_real_escape_string() around a value inside quotes |
Helps for that value. Useless if the value is used unquoted, and useless for identifiers like table or column names. |
intval() / (int) cast |
Genuinely effective for numeric parameters — and a good remediation to recommend. |
addslashes() |
Not an SQL defence. Report its use as a finding in itself. |
htmlspecialchars() at input time |
Wrong layer. It corrupts stored data and does not protect non-HTML sinks. Encode at output. |
htmlspecialchars() at output, default flags |
Does not escape single quotes unless ENT_QUOTES is passed — so it fails in a single-quoted attribute context. |
basename() before an include |
Blocks traversal but not inclusion of another file in the same directory. |
| A regex allow-list | Read it character by character. Unanchored patterns (/admin/ without ^ and $) are the classic miss. |
== comparing a password hash |
Type juggling. Two "magic hash" strings that both start 0e and continue with digits compare equal as numbers. Use hash_equals(). |
Prioritise: authentication and session code first, then anything reachable unauthenticated, then admin functionality, then the rest. Use [[Secure-Code-Review/Source-to-Sink-Taint-Tracing|Source-to-Sink Taint Tracing]] for the technique and [[Secure-Code-Review/Authentication-Bypass-Chain-Discovery|Authentication Bypass Chain Discovery]] for the highest-value pattern.
Four sweeps that are fast, mechanical, and disproportionately productive.
Missing access-control checks. Identify the auth-enforcing include, then list every page that omits it:
# 1. Find the guard.
grep -rln --include='*.php' -E "session_start|require.*(auth|session|checklogin)" . | head
# 2. List pages that never include it — each is an access-control candidate.
for f in $(find . -name '*.php'); do
grep -qE "require(_once)?\s*[\('\"].*(auth|session|checklogin)" "$f" || echo "UNGUARDED: $f"
done | tee inventory/unguarded.txtThen confirm each candidate against your running instance: request it with no cookie. A page that returns data is a finding.
Secrets in source and history. Database passwords, API keys, hardcoded admin credentials, and anything a previous commit removed but the history retains:
grep -rniE "(password|passwd|secret|api[_-]?key|token)\s*=\s*['\"][^'\"]{4,}" \
--include='*.php' --include='*.ini' --include='*.conf' . | tee inventory/secrets.txtIf the project has real history, mine it — a credential deleted in a later commit is still in the pack file. Method in [[Secure-Code-Review/Git-History-Secret-Mining|Git History Secret Mining]].
Dependencies. Inventory third-party code and check it against advisories rather than guessing:
cat composer.json composer.lock 2>/dev/null | head -40
find . -path ./vendor -prune -o -name '*.js' -print | head -20 # bundled librariesNever assert a CVE identifier, an affected version range, or a CVSS score you have not checked in the NVD or the GitHub Advisory Database. "Bundled jQuery 1.x, version confirmed from the file header, advisories not yet checked" is an honest interim finding; a fabricated CVE number is a report-destroying error. Process in [[Secure-Code-Review/Dependency-and-SCA-Review|Dependency and SCA Review]].
Configuration and deployment. Look for display_errors left on, .sql dumps or .bak files inside the web root, uploads directories that are web-accessible and executable, and installer scripts that were never deleted. These are code-review findings even though they are not code.
Confirm every finding on your local instance and capture the request and response. A code-review finding that has never been executed is a hypothesis, and honest reports label it as one.
Then look for the chain. In this class of application the recurring one is: an unguarded page discloses a record identifier → an SQL injection in that identifier reads the credential table → the credential column stores an unsalted hash → the hash cracks → you log in as an administrator legitimately. Four medium findings, one critical chain. The DVWA reference case in [[Mini-Projects/Web-Pentest-Report-Writing|Web Pentest Report Writing]] shows exactly this shape written up properly, including how to justify the severity.
Every finding in the report gets a file path and a line number. That is the single thing that distinguishes a code-review report from a pentest report, and it is what lets a developer fix it in minutes rather than hours.
- Application map — stack, route inventory, roles, session mechanism, and how authorisation is enforced (or is not).
- Source and sink inventories — the raw grep output from Phases 2 and 3, kept as evidence of coverage.
- Taint-trace records — for every candidate examined, the five-field record from Phase 4, including the ones you cleared. Cleared traces are the proof of coverage that makes the report trustworthy.
- Code-review report — one section per finding:
path/to/file.php:LINE, the vulnerable snippet quoted, the source-to-sink path in prose, the exploitation proof, impact, severity with rationale, and a corrected code snippet as the remediation. - Structural-pass results — the unguarded-page list, the secrets inventory, the dependency inventory with advisory status, and the configuration observations.
- Confirmation log — per finding, the request that proved it on your local instance, or an explicit "unconfirmed — code-review only" with the reason.
- Disclosure record, if the project is maintained — who you contacted, when, and what you sent.
| Criterion | Developing | Competent | Strong |
|---|---|---|---|
| Orientation | Started reading files immediately. | Route inventory and role list built before any bug hunting. | Auth-enforcement mechanism identified and used to drive the structural pass. |
| Coverage | Only the sinks that looked promising. | Every sink family grepped and worked through. | Cleared traces recorded, so absence of findings is evidenced. |
| Sanitiser analysis | Presence of a function accepted as a fix. | Each sanitiser judged against its actual sink context. | Report explains why the sanitiser fails, not just that it does. |
| Line anchoring | Findings described in prose. | Every finding has file and line. | Snippet quoted, path traced, and a corrected snippet supplied. |
| Confirmation | Findings asserted from reading alone. | Each finding confirmed against a local instance. | Unconfirmable findings explicitly labelled with the reason. |
| Version and CVE hygiene | Version numbers or CVEs stated from memory. | Versions read from the files themselves. | Advisories checked against NVD or the GitHub Advisory Database and cited. |
| Chaining | Findings listed separately. | One chain to a serious outcome documented. | Chain drives severity, and the single fix that breaks it is named. |
| Disclosure | Not considered. | Maintained project identified and contact made. | Timeline and content of the disclosure recorded in the report. |
- Compare with the black-box result. Run [[Mini-Projects/Full-Web-Application-Assessment|Full Web Application Assessment]] against the same application without the source, then diff the two findings lists. What you missed from outside — and what looked alarming in the code but proved unreachable — is the most instructive artefact in this course.
- Author a static-analysis rule. Take the most repeated pattern you found and express it as a rule, then measure its false-positive rate across the whole codebase. Method in [[Secure-Code-Review/Static-Analysis-Rule-Authoring|Static Analysis Rule Authoring]]; if you install Semgrep for this, follow its own documentation for syntax.
- Diff two vendor releases. If the vendor has published a newer version, diff it against yours and identify security changes made silently. Technique in [[Secure-Code-Review/Patch-Diffing|Patch Diffing]].
- Review a framework application instead. Repeat against a Laravel or Symfony codebase, where the sink list is shorter but the bugs move into configuration, mass assignment and raw-query escapes. Guidance in [[Secure-Code-Review/Framework-Specific-Review|Framework-Specific Review]].
- PHP Manual —
unserialize()— source of the signature, theallowed_classesandmax_depthavailability, and the untrusted-input warning quoted above. - PHP Manual —
create_function()— source of the "DEPRECATED as of PHP 7.2.0, REMOVED as of PHP 8.0.0" wording. - PHP Manual —
preg_replace()— source of the statement that theemodifier is an error emittingE_WARNING. - OWASP Code Review Guide
- NVD vulnerability search · GitHub Advisory Database — the only acceptable sources for a CVE, a version range, or a CVSS score in your report.
- Semgrep documentation · PHPStan · Psalm — none installed on the authoring host; consult their own docs for syntax and flags.
- [[Secure-Code-Review/Source-Code-Review-Methodology|Source Code Review Methodology]] — the master process this capstone scopes to PHP.
- [[Secure-Code-Review/PHP-Sink-Taxonomy|PHP Sink Taxonomy]] — the sink list driving Phase 3.
- [[Secure-Code-Review/Source-to-Sink-Taint-Tracing|Source-to-Sink Taint Tracing]] — the tracing technique used in Phase 4.
- [[Practical-Labs/Lab-Secure-Code-Review-Walkthrough|Lab: Secure Code Review Walkthrough]] — a guided single-file drill to run before this capstone.
- [[Mini-Projects/Web-Pentest-Report-Writing|Web Pentest Report Writing]] — the finding template, and the worked chain this review aims to reproduce.
- [[Mini-Projects/Full-Web-Application-Assessment|Full Web Application Assessment]] — the black-box counterpart to run against the same target.
- PHP Open Source Projects — install guides for the four candidate targets.
- [[Secure-Code-Review/Readme|Secure Code Review]] — the module hub for every technique used here.
- [[Mini-Projects/Readme|Mini Projects]] — capstone index and shared engagement guidance.