第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用户存在,密码随便是什么都能匹配上。

⚠️ 这就是NoSQL注入的可怕之处——攻击者可以利用MongoDB的操作符($ne、$gt、$regex等)来篡改查询逻辑。

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",不再具有操作符功能。

💡 小技巧:我建议在全局中间件中使用mongo-sanitize,而不是在每个路由里单独调用。这样可以避免遗漏。

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 });
});
🔑 核心原则:永远不要信任用户输入。对MongoDB来说,这意味着:1)剥离操作符 2)校验字段类型 3)限制可查询字段 4)使用ODM层做二次过滤。

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攻击,那又是一个让人头疼的话题。