1. 从SQL到NoSQL:注入攻击的“新瓶旧酒”

大家好,我是老张,在AI和大数据领域摸爬滚打了十几年,亲眼看着技术栈从传统的关系型数据库一路演进到现在的NoSQL。很多刚入行的朋友一听到“注入攻击”,脑子里蹦出来的第一个词肯定是“SQL注入”。没错,这玩意儿是OWASP Top 10榜单上的常客,稳坐头把交椅好多年,原理大家也熟:攻击者把恶意代码“注入”到你的SQL查询语句里,让数据库执行了不该执行的命令。

但今天咱们要聊的,是它的“兄弟”——NoSQL注入。随着MongoDB、Redis、Cassandra这些NoSQL数据库在互联网公司里遍地开花,攻击者的目光也早就转移过来了。我见过不少团队,以为用了NoSQL,用了JSON查询,就天然安全了,结果在安全审计时被爆出漏洞,一脸懵。其实啊,攻击的本质没变,还是“数据与代码的混淆”,只是战场从规规矩矩的SQL语句,换到了更灵活的JSON查询结构里。

NoSQL注入的危害一点不比SQL注入小。轻则绕过登录验证,像逛自家后花园一样查看数据;重则执行任意代码,把整个数据库拖走,甚至接管服务器。而且,因为NoSQL查询经常和应用程序的代码(比如JavaScript)绑得更紧,一旦出问题,影响面可能更大。所以,无论你是后端开发、运维还是安全工程师,搞懂NoSQL注入的原理和防御,都是必修课。这篇文章,我就结合自己踩过的坑和实战经验,带你彻底弄明白它。

2. NoSQL注入的核心原理:当查询不再是字符串

要理解NoSQL注入,咱们得先放下对SQL注入的固有印象。SQL注入的核心是“字符串拼接”,比如"SELECT * FROM users WHERE name='" + userName + "'",攻击者通过输入admin' --来篡改查询逻辑。NoSQL数据库(以MongoDB为例)的查询方式截然不同,它通常使用类似JSON的BSON结构,或者直接调用特定的查询操作符。

2.1 查询结构的本质差异

在MongoDB里,一个正常的查询是这样的:

db.users.find({ username: 'zhangsan', password: '123456' })

这传递给数据库的是一个结构化的对象(或文档),而不是一个字符串。开发者在代码中可能会这样构建这个对象(以Node.js为例):

const query = {
  username: req.body.username,
  password: req.body.password
};
db.users.find(query);

看起来挺安全,不是吗?没有字符串拼接。问题就出在,HTTP请求参数在解析后,可以直接映射为复杂的对象结构。这就是NoSQL注入的突破口。

2.2 危险的“操作符注入”

MongoDB提供了丰富的查询操作符,如$ne(不等于)、$gt(大于)、$or(或)、$where(执行JavaScript)等。如果应用程序没有对用户输入进行严格的类型检查和过滤,攻击者就可以直接注入这些操作符。

举个真实的例子,一个登录接口的代码如下(PHP):

$data = array(
    'username' => $_POST['username'],
    'password' => $_POST['password']
);
$cursor = $collection->find($data);

正常情况下,用户提交username=admin&password=123456。但攻击者可以提交这样的POST数据:

username[$ne]=&password[$ne]=

PHP会将这个请求解析为:

$data = array(
    'username' => array('$ne' => ''),
    'password' => array('$ne' => '')
);

最终生成的MongoDB查询就变成了:

db.users.find({ username: { $ne: '' }, password: { $ne: '' } })

这个查询的意思是:“找出所有用户名不为空并且密码不为空的用户”。只要数据库里存在任意一个用户,这个查询就会成功返回结果,攻击者就能绕过密码验证,直接登录。我当年审计一个内部系统时就靠这招拿到了第一个入口,当时开发同事还死活不信,直到我把payload演示给他看。

2.3 不仅仅是MongoDB

虽然我们以MongoDB为例,但NoSQL注入的思维适用于许多数据存储。比如:

  • Redis:通过注入换行符等特殊字符,可以拼接多条命令,实现“背负式查询”。
  • CouchDB:其RESTful API如果设计不当,可能遭受类似的参数污染攻击。
  • Elasticsearch:查询DSL同样复杂,不当的过滤会导致数据泄露。

