MCP 是 API 和 AI agent 之间的桥梁,许多 AIGW 为此提供了根据 OpenAPI spec,将现存 API 转换成 MCP 的功能。然而大部分 AIGW 在实现该功能时并没有严格检查客户端的输入。某些输入不仅仅会触发网关的 bug,甚至可以直接攻击到后端服务。

MCP to RESTful API:漏洞的温床?

对 MCP 实现中的命令注入问题早已有人研究。

不过许多 MCP to RESTful API 的实现,还是难以避免的出现可供注入的漏洞。严格来说,它们并不是完全信任客户端的输入,多多少少有一些检查。但也许是因为 OpenAPI spec 和 HTTP 协议太复杂了,有些地方依然有着无人把守的缺口。接下来,让我带领大家游览一下这些缺口,看看有什么办法绕过高墙。

在评估安全性之前,有个前提:我们认为配置是可信的。毕竟如果用户把 host header 作为 header parameter 发布出去,那么攻击者可以通过它来设置任意 host header 就不是什么超出预期的事情。下面我们评估的漏洞,都严格假定攻击者无法操纵 OpenAPI spec 的内容。

潜在漏洞

MCP to RESTful API 转换通常是这样实现的:

  1. 开发者通过 OpenAPI spec 或类似的 spec 定义参数的名称、类型和位置。
  2. 网关将 spec 转换成 JSONschema,发布出去。
  3. 客户端了解到对应的 schema,结合用户的上下文,生成对应的 JSON,发送给网关。
  4. 网关拿到 JSON 后,根据 spec 转换成 HTTP 请求。

其中 HTTP 请求如下:

POST /path/$path_param?query_param=$query_param_value HTTP/1.1\r\n
Host: xxxx\r\n
Header_param: $header_param_value\r\n
Cookie: cookie_x;cookie_param=$cookie_param_value\r\n
\r\n
$body_param

网关在转换的时候,就是将 path_param 之类的参数,用客户端发过来的 JSON 里面对应字段替换。

高风险

这里面最大的风险是,客户端发过来的 param 里面有 \r\n,那么就可以构造出任意请求。比如设置 path_param 的值为 HTTP/1.1\r\n...\r\nDELETE /admin,则得到的请求如下:

POST /path/ HTTP/1.1\r\n
...\r\n
DELETE /admin?query_param=$query_param_value HTTP/1.1\r\n
Host: xxxx\r\n

同样在 header_param_value 里面发送 \r\n 也有类似的危害。

中风险

次一点的风险是,path_param 的值可以被设置成带 ../ 的,这样就可以是任意的路径。虽然没办法构造出不同的 method 和 header,但配合现有的接口(比如一个低权限的 DELETE /{user_id}/db/${db_id}),可以把它变成高权限的操作(比如 DELETE /admin/resources)。

在测试中,我发现有些 AIGW 会接受用户发过来的 JSON 里面所有的字段,哪怕这些字段没有在 spec 里面列出。这种问题会导致攻击者能够指定任意的 header,可以造成后端服务不可用(下文会说明如何操作)。

低风险

最后值得一提的是,不同位置的参数有不同的分隔符。如果 AIGW 没有检测这些分隔符,则攻击者也可以通过这种方式来注入额外的参数。尽管这种注入方式要比 header 位置的注入的危害小一些,但还算得上是一种风险。

  • path 参数:/ | ?
  • query 参数:&
  • cookie 参数:;

实际支持 cookie 参数的 AIGW 很少,而且即使注入了额外的 cookie,也没什么危害,所以我没有测试各个 AIGW 对它的过滤情况。

测试结果

在阐述了 MCP 转 RESTful API 的潜在攻击面后,我们对几个支持此功能的知名开源项目进行了测试,以检验其是否存在上述问题。测试对象包括 Higress、AgentGateway、litellm 和 Unla。选择标准为:高知名度、开源、文档明确提及支持 MCP 转 RESTful API,且在同一技术栈下选取最具代表性的一个。鉴于存在安全风险的项目较为普遍,未测试的商业版产品未必更安全。

Higress

Higress 的技术栈是 Go Wasm (业务代码)+ Envoy (底层框架)。

高风险:

  • Higress 调用了 url.Parse 来解析最终的 path,该函数会拒绝 \r\n
  • Envoy 在执行请求时会拒绝 header 里面的 \r\n 字符。

中风险:

  • Envoy 在执行请求时会对含 /../ 的请求做 301 跳转,所以无法设置任意路径。
  • Higress 的请求参数必须在配置中显式声明,无法插入未声明的 header

低风险:

  • 没有检查 path 里面是否含有 /?。所以可以在 path 里面注入分界符,如把 DELETE /users/{user_id}/orders/{order_id} 变成 DELETE /users/1?c=/orders/2,或 GET /users/{user_id} 变成 GET /users/1/orders/2。当然也可以在里面插入任意的 query 参数。
  • 同样没有检查 query 参数里面是否有 &
  • 顺便一提,如果参数值里面有 \0,比如
    curl -X POST http://localhost:8000/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_user","arguments":{"user_id":"ac","include_details":"a\0c"}}}'
    会触发某些 wasm 代码执行路径,导致跳过参数替换,比如 /users/{user_id} 变成 /users/。

结论:Higress 存在低风险。

