【実務・中級編】 NoSQLインジェクションの仕組みと防御 – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

おい、みんな。席についてくれ。
最近のペネトレーションテストやインシデント調査で、一番何が怖いかって知ってるか? 「うちはリレーショナルデータベース(RDB)じゃなくて、今風のMongoDBやRedisを使っているからSQLインジェクションは関係ない」なんて、お花畑な勘違いをしている開発チームの多さだ。

NoSQLは柔軟でスケーラブルだし、スキーマレスだからアジャイル開発には持ってこいだ。だがな、その「柔軟さ」こそが、攻撃者にとっての最高の踏み台になる。今回は、RDBの常識が通用しない世界、NoSQLインジェクション(NoSQLi)の泥臭い実態と、それを現場で完全に叩き潰すための実装アプローチを解説していく。

—

1. なぜNoSQLインジェクションはRDBのSQLiより凶悪なのか

SQLインジェクションであれば、脆弱な箇所に ' OR '1'='1 のような文字列を叩き込み、構文エラーを引き起こしたり、UNION句でデータを引っ張り出したりするのが基本だ。

しかし、MongoDBなどのNoSQL(ドキュメント指向DB)が内部で処理しているのは、文字列ではなくJSON(BSON)オブジェクトそのものだ。
例えば、ログインフォームのパスワード検証を考えてみよう。脆弱なコードは、ユーザーから送られてきた入力をそのままクエリの条件に組み込んでしまう。

ここで攻撃者は、単なる「文字列」ではなく、MongoDBの比較演算子($gt、$ne、$regexなど)を含んだ連想配列(JSON)をリクエストとして送り込む。
例えば、password フィールドに {"$ne": "invalid_password"} というJSONを流し込まれたとする。DB側のクエリは「指定したパスワードと一致しない最初のレコードを返す」という意味になり、結果として認証 bypass がいとも簡単に成立してしまうのだ。

RDBのSQLiが「文法の解釈の隙をつくもの」だとすれば、NoSQLiは「アプリケーションが信頼して受け入れた構造化データそのものが、クエリのロジックを書き換えてしまうもの」と言える。この違いを理解していないと、いくらプレースホルダーを意識していても足元をすくわれる。

—

2. 【攻撃者視点】実録・NoSQLインジェクションのPoC

百聞は一見にしかずだ。オフェンシブエンジニアが現場の監査でどうやってシステムをハックするか、その手口を覗いてみよう。

以下は、脆弱なNode.js(Express + MongoDB)のログインAPIのイメージだ。

// 【脆弱な実装例】絶対に真似してはいけないコード
app.post('/api/login', async (req, res) => {
    try {
        // リクエストボディをそのままクエリに突っ込んでいる最悪のパターン
        const user = await db.collection('users').findOne({
            username: req.body.username,
            password: req.body.password
        });

        if (!user) {
            return res.status(401).json({ error: '認証失敗' });
        }
        res.json({ success: true, message: 'ログイン成功', token: generateToken(user) });
    } catch (err) {
        res.status(500).json({ error: 'サーバーエラー' });
    }
});

このコードに対し、攻撃者はJSONの型操作(Type Juggling)を利用した攻撃リクエストを投げる。Content-Typeを application/json にし、以下のようなペイロードを送信する。

{
    "username": "admin",
    "password": {
        "$gt": ""
    }
}

$gt は 「Greater Than(より大きい)」を意味するMongoDBの演算子だ。パスワードが空文字 "" より大きいユーザーは実質的に全員該当するため、DBは管理者である admin のドキュメントをホイホイと返してしまう。攻撃者はパスワードのハッシュ値すら知る必要がない。これがNoSQLiの恐怖だ。

—

3. 現場で使える完全防御策:入力の型制限とクエリの抽象化

では、この理不尽な攻撃をどう防ぐか。
防御の基本方針は明確だ。「ユーザーからの入力を、決してそのままクエリのオブジェクトや演算子として解釈させないこと」。

具体的には以下の3ステップを徹底する。
1. 厳格な型バリデーション(Type Hinting / Schema Validation): 入力が想定された型(文字列や数値など)であるかを強制する。オブジェクトや配列が紛れ込んでいたら即座に弾く。
2. クエリの抽象化とパラメータの明示的バインド: データの値をクエリ構造から完全に切り離す。
3. ORM / ODMの適切な利用: MongooseなどのODMを使用する場合でも、スキーマ定義をサボらず、かつ不安全なクエリ構築メソッド($where や生クエリの直値埋め込み)を封印する。

セキュアな実装サンプル(Node.js / Express + Mongoose)

