
2024년 1월 11일, GitLab이 긴급 보안 업데이트를 냈습니다. 취약점(CVE-2023-7028)의 위험도 점수는 CVSS 만점인 10.0이었습니다. 원인은 로그인이 아니라 비밀번호 재설정 기능에 있었습니다. 16.1.0 버전에서 추가된 기능 때문에 재설정 메일이 검증되지 않은 이메일 주소로도 발송될 수 있었고, 사용자가 아무것도 누르지 않아도 계정을 넘겨받을 수 있었습니다. 패치가 나오고 2주가 지난 뒤에도 인터넷에 노출된 미패치 GitLab이 5천 대 넘게 관측됐습니다(Shadowserver).
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 4-4, 4-5, 4-6, 4-7
이 글의 범위. 계정 복구, MFA 승인, SMS 인증, 응답 시간이라는 네 흐름이 어떻게 계정을 여는지 원리와 방어를 다룹니다. 조작된 요청 예시, 푸시 반복 도구, 타이밍 측정 스크립트 같은 공격 쪽 내용은 싣지 않습니다. 코드는 모두 방어 쪽 코드이고 값은 가짜입니다. 사실관계는 2026년 11월 1일 기준 공개 자료를 따르고, 보도로만 확인된 수치는 그렇게 밝힙니다.

| 기법 | 한 줄 정의 | 대표 사례 | 막는 핵심 | 가장 가까운 OWASP |
|---|---|---|---|---|
| 비밀번호 재설정 오염·복구 남용 | 재설정 링크의 도메인이나 받는 주소를 요청 값으로 정해 토큰이 공격자에게 감 | GitLab CVE-2023-7028(CVSS 10, 2024-01) | 링크는 설정값 도메인, 발송은 DB의 검증된 이메일만 | A07·A02 |
| MFA 피로 공격 | 비밀번호를 가진 공격자가 푸시 승인을 반복해 사용자가 누르게 만듦 | Uber(2022-09), Cisco(2022-08) | 번호 일치, 푸시 횟수 제한, 새 기기 등록 통제 | A07 |
| SMS 단일 인증 의존 | SMS 인증번호를 본인 그 자체로 믿어 통신 구간이 넘어가면 결제·복구가 통과 | KT 무단 소액결제(2025) | 고위험 작업은 SMS 단독 금지, 위험 기반 추가 인증 | A07 |
| 인증 타이밍 사이드채널 | 응답 문구는 같은데 응답 시간이 계정 존재 여부나 토큰 일치를 드러냄 | Mautic CVE-2024-47057, CVE-2025-9824 | 없는 사용자도 더미 해시 검증, 상수 시간 비교 | A07·A04 |
네 기법 모두 "로그인 기능은 잘 만들었다"는 팀에서 생깁니다. 비밀번호 해시와 로그인 속도 제한을 갖춘 서비스도, 재설정 메일을 만드는 코드와 푸시 승인 화면과 문자 인증 이후 단계는 다른 사람이 다른 시기에 만든 경우가 많습니다. 이 글은 그 네 흐름을 하나씩 엽니다.

