로그인 말고 복구로 들어온다: 재설정 오염, MFA 피로, SMS 의존, 타이밍 공격

아프로·2026년 11월 1일

로그인 말고 복구로 들어온다 표지

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일 기준 공개 자료를 따르고, 보도로만 확인된 수치는 그렇게 밝힙니다.

한눈에 보기

정문인 로그인과 네 개의 뒷문. 비밀번호 재설정 오염, MFA 피로 공격, SMS 단일 인증 의존, 타이밍 사이드채널

기법한 줄 정의대표 사례막는 핵심가장 가까운 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

네 기법 모두 "로그인 기능은 잘 만들었다"는 팀에서 생깁니다. 비밀번호 해시와 로그인 속도 제한을 갖춘 서비스도, 재설정 메일을 만드는 코드와 푸시 승인 화면과 문자 인증 이후 단계는 다른 사람이 다른 시기에 만든 경우가 많습니다. 이 글은 그 네 흐름을 하나씩 엽니다.

1. 비밀번호 재설정 오염과 계정 복구 남용

원리: 재설정 메일의 두 값은 누가 정하나

재설정 메일의 링크 도메인과 받는 주소를 정상, Host 헤더 오염, 복구 대상 혼동으로 비교한 그림

비밀번호 재설정은 이렇게 동작합니다. 사용자가 "비밀번호를 잊었어요"를 누르면 서버가 일회용 토큰을 만들고, 그 토큰을 담은 링크를 계정 이메일로 보냅니다. 링크를 연 사람이 새 비밀번호를 정합니다. 토큰을 가진 사람이 곧 계정 주인으로 취급되는 구조입니다.

이 메일에는 서버가 정해야 하는 값이 두 개 있습니다. 링크의 도메인과 받는 주소입니다. 둘 중 하나라도 요청에서 가져오면 토큰이 엉뚱한 곳으로 갑니다.

① Host 헤더 오염. 많은 웹 프레임워크가 "지금 요청을 받은 주소"를 쉽게 꺼내 쓰도록 도와줍니다. 재설정 링크를 만들 때 이 값, 곧 요청의 Host 헤더(또는 프록시가 붙이는 X-Forwarded-Host)를 쓰면 문제가 생깁니다. Host 헤더는 요청을 보내는 쪽이 정하는 값입니다. 공격자가 피해자 계정으로 재설정을 요청하면서 Host를 자기 도메인으로 바꾸면, 서버는 진짜 서비스 메일을 피해자에게 보내지만 그 안의 링크는 공격자 도메인을 가리킵니다. 피해자가 메일을 의심하지 않고 링크를 누르면 토큰이 공격자 서버의 로그에 남습니다.

② 복구 대상 혼동. "이 계정의 이메일"을 서버 DB에서 꺼내지 않고 요청 본문에서 받거나, 사용자가 등록만 하고 검증하지 않은 보조 이메일에도 메일을 보내면, 토큰이 처음부터 공격자 메일함으로 갑니다. 이 경우는 피해자가 아무것도 누를 필요가 없습니다. GitLab 사례가 이 유형입니다.

두 경우 모두 서버 입장에서는 "재설정 메일 한 통을 보냈을" 뿐입니다. 로그에 이상한 오류가 남지 않고, 기능 테스트도 통과합니다.

왜 OWASP Top 10에 이름이 없나

이 문제는 A07 인증 실패(특히 계정 복구)와 A02 보안 설정 오류(신뢰할 프록시와 허용 호스트 설정)에 걸칩니다. Top 10은 "약한 계정 복구 절차"를 언급하지만, Host 헤더 오염은 이름으로 다루지 않습니다. 놓치기 쉬운 이유가 하나 더 있습니다. 어떤 프레임워크는 허용 호스트 목록을 기본으로 강제하고, 어떤 프레임워크는 설정하지 않으면 받은 값을 그대로 돌려줍니다. 같은 코드가 프레임워크와 배포 구성에 따라 안전하기도 하고 아니기도 합니다.

