인증 시스템의 숨겨진 취약점: Timing Attacks, IP Spoofing 및 Race Conditions에 대한 심층 분석

발행: (2025년 12월 8일 오전 05:24 GMT+9)
9 min read
원문: Dev.to

Source: Dev.to

소개

인증은 애플리케이션 보안의 기반입니다. 그러나 많은 개발자—경험이 풍부한 개발자조차—사용자 데이터를 노출시키거나 계정 열거를 가능하게 하거나 공격자가 속도 제한을 우회할 수 있는 미묘한 취약점을 간과합니다. 지난 몇 달 동안 인증 서비스를 강화하면서 생산 시스템에서 놀라울 정도로 흔히 발견되는 세 가지 중요한 취약점을 발견했습니다.

이 글에서는 이러한 취약점을 살펴보고, 왜 위험한지 설명하며, 해결 방법을 보여드리겠습니다. 직접 인증 시스템을 구축하든 타사 솔루션을 평가하든, 이 문제들을 이해하는 것이 중요합니다.

취약점 #1: 비밀번호 검증에 대한 타이밍 공격

문제점

공격자는 다양한 비밀번호를 사용해 로그인 요청을 보내고 응답 시간을 측정함으로써 사용자의 비밀번호를 추측할 수 있습니다. 서버가 사용자 이름이 존재할 때(비밀번호가 틀리더라도) 응답 시간이 더 길다면, 공격자는 해당 계정이 존재한다는 정보를 얻게 됩니다—전형적인 타이밍 공격입니다.

무슨 일이 일어나고 있었는가

사용자가 존재하지 않으면 함수가 즉시 반환합니다. 사용자가 존재하지만 비밀번호가 틀리면, 먼저 비용이 많이 드는 비밀번호 검증을 수행합니다. 이 타이밍 차이가 정보를 누출합니다.

왜 중요한가

  • 계정 열거 – 공격자는 어떤 이메일 주소가 등록되어 있는지 판단할 수 있습니다.
  • 사용자 프라이버시 – 계정 존재 여부가 노출됩니다.
  • 표적 공격 – 공격자는 알려진 계정에 집중할 수 있습니다.

해결 방법

존재하지 않는 사용자에 대해서도 항상 비밀번호 검증을 수행하여 상수 시간 검증을 구현합니다.

// SECURE CODE
const user = await findUserByEmail(email);
const DUMMY_HASH = '$argon2id$v=19$m=65536,t=3,p=4$...'; // Pre‑computed dummy hash
const DUMMY_PASSWORD = 'invalid';

// Always perform both verifications in parallel
const [, realResult] = await Promise.all([
  verifyPassword(DUMMY_HASH, DUMMY_PASSWORD), // Dummy verification (always fails)
  user && user.passwordHash
    ? verifyPassword(user.passwordHash, input.password)
    : verifyPassword(DUMMY_HASH, DUMMY_PASSWORD), // Dummy if no user
]);

if (!user || !user.passwordHash || !realResult) {
  throw new AuthError('Invalid credentials');
}

핵심 개선점

  • 두 경로 모두 비슷한 시간(상수 시간) 동안 실행됩니다.
  • 사용자 존재 여부에 대한 정보 누출이 없습니다.
  • 병렬 실행으로 성능을 유지합니다.

취약점 #2: 속도 제한에서의 IP 스푸핑

문제점

속도 제한은 무차별 대입 공격을 방지하는 데 필수적이지만, 많은 구현이 X‑Forwarded‑For 헤더를 직접 신뢰합니다. 이 헤더는 공격자가 쉽게 위조할 수 있습니다.

// VULNERABLE CODE
const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.ip;
// Use IP for rate limiting

왜 중요한가

  • 속도 제한 우회 – 공격자는 무제한 요청을 할 수 있습니다.
  • IP 스푸핑 – 합법적인 사용자의 IP를 사용해 프레임을 만들 수 있습니다.
  • DDoS 증폭 – 여러 가짜 IP에 공격을 분산시킬 수 있습니다.

해결 방법

Express의 내장 req.ip를 사용하고, trust proxy 설정을 올바르게 구성합니다.

// SECURE CODE
// In app.ts – configure trust proxy
if (env.nodeEnv === 'production') {
  app.set('trust proxy', true); // Trust reverse proxy
} else if (process.env.TRUST_PROXY) {
  app.set('trust proxy', process.env.TRUST_PROXY);
}

// In rate limiter
keyGenerator: (req) => {
  // req.ip respects 'trust proxy' setting
  // Express validates X‑Forwarded‑For when trust proxy is configured
  return req.ip || 'unknown';
}

핵심 개선점

  • trust proxy가 설정된 경우에만 Express가 프록시 헤더를 검증합니다.
  • 위조될 수 있는 수동 헤더 파싱을 없앱니다.
  • 프로덕션 배포에 맞는 적절한 구성입니다.

취약점 #3: OTP 검증에서의 레이스 컨디션

문제점

일회용 비밀번호(OTP)를 검증할 때, OTP가 유효한지 확인하고 사용된 것으로 표시하는 사이에 시간이 존재합니다. 동시에 들어오는 요청은 이 TOCTOU(시간‑검사, 시간‑사용) 레이스 컨디션을 악용할 수 있습니다.

// VULNERABLE CODE (TOCTOU)
const otp = await findOTP(userId, code);

if (!otp || otp.used || otp.expiresAt  Math.min(times * 50, 3000),
});

redisClient.on('error', (error) => {
  logger.warn('Redis connection error, falling back to memory store');
  // express-rate-limit automatically falls back to memory store
});

영향: 이전 vs. 이후

이전이후
타이밍 공격사용자 계정을 열거할 수 있었음상수 시간 비밀번호 검증(누출 없음)
속도 제한IP 스푸핑으로 우회 가능Express trust proxy를 통한 올바른 IP 처리
OTP 재사용레이스 컨디션으로 가능원자적 OTP 검증(레이스 컨디션 없음)
확장성서버 인스턴스당 속도 제한만 적용Redis를 이용한 분산 속도 제한

인증 보안 모범 사례

  • 상수 시간 연산 – 입력에 관계없이 보안에 중요한 검사는 동일한 시간에 수행하도록 하고, 필요 시 더미 연산을 사용합니다.
  • 클라이언트 헤더를 직접 신뢰하지 않기 – 프레임워크가 제공하는 메커니즘(예: Express의 trust proxy)을 통해 프록시 헤더를 검증합니다.
  • 원자적 데이터베이스 연산 사용 – 상태를 변경하는 검증(예: OTP 검증)에서는 체크와 업데이트를 하나의 트랜잭션으로 수행합니다.
  • 분산 속도 제한 배포 – 여러 인스턴스 간에 일관된 제한을 적용하려면 Redis와 같은 공유 스토어를 사용합니다.
  • 라이브러리 최신 상태 유지 – 해싱, JWT 및 기타 보안 관련 의존성을 정기적으로 업데이트합니다.
  • 인증 이벤트 모니터링 및 로깅 – 급격한 로그인 시도나 반복적인 OTP 실패와 같은 이상 징후를 감지합니다.

이러한 실천 방안을 적용하면 인증 시스템의 공격 표면을 크게 줄이고 사용자의 자격 증명을 보다 안전하게 보호할 수 있습니다.

Back to Blog

관련 글

더 보기 »