它们的共同点是,查询语言更强大、更灵活,但也意味着攻击面更广。开发者如果还抱着“我不是在用SQL,所以没有注入风险”的想法,那系统离出事就不远了。

3. 五大攻击手法实战拆解

纸上谈兵终觉浅,咱们直接上实战案例,看看攻击者具体有哪些招数。我把常见的NoSQL注入手法归纳为五类,这比单纯看理论要直观得多。

3.1 重言式注入

也叫“永真式注入”,这是最常见、也最容易被利用的一种。上面提到的$ne操作符绕过登录就是典型例子。它的核心是让查询条件永远为真。除了$ne,还有一堆操作符可以用:

  • $gt(大于)和$lt(小于):username[$gt]= 和 password[$gt]=。如果字段是字符串,会按字典序比较,也可能意外匹配到数据。
  • $regex(正则表达式):这个更狠,username[$regex]=.* 可以匹配任意用户名。
  • $exists(存在):username[$exists]=true,直接检查字段是否存在。

我印象最深的一次,是一个内容管理系统的后台列表接口,用了类似{ category: req.query.category }的查询。攻击者传入category[$ne]=,结果把系统里所有待审核的、已删除的、甚至权限更高的内容全给刷出来了。开发者的本意是做个简单的过滤,却成了全库数据泄露的通道。

3.2 联合查询注入

这招是从SQL注入“移植”过来的。在某些早期或不规范的代码中,如果开发者还在手动拼接查询字符串(尽管NoSQL不常见,但仍有发生),就会中招。

假设有一段不安全的Node.js代码(现在不推荐这么写):

const query = `{ username: '${req.body.username}', password: '${req.body.password}' }`;
db.users.find(JSON.parse(query));

攻击者可以输入这样的用户名:

admin' }, { $or: [ {}, { 'a':'a

而密码字段可以任意填,比如' } ], $comment: 'injected' }//。拼接后的查询变成了:

{
  username: 'admin' },
  { $or: [ {}, { 'a':'a',
  password: '' } ],
  $comment: 'injected' }//'
}

这会导致查询逻辑错乱,可能绕过验证。虽然现在主流的驱动都要求使用对象形式传参,大大减少了这类风险,但在一些边缘场景或老旧系统中,依然可能遇到。

3.3 JavaScript注入

这是NoSQL注入里“威力”最大的一种,因为MongoDB支持在查询中执行JavaScript代码(通过$where操作符或eval命令)。如果用户输入被直接拼接到JS代码中,就相当于给了攻击者一个在数据库服务器上执行代码的“后门”。

看这段危险的代码:

// 千万不要这么做!
const username = req.body.username;
db.users.find({
    $where: `function() { return this.username == '${username}' }`
});

攻击者输入的用户名可以是:

'; return true; //

或者更危险的:

'; (function(){var d=new Date(); do{cd=new Date();}while(cd-d<5000); return true; })(); //

前者让查询永远返回真,后者则是一个简单的“睡眠”攻击,会让数据库线程卡住5秒,如果并发多个这样的请求,直接导致DoS。

更可怕的是db.eval命令(虽然新版已废弃),如果被不当调用,攻击者理论上可以执行任何数据库管理命令,比如删库。我强烈建议,在生产环境中彻底禁用$where和类似的功能,除非有极其特殊的业务需求,并且做好严格的输入沙箱隔离。

3.4 背负式查询注入

这种攻击主要针对像Memcached、Redis这样的键值存储,以及它们的一些老旧客户端驱动。攻击的核心是利用协议解析的漏洞,通过注入特殊字符(如回车换行\r\n)来插入额外的命令。

例如,一个PHP Memcached客户端的老版本存在漏洞,攻击者可以这样设置一个键:

key1 0 3600 10\r\nvalue_data\r\nset key2 0 3600 6\r\ninject\r\n

由于驱动未能正确处理换行符,这会被解析为两条命令:先设置key1,再设置key2。如果这个“键”的内容是来自用户输入(比如会话令牌),攻击者就能在缓存中非法插入自己的数据。虽然这类漏洞在较新的驱动中已修复,但它提醒我们:即使是最简单的API,如果信任了未经验证的输入,也会酿成大祸。

3.5 跨域请求伪造攻击

