인증번호 요청에는 번호당 30초 쿨타임밖에 없었음. 스크립트로 번호 만 개를 넣으면 문자 요금이 그대로 나감. 세 겹으로 막았고, 그중 두 겹은 일부러 느슨하게 뒀음.
로그인 전에 오는 요청
가입할 때 휴대폰 번호를 넣으면 인증번호 문자가 감. 문자는 한 통마다 돈이 듦. 그리고 이 요청은 로그인하기 전에 옴. 계정이 없으니 누가 보내는지 따질 근거가 거의 없음.
그때 막혀 있던 건 "같은 번호에는 30초에 한 번"뿐이었음. 번호를 바꿔 가며 보내면 아무것도 안 막힘. 스크립트로 번호 만 개에 요청을 보내면 문자 요금이 그대로 나감. 남의 서비스 인증문자로 요금을 뽑아 먹는 수법이 실제로 있음. 브라우저 정보(User-Agent)를 보는 검사가 있긴 했지만 헤더 한 줄로 위조됨.
세 겹으로 막음
| 겹 | 어디서 | 무엇을 |
|---|---|---|
| ① | nginx | 같은 IP에서 짧은 시간에 몰리는 요청을 429로 자름. 앱까지 오지도 않음 |
| ② | 앱 | IP 하나가 하루에 보낼 수 있는 인증 요청 수를 셈 |
| ③ | 앱 | 서비스 전체의 발송량이 짧은 시간에 일정량을 넘으면 발송을 전부 멈추고(503) 운영자에게 메일을 보냄 |
문자와 이메일 인증은 합쳐서 셈. 한쪽만 세면 남은 쪽으로 돌아가기 때문임.
IP 상한은 넉넉하게
처음 계획은 IP 하나에 분당 몇 번, 하루 스무 번 정도로 빡빡하게 거는 것이었음. 그러다 멈췄음.
한국 통신사의 모바일 망은 여러 사용자가 바깥 IP 하나를 나눠 씀. 같은 IP에서 요청이 많이 온다고 같은 사람이라는 보장이 없음. IP 상한을 빡빡하게 걸면 출시 직후 가입이 몰리는 날 정상 사용자들이 서로 남의 몫을 써 버려서 가입이 막힘.
그래서 ①과 ②는 정상적인 사용으로는 닿지 않을 만큼 넉넉하게 두고, 실제 방어선은 ③으로 잡았음. 공격자가 IP를 아무리 바꿔도 전체 발송량은 숨길 수 없음. ③이 걸리면 그동안 가입이 잠깐 멈추지만 시간이 지나면 저절로 다시 열림. 정상적인 가입 폭주로 걸린 거라면 메일을 보고 기준을 올려 다시 배포하면 됨.
X-Forwarded-For 헤더 구멍
②를 만들면서 한 군데에서 크게 걸릴 뻔했음. 앱은 nginx 뒤에 있어서 진짜 사용자 IP는 nginx가 붙여 주는
X-Forwarded-For 헤더로 받음. 흔히 쓰는 설정은 이럼.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
이 설정은 클라이언트가 보낸 X-Forwarded-For 뒤에 진짜 IP를 덧붙임. 그런데 앱 서버(uvicorn)는
그 헤더의 첫 번째 값을 사용자 IP로 읽음. 클라이언트가 헤더에 아무 IP나 적어 보내면 그게 첫 값이
됨. 요청마다 다른 가짜 IP를 적으면 ②는 하나도 안 걸림.
그래서 nginx가 그 헤더를 덧붙이지 않고 덮어쓰게 했음.
proxy_set_header X-Forwarded-For $remote_addr;
확인 방법
같은 IP에서 빠르게 30번을 보냈음. 앞쪽 요청은 앱까지 가서 앱의 응답을 받고, 그 뒤부터는 nginx가 429로 자름.
nginx가 자른 요청은 앱 로그에는 아예 없고 nginx 오류 로그에 limiting 줄로 남음. 자동 테스트도 하나
만들어서, 같은 IP로 상한을 넘기면 막히는지, 번호를 바꿔도 IP 상한에 걸리는지 매번 확인함.
가입 여부 노출도 같이 막음
같은 날 이 주소의 다른 구멍도 막았음. 이미 가입된 번호를 넣으면 "이미 가입에 사용된 번호입니다"가 떴음. 아무 번호나 넣어 보면 그 사람이 이 서비스에 가입했는지 알 수 있었던 셈임. 이제는 가입된 번호든 아니든 응답이 똑같고, "이미 가입된 번호입니다"는 그 번호의 주인에게 문자로만 감. 지인차단에서 숫자를 지운 것과 같은 이유임.