AI 摘要

宝宝读完啦,讲的是有个大哥哥去别人家里看看。他先在墙上画画,写了个大大的字(h1),哇,真的画上去了!然后他想放会跑的小火车(script),但是墙上的框框不让开,小火车跑不起来。 然后然后,他换了个办法!他贴了张假照片(img),照片坏了的时候就"砰"地弹出一个窗口(onerror)。这里超厉害!他还能让大家一跳跳到百度去。 这是什么呀?原来是留言板太随便,别人写什么就贴什么,都不检查有没有坏东西。 后来大哥哥告诉家里主人要修好,要安全地贴字(用textContent),还要装防护网(CSP)。宝宝讲完啦!

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

GitHub 仓库:@yuhan2680/Website


探索开始

XSS(跨站脚本攻击)是指攻击者往网页中注入恶意代码,当其他用户访问该页面时,代码在用户的浏览器中执行,从而窃取数据、劫持会话或进行恶意操作。

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

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

尝试注入 HTML 标签

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

提交后页面直接渲染了标题 —— 意味着没有任何过滤,可以正常提交。

<h1>h1</h1>
<h2>h1</h2>
<h3>h1</h3>
<p>test</p>

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

尝试注入 <script> 标签

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

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

参考:MDN - innerHTML 安全问题

尝试 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优化排版。

本文已获得站长的授权,并且仅用于学习用途。