계정 열거는 로그인·회원가입·비밀번호 찾기의 응답 차이로 특정 이메일이 가입된 계정인지 외부에서 확정할 수 있는 정보 노출 결함이다.
데이터가 직접 새지는 않지만, 확정된 회원 명단은 뒤따르는 모든 공격의 단가를 떨어뜨린다. 다른 곳에서 유출된 비밀번호 목록이 우리 회원과 정확히 교차된다.
이 글은 자주 발생하는 실수 패턴, 표준 구현 코드, 30초 자가진단, 24시간 패치 순서를 정리했다.
한눈에 보는 핵심
Q. ‘가입되지 않은 이메일입니다’가 왜 취약점인가요? A. 이메일만 바꿔 같은 요청을 반복하면 응답이 예/아니오로 갈려 회원 명단이 확정됩니다. OWASP WSTG-IDNT-04의 계정 열거이며 CWE-204에 해당합니다. Q. 문구만 통일하면 끝나나요? A. 아닙니다. 문구가 같아도 HTTP 상태코드·리다이렉트 경로·응답 시간이 갈리면 그대로 판별 근거가 되고, 실무에서는 응답 크기 차이도 판별에 쓰입니다. 없는 계정에도 더미 해시로 같은 연산을 수행해야 합니다. Q. 자동 점검으로 어디까지 잡히나요? A. URL 기반 외부 점검은 표준 로그인 경로(/login·/signin 등)에 없는 계정으로 로그인해, 응답 본문이 '가입되지 않은·등록되지 않은' 류 문구로 계정 없음을 그대로 드러내는지까지 봅니다. 문구는 같은데 상태코드·리다이렉트·본문 길이가 갈리는 경우, 응답 시간 차, 비밀번호 찾기·중복확인 경로, 커스텀 로그인 경로는 외부 점검으로 잡히지 않으니 개발팀이 직접 확인해야 합니다.자주 발생하는 실수 패턴
| # | 패턴 | 위험도 |
|---|---|---|
| 1 | 로그인 실패를 ‘가입되지 않은 이메일’과 ‘비밀번호 오류’로 구분 | 높음 |
| 2 | 이메일 중복확인을 인증·제한 없는 공개 GET API로 노출 | 높음 |
| 3 | 비밀번호 찾기에서 미등록 주소만 ‘등록된 계정 없음’으로 분기 | 높음 |
| 4 | 문구는 같지만 HTTP 상태코드·리다이렉트가 계정 유무에 따라 다름 | 중간 |
| 5 | 없는 계정은 해시 비교를 건너뛰어 응답 시간이 짧음 | 중간 |
| 6 | 프로필·초대 URL이 /user/이메일 형태라 404·200으로 존재가 갈림 | 중간 |
표준 구현 코드
# 문구·상태코드·처리시간 3축을 통일한다 (Django 예시)
ERR = '이메일 또는 비밀번호가 올바르지 않습니다.'
DUMMY = make_password('timing-equalizer')
def login_view(request):
pw = request.POST.get('password', '')
u = User.objects.filter(email__iexact=request.POST.get('email', '')).first()
if u is None:
check_password(pw, DUMMY) # 없는 계정도 같은 연산 (CWE-208)
elif u.check_password(pw) and u.is_active:
return do_login(request, u)
# 계정 없음·비밀번호 불일치·잠금 전부 같은 문구, 같은 상태코드
# 401은 WWW-Authenticate 동반이 필수(RFC 9110 §15.5.2)라 폼 로그인엔 200 또는 422
return render(request, 'login.html', {'error': ERR}, status=200)
def reset_view(request): # 비밀번호 찾기도 응답 고정
u = User.objects.filter(email__iexact=request.POST['email']).first()
if u:
send_reset_mail(u)
return render(request, 'sent.html', status=200)
핵심은 "동작하는 코드"와 "안전한 코드"가 다르다는 점이다.
AI 코딩 도구는 기능 충족 코드를 우선 출력하므로 보안 분기는 별도로 강제해야 한다.
미들웨어·정책·CI 게이트로 모든 경로에 일괄 적용하는 것이 표준이다.
30초 자가진단
# macOS/Linux 셸 기준. example.com·경로를 내 값으로 교체
# 비밀번호 찾기 경로는 실제 계정으로 찌르면 재설정 메일이 발송된다. 미등록 주소로만 테스트할 것
# CSRF 보호 폼(Django·Rails 등)은 토큰 없는 POST에 계정 유무와 무관한 403을 주므로
# GET으로 토큰·쿠키를 먼저 받아 함께 보낸다. 403/405만 나오면 '판정 불가'로 읽어라
for P in login password/reset/; do
for E in [email protected] [email protected]; do
rm -f jar
T=$(curl -s --max-time 15 -c jar "https://example.com/$P" \
| grep -o 'csrfmiddlewaretoken[^>]*value="[^"]*"' | grep -o 'value="[^"]*"' | cut -d'"' -f2)
curl -s --max-time 15 -b jar -o /tmp/o -X POST "https://example.com/$P" \
-H "Referer: https://example.com/$P" \
-d "csrfmiddlewaretoken=$T&email=$E&password=WrongPass123!" \
-w "$P $E %{http_code} %{size_download} %{time_total}\n"
grep -ciE '가입되지 않은|등록되지 않은|not registered' /tmp/o
done; done
# 비교용 '존재하는 계정'은 본인이 직접 만든 테스트 계정으로만 같은 명령을 돌린다
# 같은 경로 두 줄이 다르면 열거 가능
# 공개 중복확인 API (200이면 회원 조회기)
for U in api/check-email api/users/exists; do
printf '%s ' "$U"
curl -s --max-time 15 -o /dev/null -w '%{http_code}\n' \
"https://example.com/[email protected]"
done
위 시그널이 발견되면 결함이다. OWASP WSTG-IDNT-04 계정 열거 테스트를 함께 참조하라.
24시간 패치 절차
| 순위 | 조치 |
|---|---|
| 1 | 3경로(로그인·비밀번호 찾기·가입) 실패 문구를 단일 상수로 통일 — 내부 상태를 흘리는 분기 문구 제거 |
| 2 | 계정 없음·비밀번호 불일치·잠금 전부 같은 문구·같은 상태코드로 고정 (폼 로그인은 200 또는 422, 401을 쓰면 WWW-Authenticate 동반 필수) — 리다이렉트 경로와 응답 크기도 함께 맞춘다 |
| 3 | 없는 계정에도 더미 해시 비교 수행 — 응답 시간 차 제거 |
| 4 | 공개 이메일 중복확인 엔드포인트 제거, 결과 분기는 화면이 아니라 메일로만 |
| 5 | 남은 경로 점검: /user/이메일 URL, 초대 링크 404/200 차, 소셜 로그인 문구 (WSTG의 URI Probing 항목) |
지금 해야 할 것
실무자: CodeScan 무료 스캔으로 30초 점검 후 우선순위대로 패치.
CISO·임원: 조직 전체 점검·규제 대응은 SENTRIX 30분 1:1 상담으로.
결함은 발견 즉시, 늦어도 24시간 안에 패치하는 것이 표준이다.