비밀번호 재설정은 이렇게 동작합니다. 사용자가 "비밀번호를 잊었어요"를 누르면 서버가 일회용 토큰을 만들고, 그 토큰을 담은 링크를 계정 이메일로 보냅니다. 링크를 연 사람이 새 비밀번호를 정합니다. 토큰을 가진 사람이 곧 계정 주인으로 취급되는 구조입니다.
이 메일에는 서버가 정해야 하는 값이 두 개 있습니다. 링크의 도메인과 받는 주소입니다. 둘 중 하나라도 요청에서 가져오면 토큰이 엉뚱한 곳으로 갑니다.
① Host 헤더 오염. 많은 웹 프레임워크가 "지금 요청을 받은 주소"를 쉽게 꺼내 쓰도록 도와줍니다. 재설정 링크를 만들 때 이 값, 곧 요청의 Host 헤더(또는 프록시가 붙이는 X-Forwarded-Host)를 쓰면 문제가 생깁니다. Host 헤더는 요청을 보내는 쪽이 정하는 값입니다. 공격자가 피해자 계정으로 재설정을 요청하면서 Host를 자기 도메인으로 바꾸면, 서버는 진짜 서비스 메일을 피해자에게 보내지만 그 안의 링크는 공격자 도메인을 가리킵니다. 피해자가 메일을 의심하지 않고 링크를 누르면 토큰이 공격자 서버의 로그에 남습니다.
② 복구 대상 혼동. "이 계정의 이메일"을 서버 DB에서 꺼내지 않고 요청 본문에서 받거나, 사용자가 등록만 하고 검증하지 않은 보조 이메일에도 메일을 보내면, 토큰이 처음부터 공격자 메일함으로 갑니다. 이 경우는 피해자가 아무것도 누를 필요가 없습니다. GitLab 사례가 이 유형입니다.
두 경우 모두 서버 입장에서는 "재설정 메일 한 통을 보냈을" 뿐입니다. 로그에 이상한 오류가 남지 않고, 기능 테스트도 통과합니다.
이 문제는 A07 인증 실패(특히 계정 복구)와 A02 보안 설정 오류(신뢰할 프록시와 허용 호스트 설정)에 걸칩니다. Top 10은 "약한 계정 복구 절차"를 언급하지만, Host 헤더 오염은 이름으로 다루지 않습니다. 놓치기 쉬운 이유가 하나 더 있습니다. 어떤 프레임워크는 허용 호스트 목록을 기본으로 강제하고, 어떤 프레임워크는 설정하지 않으면 받은 값을 그대로 돌려줍니다. 같은 코드가 프레임워크와 배포 구성에 따라 안전하기도 하고 아니기도 합니다.
재설정·초대·이메일 인증 메일의 링크는 배포 설정에 고정한 공개 주소로 만듭니다. 요청 헤더는 쓰지 않습니다.
# 재설정 링크 생성 (방어 예시, Python)
# 나쁜 예: f"{request.scheme}://{request.host}/reset?token=..." ← 요청이 정한 도메인
from settings import PUBLIC_BASE_URL # 예: "https://app.example.com" (배포 설정에 고정)
def build_reset_url(token: str) -> str:
return f"{PUBLIC_BASE_URL}/reset?token={token}"
// Spring: 재설정 링크는 설정값으로 (방어 예시)
// 나쁜 예: request.getRequestURL(), request.getServerName()으로 도메인을 만든다
@Value("${app.public-base-url}") // application.yml: app.public-base-url: https://app.example.com
private String publicBaseUrl;
String resetUrl = UriComponentsBuilder.fromHttpUrl(publicBaseUrl)
.path("/reset").queryParam("token", rawToken).toUriString();
request.getServerName()이나 HttpServletRequest.getRequestURL()로 링크를 만들고 있다면 설정값으로 바꾸는 것이 가장 먼저 할 일입니다. 수정 범위가 작고 효과는 확실합니다.
요청 본문에는 계정을 찾을 식별자만 받습니다. 메일을 보낼 주소는 서버가 DB에서 다시 꺼냅니다. 요청에 이메일 배열이나 추가 주소가 들어와도 무시합니다(GitLab 유형).
# 재설정 요청 처리 (방어 예시)
import secrets, hashlib, datetime
TOKEN_TTL = datetime.timedelta(minutes=20)
def request_password_reset(identifier: str):
user = users.find_by_login(identifier) # 요청에서는 '누구인지'만 받는다
if user and user.primary_email_verified:
raw = secrets.token_urlsafe(32) # 256비트 난수
resets.invalidate_all(user.id) # 이전 토큰 폐기
resets.insert(user_id=user.id,
token_hash=hashlib.sha256(raw.encode()).hexdigest(), # DB에는 해시만
expires_at=now() + TOKEN_TTL)
mail_queue.enqueue("reset", to=user.primary_email, # 받는 주소는 DB 값
url=build_reset_url(raw))
notify_other_channels(user, "비밀번호 재설정이 요청됐습니다") # 앱 푸시 등
# 계정이 있든 없든 같은 응답, 같은 경로(4절 타이밍과 연결)
return {"message": "입력한 정보와 일치하는 계정이 있으면 메일을 보냈습니다."}
토큰 규칙도 함께 정리합니다.
애플리케이션 코드와 별개로, 프레임워크와 리버스 프록시에서 허용 호스트를 명시합니다. Django는 ALLOWED_HOSTS, Spring은 server.forward-headers-strategy와 신뢰할 프록시 범위를 정해 아무 X-Forwarded-Host나 받아들이지 않게 합니다.
# nginx: 등록되지 않은 Host는 기본 서버 블록에서 거절 (방어 예시, 도메인은 가짜)
server {
listen 443 ssl default_server;
server_name _;
ssl_reject_handshake on; # 알 수 없는 이름의 TLS 연결은 받지 않는다
}
server {
listen 443 ssl;
server_name app.example.com;
location / {
proxy_set_header Host $host; # 위에서 걸러진 값만 남는다
proxy_set_header X-Forwarded-Host app.example.com; # 클라이언트 값을 덮어쓴다
proxy_pass http://127.0.0.1:8080;
}
}

