SSTによる安全なWebサイト運営のためのセキュリティ情報

エンジニアブログ
  • shareSNSでシェア
  • Facebookでシェアする
  • Xでシェアする
  • Pocketに投稿する
  • はてなブックマークに投稿する

社内向けサービスでSSRFを起こした話

はじめに

こんにちは。SST研究開発部の小野里です。最近はキーボードを自作しようと思って色々パーツを買いそろえており、キーボードのことばかり考えています。今回の話はキーボードとは全く関係ありません。

さて、先日以下の記事で社内向けの蔵書管理サービスを作ったことを書きました。

新卒研修記~Webアプリの開発~

その後、私の新卒研修ではこのサービスについて弊社の主な業務である脆弱性診断を行っていたのですが、そこでSSRFという中々面白い脆弱性が見つかりました。この記事ではSSRFがなぜ引き起こされてしまったのかと、その対策について書いていこうと思います。

SSRFとは

SSRF (Server Side Request Forgery)とは、公開サーバの設定不備により、非公開サーバに対して不正にアクセスされる脆弱性です。
通常、直接のアクセスが禁止されているサーバに対して、公開サーバを経由して何らかのリクエストを送信することで、不正にアクセスを行うことが出来てしまいます。

 

SSRFの仕組み

SSRFの仕組み

 

攻撃対象のサーバ(イントラネットなど)がファイアウォールで隔離されている場合、攻撃者からは直接アクセスすることが出来ません。しかし、攻撃対象となる非公開サーバに公開サーバからアクセス可能な場合、攻撃者は公開サーバに対して非公開サーバにアクセスさせるようなリクエストを送り、公開サーバはそれを元に非公開サーバにアクセスします。結果として、公開サーバを経由して攻撃者は自由に非公開サーバにアクセスすることが可能になり、情報漏洩や不正操作などの被害に繋がります。

今回の例

SSRFの概要について説明したところで、早速実際に発生したSSRFの例を見てみたいと思います。

※注意
以下のコードは脆弱性の理解・セキュリティ技術の向上のために記載しているものであり、決して悪意ある攻撃の実行を示唆するものではありません。実際に攻撃手法を試してみる場合、必ず自分の管理下にあるサーバとの通信に限ってください。自分の管理下に無いサーバに対しての通信を改ざんすると攻撃と見做される可能性があり、場合によっては法的に処罰されることもあります。また、改ざんする内容によってはサーバ上のデータを破壊してしまうこともあり得ますので、実験する際は予めバックアップを取っておくことを推奨します。

処理の概要

SSRFが発生したのはここ、書籍情報の登録画面です。

 

書籍情報登録画面

書籍情報登録画面

 

この画面にはOpenBDという外部APIから書籍情報を取得する機能があり、その際に書籍の表紙画像も取得することが出来ます。画像情報は保存されているサーバへのURLで送られてくるため、JavaScriptで取得した画像データへのURLをバックエンドのDjangoに渡し、Django側で保存する必要がありました。これを実現するために、以下のようなhiddenなフォームを作成し、POSTリクエストの値としてAPIから取得したURLをDjangoへ送信していました。

<input type="hidden" name="image_url" class="form-control" id="id_image_url">

Djangoが受け取ったURLは以下のような関数により処理され、リンク先のコンテンツがファイルとして保存されます。

def get_image_from_url(url: str) -> Optional[str]:
    """
    URLから画像を取得
    url:画像のURL,
    戻り値:保存先へのフルパス or None
    """
    # urlopenでimage_urlから画像データを取ってくる
    if url is not None and url != "None":
        tmp_image_response = urllib.request.urlopen(url).read()
    else:
        return None
    # URLからファイルの拡張子を取得
    file_extension = os.path.splitext(url)[1]
    # ファイル名も含めた保存先へのフルパスを生成
    media_path = settings.MEDIA_ROOT + "/" + create_unique_filename() + file_extension

    # バイナリモードで書き込み
    with open(media_path, mode="wb") as f:
        f.write(tmp_image_response)

    return media_path

攻撃例

では、早速上記の機能にSSRF攻撃をしかけてみましょう。
ローカルプロキシツールのBurp Suiteを使い、image_urlに不正な値を入力します。
Webアプリは社内からアクセス可能なAmazon EC2サーバで動いているため、本来ユーザには見せないはずのインスタンスメタデータを取得してみましょう。
以下のように、POSTリクエストのパラメータ、image_urlの部分にインスタンスメタデータを取得するURLを入力してみます。

------WebKitFormBoundary4L3rkCOFBaD20kjA
Content-Disposition: form-data; name="jan1"

9781111111111
------WebKitFormBoundary4L3rkCOFBaD20kjA
Content-Disposition: form-data; name="title"

testbook
------WebKitFormBoundary4L3rkCOFBaD20kjA

(中略)

------WebKitFormBoundary4L3rkCOFBaD20kjA
Content-Disposition: form-data; name="image_url"

http://169.254.169.254/latest/meta-data/
------WebKitFormBoundary4L3rkCOFBaD20kjA--

そのままリクエストを送信すると、問題なく書籍情報が登録されました。

 

登録された書籍情報

登録された書籍情報

 

当然ながら、URLのリンク先は画像ではないので、画像としては表示されません。しかしながら、ここには登録時に保存した画像を表示しようとして、画像データをサーバの公開ディレクトリから取得するリクエストが送信されています。そのレスポンスを見てみましょう。

