앱 50개의 정책·지원 페이지를 한 도메인 아래 서브패스로 서비스하고 있습니다. /<앱이름>/index.html 형태고, 배포는 rsync/scp입니다.
어느 날 한 앱의 디렉터리를 열어봤더니 index, privacy, terms, support 네 파일이 전부 다른 앱의 스텁이었습니다. title이 다른 앱 이름이었고 크기가 15KB. 정상 페이지는 2779KB입니다. 나흘 전 배포에서 잘못 올라간 것으로 보였습니다.
진짜 문제는 "배포 성공을 확인할 방법이 없었다"는 것
이 도메인은 Cloudflare 뒤에 있고, 비브라우저 요청에는 5.4KB짜리 챌린지 페이지를 줍니다. 즉 curl로 URL을 때리면 앱 페이지가 아니라 챌린지가 옵니다. HTTP 200이 오지만 내용은 챌린지입니다. 배포 성공 판정에 HTTP를 쓸 수 없습니다.
그래서 나흘 동안 아무도 몰랐습니다. 배포 스크립트는 성공을 반환했고, URL을 열어보는 자동 검증은 애초에 불가능했고, 사람이 브라우저로 그 앱 페이지를 열어볼 일이 없었습니다.
유일한 권위 있는 검증은 SSH로 서버 파일의 md5를 로컬과 대조하는 것이었습니다. 배포한 사람이 접근할 수 있는 유일한 진실이 파일시스템뿐이라는 뜻입니다.
한 가지 더. 이 URL은 외부 심사 서류에 "서비스 웹페이지 및 출처 표기 위치"로 명시한 주소였습니다. 심사위원이 열었으면 전혀 다른 앱이 나왔을 겁니다. 사고의 심각도가 파일 4개가 아니라 그 URL이 어디에 적혀 있느냐로 결정되는 종류의 일이었습니다.
코드
오염 판별 신호 — 크기만 봐도 드러납니다.
정상 페이지: 27~79KB
오염 페이지: 1~5KB (다른 앱 스텁)전수 스윕(50개 앱 디렉터리, SSH 1회):
cd ~/public_html
for d in */; do d=${d%/}; [ -f "$d/index.html" ] || continue
echo "$d|$(stat -c%s $d/index.html)|$(grep -m1 -oE '<title[^>]*>[^<]*' $d/index.html | sed 's/<title[^>]*>//')"
done⚠️ 로컬 <title>은 <title data-i18n="title"> 형태라 <title> 정규식으로는 안 잡힙니다. <title[^>]*>로 매칭할 것. 이거 때문에 첫 스윕이 전부 빈 title을 반환했습니다.
복구는 서버 사본 백업 → 로컬 파일 scp → md5 전량 대조 순입니다. 참조 자산(공용 CSS, i18n JS, 아이콘)은 서버에 이미 있어 재배포 불필요했습니다. 스윕 결과 해당 앱 외 49개는 전부 정상이었습니다.
일반화 — CDN/WAF 뒤에선 배포 검증 경로가 사라진다
봇 보호는 배포 파이프라인도 봇으로 취급하기 때문입니다. 선택지는 세 가지쯤입니다.
- 검증용 경로만 챌린지에서 제외한다 (이 사이트는
.well-known/만 200으로 열려 있다) - 서버 파일시스템에 직접 접근해 해시를 대조한다 (여기서 택한 방법)
- 배포 후 실제 브라우저로 렌더해서 확인한다 (비용이 크다)
어느 쪽이든 "HTTP 200을 받았으니 배포 성공"이라는 가정은 못 쓴다는 걸 먼저 인정해야 합니다.
정직하게
- 발견이 우연이었습니다. 정기 점검이나 알림으로 잡은 게 아니라 다른 일을 하다 눈에 띄었습니다. 같은 사고가 다른 앱에서 났다면 더 오래 갔을 겁니다.
- 복구는 했지만 왜 그 디렉터리에 다른 앱 스텁이 올라갔는지는 규명하지 못했습니다. 배포 스크립트의 경로 인자 실수로 추정하지만 그날의 명령을 재구성하지 못했습니다. 원인 불명인 채 증상만 복구한 상태입니다.
- 전수 스윕은 1회성이었습니다. 자동화된 정기 검증은 아직 없습니다. 지금 상태로는 같은 사고가 또 나면 또 우연히 발견해야 합니다.
- 스윕에서 오탐이 하나 났습니다. 한 앱의 title이 예상과 달라 오염으로 의심했는데, 알고 보니 그 앱이 실제로 리브랜딩돼서 사이트가 맞는 상태였습니다. 페이지가 8KB로 작은 것도 별도 제작이라 정상이었습니다. "다르다"를 "틀렸다"로 읽으면 안 된다는 걸 확인 과정에서 다시 배웠습니다.
이 글에서 언급하는 앱 상세 페이지 URL(ootssu.com/<slug>/)은 지금도 비브라우저 요청에 403입니다 — 이 403 자체가 글의 소재입니다. 등장 앱: HiddenGem.
당신의 배포 스크립트가 마지막에 확인하는 그 200, 정말 당신이 올린 파일을 받은 겁니까, 아니면 WAF가 대신 답한 챌린지입니까? 둘은 똑같이 초록불입니다.