8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SSRFのやられアプリを作ってみた ~Gemini CLIを活用して~

8
Posted at

初めに

こんにちは、最近Gemini CLI でのバイブコーディングにはまっているセキュリティエンジニアです。

普段全然業務でコーディングせず、アプリ製作経験もない筆者ですがGemini CLI があまりに強力(かつ gemini.md で人格を付与できるのが楽しい)すぎて数週間前からアプリ制作にいそしむようになりました。

このままでは本業の勉強ができない...と焦りはじめ、セキュリティの勉強もしているとの言い訳づくりにやられアプリ作成を決意しました。
今回はその中でも、知名度はSQLI等に比べるとマイナーだが危険な脆弱性であるSSRFが刺さるアプリを作成したので紹介します。

本業の脆弱性診断ではあまり出会わない脆弱性ですので、ほとんど私の勉強内容まとめのようになりますが少しでも参考になれば幸いです。

SSRFとは

SSRF(Server-Side Request Forgery、サーバサイドリクエストフォージェリ)とは、攻撃者が細工したリクエストを送り込むことで、サーバ自身に意図しない外部・内部リソースへのアクセスを行わせる脆弱性です。
通常、クライアントからは直接到達できない内部ネットワークや管理用サービスに対して、サーバが「代理」でアクセスしてしまうため、攻撃者はその仕組みを利用して情報収集やさらなる攻撃を行うことが可能になります。

SSRFの基本的な仕組み

通常、Webアプリケーションは外部のAPIやサービスにアクセスする機能を持っています。例えば:

  • 画像のURLを指定してサーバー上で画像を取得・処理する機能
  • 外部APIからデータを取得して表示する機能
  • URLの内容を取得してプレビューを生成する機能

SSRFは、これらの機能においてユーザーが指定するURL先を適切に検証していない場合に発生します。攻撃者は本来アクセスすべきでない内部リソースやサービスを指定することで、サーバーに不正なリクエストを送信させることができます。

SSRFで狙われる主なターゲット

  1. 内部ネットワークのサービス

    • http://localhost:8080/admin - 管理者向け内部サービス
    • http://192.168.1.100/api - 内部API
    • http://10.0.0.5:3306 - データベースサーバー
  2. クラウド環境のメタデータサービス

    • AWS: http://169.254.169.254/latest/meta-data/
    • GCP: http://metadata.google.internal/
    • Azure: http://169.254.169.254/metadata/
  3. 内部ファイルシステム

    • file:///etc/passwd - システムファイル
    • file:///proc/self/environ - 環境変数

SSRFの脅威と影響

SSRFが成功すると、以下のような深刻な影響が発生する可能性があります:

  • 内部情報の漏洩: 外部からアクセスできない内部システムの情報取得
  • 認証の回避: 内部ネットワークの信頼関係を悪用した不正アクセス
  • 機密データの取得: クラウドの認証情報やAPIキーの窃取
  • 内部システムへの攻撃: ポートスキャンや内部サービスへの攻撃

実際の攻撃例

POST /api/fetch-image HTTP/1.1
Host: vulnerable-app.com
Content-Type: application/json

{
  "imageUrl": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}

上記のような一見無害な画像取得APIが、実際にはAWSのメタデータサービスにアクセスし、IAMの認証情報を取得してしまう可能性があります。

次回は、実際にSSRFの脆弱性を含むやられアプリケーションを作成し、具体的な攻撃手法と対策について詳しく解説していきます。

実装してみた

ここからは Gemini CLI を利用して作成した SSRF が刺さるアプリケーションについて解説します。

構成

初めにアプリケーションの構成について解説します。今回は Docker 上で稼働するアプリを作成しました(アプリ制作素人丸出しすぎて全容をお見せするのは恥ずかしいのでかいつまんでお見せします)。

doker-compose.yml
version: '3.8'

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile.frontend
    ports:
      - "5000:5000"
    volumes:
      - ./frontend_app.py:/app/frontend_app.py
      - ./templates:/app/templates
      - ./flag.txt:/app/flag.txt
      - ./static:/app/static
    networks:
      - app-network

  internal_api:
    build:
      context: .
      dockerfile: Dockerfile.internal
    # ポートは外部に公開しない!
    volumes:
      - ./internal_api.py:/app/internal_api.py
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

本アプリはインターネット上からアクセスできる frontend の他に外部にはポートを公開していない internal_api も稼働しており、app-network により二つのサービスは接続されています。

やられアプリの設定

次にやられアプリを見ていきます。アプリの外観は以下です。

image.png

本アプリで用意している建前としては OGP のアプリになります。

※OGPとは

