flawopen.com/ssrf/Ruby

● CWE-918 · 高
セキュリティ研究 · FlawOpen

脆弱性の解説:Server-Side Request Forgery in Ruby

How Net::HTTP and Faraday allow internal network requests in Ruby/Rails, and how to filter resolved IP addresses.

💡 わかりやすい解説 (ELI5)

オフィスの宅配便受取係に小包を取りに行くよう依頼する際、外部の店舗ではなく役員室の金庫の部屋番号を指定する場面を想像してください。受取係は社内通行バッジを持っているため、疑われることなく金庫室に入り機密書類を持ち出してしまいます。

主要な概念と専門用語

Web Application Security
主要概念 (Web Application Security):Core architecture component affected by CWE-918.
CWE-918
主要概念 (CWE-918):Standard Common Weakness Enumeration classification for ssrf-ruby.
Defense-in-Depth
主要概念 (Defense-in-Depth):Multi-layered engineering verification and runtime boundary isolation.

ステップ・バイ・ステップの攻撃フロー

Step 1

外部URLパラメータの受信

アプリケーションがユーザーから指定されたURLを受け取り、画像の取得やWebhook配信を実行します。

Step 2

内部ネットワークアドレスの指定

攻撃者がクラウドメタデータサービス(例: http://169.254.169.254/latest/meta-data/)や内部ループバックURLを指定します。

Step 3

保護領域からの制限なきソケット送信

サーバーのHTTPクライアントがプライベートVPC内からリクエストを発行し、内部IPチェックを行いません。

Step 4

クラウド認証情報およびトークンの窃取

メタデータサービスがリクエストを信頼し、一時的なIAM認証情報や内部APIデータを攻撃者に返却します。

ソースコード比較:脆弱 vs 堅牢化

✕ 脆弱な実装
# VULNERABLE: Naive requests.get to user-provided URL
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/fetch-avatar')
def fetch_avatar():
    url = request.args.get('url')
    # Attacker passes: http://169.254.169.254/latest/meta-data/iam/security-credentials/
    # Server queries AWS metadata and returns cloud credentials!
    resp = requests.get(url, timeout=5)
    return resp.content, 200, {'Content-Type': resp.headers.get('Content-Type', 'image/png')}
✓ 堅牢化されたセキュアパッチ
# HARDENED: Resolve DNS, parse IP address, deny private/link-local CIDRs
import socket
import ipaddress
import urllib.parse
import requests
from flask import Flask, request, abort

app = Flask(__name__)

BLOCKED_NETWORKS = [
    ipaddress.ip_network('127.0.0.0/8'),      # Loopback
    ipaddress.ip_network('10.0.0.0/8'),       # RFC 1918 Private
    ipaddress.ip_network('172.16.0.0/12'),    # RFC 1918 Private
    ipaddress.ip_network('192.168.0.0/16'),   # RFC 1918 Private
    ipaddress.ip_network('169.254.0.0/16'),   # Link-local / Cloud IMDS
    ipaddress.ip_network('::1/128'),          # IPv6 Loopback
    ipaddress.ip_network('fc00::/7'),         # IPv6 Private ULA
]

def is_safe_ip(ip_str):
    ip = ipaddress.ip_address(ip_str)
    return not any(ip in net for net in BLOCKED_NETWORKS)

@app.route('/fetch-avatar')
def fetch_avatar():
    url = request.args.get('url', '')
    parsed = urllib.parse.urlparse(url)
    
    if parsed.scheme not in ('http', 'https') or not parsed.hostname:
        abort(400, "Invalid URL scheme")
        
    # 1. Resolve all DNS IPs for hostname
    try:
        addr_info = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == 'https' else 80))
        resolved_ips = [ai[4][0] for ai in addr_info]
    except socket.gaierror:
        abort(400, "Unable to resolve host")
        
    # 2. Verify all resolved IPs are public
    for ip in resolved_ips:
        if not is_safe_ip(ip):
            abort(403, "Access to internal IP address is prohibited")
            
    # 3. Fetch with redirects disabled to prevent DNS rebinding or redirect bypass
    resp = requests.get(url, timeout=3, allow_redirects=False)
    return resp.content, 200, {'Content-Type': resp.headers.get('Content-Type', 'image/png')}

エンジニアリング&システム堅牢化チェックリスト

References