6월 12일, 시험이 진행 중인 시스템에서 비밀번호 저장 방식을 argon2id로 교체했습니다. 3차 결함리포트가 지적한 내용은 정확했습니다. 비밀번호가 솔트 없이 SHA-256 한 번으로 저장되어 있어, 레인보우 테이블로 평문을 복원할 수 있는 상태였습니다. 시험원이 계정으로 로그인하는 기간이었으므로 로그인이 한 번이라도 멈추면 안 됐고, 기존 사용자의 해시를 어떻게 전환할지가 실제 설계의 대부분을 차지했습니다.
GS인증 전체 결함 80건 중 보안성은 5건이었습니다. 수는 가장 적었지만 애플리케이션의 가장 아래층을 건드리는 수정이었습니다.
솔트가 없다는 지적은 정확했습니다
솔트 없는 단방향 해시의 문제는 두 가지입니다. 같은 비밀번호를 쓰는 두 사용자의 해시값이 같아지고, 미리 계산된 해시 사전(레인보우 테이블)과 대조하면 평문이 복원됩니다. SHA-256이 약한 알고리즘이어서가 아니라, 빠른 범용 해시를 솔트 없이 쓴 구조 자체가 문제였습니다.
대체제로는 argon2id를 선택했습니다. 파라미터는 argon2-cffi 기본값(m=65536, t=3, p=4, 솔트 16바이트, 해시 32바이트)을 그대로 썼습니다. 결과물은 $argon2id$v=19$... 형태의 PHC 문자열 하나로 저장되는데, 이 문자열이 솔트와 파라미터를 자체 포함한다는 점이 운영에서 중요했습니다. 솔트 컬럼을 따로 만들 필요가 없고, 나중에 파라미터를 올려도 기존 행과 새 행이 한 컬럼에 공존하며, Rust로 작성된 게이트웨이에서도 같은 문자열로 검증할 수 있었습니다.
프론트의 SHA-256 위에 argon2id를 덧씌웠습니다
XGEN의 프론트엔드는 원래 평문을 SHA-256으로 해시해서 전송하고 있었습니다. 이 구조는 유지했습니다. 서버가 받는 값은 언제나 64자 hex 문자열이고, 서버는 그 위에 사용자별 솔트와 argon2id를 덧씌워 저장합니다. 서버는 끝까지 평문을 보지 않습니다.
이 결정으로 클라이언트 코드는 한 줄도 바꾸지 않았고, 해시 함수의 입력이 "평문"이 아니라 "프론트가 보낸 SHA-256"이라는 계약만 명확히 문서화했습니다. 대신 조심할 지점이 생겼습니다. 이미 argon2id로 저장된 값을 다시 해시 함수에 넣으면 이중 해시가 되어 로그인이 영구히 깨집니다. 그래서 사용자 편집 API에서는 새 비밀번호가 실제로 전달된 경우에만 해시를 수행하고, 값이 없으면 기존 해시를 그대로 유지하도록 분기를 박았습니다.
기존 사용자는 로그인 성공 순간에 업그레이드했습니다
마이그레이션 이전 행에는 솔트 없는 SHA-256 hex 64자가 그대로 저장돼 있습니다. 저장값이 64자 16진수 패턴이면 레거시로 판별하고, PHC 문자열이면 argon2id로 검증하는 이중 경로를 만들었습니다. 레거시 사용자가 로그인에 성공하는 순간, 방금 검증에 쓴 값으로 argon2id 해시를 새로 만들어 그 자리에서 업그레이드합니다.
동시에 일괄 마이그레이션 스크립트도 준비해 기존 사용자 전체를 전환했습니다. 로그인 시점 업그레이드만 믿으면 오래 접속하지 않는 계정이 레거시 해시로 남기 때문입니다. 전환이 끝난 뒤에는 같은 비밀번호를 쓰는 사용자라도 해시값이 전부 달라졌고, 시험원의 재확인에서 평문 추적이 불가능함이 확인됐습니다.
다섯 번 틀리면 30분 잠급니다
같은 리포트의 다른 결함은 로그인 실패를 아무리 반복해도 계정이 잠기지 않는다는 것이었습니다. 실패 횟수를 세는 컬럼(failed_login_count)과 잠금 만료 시각(locked_until)을 추가하고, 연속 5회 실패 시 30분 잠금으로 정했습니다. 잠금 중에는 올바른 비밀번호를 입력해도 로그인이 차단되고, 30분이 지나면 자동 해제되거나 관리자가 즉시 해제할 수 있습니다.
구현에서 두 가지를 신경 썼습니다. 잠금 만료 비교는 애플리케이션이 아니라 데이터베이스의 NOW()로 수행해서, 서버와 DB의 타임존이 어긋나도 판정이 흔들리지 않게 했습니다. 관리자의 잠금 해제는 카운터 초기화와 함께 로그인 로그 테이블에 unlock 이벤트를 남겨, 누가 언제 풀었는지 감사 추적이 가능하도록 했습니다. 6월 16일에는 신규 컬럼이 없는 배포 환경에서도 동작하도록 컬럼 자동 생성 마이그레이션을 안전망으로 추가했습니다.
헤더 한 줄도 결함이었습니다
나머지 보안 결함은 애플리케이션 바깥에 있었습니다. HTTPS를 제공하면서 응답 헤더에 HSTS(Strict-Transport-Security)가 없다는 지적에는 프론트엔드와 백엔드 미들웨어에 max-age, includeSubDomains, preload 세 옵션을 일괄 적용했습니다. 시험기관은 preload 옵션 누락이 흔한 결함 지점이라고 함께 알려 줬습니다. OpenSSH의 원격 코드 실행 취약점(RegreSSHion)에 대한 지적은 서버의 SSH 접근을 차단해 대응했고, 게스트 회원가입에 비밀번호 확인 입력이 없던 것도 일반 가입과 동일하게 맞췄습니다.
보안성 결함 5건은 코드 다섯 줄이 아니라 저장 모델, 인증 흐름, 인프라 설정을 각각 관통했습니다. 그리고 흥미롭게도, 다섯 건 모두 기능이 "동작하는지"가 아니라 "무엇을 저장하고 무엇을 거부하는지"를 묻고 있었습니다. 다음 편에서는 반대로 가장 흔했던 패턴, 실패가 성공처럼 보이던 화면들을 정직하게 만드는 과정을 다룹니다.

