직접 만든 관리형 MCP 서버를 망가뜨린 후, 클래스 1 오류를 잡아내는 스캐너를 구축하기까지
요약
MCP(Model Context Protocol) 서버의 권한 제어 버그를 탐지하기 위해 개발된 런타임 보안 스캐너 'Siege'를 소개합니다. 정적 매니페스트 분석의 한계를 넘어, 실제 실행 환경에서 다양한 역할(Role)별 데이터 차이를 비교 분석하여 보안 취약점을 찾아냅니다.
핵심 포인트
- 정적 스캐너는 매니페스트 기반이라 런타임 권한 유출을 잡지 못함
- Siege는 실제 사용자 역할을 시뮬레이션하여 접근 제어를 테스트함
- 차분 분석(Differential analysis) 방식을 통해 권한 위반을 탐지함
- 입력 필터를 통한 정보 유출(Inference attack) 방지의 중요성 강조
몇 주 전, 저는 MCP (Model Context Protocol) 서버 앞에 위치하여 누가 무엇을 읽을 수 있는지 강제하는 거버넌스 계층(governance layer)인 Warden을 출시했습니다. 역할 기반(Role-based), 필드 수준(field-level) 제어가 가능합니다. 데모에는 고객 계정을 나열할 수는 있지만 결제 tier는 절대 볼 수 없는 support 역할이 있었습니다. tier 필드는 지원(support) 역할이 받는 모든 결과에서 제거됩니다.
저는 제 작업물을 완전히 신뢰하지 못할 때 하는 방식대로 그것을 쿡쿡 찔러보았습니다. 다음과 같이 시도해 보았습니다:
query_resource("accounts", {"tier": "Enterprise"})
6개의 행이 반환되었습니다. Acme Corp, Initech, Umbrella, Hooli, Stark, Wayne. support 역할은 tier를 볼 수는 없지만, 쿼리 계층(query layer)은 여전히 이를 필터로 수락했습니다. 즉, 모든 Enterprise 계정을 요청하면, 결과에 포함되어 있다는 사실만으로도 해당 계정의 tier가 무엇인지 알 수 있게 됩니다. 출력(output)에 대한 레드액션(Redaction, 정보 삭제)은 유지되었지만, 입력(input)을 통해 정보가 유출된 것입니다.
이것이 버그입니다. 작고 지루하며, 실제로 배포되는 바로 그런 종류의 문제입니다.
버그보다 저를 더 괴롭혔던 부분은 따로 있습니다. 저는 그것에 대해 MCP 보안 스캐너들을 실행해 보았습니다. 현재 모두가 사용하는 스캐너들은 도구의 매니페스트(manifest)를 읽습니다. 도구 설명을 읽고, 오염된 지침(poisoned instructions)을 검색(grep)하며, 의심스러운 메타데이터를 찾아냅니다. 좋은 도구들입니다. 하지만 모두 '통과(green)' 판정을 내렸습니다. 당연합니다. 매니페스트에는 아무런 문제가 없기 때문입니다. query_resource 도구 설명은 정직합니다. 버그는 서버가 실행되고 실제 역할이 실제 호출을 할 때만 존재합니다. 텍스트를 읽는 스캐너는 여기에 도달할 수 없습니다.
그래서 저는 그것을 할 수 있는 것을 만들었습니다. 이름은 Siege입니다.
서버를 읽지 말고, 실행하라
Siege는 라이브 MCP 서버를 대상으로 하며, 실제 역할처럼 공격자처럼 행동합니다. 매니페스트 검색(grep) 방식이 아닙니다. 당신이 부여한 각 신원(identity)으로 접속하여, 반환되는 결과값을 비교(diff)합니다.
핵심적인 차이점은 런타임 권한 부여 (runtime authorization)입니다. 정적 스캐너 (Static scanners)는 정적 도구 오염 (static tool-poisoning)을 잡아내는 데 특화되어 있으며, 그 분야에서는 충분히 제 역할을 합니다. 제가 그들을 grep으로 이기려는 것이 아닙니다. 아무도 출시하지 않는 것은, 실행 중인 서버를 서로 다른 사용자로 실행하며 접근 제어 (access control)를 깨뜨리려고 시도하는 도구입니다. RBAC 벤더들은 모두 조언으로서 "권한 부여 범위 (authorization scope)에 대해 레드팀 (red-team) 테스트를 수행해야 합니다"라고 말합니다. Siege는 그 조언을 실제로 실행 가능한 도구로 구현한 것입니다.
제가 스스로에게 부여한 엄격한 규칙은 다음과 같습니다: 필드 이름을 하드코딩하지 말 것, 역할을 하드코딩하지 말 것. 만약 제가 tier에 대해 알려주었기 때문에 Warden 버그를 잡아낼 수 있었다면, 그것은 스캐너가 아니라 단위 테스트 (unit test)에 불과할 것입니다. 따라서 방법론은 차분 분석 (differential) 방식입니다. 가장 허용 범위가 넓은 신원(identity), 즉 모든 것을 볼 수 있는 신원으로부터 스키마 (schema)와 실제 값들을 학습합니다. 그런 다음 제한된 모든 역할에 대해, 해당 역할이 보는 것과 기준점(baseline)을 비교(diff)하고 그 간극을 조사합니다.
그 결과, 모두 역할 상대적 (role-relative)인 네 가지 탐지기가 만들어졌습니다:
- 필드 마스킹(Redacted-field) 필터 유출. Warden 버그를 일반화한 것입니다. 특정 역할의 출력에서 제거된 모든 필드에 대해, 이를 필터로 사용해 봅니다. 만약 해당 필드로 필터링했을 때 기준점보다 적은 행(row)이 반환된다면, 숨겨진 값이 차이점을 통해 유출된 것입니다.
- 행 범위 (Row-scope) 에스컬레이션. 일반적인 뷰가 특정 하위 집합(예: region = West)으로 제한된 역할이 범위 외의 필터 값을 시도합니다. 만약
region=East가 권한이 없는 행을 반환한다면, 필터가 제한된 데이터셋이 아닌 전체 데이터셋을 대상으로 실행된 것입니다. - ID 열거 (ID enumeration). 목록 경로 (list path)는 통제되지만, 단일 레코드 조회 (single-record lookup)는 통제되지 않는 경우가 많습니다. 따라서 추측한 ID로
get_record를 호출하면query_resource가 강제하는 범위 제한을 그대로 통과해 버립니다. MCP 버전의 전형적인 IDOR (Insecure Direct Object Reference)입니다. - 금지된 리소스 읽기. 특정 역할이 리소스를 목록화(list)조차 할 수 없음에도 불구하고,
get_record가 리소스를 건네주는 경우입니다. 목록(list)과 쿼리(query) 시에는 접근 권한을 확인하지만, ID 기반 경로(by-id path)에서는 확인을 잊어버린 것입니다.
마지막 세 가지는 수동으로 찾아낸 것이 아닙니다. 첫 번째 탐지기를 일반적인 방식으로 작성하는 과정에서 자연스럽게 도출되었습니다. 하나의 버그를 위한 엔진을 구축하면, 다음 몇 가지 버그도 함께 끌어올리게 됩니다.
핵심 장면 (The money shot)
저는 두 가지 Warden 빌드를 유지합니다: 취약한 커밋 (vulnerable commit)과 수정된 커밋 (fixed one). Siege는 이 두 가지 모두에 대해 실행됩니다.
이전 — 취약한 Warden (4938bdf)
## 1. [HIGH] 'accounts'에 대한 필터 술어(filter predicate)를 통해 'tier' 필드가 노출됨
role: support로 발견됨
...
모든 탐지 결과(finding)는 정확하고 재현 가능한 재현 단계(repro)를 포함합니다: 사용된 도구, 인자(arguments), 그리고 반환된 행(rows)들입니다. 이를 자신의 클라이언트에 붙여넣어 데이터가 유출되는 것을 직접 확인할 수 있습니다. 또한, 탐지기들이 모든 것을 통과시켜 버리는 무의미한(no-ops) 도구가 아님을 보장하기 위해, 저장소(repo)에는 의도적으로 고장 난 피스처 서버(fixture server)가 포함되어 있습니다. Siege는 치명적인 금지된 리소스 읽기(forbidden-resource read)를 포함하여 해당 서버에 대해 네 가지 탐지기를 모두 실행합니다. 직접 확인해보고 싶다면 그 안에 준비되어 있습니다.
두 번째 클래스: 에이전트가 실제로 장악되었는가
도구 오염(Tool-poisoning)은 모두가 이야기하는 공격 방식입니다. 도구 설명(tool description)이나 도구의 출력(output)에 명령을 숨겨두면, 이를 읽는 에이전트가 사용자의 요청 대신 공격자가 말한 대로 행동하게 만드는 것입니다. 정적 스캐너(Static scanners)는 "이 설명이 적대적으로 보이는가?"라고 묻습니다. 반면 Siege는 다른 질문을 던집니다. "에이전트가 하이재킹(hijacked)되는가?"
따라서 Siege는 실제 에이전트 루프(agent loop)를 실행합니다. 무해한 읽기 도구(Benign read tool)와 데이터를 특정 URL로 전송하는 export_record 싱크(sink)를 사용합니다. 사용자의 작업은 읽기 전용입니다: 레코드 1을 요약하는 것이 전부입니다. 그런 다음 Siege는 설명 채널(description channel)과 출력 채널(output channel)을 통해 각각의 페이로드(payload)를 주입하고, 모델이 요청받은 적 없는 공격자의 목적지로 싱크를 실행하는지 관찰합니다. 하이재킹은 텍스트로부터 추론되는 것이 아니라 관찰됩니다.
결과물은 판결(verdict)이 아닌 매트릭스(matrix) 형태입니다. 두 개의 채널에 걸쳐 다섯 가지 페이로드를 테스트합니다: 시스템 차단 사칭(system-block spoofing, 설명과 출력 채널 모두를 통해 실행), 일반적인 정책 텍스트(plain policy text), 역할 혼동(role-confusion), 작업 분해(task-decomposition). 어떤 페이로드가 모델을 조종했는지, 어떤 것은 튕겨 나갔는지 확인할 수 있습니다. 5개 중 0개가 탐지된 깨끗한 결과 또한 실제적인 결과입니다. 이는 모델 버전을 업그레이드했을 때, 과거에는 통하지 않던 프레이밍(framing)이 더 이상 통하지 않게 되는 상황을 대비한 회귀 방지책(regression guard)이 됩니다.
이것이 하지 않는 것
이 보고서는 실행한 클래스(classes)들을 명시하고 건너뛴 항목들을 출력합니다. 현재는 MCP 서버만을 대상으로 하며, OpenAI의 함수 호출 (function-calling) 기능은 포함되지 않았습니다. 이는 추후 확장될 예정입니다. 현재는 stdio 전송 (stdio transport) 방식을 사용하며, 다음 단계로 HTTP를 지원할 예정입니다. 서버가 빈 데이터를 반환하면서 성공했다고 주장하는 '침묵하는 실패 (silent-failure)' 클래스는 설계되었으나 아직 출시되지는 않았습니다. 출력 결과 어디에도 "모든 취약점을 찾아냅니다"라는 문구는 포함되지 않습니다. 왜냐하면 그런 문장은 스캐너들이 거짓말을 하는 방식이기 때문입니다.
또한, 이 도구는 오직 제가 직접 만든 피스처 (fixtures)와 제가 명시적으로 참여(opt in)한 서버만을 공격합니다. 초대 없이 타인의 라이브 서버를 향해 런타임 레드팀 (runtime red-team) 도구를 겨누는 것은 데모가 아닙니다.
위치 (Where it sits)
Siege는 세 가지 구성 요소로 이루어진 스택의 공격 측면 (offense leg)입니다. Warden은 서버를 관리 (govern)합니다. Crumb는 모든 호출을 승인한 사람에게 귀속 (attributes)시킵니다. Siege는 Warden이 구축한 것을 파괴하려고 시도하는 부분입니다. 성벽을 쌓은 다음, 그 성을 포위 공격 (siege)하는 것입니다.
코드는 공개되어 있습니다: github.com/AlexlaGuardia/siege. 현재 v0.1 버전이며 의도적으로 범위를 좁게 설정했습니다. 실제 역할 (real roles)로서 라이브 서버를 대상으로 실행됩니다. 매니페스트 (manifest)가 보여줄 수 없는 부분입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기