お疲れ。最近、多くの現場で「うちはRDBじゃなくてMongoDBを使っているからSQLインジェクションは関係ないね」という楽観的な声を耳にするが、それはセキュリティエンジニアとして一番冷や汗をかく瞬間だ。
SQLが通らないからといって、アプリケーションが安全だとは限らない。NoSQLにはNoSQL特有の、そしてSQLインジェクションよりも遥かに直感的で破壊力のある悪夢が潜んでいる。それが NoSQLインジェクション(NoSQLi) だ。
今日は、MongoDBを代表とするドキュメント指向データベースのクエリ構造の盲点を突き、認証バイパスやデータ窃取を引き起こすメカニズムと、それを実務で完全に叩き潰すためのセキュアコーディングを、俺の経験を交えて徹底的に解説する。後輩のコードレビューや、自身のシステムの総点検に役立ててほしい。
—
1. なぜNoSQLインジェクションが起きるのか?(クエリ構造の悪用)
従来のSQLインジェクションは、文字列結合によってSQLの文法を書き換える攻撃だった。しかし、NoSQL(特にMongoDB)におけるインジェクションの本質は、「ユーザーからの入力値として、文字列ではなく『JSONオブジェクト(クエリ演算子)』を注入すること」にある。
MongoDBのクエリは、BSON(実質的なJSON)の形式で構築される。例えば、ログインフォームでユーザー名とパスワードを受け取る以下の処理を考えてみてほしい。
// 脆弱なNode.js/Expressのコード例
const username = req.body.username;
const password = req.body.password;
// 入力値をそのままオブジェクトのプロパティとしてクエリに組み込んでいる
db.collection('users').findOne({
username: username,
password: password
}, (err, user) => {
// 認証処理...
});
一見すると、usernameとpasswordは単なる変数に見える。しかし、攻撃者はHTTPリクエストのパラメータに、単なる文字列ではなくJSONオブジェクトや配列を送り込む。
悪用のメカニズム:演算子注入(Operator Injection)
ここで攻撃者が、パスワードフィールドに {"$ne": null} というオブジェクト(またはURLエンコードされたクエリパラメータ password[$ne]=null)を送り込んだとする。
アプリケーションがこれをそのままMongoDBに渡すと、実際に実行されるクエリはこう変貌する。
{
username: "admin",
password: { "$ne": null } // 「nullではない」というMongoDBの比較演算子
}
$ne は Not Equal を意味するMongoDBのクエリ演算子だ。この結果、データベースは「ユーザー名が admin で、パスワードが null ではない最初のユーザー」を返してしまう。データベースに登録されているユーザーのパスワードが null であるはずがないため、この条件は常に真(True)となり、パスワードを知るすべもなく認証が完全にバイパスされる。これがNoSQLインジェクションの恐ろしさだ。
—
2. 実践:攻撃のPoCとリスクの深刻度
実際のペネトレーションテストやレッドチームの演習では、この脆弱性を突いてデータストア全体を丸裸にする。
例えば、パスワードリセット機能や検索機能で、正規表現演算子である $regex が注入できた場合を想像してほしい。
{
"username": "admin",
"password": { "$regex": "^P" }
}
もしこれでログイン成功やエラーの有無(ブラインドNoSQLi)が判別できるなら、攻撃者は1文字ずつ正規表現を総当たりさせ、adminのパスワードハッシュや機密情報を数分で完全に抽出することができる。RDBの盲点を突くのと同様、いや、それ以上に「クエリがそのまま言語のデータ構造に直結している」というNoSQLの特性が、攻撃者にとって最高の武器になってしまうのだ。
—
3. 【完全防御】セキュアな実装サンプルコード
この脆弱性を防ぐための鉄則はシンプルだ。
1. 入力値の型を厳格に検証する(プリミティブ型以外を受け入れない)
2. ODM(Object Document Mapper)や適切なライブラリを正しく利用し、クエリの意図しない構造化を防ぐ
ここでは、現場で即座に使えるNode.js(Express + Mongoose)およびPython(Flask + PyMongo)でのセキュアな実装サンプルを示す。
A. Node.js (Express + Mongoose) の場合
リクエストボディとしてオブジェクトや配列が混入することを防ぐため、型を厳密にチェックし、Mongooseのスキーマバリデーションを強制する。
const express = require('express');
const mongoose = require('mongoose');
const app = express();
// JSONボディパーサーの利用
app.use(express.json());
// 厳格なスキーマ定義
const userSchema = new mongoose.Schema({
username: { type: String, required: true },
password: { type: String, required: true }
});
const User = mongoose.model('User', userSchema);
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
// 【重要】入力値が確実に「文字列(String)」であることを強制する
// これにより、攻撃者が {"$ne": null} などのオブジェクトを送り込んでも弾かれる
if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: '不正な入力値の型です。' });
}
// Mongooseモデルを通じた安全なクエリ検索
// スキーマ定義にない型や不正な演算子はMongoose層でサニタイズ・無視される
const user = await User.findOne({
username: username,
password: password
});
if (!user) {
return res.status(401).json({ error: '認証に失敗しました。' });
}
return res.json({ message: 'ログイン成功', userId: user._id });
} : (err) => {
console.error(err);
return res.status(500500).json({ error: '内部サーバーエラー' });
}
});
B. Python (Flask + PyMongo) の場合
PythonでMongoDBを叩く際も同様だ。flask や marshmallow などのバリデーションライブラリを挟むか、自前で厳密な型チェックを行うこと。
from flask import Flask, request, jsonify
from pymongo import MongoClient
app = Flask(__name__)
client = MongoClient('mongodb://localhost:27017/')
db = client['secure_db']
users_collection = db['users']
@app.route('/api/login', methods=['POST'])
def login():
data = request.get_json()
if not data:
return jsonify({"error": "リクエストボディが空です。"}), 400
username = data.get('username')
password = data.get('password')
# 【重要】入力値がPythonの文字列型(str)であることを厳格に検証
# リクエストで辞書型(dict)などが渡された場合、ここで確実に弾く
if not isinstance(username, str) or not isinstance(password, str):
return jsonify({"error": "不正なパラメータ形式です。"}), 400
# PyMongoを使ったクエリ実行
# 入力値は純粋な文字列保証されているため、演算子インジェクションは発生しない
user = users_collection.find_one({
"username": username,
"password": password
})
if not user:
return jsonify({"error": "認証失敗"}), 401
return jsonify({"message": "ログイン成功", "username": user["username"]}), 200
if __name__ == '__main__':
app.run(port=5000)
—
4. インフラ層・アプリケーション層での追加の防御レイヤー
コードレベルでのバリデーションはもちろんマストだが、ディフェンス・イン・デプス(多層防御)の観点から、以下の設定も怠ってはならない。
- WAF(Webアプリケーションファイアウォール)の活用:
リクエストのJSONボディ内に $ne や $gt, $regex といったMongoDBのクエリ演算子文字列、あるいはURLパラメータに [$ や _id といった不審なパターンが含まれていないかを検知・ブロックするシグネチャを有効化する。
- データベース権限の最小化:
アプリケーションがMongoDBに接続する際のアカウントには、必要最小限の権限(readWriteなど)のみを付与し、管理者権限(dbAdminやroot)を持った接続文字列をアプリケーションから絶対に露出させないこと。万が一インジェクションが成功した際の被害を最小限に抑えるためだ。
—
チームへの申し送り事項
「うちはフレームワークを使っているから大丈夫」「NoSQLだからSQLインジェクションは起きない」という油断こそが、最大の脆弱性だ。
動的言語の特性やNoSQLの柔軟性は、開発スピードを上げる一方で、型チェックを怠ると一瞬でセキュリティの穴に変わる。今日紹介したコードのように、「外部から受け取った値の型をプログラムの境界で必ず検証する」という基本中の基本を、チーム全体のコードレビューの共通認識として徹底してほしい。頼んだぞ。
コメント