披露情况:已向 Higress 报告(https://github.com/alibaba/higress/issues/3266),截至报告撰写时,该问题尚未得到修复。

AgentGateway

AgentGateway 的技术栈是 Rust。

高风险:

  • AgentGateway 使用的 Rust 库会拒绝 path 里的 \r\n
  • header 里的 \r\n 同样会被拒绝。

中风险:

  • 在执行请求时会对含 /../ 的请求做 301 跳转,所以无法设置任意路径。
  • AgentGateway 会直接使用 tools/call arguments 里面的 {"header":{...}} 来构造最终发送给后端的请求,导致攻击者可以通过自己的 header 来覆盖由 agentgateway 设置的 header。比如使用自定义的 host 来覆盖 agentgateway 配置的 host。有一种攻击方向是通过设置一个较小的 Content-Type,将 body 从中间截断。如果 client 支持 HTTP1 pipeline,则截断的剩余部分会成为一个新的请求。不过,Rust 认为 HTTP1 pipeline 不安全,没有在 client 中支持,此路径无法利用。当然可以通过设置一个特别大的 Content-Type,迫使后端服务一直尝试读取直到超时为止。用这种方式可以快速消耗后端服务的连接数(通过 http2 可以做到在单条客户端连接不断发起请求,来持续消耗后端服务的连接),如果后端是传统的一个线程一个请求的 IO 模型,而且没有调整默认的单进程的最大线程数,可以打满后端的线程资源,造成后端不可用。

低风险:

  • 没有检查 path 里面是否含有 /?。所以可以在 path 里面注入分界符,如把 DELETE /users/{user_id}/orders/{order_id} 变成 DELETE /users/1?c=/orders/2,或 GET /users/{user_id} 变成 GET /users/1/orders/2。当然也可以在里面插入任意的 query 参数。
  • 同样没有检查 query 参数里面是否有 &

结论:AgentGateway 存在中风险。

披露情况:已向 AgentGateway 报告,然而对方并不积极。截至报告撰写时,对方尚未告知是否修复了此问题。

litellm

litellm 的技术栈是 Python。

高风险:

  • litellm 里用到的 Python 库 httpx 会拒绝 path 里的 \r\n:httpx.InvalidURL: Invalid non-printable ASCII character in URL, '\r' at position 26.
  • litellm 只支持 path parameters 和 query parameters,tools/call 时不支持 header,所以不能测试这个。需指出的是,litellm 可以正常加载带 header parameters 的 OpenAPI spec,而且文档里也没有说不支持,甚至 tools/list 时也能列出 header parameters 的参数,但是实际上在代码里是没有写关于 header parameters 的实现的。我花了不少时间调试才发现了这一点。另外 litellm 没有做不同种类 parameters 的隔离,如果不同 parameters 间有同名的参数,比如 path var user_id 和 query var user_id,在加载 OpenAPI spec 时会报错。

中风险:

  • 在执行请求时不会对含 /../ 做特殊处理,所以可以利用这个漏洞访问任意后端路径,如通过 ../admin 来访问 /admin 接口。
  • litellm 会检查入参是否在配置中。它的检查在全部四个测试对象里是最严格的,甚至要求入参类型和配置的类型一致,而不是简单地做一个 to string 的转换。

低风险:

  • 没有检查 path 里面是否含有 /。所以可以把 GET /users/{user_id} 变成 GET /users/1/orders/2。不过 litellm 有检查 ?
  • 无法通过 & 在 query 里注入额外参数。

结论:litellm 存在中风险。

披露情况:已向litellm报告,项目方已确认并修复:https://github.com/BerriAI/litellm/pull/18597

Unla

Unla 的技术栈是 Go。

高风险:

  • 会拒绝 path 中的 \r\n
  • 会拒绝 header 里面的 \r\n 字符。
    (注意当输入包含 \r\n 时,输出会是
    HTTP/1.1 202 Accepted
    Content-Type: text/plain; charset=utf-8
    Date: xxx
    Content-Length: 69

Acceptedevent: message
data: {"jsonrpc":"2.0","id":xx,"result":null}
这种混合了 200 和 202 HTTP 状态码的响应。估计触发了什么异常路径)

中风险:

  • 在执行请求时不会对含 /../ 做特殊处理,所以可以利用这个漏洞访问任意后端路径,如通过 ../admin 来访问 /admin 接口。
  • 请求参数必须在配置中显式声明,无法插入未声明的 header

低风险:

  • 没有检查 path 里面是否含有 /?。所以可以在 path 里面注入分界符,如把 DELETE /users/{user_id}/orders/{order_id} 变成 DELETE /users/1?c=/orders/2,或 GET /users/{user_id} 变成 GET /users/1/orders/2。当然也可以在里面插入任意的 query 参数。
  • query 中的 & 会被转义。

结论:Unla 存在中风险。
注意 Unla 如果不设置 responseBody template 则返回的响应为空。这样虽然用起来比较麻烦(不能直接使用返回的 JSON,必须配一个模板),但是避免了不少泄露敏感数据的风险,因为异常的响应无法在模板中渲染出来。不过这不能防治攻击者任意发起写请求(只要用户暴露了一个 DELETE 接口即可)。所以我还是维持中风险的评估。

披露情况:已向Unla报告,项目方已确认并修复。

标签: none

添加新评论