MFA를 켠 서비스에서 공격자가 비밀번호를 손에 넣었다고 해 봅시다. 로그인하면 사용자 휴대폰에 "로그인을 승인할까요? 예 / 아니오" 알림이 갑니다. 사용자는 이 로그인이 자기 것인지 확인할 방법이 없습니다. 화면에는 버튼 두 개뿐입니다.
공격자는 로그인을 계속 반복합니다. 서버가 푸시 발송 횟수를 제한하지 않으면 알림이 수십 번 울립니다. 사용자는 오류라고 생각하거나, 알림을 멈추려고, 또는 실수로 "예"를 누릅니다. 한 번이면 충분합니다. 여기에 지원팀을 사칭한 전화("보안 점검 중이니 알림을 승인해 주세요")가 함께 쓰이는 경우가 많습니다.
이 공격은 MFA를 우회하지 않습니다. MFA는 설계대로 동작했고, 사람이 승인했습니다. 약점은 승인 화면이 판단 근거를 주지 않는 것과 발송 횟수에 상한이 없는 것, 두 가지입니다.
A07 인증 실패에 포함될 수 있지만 이름으로 다뤄지지 않습니다. Top 10은 주로 "MFA가 없음"을 지적합니다. MFA 피로 공격은 MFA가 있는데도 승인 화면과 발송 제한 설계 때문에 침해된다는 점이 다릅니다. 점검표에 "MFA 적용: 예"라고 적힌 시스템이 대상입니다.
로그인 시도마다 2자리 숫자를 만들어 로그인 화면에만 보여 주고, 승인 앱에서 그 숫자를 입력해야 통과시킵니다. 로그인을 시도하지 않은 사용자는 숫자를 모르므로, 반복 알림에 지쳐도 "예"를 누를 수 없습니다.
# 번호 일치 MFA와 푸시 발송 제한 (방어 예시, 값은 가짜)
import secrets, hmac
PUSH_LIMIT, PUSH_WINDOW = 3, 600 # 계정당 10분에 3회
ATTEMPT_TTL = 120 # 승인 대기 2분
def start_mfa_push(user_id: str, ctx: dict) -> dict:
n = r.incr(f"mfa:push:{user_id}")
if n == 1:
r.expire(f"mfa:push:{user_id}", PUSH_WINDOW)
if n > PUSH_LIMIT:
security_event("mfa_push_limit", user_id=user_id, **ctx)
return {"next": "totp_or_passkey"} # 푸시를 멈추고 다른 수단으로만
attempt_id = secrets.token_urlsafe(16)
number = f"{secrets.randbelow(90) + 10}" # 10~99
r.setex(f"mfa:attempt:{attempt_id}", ATTEMPT_TTL, f"{user_id}:{number}")
push.send(user_id, attempt_id=attempt_id, # 푸시에는 숫자를 넣지 않는다
location=ctx["geo"], device=ctx["ua_summary"], app=ctx["app_name"])
return {"next": "wait_push", "attempt_id": attempt_id, "show_number": number} # 로그인 화면에만
def on_push_answer(attempt_id: str, user_id: str, typed: str, denied: bool, not_me: bool):
val = r.getdel(f"mfa:attempt:{attempt_id}") # 1회만 소비
if not val:
return False
owner, number = val.decode().split(":")
if owner != user_id:
return False
if denied or not_me:
abort_login_attempts(user_id) # 진행 중인 시도 중단
if not_me:
force_password_reset(user_id) # 비밀번호가 새었다는 뜻
security_event("mfa_not_me", user_id=user_id)
return False
return hmac.compare_digest(typed, number)
Cisco 사례에서 공격자는 승인 한 번을 얻은 뒤 자기 기기를 MFA 수단으로 등록했습니다. 새 MFA 기기 등록은 기존 기기의 승인이나 패스키 같은 강한 인증 없이는 불가능하게 하고, 등록되면 모든 기존 기기와 이메일로 알립니다.
// 새 MFA 기기 등록: 최근 강한 인증을 요구하고 기존 기기에 알린다 (방어 예시)
public void registerMfaDevice(User user, Session session, MfaDevice newDevice) {
if (!session.hasRecentStrongAuth(Duration.ofMinutes(5))) // 패스키 또는 기존 기기 승인
throw new StepUpRequired("existing_device_or_passkey");
mfaDevices.save(user.getId(), newDevice);
notifier.toAllDevicesAndEmail(user, "새 인증 기기가 등록됐습니다: " + newDevice.summary());
securityEvents.publish("mfa_device_registered", user.getId(), session.context());
}

