Skip to main content

SQLインジェクション対策:ORMだけでは不十分

Fixing SQL Injection ORM is not enough tumb

2016年6月8日

0 分で読めます

最も危険で広く見られる脆弱性の一つがSQLインジェクションです。攻撃者にバックエンドデータベースへのアクセスを許してしまいます。プリペアドステートメントやオブジェクト関係マッピング(ORM)を使うことは、SQLインジェクション対策として有効ですが、それだけでは不十分です。この記事で紹介するように、SequelizeやMySQLなどのORMパッケージにも欠陥が存在することがあり、システムが危険にさらされる可能性があります。本当に保護するには、さらに対策が必要です。

2019年の「State of Open Source Security Report」では、SQLインジェクションの脆弱性が依然としてよくあるセキュリティ上の懸念事項であり、PHP Packagistリポジトリのライブラリで最大16件の脆弱性が見つかったことが分かりました。

要約

SQLインジェクションの脆弱性(略してSQLi)とは、攻撃者が構造化されていないテキストをSQLコマンドに追加(「注入」)し、意図しない結果を引き起こせる脆弱性です。SQLインジェクションを防ぐ方法の一つはORMパッケージを使うことです。ORMはオブジェクトとその操作をSQLに変換します。こうしたパッケージを使えば、SQLを直接組み立てる必要がないため、悪意あるインジェクションを許すミスも起こりません。すばらしいですね!

ただし、自分がSQLを組み立てていないからといって、SQLが組み立てられていないわけではないことを忘れがちです。実際、npmのORMパッケージは、こうした操作をSQLに変換する必要があります。これらのパッケージもほかのパッケージと同じソフトウェアであり、ソフトウェアにはバグが潜む可能性があります。過去1年間に、npmで人気の高い2つのORMパッケージ、sequelizeとnode-mysqlで、4件のSQLインジェクション脆弱性が報告され、この懸念は現実のものとなりました。これらの問題は最新バージョンで修正されていますが、古いバージョンを使っている可能性があり、新たな脆弱性が見つかることもあります。

この記事では、こうした欠陥の例をいくつか紹介し、適用すべき追加の防御レイヤーについて説明します。主な対策は次のとおりです。

問題のシナリオを詳しく見る前に、まずSQLインジェクションとは何か、どのような対策があるのか、そしてORMとは何かを簡単におさらいしましょう。

SQLインジェクションの簡単な例

検索機能を備えたタスクリストアプリを想像してください。指定したテキストを含む項目を検索します。検索はsequelizeパッケージを使ったDBクエリで実行されます。DB接続がすでに設定されているとして、バックエンドの検索関数は次のようになります。

function findItems(req, resp)
{
  try {
    // Find the relevant items
    sequelize.query(
      "SELECT Desc FROM Items WHERE Desc like ('%" + req.params.snippet + "%')",
      { type: sequelize.QueryTypes.SELECT}
    ) // Retrieve results
    .spread(function(results, metadata) {
        // Add results to response
     });
  } catch {
    // Handle error
  }
}

正当な利用では、ユーザーはBuyなどの単純な値を入力します。すると、次のような正当なクエリに変換され、リスト内の該当する買い物タスクがすべて返されます。

SELECT Desc FROM Items WHERE Desc like ('%Buy%')

しかし、攻撃者はシングルクォート(')を意図的に入力し、想定された文字列の範囲を抜けてクエリに入り込もうとすることがあります。たとえば、攻撃者が') UNION SELECT username||'_'||password FROM Users --という値を入力すると、次のクエリが生成されます。

SELECT Desc FROM Items WHERE Desc like ('%') UNION SELECT username||'_'||password FROM Users -- %')

Usersテーブルにpasswordフィールドとusernameフィールドがあるとすると、上記のクエリによって、ユーザー名とパスワードの一覧全体がTODO項目のリストに追加されます。残りのテキストは、SQLクエリを壊さないように--コマンドで簡単にコメントアウトできます。

