本サイトは、快適にご利用いただくためにクッキー(Cookie)を使用しております。
Cookieの使用に同意いただける場合は「同意する」ボタンを押してください。
なお本サイトのCookie使用については、「個人情報保護方針」をご覧ください。
MBSDの寺田です。普段はWebアプリケーションスキャナの開発をしています。
本日は、調べものの中で見つけた、ORM(クエリビルダ)やDBの仕様を利用した攻撃、特に変数の型を利用した攻撃について紹介します(原理としては古くから一部で知られている攻撃です)。
攻撃の概要
DBに保存された値とリクエストパラメータの値が一致するかを、SQL(ORM)を使い検証しているアプリケーションが攻撃対象です。
User::where('token', $request->token) // Laravel風のコード
ORMやDBによっては、本来とは異なる型のパラメータ(数値, オブジェクト, 配列など)を与えることで、このようなSQLによる値の検証を回避できる場合があります。それがセキュリティ目的の検証であるならば、(当たり前ですが)回避されるとセキュリティ的にマズいことになります。
調査対象にしたのは下記のORMです。
ソフトウェア名 | バージョン | 言語 |
---|---|---|
Laravel (Eloquent) | 11.20.0 | PHP |
Django (?) | 5.1.1 | Python |
Rails (ActiveRecord) | 7.2.1 | Ruby |
Prisma | 5.19.1 | JS |
以下に3種類の攻撃について概要を説明します。概要の後に、もう少し詳細の情報を書いています。
本記事で「MySQL」と書いてある箇所は、MariaDBも含むと考えてください。
A-1 数値攻撃 [MySQL]
本攻撃は、文字列と数値の比較に関する、MySQLに特有の仕様を利用します。
以下は、文字列カラム(token)と数値(0)を比較させた結果です。
mysql> SELECT id, token FROM users WHERE id=1 AND token=0;
+----+------------------------+
| id | token |
+----+------------------------+
| 1 | M4fjL71F4e6oFw5WBCmHew | ← これが「0」と一致
+----+------------------------+
明らかに0ではない文字列が0と一致しています。これは下記のMySQLの仕様によるものです(MySQLマニュアル)。
- 文字列と数値の比較の際に、文字列が数値に変換されて比較される。
- 文字列 → 数値変換においては、文字列の先頭の数値らしい値に変換される。
例:'51z3'
→51
'abc'
→0
(先頭が英字なら0に変換される)
この仕様の影響を受けるORMの1つはLaravelです。下記は、Laravelのドキュメントにあった、tokenを使ったログイン機能のサンプルコードです。
Auth::viaRequest('custom-token', function (Request $request) {
return User::where('token', $request->token)->first();
});
攻撃者は{"token":0}
のようなJSONのリクエストを送り、下線のtokenパラメータの値を0(int)にします。Laravelは、intのPHP変数をint(PDO::PARAM_INT)としてSQLにBindするので、DBではtoken=0
のような比較が行われます。結果として、DBから誰かのレコードが返ってきて、そのユーザになりすましができてしまう可能性があります。
今回対象とした4つのORMのうち、Rails(v7以降)は基本的に本攻撃の影響を受けません。Django, Prismaは呼び出し方(下記)に依存します。
# ①カラムが明確なケース
User.where(:token => input_token)
# ②RawSQL的なケース
User.where("token=? AND …", input_token)
①においては、Django, Prismaは、DBのtokenカラムの型定義(varchar)に合わせて、SQLにBindする値をstringに変換するため、攻撃の影響を受けませんが、その仕組みが働かない②では影響を受ける可能性があります。Laravelは①②ともに影響を受ける可能性があります。
A-2 演算式攻撃 [Prisma]
Prismaがオブジェクト形式の演算式を受け入れることを利用する攻撃です。
攻撃対象のコード(JS)は、以下のようにSQLでtokenの値を検証しています。
await prisma.user.findFirst({
where: {
uid: req.body.uid,
token: req.body.token,
}
以下は、このコードに対する正常な入力と、攻撃者が操作した入力です。
// 正常な入力 (正しいtoken値を含む)
{"uid": 123, "token": "M4fjL71F4e6oFw5WBCmHew"}
// 操作した入力
{"uid": 123, "token": {"not": "a"}} // NOT演算子 → token が "a" 以外なら真
上のように操作した入力を与えると、正しいtokenを与えなくてもWHEREの条件を真にすることができます。つまりSQLによるtokenの検証を回避できるということです。
A-3 配列-IN攻撃 [Rails]
配列を含む入力に対して、一部のORM(Rails)がIN演算子を含むSQLを生成することを利用する攻撃です。
下の攻撃対象のコードは、リクエストパラメータのワンタイムパスワード(OTP)の値をSQLで検証しています。
User.where(:otp => params[:otp], …)
以下は正常なパラメータ(左)と、それによりORMが内部的に生成するSQL(右)です。
正常: otp=2951 → WHERE otp='2951' …
リクエストパラメータを配列に操作すると、INを使うSQLが生成されてしまいます。
操作: otp[]=0000&otp[]=0001&otp[]=0002&… → WHERE otp IN ('0000','0001','0002',…) …
OTPの試行に回数制限を設けており、例えば3回間違えるとアカウントをロックアウトする、という仕様のアプリケーションの場合、INを使うことで実質的にその制限を回避されてしまうかもしれません。
4桁のOTPをDBに生保存しており、SQLで値を検証しているという前提です。あくまで攻撃の概念を示すための例です。
対策
アプリケーションで手早くできる対策は型チェックやCastです。
User::where('token', (string) $request->token) // PHPでの型Castの例
以下に、それ以外の対策について書きます。
プログラム言語での比較
アプリケーションで比較処理を行う対策もあります。DBからSQLでデータを取り出し、取り出したデータとユーザ入力値が一致するかを、プログラム言語の演算子/関数で検証する、というイメージです。
この方法を挙げたのは、数値攻撃の例からも分かるように、SQLの「=」演算にはかなりのクセがあるからです。DB種類や設定によりますが、SQLの「=」は大文字/小文字や全角/半角を区別しなかったり、NULLと空文字を区別しなかったり、末尾の空白を無視したりします。
プログラム言語での比較においては、ルーズな比較をするものは避けて、厳密な比較をするものを使います。例えばPHPならば「==」を避けて「===」を使います。ベストプラクティスは、記事の最後で触れるTiming attackへの対策のために、プログラム言語やフレームワークが提供する安全な文字列比較関数(constant time)を使うことです。
ハッシュ/暗号化保存
上記の攻撃例では、tokenなどを生でDBに保存していることを前提にしました。しかし、型を使う攻撃以前の話として(機能的に支障が無いならば)ハッシュなどをDBに保存する方がよいです。ハッシュ保存の目的はDBデータが漏えいした際の被害を軽減することですが、ハッシュ保存をすれば入力値をそのままORMに渡してSELECTすることもないはずなので、(副次的な効果として)型を使用した攻撃が成立することもなくなるでしょう。
ハッシュ保存については本記事の主題ではありませんが、Laravelが過去に行った変更(tokenを生保存 → ハッシュ保存)について、記事の最後で説明しています。
補足情報
ここでは上の概要の節では省略した情報について書きます。今回取り上げた攻撃に関する過去の経緯や、DB/ORMベンダーが取っている対処、筆者とベンダーとのやりとりなど、雑多な内容です。
A-1 数値攻撃 [MySQL]
上述のとおり、MySQLにおいて'abc'=0
のような式が真になることを利用する攻撃です。
歴史的経緯 - Rails
この攻撃手法は古くから知られています。初出はおそらくRailsに対する攻撃を扱った以下のブログ記事(2013年)です。
MySQL madness and Rails - by Phenoelit
概要の節で説明したLaravelと同様に、Railsには、ⒶJSONなどによりWebアプリに数値を送り込める、Ⓑ数値のRuby変数をDBにも数値として渡す、という性質があり、そのためにこの種の攻撃が成立しやすい環境でした。
指摘を受けたRailsは、アプリケーションの開発者に注意を促すアドバイザリを公開し(2013年)、その後に以下のように2回にわたってORMの改修を行いました。
①カラムが明確なケース #9207
User.where(:token => params[:token])
上のコードのように、扱っているカラムをORMが認識できるケースでは、カラム定義の型に合わせてBindする値の型を調整するようになりました(2014年)。例えば、上のコードにおいて、DBのtokenカラムがvarchar型ならば、Railsが強制的に値をStringとみなすようになりました。この変更にはCVE-2013-3221が振られています。
②RawSQL的なケース #42440
User.where("token=? AND …", params[:token])
このコードのように、カラム名が単体で明示的に与えられない、RawSQL的なケースでは①の対策は機能しません。Railsはこのケースにも対応するため、明示的に指定されない限り、全ての値を文字列として扱うように処理を変更しました(2021年 Rails v7)。しかし、数値と数値文字列はDBで完全に同じ扱いを受けるわけではないので、変更により副作用(互換性の問題)が生じました。そのためもあったのか、この変更はv6にはバックポートされませんでした。
Rails以外のORM
Railsに続いてDjangoも2014年に①のケースに対応しました(MySQL typecasting, CVE-2014-0474)。しかし、その後②には対応せず、ドキュメントにて開発者に注意喚起を行っています。
他のORMで、DBに渡すデータ型を制御しているのはPrismaです。Prismaは「型安全」を重視しており、おそらくその結果として①に対応していますが、②のRawSQL的なケースには対応していません。
調査を行った2023年の時点で、Laravelは①②ともに対応していませんでした。上述のようにRailsが①を脆弱性として修正した事例があるため、Laravelに対してレポートを送りました(2023/8)。Laravelは、ORM機能の改修は行いませんでしたが、前述のドキュメントのサンプルコードを修正し(2023/8)、ORMでMySQLを使う際の注意事項をドキュメントに追加する対応を実施しました(2024/10)。
本記事の対象のORMには含めていませんが、JSのORMであるSequelizeも①②の両方に対応していなかったのでベンダーにレポートしたところ、v6.37.5にて①の対応がされました(2024/10)。
MySQLへの報告
数値攻撃は、MySQLの「=」演算の仕様が特殊なものであることと、アプリケーションが型チェックを怠っていることが前提条件になっています。その中で、ORMは「アプリケーションから数値として渡された変数を、数値としてDBに渡す」という自然な処理をしているだけです。
したがって、アプリケーションの責任を置いておくと、数値攻撃の責任の多くはMySQLの特殊な仕様にある、というのが筆者の捉え方です(上で紹介したブログ記事のタイトルも「MySQL madness and Rails」でした)。そのような仕様のDBはおそらくMySQLだけですし、そういう仕様にするメリットも無いように見えます。前述のように、Railsなどは「数値の変数を数値としてDBに渡す」という処理をしていただけでしたが、このMySQLの仕様を回避するためにややこしい改修を行うことになってしまいました。
大元のMySQLが「=」演算の仕様を変更してくれれば、(変更したバージョンが普及する頃には)個別のORMやアプリケーションで余計な回避処理をする必要はなくなります。という訳で、仕様変更を提案するFeature requestをMySQLに送りました(2023/8)。MySQLには既にstrictモード(STRICT_TRANS_TABLES
)があり、無理な数値変換はエラーになりますが、対象は更新系のSQLだけです。筆者が提案したのは、数値攻撃への対策になるSELECT文でも有効な新strictモードです。しかしこの提案は「類似のRequestが複数あるので"重複"」という扱いになり、残念ながら本稿執筆時点では実現していません。
ちなみに、DBではありませんが、演算子の仕様変更を実際に行った例としてPHPの「==」があります。昔はMySQLと同様に'abc'==0
は真でしたが、PHP8以降はこれらが偽になるように変更(RFC)されました。似た方向の変更をMySQLでもして欲しい、というのが筆者のRequestです。
別の解決方法
Railsなどで採られた①②の対策について説明しましたが、ORM側でできる対策は他にも考えられます。
MySQL5.5以上の環境において、SELECT文の中で'abc'=0
のような無理な数値変換をすると、(エラーは発生しないものの)Warningが発生します。そのため、SELECT文を実行した直後にshow warnings
などを実行し、無理な数値変換が発生していたことが分かった場合には、ORMから例外を投げるなどによりアプリケーション処理を中断させる、という対策が可能です。
この方法のメリットは、①だけでなく②のRawSQLのケースにも対応できることと、Railsの「全て文字列として扱う」という方式で発生したような副作用を(おそらく)起こさないことです。デメリットはDBとのやり取りが1回増えることです。
この方法は、筆者がMySQLにFeature requestを送った際に、MySQLの方から提案された方法です。 MySQLが(Warningではなく)エラーを投げてくれれば、このような対策を各ORMでやる必要は無くなります。
影響するDB
前記のRailsのアドバイザリは、数値攻撃の影響を受けるDBとして、MySQL、SQL Server、DB2(設定による)の3つを挙げていますが、筆者が再現を確認できたのはMySQLだけでした。下表は筆者が再現確認をした結果をまとめたものです。
DB種類 | バージョン | token=0 の結果 |
---|---|---|
MySQL | 5.0.96 5.1.72 | 真 (Warning無し) |
5.5.50 5.6.10 5.7.9 8.0.39 9.0.1 | 真 (Warningあり) | |
PostgreSQL | 8.1 8.2 | 偽 |
8.4 10.20 15.2 16.0 | エラー | |
SQL Server | 2008R2 2012 2017 2022 | エラー |
Oracle | 10 11 21 23 | エラー |
SQLite | 3.31.1 | 偽 |
DB2 | 9.7 10.5 11.5.8 | エラー |
再現確認は、SELECT文のWHERE内でtoken=0
のような比較(文字列カラムと数値の比較)をさせる方法で実施しました。表のとおり、MySQL以外のDBにおいては、SQLエラーになるか、比較結果が偽になる、という結果でした。
A-2 演算式攻撃 [Prisma]
前述のとおり、Prismaがオブジェクト形式の演算式を受け入れることを利用する攻撃です。
演算式/演算子とは
演算式自体は、アプリケーションの中で正常な機能として使用するものです。以下のPrismaのコード例では、gt
, contains
が演算子であり、通常はアプリケーションにハードコードされます。
where {
color: req.query.color,
qty: {gt: Number(req.query.qty)},
title: {contains: req.query.title},
}
問題なのは、演算式が素のオブジェクトであり、外からも埋め込める(外から来たものとハードコードされたものが区別できない)という点です。上のコードで言えば、リクエストパラメータのcolor=green
を、color[not]=x
に操作すると、not
演算子を含む式がwhere
のcolor
にセットされ、ハードコードされた演算式と同様に機能します。
歴史的経緯 - MongoDBとSequelize
この手の演算式を使う攻撃手法は最近発見されたものではありません。例えばMongoDBに対するNoSQL Injectionは10年以上前から知られています。しかし、MongoDB(及び関連するライブラリ)は、攻撃が知られるようになってからも演算式の仕様を変更しませんでした。変更できなかったと言う方が正確かもしれませんが、ともかくこれは「by design」であり、意図しない演算式が入り込むのを防ぐためのチェックはアプリケーション側が責任を負う、という考え方だと思われます。
一方で、ORM側の仕様の不備と捉えて対策を実施したSequelizeのようなORMもあります。Sequelizeは、演算式攻撃を防ぐために、下のように演算子に用いるオブジェクトキーをJSのSymbolに変更しました。互換性を損なう(既存のアプリケーションを壊しうる)仕様変更でしたが、これにより外部から演算式を挿入する攻撃はできなくなりました。
// 旧Sequelize: 演算子は文字列
{token: {$not: "x"}}
// 新Sequelize: 演算子はSymbol (Op.not → Symbol.for("not"))
{token: {[Op.not]: "x"}}
Undefinedの問題
Prismaについては、値がundefinedであるWHERE条件を無視する仕様が知られており(Cloudbase, Prisma docs)、この仕様も攻撃に利用できる可能性があります。
例えば、下のアプリケーションに対して、攻撃者はtokenパラメータを削除したリクエストを送信します。
await prisma.user.findFirst({
where: {
uid: req.body.uid,
token: req.body.token,
}
するとreq.body.token
はundefinedになり、WHEREはuidの条件だけになります。結果として正しいtokenを与えなくてもWHEREの条件を真にすることができます。
Prismaへの報告
MongoDBやSequelizeの例をみると、この手の問題は「仕様」と「脆弱性」の中間にあるものと言えそうです。Prismaの件もどう扱うべきか微妙だと思いつつ、演算式やundefinedを利用した攻撃についてのレポートをPrismaに送りましたが(2024/9)、Prismaからの返信は「アプリケーションで型Castすべし」という趣旨のものでした。Prismaへの演算式攻撃については既にネット上に情報があることも考慮して、公開して注意喚起する方がよいだろうとの考えで、本記事でPrismaに対する攻撃手法について書いています。
A-3 配列-IN攻撃 [Rails]
前述のとおり、パラメータを配列にすることで、IN演算子を含むSQLを実行させる攻撃です。
該当するORMはRailsですが、機能によって配列をINに展開したりしなかったりするORMもあります。例えばLaravelは、汎用的なwhere()
ではINに展開しませんが、主キー検索用のfind()
ではINに展開します。
INを使う攻撃では、攻撃者は候補値を列挙しなければなりません。環境によりますが、SQL文の長さや列挙できる候補数には上限があるため、有意な攻撃ができるケースは限られます。
Tokenの保存/比較方法
Laravel 5.xにおいて、パスワード再設定用のtokenの保存方法が変更されました(2016年頃)。本記事のテーマに微妙に関連するものなので、この変更についても紹介します。
変更前は、tokenはDBに生で保存されており、SQLのwhereでtokenの値が検証されていました。使われていたのは下のPHPコードです。
$this->getTable()->where('email', $email)->where('token', $token)->first();
この処理について、PR #16850は以下の2つの問題点を指摘しました。
- DBに保存されたtokenが漏れたときに、なりすましに至る。
- 文字列比較処理がTiming attackに脆弱である。
1つ目は、攻撃者がDBデータを入手したとすると、DBに生で保存されている他人のパスワード再設定用tokenを使い、そのユーザのパスワードを再設定できてしまうため、tokenを生でDBに保存するのは避けた方が良い、という指摘です。
2つ目の文字列比較のTiming attackは、ある正解文字列とテスト文字列の比較に掛かる時間を、テスト文字列の値を変えながら測定することで、正解文字列の長さや値を突き止める攻撃です。
上のPRにより、Laravelのパスワード再設定機能は、正解となるtokenをpassword_hash()
でハッシュ化した値をDBに保存するようになりました。リクエストパラメータとして送られて来るtokenが正解と一致するかを検証する際には、DBから正解のハッシュを取り出したうえでPHPのpassword_verify()
により比較します。
なお、1はDBのデータを攻撃者が入手することが攻撃の前提になっています。2のTiming attackは近年研究が進んでいる分野ではあるものの、文字列比較の処理にかかる時間は極めて小さいため、リモートからの実際的な攻撃は現時点ではかなり難しいと思います。その意味で1,2ともにハードルが高い攻撃であり、弊社の脆弱性診断で発見しても、せいぜい「情報」(脆弱性未満)として指摘することが多いと思います。
おすすめ記事