국내 서비스는 휴대폰 본인인증과 SMS 인증번호에 특히 많이 기댑니다. 결제, 비밀번호 재설정, 출금, 기기 등록이 문자 한 통으로 끝나는 서비스가 흔합니다. 이 설계는 "그 번호로 온 문자를 받은 사람 = 본인"이라는 전제 위에 서 있습니다.
그런데 SMS는 서버 팀이 통제할 수 없는 통신사 망을 거칩니다. 문자가 다른 사람에게 가는 경로는 여럿입니다.
서버가 SMS 인증 하나로 고위험 작업을 허용하면, 그 서비스의 보안 수준은 통신사 보안 수준에 묶입니다. 서버 팀이 통신사 보안을 바꿀 수는 없습니다. 바꿀 수 있는 것은 "SMS 하나만 믿는 설계"입니다.
A07 인증 실패의 "약한 인증 요소"에 해당하지만, Top 10은 SIM 스와프나 통신망 위험을 다루지 않습니다. 웹 애플리케이션 바깥의 문제로 보기 때문입니다. 미국 NIST의 디지털 신원 지침 SP 800-63B는 SMS(전화망) OTP를 "제한적(restricted)" 인증 수단으로 분류해, 쓰더라도 위험을 알리고 대안을 제공하도록 요구합니다.
2025년 국내 두 통신사 사고는 "SMS는 통신사 보안만큼만 안전하다"를 한 해에 두 번 보여 줬습니다.
출금, 결제 한도 상향, 계정 복구, 새 기기 등록에는 SMS 외의 수단을 하나 더 요구합니다. 패스키, 앱 기반 승인(2절의 번호 일치), 기존 기기 확인 중 하나입니다.
모든 결제에 두 번째 수단을 요구하면 사용자가 떠납니다. 그래서 상황에 따라 다음 단계를 정합니다. 기기 지문, 위치, 시간대, 거래 패턴, 그리고 최근 번호 변경 여부를 합쳐 점수를 내고, 점수에 따라 진행·추가 인증·보류를 고릅니다.
# SMS 인증 성공 이후의 다음 단계 결정 (방어 예시, 값은 가짜)
from dataclasses import dataclass
HIGH_RISK_ACTIONS = {"withdraw", "raise_limit", "account_recovery", "register_device"}
@dataclass
class Ctx:
action: str
amount: int
known_device: bool
usual_region: bool
number_changed_hours: float | None # 통신사·본인확인기관 조회 결과(없으면 None)
region_burst: bool # 같은 지역·시간대에 여러 계정 결제가 몰림
def after_sms_ok(ctx: Ctx) -> str:
if ctx.number_changed_hours is not None and ctx.number_changed_hours < 72:
if ctx.action in HIGH_RISK_ACTIONS:
return "hold_and_notify" # 최근 번호 변경 + 고위험: 보류, 기존 채널로 통지
if ctx.region_burst:
return "hold_and_alert" # KT 사례 유형: 이상탐지 경보
if ctx.action in HIGH_RISK_ACTIONS:
return "require_passkey_or_app" # SMS 단독 금지
if not ctx.known_device:
return "require_app_approval_low_limit" # 새 기기: 추가 인증 + 낮은 한도 + 쿨링
return "proceed"

