웹훅 서명을 ===로 비교하고 있던 것을 고친 이유
문제 발생
외부 서비스의 웹훅을 검증하는 코드였습니다.
const expected = hmac(secret, rawBody);
if (signature !== expected) return new Response("invalid", { status: 401 });기능은 정상이었지만 보안 리뷰에서 지적을 받았습니다 — 이 비교는 걸린 시간으로 정답에 얼마나 가까운지를 알려줍니다.
원인 분석
문자열 비교는 다르면 즉시 멈춥니다. 첫 바이트가 다르면 1바이트만 보고 끝나고, 앞 10바이트가 맞으면 11바이트째까지 봅니다. 그 차이는 나노초 단위지만 수천 번 반복해 평균을 내면 통계적으로 드러납니다. 공격자는 한 바이트씩 맞춰가며 서명을 복원할 수 있습니다.
Node.js는 이 용도의 함수를 제공합니다. crypto.timingSafeEqual(a, b)는 타이밍 공격을 방지하기 위해 상수 시간으로 비교하며, 두 입력의 바이트 길이가 같아야 하고 다르면 예외를 던집니다.
문서에는 이 함수의 한계도 적혀 있습니다 — 길이 검사 자체는 상수 시간이 아니므로, 길이가 미리 정해진 암호학적 값이 아니라 사용자가 제공한 문자열에 그대로 쓰면 공격자가 비교 대상의 길이를 타이밍으로 유추할 수 있습니다.
해결 방안
- 상수 시간 비교로 바꿉니다. 길이가 다를 때 던지는 예외를 그대로 흘리지 않고 실패로 처리합니다.
import { timingSafeEqual } from "node:crypto";
function safeEqual(a: Buffer, b: Buffer) {
return a.length === b.length && timingSafeEqual(a, b);
}- 길이 노출을 피하려면 양쪽을 해시해서 비교합니다. 입력 길이와 무관하게 항상 같은 길이가 되어 문서가 지적한 길이 검사 문제도 사라집니다.
const h = (v: string) => createHash("sha256").update(v).digest();
if (!timingSafeEqual(h(signature), h(expected))) return unauthorized();-
어디에 필요한지 구분합니다. 웹훅 서명, API 키, 세션 토큰, 비밀번호 재설정 토큰처럼 공격자가 값을 맞혀야 하는 비교가 대상입니다. 사용자 이름 비교 같은 곳에는 필요 없습니다.
-
비밀번호는 애초에 다른 함수를 씁니다. bcrypt/argon2의 검증 함수는 내부적으로 상수 시간 비교를 하므로 직접 만들지 않습니다.
-
라이브러리가 제공하면 그걸 씁니다. 결제·메신저 SDK의 서명 검증 함수는 대부분 이 처리를 이미 하고 있습니다 — 직접 구현하기 전에 확인합니다.
-
재생 공격도 함께 막습니다. 서명이 맞아도 오래된 요청이면 거절하도록 타임스탬프를 검증하고, 그 값도 서명 대상에 포함시킵니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.