실제 사례

  • GitLab CVE-2023-7028(2024-01-11 패치, CVSS 10.0). 16.1.0에 추가된 보조 이메일 재설정 기능 때문에 재설정 메일이 검증되지 않은 주소로도 갈 수 있었습니다. 사용자 상호작용 없이 계정 탈취가 가능했고, 2단계 인증을 켠 계정은 탈취까지 이어지지 않았습니다. 패치 2주 뒤에도 노출된 미패치 인스턴스가 5천 대 이상 관측됐고(Shadowserver), 호주 사이버보안센터(ACSC)가 경보를 냈습니다.
  • Pimcore Admin Classic Bundle CVE-2024-23648(2024-01, CVSS 8.8). 재설정 URL을 요청의 Host 헤더로 만들었습니다. 수정은 설정된 호스트만 쓰도록 바꾼 것입니다.
  • CouchAuth(npm @perfood/couch-auth 0.20.0 이하, GHSA-fqh6-6h6c-366m). 같은 유형으로 재설정 토큰이 공격자 서버로 유출될 수 있었습니다.

서버에서 막기

① 링크 도메인은 설정값으로만

재설정·초대·이메일 인증 메일의 링크는 배포 설정에 고정한 공개 주소로 만듭니다. 요청 헤더는 쓰지 않습니다.

# 재설정 링크 생성 (방어 예시, 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의 검증된 기본 이메일 하나

요청 본문에는 계정을 찾을 식별자만 받습니다. 메일을 보낼 주소는 서버가 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": "입력한 정보와 일치하는 계정이 있으면 메일을 보냈습니다."}

토큰 규칙도 함께 정리합니다.

  • 충분한 난수: 128비트 이상. 위 예시는 256비트입니다.
  • DB에는 해시만: DB가 유출돼도 토큰을 바로 쓸 수 없게 합니다.
  • 1회 사용, 짧은 만료: 15~30분. 사용하면 즉시 폐기합니다.
  • 비밀번호를 바꾸면 전부 폐기: 다른 재설정 토큰과 기존 세션을 모두 끊습니다. 공격자가 먼저 들어와 있었다면 그 세션도 함께 끊깁니다.

③ 설정 수준: 알 수 없는 Host는 앞에서 거절

애플리케이션 코드와 별개로, 프레임워크와 리버스 프록시에서 허용 호스트를 명시합니다. 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 변경 같은 고위험 작업을 제한합니다. 복구가 탈취였을 때 피해가 번지는 것을 늦춥니다.

탐지 신호

  • 재설정 요청의 Host·X-Forwarded-Host가 서비스 도메인과 다름
  • 같은 계정에 짧은 간격으로 재설정 요청이 반복됨
  • 재설정 직후 이메일·MFA 변경
  • 재설정 요청 본문에 예상하지 않은 이메일 필드가 있음

2. MFA 피로 공격(푸시 폭탄)

원리: 승인하는 사람에게 확인할 수단이 없다

예/아니오 푸시와 번호 일치 방식 비교, 그리고 번호 일치의 서버 처리 세 단계

MFA를 켠 서비스에서 공격자가 비밀번호를 손에 넣었다고 해 봅시다. 로그인하면 사용자 휴대폰에 "로그인을 승인할까요? 예 / 아니오" 알림이 갑니다. 사용자는 이 로그인이 자기 것인지 확인할 방법이 없습니다. 화면에는 버튼 두 개뿐입니다.

공격자는 로그인을 계속 반복합니다. 서버가 푸시 발송 횟수를 제한하지 않으면 알림이 수십 번 울립니다. 사용자는 오류라고 생각하거나, 알림을 멈추려고, 또는 실수로 "예"를 누릅니다. 한 번이면 충분합니다. 여기에 지원팀을 사칭한 전화("보안 점검 중이니 알림을 승인해 주세요")가 함께 쓰이는 경우가 많습니다.

이 공격은 MFA를 우회하지 않습니다. MFA는 설계대로 동작했고, 사람이 승인했습니다. 약점은 승인 화면이 판단 근거를 주지 않는 것과 발송 횟수에 상한이 없는 것, 두 가지입니다.

