Code 실전

MCP 요청 위변조를 HMAC 서명으로 원천 차단하는 법

seunghyeonlab 2026. 5. 15. 13:02

hero

요청이 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.dumpsseparators=(',', ':')를 명시하는 이유가 있다. 기본 설정은 공백을 포함하기 때문에 직렬화 방식이 달라지면 같은 데이터여도 서명이 불일치한다. 발신과 수신 양쪽이 동일한 직렬화 규칙을 써야 한다.

타임스탬프를 서명 본문에 포함시키는 게 핵심이다. 과거에 캡처한 유효한 서명을 나중에 그대로 재전송하는 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 구독