인증 시스템의 숨겨진 취약점: Timing Attacks, IP Spoofing 및 Race Conditions에 대한 심층 분석
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 실패와 같은 이상 징후를 감지합니다.
이러한 실천 방안을 적용하면 인증 시스템의 공격 표면을 크게 줄이고 사용자의 자격 증명을 보다 안전하게 보호할 수 있습니다.