왜 OWASP Top 10에 이름이 없나

A07 인증 실패에 포함될 수 있지만 이름으로 다뤄지지 않습니다. Top 10은 주로 "MFA가 없음"을 지적합니다. MFA 피로 공격은 MFA가 있는데도 승인 화면과 발송 제한 설계 때문에 침해된다는 점이 다릅니다. 점검표에 "MFA 적용: 예"라고 적힌 시스템이 대상입니다.

실제 사례

  • Uber(2022-09-15). 공격자는 다크웹에서 구매한 것으로 보이는 외주 직원 계정 비밀번호로 로그인을 반복했습니다. 처음에는 2단계 승인 요청이 접근을 막았지만 반복 끝에 직원이 승인 하나를 눌렀고, 이후 다른 직원 계정과 G-Suite·Slack 같은 내부 도구 권한까지 확보했습니다. Uber는 Lapsus$ 그룹과 연계된 것으로 추정했습니다.
  • Cisco(2022-05-24 인지, 2022-08-10 Talos 공개). 직원 개인 Google 계정에 동기화된 저장 비밀번호가 출발점이었습니다. 며칠에 걸친 보이스피싱과 반복 푸시로 MFA 승인을 얻어 VPN에 접속했고, 이후 새 MFA 기기 여러 대를 등록했습니다. 한 번 승인을 얻은 뒤 다음부터 승인을 요청할 필요가 없게 만든 것입니다.
  • Microsoft Authenticator 번호 일치 기본 적용(2023-05-08). Microsoft는 MFA 피로 공격 대응으로 모든 Authenticator 푸시에 번호 일치를 적용했습니다. 로그인 화면에 표시된 숫자를 앱에 입력해야 승인됩니다.

서버에서 막기

① 번호 일치: 로그인 화면에만 보이는 숫자

로그인 시도마다 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)
  • 푸시 화면에는 위치, 기기, 앱 이름을 함께 보여 줍니다. 사용자가 판단할 근거가 생깁니다.
  • "거절" 응답보다 강한 "내 시도가 아님" 버튼을 둡니다. 이 신호는 비밀번호가 이미 공격자 손에 있다는 뜻이므로, 비밀번호를 무효화하거나 보안팀에 알립니다.

② 새 MFA 기기 등록은 기존 강한 인증으로

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());
}

③ 설정과 아키텍처

  • 관리자·외주 계정은 피싱 저항 MFA만: FIDO2·패스키처럼 등록된 기기와 사이트 주소를 함께 확인하는 수단만 허용합니다. 승인 버튼만 누르는 방식은 쓰지 않습니다. Uber 사례의 출발점이 외주 직원 계정이었습니다.
  • 인증 이벤트를 SIEM으로: 인증 서버에서 "비밀번호 성공 + MFA 반복 실패·무응답" 패턴을 이벤트로 발행하고, 보안 관제 규칙으로 연결합니다. 실시간 탐지 시리즈의 인가 거부를 보안 이벤트로 남기기와 같은 방식입니다.

탐지 신호

  • 한 계정에 짧은 시간 다수 푸시 발송
  • 거절 직후 즉시 재시도
  • 비정상 위치에서 비밀번호 성공 후 MFA 대기
  • 승인 직후 새 MFA 기기 등록
  • 새벽 시간대 승인

3. SMS 단일 인증 의존

원리: SMS는 통신사 보안만큼만 안전하다

SMS 인증 성공 뒤 상황별로 고위험 작업에 대한 서버 처리를 정한 위험 기반 추가 인증 표

국내 서비스는 휴대폰 본인인증과 SMS 인증번호에 특히 많이 기댑니다. 결제, 비밀번호 재설정, 출금, 기기 등록이 문자 한 통으로 끝나는 서비스가 흔합니다. 이 설계는 "그 번호로 온 문자를 받은 사람 = 본인"이라는 전제 위에 서 있습니다.