この脆弱な実装は特に単純に見えるかもしれませんが、同様のパターンは非常によく見られます。いずれの場合も、未検証のユーザー入力が何らかの形で生のSQLクエリに追加され、元の文脈(文字列など)から抜け出して、想定外の操作を実行します。一方、ここで示した攻撃はかなり単純で的確でしたが、実際の攻撃者はさまざまな攻撃パターンを次々に試し、そのうち一つでも成功すれば目的を達成します。

影響と対策

SQLインジェクションは非常に深刻な脆弱性です。ほとんどの場合、WebサイトのどこかにあるSQLインジェクションを足がかりに、最終的にはDB上の任意のクエリを実行し、データを抽出・改ざんされる可能性があります。DBにはシステム内で最も機密性の高い情報が保管されていることが多く、攻撃者にアクセスを許せば甚大な被害につながります。

SQLインジェクションを防ぐ主な方法は、相互に排他的ではない次の2つです。入力検証とプリペアドステートメント。

入力検証

ほかのインジェクション攻撃と同様に、SQLインジェクションも悪意あるユーザー入力から始まります。そのため、ユーザーが入力した値が有効かどうかを確認することが効果的な対策です。有効でなければ、処理全体を失敗させるか、危険な可能性のある文字を取り除きます。

入力検証には、ネガティブセキュリティモデルとポジティブセキュリティモデルがあります。

ネガティブセキュリティモデルでは、特定の危険な文字やパターンを禁止します。たとえば上の例では、文字列の範囲から抜け出す原因となったシングルクォートを禁止できます。しかしSQLは複雑なため、危険な可能性のある文字をすべて特定するのは困難です。この例でいえば、バックスペース(b)、バックスラッシュ(\)、ヌル(x00)などもブロックする必要があり、複数のDBタイプに対応するなら、対象となる文字はさらに増えるでしょう。それでも、危険な文字をブロックする方法は、比較的効果的でシンプルな緩和策です。

ポジティブセキュリティモデルでは、特定の文字だけを許可します。上の例では、入力を英字、数字、スペース(/a-zA-Z0-9 /)に限定することで、リスクに効果的に対処できます。この方法はホワイトリスト方式とも呼ばれ、想定外の値による問題を避けられるため、通常はセキュリティ面で優れています。ただし、特にUnicode文字セットへの対応を広げる場合、正当な文字までブロックしてしまう可能性が高くなります。

プリペアドステートメントとORM

SQLインジェクション攻撃が入力から始まり、SQLクエリで実行されるなら、クエリ側にも問題を防ぐ機会があります。ここでの根本的な問題は、SQLクエリの作成に単純な文字列連結を使っていることです。代わりにクエリのテンプレートを使えば、DB(または接続ライブラリ)に値が文字列であることを伝え、必要に応じて文字列をエンコードしてもらえます。これが、この記事の冒頭で触れた解決策です。

こうしたテンプレートは一般に「プリペアドステートメント」、または「パラメーター化クエリ」と呼ばれます。上で使用したsequelizeパッケージもサポートしているため、関数を次のように変更すれば脆弱性を修正できます。

function findItems(req, resp)
{
  try {
    // Find the relevant items
    sequelize.query(
      "SELECT Desc FROM Items WHERE Desc like ?",
      { replacements: ['%'+req.params.snippet+'%'],
        type: sequelize.QueryTypes.SELECT }
    ) // Retrieve results
    .spread(function(results, metadata) {
        // Add results to response
     });
  } catch {
    // Handle error
  }
}

Sequelizeは?がSQLコマンドではなく値を表すことを認識し、適切にエンコードするため、文字列の範囲を抜け出す攻撃をブロックできます。

プリペアドステートメントは、コードを安全にするだけでなく、読みやすく、保守しやすくする方法でもあります。つまり、SQL文に値を連結したくなったら、その衝動を抑えてプリペアドステートメントを使いましょう。

