예전에 결제 콜백을 받아 처리하는 서버 코드를 열어봤다가 서명 검증이 hashlib.sha256((secret + payload).encode()).hexdigest()로 짜여 있는 걸 발견한 적이 있어요. 저도 처음엔 "어? 원래 이렇게 하는 거 아닌가?" 싶었는데, 파보니 이 방식은 Length Extension Attack이라는 오래된 공격에 뚫립니다. 그래서 실무의 웹훅(Stripe, GitHub, Slack)은 하나같이 HMAC이라는 별도 구조를 써요. 오늘은 왜 순진한 sha256(secret + msg)가 안 되는지, HMAC이 그걸 어떻게 해결하는지 한 번 파헤쳐볼게요.
개념 / 원리
문제부터 봅시다. 서명 = SHA-256(secret + message)로 만들면 뭐가 나쁠까요?
SHA-256(그리고 SHA-1, MD5도) 은 Merkle–Damgård 라는 구조입니다. 메시지를 블록 단위(64바이트)로 잘라서 순서대로 처리하고, 이전 블록을 계산한 내부 상태를 다음 블록의 입력으로 씁니다. 최종 해시 값은 마지막 상태 그 자체예요.
이게 왜 문제일까요? 공격자가 원본 서명 H = SHA-256(secret + msg)를 알고 있다면, 그 H를 "다음 블록의 초기 상태"로 삼아 뒤에 임의 데이터를 이어붙인 새 해시를 계산할 수 있습니다. secret을 모르고도 SHA-256(secret + msg + padding + extra)를 만들어내는 거죠. hashpump 같은 툴로 실제 재현 가능합니다.
즉, ?user=guest&amount=1000&sig=xxxx라는 서명된 URL을 받았다면, secret 없이도?user=guest&amount=1000&…&amount=999999&sig=yyyy 같은 위조 URL을 만들 수 있어요.
HMAC은 여기서 등장합니다. 정의는 이렇습니다.
HMAC(K, m) = H( (K' ⊕ opad) || H( (K' ⊕ ipad) || m ) )핵심은 두 번 해시한다는 것. 외부 해시가 내부 해시의 결과를 다시 감싸기 때문에, 공격자가 내부 상태에 무언가를 이어붙일 수 없습니다. Length extension 무력화. 게다가 키를 블록 크기에 맞춰 XOR로 두 번 섞기 때문에 키 관리도 안전해져요. 이 구조는 RFC 2104(1997)로 표준화됐고 지금도 웹훅·JWT HS256·AWS Signature V4 어디에나 살아 있습니다.
실전 예제
Python 표준 라이브러리 hmac 모듈이 다 해줍니다.
import hmac
import hashlib
# 나쁜 예 (Length Extension 취약)
def bad_sign(secret: str, msg: str) -> str:
return hashlib.sha256((secret + msg).encode()).hexdigest()
# 올바른 예 (HMAC-SHA256)
def sign(secret: str, msg: str) -> str:
return hmac.new(
secret.encode(),
msg.encode(),
hashlib.sha256,
).hexdigest()
# 검증 - 반드시 compare_digest 사용
def verify(secret: str, msg: str, signature: str) -> bool:
expected = sign(secret, msg)
return hmac.compare_digest(expected, signature)
== 대신 hmac.compare_digest를 쓰는 이유는 Timing Attack 때문입니다. 일반 문자열 비교는 다른 바이트를 만나는 순간 return하기 때문에 처리 시간이 미묘하게 달라져요. 공격자가 그 시간 차이로 서명을 한 바이트씩 맞춰갈 수 있습니다. compare_digest는 길이가 같으면 항상 끝까지 비교하므로 안전해요.
웹훅 검증 실무 코드는 이런 모양입니다.
import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = b"whsec_your_secret_here"
@app.route("/webhook", methods=["POST"])
def webhook():
payload = request.get_data() # 원본 바이트 그대로!
signature = request.headers.get("X-Signature", "")
expected = hmac.new(WEBHOOK_SECRET, payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
# 검증 성공 후 처리
return "OK"
포인트는 request.get_data()로 원본 바이트를 서명한다는 것. request.json으로 파싱한 뒤에 다시 직렬화해서 서명하면 공백·키 순서가 바뀌어 서명이 안 맞습니다.
주의할 점
첫째, == 대신 반드시 hmac.compare_digest. 반복입니다만, 서명 검증에서 이거 하나 놓치면 앞의 모든 노력이 무의미해집니다. Node.js는 crypto.timingSafeEqual, Go는 hmac.Equal이 같은 역할이에요.
둘째, secret 길이. HMAC은 키가 짧아도 동작은 하지만, 짧으면 무차별 대입에 뚫립니다. 최소 32바이트 랜덤 값을 권장해요. secrets.token_hex(32)로 뽑아 쓰세요.
셋째, 해시 함수 선택. HMAC-MD5, HMAC-SHA1이 지금도 아주 즉시 위험한 건 아니지만 신규 시스템에선 쓰지 마세요. 표준은 HMAC-SHA256, 더 보수적으로 가려면 HMAC-SHA512.
넷째, JSON 서명은 원본 바이트로. json.dumps 결과는 키 순서·공백·이스케이프에 따라 달라져서 서명이 깨집니다. 웹훅은 파싱하기 전 원본 body로 검증하고, 검증 성공 뒤에 파싱하세요.
마지막으로, HMAC이 만능은 아닙니다. HMAC은 대칭키 서명이라 양쪽이 secret을 공유해야 해요. 서로를 신뢰할 수 없는 관계(제3자 인증)에는 RSA·ECDSA 같은 공개키 서명이 필요합니다. JWT의 HS256이 HMAC, RS256·ES256이 공개키 서명이에요.
마무리
정리하면 세 줄입니다.
sha256(secret + msg)는 Length Extension Attack에 뚫리니, 서명·인증에는 HMAC을 쓴다.- Python은
hmac.new(key, msg, sha256)로 만들고, 검증은 반드시hmac.compare_digest로 timing attack까지 함께 막는다. - 웹훅은 원본 바이트로 서명하고, secret은 32바이트 이상 랜덤, 해시는 SHA-256 이상을 기본으로 잡는다.
'보안' 카테고리의 다른 글
| 패스워드 해싱 - bcrypt와 argon2, 뭘 선택해야 할까 (0) | 2026.06.24 |
|---|---|
| XSS - Stored, Reflected, DOM-based 세 종류와 막는 법 (0) | 2026.06.11 |
| OAuth 2.0 Authorization Code Grant - 구글 로그인 뒤에서 일어나는 일 (0) | 2026.06.05 |
| CSRF와 SameSite 쿠키 - 토큰 없이도 막을 수 있을까 (0) | 2026.05.25 |
| [보안] HSTS와 보안 헤더 - HTTPS를 정말 강제하는 방법 (0) | 2026.05.22 |