Second-Order Injection

Second-Order Injection and Trusting Your Own Database

Binding values on the way in is only half the job. In a second-order injection the hostile string is stored safely with a prepared statement, then does its damage later when other code reads it back and pastes it into a new query. The database is not a trust boundary; a value read from it deserves the same care as one from $_POST.

A name stored safely, then re-used unsafely and safelyPHP
$stored = "anon' OR '1'='1";                 // came back from the DB, inserted with a placeholder
$rows = $pdo->query("SELECT id FROM customers WHERE name = '$stored'")->fetchAll();  // VULNERABLE
echo 'concatenated query returned ', count($rows), " rows\n";
$st = $pdo->prepare('SELECT id FROM customers WHERE name = ?');   // the fix: bind it again
$st->execute([$stored]);
echo 'prepared query returned ', count($st->fetchAll()), " rows\n";
Output
concatenated query returned 8 rows
prepared query returned 0 rows

The stored name matched no real customer when bound, but returned all eight rows when concatenated. There is no pre-sanitized data in a table: bind on read too.

The same rule condemns unserialize() on anything a user could influence, such as a cookie. A crafted string can instantiate any loaded class and fire its magic methods (a POP gadget), so an object's __destruct() runs on attacker-chosen data, which is OWASP A08, Data Integrity Failures.

A crafted object whose __destruct deletes a file, then the fixJavaScript
class TempFile {
    public string $path = '';
    public function __destruct() { if ($this->path) @unlink($this->path); }   // fires when freed
}
$payload = 'O:8:"TempFile":1:{s:4:"path";s:21:"/tmp/ch04-17/keep.txt";}';
unserialize($payload);                                    // VULNERABLE: __destruct deletes the file
echo get_class(unserialize($payload, ['allowed_classes' => false])), "\n";   // fix: nothing fires
Output
__PHP_Incomplete_Class

Unrestricted, unserialize() rebuilt the TempFile and its destructor deleted the file; allowed_classes => false restored it as an inert __PHP_Incomplete_Class. Pass allowed_classes on every unserialize() of external data, or use JSON (JSON, cURL and HTTP APIs).