그런데 SMS는 서버 팀이 통제할 수 없는 통신사 망을 거칩니다. 문자가 다른 사람에게 가는 경로는 여럿입니다.

  • SIM 스와프: 공격자가 통신사를 속여 피해자 번호를 자기 USIM으로 옮깁니다. 그 순간부터 문자는 공격자 휴대폰으로 갑니다.
  • USIM 인증 정보 유출: 통신사에서 가입자 식별 정보와 USIM 인증키가 유출되면 복제 위험이 생깁니다.
  • 통신사 망에 무단 접속한 장비: 불법 기지국이 망에 붙어 문자와 ARS 인증을 가로챕니다.

서버가 SMS 인증 하나로 고위험 작업을 허용하면, 그 서비스의 보안 수준은 통신사 보안 수준에 묶입니다. 서버 팀이 통신사 보안을 바꿀 수는 없습니다. 바꿀 수 있는 것은 "SMS 하나만 믿는 설계"입니다.

왜 OWASP Top 10에 이름이 없나

A07 인증 실패의 "약한 인증 요소"에 해당하지만, Top 10은 SIM 스와프나 통신망 위험을 다루지 않습니다. 웹 애플리케이션 바깥의 문제로 보기 때문입니다. 미국 NIST의 디지털 신원 지침 SP 800-63B는 SMS(전화망) OTP를 "제한적(restricted)" 인증 수단으로 분류해, 쓰더라도 위험을 알리고 대안을 제공하도록 요구합니다.

실제 사례

  • KT 무단 소액결제(2025-08-05 최초 발생, 2025-09 공개). KT 시스템에 등록되지 않은 불법 초소형 기지국(펨토셀)이 KT 망에 접속했습니다. 과학기술정보통신부 브리핑 기준 초기 피해는 278건, 1억 7천만 원이었고, 이후 불법 펨토셀 ID 20개가 확인됐습니다. 2025년 12월 민관합동조사단은 KT의 펨토셀 보안 관리 미흡을 회사 귀책으로 판단했습니다. 2026년 7월 보도에 따르면 약 540억 원의 과징금이 부과됐습니다 [보도 기준, 개인정보위 원문 확인 필요]. 사고 전체 경위와 최종 피해 규모는 국내편 KT 무단 소액결제: 잃어버린 기지국 하나가 인증을 무너뜨렸다에서 자세히 다뤘습니다. 서버 쪽에서 볼 지점은 하나입니다. 결제가 문자 인증 하나로 승인됐다는 것입니다.
  • SK텔레콤 USIM 정보 유출(2025-04 공개). 가입자 약 2,324만 명의 휴대폰 번호, IMSI(가입자 식별번호), USIM 인증키 등 25종 정보가 유출됐습니다. 개인정보위는 2025년 8월 27일 과징금 1,347억 9,100만 원을 의결했고(출범 이후 최대), SKT는 취소 소송을 제기했습니다. 국내편 SK텔레콤 유심 해킹: 4년 숨은 백도어와 평문 인증키에서 다뤘습니다.
  • 미국 FCC SIM 스와프 규칙(2023-11 채택). 미국 연방통신위원회는 통신사가 SIM 교체·번호 이동 전에 고객을 확실히 인증하고 고객에게 알리도록 의무화했습니다.

2025년 국내 두 통신사 사고는 "SMS는 통신사 보안만큼만 안전하다"를 한 해에 두 번 보여 줬습니다.

서버에서 막기

① 고위험 작업은 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"
  • 번호 변경·USIM 교체 이력 조회: 국내에서는 본인확인기관이나 통신사가 이런 조회 서비스를 제공하는 경우가 있습니다. 다만 서비스별로 제공 범위가 다릅니다 [추정]. 연동할 수 있다면 최근 교체된 번호의 고위험 작업을 일정 시간 막습니다.
  • SMS 인증 직후 한도는 낮게: 새 기기 + SMS 인증 조합은 바로 처리하지 않고 쿨링 기간을 둡니다.

