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.
$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";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.
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__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).