OGP(Open Graph Protocol)は、Webページの内容をSNSで正しくシェアできるようにするための仕組み です。 LINEやXでリンクを貼ると、下に画像付きのボックスが現れるあれです。
Webページに特定のメタタグを埋め込むことで、タイトルや説明、画像などの情報をSNS側に伝えることができます。

Qiita でもリンクを張ると出現しますよね (私の別記事の宣伝を兼ねて...)。

実際の挙動

本アプリではサンプルページを用意しています。

image.png

アスカを憑依させた Gemini CLI に作ってもらったのでエヴァの世界観が反映されたサイトになっちゃいました。

このサンプルページのヘッダーには OGP のタグが記載されています。

sample.html
<head>
    <meta charset="UTF-8">
    <title>社内報:第3新東京市エネルギー供給最適化</title>
    <!-- OGP Meta Tags -->
    <meta property="og:title" content="【社内報】第3新東京市におけるエネルギー供給の最適化について" />
    <meta property="og:type" content="article" />
    <meta property="og:description" content="マギシステムの計算資源を活用し、ポジトロンライフルへのエネルギー充填プロセスを3.4%高速化することに成功しました。詳細は本文にて。" />
    <meta property="og:image" content="/static/images/office.png" />
    <meta property="og:url" content="http://localhost:5001/sample" />
    <meta property="og:site_name" content="NERV Internal Network" />

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>

このサンプルページのリンクを作成したやられアプリのフォームに入力すると 以下のようにプレビューが作成されます。

image.png

攻撃例

それではいよいよ攻撃に移ります。まずは外部公開されていないサービスにアクセスしてみます。

image.png

当然外部からの入り口はないのでアクセスに失敗します。

続いて、先ほどの OGP のフォームに内部サービスの URL を張り付けてみます。

image.png

「プレビュー生成失敗」のエラーが出てきますが、同時に internal_api.py に記載された DB 情報が表示されます。これは本来外部からアクセスできないはず情報ですので攻撃成功です。

※ ローカルファイル読み取り

ちなみにファイルスキームを利用するとローカルファイルにもアクセスすることができちゃいます。

以下は file:///etc/passwd の例です。

image.png

脆弱性が作りこまれる理由 (該当エンドポイントの解説)

今回のアプリでは、受け取ったURLを /fetch エンドポイントで処理しています。
以下が該当箇所です。

frontend_app.py /fetch
@app.route('/fetch')
def fetch():
    url = request.args.get('url')
    if not url:
        return jsonify({"error": "Please provide a 'url' parameter."}), 400

    try:
        parsed_url = urlparse(url)
        
        if parsed_url.scheme in ['http', 'https']:
            headers = {'User-Agent': 'Mozilla/5.0 (compatible; GeminiBot/1.0; +http://www.google.com/bot.html)'}
            res = requests.get(url, timeout=5, headers=headers)
            res.raise_for_status()

            content_type = res.headers.get('content-type', '').lower()
            if 'text/html' in content_type:
                soup = BeautifulSoup(res.text, 'html.parser')
                ogp_data = {
                    'original_url': url,
                    'title': None,
                    'description': None,
                    'image': None,
                }
                title_tag = soup.find('meta', property='og:title')
                if title_tag and title_tag.get('content'):
                    ogp_data['title'] = title_tag.get('content')
                else:
                    title_tag = soup.find('title')
                    if title_tag and title_tag.string:
                        ogp_data['title'] = title_tag.string
                desc_tag = soup.find('meta', property='og:description')
                if desc_tag and desc_tag.get('content'):
                    ogp_data['description'] = desc_tag.get('content')
                image_tag = soup.find('meta', property='og:image')
                if image_tag and image_tag.get('content'):
                    ogp_data['image'] = image_tag.get('content')
                return jsonify(ogp_data)
            else:
                return Response(res.text, mimetype='text/plain')

        elif parsed_url.scheme == 'file':
            with open(parsed_url.path, 'r') as f:
                return Response(f.read(), mimetype='text/plain')
        else:
            return Response(f"Unsupported scheme: {parsed_url.scheme}", mimetype='text/plain', status=400)
            
    except requests.exceptions.RequestException as e:
        return Response(f"URLの取得中にエラーが発生しました: {e}", mimetype='text/plain', status=500)

以上です。特に URL の検証はされておらず、内部リソースであってもお構いなしにアクセスして結果を返す仕組みになっています。
しかもご丁寧に file スキームを利用されたときの処理まで記述されており、どうぞローカルファイルにもアクセスしてくださいと言わんばかりの作りです。

最後に

今回は SSRF を、アプリを作成しながら紹介しました。URL を入力値として受け取るアプリを制作する際は注意しましょう。

8
5
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
8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?