③ 설정과 아키텍처

  • 사용자에게 강한 수단(패스키, 앱 인증)을 등록하고 SMS 인증을 끌 수 있는 선택지를 줍니다.
  • 이상이 감지되면 SMS 대신 다른 채널(앱 푸시, 이메일)로 통지합니다. 문자가 공격자에게 가고 있다면 문자로 보낸 경고도 공격자가 받습니다.
  • 결제 쪽에는 같은 지역·같은 시간대에 몰리는 소액결제를 잡는 이상탐지를 둡니다.

탐지 신호

  • 짧은 시간에 다수 계정에서 같은 지역·같은 시간대 SMS 인증 후 소액결제(KT 사례 유형)
  • 번호 변경 직후 복구 요청
  • 새 기기 + 고액 결제 조합
  • 인증 성공 직후 결제수단 변경

4. 인증 타이밍 사이드채널

원리: 문구는 같아도 시간이 다르다

로그인 실패 응답 시간 개념도. 조기 반환은 없는 사용자가 빨리 응답하고, 더미 해시 검증은 두 경우가 같은 시간이 걸린다

로그인 실패 메시지를 "아이디가 없습니다"와 "비밀번호가 틀립니다"로 나누면 계정 존재 여부가 드러난다는 것은 잘 알려져 있습니다. 그래서 많은 서비스가 "이메일 또는 비밀번호가 올바르지 않습니다"로 문구를 합쳤습니다. 다음 단계가 응답 시간입니다.

① 사용자 열거. 비밀번호는 bcrypt나 Argon2처럼 일부러 느리게 만든 해시로 저장합니다. 검증에 수십~수백 밀리초가 걸리도록 설계된 함수입니다. 그런데 코드가 "사용자가 없으면 바로 실패를 돌려준다"로 돼 있으면, 없는 사용자는 해시 검증을 건너뛰어 눈에 띄게 빨리 응답합니다. 문구는 같아도 시간이 계정 존재 여부를 알려 줍니다. 비밀번호 재설정도 같습니다. 있는 사용자에게만 토큰 생성과 메일 발송을 하면 그만큼 시간 차가 생깁니다. 이렇게 확인된 계정 목록은 크리덴셜 스터핑(유출된 아이디·비밀번호 묶음을 다른 서비스에 대입하는 공격)의 효율을 높입니다.

② 토큰·서명 비교. 토큰이나 HMAC 서명을 일반 문자열 비교(==, equals)로 확인하면, 비교가 첫 번째 다른 글자에서 끝납니다. 앞부분이 많이 맞을수록 조금 더 오래 걸립니다. 네트워크 지터 때문에 원격에서 이 차이를 재기는 어렵지만, 통계적 측정과 요청을 동시에 도착시키는 기법으로 가능성이 커졌다는 연구가 있습니다(USENIX Security 2020, "Timeless Timing Attacks").

왜 OWASP Top 10에 이름이 없나

사용자 열거는 A07 인증 실패에, 상수 시간이 아닌 비교는 A04 암호화 실패에 걸칩니다. CWE로는 CWE-208(관찰 가능한 타이밍 차이)과 CWE-203(관찰 가능한 차이)이지만 Top 10에 이름은 없습니다. 기능 테스트에서 응답이 똑같아 보이므로 발견이 늦습니다.

실제 사례

  • Mautic CVE-2024-47057(비밀번호 찾기). 존재·비존재 사용자의 응답 시간 차이와 요청 제한 부재가 합쳐져 사용자명 확인이 가능했습니다.
  • Mautic CVE-2025-9824(로그인, GHSA-3ggv-qwcp-j6xg). 유효한 사용자명일 때만 비밀번호 해시 검증을 수행했습니다. 수정은 없는 사용자에게도 더미 해시 검증을 수행하는 인증기를 넣은 것입니다.
  • Fides CVE-2024-45052(2.44.0 수정), Vendure CVE-2026-25050(GHSA-6f65-4fv2-wwch), Note Mark CVE-2026-40263. 모두 "사용자가 없으면 일찍 반환" 패턴입니다. 같은 결함이 2024년부터 2026년까지 프로젝트를 바꿔 가며 반복 보고되고 있습니다.