さらにプログラム的にSQLを扱う方法として、オブジェクト関係マッピング(ORM)があります。ORMを使うと、DBテーブルをオブジェクトにマッピングし、オブジェクト全体の読み取り、書き込み、クエリを実行できます。明示的なSQLの使用をさらに減らせるため、ORMもSQLインジェクションを防ぐ有効な方法です。

ORMに脆弱性がある場合:Sequelize

プリペアドステートメントとORMは、エンコード処理を専門のパッケージに任せる有効な方法です。しかし、専門家だからといってバグがないわけではありません……冒頭で触れたように、昨年、npmで人気の高い2つのORMパッケージ、sequelizeとnode-mysqlで4件のSQLインジェクション脆弱性が確認されました。

これらの脆弱性は、さまざまなORMやプリペアドステートメントの呼び出しで、パラメーターが検証されていなかったことが原因でした。ORMやプリペアドステートメントの関数も、最終的にはパラメーターをSQL文に変換する必要があり、その際にエスケープや検証を忘れる可能性があります。

何が起きたのかを詳しく理解するために、sequelizeの脆弱性をいくつか確認しましょう。これらの脆弱性はすでに修正されており、問題が報告・登録されるとSequelizeの開発者は迅速に対応したことに留意してください。Sequelizeの脆弱性一覧と修正方法は、SnykのVulnerability DBで確認できます。

最初の脆弱性は2016年1月に開示され、バージョン3.17.0で修正されました。ORMを使ってDBからオブジェクトを検索する際によく使われるfindAll関数に影響するものでした。パラメーターをSQLに変換するときに、関数がLIMITパラメーターの値を制限していなかったため、SQLインジェクションの脆弱性につながりました。

TODO項目のリストで、この脆弱性がどのように悪用されるかを示します。

models.Items.findAll({
  limit: '1; DELETE FROM Items WHERE 1=1; --',
}).then(function (users) {
  console.log(users);
});

ItemsにUsernameフィールドとDescフィールドがあるとすると、生成されるクエリはおおよそ次のようになります。

SELECT Username,Desc FROM Items LIMIT 1; DELETE FROM Items WHERE 1=1; --

これと同様の見落としが、2016年3月下旬に開示されました。今度の欠陥は、プリペアドステートメントの作成時にIN文の値を連結していたことにありました。攻撃の例を示します。

db.query('SELECT Desc FROM Items WHERE Username IN (:names)', {
  replacements: {
    names: ["Bobby", "'); DELETE FROM Items WHERE 1=1; --')"]
  }
});

これらの脆弱性は、発見されてみればそれほど複雑なものではありませんが、人気の高いパッケージであっても万全ではないことを示しています。SQLは複雑で、特殊なケースを見落としやすいのです。

解決策:多層防御

まだ明確でなければ、はっきりお伝えします。ORMとプリペアドステートメントは必ず使ってください。これらはSQLインジェクションのリスクの大部分を取り除き、一般的にも優れたソフトウェア開発手法です。ただし、こうしたパッケージを使えば完全に安全だと考えてはいけません。

そのうえで、入力検証も必ず実施してください。悪意ある入力がそもそもシステムに入るのを防ぎ、リスクを減らす有効な方法です。このように複数の防御策を適用することを「多層防御」と呼ぶことがあり、とても効果的です。攻撃が一つ目の防御レイヤー(入力検証)を突破しても、二つ目のレイヤーでブロックできる可能性があります。各レイヤーを通過する攻撃が1%だとしても、2つのレイヤーで攻撃の99.99%をブロックできます。効果は明らかです。

さらに、使用しているパッケージの既知の脆弱性を把握し、見つかったらすぐに修正してください。Snykを無料で利用して、アプリケーションに上記の脆弱性がないかテストできます。また、脆弱性テストを開発プロセスに組み込むことで、脆弱性のない状態を維持しやすくなります。

開発者に愛され、セキュリティチームから信頼される。

Snykの開発者ファーストのツールは、ガバナンスやコンプライアンスのニーズに応える、統合された自動化セキュリティを提供します。