一个被忽略的请求头
假设你把一个远程 MCP Server 部署在公网,前面挂了 OAuth 2.1 授权,客户端拿访问令牌才能调用工具。某天日志里出现一批陌生 IP 的 tools/call 请求,它们既没有重新走授权流程,也没有触发任何告警,却成功读到了某个用户的知识库内容。排查后发现,攻击者只是拿到了一个 Mcp-Session-Id。
直觉上这不应该成立:请求不是还要带 Authorization: Bearer 吗?问题恰恰出在这里。如果服务端把会话 ID 当成“已认证”的凭证,只在建立会话时校验一次令牌,后续请求只看会话 ID,那么会话 ID 就等价于一张通行证。谁复制到它,谁就是那个用户。
本文以“公网远程 MCP Server + 企业知识库工具”为贯穿场景,拆开会话绑定的机制,说明它在哪一步失效,以及无状态模式和每请求鉴权各自付出什么代价。
会话 ID 到底标识了什么
它解决的是上下文连续性问题
MCP 用 JSON-RPC 编码消息,Streamable HTTP 是它定义的两种标准传输之一,另一种是本地子进程的 stdio。Streamable HTTP 下,服务端作为独立进程处理多个客户端连接,使用 HTTP POST 和 GET,并可选地用 Server-Sent Events(SSE)流式返回多条消息。
规范要求服务端提供一个统一的 MCP 端点路径,同时支持 POST 和 GET。客户端每发一条 JSON-RPC 消息,就是一次新的 HTTP POST;如果这条消息是请求,服务端要么返回 application/json 的单个 JSON 对象,要么返回 text/event-stream 开启 SSE 流。
问题在于,一次会话里往往有多次交互:初始化、列工具、调用工具、接收进度通知、断线后重投消息。服务端需要知道“这条 POST 属于哪次会话”。Mcp-Session-Id 就是为此引入的标识。按规范描述,服务端在初始化时返回该 ID,客户端后续每次请求都要携带它。它的作用是关联一次会话的多次交互。
这里有一个容易被误读的边界:规范把会话 ID 定义为关联交互的标识,而不是身份凭证。授权规范另有一套机制,MCP 服务端在 OAuth 2.1 里扮演资源服务器,客户端扮演 OAuth 客户端,访问令牌通过 Authorization: Bearer 头携带。会话 ID 和访问令牌是两条独立的线。
服务端通常怎么用它
一个典型实现会在内存或 Redis 里维护一张会话表,键是 Mcp-Session-Id,值包含:已认证的主体(subject)、令牌的过期时间、已订阅的资源、SSE 流的重投缓冲、以及该会话允许的工具范围。
请求到达时,服务端先按会话 ID 查出这条记录,再决定是否执行工具。如果查表这一步之后不再比对令牌,那么这张表就成了“会话 ID 换权限”的兑换窗口。攻击者不需要伪造令牌,只需要猜中或偷到一个有效的会话 ID。
flowchart TD
A[客户端初始化请求] --> B{校验访问令牌}
B -- 通过 --> C[创建会话记录]
C --> D[返回 Mcp-Session-Id]
D --> E[客户端携带会话 ID 发起 tools/call]
E --> F{服务端查会话表}
F -- 命中 --> G[按会话记录执行工具]
F -- 未命中 --> H[返回错误]
G --> I[返回知识库内容]
J[攻击者复制会话 ID] --> K[发起 tools/call]
K --> F
这张图里最关键的一步是 F:它只查会话表,没有再问“这次请求的令牌是谁”。只要 J 能拿到 D 返回的字符串,它就走完了和合法客户端相同的路径。
会话劫持的三个入口
会话 ID 可预测
OWASP 的会话管理备忘单把会话 ID 的熵和长度列为基本属性,并强调会话 ID 必须当作不可信输入处理。如果服务端用自增整数、时间戳、用户 ID 加固定后缀来生成会话 ID,攻击者不需要窃取,枚举即可。
在 MCP 场景里,这种风险会被放大。远程 MCP Server 的会话 ID 出现在响应头或初始化结果里,客户端可能把它写进日志、错误上报或调试面板。一个可预测的 ID 意味着攻击者可以批量试探,直到命中一个活跃会话。
会话 ID 没有和令牌绑定
更常见的情况是 ID 本身随机性足够,但它和访问令牌之间没有强关联。服务端在初始化时校验了令牌,把主体写进会话记录,之后就不再校验。
这带来一个直接后果:会话的有效期和令牌的有效期脱钩。用户撤销授权、令牌过期、管理员禁用账号,这些事件都发生在令牌层面;只要会话记录还在,持有会话 ID 的请求就继续被接受。OWASP 备忘单在会话生命周期部分建议,在权限级别变化或风险事件后重新认证并轮换会话 ID。MCP 的授权规范也把令牌受众绑定和校验列为安全考虑项。两条建议指向同一个动作:每次请求都要重新确认令牌,而不是只在开头确认一次。
重连后没有重新校验
Streamable HTTP 支持断线恢复。规范描述了一种机制:服务端可以在 SSE 事件上附加 id 字段,客户端断线后重新发起 HTTP GET,并带上 Last-Event-ID 头,表示自己收到的最后一个事件 ID,服务端据此重投可能丢失的消息。
恢复流程本身是合理的,问题在于它常常被实现成“凭会话 ID 恢复”。如果重连请求只带 Mcp-Session-Id 和 Last-Event-ID,而不重新校验访问令牌,那么攻击者只要在合法客户端断线期间抢先发起恢复请求,就能接管这条流。规范还要求事件 ID 在会话内全局唯一,或在没有会话管理时对该客户端全局唯一;如果实现把事件 ID 也做得可预测,重投缓冲区的边界就更难守住。
无状态模式与每请求鉴权的取舍
Streamable HTTP 相比旧的 HTTP+SSE 传输,一个重要变化是允许服务端以完全无状态的方式交互,即普通的 RESTful POST。这给了一个绕开会话绑定的选项:不维护会话状态,每次请求独立鉴权。
两种方案的差异可以放在同一张表里看。
| 维度 | 有状态会话 + 会话 ID | 无状态 + 每请求鉴权 |
|---|---|---|
| 身份来源 | 初始化时校验一次,后续靠会话 ID | 每次请求校验 Authorization 令牌 |
| 会话 ID 的角色 | 关联交互,可能被误当凭证 | 可以不使用,或仅作日志关联 |
| 断线恢复 | 依赖会话记录和事件 ID 重投 | 由客户端重发请求,无服务端缓冲 |
| 服务端存储 | 需要会话表,含主体、订阅、缓冲 | 无需会话表,水平扩展简单 |
| 令牌撤销生效 | 取决于是否每请求校验 | 立即生效 |
| 服务端主动推送 | 可通过 GET 打开的 SSE 流实现 | 需要额外机制,如客户端轮询 |
| 适用场景 | 长任务、进度通知、资源订阅 | 短请求、无状态工具调用 |
这张表说明取舍不在“安全或不安全”,而在状态放在哪里。有状态会话能支撑进度通知、取消和资源订阅,代价是会话表成为必须保护的资产。无状态模式天然免疫会话 ID 劫持,代价是失去服务端主动推送和断线重投。
工程上常见的折中是保留会话,但把会话降级为“性能优化”而非“权限来源”:会话表只缓存令牌校验结果和订阅关系,每次请求仍然解析并校验令牌,会话 ID 命中与否不影响是否放行。
把会话绑到身份上
绑定与轮换
如果业务确实需要会话状态,至少要做到三件事。
第一,会话记录里保存令牌的稳定标识,例如 jti 或令牌哈希,并在每次请求时比对当前请求携带的令牌。比对失败就拒绝,而不是回退到“会话存在即放行”。
第二,会话 ID 用密码学安全的随机源生成,长度足够,避免任何可推导的结构。OWASP 备忘单把熵和长度列为会话 ID 的基本要求,并建议在权限变化后轮换。
第三,重连和恢复请求同样要带令牌并重新校验。Last-Event-ID 只用来定位重投位置,不能替代身份确认。
可观测指标
会话绑定是否真的生效,可以从几个信号上看出来。
- 会话创建数与令牌主体数的比值。如果远大于 1,说明同一用户反复建会话,可能是客户端没有复用,也可能是攻击者在试探。
- 会话 ID 校验失败率,按失败原因拆分:查表未命中、令牌与会话不匹配、令牌已过期。第二类持续出现,通常意味着有人在拿别人的会话 ID 试。
- 单会话的源 IP 和 User-Agent 变化次数。合法客户端在移动网络下会换 IP,但短时间内跨大洲跳变是异常。
- 令牌撤销到会话失效的延迟。如果这个值接近会话的绝对超时时间,说明撤销没有传导到会话层。
- 重连请求中缺少令牌的比例。这个比例高,说明恢复路径没有做鉴权。
这些指标不需要全部告警,但至少要能在排查时回答一个问题:某个会话 ID 是从哪次初始化来的,当时用的是哪个令牌,现在这个令牌还有效吗。
什么时候这套机制会退化
会话绑定的前提是服务端能拿到并校验令牌。以下情况会让它退化。
客户端把会话 ID 放进 URL 查询参数。URL 会进入代理日志、浏览器历史和 Referer 头,等于把凭证散播到多个位置。
服务端为了兼容旧客户端,对缺少令牌的请求放行。MCP 授权规范说明授权是可选的,不支持 OAuth 的旧客户端仍可连接不要求认证的服务端。一旦服务端在“兼容模式”下接受无令牌请求,会话 ID 就成了唯一门槛。
多实例部署但会话表没有共享。请求被负载均衡打到另一个实例,会话查不到,实现可能选择重建会话而不是拒绝,这会把绑定关系冲掉。
SSE 长连接没有与会话主体绑定。规范要求服务端把每条消息只发到一条已连接的流上,不得广播。如果实现按会话 ID 而不是按主体选择流,攻击者恢复流后就能收到本应发给别人的通知。
仍未解决的问题
MCP 规范把会话 ID 定义为关联交互的标识,把授权留给 OAuth 2.1 框架,但没有规定两者必须如何绑定。这给实现留了空间,也留了坑。
一个尚未统一的问题是:当客户端同时维持多条 SSE 流时,会话 ID 和流 ID 的关系如何表达,才能既支持重投又防止跨主体接管。另一个问题是,令牌轮换后旧会话是立即失效还是允许短暂并存,规范没有给出统一答案。在这些问题有明确约定之前,把每次请求都当作独立鉴权请求来处理,是更容易验证的选择。