서버에서 막기

① 없는 사용자도 더미 해시를 검증한다

// 로그인: 없는 사용자도 같은 비용의 해시 검증을 수행한다 (방어 예시, 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 키처럼 코드와 로그 밖에 숨겨야 하는 값, 곧 시크릿을 비교할 때는 언어가 제공하는 상수 시간 비교 함수를 씁니다.

언어함수
JavaMessageDigest.isEqual(a, b)
Pythonhmac.compare_digest(a, b)
Node.jscrypto.timingSafeEqual(a, b) (길이가 같아야 함)
Gosubtle.ConstantTimeCompare(a, b)

토큰은 DB에 해시로 저장하고, 들어온 토큰을 해시한 뒤 비교하면 비교 대상의 길이도 항상 같아집니다.

③ 메일 발송은 큐로, 응답은 같은 경로로

재설정·가입 확인 메일 발송은 비동기 큐로 넘깁니다. 1절의 재설정 코드처럼 계정 유무와 관계없이 같은 문구로 바로 응답하면, 메일 발송 시간이 응답 시간에 섞이지 않습니다. 오류 메시지뿐 아니라 상태 코드와 응답 크기도 통일합니다.

④ 속도 제한이 타이밍 공격을 실제 공격으로 만들지 않게 한다

타이밍 차는 한 번 재서는 알 수 없고, 같은 요청을 많이 보내 분포를 봐야 드러납니다. 로그인·재설정 엔드포인트에 IP와 계정 두 축의 속도 제한을 두면 측정 자체가 어려워집니다. Mautic CVE-2024-47057은 시간 차와 요청 제한 부재가 합쳐져서 취약점이 됐습니다. 로그인 속도 제한과 대량 로그인 탐지는 국내편 GS리테일·티머니·알바몬: 비밀번호가 맞아도 막아야 했던 로그인에서 자세히 다뤘습니다.

⑤ 아키텍처 수준: 인증 로직은 한곳에

서비스마다 로그인·재설정을 따로 구현하면, 한 곳은 더미 해시를 쓰고 다른 곳은 조기 반환을 하는 일이 생깁니다. 인증 로직을 공용 라이브러리나 인증 게이트웨이로 모읍니다.

탐지 신호

  • 존재하지 않는 계정명으로 대량 로그인·재설정 요청
  • 계정명이 사전 순서로 들어오는 요청
  • 같은 계정에 반복 요청하며 응답 시간 분포를 재는 듯한 패턴

열거 시도를 신원 축으로 잡는 방법은 실시간 탐지 시리즈의 신원별 슬라이딩 윈도우와 검증 3: 열거·퍼징·느린 공격에서 다뤘습니다.

설계 원칙으로 정리

  1. 복구는 로그인만큼 강하게. 재설정, MFA 기기 등록, 번호 변경 후 인증은 모두 "계정을 여는 흐름"입니다. 로그인과 같은 속도 제한, 알림, 고위험 작업 제한을 둡니다.
  2. 보안에 쓰이는 값은 서버가 정한다. 링크 도메인은 설정값, 받는 주소는 DB의 검증된 값. 요청에서 온 Host나 이메일은 어느 쪽에도 쓰지 않습니다.
  3. 승인하는 사람에게 판단 근거를 준다. 번호 일치, 위치·기기 정보, "내 시도 아님" 버튼이 있어야 사람이 마지막 방어선 역할을 할 수 있습니다.
  4. 한 요소만 믿지 않는다. SMS는 통신사 보안에, 푸시는 사람의 주의력에 기댑니다. 고위험 작업에는 성격이 다른 두 번째 수단을 붙입니다.
  5. 겉으로 같으면 속도도 같게. 문구, 상태 코드, 응답 크기, 응답 시간까지 계정 유무와 무관하게 만듭니다.