严格来说,这不完全是“注入”,但与NoSQL数据库暴露的REST API强相关。很多NoSQL数据库(或它们的第三方管理工具)提供了HTTP接口。如果这个接口部署在内网,且没有验证请求来源,攻击者就可以构造一个恶意网页。

当已授权用户访问这个网页时,浏览器会自动向数据库的API发送请求(比如POST一个插入管理员用户的请求)。因为请求来自用户的浏览器,且携带了用户的内部网络凭证,攻击就成功了。这种攻击完全绕过了前端的防护,直达数据层。所以,千万不要把数据库的管理接口直接暴露给不可信的网络,即使在内网也要做好严格的访问控制和CSRF防护。

4. 防御实战:从代码到架构的全方位加固

知道了攻击手法,防御就有了方向。我的经验是,安全不能只靠某一个环节,必须贯穿整个开发和运维流程。下面这套组合拳,是我在多个项目里实践过的,亲测有效。

4.1 安全编码的第一道防线:输入验证与类型转换

这是最根本、最有效的方法。原则就是:不要相信任何来自客户端的输入。

  • 严格类型转换:在构建查询对象前,强制将用户输入转换为期望的类型。比如,用户名应该是字符串,就明确转换。
    // Node.js 示例
    const username = String(req.body.username || '');
    const query = { username: username };
    // 或者更严格地,只允许特定字符
    if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
        throw new Error('Invalid username');
    }
    
  • 使用安全的查询构建器:尽量使用ORM(如Mongoose)或ODM提供的方法,它们通常有内置的防护机制。Mongoose会对查询进行模式校验,无效的操作符会被过滤掉。
    // 使用 Mongoose,直接传入对象是安全的
    User.findOne({ username: req.body.username }); // Mongoose会处理类型
    
  • 创建操作符白名单:如果业务复杂,必须动态使用操作符,那么务必建立一个允许使用的操作符白名单,并在应用层进行过滤。
    const allowedOperators = ['$eq', '$gt', '$lt', '$in', '$regex'];
    function sanitizeQuery(inputQuery) {
        const cleanQuery = {};
        for (const [key, value] of Object.entries(inputQuery)) {
            if (allowedOperators.includes(key)) {
                cleanQuery[key] = value;
            }
            // 否则,丢弃或记录日志
        }
        return cleanQuery;
    }
    

4.2 禁用或严格限制危险功能

有些功能天生风险就高,最好的防御就是不用。

  • 禁用$where和eval:在MongoDB配置和代码审查中,明确禁止使用$where操作符和db.eval()命令。99%的业务场景都不需要它们。如果确需使用,必须确保传入的JavaScript字符串是静态的、预先定义好的,或者来自绝对可信的内部源,绝不直接拼接用户输入。
  • 谨慎使用Map-Reduce和Aggregation:这些强大的功能也可能成为注入点,确保传递给它们的参数是经过严格验证的。

4.3 实施最小权限原则

这是被很多人忽视,但极其重要的一环。即使注入发生了,我们也要把损失降到最低。

  • 数据库用户权限分离:为Web应用创建专用的数据库用户,只授予它完成业务所必需的最小权限。比如,一个只读的报表应用,就只给read权限;一个用户服务,可能只需要对users集合的find、insert、update权限,绝对不要给dropDatabase、eval这种高危权限。
  • 使用角色-Based Access Control:MongoDB支持细粒度的角色授权。好好利用它,别用一个root账号走天下。

4.4 网络与架构层隔离

代码层面的防护很重要,但架构层面的纵深防御能兜住底。

  • 网络隔离:将数据库部署在独立的私有子网中,只允许特定的应用服务器通过特定端口访问。使用安全组或防火墙规则严格限制源IP。
  • 关闭不必要的服务:MongoDB默认监听27017端口,不要将数据库的REST接口(如果存在)暴露在公网。如果使用云服务,充分利用其提供的VPC、私有链接等网络隔离功能。
  • API网关与校验:如果确实需要提供数据库的HTTP API,务必通过一个API网关,在网关层实施严格的请求校验、频率限制和身份认证。

4.5 安全工具与持续监控