HTTP/1.1 200 OK
Server: nginx/1.20.0
Date: Fri, 19 Nov 2021 10:40:27 GMT
Content-Type: application/octet-stream
Content-Length: 298
Last-Modified: Fri, 19 Nov 2021 10:40:25 GMT
Connection: close
ETag: "61977f19-12a"
Accept-Ranges: bytes

ami-id
ami-launch-index
ami-manifest-path
block-device-mapping/
events/
hibernation/
hostname
identity-credentials/
instance-action
instance-id
instance-life-cycle
instance-type
local-hostname
local-ipv4
mac
metrics/
network/
placement/
profile
public-keys/
reservation-id
security-groups
services/

はい、バッチリとインスタンスメタデータが返ってきていました。つまり、

  1. Djangoは受け取ったimage_urlのリンク先(インスタンスメタデータ)を保存
  2. リクエストを受けて保存したインスタンスメタデータを返す
  3. ブラウザは画像として表示しようとするが失敗。だが、レスポンス自体は受け取っているため、内容の確認が可能

という流れになっています。これで、SSRFによる非公開データの取得が可能という事が分かりました。

原因

今回のSSRFにおける直接の原因は、画像の保存時に一切のチェックをせず、受け取ったURLのリンク先にあるファイルをそのままの状態で保存していることです。
先ほどのget_image_from_url()関数をよく見てもらえれば分かりますが、「URLから画像を取得」と書いてあるものの画像であるかどうかのチェック処理はどこにも入っていません。「URLからファイルの拡張子を取得」という処理が入っているように、そもそもこの関数は画像に限らずどんなファイルでも保存可能になっています。
また、根本的な部分として、URLをユーザが任意に入力可能な値として渡すべきではありません。type属性がhiddenのinputタグはあくまでブラウザ上に表示されないフィールドというだけで、HTMLソースを見れば簡単に見ることが出来ます。先ほどのBurpのようなツールを使って、ユーザが任意に値を入力することも非常に簡単です。

対策

今回のようなSSRFの対策として、次の3つの方法が考えられます。

  1. ユーザ側での入力が不可能な形でURLをやり取りする
    今回はフロント側のJavaScriptで受け取ったURLをバックエンドのDjangoに渡す処理でした。難しい処理ですが、何らかのユーザの入力が介在しない方法でAPIから受け取ったURLをDjangoに渡すことが出来れば、今回のSSRFは根本から解決という事になります。
  2. URLの指定先を固定する
    現状では正常遷移でimage_urlに入るURLは、OpenBDの画像配信サーバのURLのみになります。したがって、想定されたドメイン以外へのアクセスは弾く仕様にすれば、SSRFは回避できます。ただし、クエリパラメータでリダイレクトできる場合SSRFが発生する可能性がある上、URLのパースでも問題が入り込む可能性があります。この点については徳丸先生の記事が詳しいので、是非参照してみてください。
  3. URLの指定先が想定されたファイル形式かどうか、厳密にチェックする
    非常に難しい処理になりそうですが、今回の例だと想定されているのは画像ファイルのため、URLのリンク先が画像かどうか厳密にチェックできれば良さそうです。ただし、ファイル形式の厳密なチェックにはいくつも抜け穴があり非常に難しいうえ、仮にできたとしても画像経由でのXSSなど別の脆弱性が入り込む余地があります。補助的な対策としては有効かもしれませんが、単体では推奨できない対策と言えます。

まとめ

社内向けのWebアプリでSSRF脆弱性を発見した話と、その対策例を紹介しました。
今回の脆弱性が発生した理由として、開発の際の強い思い込みが挙げられます。直接の原因となったget_image_from_url()関数は名前に反して画像以外のあらゆるファイルを取得できるようになっていますが、開発時はその点に思い至りませんでした。

「APIから取得したURLのリンク先は画像だけなので大丈夫」
「フォームにはAPIから取得したURLしか入力されないから大丈夫」
「ユーザは取得したURLが見えないから大丈夫」
「バックエンドに渡されるURLは絶対に画像なので普通に保存するだけで大丈夫」
こういった思い込みが重なり、結果としてSSRF脆弱性を作りこむことになってしまいました。

「渡されるURLは本当に想定内のものだけか?」
「ユーザから任意のURLが入力できるようになってはいないか?」
「保存しようとしているファイルは本当に想定した形式か?」
常にこういった疑いを持つ、いわば「かもしれない開発」とでも言いましょうか。こういった凝り固まらない柔軟な発想こそが、セキュアな開発には必要なのだと痛感した次第です。

今後該当の社内向けサービスについては、1番の「ユーザ側での入力が不可能な形でURLをやり取りする」の方向で修正を進めていく予定です。
この記事が自分のコーディングを見直し、思い込みから脆弱性が入る余地が無いかどうかを考えるきっかけになれば幸いです。

参考文献

SSRF(Server Side Request Forgery)徹底入門 | 徳丸浩の日記
What is SSRF (Server-side request forgery)? Tutorial & Examples | Web Security Academy
SSRF攻撃とは?仕組みや被害事例、効果的な対策について徹底解説|サイバーセキュリティ.com
URL の取り扱いには要注意! SSRF の攻撃と対策 | yamory Blog

  • shareSNSでシェア
  • Facebookでシェアする
  • Xでシェアする
  • Pocketに投稿する
  • はてなブックマークに投稿する

この記事の筆者

筆者

小野里亮祐

2021年4月にSSTへ新卒入社。研究開発部所属。
ついに入社4年目になってしまった。まだまだ新人でいたかったと思っている。