블로그 구축기 네 번째 글이다. 지난 글은 DB 재시작 장애였고, 이번에는 보안 사고다. 피해로 이어지지는 않았지만 이 시리즈에서 가장 아찔했던 순간이다.
배경: Web Deploy로 원격 배포
블로그는 회사의 Windows 서버 IIS에서 돌아간다. 개발 PC에서 운영 서버로 배포할 때는 Web Deploy(msdeploy)의 원격 에이전트를 쓴다. 배포 계정의 아이디와 비밀번호는 저장소에 두지 않고 개발 PC의 사용자 환경변수에 넣었다. 배포 스크립트(deploy.ps1)는 실행할 때 환경변수를 읽어 msdeploy에 넘긴다.
$remote = "computerName=$AgentUrl,userName=$user,password=$pass,authType=NTLM"
& msdeploy.exe -verb:sync -source:contentPath=... -dest:contentPath=...,$remote
msdeploy는 비밀번호를 명령줄 인수로 받는다. 그래서 출력에 비밀번호가 섞여 나올 수 있다는 건 처음부터 알고 있었다. 스크립트에는 출력의 password=... 부분을 ***로 바꾸는 마스킹 함수를 넣어 두었다.
사고: 실패한 배포가 비밀번호를 그대로 찍었다
첫 배포를 준비하면서 원격 에이전트의 계정 권한 설정 등으로 배포가 몇 번 실패했다. 그중 한 번, 콘솔에 msdeploy 오류가 찍히면서 배포 비밀번호가 평문으로 그대로 나왔다. 마스킹 함수가 있는데도 가려지지 않았다.
msdeploy는 실패하면 오류 메시지와 함께 자기가 받은 인수 전체를 다시 출력한다. 거기까지는 예상한 대로였다. 문제는 그 출력이 마스킹 함수에 도달하기 전에 화면으로 나갔다는 것이다.
원인: 2>&1과 $ErrorActionPreference = 'Stop'
당시 스크립트를 단순화하면 이런 구조였다.
$ErrorActionPreference = 'Stop' # 스크립트 맨 위: 오류가 나면 바로 멈춘다
& $MsDeployPath @arguments 2>&1 | ForEach-Object { Hide-Secret "$_" }
의도는 msdeploy의 표준 출력과 표준 오류(stderr)를 모두 받아 한 줄씩 가린 뒤 출력하는 것이었다. 하지만 Windows PowerShell 5.1에서는 동작이 다르다.
2>&1로 받은 stderr의 각 줄은 문자열이 아니라 오류 레코드(ErrorRecord)가 된다.$ErrorActionPreference가Stop이면 첫 번째 오류 레코드가 나오는 순간 종료 예외가 발생한다.- 예외는 파이프라인의
ForEach-Object를 거치지 않고, 그 줄의 원문을 그대로 담은 채 화면에 출력된다.
msdeploy가 비밀번호가 든 인수를 stderr로 다시 찍었고, 그 줄은 마스킹되지 않은 채 예외 메시지가 됐다. 오류를 엄격하게 다루려고 넣은 Stop 설정이 비밀번호를 내보내는 통로가 된 셈이다.
수정: Continue로 받아서 가린 뒤 판단한다
msdeploy를 호출하는 동안만 $ErrorActionPreference를 Continue로 바꿨다. 그러면 stderr도 예외 없이 파이프라인을 따라 흘러 마스킹 함수를 거친다. 성공 여부는 출력 대신 종료 코드로 판단한다.
function Hide-Secret([string]$text) {
# 배포 비밀번호는 어떤 출력에도 남기지 않는다 (msdeploy 는 오류 시 인수 전체를 다시 출력한다)
$text = $text.Replace($pass, '***')
return [regex]::Replace($text, '(?i)password=[^,"'' ]*', 'password=***')
}
function Invoke-MsDeploy([string[]]$arguments, [string]$title) {
# Stop 상태에서는 stderr 가 마스킹 전에 예외로 던져진다 → Continue 로 받아서 가린다
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& $MsDeployPath @arguments 2>&1 | ForEach-Object { ' ' + (Hide-Secret "$_") }
$exitCode = $LASTEXITCODE
}
finally {
$ErrorActionPreference = $previous
}
if ($exitCode -ne 0) { throw "msdeploy 실패 (exit $exitCode): $title" }
}
마스킹은 두 겹으로 했다. 비밀번호 값 자체를 문자열로 찾아 바꾸고, password= 뒤의 값도 패턴으로 한 번 더 가린다. 하나만 두면 인코딩이나 따옴표 처리가 달라졌을 때 빠져나갈 수 있어서다. 마지막에 던지는 예외 메시지에는 비밀번호가 들어갈 여지가 없도록 제목과 종료 코드만 넣었다.
사고 뒤 처리
출력을 가리는 것과 별개로, 한 번 화면에 나온 비밀번호는 노출된 것으로 본다. 콘솔 출력은 스크롤백, 로그, 작업 기록 어딘가에 남을 수 있고, 그걸 전부 찾아 지우는 건 불가능에 가깝다. 그래서 바로 배포 계정의 비밀번호를 바꾸고 개발 PC의 환경변수도 새 값으로 갱신했다.
이 배포 계정은 원격 에이전트 요구 사항 때문에 서버 관리자 그룹에 속해 있다. 노출된 비밀번호를 그대로 두면 서버 전체가 위험해진다. 교체를 미룰 이유가 없었다.
이 일은 배포 문서의 장애 대응 표에 "노출되면 즉시 비밀번호 교체"라는 조치와 함께 기록해 두었다.
정리하며
- 비밀값을 명령줄 인수로 넘기는 도구는 출력 전체를 의심한다. 성공할 때만 확인하지 말고 일부러 실패시켜서 오류 출력도 확인한다. 이번 문제는 실패할 때만 드러났다.
- PowerShell에서 네이티브 프로그램의 stderr를 다룰 때는
$ErrorActionPreference를 확인한다. Windows PowerShell 5.1에서Stop과2>&1을 함께 쓰면, stderr 한 줄만 나와도 예외가 되고 원문이 그대로 출력된다. 네이티브 프로그램의 성공 여부는$LASTEXITCODE로 판단하는 편이 안전하다. - 마스킹은 마지막 방어선일 뿐이다. 근본적으로는 비밀값이 출력에 들어가지 않게 하는 것이 좋다. 그게 어려운 도구라면 마스킹을 두 겹으로 하고, 노출되면 바로 교체한다.
다음 글에서는 디자인 개편을 배포하다 app_offline.htm이 서버에 남아 사이트가 503을 낸 일을 다룬다.