それでは、先ほどの脆弱なログインAPIを、堅牢にリファクタリングしたコードを見てみよう。ここでは express-validator を使って入力値が「文字列(String)」であることを強制し、かつMongooseのスキーマでデータ型を厳格に縛っている。

const express = require('express');
const { body, validationResult } = require('express-validator');
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const router = express.Router();

// 1. Mongooseのスキーマ定義で型を厳格に縛る
const userSchema = new mongoose.Schema({
    username: { type: String, required: true, unique: true },
    passwordHash: { type: String, required: true }
});
const User = mongoose.model('User', userSchema);

// 2. バリデーションルールを定義したセキュアなエンドポイント
router.post('/api/secure-login', [
    // usernameとpasswordが確実に「プリミティブな文字列」であることを検証する
    // 配列やオブジェクト(MongoDB演算子など)が混入した場合はバリデーションエラーにする
    body('username').isString().trim().notEmpty(),
    body('password').isString().notEmpty()
], async (req, res) => {
    
    // バリデーションエラーのチェック
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        // 攻撃者へのヒントを与えないため、詳細なエラー理由は返さず汎用的なメッセージにする
        return res.status(400).json({ error: '入力値が不正です。' });
    }

    const { username, password } = req.body;

    try {
        // クエリにはプリミティブな値のみが渡るため、$gtなどの演算子は機能しない
        const user = await User.findOne({ username: username });
        if (!user) {
            // タイミング攻撃対策のため、ユーザーが存在しない場合でもハッシュ検証のふりをするか、
            // 共通のエラーメッセージを返す
            return res.status(401).json({ error: '認証に失敗しました。' });
        }

        // パスワードのハッシュ照合(bcryptを使用)
        const isMatch = await bcrypt.compare(password, user.passwordHash);
        if (!isMatch) {
            return res.status(401).json({ error: '認証に失敗しました。' });
        }

        // 認証成功時の処理
        res.json({ success: true, message: 'ログイン成功' });

    } catch (err) {
        // 内部エラーの詳細を外部に出さない
        console.error('Login Error:', err.message);
        res.status(500).json({ error: '内部サーバーエラーが発生しました。' });
    }
});

module.exports = router;

このコードのポイントは、req.body.password がたとえ { "$gt": "" } というオブジェクトであっても、express-validator の isString() によって弾かれるか、あるいはMongoose側で文字列としてキャスト(または無視)されるため、意図しないクエリの拡張が物理的に不可能になる点だ。

—

4. アプリケーション層以外での多層防御(WAFとネットワーク制御)

アプリのコードを書くだけがセキュリティエンジニアの仕事じゃない。万が一、開発者がやらかして脆弱なコードをデプロイしてしまったときの「最後の砦(セーフティネット)」についても触れておこう。

1. WAF(Web Application Firewall)によるクエリ監視

MongoDBなどを狙うNoSQLiのペイロードには、特徴的なパターン($where, $gt, $ne, $regex などの演算子や、JavaScriptコード片)が含まれることが多い。
CloudflareやAWS WAF、ModSecurityなどのルールセットを利用し、リクエストボディ内にJSONとして隠された不審な演算子キーが含まれていないかを検査・ブロックする設定を必ず有効化しておこう。

2. データベース接続の権限最小化(Least Privilege)

もし万が一、アプリケーションがNoSQLiに屈したとしても、被害を最小限に抑えるための鉄則が「データベース権限の最小化」だ。
WebアプリケーションからMongoDBに接続する際、管理者権限(root や userAdminAnyDatabase)を持ったコネクションをそのまま使っていないか?
該当するWebアプリがアクセスするデータベースに対してのみ、読み書き(readWrite)権限を持つ専用のユーザーアカウントを発行し、システム全体の破壊や他のデータベースへの横展開(Lateral Movement)を許さない設計にすること。これインフラ運用の基本中の基本だからな。

—

最後に:セキュリティは「思い込み」を捨てたところから始まる

「ウチはNoSQLだから大丈夫」なんて言っているエンジニアがいたら、今日学んだ手口を教えてやってくれ。技術が新なかろうが古なかろうが、「ユーザーからの入力を信用するな」「データの型と構造を厳格に検証しろ」というセキュリティの鉄則は1ミリも変わらない。

システムを守る主役は、いつだって最前線にいる俺たち開発者・インフラエンジニアだ。設計の段階からセキュリティを織り込み、攻撃者に付け入る隙を一切与えない強靭なシステムを組み上げていこうぜ。頼んだぞ!

コメント

タイトルとURLをコピーしました