로그인 실패 메시지를 "아이디가 없습니다"와 "비밀번호가 틀립니다"로 나누면 계정 존재 여부가 드러난다는 것은 잘 알려져 있습니다. 그래서 많은 서비스가 "이메일 또는 비밀번호가 올바르지 않습니다"로 문구를 합쳤습니다. 다음 단계가 응답 시간입니다.
① 사용자 열거. 비밀번호는 bcrypt나 Argon2처럼 일부러 느리게 만든 해시로 저장합니다. 검증에 수십~수백 밀리초가 걸리도록 설계된 함수입니다. 그런데 코드가 "사용자가 없으면 바로 실패를 돌려준다"로 돼 있으면, 없는 사용자는 해시 검증을 건너뛰어 눈에 띄게 빨리 응답합니다. 문구는 같아도 시간이 계정 존재 여부를 알려 줍니다. 비밀번호 재설정도 같습니다. 있는 사용자에게만 토큰 생성과 메일 발송을 하면 그만큼 시간 차가 생깁니다. 이렇게 확인된 계정 목록은 크리덴셜 스터핑(유출된 아이디·비밀번호 묶음을 다른 서비스에 대입하는 공격)의 효율을 높입니다.
② 토큰·서명 비교. 토큰이나 HMAC 서명을 일반 문자열 비교(==, equals)로 확인하면, 비교가 첫 번째 다른 글자에서 끝납니다. 앞부분이 많이 맞을수록 조금 더 오래 걸립니다. 네트워크 지터 때문에 원격에서 이 차이를 재기는 어렵지만, 통계적 측정과 요청을 동시에 도착시키는 기법으로 가능성이 커졌다는 연구가 있습니다(USENIX Security 2020, "Timeless Timing Attacks").
사용자 열거는 A07 인증 실패에, 상수 시간이 아닌 비교는 A04 암호화 실패에 걸칩니다. CWE로는 CWE-208(관찰 가능한 타이밍 차이)과 CWE-203(관찰 가능한 차이)이지만 Top 10에 이름은 없습니다. 기능 테스트에서 응답이 똑같아 보이므로 발견이 늦습니다.
// 로그인: 없는 사용자도 같은 비용의 해시 검증을 수행한다 (방어 예시, Spring Security)
private static final String DUMMY_HASH =
"$2a$12$abcdefghijklmnopqrstuuDUMMYDUMMYDUMMYDUMMYDUMMYDUMMYD"; // 가짜 값, 실제 비용 파라미터와 같게
public boolean authenticate(String email, String rawPassword) {
User u = repo.findByEmail(email).orElse(null);
String hash = (u != null) ? u.getPasswordHash() : DUMMY_HASH;
boolean ok = encoder.matches(rawPassword, hash) && u != null; // 검증은 항상 실행
return ok;
}
더미 해시는 실제 비밀번호 해시와 같은 알고리즘, 같은 비용 파라미터로 만들어야 합니다. 비용이 다르면 시간 차가 그대로 남습니다. 비용 파라미터를 올릴 때 더미 해시도 함께 바꿉니다.
토큰, HMAC 서명, API 키처럼 코드와 로그 밖에 숨겨야 하는 값, 곧 시크릿을 비교할 때는 언어가 제공하는 상수 시간 비교 함수를 씁니다.
| 언어 | 함수 |
|---|---|
| Java | MessageDigest.isEqual(a, b) |
| Python | hmac.compare_digest(a, b) |
| Node.js | crypto.timingSafeEqual(a, b) (길이가 같아야 함) |
| Go | subtle.ConstantTimeCompare(a, b) |
토큰은 DB에 해시로 저장하고, 들어온 토큰을 해시한 뒤 비교하면 비교 대상의 길이도 항상 같아집니다.
재설정·가입 확인 메일 발송은 비동기 큐로 넘깁니다. 1절의 재설정 코드처럼 계정 유무와 관계없이 같은 문구로 바로 응답하면, 메일 발송 시간이 응답 시간에 섞이지 않습니다. 오류 메시지뿐 아니라 상태 코드와 응답 크기도 통일합니다.
타이밍 차는 한 번 재서는 알 수 없고, 같은 요청을 많이 보내 분포를 봐야 드러납니다. 로그인·재설정 엔드포인트에 IP와 계정 두 축의 속도 제한을 두면 측정 자체가 어려워집니다. Mautic CVE-2024-47057은 시간 차와 요청 제한 부재가 합쳐져서 취약점이 됐습니다. 로그인 속도 제한과 대량 로그인 탐지는 국내편 GS리테일·티머니·알바몬: 비밀번호가 맞아도 막아야 했던 로그인에서 자세히 다뤘습니다.
서비스마다 로그인·재설정을 따로 구현하면, 한 곳은 더미 해시를 쓰고 다른 곳은 조기 반환을 하는 일이 생깁니다. 인증 로직을 공용 라이브러리나 인증 게이트웨이로 모읍니다.
열거 시도를 신원 축으로 잡는 방법은 실시간 탐지 시리즈의 신원별 슬라이딩 윈도우와 검증 3: 열거·퍼징·느린 공격에서 다뤘습니다.
request.getServerName(), request.host 미사용)오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. KT 사례의 과징금 약 540억 원은 보도 기준이며 개인정보위 의결서 원문으로 확인하지 못했습니다. GitLab과 Pimcore 공지는 2차 출처를 거쳐 확인했습니다. 이 글은 법률 자문이 아닙니다.
이전 글 봇과 청구서가 공격이 될 때: 자동화 위협과 비용 고갈 막기
다음 글 연동 앱이 뚫리면 내 데이터는: OAuth 토큰 탈취와 토큰 바인딩
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록