점검 체크리스트

  • 재설정·초대·인증 메일의 링크 도메인은 설정값으로만 만든다(request.getServerName(), request.host 미사용)
  • 재설정 메일은 DB에 저장된 검증 완료 기본 이메일 하나로만 보낸다. 요청 본문의 이메일은 무시한다
  • 재설정 토큰은 128비트 이상 난수, DB에는 해시, 1회 사용, 15~30분 만료이고, 비밀번호 변경 시 다른 토큰과 세션을 모두 폐기한다
  • 프레임워크 허용 호스트와 신뢰 프록시가 명시돼 있고, 리버스 프록시가 알 수 없는 Host를 거절한다
  • 재설정이 요청되면 기존 채널로 알리고, 복구 직후 고위험 작업이 일정 시간 제한된다
  • 푸시 MFA는 번호 일치를 쓰고, 계정당 발송 횟수 제한이 있으며, "내 시도 아님" 신호가 비밀번호 무효화로 이어진다
  • 새 MFA 기기 등록에 기존 기기 승인이나 패스키가 필요하고, 등록 시 모든 기존 기기에 알린다
  • 관리자·외주 계정은 FIDO2·패스키만 허용한다
  • 출금·한도 상향·복구·기기 등록은 SMS 단독으로 처리되지 않는다
  • 새 기기·최근 번호 변경·지역 몰림을 반영한 위험 기반 추가 인증이 있다
  • 로그인·재설정에서 없는 사용자도 같은 비용의 더미 해시 검증을 하고, 시크릿 비교는 상수 시간 함수로 한다
  • 로그인·재설정 엔드포인트에 IP와 계정 두 축의 속도 제한이 있다

정리

  • 계정은 로그인 폼으로만 열리지 않습니다. 재설정 메일, 푸시 승인, SMS 인증, 응답 시간이 모두 계정을 여는 흐름이고, 대개 로그인보다 덜 검토됩니다.
  • 재설정 오염은 설정값 하나와 DB 조회 하나로 막힙니다. 링크 도메인과 받는 주소를 요청에서 가져오지 않으면 됩니다.
  • MFA는 있는 것만으로는 부족합니다. 번호 일치와 발송 제한이 없으면 사람이 지쳐서 승인하고, SMS 하나에 기대면 통신사 사고가 곧 우리 사고가 됩니다.
  • 문구를 통일했다면 다음은 시간입니다. 더미 해시 한 줄과 상수 시간 비교 함수로 응답 시간 차이를 없앱니다.

더 깊게

보안을 더 알고 싶다면

오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.

실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩

  • AI로 자동화된 해킹 시대, 오픈소스 쇼핑몰 코드에서 실제 사고 22건을 재연하고 직접 취약점을 찾아 한 건씩 막습니다.
  • OWASP Top 10:2025 전체와 목록 밖 위험 20가지를 102개 수업으로 다루고, 취약점 37개를 모두 막은 최종 소스를 가져갑니다. Spring Boot와 Express 모두 다룹니다.
  • 이 글과 이어지는 수업: 4-4, 4-5, 4-6, 4-7

서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.

백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지

  • 서버를 공격하는 흐름을 로그로 보고, 내 서버와 Spring·Express 코드에서 직접 막아 봅니다.
  • SSH 하드닝, 인젝션, 인증·인가, 시크릿과 개인정보 보호, 침해사고 첫 한 시간까지 8개 섹션 54개 수업입니다.
  • 이 글과 이어지는 수업: 3-1, 3-5, 3-6, 3-7

출처와 확인 날짜

수치와 사실은 2026년 11월 1일 기준 공개 자료입니다. KT 사례의 과징금 약 540억 원은 보도 기준이며 개인정보위 의결서 원문으로 확인하지 못했습니다. GitLab과 Pimcore 공지는 2차 출처를 거쳐 확인했습니다. 이 글은 법률 자문이 아닙니다.


이전 글 봇과 청구서가 공격이 될 때: 자동화 위협과 비용 고갈 막기
다음 글 연동 앱이 뚫리면 내 데이터는: OAuth 토큰 탈취와 토큰 바인딩
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

0개의 댓글