今天在 GitHub 闲逛时发现了一个个人网站仓库,看起来像是一位大佬的站点。我决定去看看,运气好的话或许还能互换友链。
GitHub 仓库:@yuhan2680/Website

探索开始
打开网站首页,风格简洁,应该是站长自己写的。

最明显的是首页有一个留言板(划重点)。

尝试注入 HTML 标签
先试试提交一些基础的 HTML 标签,比如 <h1> 等:

提交后页面直接渲染了标题 —— 意味着没有任何过滤,可以正常提交。
<h1>h1</h1>
<h2>h1</h2>
<h3>h1</h3>
<p>test</p>

不过继续尝试后,发现还是存在部分限制的。


尝试注入 <script> 标签
提交 <script> 标签看起来被插入了,但实际上并没有弹出窗口。


查看评论区加载的代码,并结合查阅的资料,可以确定网站是使用 innerHTML 来渲染评论内容的。
根据 HTML5 规范:通过 innerHTML 插入的 <script> 标签不会被执行,这是浏览器为了防止 XSS 攻击设置的一项核心安全措施。

尝试 onerror 事件注入
既然 <script> 行不通,那试试事件处理器。以 <img> 标签为例:
<img src="invalid.jpg" onerror="alert('XSS')">
src指向一个不存在的图片地址,浏览器加载图片必然失败;- 加载失败会触发
error事件; onerror中指定的 JavaScript 代码alert('XSS')就会被执行。

提交后 —— 成功弹窗!XSS 漏洞确认存在。


利用 onerror 实现跳转
理论上可以通过这个漏洞让访问者跳转到任意网站。于是我在评论区提交了:
<img src="invalid.jpg" onerror="window.location.replace('https://www.baidu.com')">
提交后,访问该评论页面的用户会被立即跳转到百度。
漏洞成因与修复建议
漏洞的根本原因在于:直接将用户提交的内容通过 innerHTML 插入页面,且未对用户输入进行任何转义或过滤。
下面是漏洞代码片段及对应的修复方案。
原漏洞代码
// 获取当前文章 ID:例如 /posts/post0.html → post0
// 解析当前页面的 URL 路径,用 / 分割,取最后一段,再移除 .html 后缀,得到文章的唯一标识符
const postId = location.pathname.split("/").pop().replace(".html", "");
//这里还有几行是设置提交时间间隔的,我省略了
// 加载评论的函数
function loadComments() {
// API 发送 GET 请求,获取当前文章的所有评论
fetch(`/api/comments?post=${postId}`)
// 将服务器返回的响应对象解析为 JSON 格式
.then(res => res.json())
// 处理解析后的数据(data 是一个包含评论对象的数组)
.then(data => {
// 判断评论数组是否为空
if (!data.length) {
// 如果为空,找到 id 为 "commentList" 的元素,将对应 HTML 文本设置为 "暂无评论"
document.getElementById("commentList").innerHTML = "暂无评论";
// 直接返回
return;
}
// 如果存在评论,则生成 HTML 结构并插入到页面中
// 使用 data.map 遍历每条评论,生成对应的 HTML 字符串
document.getElementById("commentList").innerHTML = data.map(c => `
// 每条评论的外层 div
// 显示评论者的昵称,直接插入
${c.nickname}
// 显示评论内容,依旧直接插入
${c.content}
// 显示评论时间:将 ISO 格式中的 'T' 替换为空格,并截取前 16 个字符(如 "2025-11-20 12:34")
${c.created_at.replace('T',' ').slice(0,16)}
`).join(""); // 将数组中的所有 HTML 片段拼接成一个完整的字符串,赋值给 innerHTML,到这里就成功写出了漏洞()
});
}
修复方案
方案1:使用 textContent 或安全地创建 DOM 元素
完全放弃 innerHTML,改用 document.createElement 和 textContent 来插入用户数据。
// 定义加载评论的函数
function loadComments() {
// API 发送 GET 请求,获取当前文章的所有评论,本部分不作修改
fetch(`/api/comments?post=${postId}`)
.then(res => res.json())
.then(data => {
const commentList = document.getElementById("commentList");
// 如果没有评论,用 textContent 直接显示文本
if (!data.length) {
commentList.textContent = "暂无评论";
return;
}
// 如果有评论,先清空 div 的所有内容,然后逐条构建 DOM 元素
commentList.innerHTML = ""; // 清空之前的内容,保证 div 内部是空的
data.forEach(c => {
// 创建外层div.comment-item
const itemDiv = document.createElement("div");
itemDiv.className = "comment-item";
// 创建div.comment-nick
const nickDiv = document.createElement("div");
nickDiv.className = "comment-nick";
nickDiv.textContent = c.nickname; // 使用 textContent 安全设置昵称
// 创建div.comment-content
const contentDiv = document.createElement("div");
contentDiv.className = "comment-content";
contentDiv.textContent = c.content; // 使用 textContent 安全设置评论内容
// 创建div.comment-time
const timeDiv = document.createElement("div");
timeDiv.className = "comment-time";
// 时间由服务器生成,虽然不需要转义(不包含用户输入),但用 textContent 也没问题
timeDiv.textContent = c.created_at.replace('T', ' ').slice(0, 16);
// 将三个 div 添加到 itemDiv 中
itemDiv.appendChild(nickDiv);
itemDiv.appendChild(contentDiv);
itemDiv.appendChild(timeDiv);
// 将 itemDiv 添加到评论列表容器
commentList.appendChild(itemDiv);
});
});
}
方案2:实施严格的 CSP 策略
通过 HTTP 响应头 Content-Security-Policy 限制脚本执行来源,禁止内联事件处理器。
Content-Security-Policy: default-src 'none'; script-src 'self'; img-src 'self'; connect-src 'self';
default-src 'none':默认不加载任何资源。script-src 'self':仅允许执行同源的外部脚本,禁止所有内联脚本和事件处理器。img-src 'self':仅允许加载同源图片,防止利用图片标签外传数据。connect-src 'self':仅允许向同源发送 Ajax/fetch 请求。
方案3:设置 HttpOnly Cookie(适用于有登录功能)
若网站有用户登录功能,务必为会话 Cookie 添加 HttpOnly 属性,这样 JavaScript 就无法通过 document.cookie 读取 Cookie,即使 XSS 成功,也无法窃取用户登录凭证。
但是该网站目前没有后台,此方案仅作为建议列出()
结语
这次探索增加了我的经验,也成功实践了一次没有任何难度的 XSS 攻击。
在此对受害网站 https://naiwenel.com 表示歉意,对不起。
本文使用AI优化排版。
本文已获得站长的授权,并且仅用于学习用途。

