요청이 MCP 서버에 닿기 전, 누군가 본문을 바꿔치기한다면 알아챌 방법이 있을까. Claude Code로 에이전트를 구축하면서 이 질문에 정면으로 부딪혔다. 이 글은 HMAC 서명을 MCP 클라이언트-서버 구간에 직접 심어 위변조와 Replay Attack을 동시에 차단한 실전 기록이다. 코드 50줄, 외부 라이브러리 추가 없이 40분 만에 끝났다.
왜 HMAC인가 — SHA-256 해시만으로는 구멍이 생긴다
SHA-256 해시를 메시지에 붙이면 "변조됐는지"는 알 수 있다. 문제는 공격자도 이걸 안다는 점이다. 본문을 원하는 대로 고친 뒤 해시를 새로 계산해서 붙이면, 수신 측은 "해시가 맞네"라고 판단하고 그냥 처리한다. 잠금장치 없는 편의점 택배함과 다를 바 없다.
HMAC(Hash-based Message Authentication Code)은 여기에 공유 비밀 키를 섞는다. 키를 모르는 공격자는 본문을 건드린 순간 올바른 서명을 다시 만들 수 없다. 발신자와 수신자, 두 사람만 같은 열쇠를 가진 구조다.
| 방법 | 위변조 탐지 | 키 없는 재계산 방지 | Replay 차단 |
|---|---|---|---|
| SHA-256 단독 | ✅ | ❌ | ❌ |
| HMAC-SHA256 | ✅ | ✅ | 타임스탬프 추가 시 ✅ |
MCP 구간은 기본값으로 서명이 없다. 에이전트가 read_file 도구를 호출할 때 path 파라미터가 중간에 /etc/passwd로 바뀌어도 서버는 그대로 실행한다. 이걸 직접 재현해봤고, 실제로 됐다.
서명 생성 — 요청이 떠나기 전 인감 찍기
테스트 환경: Mac Mini M4, Python 3.12, httpx 0.27.
import hmac, hashlib, time, json, httpx
SECRET = b"mcp-shared-secret-256bit"
def sign_request(payload: dict) -> dict:
body = json.dumps(payload, separators=(',', ':')).encode()
ts = str(int(time.time()))
mac = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return {"X-MCP-Timestamp": ts, "X-MCP-Signature": f"sha256={mac}"}
payload = {"tool": "read_file", "path": "/data/report.md"}
headers = sign_request(payload)
response = httpx.post("http://localhost:8080/mcp", json=payload, headers=headers)
json.dumps에 separators=(',', ':')를 명시하는 이유가 있다. 기본 설정은 공백을 포함하기 때문에 직렬화 방식이 달라지면 같은 데이터여도 서명이 불일치한다. 발신과 수신 양쪽이 동일한 직렬화 규칙을 써야 한다.
타임스탬프를 서명 본문에 포함시키는 게 핵심이다. 과거에 캡처한 유효한 서명을 나중에 그대로 재전송하는 Replay Attack을 막는 장치다.
서명 검증 — 수신 측 게이트웨이의 판단 로직
MCP 서버는 요청이 들어오자마자 서명을 먼저 확인한다. 도구 실행은 그 다음이다.
MAX_DRIFT_SEC = 30 # 허용 타임스탬프 오차
def verify_request(headers: dict, body: bytes) -> bool:
ts = headers.get("X-MCP-Timestamp", "")
sig = headers.get("X-MCP-Signature", "").removeprefix("sha256=")
if abs(int(time.time()) - int(ts)) > MAX_DRIFT_SEC:
return False # 30초 초과 — 재전송 공격 차단
expected = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig) # 타이밍 공격 방지
hmac.compare_digest를 쓰는 이유는 단순 == 비교의 구조적 결함 때문이다. ==는 문자열 앞에서부터 비교하다가 처음 틀린 위치에서 바로 종료한다. 공격자가 서명 후보를 대량으로 시도하면서 응답 시간 차이를 측정하면, 서명 값을 한 글자씩 추측하는 타이밍 공격이 가능해진다. compare_digest는 항상 전체 길이를 비교해 이 경로를 막는다.
Mac Mini M4 기준 검증 오버헤드는 요청당 평균 0.3ms 이하였다. 체감 지연은 없다.
도입 전후 — 실측으로 확인한 차이
도입 전: 서명이 없는 MCP 서버에 중간자가 path 파라미터를 /data/report.md에서 /etc/passwd로 교체했다. 서버는 아무 의심 없이 처리했다.
도입 후: 동일 시나리오에서 서버가 즉시 403 Signature mismatch를 반환했다. 본문을 1바이트만 건드려도 HMAC 값이 완전히 달라지기 때문이다.
# 도입 후 위변조 시도 시 서버 응답
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error": "Signature mismatch", "detail": "body tampered or key mismatch"}
소규모 Claude Code 프로젝트 기준 서명 레이어 추가에 걸린 시간은 약 40분이다. 코드 50줄 미만, 외부 라이브러리 추가 0개.
운영 팁 — 환경별 주의사항과 함정
키 관리: SECRET을 소스코드에 하드코딩하면 안 된다. 환경변수나 시크릿 매니저에서 주입하고, 최소 256비트(32바이트) 이상을 권장한다.
# .env 예시
MCP_HMAC_SECRET=your-32-byte-secret-here-256bit
import os
SECRET = os.environ["MCP_HMAC_SECRET"].encode()
타임스탬프 오차 설정: MAX_DRIFT_SEC = 30은 네트워크 지연을 감안한 값이다. 동일 LAN 환경이면 10초로 좁혀도 된다. 인터넷을 경유하는 구간이라면 60초까지 늘리되, 그 이상은 Replay 창이 너무 넓어진다.
Docker/컨테이너 환경: 컨테이너 간 시스템 시계가 다를 수 있다. chrony 또는 ntp 동기화를 확인하지 않으면 정상 요청이 타임스탬프 오차로 거절될 수 있다.
Linux vs Mac 차이: time.time() 정밀도는 플랫폼마다 다르지만, 초 단위 Unix epoch를 쓰는 이 구현에서는 차이가 없다.
마무리
HMAC 서명은 MCP 구간 위변조와 Replay Attack을 50줄 이하 코드로 동시에 차단한다. 보안은 나중에 덧붙이는 게 아니라, 에이전트 구조를 설계할 때 같이 심어야 한다. 지금 운영 중인 MCP 서버가 있다면 헤더 두 줄부터 오늘 추가해보자.
다음 글에서는 서명 검증을 FastAPI 미들웨어로 분리해 모든 엔드포인트에 일괄 적용하는 방법을 다룬다.
🐦 X에서 더 빠르게: @baegseungh7061
📚 이 시리즈 더 보기: Code 실전
💌 새 글 알림: X 팔로우 또는 블로그 RSS 구독
'Code 실전' 카테고리의 다른 글
| 코드 배포 없이 에이전트 행동을 즉시 바꾸는 런타임 토글 설계법 (0) | 2026.05.21 |
|---|---|
| Claude Code Hooks로 배포 안전망 만들기: staging·prod 환경 분기 자동 차단 (1) | 2026.05.19 |
| Claude Code 훅을 이벤트 버스로 바꾸면 응답이 26배 빨라진다 (0) | 2026.05.13 |
| 에이전트 실행 순서, DAG와 위상 정렬로 코드에 새기기 (0) | 2026.05.12 |
| Claude API 프롬프트 캐싱으로 API 비용 89% 줄이는 법 — Mac Mini 클러스터 실측 (1) | 2026.05.11 |