安全是一个持续的过程,需要工具辅助。

  • SAST/DAST工具:在CI/CD流水线中集成静态应用安全测试和动态应用安全测试工具。虽然传统的工具对NoSQL注入规则支持可能不完善,但像Checkmarx、Fortify等主流工具已经在不断更新规则库。至少,它们能帮你发现代码中明显的输入拼接问题。
  • 日志与监控:启用数据库的详细审计日志,记录所有查询操作,尤其是异常查询(比如使用了$where、eval)。配合SIEM系统,设置告警规则,例如:短时间内出现大量$ne操作符的查询,可能意味着正在遭受自动化攻击。
  • 依赖库检查:定期使用npm audit或OWASP Dependency-Check等工具检查项目依赖的第三方库(包括数据库驱动)是否存在已知漏洞。及时更新到安全版本。

5. 实战演练:构建一个安全的Node.js + MongoDB应用

光说不练假把式,咱们最后用一个简化的用户登录场景,把上面的防御措施串起来,写一段相对安全的代码。

假设我们有一个基于Express和Mongoose的简单登录接口。

第一步:定义严格的Mongoose模式

const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
    username: {
        type: String,
        required: true,
        unique: true,
        match: /^[a-zA-Z0-9_]{3,20}$/, // 白名单正则,只允许字母数字下划线
        trim: true
    },
    passwordHash: {
        type: String,
        required: true
    },
    role: {
        type: String,
        enum: ['user', 'admin'], // 枚举,防止无效值
        default: 'user'
    }
}, { timestamps: true });
const User = mongoose.model('User', userSchema);

第二步:实现安全的登录控制器

const express = require('express');
const router = express.Router();
const bcrypt = require('bcrypt');

router.post('/login', async (req, res) => {
    try {
        // 1. 基础验证和类型转换
        const username = String(req.body.username || '').trim();
        const password = String(req.body.password || '');

        if (!username || !password) {
            return res.status(400).json({ error: '用户名和密码必填' });
        }

        // 2. 使用Mongoose进行查询,它会对查询对象进行模式校验
        // 直接传递字符串是安全的,Mongoose会将其视为精确匹配
        const user = await User.findOne({ username: username });

        if (!user) {
            // 使用通用错误信息,防止用户名枚举攻击
            return res.status(401).json({ error: '用户名或密码错误' });
        }

        // 3. 安全地比较密码哈希
        const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
        if (!isPasswordValid) {
            return res.status(401).json({ error: '用户名或密码错误' });
        }

        // 4. 登录成功,生成会话令牌(此处简化)
        const token = generateSecureToken(user);
        res.json({ token, role: user.role });

    } catch (error) {
        // 5. 记录详细的服务器错误,但返回给用户通用信息
        console.error('登录错误:', error);
        res.status(500).json({ error: '内部服务器错误' });
    }
});

第三步:全局错误处理和输入净化中间件

// 一个简单的中间件,防止JSON查询对象被污染
app.use((req, res, next) => {
    // 深度净化body和query中的对象,移除以$开头的键(危险操作符)
    const sanitize = (obj) => {
        if (obj && typeof obj === 'object') {
            for (const key in obj) {
                if (key.startsWith('$')) {
                    delete obj[key]; // 或者可以抛出错误
                } else if (typeof obj[key] === 'object') {
                    sanitize(obj[key]);
                }
            }
        }
        return obj;
    };
    req.body = sanitize(req.body);
    req.query = sanitize(req.query);
    next();
});

第四步:基础设施配置

  • Docker Compose:确保MongoDB服务不暴露公网IP。
    version: '3'
    services:
      mongo:
        image: mongo:latest
        container_name: myapp-mongo
        environment:
          MONGO_INITDB_ROOT_USERNAME: root
          MONGO_INITDB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
        ports:
          - "127.0.0.1:27017:27017" # 只绑定到本地回环地址
        volumes:
          - mongo-data:/data/db
        command: [--auth, --bind_ip_all] # 启用认证,谨慎绑定
    
  • MongoDB用户创建:在初始化脚本中,为应用创建低权限用户。
    // init-mongo.js
    db.createUser({
        user: "app_user",
        pwd: "strong_app_password",
        roles: [
            { role: "readWrite", db: "myapp" }
        ]
    });
    

这套组合拳下来,你的应用对NoSQL注入的防御能力会大大增强。记住,安全没有银弹,靠的是每一个细节的严谨把控。在实际项目中,还要结合WAF、定期渗透测试、安全培训等手段,才能构建起真正稳固的防线。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