第4章:NoSQL注入攻击:MongoDB注入原理与防御
说到NoSQL注入,很多人第一反应是:「MongoDB又不是SQL,哪来的注入?」
嗯,我当年也是这么想的。直到我在一个项目中,亲眼看到攻击者通过一个登录框,直接绕过了整个认证系统。那感觉,就像被人当面打了一拳。
4.1 MongoDB注入的本质
说白了,NoSQL注入和SQL注入的核心思路是一样的——都是把用户输入拼接到查询语句里。只不过MongoDB用的是JSON对象,不是SQL字符串。
举个例子,一个典型的登录查询:
// 不安全的写法
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// 直接拼接用户输入
const query = {
username: username,
password: password
};
const user = await db.collection('users').findOne(query);
if (user) {
res.send('登录成功');
} else {
res.send('登录失败');
}
});
看起来没什么问题对吧?但攻击者只需要在密码框里输入:
{ "$ne": "" }
整个查询就变成了:
db.users.findOne({
username: "admin",
password: { "$ne": "" }
})
$ne是MongoDB的「不等于」操作符。这个查询的意思是:找一条username是admin,并且password不等于空字符串的记录。你想想看,只要admin用户存在,密码随便是什么都能匹配上。
4.2 常见的注入手法
我在实际渗透测试中,见过以下几种最常见的攻击方式:
| 攻击手法 | 输入示例 | 效果 |
|---|---|---|
| $ne绕过 | {"$ne": ""} |
匹配任何非空值 |
| $gt绕过 | {"$gt": ""} |
匹配大于空字符串的值 |
| $regex盲注 | {"$regex": "^a"} |
通过正则逐字符猜解 |
| $where注入 | {"$where": "this.password.length > 0"} |
执行JavaScript表达式 |
其中$where是最危险的。它允许执行任意JavaScript代码。我曾经见过一个案例,攻击者通过$where直接执行了系统命令。
4.3 使用mongo-sanitize库防御
好了,问题摆在这了。怎么防?
我个人习惯用mongo-sanitize这个库。它做的事情很简单——把用户输入中的MongoDB操作符全部剥离掉。
4.3.1 安装
npm install mongo-sanitize
4.3.2 基本用法
const sanitize = require('mongo-sanitize');
app.post('/login', async (req, res) => {
// 先净化用户输入
const cleanBody = sanitize(req.body);
const { username, password } = cleanBody;
const query = {
username: username,
password: password
};
const user = await db.collection('users').findOne(query);
// ...
});
就这么简单。如果攻击者传入{"$ne": ""},经过sanitize处理后,会变成普通的字符串"$ne",不再具有操作符功能。
4.3.3 全局中间件写法
const sanitize = require('mongo-sanitize');
// 全局净化中间件
app.use((req, res, next) => {
if (req.body) {
req.body = sanitize(req.body);
}
if (req.query) {
req.query = sanitize(req.query);
}
if (req.params) {
req.params = sanitize(req.params);
}
next();
});
4.4 更深层的防御策略
光靠mongo-sanitize就够了吗?不够。我曾经在一次代码审计中发现,即使用了sanitize,如果代码写得有问题,照样能被绕过。
4.4.1 类型校验
MongoDB是动态类型语言,但你的业务逻辑不是。比如密码字段,它应该永远是字符串:
// 额外的类型检查
if (typeof password !== 'string') {
return res.status(400).send('密码格式不正确');
}
4.4.2 白名单字段
我习惯的做法是,只允许查询中出现的字段:
const allowedFields = ['username', 'password', 'email'];
const query = {};
for (const field of allowedFields) {
if (cleanBody[field]) {
query[field] = cleanBody[field];
}
}
4.4.3 使用ORM/ODM
如果你用Mongoose,它默认就会做类型校验和字段过滤。我个人强烈推荐:
const UserSchema = new mongoose.Schema({
username: { type: String, required: true },
password: { type: String, required: true },
role: { type: String, enum: ['user', 'admin'] }
});
const User = mongoose.model('User', UserSchema);
// Mongoose会自动过滤掉schema中不存在的字段
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// 这里即使传入$ne,Mongoose也会忽略
const user = await User.findOne({ username, password });
});
4.5 避坑指南
我曾经犯过一个错误,在这里分享给大家:
有一次,我在一个搜索功能里用了$regex来实现模糊匹配。虽然用了mongo-sanitize,但我在代码里手动把用户输入拼到了正则里:
// 错误的做法
const keyword = sanitize(req.query.keyword);
const query = {
name: { $regex: keyword } // 这里手动使用了$regex
};
结果攻击者传入了一个超长的正则表达式,直接导致MongoDB CPU飙升到100%。
正确的做法是:如果必须用$regex,一定要限制输入长度,并且对特殊字符做转义:
// 安全的做法
const keyword = sanitize(req.query.keyword);
// 限制长度
if (keyword.length > 50) {
return res.status(400).send('关键词太长');
}
// 转义正则特殊字符
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const query = {
name: { $regex: escaped, $options: 'i' }
};
4.6 总结
NoSQL注入不是什么高深的技术,但它确实能造成严重的后果。我见过太多项目,因为觉得「NoSQL很安全」就放松了警惕。
记住三点:
- 用mongo-sanitize——这是最基础的防线
- 加类型校验——别让数字变成对象,别让字符串变成操作符
- 用ODM层——Mongoose这类工具能帮你挡住很多坑
嗯,这一章就到这里。下一章我们聊聊XSS攻击,那又是一个让人头疼的话题。