본문으로 건너뛰기
개발 머꼬
개발 노트보안
hohyeon.dev23

웹훅 서명을 ===로 비교하고 있던 것을 고친 이유

  • #Engineering Note
  • #보안

문제 발생

외부 서비스의 웹훅을 검증하는 코드였습니다.

const expected = hmac(secret, rawBody);
if (signature !== expected) return new Response("invalid", { status: 401 });

기능은 정상이었지만 보안 리뷰에서 지적을 받았습니다 — 이 비교는 걸린 시간으로 정답에 얼마나 가까운지를 알려줍니다.

원인 분석

문자열 비교는 다르면 즉시 멈춥니다. 첫 바이트가 다르면 1바이트만 보고 끝나고, 앞 10바이트가 맞으면 11바이트째까지 봅니다. 그 차이는 나노초 단위지만 수천 번 반복해 평균을 내면 통계적으로 드러납니다. 공격자는 한 바이트씩 맞춰가며 서명을 복원할 수 있습니다.

Node.js는 이 용도의 함수를 제공합니다. crypto.timingSafeEqual(a, b)타이밍 공격을 방지하기 위해 상수 시간으로 비교하며, 두 입력의 바이트 길이가 같아야 하고 다르면 예외를 던집니다.

문서에는 이 함수의 한계도 적혀 있습니다 — 길이 검사 자체는 상수 시간이 아니므로, 길이가 미리 정해진 암호학적 값이 아니라 사용자가 제공한 문자열에 그대로 쓰면 공격자가 비교 대상의 길이를 타이밍으로 유추할 수 있습니다.

해결 방안

  1. 상수 시간 비교로 바꿉니다. 길이가 다를 때 던지는 예외를 그대로 흘리지 않고 실패로 처리합니다.
import { timingSafeEqual } from "node:crypto";

function safeEqual(a: Buffer, b: Buffer) {
  return a.length === b.length && timingSafeEqual(a, b);
}
  1. 길이 노출을 피하려면 양쪽을 해시해서 비교합니다. 입력 길이와 무관하게 항상 같은 길이가 되어 문서가 지적한 길이 검사 문제도 사라집니다.
const h = (v: string) => createHash("sha256").update(v).digest();
if (!timingSafeEqual(h(signature), h(expected))) return unauthorized();
  1. 어디에 필요한지 구분합니다. 웹훅 서명, API 키, 세션 토큰, 비밀번호 재설정 토큰처럼 공격자가 값을 맞혀야 하는 비교가 대상입니다. 사용자 이름 비교 같은 곳에는 필요 없습니다.

  2. 비밀번호는 애초에 다른 함수를 씁니다. bcrypt/argon2의 검증 함수는 내부적으로 상수 시간 비교를 하므로 직접 만들지 않습니다.

  3. 라이브러리가 제공하면 그걸 씁니다. 결제·메신저 SDK의 서명 검증 함수는 대부분 이 처리를 이미 하고 있습니다 — 직접 구현하기 전에 확인합니다.

  4. 재생 공격도 함께 막습니다. 서명이 맞아도 오래된 요청이면 거절하도록 타임스탬프를 검증하고, 그 값도 서명 대상에 포함시킵니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.