웹훅 서명 검증에서 자주 틀리는 네 가지
서명이 맞지 않는 원인은 대개 원문 처리에 있습니다. HMAC 검증 순서와 타이밍 공격, 재전송 대응을 정리했습니다.
웹훅을 받는 엔드포인트는 인터넷에 열려 있습니다. 결제 완료나 배포 성공 같은 이벤트가 들어오는데, 요청을 보낸 쪽이 정말 그 서비스인지 확인할 방법이 필요합니다. IP 대역으로 거르는 방식은 목록이 바뀌면 깨지므로, 대부분 서명을 씁니다.
해시가 아니라 HMAC
본문의 SHA-256을 헤더에 넣는 것으로는 아무것도 확인되지 않습니다. 본문을 아는 사람은 누구나 그 값을 계산할 수 있으므로, 요청을 통째로 위조하면 서명도 함께 만들어집니다.
HMAC은 양쪽이 공유하는 비밀키와 본문을 함께 넣어 값을 만듭니다. 키를 모르면 값을 만들 수 없습니다.
signature = HMAC-SHA256(webhook_secret, request_body)
헤더에는 보통 알고리즘을 앞에 붙인 16진수 문자열이 들어옵니다.
X-Signature-256: sha256=28b662b0d2478f16dfe24997114352f1e753db9b7b114b477859947e73144763
받는 쪽은 같은 계산을 하고 값을 비교합니다. 여기까지는 간단한데, 실제로 맞지 않는 경우가 자주 생깁니다.
1. 파싱된 객체가 아니라 원문 바이트로 계산한다
가장 흔한 원인입니다. 프레임워크의 JSON 파서가 본문을 객체로 바꾸고 원문을 버리면, 다시 직렬화한 문자열은 원문과 다를 수 있습니다.
받은 원문: {"a": 1}
재직렬화: {"a":1}
공백 하나만 달라져도 HMAC 값은 완전히 달라집니다. 키 순서, 유니코드 이스케이프, 소수 표기도 마찬가지입니다.
서명 검증에는 파싱 전의 바이트를 그대로 씁니다. 프레임워크마다 원문을 보관하는 방법이 다릅니다. Express는 express.json의 verify 옵션에서 원본 버퍼를 받아 두고, 다른 프레임워크에서도 해당 경로만 파서를 끄고 raw body를 읽는 방법이 있습니다. 이 설정은 웹훅 경로에만 적용합니다.
2. 문자열 비교에 ===를 쓰지 않는다
일반적인 문자열 비교는 다른 문자가 나오는 순간 멈춥니다. 그래서 앞부분이 얼마나 일치했는지에 따라 걸리는 시간이 미세하게 달라지고, 요청을 반복하면 서명을 한 글자씩 맞춰 나갈 수 있습니다.
길이가 같은 두 값을 처음부터 끝까지 비교하는 함수를 씁니다.
- Node.js —
crypto.timingSafeEqual(a, b). 두 버퍼의 길이가 다르면 예외가 나므로 길이를 먼저 확인합니다 - Python —
hmac.compare_digest(a, b) - Go —
hmac.Equal(a, b) - PHP —
hash_equals($a, $b)
3. 타임스탬프를 확인한다
서명이 유효한 요청을 그대로 저장해 두었다가 나중에 다시 보내면, 서명도 그대로 유효합니다. 결제 완료 웹훅이 열 번 재생되면 처리도 열 번 일어납니다.
대응은 두 가지를 함께 씁니다.
서명 대상에 타임스탬프를 포함합니다. 헤더로 온 타임스탬프와 본문을 이어 붙인 문자열로 서명을 계산하는 방식입니다.
signed_payload = timestamp + "." + body
타임스탬프가 서명 대상 안에 있으므로 값을 바꾸면 서명이 깨집니다. 받는 쪽은 현재 시각과의 차이를 확인해 5분처럼 정해진 범위를 넘으면 거부합니다.
이벤트 ID로 중복을 거릅니다. 처리한 이벤트 ID를 저장해 두고 같은 ID가 다시 오면 성공 응답만 돌려주고 처리는 건너뜁니다. 웹훅은 전송 실패 시 재시도되는 것이 정상이므로, 이 처리는 재전송 공격과 무관하게 필요합니다.
4. 시크릿 교체 중에는 두 키를 함께 인정한다
시크릿을 바꾸면 전환 시점에 이미 전송 중인 요청은 옛 키로 서명돼 있습니다. 한쪽만 인정하면 그 요청들이 전부 실패합니다.
교체 기간에는 두 키로 각각 검증하고 하나라도 맞으면 통과시킵니다. 전송이 정리되면 옛 키를 제거합니다. 서명 헤더에 여러 값이 들어오는 형식(v1=..., v1=...)을 쓰는 서비스가 있는 것도 이 때문입니다.
HMAC으로 안 되는 것
HMAC은 대칭키입니다. 보내는 쪽과 받는 쪽이 같은 키를 갖고 있으므로, 받는 쪽도 같은 서명을 만들 수 있습니다. 내부에서 요청이 진짜인지 확인하는 데는 충분하지만, 제3자에게 "이 요청은 저쪽이 보낸 것"이라고 증명할 수는 없습니다.
부인 방지가 필요하면 공개키 서명을 씁니다. 보내는 쪽이 개인키로 서명하고 받는 쪽은 공개키로 검증하는 방식이라, 받는 쪽은 검증만 할 수 있고 만들 수는 없습니다.
확인 순서
- 본문을 파싱하기 전에 원문 바이트를 확보합니다
- 헤더의 서명에서
sha256=같은 접두어를 떼고 16진수만 남깁니다 - 시크릿과 원문으로 HMAC-SHA256을 계산합니다. 서명 대상에 타임스탬프가 포함되는 형식인지 문서에서 확인합니다
- 타이밍 안전 비교 함수로 대조합니다
- 타임스탬프 오차와 이벤트 ID 중복을 확인합니다
- 값이 맞지 않으면 계산한 값과 받은 값을 함께 로그에 남기고 길이부터 봅니다. 길이가 다르면 인코딩(16진수인지 Base64인지)이 다른 것입니다
tools.onuel.dev의 HMAC 도구에 시크릿과 본문을 넣으면 받은 헤더 값과 같은지 바로 확인할 수 있습니다. 값이 다를 때 SHA-256으로 본문만 해싱해 보면 원문이 달라진 것인지 키가 달라진 것인지 나눠 볼 수 있습니다. 입력한 값은 브라우저 밖으로 나가지 않습니다.
- #웹훅
- #HMAC
- #보안