Loading the catalog…
Loading the catalog…
"소셜 로그인은 당연히 공급자(Google, Kakao, Naver)가 이메일을 인증해 줬겠지?" 이 안일한 가정 하나가 서비스 전체의 관리자 계정 탈취(Account Takeover) 취약점으로 이어질 수 있습니다. 실제 서비스 운영 중 코드 리뷰를 진행하다가 발견한 치명적인 OAuth 계정 선점 및 탈취 취약점 과, 이를 프로덕션 무중단 배포로 안전하게 방어해 낸 과정을 공유합니다. 🚨 1. 문제의 발단: 어떻게 계정이 털릴 수 있었을까? 기존 시스템의 소셜 로그인 로직은 일반적인 웹 서비스들과 비슷하게 구성되어 있었습니다: 사용자가 카카오/구글/네이버로 로그인 요청. IdP(공급자)가 인증 후 사용자 정보(User Info)를 넘겨줌. 백엔드의 OAuth2SuccessHandler 가 넘어온 email 문자열 을 읽음. DB에서 해당 이메일을 가진 회원이 있는지 조회: 있으면? ➡️ 기존 회원으로 판단하고 즉시 로그인(JWT 발급) 없으면? ➡️ 신규 소셜 회원가입 처리 [공격 시나리오] 1. 피해자(또는 관리자)의 이메일: admin@company.com (일반 이메일/비밀번호 가입 계정) 2. 공격자가 카카오나 타 플랫폼에서 admin@company.com을 임의로 입력하여 소셜 계정 생성 (이메일 인증 미완료 상태) 3. 공격자가 우리 서비스에서 "카카오 로그인" 클릭 4. 우리 서버는 넘어온 email이 "admin@company.com"이라는 이유만으로 기존 관리자 계정의 세션을 그대로 발급해버림! ➡️ 💥 비밀번호 없이 관리자 계정 로그인 성공 (Account Takeover) 더 심각했던 점은, 아직 가입하지 않은 피해자의 이메일로 공격자가 먼저 소셜 가입을 해두면, 나중에 피해자가 정식 가입을 하려고 해도 계정이 선점당하는 문제도 존재했습니다. 🔍 2. 근거와 원인 분석: 공급자(IdP)의 이메일을 무조건 믿으면 안 되는 이유 소셜 공급자마다 '이메일 인증 여부' 를 다루는 정책이 완전히 다릅니다: 공급자 이메일 인증 여부 필드 특징 Google email_verified (boolean) 대부분 true이지만 예외적인 경우(도메인 미인증 계정 등) false 가능 Kakao kakao_account.is_email_verified 이메일 인증을 안 거친 계정도 존재할 수 있음! (is_email_valid도 별도 존재) Naver ❌ 인증 여부 필드를 아예 안 줌 네이버는 응답 객체에 이메일 인증 여부 플래그 자체가 없음 문제의 코드 (Before) // 기존 OAuth2UserInfo - 공급자가 넘겨준 email 문자열만 그대로 추출 public class OAuth2UserInfo { private String email; public static OAuth2UserInfo of(String registrationId, Map<String, Object> attributes) { // ... 공급자별 파싱 로직 // ❌ 치명적 실수: 공급자가 '이 이메일의 소유권을 인증했는지' 확인하는 플래그를 전혀 읽지 않음! return new OAuth2UserInfo(email); } } 결국 "공급자가 넘겨준 이메일은 이미 검증된 이메일일 것이다"라는 암묵적 가정 이 보안 구멍을 만든 근본 원인이었습니다. 🛠️ 3. 해결 방안: 어떻게 방어했을까? 이 취약점을 해결하기 위해 3중 방어 체계를 구축했습니다. 1 공급자별 이메일 인증 여부(emailVerified) 필수 해석 public class OAuth2UserInfo { private String email; private boolean emailVerified; // 인증 여부 필드 추가 public static OAuth2UserInfo ofKakao(Map<String, Object> attributes) { Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account"); String email = (String) account.get("email"); // 카카오: 인증 여부(is_email_verified)와 유효성(is_email_valid) 모두 확인 boolean verified = Boolean.TRUE.equals(account.get("is_email_verified")) && Boolean.TRUE.equals(account.get("is_email_valid")); return new OAuth2UserInfo(email, verified); } public static OAuth2UserInfo ofNaver(Map<String, Object> attributes) { // 네이버: 인증 여부 필드를 제공하지 않으므로 기본적으로 '미확인(false)' 처리 return new OAuth2UserInfo(email, false); } } 2 OAuth2SuccessHandler 교차 로그인 보안 규칙 강화 구글 / 카카오 (미인증 이메일) : 기존 계정이 있더라도 로그인을 단호히 거절 하고 에러 페이지로 리다이렉트 ( /login?error=unverified_email ). 사용자에게 해당 소셜 플랫폼에서 이메일 인증을 완료하고 오도록 안내. 네이버 (인증 여부 미제공) : 기존 계정의 가입 방식(Provider)이 이미 NAVER일 때만 로그인 허용 . 기존 계정이 일반 이메일 가입자라면 자동으로 엮지 않고 명시적인 계정 연동을 요구 ( /login?error=link_required ). 3 후속 아키텍처 개선: 이메일이 아닌 '소셜 고유 식별자(ID)'로 1순위 식별 이메일은 변경될 수 있고 공급자 정책에 따라 위험이 따르므로, 장기적으로는 social_accounts 매핑 테이블을 두고 소셜 고유 ID로 식별하도록 개선했습니다: Google: sub Kakao: id Naver: response.id 최초 1회만 엄격한 인증 검증 후 연동을 맺어두면, 이후에는 소셜 쪽 이메일 상태와 무관하게 안전하고 빠른 로그인이 보장됩니다. 🧪 4. 검증 결과 단위 테스트 작성 : OAuth2SuccessHandlerTest (8개 시나리오 - 미인증 이메일 거절, 교차 연동 차단 등 100% 검증) 전체 회귀 테스트 336개 ALL PASS 운영 배포 : 다운타임 없이 안전하게 배포 완료, 기존 정상 인증 회원들의 로그인에는 영향 없이 비정상 접근만 완벽 차단. 💡 이번 트러블슈팅을 통해 얻은 교훈 외부 플랫폼(IdP)의 데이터를 맹신하지 말 것 : "구글/카카오니까 당연히 검증되었겠지"라는 생각이 가장 위험합니다. 이메일을 소셜 계정의 유일한 식별자로 쓰지 말 것 : 이메일 대신 공급자의 고유 회원 식별 번호( sub , id )를 Key로 사용하는 것이 정석입니다. 보안 검증은 반드시 서버에서 강제할 것 : 프론트엔드 화면에서 아무리 가드를 쳐도, 엔드포인트 직통 호출을 막으려면 백엔드 비즈니스 로직에서 인증 여부를 직접 검증해야 합니다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
[보안] 소셜 로그인에서 이메일 인증 여부를 확인하지 않으면 생기는 일 (OAuth Account Takeover 방어기). "소셜 로그인은 당연히 공급자(Google, Kakao, Naver)가 이메일을 인증해 줬겠지?" 이 안일한 가정 하나가 서비스 전체의 관리자 계정 탈취(Account Takeover) 취약점으로 이어질 수 있습니다. 실제 서비스 운영 중 코드 리뷰를 진행하다가 발견한 치명적인 OAuth 계정 선점 및 탈취 취약점 과, 이를 프로덕션 무중단 배포로 안전하게 방어해 낸 과정을 공유합니다. 🚨 1. 문제의 발단: 어떻게 계정이 털릴 수 있었을까? 기존 시스템의 소셜 로그인 로직은 일반적인 웹 서비스들과 비슷하게 구성되어 있었습니다: 사용자가 카카오/구글/네이버로 로그인 요청.…
Open source