스팸이 속도 제한을 건 문의 양식을 이긴 이유

속도 제한을 건 문의 양식을 왜 스팸이 통과합니까? 속도 제한은 주소별로 요청을 셀 뿐, 누가 보냈는지는 전혀 묻지 않기 때문입니다. 10월 30일과 11월 2일, 두 취약점 스캐너가 제 사이트를 크롤링하면서 양식이 있는 홈페이지를 여느 방문자처럼 불러오고, 유효한 CSRF 토큰을 손에 넣은 뒤, 다른 곳으로 옮겨 가거나 차단될 때까지 양식을 제출했습니다. 첫날에는 101건, 둘째 날에는 31건이 통과했습니다. 모두 앱이 200을 돌려준 것인데, 앱은 발송에도 실패한 발송에도 200을 돌려주므로 이 숫자는 이들이 만들어 낼 수 있었던 이메일의 최대치입니다.12 주소당 분당 5건으로 설정한 제 앱의 리미터는 두 날에 각각 830건과 31건의 제출을 거부하고 101건과 31건의 발송을 통과시켰습니다. 그 설정의 정확한 리미터라면 허용했을 수보다 47건과 9건 많은 수입니다.3 엣지의 속도 제한은 두 스캐너 어느 쪽에도 한 번도 발동하지 않았습니다. 첫 번째 플러드에서는 마지막 양식 제출로부터 약 12분 뒤에야 제가 그 규칙을 만들었고, 두 번째 스캐너의 제출은 두 Cloudflare 데이터 센터를 거쳐 들어왔는데 데이터 센터마다 카운터가 따로 있습니다. 이 분할이 두 번째 침묵에 대한 제 가장 강력한 설명입니다. 분당 합계와 들어맞지만, 합계로 그것을 입증할 수는 없습니다.456 제 스튜디오 사이트에서는 허니팟 필드가 8일 동안 52건 중 42건의 제출을 잡아냈고, 보낸 쪽이 허니팟을 비워 둔 10건은 통과시켰습니다.78 이 사이트의 엣지에는 사람이 있는지 물을 수 있는 규칙, 즉 양식 경로에 대한 관리형 챌린지가 있었습니다. 하지만 이 규칙은 목록에 있는 사용자 에이전트 문자열에만 발동하고, 두 스캐너는 모두 브라우저의 문자열을 보냈습니다. 높은 위협 점수를 키로 하는 두 번째 챌린지는 Cloudflare가 이제 그 점수를 항상 0으로 설정하기 때문에 결코 발동할 수 없었습니다. 그래서 모든 제출에 그 질문을 던지는 것은 아무것도 없었습니다.910411 11월 3일 아침부터는 제 받은편지함에 닿을 수 있는 모든 제출에 대해, 두 양식 모두에서 Turnstile이 그 질문을 던집니다.12

TL;DR

  • 스캐너 둘, 받은편지함 하나. 10월 30일 한 주소가 1분 단위 버킷 53개에 걸쳐 제 사이트에 76,429건의 요청을 보냈고, 11월 2일에는 다른 주소가 버킷 22개에 걸쳐 10,907건을 보냈습니다. 둘 다 사이트 전체를 크롤링했고, 둘 다 경로와 User-Agent 헤더에 SQL 인젝션 문자열을 넣었으며, 둘 다 문의 양식을 제출했습니다. 첫날 1,065번, 둘째 날 500번입니다.131412
  • 유효한 CSRF 토큰이 입증하는 것은 제 사이트가 발급한 토큰을 클라이언트가 갖고 있었다는 사실뿐입니다. 두 날의 모든 발송이 토큰을 갖고 있었습니다. 제 토큰은 상태가 없어서 페이지를 가져가는 클라이언트라면 누구나 하나를 얻기 때문입니다. 두 번의 플러드를 통틀어 CSRF 검사가 거부한 제출은 4건이었습니다.1516
  • 제 리미터가 새고 있었습니다. 리미터는 Cloudflare KV에서 카운터를 읽고, 비교한 다음, 다시 읽고 나서 카운트에 1을 더한 값을 별도의 요청으로 씁니다. 두 번째 읽기가 실패하면 1이 쓰이고, KV 호출이 실패하면 한 프로세스의 메모리에 보관한 카운트로 대체됩니다. 이 경로 중 어느 것이든 5건을 넘게 통과시킬 수 있고, 어느 경로가 그랬는지는 로그로 알 수 없습니다. 한도 5건에 대해 1분 동안 18건의 발송이 나갔습니다.171
  • 엣지의 속도 제한은 데이터 센터별, 주소별로 셉니다. 11월 2일 18:56부터 19:05:00 UTC까지, 즉 차단 전후 몇 분 동안 스캐너의 양식 제출 85건은 암스테르담(44건)과 런던(41건)을 거쳐 들어왔고, 가장 바쁜 1분은 두 곳 사이에서 18대 14로 나뉘었습니다. 이 분할이 규칙이 조용했던 가장 그럴듯한 이유이지만, 분당 합계로 그것을 입증할 수는 없습니다. 다음 날 아침 제가 실행한 프로브는 규칙이 발동한다는 것, 그리고 Cloudflare가 그것을 기록한다는 것을 입증했습니다.518
  • 차단은 주소 차단이었고, 둘 다 이메일을 막기에는 너무 늦었습니다. 10월 30일에는 제가 알아챈 지 3.9분 뒤, 그 주소의 첫 양식 제출로부터 약 50분 뒤에 손으로 주소를 차단했습니다. 11월 2일에는 스캔의 첫 1분으로부터 8분 뒤에 제 모니터가 차단했습니다. 두 날 모두 차단이 들어간 분 이후에 나간 발송은 없습니다. 10월 30일에는 마지막 발송이 차단보다 12분 넘게 앞섰고, 11월 2일에는 차단 전 몇 분 동안 26건, 차단이 들어간 그 분 안에 5건이 나갔는데, 이 5건은 분 단위 데이터로는 차단 시각의 초보다 앞인지 뒤인지 정할 수 없습니다.101912
  • 허니팟은 모든 필드를 채우는 봇을 잡아냅니다. 제 스튜디오 사이트에서 허니팟은 52건 중 42건의 제출에 성공한 것처럼 응답하고 아무것도 저장하지 않았습니다. 도착한 시간대로 보면, Tor 출구에서 온 제출은 모두 그 안에 들어 있었습니다. 저장된 10건, 즉 최대 10통의 이메일은 허니팟을 비워 둔 발신자들에게서 왔습니다.720
  • 바뀐 것. 이제 두 양식 모두 관리형 모드의 Cloudflare Turnstile 위젯을 갖고 있고, 서버는 Cloudflare의 siteverify API가 제출의 토큰을 받아들이기 전까지 아무것도 저장하거나 발송하지 않으며, 토큰이 없거나, 너무 크거나, 검증할 수 없으면 거부합니다. Turnstile은 방문자의 브라우저에서 챌린지를 실행하고 IP 주소, TLS 지문, 사용자 에이전트 같은 신호를 처리합니다. Cloudflare는 양식 입력 내용에는 손대지 않는다고 말합니다. 사람이 손으로 스팸을 입력하는 것은 막지 못하며, 양을 묶어 두는 속도 제한을 대신하지도 않습니다.122122

1. 질문: “혹시 당신이에요?”

11월 2일 제 받은편지함이 문의 양식 메시지로 가득 찼고, 저의 첫 질문은 제 인프라를 운영하는 에이전트 세션을 향한 것이었습니다. 제 자동화가 이것을 보내고 있느냐는 질문이었습니다. 아니었습니다. 보내고 있던 것은 스캐너였고, 이 사이트의 문의 양식을 통해서였습니다. 제 스튜디오 사이트인 941apps.com의 양식도 나름대로 조금씩 이어지는 제출을 받고 있었지만, 그날 받은편지함에 닿은 것은 없었습니다.238 모니터의 알림과 두 양식, 이 세 메일 흐름은 모두 제 메일 도메인의 한 주소에서 발송되기 때문에 받은편지함에서는 비슷해 보였습니다.23

정체를 드러낸 것은 제목이었습니다. 이 사이트의 문의 핸들러는 각 제출을 “Contact Form:”이라는 제목 뒤에 발신자가 이름 칸에 입력한 내용을 붙여 제게 보냅니다. 그래서 스캐너의 페이로드가 그대로 제목 줄이 됩니다.2423 스튜디오 사이트의 양식은 각 메시지를 데이터베이스에 저장한 다음 같은 받은편지함으로 메일을 보냅니다.25

처음 던진 질문은 틀린 질문이었고, 맞는 질문에 답하는 데는 하루치 쿼리가 필요했습니다. 그 질문은 이것입니다. 이 양식들에 있는 통제 장치 중 어느 것이 스캐너와 사람을 구별할 수 있었는가, 그리고 각각은 왜 나머지를 통과시켰는가. 통제 장치는 다섯 종류였습니다. 이 사이트에는 사용자 지정 규칙을 가진 엣지 방화벽, 엣지 속도 제한, CSRF 토큰, 그리고 앱 안의 속도 리미터가 있었습니다. 스튜디오 사이트에는 같은 종류의 엣지 규칙, 엣지 속도 제한, 관찰된 양식 제출을 하나도 거부하지 않은 자체 앱 리미터, 그리고 허니팟 필드가 있었습니다.9257 이 글의 나머지는 두 번의 플러드와 조금씩 이어진 제출을 하나씩 살펴보고, 이어서 다섯 계층을 하나씩, 각각의 규칙 문구나 코드의 동작과 함께 살펴봅니다.

숫자의 출처

아래의 모든 건수는 두 존에 대한 Cloudflare의 요청 분석과 방화벽 이벤트(11월 3일에 이미 닫힌 기간을 대상으로 쿼리한 것), 스튜디오 사이트의 메시지 테이블, 그리고 제 모니터 자체의 로그에서 나왔습니다.16 스크립트가 저장된 쿼리 결과를 수백 행짜리 원장으로 바꾸었고, 각 행에는 그것을 만든 규칙이 붙어 있습니다. 이 글은 원장과 그 부록에서만 숫자를 가져오고, 다른 곳에서는 가져오지 않습니다. 그날의 제 메모와 원장이 어긋나면 원장을 따릅니다. 10월 30일 메모는 플러드 몇 분 뒤, Cloudflare가 아직 마지막 몇 분을 수집하던 중에 쓴 것이라 적게 셌습니다.1626 Cloudflare는 이 데이터 세트를 “at least 31 days of data on every plan”(모든 요금제에서 최소 31일치 데이터) 보관하므로, 플러드를 사후에 셀 수는 있었지만 영원히 그럴 수 있는 것은 아닙니다.27

두 종류의 숫자에는 경고가 필요합니다. 방화벽 이벤트는 표본이므로 어느 규칙이 언제 작동했는지에만 쓰고, 건수에는 요청 데이터 세트를 씁니다.16 그리고 이 사이트에서 200으로 응답한 양식 제출은 발송이거나, 핸들러가 역시 200으로 응답하는 실패(그런 실패 경로가 두 개 있습니다)이지, 배달된 이메일이 아닙니다. 메일 서비스의 로그도 확인하지 않았습니다. 따라서 이 글의 모든 이메일 건수는 상한입니다.2428

평소의 모습

플러드 이전에 양식은 조용했습니다. 쿼리가 다루는 8일 동안 두 스캐너 말고 양식 제출에 성공한 주소는 셋이었고, 각각 정확히 두 번씩, 10월 27일, 28일, 29일에 하루 한 주소였습니다.29 그것이 사람이었는지는 모릅니다. 한 주소에서 온 두 번의 제출은 더블클릭과도 맞고, 재시도하는 봇과도 맞습니다. 이 불확실성이 문제 전체의 축소판입니다. 로그도, 통제 장치도 그것을 말해 줄 수 없었습니다.

2. 첫 번째 플러드: 10월 30일, 손으로 차단

첫 번째 플러드는 지역 인터넷 레지스트리가 BULLIONET FBW NETWORKS SAS 명의로 올려 둔, 프랑스에 등록된 네트워크의 한 주소에서 왔습니다.30 첫 요청은 17:59 UTC의 분(태평양 일광 절약 시간 10:59)에, 마지막 요청은 18:51의 분(11:51 PDT)에 도착했습니다. 1분 단위 버킷 53개, 이 사이트에 대한 요청 76,429건입니다. 그 주소에 대한 분별 쿼리와 제 계정의 모든 존을 훑는 쿼리, 두 개의 독립된 쿼리가 이 합계에서 일치하며, 이 주소는 그 전에도 후에도 아무것도 보내지 않았습니다.13

스캔의 모양

양식은 그 일부에 불과했습니다. 차단 전에 이 주소는 분당 평균 1,389.7건의 요청을 보냈고 18:36의 분에 3,219건으로 정점을 찍었으며, 사이트 전체를 돌아다녔습니다. 서로 다른 경로가 최소 2,881개인데, 경로 쿼리가 행 한도에 걸렸으므로 이는 하한입니다.1331 스캔 전체의 결과는 다음과 같이 나뉩니다.13

결과 요청 비율
제공됨(2xx, 3xx) 49,513 64.8%
앱이 거부(4xx), 사이트의 에이전트 게이트에 의한 10,019건 포함 18,990 24.8%
오리진 오류(520) 2,339 3.1%
엣지에서 차단(403) 5,587 7.3%

엣지의 7.3%는 거의 전부가 차단 때문입니다. 5,587건의 거부 중 5,234건이 차단이 들어간 분 이후에 나왔습니다.13 그 전 50분 동안 작동한 것은 익스플로잇 경로 규칙뿐이었습니다. 그 규칙은 첫 1분부터 작동했고 첫 방화벽 이벤트는 17:59:53 UTC였지만, .php나 /wp- 같은 경로 부분 문자열에 일치하는 규칙이었고 이 스캔의 대부분은 실제 페이지를 요청했습니다.109

이것을 크롤링이 아니라 스캔으로 만든 것은 요청에 무엇을 넣었느냐였습니다. 트래픽의 거의 전부, 최소 69,322건의 요청이 Windows용 평범한 Chrome 141 사용자 에이전트 하나를 썼습니다. 최소 2,755건은 Googlebot을 자처했고, 엣지의 검증된 봇 규칙은 이들을 Google로 취급하지 않았습니다.32 최소 3,154건의 요청은 User-Agent 헤더 자체에 SQL 인젝션 페이로드를 담았고 서로 다른 문자열은 최소 396개였으며, 최소 2,680건의 요청은 경로에 페이로드를 담았고 서로 다른 경로는 최소 810개였습니다.3231 이 페이로드들은 문서화된 계열에 속합니다. PG_SLEEP(15), waitfor delay '0:0:15', XOR(if(now()=sysdate(),sleep(15),0))는 시간 기반 프로브입니다. PortSwigger의 치트 시트는 Oracle, Microsoft SQL Server, PostgreSQL, MySQL의 지연 구문을 나열하고, sqlmap 자체의 페이로드 정의에도 같은 함수들이 들어 있습니다.3334 PortSwigger는 이것이 통하는 이유를 이렇게 설명합니다. “As SQL queries are normally processed synchronously by the application, delaying the execution of a SQL query also delays the HTTP response.”(SQL 쿼리는 보통 애플리케이션이 동기적으로 처리하므로, SQL 쿼리의 실행을 지연시키면 HTTP 응답도 지연된다)35 느린 응답 한 번은 거의 아무것도 입증하지 않습니다. 페이로드가 요구할 때마다 약 15초의 지연이 돌아오고, 페이로드가 없는 대조 요청에서는 돌아오지 않는다면, 그것이 인젝션 가능성의 증거입니다.

이 사이트의 페인트 앱에는 공유용 엔드포인트가 있고, 스캐너는 그것을 찾아냈습니다. 공유 경로에 POST를 320건 보냈는데, 쿼리가 다루는 8일 동안 그곳에 제출한 주소는 이것 하나였고, 엔드포인트는 320건을 모두 거부하고 아무것도 저장하지 않았습니다. 그 주변으로는 /paint/share'||DBMS_PIPE.RECEIVE_MESSAGE(CHR(98)||CHR(98)||CHR(98),15)||'나 /paint/(select 198766*667891 from DUAL)처럼 페이로드를 끼워 넣은 경로 변형에도 제출했고, 사이트는 여기에 404로 응답했습니다.31 이 주소는 제 계정의 다른 존 하나에도 손을 댔습니다. kiradex.app에 GET 387건을 보냈고 모두 200을 받았으며, 다른 존은 건드리지 않았습니다.13 제 해석으로는 이 사이트에서 그 사이트로 가는 링크를 따라간 것입니다. 링크는 확인하지 않았습니다.

OWASP의 자동화된 위협 목록에는 이 활동을 가리키는 이름이 있습니다. OAT-014, Vulnerability Scanning(취약점 스캐닝)으로, “Crawl and fuzz application to identify weaknesses and possible vulnerabilities.”(애플리케이션을 크롤링하고 퍼징하여 약점과 가능한 취약점을 식별한다)입니다.36 문의 양식은 퍼징할 입력이 하나 더 있었을 뿐입니다.

양식, 분 단위로

이 주소는 문의 양식을 1,065번 제출했고, 모든 제출이 앱에 도달했습니다. 엣지는 하나도 막지 않았습니다.1 첫 제출과 첫 발송은 18:00 UTC의 분(11:00 PDT)에 왔습니다. 다음은 양식 제출이 앱에 도달한 모든 분을, 앱이 돌려준 상태별로 나타낸 것입니다.1

UTC PDT 발송(200) CSRF 거부(403) 필수 필드 누락(422) 리미터 거부(429)
18:00 11:00 8
18:01 11:01 12 133
18:02 11:02 9 148
18:03 11:03 4 4 200
18:04 11:04 9 89
18:05 11:05 16 164
18:06 11:06 18 85
18:07 11:07 7 11
18:08 11:08 2
18:09 11:09 4
18:10 11:10 9
18:11 11:11 1
18:12 11:12 1
18:35 11:35 1
18:36 11:36 1
18:37 11:37 129

합계는 발송 101건, 앱 리미터의 거부 830건, 필수 필드 누락으로 인한 거부 130건, CSRF 검사의 거부 4건이며, 이 4건은 모두 18:03의 분에 있습니다.1 429는 앱의 것이었습니다. 엣지의 속도 제한 규칙은 아직 존재하지 않았고, 이 주소에 대한 속도 제한 방화벽 이벤트는 하나도 없으며, Cloudflare가 이 제출들에 대해 오리진의 상태를 기록한 범위에서는 429가 오리진에서 왔습니다.1037 422는 FastAPI의 양식 검증에서 나온 것인데, 이 검증은 리미터보다 먼저 실행되므로 리미터의 자리를 소모하지 않았습니다. 이 순서는 열 줄짜리 FastAPI 앱으로 확인했습니다. 필수 필드가 빠진 제출은 래퍼를 한 번도 호출하지 않고 422를 돌려주었습니다.38 18:37의 129건 묶음은 스캐너가 필드를 하나씩 빼 가며 양식을 퍼징한 것으로 보입니다.

“발송” 열을 리미터와 맞대어 읽어 보십시오. 리미터는 주소당, 시계 기준 1분당 5건의 제출을 허용하지만, 그 가운데 여덟 개의 분에서 5건이 넘는 발송이 나갔고 18:06에는 18건이었습니다.13 그 이유는 4절에서 설명합니다.

알아챔, 그리고 차단

저는 11:46 PDT에 알아챘고, 무언가가 사이트의 문의 양식을 계속 두드리고 있다고 적어 두었습니다.26 그때는 첫 양식 제출로부터 약 46분이 지난 뒤였고, 양식 플러드 자체는 11:37 PDT에 끝나 있었습니다.1 저는 그 주소를, 존의 첫 번째 사용자 지정 규칙이 이미 대조하고 있던 계정 수준의 차단 목록에 손으로 추가했고, 그 규칙의 첫 이벤트는 18:49:53 UTC(11:49:53 PDT)에 나왔습니다.10 차단은 제가 알아챈 지 3.9분 뒤, 첫 양식 제출로부터 약 50분 뒤, 스캔의 첫 1분으로부터 50.9분 뒤에 들어갔습니다.113 101건의 발송은 모두 그 전에 나간 뒤였습니다. 앱에 도달한 마지막 양식 제출은 차단보다 약 12분 앞섰습니다.1 차단이 들어간 분 이후 이 주소는 5,234건의 요청을 더 보냈고, 엣지는 그 전부를 거부했습니다.13

같은 초, 18:49:53 UTC에 저는 존의 유일한 속도 제한 규칙을 만들었습니다. 문의 경로와 페인트 공유 경로로 가는 양식 POST를 대상으로, 주소당 10초에 3건, 10초 동안 차단하는 규칙입니다.9 18:52:38 UTC에 제 컴퓨터에서 그것을 시험했습니다. 그 시험에 대한 제 메모에는 브라우저 사용자 에이전트를 쓰고 유효한 CSRF 토큰 없이 1초 간격으로 보낸 여섯 건의 제출이 “returned 403 403 403 429 429 429 (the app’s CSRF rejection, then the edge’s rate limit)”(403 403 403 429 429 429를 돌려주었다(앱의 CSRF 거부, 이어서 엣지의 속도 제한))라고 적혀 있고, 평범한 curl 제출은 기존 사용자 지정 규칙이 대신 관리형 챌린지를 내주었기 때문에 이 규칙에 아예 닿지 않았습니다.26 메모에는 제출에 토큰이 없었다고 되어 있지만, 11월 3일의 제 프로브가 보여 주었듯 그 필드가 빠진 제출은 핸들러가 실행되기 전에 422를 받습니다. 따라서 403은 토큰이 있었지만 유효하지 않았다는 뜻입니다.18 방화벽 이벤트도 이와 일치합니다. 제 시험 주소에서 나온 속도 제한 이벤트 3건(첫 건은 바로 그 초)과, 제 curl 제출에 대한 관리형 챌린지 24건입니다.39 제 시험 트래픽은 위의 어떤 건수에도 들어 있지 않습니다.

그날 제 모니터는 돌아가고 있지 않았습니다. 모니터는 10월 31일에 다시 가동했고, 트래픽 탐지기는 이 플러드 이후에 들어왔습니다.19 10월 30일의 차단은 받은편지함을 읽은 사람이 한 것이었습니다.

2026년 10월 30일 태평양 시간 10:59부터 11:51까지, 한 스캐너 주소가 blakecrosley.com에 보낸 모든 요청(총 76,429건)을 1분마다 막대 하나로 나타내고 누가 응답했는지로 나눈 막대그래프. 차단 전 막대는 분당 평균 1,390건(정점은 11:36 PDT의 3,219건)으로 대부분 제공된 페이지이고, 앱의 거부와 일부 오리진 오류가 섞여 있다. 문의 양식 플러드(제출 1,065건, 그중 101건이 200으로 응답. 200은 발송이거나 핸들러가 역시 200으로 응답하는 실패이므로 이메일은 최대 101통)는 11:00부터 11:37 PDT까지의 38분 안에 들어 있다. 점선은 글쓴이가 알아챈 11:46 PDT를, 파선은 그가 손으로 추가한 차단이 적용된 11:49:53 PDT(18:49:53 UTC)를 표시하며, 스캔의 첫 1분으로부터 51분 뒤다. 그 이후 1분 버킷의 모든 요청은, 요청이 있었던 마지막 분인 11:51 PDT까지 엣지에서 차단되었다.

10월 30일: 한 주소에서 온 요청 76,429건, 분 단위. 제가 손으로 주소를 차단하기 전까지 엣지는 거의 아무것도 거부하지 않았습니다.1310

3. 두 번째 플러드: 11월 2일, 제 모니터가 차단

두 번째 주소는 네덜란드의 호스팅 업체 NovoServe B.V.의 것입니다.40 이 주소는 07:49 UTC(11월 1일 태평양 표준시 23:49)에 GET 두 건을 보낸 뒤 11.1시간 동안 아무것도 보내지 않았습니다. 본격적인 스캔은 18:56 UTC의 분부터 19:17의 분까지(10:56부터 11:17 PST) 이어졌습니다. 1분 단위 버킷 22개에 요청 10,907건, 새벽의 GET 두 건을 포함하면 10,909건입니다. 네 개의 독립된 쿼리가 이 합계에서 일치합니다.14

같은 도구처럼 보였습니다. 10월 30일 주소와 똑같은 Windows용 Chrome 141 사용자 에이전트 문자열을 9,725건의 요청에, 똑같은 Googlebot 문자열을 942건에 썼고, 같은 인젝션 계열을 보냈으며, 같은 두 존만 건드렸고 kiradex.app에는 GET 368건을 보냈습니다.414214 두 나라의 두 네트워크에 있는 두 주소로는 누가 그것을 돌렸는지 아무것도 입증할 수 없으므로, “보였다”에서 멈추겠습니다.

두 시간대로 본 타임라인

UTC PST 일어난 일
18:56 10:56 스캔 시작. 이 1분 동안 요청 29건.1419
18:57:00 10:57:00 제 모니터의 실행 한 번이 시작된다. 그 창은 18:57:22에 닫히므로 스캔을 약 1분만 보고, 아무것도 찾지 못한다.19
18:57 10:57 차단 전 가장 바쁜 1분, 요청 637건. 엣지의 익스플로잇 경로 규칙이 18:57:11에 차단을 시작한다.144
18:58 10:58 첫 양식 제출 3건. 앱은 3건 모두 발송한다.2
19:00 11:00 양식 제출 4건 추가, 4건 모두 발송.2
19:02 11:02 양식 제출 32건이 앱에 도달. 발송 9건, 앱 리미터의 거부 23건.2
19:02:46 11:02:46 다음 모니터 실행이 시작된다.19
19:03 11:03 발송 10건, 거부 8건.2
19:03:53 11:03:53 제 모니터가 그 주소의 요청 1,914건에 대해 크리티컬 요청 플러드 탐지 결과를 낸다.19
19:04:01 11:04:01 제 모니터가 그 주소를 계정 차단 목록에 추가한다.19
19:04:03 11:04:03 차단 목록 규칙의 첫 이벤트.4
19:04 11:04 마지막 발송 5건과 엣지에서 거부된 양식 제출 22건. 모두 차단이 들어간 그 분 안이며, 분 버킷으로는 어느 쪽이 먼저인지 알 수 없다.2
19:07 11:07 스캔에서 가장 바쁜 1분, 요청 1,107건, 전부 차단.14
19:17 11:17 마지막 1분, 요청 300건, 전부 차단.14

제 모니터의 8분

제 모니터는 스캔의 첫 1분으로부터 7.9분 뒤에 첫 탐지 결과를 냈고, 차단은 그로부터 8.0분 뒤, 즉 첫 방화벽 이벤트로부터 6.8분 뒤에 적용되었습니다.194 그때까지 차단 전 몇 분 동안 26건의 발송이 나갔고, 마지막 5건은 차단이 들어간 그 분 안에 들어 있었는데, 분 단위 데이터로는 이 5건을 차단 시각의 초보다 앞이나 뒤에 놓을 수 없습니다. 그 이후의 분에는 한 건도 없습니다.2 8분은 차단으로서는 빠르고, 받은편지함으로서는 느린 시간입니다.

10:57:00 PST에 시작된 실행은 스캔을 약 1분 동안만 시야에 두었고, 그 창 안에서 이 주소는 29건에서 666건 사이의 요청을 보냈습니다. 위쪽 숫자는 18:57의 1분 전체를 센 것이고, 창이 본 것은 그중 22초뿐입니다. 탐지기는 아무 탐지 결과도 기록하지 않았습니다.19 11:02:46 PST의 다음 실행이 플러드를 찾아내 차단했고, 그 실행은 첫 번째가 왜 놓칠 수 있었는지도 보여 줍니다. 그 창은 Cloudflare에 질의한 순간에 끝났기 때문에, 가장 최근의 이벤트가 아직 도착하지 않은 상태였습니다. 실행의 로그에는 이 주소의 요청이 1,914건으로 기록되어 있지만, 11월 3일에 완전한 데이터로 같은 5분 창을 재현하면 2,143건이 나옵니다. 이 재현은 명령과 전체 출력과 함께 저장되어 있고, 그 건수는 원장 부록의 행들입니다.1943 재현은 실시간 실행이 한 번도 내지 않은 양식 플러드 탐지 결과도 냈습니다. 39건의 제출(그중 23건이 거부)에서 나온 높은 심각도의 결과로, 이메일이나 차단이 아니라 일일 다이제스트의 한 줄이 되었을 것입니다.43 제가 재현한 것은 두 번째 실행의 창이지 첫 번째의 창이 아니므로, 10:57 PST 실행에 대해서도 같은 지연이 원인일 가능성이 높지만 측정한 것은 아닙니다. 같은 날 저는 모니터의 창이 각 실행의 90초 전에 끝나고 이전 창이 끝난 곳에서 시작하도록 바꾸었습니다.43 이 주소에 대해 탐지 결과는 모두 6건이 발동했고, 제 모니터는 그 주소가 이미 차단 원장에 올라 있었기 때문에 그 전부를 이메일로 보내는 대신 일일 다이제스트에 합쳤습니다.19 제 모니터가 어떻게 알림을 보내야 하는지, 그리고 모니터 쪽에서 본 이 스캐너는 알림 피로: 보안 모니터가 하루에 보낸 이메일 82통이라는 별도의 글이 다룹니다. 이 글에서 중요한 것은 차단은 반응이고, 그 반응은 31건의 발송 중 최소 26건보다 늦게 왔다는 사실입니다.

양식, 상태별로

이 주소는 양식을 500번 제출했습니다.2

UTC PST 발송(200) 리미터 거부(429) 엣지 차단(403)
18:58 10:58 3
19:00 11:00 4
19:02 11:02 9 23
19:03 11:03 10 8
19:04 11:04 5 22
19:05 11:05 39
19:06 11:06 19
19:07 11:07 10
19:08 11:08 13
19:09 11:09 31
19:10 11:10 41
19:11 11:11 47
19:12 11:12 97
19:13 11:13 29
19:14 11:14 23
19:15 11:15 21
19:16 11:16 28
19:17 11:17 18

합계는 발송 31건, 앱 리미터의 거부 31건, 엣지에서의 거부 438건이며, 엣지의 거부는 모두 차단이 들어간 분이나 그 이후입니다. 따라서 앱에 도달한 제출은 62건입니다.2 발송 하나하나가 스캐너가 유효한 CSRF 토큰을 갖고 있었다는 것을 입증합니다. 핸들러는 발송 전에 토큰을 확인하기 때문입니다. 429는 어느 쪽도 입증하지 않습니다. 리미터는 핸들러가 토큰을 읽기 전에 제출을 거부하기 때문입니다.2416 Cloudflare가 그 429들에 대해 오리진의 상태를 기록한 범위에서는 그것이 오리진의 것, 즉 앱 리미터의 것이었고, 이 주소에 대한 속도 제한 방화벽 이벤트는 하나도 없습니다.374

스캔의 나머지

결과별로 보면 11월 2일 스캔은 다음과 같이 나뉩니다.14

결과 요청 비율
제공됨(2xx, 3xx) 1,388 12.7%
앱이 거부(4xx), 에이전트 게이트에 의한 420건 포함 1,473 13.5%
오리진 오류(520) 116 1.1%
엣지에서 차단(403) 7,930 72.7%

엣지의 72.7%는 대부분 차단 때문입니다. 차단이 들어간 분 이전의 요청이 2,986건, 이후가 7,445건이며, 사이트는 이후의 요청을 하나도 제공하지 않았습니다.14 이날의 경로 쿼리는 완전했습니다. 서로 다른 경로가 2,173개였고, 그중 542개 경로로 간 778건의 요청이 SQL 인젝션 페이로드를 담고 있었습니다.44 사용자 에이전트 쿼리도 완전했고, 서로 다른 문자열은 73개였습니다. 평범한 Chrome 문자열과 Googlebot 자처 외에, 스캐너는 149건의 요청에 서로 다른 SQL 페이로드 54개를, 78건에 따옴표를 깨는 문자열 3개를 보냈고, 마커 토큰 12개를 각각 한 번씩 보냈습니다.41

그중 몇몇 문자열은 스캐너가 무엇을 묻고 있었는지 보여 주므로 살펴볼 만합니다. (select 198766*667891 from DUAL)은 테이블 없는 select 안의 산술식입니다. PortSwigger의 치트 시트가 보여 주듯 FROM dual은 그런 select를 위한 Oracle의 구문입니다.33 if(now()=sysdate(),sleep(15),0)은 같은 치트 시트에 있는 조건부 시간 지연과 같은 IF(condition, SLEEP(n), x) 모양입니다.33 @@qkLpP는 치트 시트가 Microsoft와 MySQL용 SELECT @@version에서 쓰는 @@ 접두사 뒤에 다섯 글자가 붙은 것이고, 스캐너는 이와 비슷한 것을 열두 개 보냈습니다.3341 OWASP의 테스트 가이드는 이 문자열들이 쓰는 두 기법을 명시합니다. “forces the database to generate an error”(데이터베이스가 오류를 생성하도록 강제하는) 오류 기반 인젝션과, “use database commands (e.g. sleep) to delay answers in conditional queries.”(데이터베이스 명령(예: sleep)을 사용해 조건부 쿼리의 응답을 지연시키는) 시간 지연입니다.45 제가 가져온 페이지 중 어느 것도 이 정확한 문자열들을 기록하고 있지 않으므로, 저는 이것을 묘사만 하고 특정 도구의 것이라고 단정하지 않습니다.

2026년 11월 2일 태평양 시간 10:56부터 11:17까지, 한 스캐너 주소가 blakecrosley.com에 보낸 모든 요청(총 10,907건)을 1분마다 막대 하나로 나타내고 누가 응답했는지로 나눈 막대그래프. 차단 전 막대는 대부분 제공된 페이지와 앱의 거부이며, 정점은 분당 637건. 이 주소의 문의 양식 제출 중 62건이 앱에 도달했고 그중 31건이 200으로 응답했다. 200은 발송이거나 핸들러가 역시 200으로 응답하는 실패이므로 이메일은 최대 31통이며, 차단이 들어간 분 이전의 몇 분 동안 26건, 차단이 들어간 그 분 안에 5건, 그 이후의 분에는 0건이다. 파선은 모니터가 건 11:04:01 PST(19:04:01 UTC)의 차단을 표시하며, 스캔의 첫 1분으로부터 8분 뒤다. 가는 점선은 스캔의 첫 순간만 보고 아무것도 찾지 못한 10:57:00 PST의 모니터 실행과, 플러드 탐지 결과를 낸 11:02:46 PST의 실행을 표시한다. 그 이후 1분 버킷의 모든 요청(총 7,445건)은 엣지에서 차단되었고, 정점은 분당 1,107건이다.

11월 2일: 한 주소에서 온 요청 10,907건, 분 단위. 제 모니터의 두 실행과, 모니터가 11:04:01 PST에 건 차단을 표시했습니다.1419

4. 다섯 계층, 그리고 각각이 구별할 수 있었던 것

이 봇들과 제 받은편지함 사이에는 다섯 종류의 통제 장치가 있었습니다. 각각에 대해 저는 같은 질문을 던졌습니다. 봇과 사람을 구별할 수 있었는가. 엣지 규칙에 있는 관리형 챌린지 하나는 방문자에게 그 질문을 던질 수 있지만, 대상은 사용자 에이전트 문자열이 목록에 있는 요청뿐이었고 스캐너들의 문자열은 거기 없었습니다. 위협 점수를 키로 하는 두 번째 챌린지는 결코 발동할 수 없었으므로, 모든 제출에 그 질문을 던지는 것은 아무것도 없었습니다. 통제 장치 중 둘은 수를 세고, 하나는 이름과 목록을 대조하고, 하나는 사이트가 발급한 토큰을 클라이언트가 갖고 있음을 입증하고, 하나는 숨겨진 필드를 채우는 봇만 잡아냅니다. 다음은 각각이 무엇을 확인하고, 무엇을 막았으며, 나머지를 통과시킨 정확한 이유가 무엇인지입니다.

엣지 사용자 지정 규칙

무엇을 확인하는가. 이 사이트에서 첫 번째 사용자 지정 규칙은 계정 수준 목록에 있는 모든 주소와, ASN으로 지정한 호스팅 네트워크 일곱 곳을 차단합니다. 다른 규칙은 .php, /wp-, /.env 같은 익스플로잇 경로 부분 문자열을 차단합니다. 두 규칙이 더 있어서 문의 양식과 관리자 경로에 관리형 챌린지를 내줍니다. 하나는 사용자 에이전트가 비어 있거나 curl, wget, python-requests, httpx, scrapy, HeadlessChrome, PhantomJS, Selenium을 포함하는 경우이고, 다른 하나는 Cloudflare 위협 점수가 높은 경우입니다. 스튜디오 사이트에도 같은 종류의 규칙이 있는데, 두 챌린지가 사이트 전체에 적용되고 API 경로에 대한 차단은 문의 양식을 예외로 둡니다.9 위협 점수 챌린지는 사이트마다 하나씩 있었고, 둘 다 죽은 규칙이었습니다. Cloudflare의 필드 참조 문서에 따르면 이 점수는 한때 0에서 100 사이였지만 “Now, the threat score is always 0 (zero).”(이제 위협 점수는 항상 0(영)이다)입니다. 높은 점수를 요구하는 규칙은 아무것에도 일치하지 않으며, 저는 그것을 읽은 11월 3일에 둘 다 비활성화했습니다. 사용자 에이전트 챌린지는 그대로 둡니다.1146 Cloudflare의 Free 요금제는 사용자 지정 규칙 다섯 개를 허용하며, 이 규칙들은 “work as customized web application firewall (WAF) rules that you can use to perform actions like Block or Managed Challenge on incoming requests.”(들어오는 요청에 Block이나 Managed Challenge 같은 동작을 수행하는 데 쓸 수 있는, 맞춤형 웹 애플리케이션 방화벽(WAF) 규칙으로 작동한다)는 것입니다.47

무엇을 막았는가. 익스플로잇 경로는 두 날 모두 첫 1분부터 막았습니다.104 10월 30일에는 양식 제출을 하나도 막지 않았습니다.110 11월 2일에는 제 모니터의 차단 이후 양식 제출 438건을 막았습니다.2 스튜디오 사이트에서는 양식 제출을 하나도 막지 않았습니다. 8일 동안 방화벽은 양식에 대한 POST에서 이벤트를 하나도 기록하지 않았습니다.7

나머지는 왜 통과했는가. 어떤 규칙도 요청이 무엇인지를 보지 않기 때문입니다. 목록 규칙은 무언가가 주소를 목록에 올린 뒤에야 그 주소에 일치합니다. 10월 30일에는 사람이, 11월 2일에는 제 모니터가 올렸습니다.91019 사용자 에이전트 챌린지는 문자열에 일치하고, 두 스캐너는 모두 브라우저 문자열을 보냈습니다. 첫날 최소 69,322번, 둘째 날 9,725번입니다.3241 사용자 에이전트 챌린지는 자기 정체를 밝히는 도구에는 실제로 통합니다. 10월 30일 제 curl 시험은 관리형 챌린지 24건을 받았습니다.39 위협 점수 챌린지는 누구에게도 발동할 수 없었고, 방화벽은 두 주소 어느 쪽에 대해서도 양식에서 챌린지를 기록하지 않았습니다.11104 Tor 출구와 지정한 일곱 ASN 밖의 호스팅 네트워크는 어떤 목록에도 없습니다.

엣지 규칙은 알려진 주소, 알려진 네트워크, 정직한 도구를 나머지와 구별할 수 있고, 그 챌린지가 사람이 있는지 묻는 것은 그 표현식이 고른 요청에 대해서뿐입니다. 스캐너가 보낸 브라우저 문자열을 브라우저와 구별할 수는 없습니다.

엣지 속도 제한

무엇을 확인하는가. 규칙 문구는 짧습니다.9

(http.request.method eq "POST" and http.request.uri.path in {"/api/contact" "/paint/share"})

일치하는 요청을 Cloudflare 데이터 센터와 클라이언트 주소별로 세고, 10초에 3건까지 허용하며, 10초 동안 차단합니다. 고르게 펴면 그 상한은 데이터 센터별, 주소별로 분당 18건에 명목상 해당합니다. 규칙 자체는 10초 기간 단위로 셉니다.916 Free 요금제는 기간, 타임아웃, 카운트 키를 고정하고, 존에 이런 규칙을 하나만 줍니다. 10초의 카운트 기간, 10초의 완화 타임아웃, IP 기준 카운트입니다.48 데이터 센터도 선택 사항이 아닙니다. Cloudflare 문서는 “Every rate limiting rule includes the Cloudflare data center ID (cf.colo.id) as a mandatory characteristic”(모든 속도 제한 규칙은 Cloudflare 데이터 센터 ID(cf.colo.id)를 필수 특성으로 포함한다)이라고 하고, “Counters are not shared across data centers, with the exception of data centers associated with the same geographical location.”(같은 지리적 위치에 연결된 데이터 센터를 제외하면, 카운터는 데이터 센터 간에 공유되지 않는다)이라고 말합니다.6

무엇을 막았는가. 두 날 모두 스캐너의 요청은 하나도 막지 않았습니다. 사고 시간대에 이 규칙이 낸 이벤트는 제 시험의 3건뿐이었습니다.10439 스튜디오 사이트에도 자체적인 사이트 전체 속도 제한 규칙이 있는데, Cloudflare가 검증된 봇으로 분류하는 클라이언트를 제외한 모든 요청을 같은 방식으로 세며, 조금씩 이어진 제출(드립)의 양식 제출은 하나도 거부하지 않았습니다. 8일 동안 방화벽은 양식에 대한 제출에서 이벤트를 하나도 기록하지 않았습니다. 그 규칙은 한 주소가 사이트에 보내는 그런 요청을 모두 세고, 제 데이터는 양식 제출만 세므로, 카운터에 여유가 얼마나 남아 있었는지는 측정하지 못합니다.97

나머지는 왜 통과했는가. 10월 30일에는 규칙이 18:49:53 UTC까지 존재하지 않았습니다. 손으로 건 차단의 첫 이벤트와 같은 초이고, 마지막 양식 제출이 앱에 도달한 지 약 12분 뒤입니다.91 그 스캐너의 제출 1,065건은 모두 한 데이터 센터, 파리를 거쳐 들어왔으므로, 규칙이 있었다면 그것들에 대해 카운터 하나를 유지했을 것입니다.5 11월 2일에는 규칙이 존재했고, 스캐너의 양식 제출은 18:56부터 19:05:00 UTC까지 두 데이터 센터를 거쳐 들어왔습니다. 암스테르담이 44건, 런던이 41건입니다. 가장 바쁜 1분인 19:02에는 런던이 18건, 암스테르담이 14건을 실어 날랐습니다.5 분당 18건은 규칙의 상한에 명목상 해당하지만, 제 데이터는 도착 시각이 아니라 분당 합계이므로 한 데이터 센터의 어느 10초 기간에 3건이 넘게 들어왔는지는 보여 주지 못합니다. Cloudflare는 정밀도에 대해 분명히 말합니다. “Rate limiting rules are not designed to allow a precise number of requests to reach your origin server. There may be a delay of up to a few seconds between detecting a request and updating rate counters.”(속도 제한 규칙은 정확한 수의 요청이 오리진 서버에 도달하도록 설계되지 않았다. 요청을 감지한 뒤 속도 카운터를 갱신하기까지 최대 몇 초의 지연이 있을 수 있다)48 앱에 도달한 첫 제출부터 마지막 제출까지의 7분 동안 스캐너는 분당 평균 8.86건이었습니다.2 그날의 제 메모는 그것을 “about nine a minute”(분당 약 9건)이라고 부르고 거기서 멈췄습니다. 데이터 센터 간의 분할이 메모가 놓친 부분입니다.23

규칙이 애초에 작동한다는 증거가 필요했기에, 11월 3일 아침 제 컴퓨터에서 그것을 프로브했습니다. 06:36:49 PST에 CSRF 토큰 없이 POST 열 건을 0.4초 간격으로 보냈습니다. 네 건은 필수 필드가 빠져 FastAPI가 거부했기 때문에 422로 돌아왔고, 이어서 여섯 건이 429로 돌아왔습니다. 06:38:03 PST에는 모든 필드를 갖춘 여덟 건을 보냈습니다. 세 건은 오리진의 CSRF 거부인 403으로 돌아왔고, 이어서 다섯 건이 Cloudflare의 “error code: 1015”라는 본문과 함께 429로 돌아왔습니다. 그 몇 분 동안 방화벽은 양식에서 이벤트 11건을 기록했고, 모두 속도 제한 규칙의 차단이었습니다.18 규칙은 발동하고, Cloudflare는 그것을 기록합니다. 11월 2일에 대한 제 해석은 가설입니다. 두 카운터로 나뉘고 각각에서 명목상 상한 가까이 달렸기 때문에 스캐너는 어느 쪽도 작동시키지 않았다는 것입니다. 분당 합계에 들어맞는 가장 강력한 설명이지 입증이 아닙니다. 합계로는 각 분 안에서 제출이 어떤 간격으로 들어왔는지 알 수 없고, Cloudflare도 카운터가 갱신되는 동안 초과 요청이 통과할 수 있음을 인정합니다.548

속도 제한은 한 데이터 센터의 한 주소에 대해 빠름과 느림을 구별할 수 있습니다. 누가 보내는지는 구별하지 못합니다.

CSRF 토큰

무엇을 확인하는가. 이 사이트의 양식에는 페이지에 렌더링된 숨겨진 토큰이 있고, 세 부분으로 이루어집니다. 타임스탬프, 무작위 값, 그리고 앞의 둘에 대한 HMAC 서명입니다. 서버는 서명이 일치하고 타임스탬프가 한 시간 이내이면 토큰을 받아들입니다.15 스튜디오 사이트의 양식에는 CSRF 토큰이 없습니다.25

무엇을 막았는가. 10월 30일에 제출 4건(모두 18:03의 분), 11월 2일에는 0건입니다.12

나머지는 왜 통과했는가. 토큰에 상태가 없기 때문입니다. 세션에도, 쿠키에도, 주소에도 묶여 있지 않으므로 페이지를 가져가는 클라이언트는 누구나 유효한 토큰을 얻고, 다른 클라이언트에게서 토큰을 건네받은 클라이언트도 유효 기간 동안 그것을 제시할 수 있습니다. 두 스캐너는 실제로 사이트의 페이지를 가져갔습니다. 요청 로그가 그것을 보여 줍니다. 검사가 보여 주는 것은 더 좁습니다. 두 날의 모든 발송이 제 사이트가 발급한, 유효하고 만료되지 않은 토큰을 갖고 있었다는 것입니다. 핸들러가 발송 전에 토큰을 확인하기 때문입니다.1513142416 CSRF 토큰은 다른 사이트가 방문자의 브라우저로 하여금 제 양식을 제출하게 만드는 것을 막기 위해 존재합니다. 스스로 양식을 제출하려는 클라이언트를 막도록 의도된 적은 없었고, 실제로 막지 않았습니다.

같은 검사에는 두 번째 결함이 있었고, 그것은 봇이 아니라 사람에게 떨어졌습니다. 토큰이 실패하면 핸들러는 HTMX 양식이 한 번도 표시하지 않는 JSON 403을 던졌으므로, 토큰이 만료된 사람은 보내기를 눌러도 아무것도 보지 못했을 것입니다.12 이 결함은 고쳤지만, 첫 번째 수정도 작동하지 않았습니다. 그 경위는 6절에서 설명합니다.

유효한 CSRF 토큰은 제 사이트가 발급한 만료되지 않은 토큰을 클라이언트가 갖고 있었음을 입증합니다. 누가 양식을 채웠는지는, 심지어 이 클라이언트가 페이지를 불러왔는지조차 입증하지 못합니다.

앱 속도 리미터

무엇을 확인하는가. 이 사이트의 핸들러는 데코레이터 뒤에 있고, 이 데코레이터는 클라이언트 주소별, 시계 기준 1분별 양식 제출을 Cloudflare KV에서 세며(메모리 내 대체 경로 포함) 5건을 넘으면 429로 거부합니다.17 스튜디오 사이트에는 익명 클라이언트를 위한 프로세스별 메모리 내 리미터가 있어서, 주소별, 클라이언트 분류별로 셉니다. 이 분류는 리미터가 사용자 에이전트와 주소로부터 부여하는 등급이며, 헬스 체크와 정적 파일을 제외한 모든 경로가 대상입니다.25

무엇을 막았는가. 10월 30일에 830건, 11월 2일에 31건의 제출입니다. 스튜디오 사이트에서는 0건입니다.127

나머지는 왜 통과했는가. 설계상 누구에게나 분당 5건을 통과시키고, 창이 시계 기준 1분이므로 분 경계에 걸친 폭주는 허용량을 두 번 받습니다.17 거기까지는 알고 있었습니다. 건수를 코드와 맞대어 읽기 전까지 몰랐던 것은 이것이 샌다는 사실이고, 코드에는 샐 수 있는 경로가 적어도 세 가지 있습니다. 리미터는 KV에서 HTTP로 카운터를 읽고, 한도와 비교한 다음, 카운트에 1을 더한 값을 별도의 요청으로 씁니다. 그래서 어느 쪽도 쓰기 전에 둘 다 읽은 두 제출은 둘 다 통과합니다. 증가 연산은 쓰기 전에 카운터를 한 번 더 읽는데, 그 두 번째 읽기가 실패하면 쓰기가 카운트를 1로 되돌리므로 그 분은 처음부터 다시 시작됩니다. 그리고 KV 읽기가 아무것도 돌려주지 않거나 쓰기가 실패할 때마다, 판단은 한 프로세스의 메모리에 보관한 목록으로 넘어가며 다른 프로세스는 그 목록을 공유하지 않습니다.17

건수가 누수를 보여 줍니다. 10월 30일에는 여덟 개의 분이 5건을 넘는 발송을 담았고, 18:06의 분은 18건을 담았습니다.1 분당 5건의 정확한 리미터였다면 스캐너가 제출한 분들에 걸쳐 최대 54건의 발송만 허용했을 텐데, 101건이 나가 상한을 47건 넘었습니다. 11월 2일에는 19:02와 19:03의 분이 각각 9건과 10건을 통과시켰습니다. 정확한 리미터였다면 자신이 본 62건의 제출 중 최대 22건만 허용했을 텐데, 31건이 나가 상한을 9건 넘었습니다.3 어느 경로가 샜는지는 건수가 보여 주지 않습니다. 제 해석으로는, 어느 쪽도 쓰기 전에 여러 제출이 같은 카운트를 읽었거나, 부하 속에서 KV 호출이 실패해 카운트가 초기화되거나 판단이 프로세스별 목록으로 넘어갔거나, 혹은 이것들이 섞인 것입니다. 제가 가진 로그는 앱이 돌려준 상태만 기록하고 어느 경로가 그것을 결정했는지는 기록하지 않으므로, 경로들을 구별할 수 없습니다.173 해법은 어느 경로든 같고, 한 줄짜리 변경이 아닙니다. 원자적 증가를 지원하는 카운터 저장소입니다. KV 읽기 뒤에 별도의 쓰기를 하는 방식은 그것을 제공하지 않습니다. 이 누수는 제 버그입니다. 동시에 그것은 이 글의 논지를, 제가 일부러 만들 수 있었던 것보다 더 날카롭게 만들어 줍니다. 5건이라고 말하면서 18건을 통과시키는 통제 장치는 애초에 누가 제게 글을 쓸 수 있는지를 결정하는 것이 될 수 없었습니다.

스튜디오 사이트에서는 앱 리미터가 관찰된 양식 제출을 하나도 거부하지 않았고, 가장 바쁜 주소도 한 시간에 13건을 보냈습니다. 그 카운터는 같은 클라이언트 분류 아래에서 한 주소가 다른 모든 경로로 보낸 요청까지 받아들이고, 제 데이터는 양식 제출만 다루므로, 건수로는 그 카운터에 여유가 얼마나 있었는지 측정하지 못합니다.725

속도 리미터는 요청이 차례로 올 때 5건과 6건을 구별할 수 있습니다. 어떤 건수에서도 사람과 스크립트는 구별하지 못합니다.

허니팟

무엇을 확인하는가. 스튜디오 사이트의 양식에는 사람이 결코 보지 못하는 website라는 이름의 텍스트 입력이 있습니다. tabindex="-1"과 aria-hidden이 붙어 있어서 키보드도 화면 낭독기도 그곳에 멈추지 않습니다. 그 칸에 무언가가 담겨 돌아오면, 핸들러는 사람이 보게 될 것과 똑같은 성공 메시지로 응답하고 아무것도 저장하거나 메일로 보내지 않습니다.25 이 분기는 제가 6월 5일에 양식을 만들 때부터 핸들러에 있었습니다.25 이 사이트의 양식에는 허니팟이 없습니다.24

Ned Batchelder는 2007년에 이 기법을 설명했습니다. “These fields are called honeypots and are validated when the form data is posted. If they contain any text, then the submitter must be a bot”(이 필드들은 허니팟이라고 불리며 양식 데이터가 제출될 때 검증된다. 무언가 텍스트가 들어 있다면 제출자는 봇임에 틀림없다)49 그가 이것이 통하는 이유로 든 것은 양식을 채우는 봇이 “cannot distinguish invisible fields from visible ones.”(보이지 않는 필드와 보이는 필드를 구별하지 못한다)는 점입니다.49

무엇을 막았는가. 8일 동안의 제출 52건 중 42건, 80.8%입니다. 도착한 시간대로 보면 Tor 출구에서 온 모든 제출이 포함되고, 시간대와 메시지 패턴으로 보면 스스로를 SQL 스파이더라고 밝힌 것의 한 차례 폭주에서 온 13건 전부도 포함됩니다.720

나머지는 왜 통과했는가. 보이는 필드만 채우는 봇은 website를 결코 건드리지 않습니다. 그리고 제출이 저장되어 제 받은편지함에 닿을 수 있었던 발신자들은 그 칸을 비워 두었습니다.825 그들이 일부러 보이는 필드를 골랐다는 것은 제 해석입니다. 핸들러가 기록하는 것은 숨겨진 필드가 비어서 돌아왔다는 사실뿐입니다. Batchelder는 이것을 내다보았습니다. “In theory, it is possible for a spambot to defeat any of these measures. But in practice, bots are very stupid”(이론상 스팸봇은 이 대책 중 어느 것이든 무력화할 수 있다. 하지만 실제로는 봇이 아주 멍청하다)49 어떤 봇은 다른 봇보다 덜 멍청하고, 리드 생성을 파는 봇이 가장 덜 멍청합니다.

허니팟이 입증하는 것은 클라이언트가 사람이 볼 수 없는 필드를 채웠다는 사실뿐입니다. 비어 있는 허니팟은 아무것도 입증하지 않습니다.

스트림마다 하나씩 세 그룹으로 나뉜 가로 막대그래프로, 각 막대는 엣지에 도달한 제출 중의 비율이다. blakecrosley.com의 10월 30일 스캐너: 1,065건이 엣지에 도달했고, 1,065건 모두 앱에 도달했으며, 101건이 200으로 응답했다. 200은 발송이거나 핸들러가 역시 200으로 응답하는 실패이므로 받은편지함에 닿은 것은 최대 101건이다. 11월 2일 스캐너: 500건이 엣지에 도달했고, 62건이 앱에 도달했으며(차단이 들어간 분 이후는 0건), 31건이 200으로 응답했으므로(차단이 들어간 분 이전의 몇 분 동안 26건, 차단이 들어간 그 분 안에 5건) 받은편지함에 닿은 것은 최대 31건이다. 941apps.com의 8일: 52건이 엣지에 도달했고, 52건 모두 앱에 도달했으며, 10건이 저장되었으므로 메일로 보낸 것은 최대 10건이다. 나머지 42건에는 허니팟이 성공한 것처럼 응답하고 아무것도 저장하지 않았다.

각 스트림이 어디까지 갔는가: 이 사이트에 대한 두 스캐너의 제출과, 스튜디오 사이트에 대한 8일간의 드립.1278

다섯 계층의 공통점

다섯 개의 마무리 문장을 나란히 읽으면 빈틈이 분명해집니다. 속도 제한은 주소별 양을 묶어 둡니다. CSRF 토큰은 클라이언트가 사이트가 발급한 토큰을 갖고 있었음을 입증합니다. 허니팟은 클라이언트가 숨겨진 필드를 채웠음을 입증합니다. 엣지 규칙은 이름과 목록을 압니다. 그리고 반대편에 사람이 있는지 물을 수 있는 것, 즉 사용자 에이전트 챌린지는 사용자 에이전트 문자열이 목록에 있는 요청에만 묻습니다. 그 옆의 위협 점수 챌린지는 결코 발동할 수 없었습니다. 어느 스캐너도 일치하지 않았으므로 모든 제출에 묻는 것은 아무것도 없었고, 스캐너들은 브라우저가 할 수 없는 일을 하나도 하지 않았습니다. 페이지를 가져가고, 그 토큰을 간직하고, 제한이 일부를 통과시키는 속도로 제출했을 뿐입니다. OWASP의 크리덴셜 스터핑 지침은 다른 양식에 대해 같은 점을 지적하면서, “not rely on a single predictable volume limit”(예측 가능한 단일 양적 한도에 의존하지 않는) 방어를 권합니다.50

5. 스튜디오 사이트의 드립

스튜디오 사이트의 양식에는 플러드가 한 번도 오지 않았습니다. 온 것은 드립, 즉 조금씩 이어지는 제출이었고, 드립은 전혀 다른 짐승입니다. 여기서의 기간은 8일 쿼리에서 양식 제출이 있었던 첫 시간대인 10월 26일 17:00 UTC부터, 제가 데이터를 저장한 11월 3일 13:59:50 UTC까지입니다. 첫날과 마지막 날은 일부만 포함됩니다.7

제출 대 저장된 행

이 기간에 양식은 POST 52건을 받았고 모두 200으로 응답했습니다. 같은 기간 메시지 테이블에는 10행이 늘었습니다. 핸들러에서 200으로 응답하면서 아무것도 저장하지 않는 경로는 허니팟 분기뿐이므로, 허니팟이 나머지 42건, 즉 80.8%를 잡아낸 것입니다.7825 UTC 날짜별로는 다음과 같습니다.78

날짜(UTC) 제출(200) 저장된 행 허니팟이 잡은 수
10월 26일(일부) 4 2 2
10월 27일 5 0 5
10월 28일 3 2 1
10월 29일 5 1 4
10월 30일 16 1 15
10월 31일 8 2 6
11월 1일 4 0 4
11월 2일 5 0 5
11월 3일(일부) 2 2 0

10월 30일의 급증은 영국의 한 주소가 Mozilla/5.0 (compatible; SQLSpider/1.5)라는 사용자 에이전트로 한 시간 안에 13건을 제출한 것입니다. 시간대와 메시지 패턴으로 보면 테이블은 그중 아무것도 저장하지 않았습니다. 그 시간대에 저장된 한 행은 리드 생성 영업 문구로, 같은 시간대에 제출했던 미국 주소의 패턴입니다.7820

누가 보내고 있었는가

Cloudflare는 Tor 네트워크에서 온 요청에 T1이라는 특별한 국가 코드를 붙입니다. 헤더 참조 문서에는 “T1 - Used for clients using the Tor network”(T1: Tor 네트워크를 사용하는 클라이언트에 사용)라고 되어 있고, IP Access 규칙 페이지에는 “T1: Tor exit nodes (country name: Tor)”(T1: Tor 출구 노드(국가 이름: Tor))라고 되어 있습니다.5152 이 코드로 보면 52건의 제출 중 23건(44.2%)이 Tor에서 왔고, 서로 다른 주소 35개 중 23개(65.7%)가 Tor 출구였습니다.7 교차 검증으로, 양식에 제출한 모든 주소를 Tor Project의 일괄 출구 목록과 비교했습니다. 이 프로젝트는 이 목록을 “a nearly real time authoritative source for Tor exits that allow contacting your server on port 80”(포트 80으로 여러분의 서버에 접속하는 것을 허용하는 Tor 출구에 대한, 거의 실시간의 권위 있는 출처)이라고 부릅니다. Cloudflare가 T1으로 표시한 주소 23개는 모두 제가 11월 3일에 가져온 목록에 있었고, 나머지 12개는 하나도 없었습니다.5354 두 출처는 일치합니다. 다만 그 목록은 11월 3일의 출구이므로 그날의 코드를 확인해 줄 뿐, 각 제출이 도착한 날에 어떤 주소가 출구였는지는 입증하지 않습니다. 이 프로젝트의 CollecTor 아카이브는 과거의 출구 목록을 보관하고 있어 사건 당일의 소속을 확정할 수 있지만, 저는 그것을 쿼리하지 않았습니다.55

이 기간에 대부분의 주소는 한 번만 제출했습니다. 35개 중 32개(91.4%)이며, Tor 출구는 모두 그 안에 있습니다.7 두 번 이상 제출한 셋은 13건의 SQL 스파이더, 한 시간에 두 건을 보낸 폴란드의 주소, 그리고 다섯 건을 보낸 미국의 주소입니다. 마지막 주소의 다섯 건은 이 기간에 테이블이 저장한 리드 생성 영업 문구 다섯 건과 같은 다섯 시간대에 들어 있고, 이 주소는 매번 사용자 에이전트를 바꾸었습니다. Linux의 Chrome 135, 이어서 Firefox 128, 137, 138, 그리고 다시 137입니다.720

사용자 에이전트도 같은 선을 따라 갈렸습니다. Tor 제출 중 18건은 Android 기기를, 5건은 데스크톱 Chrome을 내세웠고, 23건 중 21건은 Chrome 81 이하를 내세웠습니다. Tor가 아닌 제출은 그보다 훨씬 새로운 데스크톱 브라우저를 내세웠습니다. 125부터 135까지의 데스크톱 Chrome이 10건, 128부터 138까지의 Firefox가 6건, 그리고 스파이더가 자기 이름으로 보낸 13건입니다.7

누가 저장되었는가

두 무리는 허니팟에서 만났습니다. 이 기간에 저장된 행 중 Tor 제출만 있는 시간대에 놓인 것은 하나도 없고, 10행 모두 Tor가 아닌 제출이 있는 시간대에 있습니다. Tor 제출 23건 중 22건은 저장된 행이 전혀 없는 시간대에 있고, 23번째는 미국의 리드 생성 주소와 시간대를 공유합니다.20 시간대로 맞춘 것은 요청 로그와 메시지 테이블이 공통의 요청 ID를 갖고 있지 않기 때문이며, 따라서 이 대응은 추론입니다. 도착한 시간대로 보면, 이 기간의 Tor 제출은 모두 허니팟에 걸렸습니다.20 저장된 것, 즉 받은편지함에 닿을 수 있었던 것은 Tor 밖의 주소에서, 더 새로운 데스크톱 브라우저 문자열로, 허니팟을 비워 둔 채 보낸 것이었습니다.

숫자는 양식 스팸의 오래된 패턴과 맞아떨어집니다. Batchelder의 2007년 분류는 스팸 생산자를 “Playback spambots, form-filling spambots, and humans.”(재생형 스팸봇, 양식 채우기형 스팸봇, 그리고 사람)로 나눕니다.49 Tor 드립은 모든 필드를 채우고 허니팟에 빠지는 양식 채우기형처럼 행동합니다. 통과한 발신자들은 키보드 앞의 사람에 더 가까운 무언가, 혹은 사람처럼 보이도록 만들어진 스크립트처럼 행동합니다. OWASP는 이 활동을 OAT-017, Spamming(스패밍)으로 분류합니다. “Malicious or questionable information addition to public or private content, or messages.”(공개 또는 비공개 콘텐츠나 메시지에 악의적이거나 의심스러운 정보를 추가하는 것)56 OWASP는 한 가지를 범위 밖으로 두기도 합니다. “The mass abuse of broken form-to-email and form-to-SMS functions, to send messages to unintended recipients, is not included in this threat event”(의도하지 않은 수신자에게 메시지를 보내기 위해 고장 난 양식-이메일, 양식-SMS 기능을 대량으로 악용하는 것은 이 위협 이벤트에 포함되지 않는다)56 제 양식은 저에게만 메일을 보내므로 포함되는 경우, 즉 사이트 소유자를 향한 스팸이지, 제외되는 그 경우가 아닙니다.

드립은 설계상 속도 제한도 무력화합니다. OWASP의 크리덴셜 스터핑 지침은 요청을 여러 주소에 흩뜨리는 효과를 명시합니다. “This may defeat both IP block-lists and rate limiting, as per IP request volume may remain relatively low”(IP별 요청량이 비교적 낮게 유지될 수 있으므로, 이는 IP 차단 목록과 속도 제한을 모두 무력화할 수 있다)50 주소당 제출 한 건은 양으로서 더 낮아질 수 없는 수준입니다.

저장된 메시지가 말하는 것

메시지 테이블은 6월 6일까지 거슬러 올라가며, 제가 읽었을 때 108행이 있었습니다. 가장 최근 것은 11월 3일 07:24 UTC에 도착했습니다.8 저는 이것들을 고정된 규칙으로 패턴별로 분류했고, 처음 일치하는 규칙을 따랐습니다.57

패턴 테이블 전체 10월
“Hi, I wanted to know your price”(안녕하세요, 가격을 알고 싶습니다), 행마다 언어 하나, 발신자 이름 하나 49 10
업체 영업: 웹 리드, Instagram 성장, 설명 영상, SEO, B2B 데이터, 디렉터리, 디자인 22 4
경품 미끼: 잭팟, 프로모션 코드, PlayStation, 람보르기니, 롤스로이스 링크, 발신자 이름 하나 16 3
telegra.ph의 “$50,000,000 per month”(월 5천만 달러) 링크 9 9
리드 생성 영업: “If I were a real customer, how soon would I get a call back?”(제가 진짜 고객이라면 얼마나 빨리 회신 전화를 받을 수 있을까요?) 6 5
기타: 의미 없는 문자열 두 건, 메일링 리스트 요청, 다른 곳에 써 달라는 메모 4 1
9월 21일 이메일 알림 기능에 대한 제 시험 1 0
제 해석으로는 진짜 문의 1 0

이 중 몇 가지는 더 자세히 볼 만합니다. $50,000,000 행 아홉 개는 발신자 이름 하나에 서로 다른 발신자 주소 아홉 개로 들어왔고, 모두 같은 헤드라인을 조금씩 변형한 것이었습니다.8 가격 질문은 테이블 전체에서 25개 언어로 나타납니다. 아프리칸스어, 아르메니아어, 바스크어, 벨라루스어, 벵골어, 불가리아어, 카탈루냐어, 크로아티아어, 덴마크어, 영어, 조지아어, 그리스어, 헝가리어, 아이슬란드어, 인도네시아어, 아일랜드어, 이탈리아어, 라틴어, 라트비아어, 리투아니아어, 룩셈부르크어, 스페인어, 베트남어, 웨일스어, 줄루어입니다. 10월에만 아홉 개 언어로 들어왔습니다. 아르메니아어, 바스크어, 덴마크어, 인도네시아어, 라틴어, 라트비아어, 리투아니아어, 스페인어, 베트남어입니다.57 언어 판정 중 둘은 제가 한 것이고 아슬아슬합니다. 크로아티아어로 읽은 문장은 보스니아어일 수 있고, 인도네시아어로 읽은 문장은 말레이어일 수 있습니다.57 누군가가 회화집을 한 언어씩 문의 양식에 통과시키고 있는 것입니다.

빈도는 드립의 또 다른 특징입니다. 10월에는 32행이 저장되었고, 하루 1.03행, 31일 중 21일에 저장이 있었으며, 하루에 3행을 넘은 날은 없었습니다.8 하루 한 건으로는 주소별 속도 제한이 하나도 작동하지 않습니다.

어느 것이 받은편지함에 닿았는가

양식이 스튜디오 받은편지함으로 이메일을 보내기 시작한 것은 9월 21일 17:46:47 UTC부터입니다. 그 이후 43행을 저장했고, 따라서 메일로 보냈습니다. 알림 호출이 최선 노력 방식이고 배포 시각도 확인하지 않았으므로 이는 상한입니다. 그 날짜 이전에 저장된 65행은 한 번도 메일로 보내지지 않았습니다.258 증거 기간 안의 모든 행은 스팸입니다.57

제 시험을 제외하면, 테이블 전체에서 스팸이 아닌 행은 하나뿐입니다. 9월 14일, 스튜디오 제품 중 하나를 쓰는 고객이 특정 오류에 대해 사람의 말투로 글을 보냈는데, 그 메시지는 아무도 읽지 않은 채 테이블에 놓여 있었습니다.57 일주일 뒤 양식을 받은편지함으로 메일을 보내도록 연결했을 때, 커밋 메시지는 그 이유를 평이한 말로 적었습니다. “a paying customer’s message sat unread in D1 for a week”(유료 고객의 메시지가 D1에서 일주일 동안 읽히지 않은 채 있었다)25 그 메시지가 알림 기능이 존재하는 이유이고, 알림 기능을 끄는 것으로 스팸을 해결할 수 없는 이유이기도 합니다. 그 발신자는 여기서 익명으로 둡니다. 요점은 진짜 사람이 회화집과 같은 테이블 안에 있다는 것입니다.

6. 봇 검사란 무엇인가, 그리고 무엇을 출시했는가

Cloudflare의 말로 본 Turnstile

Cloudflare는 Turnstile을 자사의 “smart CAPTCHA alternative”(스마트한 CAPTCHA 대안)라고 부르며, “can be embedded into any website without sending traffic through Cloudflare and works without showing visitors a CAPTCHA.”(트래픽을 Cloudflare로 보내지 않고도 어떤 웹사이트에든 삽입할 수 있고, 방문자에게 CAPTCHA를 보여 주지 않고 작동한다)라고 말합니다.21 제 질문에 답하는 것은 그 메커니즘입니다. 개요 문서는 “First, we run a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment,”(먼저, 방문자나 브라우저 환경에 대한 신호를 모으기 위해 상호작용이 필요 없는 작은 JavaScript 챌린지들을 연달아 실행한다)라고 하고, “These challenges include proof-of-work (computational puzzles), proof-of-space, probing for web APIs, and various other challenges for detecting browser-quirks and human behavior.”(이 챌린지에는 작업 증명(계산 퍼즐), 공간 증명, 웹 API 탐색, 그리고 브라우저의 특이점과 사람의 행동을 감지하기 위한 그 밖의 다양한 챌린지가 포함된다)라고 이어 갑니다.21 속도 제한은 몇 건인지를 묻습니다. CSRF 토큰은 클라이언트가 사이트가 발급한 토큰을 갖고 있는지를 묻습니다. Turnstile은 반대편에 있는 것이 어떤 종류의 클라이언트인지를 묻습니다. 제 사용자 에이전트 챌린지도 그것을 물을 수 있었지만, 사용자 에이전트 문자열이 목록에 있는 요청에만 물었고, 위협 점수 챌린지는 결코 발동할 수 없었습니다. 여기서 중요한 차이는 선택적으로 발동하는 챌린지와 모든 제출에 요구되는 검증의 차이입니다.

위젯에는 세 가지 모드가 있습니다. 제가 쓰는 것은 관리형 모드이고, Cloudflare는 이를 “Automatically chooses between non-interactive or checkbox challenge based on visitor risk level. No images or text to decipher.”(방문자의 위험 수준에 따라 상호작용 없는 챌린지와 체크박스 챌린지 중에서 자동으로 고른다. 해독할 이미지나 글자는 없다)라고 설명합니다.58 쉽게 말해 방문자는 짧은 확인 과정만 보게 되고, 신호가 더 많은 것을 요구하지 않는 한 그 밖에는 아무것도 없으며, 요구하면 체크할 상자 하나를 받습니다. 보이지 않는 모드는 그것마저 숨기는데, Cloudflare는 여기에 조건을 붙입니다. 그 모드를 켠 사이트는 자체 개인정보 처리방침에서 Cloudflare의 Turnstile Privacy Addendum을 언급해야 합니다.58 Turnstile에는 자체 무료 요금제가 있어서 위젯을 최대 20개까지, 챌린지는 무제한으로 쓸 수 있으며, Cloudflare는 이것이 “can be used independently without requiring other Cloudflare services.”(다른 Cloudflare 서비스 없이 독립적으로 사용할 수 있다)라고 말합니다.59

서버 쪽 절반, 실제로 중요한 쪽

위젯만으로는 양식에 아무 도움도 되지 않으며, Cloudflare는 서버 측 가이드의 첫 줄에서 그렇게 말합니다. “You must call the Siteverify API to complete your Turnstile implementation. The client-side widget alone does not protect your forms.”(Turnstile 구현을 완성하려면 Siteverify API를 호출해야 한다. 클라이언트 측 위젯만으로는 양식을 보호하지 못한다) 그리고 더 직설적으로 “Tokens can be forged. An attacker can submit any string to your form endpoint without completing a challenge.”(토큰은 위조될 수 있다. 공격자는 챌린지를 완료하지 않고도 양식 엔드포인트에 임의의 문자열을 제출할 수 있다)라고 합니다.60 서버는 비밀 키와 함께 토큰을 https://challenges.cloudflare.com/turnstile/v0/siteverify로 POST하고, 방문자의 IP 주소를 remoteip로 포함할 수도 있습니다.60

토큰의 두 가지 속성이 그 주변 코드의 모양을 정합니다. “Each token is valid for 300 seconds (5 minutes) after generation.”(각 토큰은 생성 후 300초(5분) 동안 유효하다) 그리고 “Tokens are single-use. Each token can only be validated once. A replayed token will be rejected with the timeout-or-duplicate error code.”(토큰은 일회용이다. 각 토큰은 한 번만 검증될 수 있다. 재사용된 토큰은 timeout-or-duplicate 오류 코드로 거부된다)60 토큰 하나를 손에 넣은 스캐너라도 그것으로 양식을 1,065번 제출할 수는 없습니다. Cloudflare는 또한 토큰 길이를 2,048자로 제한하고, 서버에 “Validate the action and hostname when specified.”(지정된 경우 action과 hostname을 검증하라)라고 지시합니다.60 같은 페이지에서 주의할 점이 하나 있습니다. 한 곳에서는 만료된 토큰에 대해 timeout-or-duplicate를 들고, 오류 표에서는 invalid-input-response를 “Token is invalid, malformed, or expired”(토큰이 유효하지 않거나, 형식이 잘못되었거나, 만료되었다)라고 설명합니다. 따라서 서버는 로직을 코드 하나에 묶지 말고 어떤 실패든 거부로 처리해야 합니다.60

무엇을 처리하는가, Cloudflare의 말로

Cloudflare는 Turnstile이 개인 데이터를 쓰지 않는다고 주장하지 않으며, 저도 그렇게 주장하지 않겠습니다. Turnstile 개인정보 부록은 신호를 이렇게 명시합니다. “Cloudflare Turnstile processes a variety of client-side signals (“Signals”) such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin.”(Cloudflare Turnstile은 클라이언트 IP 주소, TLS 지문, User-Agent 헤더, Sitekey와 연결된 오리진 같은 다양한 클라이언트 측 신호(“Signals”)를 처리한다) 목적도 밝힙니다. “The purpose of collecting these Signals is not to identify, profile or target any individuals but solely to detect and block bots.”(이 Signals를 수집하는 목적은 개인을 식별하거나, 프로파일링하거나, 표적으로 삼는 것이 아니라 오로지 봇을 감지하고 차단하는 것이다) 그리고 Cloudflare가 제품 개선에도 그것을 쓴다고 말합니다. “Cloudflare is a data controller of Signals that we process to improve Turnstile’s bot detection capabilities.”(Cloudflare는 Turnstile의 봇 감지 능력을 개선하기 위해 처리하는 Signals의 데이터 컨트롤러다)22 Cloudflare가 손대지 않는다고 말하는 것은 내용입니다. “Turnstile does not access, store, or transmit user communications, form entries, or other page inputs.”(Turnstile은 사용자 통신, 양식 입력 내용, 기타 페이지 입력에 접근하거나, 저장하거나, 전송하지 않는다)21 제품 페이지는 “Turnstile never harvests data for ad retargeting.”(Turnstile은 광고 리타기팅을 위해 데이터를 수집하는 일이 결코 없다)라고 덧붙입니다.61 제 해석으로는, Turnstile이 보는 것은 제 엣지가 이미 모든 요청에 대해 기록하는 것과 같은 종류의 연결 데이터이고, 사람이 제 양식에 입력하는 내용은 결코 보지 않습니다.

필요한 CSP

Turnstile은 Cloudflare의 챌린지 오리진에서 로드되므로, Content Security Policy가 그것을 허용해야 합니다. Cloudflare는 두 지시어를 듭니다. “script-src: https://challenges.cloudflare.com”과 “frame-src: https://challenges.cloudflare.com”입니다.62 제 두 존은 모두 Cloudflare 응답 헤더 규칙으로 정책을 설정합니다. 이 사이트에는 frame 목록이 이미 있었고 거기에 챌린지 오리진을 더했습니다. 스튜디오 사이트에는 frame-src가 전혀 없었으므로 default-src 'self'가 위젯의 iframe을 차단했을 것입니다. 그래서 frame-src 'self'와 챌린지 오리진을 더했고, 코드 안의 앱 자체 정책에도 같은 것을 더했습니다.12

왜 양식의 POST 자체에 관리형 챌린지를 걸지 않는가

손쉬운 지름길은 이미 제 존에 있었습니다. 양식에 대한 POST에 관리형 챌린지로 응답하는 사용자 지정 규칙입니다. 기존 규칙은 curl과 그 친척들에게 그렇게 하고 있고, 그것은 통합니다. 제게 메시지를 보내려고 curl을 쓰는 사람은 없기 때문입니다.939 이것을 모든 POST로 넓혔다면 모든 사람에게 양식이 망가졌을 것입니다. Cloudflare는 그 한계를 직접 문서화합니다. “Challenge Pages interrupt the request flow by returning a full HTML page for the user’s browser to render and solve. This mechanism fails when the browser expects a non-HTML response, such as an AJAX or XHR (fetch) request.”(챌린지 페이지는 사용자의 브라우저가 렌더링하고 풀 수 있도록 완전한 HTML 페이지를 돌려줌으로써 요청 흐름을 중단시킨다. 이 메커니즘은 AJAX나 XHR(fetch) 요청처럼 브라우저가 HTML이 아닌 응답을 기대할 때 실패한다)63 이 사이트의 문의 양식은 HTMX 양식이어서 hx-post로 제출하고 응답을 페이지에 끼워 넣으므로, 그 POST는 정확히 그런 종류의 요청입니다. 문서는 HTMX를 이름으로 언급하지 않으며, 이 연결은 제 해석입니다.24 챌린지를 받은 POST는 HTML 챌린지 페이지로 돌아오는데, Cloudflare는 여기에 “set to challenge”(challenge로 설정된) cf-mitigated 헤더를 붙이고, “Regardless of the requested resource-type, the content-type of a challenge will be text/html.”(요청한 리소스 유형과 상관없이 챌린지의 content-type은 text/html이 된다)이라고 합니다.64 양식은 응답 상자에 챌린지 페이지를 끼워 넣거나, 아무것도 표시하지 않을 것입니다.

그런 경우에 대한 Cloudflare 자신의 답은 다른 기능입니다. “To ensure your API calls are protected without breaking single-page applications (SPAs) or API integrations, Cloudflare recommends using Turnstile Pre-clearance.”(단일 페이지 애플리케이션(SPA)이나 API 연동을 망가뜨리지 않고 API 호출을 확실히 보호하려면, Cloudflare는 Turnstile Pre-clearance 사용을 권장한다)63 사전 허가(pre-clearance)를 쓰면 풀린 위젯이 cf_clearance 쿠키도 발급하고, 이 쿠키는 “enables visitors to bypass WAF Challenges on all subsequent requests on the zone, including API requests, based on the security clearance level set by the customer.”(고객이 설정한 보안 허가 수준에 따라, API 요청을 포함해 존에서의 이후 모든 요청에서 방문자가 WAF 챌린지를 우회할 수 있게 한다)는 것입니다. 쿠키가 토큰을 대신하지는 않습니다. Cloudflare는 그것이 “is issued to the visitor in addition to the default Turnstile token”(기본 Turnstile 토큰에 더해 방문자에게 발급된다)이라고 말하고, 같은 페이지에서 여전히 서버의 확인을 요구합니다. “You must call Siteverify to complete your Turnstile configuration.”(Turnstile 구성을 완성하려면 Siteverify를 호출해야 한다)65 사전 허가가 더해 주는 것은 XHR 제출이 살아남을 수 있는 엣지 챌린지이며, 대상은 챌린지 수준 이상의 유효한 허가를 쿠키에 가진 방문자입니다. 저는 기본값을 썼습니다. 양식 안의 위젯과, 제 서버가 확인하는 토큰입니다.

11월 3일에 출시한 것

두 사이트 모두에서 Cloudflare의 API를 통해 관리형 모드의 위젯을 만들고, 각 사이트 자체의 도메인으로 범위를 한정했습니다. 각 위젯의 비밀 값은 같은 프로세스 안에서 API 응답으로부터 제 Mac의 키체인과 호스팅 서비스의 변수로 옮겨졌고, 명령줄이나 파일에는 한 번도 올라가지 않았습니다.12

이 사이트. 새 검증기는 방문자의 주소와 함께 토큰을 siteverify에 POST합니다. 토큰이 없거나, 2,048자보다 길거나, 거부되거나, 전송에 실패하면 모두 false를 돌려주므로, 비밀 값이 설정되어 있는 한 양식은 닫힌 상태로 실패(fail closed)합니다. 로컬 개발이나 테스트 스위트처럼 비밀 값이 설정되지 않은 경우에는 양식이 예전처럼 동작합니다.12 Cloudflare가 받아들인 토큰은 이 사이트 자체의 호스트 이름 중 하나에서 제공된 것이어야 하고, Cloudflare가 위젯의 action을 돌려주는 경우 그것은 양식 자신의 것이어야 합니다. 서버 측 가이드가 권하는 대로입니다. 호스트 이름이 없는 응답은 거부입니다. 따라서 다른 사이트나 다른 위젯에서 발급된 유효한 토큰은 통과하지 못합니다. 이 검사는 같은 날 아침 두 번째 변경으로 들어갔고, 07:20 PST부터 빌드했습니다. 07:25 PST에 캐시를 비운 뒤, 실제 페이지에서 가져온 유효한 CSRF 토큰을 갖고 Turnstile 토큰은 없는 제출이 다시 403으로 돌아왔습니다. 이는 배포 후에도 양식이 닫힌 상태로 실패한다는 것을 입증합니다. 토큰이 없는 제출로는 호스트 이름 검사 자체를 시험할 수 없고, 그것은 코드와 테스트에 기대고 있습니다.6660 세 번째 변경 전까지는 두 위젯과 두 검증기가 localhost도 받아들였고, sitekey는 공개되어 있으므로 누구의 localhost에서든 제 sitekey로 발급된 토큰은 siteverify와 호스트 이름 검사를 똑같이 통과했을 것입니다. Cloudflare의 테스트 페이지는 그것을 권하지 않습니다. “Cloudflare recommends that sitekeys used in production do not allow local domains”(Cloudflare는 프로덕션에서 쓰는 sitekey가 로컬 도메인을 허용하지 않을 것을 권장한다)라고 하며 그중에 localhost를 꼽습니다. 08:12 PST에 두 위젯에서 localhost를 뺐고, 두 검증기는 이제 기본적으로 사이트와 그 www 호스트만 받아들이므로, 검사는 이 사이트 자체의 호스트 이름만 받아들이고 그 밖에는 아무것도 받아들이지 않습니다. 로컬 개발은 설정에서 호스트 이름을 지정합니다. 그 변경 뒤 두 사이트는 태평양 시간 08:15와 08:18에 같은 실시간 검사를 다시 통과했습니다. 페이지의 위젯, 그리고 403으로 거부되는 토큰 없는 제출입니다. 호스트 이름 규칙 자체를 시험할 수 있는 실시간 검사는 없습니다. 토큰이 없거나 엉터리 토큰이면 호스트 이름을 읽기 전에 실패하기 때문입니다. 그 규칙은 위젯 설정, 코드, 테스트에 기대고 있습니다.4667 핸들러의 검사 순서는 이제 CSRF, 그다음 Turnstile, 그다음 발송이며, 예전과 같은 주소별 리미터 뒤에 있습니다. 두 거부 모두 페이지 자체의 오류 조각을 403과 함께 돌려주는데, HTMX는 기본적으로 4xx 응답을 페이지에 끼워 넣지 않으므로 양식에는 403을 통과시키는 핸들러가 필요합니다. 그 핸들러의 첫 버전은 작동하지 않았습니다. 저는 그것을 양식에 붙였지만, HTMX는 응답을 받는 요소에서 이벤트를 발생시키고, 이 페이지에서 그 요소는 양식의 형제 요소이므로, 이벤트는 핸들러에 닿지 않았고 거부당한 사람은 여전히 아무것도 보지 못했습니다. 이 글 초안의 두 번째 검토가 번들된 HTMX와 템플릿을 맞대어 읽고 그것을 잡아냈습니다. 그날 아침의 네 번째 변경인 수정은 리스너를 페이지 body에 두고 응답 상자로 범위를 한정한 것이며, 테스트가 그것이 렌더링되는지 확인합니다. 그 배포 뒤인 08:21 PST에, 위젯의 토큰을 엉터리 문자열로 바꾼 상태로 Playwright의 Chromium에서 양식을 실행했습니다. 응답 상자에는 “error: Verification failed. Reload the page and try again.”(오류: 검증에 실패했습니다. 페이지를 새로 고친 뒤 다시 시도하세요.)이 표시되었고, 아무것도 발송되지 않았습니다. 핸들러가 발송에 이르기 전에 siteverify가 엉터리 토큰을 거부하기 때문입니다.68 예전의 CSRF 거부, 즉 HTMX가 한 번도 보여 주지 않던 JSON 403은 사라졌습니다. 양식은 HTMX 요청이 끝날 때마다, Cloudflare의 초기화 호출이 받는 방식대로 위젯의 컨테이너를 지정해 자기 위젯을 초기화하므로, 재시도에는 새 토큰이 실립니다. 각 토큰은 일회용이고 수명이 300초이기 때문입니다.126069 Cloudflare의 클라이언트 측 가이드는 그 이유와 호출을 제시합니다. “To reset the widget if the given widget timed out or expired, you can use the function:”(해당 위젯이 시간 초과되거나 만료된 경우 위젯을 초기화하려면 다음 함수를 쓸 수 있다) turnstile.reset(widgetId);.69 제 첫 버전은 인자 없이 초기화를 호출했는데, 이는 가이드에 문서화되어 있지 않습니다. 네 번째 검토 라운드가 그것을 잡아냈고, 이제 두 사이트 모두에서 호출이 컨테이너를 지정합니다. 11월 3일의 브라우저 검사는 각 양식에 엉터리 토큰을 제출해 거부를 확인한 다음, 숨겨진 입력에 새 토큰이 들어온 것을 확인했습니다. 스튜디오 사이트에서는 8초 안에, 이 사이트에서는 25초 안에였고, 이 사이트에서는 위젯이 먼저 챌린지를 다시 실행했습니다.12 위젯 div와 스크립트는 사이트 키가 설정되어 있을 때만 렌더링됩니다. 테스트 11개가 통과합니다. 비밀 값이 없으면 예전처럼 발송한다. 비밀 값이 있으면 핸들러는 어떤 발송보다도 먼저 토큰 없는 제출을 거부하고, 거부된 토큰도 거부하며, 받아들여진 토큰은 발송하고, CSRF는 여전히 먼저 실행된다. 그리고 검증기는 이 사이트의 호스트 이름을 받아들이고, 다른 호스트 이름, 기본값에서의 localhost, 다른 action, 혹은 호스트 이름이 없는 경우를 거부한다.126646

스튜디오 사이트. 허니팟은 맨 앞에 그대로 둡니다. 숨겨진 필드가 채워져 있으면 여전히 성공 메시지로 응답하고 아무것도 저장하지 않으므로, 가장 값싼 봇 때문에 Cloudflare 호출 비용이 드는 일은 없습니다. 그다음 Turnstile이 오고, 토큰이 없거나, 거부되거나, 검증할 수 없으면 오류 조각과 함께 403을 받습니다. 그다음이 검증, 메시지 테이블 삽입, 알림입니다.12 이쪽 검증기도 같은 방식으로 호스트 이름(이제 사이트 자체의 두 호스트 이름만)과 action을 확인합니다. 그 변경은 07:19 PST에 배포되었고, 07:22 PST에 실제 페이지에는 위젯과 사이트 키가 있었으며, 토큰 없는 제출과 엉터리 토큰 제출은 403으로 돌아왔고, 허니팟 제출은 여전히 200으로 응답했습니다.66 배포 전에는 Cloudflare가 문서화한 테스트 키로 핸들러를 로컬에서 실행했습니다. 항상 통과하는 비밀 값에서는 토큰 없는 제출이 403으로 돌아왔고 더미 토큰은 Turnstile을 통과해 검증 단계에 이르렀습니다. 항상 실패하는 비밀 값에서는 둘 다 403으로 돌아왔습니다. 허니팟은 200으로 응답했습니다.1267 Cloudflare의 테스트 페이지는 더미 토큰이 프로덕션에서 통과할 수 없는 이유를 말합니다. “Production secret keys: Only accept real tokens, reject dummy tokens.”(프로덕션 비밀 키: 진짜 토큰만 받아들이고, 더미 토큰은 거부한다)67

실시간 검사. 그날 아침 더 이른 시각, 각 사이트가 자기 배포에 성공하고 캐시를 비운 뒤인 06:35 PST에, 각 홈페이지에는 위젯 div 하나, Turnstile 스크립트 태그 하나, 그리고 사이트 키가 있었고, 각 존의 실제 정책은 frame-src에 챌린지 오리진을 담고 있었습니다. 엉터리 CSRF 토큰을 단 이 사이트 양식 제출은 403으로 돌아왔지만, 그것은 CSRF 검사가 거부한 것이고 Turnstile에 대해서는 아무것도 입증하지 않습니다. Turnstile 검사 자체는 06:43 PST에 시험했습니다. 실제 페이지에서 가져온 유효한 CSRF 토큰을 갖고 Turnstile 토큰은 없는 제출이 “error: Verification failed. Reload the page and try again.”이라는 메시지와 함께 403으로 돌아왔고, 같은 제출에 엉터리 Turnstile 토큰을 달아도 403으로 돌아왔습니다. 따라서 비밀 값은 서비스에 설정되어 있고, 검사는 CSRF 뒤에 실행됩니다. 스튜디오 사이트에서는 토큰 없는 제출과 엉터리 토큰 제출이 모두 403으로 돌아왔고, 허니팟 제출은 200으로 돌아왔으며 아무것도 저장되거나 메일로 보내지지 않았습니다.70

브라우저 검사. Playwright로 구동한 실제 Chromium에서 두 페이지 모두 위젯이 렌더링되고, Turnstile 스크립트가 로드되었으며, 숨겨진 cf-turnstile-response 입력은 두 페이지 모두에서 6초 안에 730자짜리 토큰을 담았습니다.70 이것은 보기보다 적은 것을 입증합니다. Cloudflare의 테스트 페이지는 “Automated testing suites (like Selenium, Cypress, or Playwright) are detected as bots by Turnstile”(자동화된 테스트 스위트(Selenium, Cypress, Playwright 등)는 Turnstile에 의해 봇으로 감지된다)이라고 경고하며, 토큰이 통과하는지를 결정하는 것은 siteverify뿐입니다.67 이 검사가 입증하는 것은 위젯이 두 정책 아래에서 실행되고 자기 필드를 채운다는 것입니다. 제가 아직 하지 않은 것은 이 글의 독자에게 가장 중요한 하나의 시험, 즉 사람이 양식을 채우고 답장을 받는 것입니다. 그 시험은 이메일을 보내므로 제가 직접 해야 하는 것이고, 아직 하지 않았습니다.

7. 속도 제한은 여전히 무엇을 위해 있는가

Turnstile은 관문으로서의 속도 제한을 대신했습니다. 울타리로서의 속도 제한을 대신하지는 않았습니다. 봇 검사는 “누가”에 답합니다. “몇 건”에는 여전히 무언가가 답해야 합니다. 비어 있지 않고 크기가 유효한 토큰을 가지고 검사에 도달하는 모든 제출은 이제 Cloudflare siteverify API 호출 한 번의 비용이 들고, 통과하는 모든 제출은 여전히 발송 한 번의 비용이 들기 때문입니다.12 Cloudflare 자체의 서버 측 가이드는 siteverify 호출 자체에 대한 권고 사항 가운데 속도 제한을 꼽습니다. “Rate limit to protect against validation flooding.”(검증 플러드로부터 보호하기 위해 속도를 제한하라)60 제 두 제한은 모두 그대로 둡니다. 데이터 센터별, 주소별로 10초에 3건인 엣지 규칙과, 주소별로 분당 5건인 앱 리미터입니다.917

엣지 규칙이 어떻게 세는지 알게 된 지금, 그것은 정직한 설명을 받을 자격이 있습니다. Cloudflare는 그 설계를 분명하게 밝힙니다. “Cloudflare tracks request rates by maintaining separate counters for each unique combination of values in a rule’s characteristics,”(Cloudflare는 규칙 특성 값의 고유한 조합마다 별도의 카운터를 유지하여 요청 속도를 추적한다) 그리고 “Cloudflare does not support global rate limiting counters across the entire network.”(Cloudflare는 네트워크 전체에 걸친 전역 속도 제한 카운터를 지원하지 않는다)6 두 데이터 센터를 거쳐 제 존에 닿는 주소는 허용량을 두 번 받고, 11월 2일 스캐너의 제출은 실제로 두 곳을 거쳐 들어왔습니다. 그것이 규칙이 조용했던 이유에 대한 제 가장 강력한 설명이며, 분당 합계와 들어맞지만 합계로 입증되지는 않습니다.5 여기에 규칙이 “are not designed to allow a precise number of requests to reach your origin server”(정확한 수의 요청이 오리진 서버에 도달하도록 설계되지 않았다)라는 Cloudflare의 정밀도 단서를 더하면, 이 규칙은 입증된 상한이 아니라 명목상의 예산으로 설명하는 것이 가장 적절합니다. 데이터 센터별, 주소별로 10초에 양식 제출 3건, 명목상 분당 18건에 해당하며, 대략적으로 집행됩니다.489 Free 요금제에서는 이것이 제가 받는 유일한 속도 제한 규칙이기도 하며, 기간은 10초로 고정되고 카운트는 IP로만 합니다.48

저처럼 속도 제한 자체를 챌린지로 만들고 싶은 유혹을 느꼈다면, Free 요금제의 한 가지 세부 사항이 중요합니다. Cloudflare의 매개변수 페이지에 따르면 Free, Pro, Business 요금제에서는 챌린지 동작을 가진 속도 제한 규칙이 기간을 유지하지 않고 항상 스로틀링으로 동작하며, “When visitors pass a challenge, their corresponding request counter is set to zero.”(방문자가 챌린지를 통과하면 해당 요청 카운터가 0으로 설정된다)입니다.71 양식의 XHR POST에 대한 챌린지는 6절의 이유로도 실패할 것입니다.63 그래서 엣지 규칙은 차단으로 남기고, 챌린지는 위젯 안에 둡니다.

앱 리미터에는 그 누수가 요구하는 수정이 필요합니다. 두 제출이 같은 카운트를 읽을 수 없도록 원자적 증가를 지원하는 카운터 저장소, 그리고 카운트를 프로세스별로 쪼개지 않는 실패 경로입니다. 현재 코드, 즉 KV 읽기 뒤에 별도의 쓰기를 하고 프로세스별 대체 경로를 두는 방식은 그 어느 것도 제공하지 않으므로, 이것은 한 줄짜리 패치가 아니라 저장소의 교체입니다.173 받은편지함에 닿을 수 있는 모든 제출에 Turnstile이 조건으로 더해졌으므로, 누수가 통과시킨 제출도 Cloudflare가 받아들이는 토큰을 갖고 있어야 합니다. 그래서 누수의 비용은 양과 돈이 되고, 누가 제출할 수 있는지에 대한 결정은 더 이상 카운트에 달려 있지 않습니다. 리미터는 예산입니다. 한 번의 플러드에서 47건이나 어긋나는 예산은 나쁜 예산이지만, 그것은 애초에 문지기가 아니었습니다.

OWASP의 인증 지침은 같은 계층화를 자기 말로 표현합니다. CAPTCHA를 “as a defense-in-depth control to make brute-force attacks more time-consuming and expensive, rather than as a preventative.”(예방책이 아니라, 무차별 대입 공격에 더 많은 시간과 비용이 들게 만드는 심층 방어 통제로) 다룹니다.72 그리고 크리덴셜 스터핑 지침은 “Most tools used for these types of attacks will make direct POST requests to the server and read the responses, but will not download or execute JavaScript”(이런 유형의 공격에 쓰이는 대부분의 도구는 서버에 직접 POST 요청을 보내고 응답을 읽지만, JavaScript를 내려받거나 실행하지는 않는다)라고 적습니다.50 제 두 스캐너는 실제로 사이트의 페이지를 가져갔고, 그것이 유효한 CSRF 토큰을 갖게 된 가장 그럴듯한 경로입니다. 위젯을 실행하지 않고 양식을 제출하는 클라이언트에게는 보낼 Turnstile 토큰이 없으며, 두 양식은 이제 토큰 없는 제출을 거부합니다. 이 사이트에서는 유효한 CSRF 토큰을 갖고 Turnstile 토큰은 없는 제출이 403으로 돌아왔고, 스튜디오 사이트에서도 토큰 없는 제출이 마찬가지였습니다.70

체크리스트

Cloudflare 뒤에 있는 문의 양식에 대해, 지금의 저라면 이 순서로 하겠습니다.

  1. 양식에 봇 검사를 두고, 모든 토큰을 서버에서 검증하십시오. Cloudflare 자신의 말로는 “The client-side widget alone does not protect your forms.”(클라이언트 측 위젯만으로는 양식을 보호하지 못한다)입니다.60 같은 페이지가 권하는 대로 응답의 호스트 이름과 action도 확인해 다른 곳에서 발급된 토큰이 통과하지 못하게 하고, 프로덕션 sitekey에는 로컬 도메인을 넣지 마십시오. “Cloudflare recommends that sitekeys used in production do not allow local domains”(Cloudflare는 프로덕션에서 쓰는 sitekey가 로컬 도메인을 허용하지 않을 것을 권장한다)이기 때문입니다.606667
  2. 닫힌 상태로 실패하게 하십시오. 토큰이 없거나, 너무 크거나, 거부되거나, 검증할 수 없으면 거부입니다. “Tokens can be forged.”(토큰은 위조될 수 있다)이기 때문입니다.6012
  3. 제출할 때마다 위젯을 초기화하십시오. 토큰은 일회용이고 300초 동안 유효하므로, 초기화 없는 재시도는 이미 쓴 토큰으로 하는 재시도입니다.6069
  4. 두 CSP 지시어 모두에서 챌린지 오리진을 허용하십시오. Cloudflare는 https://challenges.cloudflare.com에 대해 script-src와 frame-src를 요구하며, default-src 'self'만 있는 정책은 iframe을 차단합니다.6212
  5. XHR이나 fetch로 제출하는 양식에 관리형 챌린지를 걸지 마십시오. Cloudflare는 챌린지 페이지가 “fails when the browser expects a non-HTML response”(브라우저가 HTML이 아닌 응답을 기대할 때 실패한다)라고 하며, HTMX 제출이 바로 그런 종류의 요청입니다.63
  6. 사람이 볼 수 있는 오류를 돌려주십시오. 제 CSRF 거부는 HTMX 양식이 한 번도 표시하지 않는 JSON 403이었으므로 토큰이 만료된 사람은 보내기를 눌러도 아무것도 보지 못했을 것이고, 제 첫 수정은 양식에서 이벤트를 기다렸지만 HTMX는 끼워 넣기 이벤트를 응답의 대상, 즉 양식의 형제 요소에서 발생시킵니다. 그러니 거부를 브라우저에서 시험하십시오.1268
  7. 허니팟을 봇 검사 앞에 두십시오. 제 스튜디오 사이트에서 숨겨진 입력 하나와 if 하나의 비용으로 52건 중 42건을 잡아냈습니다.725
  8. 속도 제한은 유지하되, 관문이 아니라 비용 상한으로 크기를 정하십시오. Cloudflare는 데이터 센터별, 주소별로 세고, 11월 2일 스캐너는 두 곳을 거쳐 들어왔습니다. 그것이 규칙이 한 번도 발동하지 않은 가장 그럴듯한 이유이지만, 분당 합계로는 입증할 수 없습니다.65
  9. 앱 리미터를 원자적으로 증가하는 저장소로 뒷받침하십시오. 제 리미터는 카운트를 읽고 별도의 요청으로 되써 넣었고, 두 번째 읽기가 실패하면 카운트를 1로 되돌렸으며, KV가 실패하면 한 프로세스의 메모리로 넘어갔고, 자체 상한을 47건과 9건 넘는 발송을 통과시켰습니다. 어느 경로가 샜는지는 로그로 알 수 없습니다.173
  10. 스캐너를 막는 데 사용자 에이전트 규칙을 믿지 마십시오. 두 스캐너 모두 평범한 Chrome 문자열을 보냈습니다. 10월 30일에 최소 69,322번, 11월 2일에 9,725번입니다.3241
  11. 이메일은 양식이 아니라 메일 서비스에서 세십시오. 제 핸들러는 실패 경로에서도 200으로 응답하고, Resend의 email.sent 이벤트는 “the API request was successful”(API 요청이 성공했다)는 뜻일 뿐입니다. 그다음 무슨 일이 있었는지는 조회한 이메일의 last_event 필드가 말해 줍니다.242873
  12. 한 달 안에 분석을 쿼리하십시오. Cloudflare의 적응형 데이터 세트는 “at least 31 days of data on every plan”(모든 요금제에서 최소 31일치 데이터)을 보관합니다.27
  13. 기간이 닫힌 뒤에 다시 세십시오. 10월 30일 플러드 몇 분 뒤에 쓴 제 메모는 Cloudflare가 마지막 몇 분의 수집을 끝내지 않았기 때문에 양식 제출을 적게 셌습니다.16
  14. 주소 차단은 뒷정리로 여기십시오. 제 차단은 10월 30일에는 첫 양식 제출로부터 약 50분 뒤, 11월 2일에는 스캔 시작으로부터 8분 뒤에 들어갔고, 두 날 모두 차단이 들어간 분 이후에 나간 발송은 없었습니다. 10월 30일에는 차단 전에 101건, 11월 2일에는 차단 전에 26건과 그 분 안에 5건입니다.1192

무엇이 바뀔 수 있는가

이것들은 예측이 아니라 조건입니다. 각각 무엇이 일어나야 하는지, 그리고 그때 제가 무엇을 할지를 적었습니다.

스캐너가 관리형 챌린지를 통과하기 시작한다면. Turnstile의 검사는 브라우저 안에서 실행되며 자동화의 흔적을 찾습니다. 그것을 통과할 만큼 완전한 브라우저를 대량으로 돌리는 도구에게 위젯은 벽이 아니라 비용 하나가 더 늘어난 것이 될 것입니다. 징후는 siteverify가 받아들이는 토큰을 단 양식 발송이 User-Agent 헤더에 인젝션 문자열도 보내는 주소에서 오는 것입니다. Batchelder는 2007년에 허니팟에 대해 이 정직한 전망을 썼습니다. “If and when they do, we can switch back to CAPTCHAs.”(그들이 그렇게 된다면, 그때 CAPTCHA로 되돌아가면 된다)49 제 대비책도 정신은 같아서, 더 강한 챌린지입니다. 그동안에도 허니팟과 속도 제한은 그대로 서 있습니다.

사전 허가를 갖춘 엣지 검사를 더한다면. Cloudflare는 챌린지 페이지를 렌더링할 수 없는 요청에 Turnstile 사전 허가를 권장하며, cf_clearance 쿠키는 방문자가 “bypass WAF Challenges on all subsequent requests on the zone, including API requests”(API 요청을 포함해 존에서의 이후 모든 요청에서 WAF 챌린지를 우회)할 수 있게 해 줍니다.6563 이를 도입하면 엣지는 챌린지 수준 이상의 유효한 허가를 쿠키에 가진 방문자에 대해 HTMX를 망가뜨리지 않고 양식의 POST 자체에 챌린지를 걸 수 있고, Cloudflare의 CSP 페이지는 그 모드에 요건 하나를 더합니다. “your connect-src directive must include ‘self’.”(connect-src 지시어에 ‘self’를 포함해야 한다)62 쿠키는 “in addition to the default Turnstile token”(기본 Turnstile 토큰에 더해) 발급되고 siteverify는 여전히 필수이므로, 엣지 챌린지는 제 서버 검사 앞에 놓이는 두 번째 검사이지 그것을 대신하는 것이 아닙니다.65 저는 아직 그렇게 하지 않았고, 이 글이 설명하는 것은 현재 가동 중인 위젯과 siteverify 설계입니다.

두 기간에 대한 Resend 로그를 내보낸다면. 이 글의 모든 이메일 건수는 상한입니다. 양식의 200은 발송이거나, 제 핸들러가 역시 200으로 응답하는 실패이기 때문입니다.24 Resend의 조회 엔드포인트는 각 이메일의 last_event(예: delivered)를 돌려주며, 그 이벤트 목록은 “sent - The email was sent successfully.”(sent: 이메일이 성공적으로 발송되었다)와 “delivered - Resend successfully delivered the email to the recipient’s mail server.”(delivered: Resend가 수신자의 메일 서버로 이메일을 성공적으로 배달했다)를 구별합니다.7374 그 내보내기가 있으면 101과 31은 정확한 건수가 되며, 줄어들 수만 있습니다.

사람이 손으로 스팸을 보낸다면. Turnstile은 브라우저가, 그리고 때로는 사람이 있는지를 묻습니다. 왜 보내는지는 묻지 않습니다. 돈을 받고 문의 양식에 영업 문구를 붙여 넣는 사람은 그것을 통과할 수 있고, 제 스튜디오 사이트의 리드 생성 발신자들도 뒤에 사람이 있다면 통과할 수 있습니다. 그 경우의 방어는 오래된 것들입니다. 양을 위한 속도 제한과, 받은편지함에서의 제 판단입니다.

Tor 드립에 날짜별 답이 필요하다면. 제가 확인한 출구 목록은 11월 3일에 가져온 것이고, Tor Project의 CollecTor 서비스는 과거 목록을 보관합니다.5355 8일치에 대해 그것을 쿼리하면, Tor 귀속은 Cloudflare의 코드와 11월 3일 목록이 아니라 제출이 도착한 날의 목록에 근거하게 됩니다. Cloudflare는 모든 요금제에서 Onion Routing도 제공하는데, 이는 Tor 사용자에게 Tor 네트워크를 통해 직접 서비스를 제공하는 것이며, Cloudflare는 이를 쓰면 “Human Tor users and bots can be distinguished by our Onion services, such that interactive challenges are only served to malicious bot traffic.”(사람인 Tor 사용자와 봇을 우리의 Onion 서비스로 구별할 수 있어, 상호작용 챌린지는 악성 봇 트래픽에만 제공된다)라고 말합니다.75 저는 그것을 켜지 않았고, 켜기 전에 Turnstile 도입 이후 스튜디오 사이트의 Tor 트래픽을 측정하고 싶습니다.

핵심 정리

문의 양식을 운영한다면:

  • CSRF 토큰과 속도 제한을 함께 써도 스캐너는 여러분에게 글을 쓸 수 있습니다. 제 두 스캐너는 모두 유효한 토큰을 갖고 있었고, 제 리미터는 101건과 31건의 발송을 통과시켰습니다.12
  • 봇 검사 토큰은 서버에서 검증하고, 닫힌 상태로 실패하게 하고, 요청마다 위젯을 초기화하십시오. 위젯만으로는 “does not protect your forms.”(양식을 보호하지 못한다)입니다.60
  • 리미터는 한 번에 요청 하나씩이 아니라 부하와 실패 상황에서 시험하십시오. 읽은 뒤 쓰는 카운터에 프로세스별 대체 경로를 더한 리미터는, 5건을 허용하도록 설정된 1분에 18건의 발송을 통과시켰습니다.117
  • 허니팟은 유지하십시오. 스택에서 가장 값싼 필터이고, 제 스튜디오 사이트에서 8일 동안 핸들러가 주소를 확인하거나, 행을 저장하거나, 메일을 보내기 전에 이 제출들의 80.8%를 잡아냈습니다.725

사이트의 엣지를 운영한다면:

  • 여러분의 속도 제한이 무엇을 세는지 아십시오. Cloudflare에서는 모든 규칙이 데이터 센터별로 세고, 두 곳을 거쳐 들어오는 스캐너는 카운터 두 개를 받습니다.65
  • 자신의 규칙을 프로브하고, 그 뒤 방화벽 이벤트를 읽으십시오. 제 프로브가 만든 방화벽 이벤트 11건은, 규칙이 왜 조용했는지 하루 종일 추론한 것보다 규칙에 대해 더 많은 것을 알려 주었습니다.18
  • XHR이나 fetch POST에 챌린지 페이지로 챌린지를 걸지 마십시오. Cloudflare는 그것이 실패한다고 문서화하고, 그 경우에는 사전 허가를 권장합니다.63
  • 사용자 에이전트 규칙은 정직한 도구만 잡는다고 예상하십시오. 여기의 스캐너들은 브라우저 문자열을 보냈고, 제 curl 시험은 챌린지를 받았습니다.3239

받은편지함을 읽는 사람이 여러분이라면:

  • 받은편지함이 가득 차면 제목보다 발신자를 먼저 확인하십시오. 제 메일 흐름 중 셋은 발신 주소 하나를 공유하며, 그래서 제 첫 추측이 틀렸습니다.23
  • 메일 서비스의 로그가 달리 말하기 전까지는 양식의 200을 이메일 건수가 아니라 상한으로 다루십시오.2428
  • 알림 기능을 끄기 전에 저장된 메시지를 읽으십시오. 진짜 고객의 메시지 하나가 일주일 동안 읽히지 않은 적이 있었고, 그 메시지가 스튜디오 양식이 애초에 제게 메일을 보내는 이유입니다.25

자주 묻는 질문

Cloudflare를 쓰는데 왜 문의 양식에 스팸이 들어옵니까?

가장 그럴듯한 이유는 양식 앞의 어떤 것도 모든 제출에 대해 사람이 보냈는지 묻지 않기 때문입니다. Cloudflare의 사용자 지정 규칙은 주소, 네트워크, 경로, 사용자 에이전트 문자열에 일치하고, 속도 제한은 데이터 센터별, 주소별로 요청을 셉니다. 브라우저 사용자 에이전트를 보내고, 먼저 페이지를 불러오고, 적당한 속도로 제출하는 스캐너는 그 모두를 통과합니다. 제 사이트에서는 두 스캐너가 정확히 그렇게 해서 101건과 31건의 제출을 통과시켰고, 제 스튜디오 사이트의 스팸은 제가 측정한 8일 동안 대부분 주소당 한 건씩 들어왔는데, 이는 어떤 주소별 카운트로도 잡을 수 없습니다.96127

속도 제한이 봇을 막습니까?

속도 제한은 한 주소가 얼마나 빨리 갈 수 있는지를 묶어 둘 뿐, 봇과 사람을 구별하지 않습니다. Cloudflare 자체의 문서는 그 규칙이 “are not designed to allow a precise number of requests to reach your origin server”(정확한 수의 요청이 오리진 서버에 도달하도록 설계되지 않았다)라고, 그리고 카운터가 데이터 센터 간에 공유되지 않는다고 말합니다. 제 엣지 규칙은 두 스캐너 어느 쪽에도 발동하지 않았고, 분당 5건으로 설정한 제 앱 리미터는 1분에 18건을 통과시켰습니다. 그 카운터는 읽기 뒤에 별도의 쓰기를 하는 방식이고, 두 번째 읽기가 실패하면 1로 초기화되며, 저장소가 실패하면 프로세스별 대체 경로가 넘겨받습니다. 이 경로 중 어느 것이든 초과 제출을 통과시킬 수 있습니다.48641173

허니팟 필드면 충분합니까?

숨겨진 필드를 채우는 봇에게는 충분하고, 제 스튜디오 사이트에서는 그것이 이 제출들의 80.8%였습니다. 제 허니팟은 8일 동안 52건 중 42건을 잡아냈고, 도착한 시간대로 보면 Tor 출구에서 온 모든 제출이 거기에 포함됩니다. 숨겨진 필드를 비워 두는 발신자에게는 아무 소용이 없고, 허니팟이 통과시킨 10건(저장되었으므로 최대 10통의 이메일)은 정확히 그렇게 했습니다.720 2007년에 이 기법을 설명한 Ned Batchelder도 그렇게 말했습니다. “In theory, it is possible for a spambot to defeat any of these measures.”(이론상 스팸봇은 이 대책 중 어느 것이든 무력화할 수 있다)49

Turnstile과 CAPTCHA: 무엇이 다릅니까?

고전적인 CAPTCHA는 방문자에게 퍼즐을 풀게 합니다. Turnstile은 “a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment”(방문자나 브라우저 환경에 대한 신호를 모으기 위한, 상호작용이 필요 없는 작은 JavaScript 챌린지들)를 실행하고, 관리형 모드에서는 방문자의 위험이 요구할 때만 체크박스를 보여 주며, “No images or text to decipher.”(해독할 이미지나 글자는 없다)입니다.2158 Cloudflare는 자사의 챌린지가 “CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters.”(CAPTCHA 퍼즐이나, 사물을 고르거나 일그러진 문자를 입력하는 것 같은 시각적 테스트)를 쓰지 않는다고 말합니다.76 어느 쪽이든 서버가 결과를 검증해야 하며, Turnstile에서는 그것이 siteverify 호출입니다.60

CSRF 보호는 왜 스팸을 막지 못합니까?

올바르게 구현된 CSRF 방어는 다른 사이트가 방문자의 브라우저로 하여금 여러분의 양식을 제출하게 만드는 것을 막기 위한 것입니다. 제 토큰은 그보다 약합니다. 상태가 없고 서명되어 있으며, 한 시간 동안 유효하고, 세션에도 쿠키에도 주소에도 묶여 있지 않습니다. 따라서 그것이 입증하는 것은 클라이언트가 제 사이트가 그 한 시간 안에 발급한 토큰을 갖고 있다는 사실뿐이고, 요청이 어디서 왔는지가 아닙니다. 어느 종류든 페이지를 불러와 스스로 양식을 제출하는 클라이언트는 막지 못합니다. 페이지를 불러오는 것이 바로 클라이언트가 유효한 토큰을 얻는 방법이기 때문입니다. 두 스캐너는 모두 유효한 토큰을 모았고, 검사가 거부한 것은 두 플러드를 통틀어 그들의 제출 중 4건이었습니다.1512

로그에서 스캐너와 사람을 어떻게 구별합니까?

양식만이 아니라 그 주소가 한 모든 일을 보십시오. 제 두 스캐너는 수천 개의 경로를 크롤링했고, 경로와 User-Agent 헤더에 SQL 인젝션 문자열을 넣었으며, 대부분의 요청에 Chrome 사용자 에이전트를, 일부에는 Cloudflare의 검증된 봇 규칙이 받아들이지 않은 Googlebot 자처를 보냈고, 분당 수십에서 수백 건의 폭주로 양식을 제출했습니다.31324441 sleep(15)나 waitfor delay '0:0:15' 같은 시간 기반 페이로드는 문서화된 프로브 계열에 속합니다.3377 사람은 실제로 읽은 페이지에서 메시지 하나를 보내며, 여러분의 서버에게 잠들라고 요구하지 않습니다.

Turnstile은 개인 데이터를 수집합니까?

Cloudflare는 아무것도 수집하지 않는다고 말하지 않습니다. Turnstile 개인정보 부록은 처리하는 신호로 “such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin”(클라이언트 IP 주소, TLS 지문, User-Agent 헤더, Sitekey와 연결된 오리진 등)을 들고, 목적은 “solely to detect and block bots”(오로지 봇을 감지하고 차단하기 위한 것)라고 하며, Cloudflare가 봇 감지 개선을 위해 그 신호의 데이터 컨트롤러라고 말합니다. 개요 문서는 Turnstile이 “does not access, store, or transmit user communications, form entries, or other page inputs.”(사용자 통신, 양식 입력 내용, 기타 페이지 입력에 접근하거나, 저장하거나, 전송하지 않는다)라고 말합니다.2221 따라서 Turnstile이 보는 것은 연결 데이터이지, 사람이 입력하는 내용이 아닙니다.

이 사이트의 관련 글. Claude Code의 auto 모드는 보안 경계가 아닙니다는 다른 통제 장치에 대해 같은 주장을 합니다. 각 동작을 판정하는 분류기는 벽이 아니라는 주장입니다. Agents.txt는 접근 제어가 아니에요는 봇에게 정책을 선언하는 것과 그것을 강제하는 것 사이에 선을 긋습니다. 알림 피로: 보안 모니터가 하루에 보낸 이메일 82통은 11월 2일 스캐너를 제 모니터 쪽에서 다룹니다. 첫 탐지 결과로부터 8초 뒤, 스캔 시작으로부터 8분 뒤의 차단, 그리고 그에 관한 이메일은 0통입니다. 잠들기 전에 실행하는 것은 다른 종류의 운영 루틴으로, 15,000개 페이지의 상태, 응답 시간, 캐시를 매일 밤 점검하는 것이고, AI 에이전트를 무감독으로 실행할 때 실제로 무엇이 망가지는가는 감독 없는 에이전트 세션, 즉 제 인프라를 운영하는 종류의 자동화가 실패하는 방식을 분류합니다.

출처

글쓴이의 데이터에 관한 메모: 제 시스템에서 나온 모든 건수는 두 존에 대한 Cloudflare의 GraphQL Analytics API(요청 및 방화벽 이벤트 데이터 세트), 스튜디오 사이트의 메시지 테이블, 제 모니터의 로그, 그리고 두 양식의 코드에서 나왔으며, 모두 2026년 11월 3일에 저장해 스크립트로 원장으로 만들었습니다. 시각은 PDT나 PST로 표시한 것 외에는 UTC입니다. 그 증거에 담긴 주소, 규칙이나 목록의 식별자, 키, 파일 시스템 경로는 이 글에 하나도 싣지 않았습니다.


  1. 글쓴이의 데이터. 이 사이트 존에 대한 Cloudflare의 요청 분석. 10월 30일 스캐너가 문의 양식에 보낸 POST를 분별, 엣지 상태별로 2026년 11월 3일에 닫힌 기간을 대상으로 쿼리: 제출 1,065건, 그중 200이 101건, 429가 830건, 422가 130건, 403이 4건이며 모두 앱에 도달. 첫 제출과 첫 200은 18:00의 분, 정점은 18:03의 분의 208건, 1분 동안 가장 많은 200은 18건(18:06), 200이 5건을 넘은 분은 여덟 개, 마지막 200은 18:36, 앱에 도달한 마지막 제출은 18:37. 원장에서 도출: 차단 전 발송 101건, 첫 제출은 제가 플러드를 보고하기 약 46분 전이자 차단 49.88분 전, 앱에 도달한 마지막 제출은 차단 11.88분 전. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  2. 글쓴이의 데이터. 이 사이트 존에 대한 Cloudflare의 요청 분석. 11월 2일 스캐너가 문의 양식에 보낸 POST를 분별, 엣지 상태별로 2026년 11월 3일에 쿼리: 제출 500건, 그중 200이 31건, 429가 31건, 차단이 들어간 분이나 그 이후의 403이 438건. 앱에 도달한 것은 62건. 첫 제출은 18:58의 분, 앱에 도달한 제출의 정점은 19:02의 분의 32건, 1분 동안 가장 많은 200은 10건(19:03), 마지막 200은 19:04의 분. 앱에 도달한 첫 제출(18:58의 분)부터 마지막 제출(19:04의 분)까지 7분 동안 분당 평균 8.86건. 발송 31건 중 차단이 들어간 분 이전의 몇 분 동안 26건, 차단이 들어간 그 분(19:04, 주소가 19:04:01에 목록에 오른 분)에 5건이며, 분 버킷으로는 그 초와의 선후를 정할 수 없음. 그 이후는 0건. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  3. 원장에서의 글쓴이의 분석: 각 분에 대해 5와 앱 리미터가 본 제출 수(200, 429, 앱의 CSRF 403. 리미터가 실행되기 전에 FastAPI가 거부하는 422는 제외) 중 작은 값을 취해 분에 걸쳐 합산. 10월 30일: 리미터가 본 제출 935건에 대해 정확한 리미터라면 발송 최대 54건, 실측 101건으로 47건 초과. 11월 2일: 62건에 대해 최대 22건, 실측 31건으로 9건 초과. 이는 반사실적 상한이지 측정값이 아니며, 측정된 발송은 위의 양식 건수다. ↩↩↩↩↩↩↩

  4. 글쓴이의 데이터. 11월 2일 주소에 대한 Cloudflare의 방화벽 이벤트, 2026년 11월 3일 쿼리(표본 추출된 데이터 세트로, 어느 규칙이 언제 작동했는지에만 쓰고 양에는 결코 쓰지 않음): 첫 이벤트는 18:57:11의 익스플로잇 경로 규칙, 차단 목록 규칙의 첫 이벤트는 19:04:03. 모든 행이 차단이고 관리형 챌린지는 없음. 문의 양식에 대한 행은 310개로 모두 차단이며 첫 행은 19:04:05. 이 주소에 대한 속도 제한 규칙의 이벤트는 없음. ↩↩↩↩↩↩↩↩↩↩

  5. 글쓴이의 데이터, 원장 부록. 두 스캐너의 양식 POST를 Cloudflare 데이터 센터, 분, 상태별로 묶은 Cloudflare의 요청 분석, 2026년 11월 3일 쿼리: 11월 2일 18:56부터 쿼리의 끝인 19:05:00까지(따라서 19:05의 분은 첫 제출만 포함) 암스테르담 경유 44건, 런던 경유 41건. 19:02의 분에는 런던 경유 18건(발송 4, 앱이 거부 14), 암스테르담 경유 14건(발송 5, 거부 9). 한 데이터 센터의 1분 중 가장 큰 건수는 19:04의 분 암스테르담의 19건이며, 그중 15건은 차단 후 엣지에서 거부됨. 10월 30일에는 제출 1,065건이 모두 파리를 경유. ↩↩↩↩↩↩↩↩

  6. Cloudflare, “How Cloudflare determines the request rate”, Cloudflare WAF 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. 특성 조합마다 별도의 카운터. 같은 지리적 위치에 있는 것을 제외하면 데이터 센터 간에 공유되지 않는 카운터. 필수 특성인 cf.colo.id. 전역 카운터는 없음. ↩↩↩↩↩↩↩

  7. 글쓴이의 데이터. 스튜디오 사이트 존에 대한 Cloudflare의 요청 분석. 문의 양식에 대한 POST를 시간대, 주소, 국가, 사용자 에이전트별로, 2026년 10월 26일 17:00 UTC부터 11월 3일 13:59:50 UTC까지: 제출 52건, 모두 200, 주소 35개에서. Cloudflare의 Tor 국가 코드를 가진 제출 23건과 주소 23개(44.2%와 65.7%). 제출이 정확히 한 건인 주소 32개(91.4%)이며 Tor 주소는 모두 그 안에 있음. 반복 제출한 세 주소는 13건(영국, 한 시간, SQLSpider 사용자 에이전트), 2건(폴란드, 한 시간), 5건(미국, 매번 새 사용자 에이전트). Tor 사용자 에이전트는 Android 18건과 데스크톱 Chrome 5건이며 21건이 Chrome 81 이하를 내세움. Tor가 아닌 사용자 에이전트는 데스크톱 Chrome 125부터 135까지 10건, Firefox 128부터 138까지 6건, 기타 13건. 이 기간에 존의 방화벽은 양식에 대한 POST에서 이벤트를 하나도 기록하지 않음. 허니팟 작동 수는 제출 수에서 저장된 행을 뺀 값으로 42건, 즉 80.8%. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  8. 글쓴이의 데이터. 스튜디오 사이트의 문의 메시지 테이블, 2026년 11월 3일 14:04 UTC에 읽음: 6월 6일부터 11월 3일 07:24 UTC까지 108행, 그중 10행이 쿼리 이후 기간에 해당. 날짜별로는 5절의 표와 같음. 10월에는 31일 중 21일에 32행, 하루 1.03행, 하루 3행을 넘은 날 없음. $50,000,000 행 아홉 개는 발신자 이름 하나에 발신자 주소 아홉 개. 알림 기능 커밋 이후 43행, 이전 65행. ↩↩↩↩↩↩↩↩↩↩↩

  9. 글쓴이의 데이터. 2026년 11월 3일 Cloudflare API로 내보낸 두 사이트의 존 설정: 이 사이트의 속도 제한 규칙(버전 1, 최종 수정 2026-10-30 18:49:53 UTC. 표현식은 인용한 대로. 특성은 데이터 센터와 클라이언트 주소. 10초 기간당 요청 3건. 10초 완화 타임아웃. 데이터 센터별, 주소별 분당 18건에 명목상 해당)과 사용자 지정 규칙 다섯 개(계정 목록과 일곱 ASN 차단, 익스플로잇 경로 차단, 검증된 봇 건너뛰기, 그리고 문의 양식과 관리자 경로에 대해 사용자 에이전트 문자열 기준과 위협 점수 기준의 관리형 챌린지 두 개. 둘 다 내보낼 때는 활성화되어 있었고 위협 점수 규칙은 그날 늦게 비활성화). 같은 종류의 스튜디오 사이트 규칙(챌린지는 사이트 전체, API 경로 차단은 양식을 예외로 둠)과, 검증된 봇이 아닌 클라이언트의 요청을 대상으로 데이터 센터와 주소별로 세는 사이트 전체 속도 제한 규칙. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  10. 글쓴이의 데이터. 10월 30일 주소에 대한 Cloudflare의 방화벽 이벤트, 2026년 11월 3일 쿼리(표본 3,932행으로 쿼리 한도 미만이며, 귀속과 타이밍에만 사용): 첫 이벤트는 17:59:53의 익스플로잇 경로 규칙으로, 이 규칙은 스캔의 첫 1분부터 작동함. 18:49:53 이전의 표본 이벤트 111건은 모두 그 규칙의 것. 차단 목록 규칙의 첫 이벤트는 18:49:53. 이 주소에 대해 속도 제한 규칙의 이벤트도, 문의 양식에서의 이벤트도 없음. ↩↩↩↩↩↩↩↩↩↩↩

  11. Cloudflare, “cf.threat_score”, Ruleset Engine 필드 참조, 2026년 11월 3일 16:12:44 UTC에 가져옴. 필드 설명: 한때 0에서 100 사이였던 점수, 그리고 “Now, the threat score is always 0 (zero).”(이제 위협 점수는 항상 0(영)이다) ↩↩↩

  12. 2026년 11월 3일에 출시한 것에 대한 글쓴이의 기록으로, 세션의 도구 출력에서 작성: 두 위젯(관리형 모드, Cloudflare API로 약 14:13 UTC에 생성), 두 존의 CSP 변경, 이 사이트의 검증기와 핸들러 순서(닫힌 상태로 실패, CSRF 다음 Turnstile 다음 발송, 403 오류 조각, HTMX 요청마다 위젯 초기화), 새 테스트 10개(10개 통과. 열한 번째 테스트는 호스트 이름 검사와 함께 추가됨), 스튜디오 사이트의 핸들러 순서(허니팟, 그다음 Turnstile, 그다음 검증, 저장, 알림), 그리고 Cloudflare 테스트 키를 이용한 로컬 확인. 두 사이트 모두 검증기는 토큰이 없거나 2,048자보다 길면 siteverify를 호출하지 않고 거부함(코드에서 읽음). 두 사이트 모두 14:32에서 14:36 UTC 사이에 배포. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  13. 글쓴이의 데이터. 10월 30일 주소에 대한 Cloudflare의 요청 분석, 분별 및 제 계정의 모든 존을 훑는 방식으로 2026년 11월 3일 쿼리: 17:59의 분부터 18:51의 분까지(버킷 53개) 이 사이트에 대한 요청 76,429건, 두 쿼리에서 같은 합계. 차단이 들어간 분 이전 69,484건으로 분당 평균 1,389.7건, 정점은 18:36의 3,219건, 차단이 들어간 분 이후 5,234건이며 제공된 것은 없음. 결과별로는 제공 49,513건(64.8%), 앱이 거부 18,990건(24.8%, 사이트 에이전트 게이트의 GET 418 10,019건 포함), 오리진 520 2,339건(3.1%), 엣지에서 차단 5,587건(7.3%). 스캔의 첫 1분부터 차단까지 50.88분. kiradex.app에 GET 387건으로 모두 200, 다른 존은 건드리지 않음. ↩↩↩↩↩↩↩↩↩↩

  14. 글쓴이의 데이터. 11월 2일 주소에 대한 Cloudflare의 요청 분석, 분별, 경로별, 사용자 에이전트별, 그리고 모든 존을 훑는 방식으로 2026년 11월 3일 쿼리: 스캔 11.1시간 전인 07:49 UTC에 GET 두 건. 18:56의 분부터 19:17의 분까지(버킷 22개) 10,907건, 새벽의 GET을 포함해 10,909건, 네 쿼리에서 같은 합계. 첫 1분에 29건, 차단 전 가장 바쁜 1분(18:57)에 637건, 가장 바쁜 1분(19:07)에 1,107건, 마지막 1분에 300건. 차단이 들어간 분 이전 2,986건(분당 평균 373.2건), 이후 7,445건이며 제공된 것은 없음. 결과별로는 제공 1,388건(12.7%), 앱이 거부 1,473건(13.5%, GET 418 420건 포함), 오리진 520 116건(1.1%), 엣지에서 차단 7,930건(72.7%). 스캔의 첫 1분부터 차단까지 8.02분. kiradex.app에 GET 368건, 다른 존은 건드리지 않음. ↩↩↩↩↩↩↩↩↩↩↩

  15. 글쓴이의 코드 읽기. 10월 30일과 11월 2일에 배포되어 있던 이 사이트의 CSRF 모듈: 타임스탬프, 무작위 값, 그리고 둘에 대한 HMAC-SHA256 서명으로 이루어진 토큰. 서명이 일치하고 타임스탬프가 3,600초 미만이면 유효. 세션, 쿠키, 주소에 묶이지 않음. 2026년 11월 3일 소스에서 읽음. ↩↩↩↩

  16. 글쓴이의 데이터. 저장된 쿼리 결과로부터 스크립트로 만든 원장(484행, 각 행에 출처와 방법)과 그 코드북, 2026년 11월 3일. 각 상태에 어느 계층이 응답했는지에 대한 규칙, 방화벽 이벤트는 귀속과 타임스탬프를, 요청 데이터 세트는 건수를 제공한다는 규칙, 유효한 CSRF 토큰을 입증하는 것은 200뿐이라는 규칙, 그리고 그날의 제 메모가 11월 3일 쿼리와 다른 지점의 표(10월 30일 메모는 11:58 PDT, Cloudflare가 마지막 몇 분을 수집하기 전에 쓰여 양식 제출, 422, 페인트 공유 제출, 그 시간대의 요청을 적게 셈)를 포함. ↩↩↩↩↩↩↩↩

  17. 글쓴이의 코드 읽기. 10월 30일과 11월 2일에 배포되어 있던 이 사이트의 양식 속도 리미터: 기본값으로 클라이언트 주소당 창마다 양식 제출 5건, 시계 기준 1분을 키로 하는 60초 창, 카운터의 KV 읽기, 실패한 읽기를 0으로 세는 증가 연산 내부의 두 번째 읽기, 그 카운트에 1을 더한 값의 별도 KV 쓰기, 읽은 카운트가 한도에 이르면 429, 그리고 KV 호출이 실패하면 프로세스별 메모리 내 목록으로 대체. 2026년 11월 3일 소스에서 읽음. ↩↩↩↩↩↩↩↩↩↩

  18. 글쓴이의 데이터, 원장 부록. 2026년 11월 3일 제 컴퓨터에서 이 사이트의 속도 제한 규칙에 대해 실행한 프로브: 14:36:49 UTC에 토큰 없는 POST 열 건을 0.4초 간격으로(422 네 건, 이어서 429 여섯 건). 14:38:03 UTC에 모든 필드를 갖춘 여덟 건(오리진의 403 세 건, 이어서 “error code: 1015”라는 본문의 429 다섯 건). 14:35부터 14:40 UTC까지 양식에 대한 Cloudflare의 방화벽 이벤트, 14:40:36 UTC에 가져옴: 11건, 모두 속도 제한 규칙의 차단. ↩↩↩↩

  19. 글쓴이의 데이터. 2026년 11월 2일 10:40부터 11:32 PST까지 제 모니터의 로그와 그 탐지 결과 및 차단 기록, 11월 3일 저장: 10:57:00 PST에 시작된 실행의 탐지기 창은 18:57:22 UTC에 끝났고 탐지 결과 없음. 그 창 안에서 이 주소의 요청은 분별 데이터로 29건에서 666건 사이. 다음 실행은 11:02:46 PST. 첫 탐지 결과는 11:03:53 PST(크리티컬 요청 플러드, 이 주소에 대해 센 요청 1,914건)로 스캔의 첫 1분으로부터 7.88분 뒤. 주소는 11:04:01 PST에 계정 차단 목록에 추가됨. 탐지 결과는 모두 6건이며 모두 일일 다이제스트에 합쳐졌고 이메일로 보낸 것은 없음. 모니터는 10월 30일에는 가동되지 않았고, 10월 31일에 다시 가동했으며, 트래픽 탐지기는 그 뒤에 들어옴. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  20. 글쓴이의 분석. 스튜디오 사이트의 저장된 행을 시간별 POST 데이터와 시간대 버킷으로 대응시킴, 2026년 11월 3일: Tor 제출만 있는 시간대에 저장된 행은 없음. 10행 모두 Tor가 아닌 제출이 있는 시간대에 있음. Tor 제출 23건 중 22건은 저장된 행이 없는 시간대에 있고, 23번째는 미국 리드 생성 주소와 공유하는 시간대에 있음. 기간 안의 리드 생성 행 다섯 개는 그 주소가 제출한 다섯 시간대에 있음. 요청 ID로 결합한 것이 아니라 시간대로 한 추론. ↩↩↩↩↩↩↩

  21. Cloudflare, “Overview”, Turnstile 문서 개요, 2026년 11월 3일 14:08:57 UTC에 가져옴. “Cloudflare’s smart CAPTCHA alternative”(Cloudflare의 스마트한 CAPTCHA 대안). Cloudflare 트래픽 없이 삽입. 상호작용 없는 JavaScript 챌린지와 작업 증명 및 공간 증명에 대한 설명. Turnstile이 양식 입력 내용에 접근하거나, 저장하거나, 전송하지 않는다는 진술. ↩↩↩↩↩↩

  22. Cloudflare, “Turnstile Privacy Addendum”, 최종 수정 2025년 6월 18일, 2026년 11월 3일 14:08:57 UTC에 가져옴. Turnstile이 처리하는 신호, 그 명시된 목적, 그리고 봇 감지 개선을 위한 데이터 컨트롤러로서의 Cloudflare의 역할. 모두 인용. ↩↩↩

  23. 글쓴이의 운영 일지(Captain’s Log), 2026년 11월 2일: 제 인프라를 운영하는 에이전트 세션에 그것이 이메일을 보내고 있는지 물은 제 질문, 발신 주소 하나를 공유하는 세 메일 흐름, 스캐너의 페이로드를 담은 이메일의 “Contact Form:” 제목, 그리고 스캐너의 양식 제출이 “ran about nine a minute”(분당 약 9건으로 진행되었다)라는 제 메모. ↩↩↩↩↩

  24. 글쓴이의 코드 읽기. 10월 30일과 11월 2일에 배포되어 있던 이 사이트의 문의 핸들러와 양식 템플릿: CSRF 토큰이 렌더링된 HTMX 양식(양식 경로로의 hx-post). 핸들러 앞의 리미터 데코레이터. 발송 전에 있으며 403을 던지는 CSRF 검사. “Contact Form:”과 발신자 이름을 제목으로 한 Resend를 통한 발송. 받아들여진 발송에 대한 성공 조각. 그리고 FastAPI의 기본 상태인 200으로 오류 조각을 돌려주는 두 실패 경로(메일 설정이 없는 경우와, 발송이 실패했거나 id가 없는 경우). 허니팟 필드 없음. 2026년 11월 3일 소스에서 읽음. ↩↩↩↩↩↩↩↩↩

  25. 2026년 11월 3일 이전에 배포되어 있던 스튜디오 사이트에 대한 글쓴이의 코드 읽기와 저장소 이력: 문의 핸들러의 허니팟 분기(숨겨진 website 필드가 비어 있지 않으면 성공 조각으로 응답하고 아무것도 저장하거나 메일로 보내지 않음. 이것이 핸들러의 첫 검사이며 주소 검사, 삽입, 알림 기능보다 앞섬)로, 2026년 6월 5일 양식을 만든 커밋부터 존재. 숨겨진 입력의 tabindex="-1"과 aria-hidden. CSRF 토큰 없음. 최선 노력 방식의 알림 기능은 2026년 9월 21일 17:46:47 UTC에 “a paying customer’s message sat unread in D1 for a week”(유료 고객의 메시지가 D1에서 일주일 동안 읽히지 않은 채 있었다)라는 메시지의 커밋으로 추가됨. 그리고 익명 클라이언트를 위한 프로세스별 메모리 내 속도 리미터로, 클라이언트 주소와 클라이언트 분류(리미터가 사용자 에이전트와 주소로부터 부여하는 봇 등급)를 키로 하며 헬스 체크와 정적 파일을 제외한 모든 경로가 대상. 2026년 11월 3일 저장소에서 읽음. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  26. 글쓴이의 운영 일지(Captain’s Log), 2026년 10월 30일, 11:58 PDT에 작성: 11:46 PDT의 제 플러드 보고, 11:48부터 11:49 PDT 사이의 수동 차단과 속도 제한 규칙, 11:52 PDT의 제 시험, 인용한 대로. 그 건수는 Cloudflare가 아직 플러드의 마지막 몇 분을 수집하는 중에 잡은 것이므로, 이 글은 대신 11월 3일 쿼리를 사용. ↩↩↩

  27. Cloudflare, “GraphQL API - Limits”, GraphQL Analytics API 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. 적응형 데이터 세트는 “retain at least 31 days of data on every plan”(모든 요금제에서 최소 31일치 데이터를 보관한다). ↩↩

  28. Resend, “Event Types”, Resend 웹훅 문서, 2026년 11월 3일 14:14:47 UTC에 가져옴. email.sent는 “Occurs whenever the API request was successful. Resend will attempt to deliver the message to the recipient’s mail server.”(API 요청이 성공할 때마다 발생한다. Resend는 수신자의 메일 서버로 메시지 배달을 시도한다). 성공적인 배달은 email.delivered. ↩↩↩

  29. 글쓴이의 데이터. 8일 쿼리에서 이 사이트의 문의 양식에 제출한, 두 스캐너와 제 시험 주소를 제외한 모든 주소에 대한 Cloudflare의 요청 분석, 2026년 11월 3일 쿼리: 주소 세 개(한국, 일본, 인도), 각각 10월 27일, 28일, 29일 중 하루에 정확히 200 두 건. ↩

  30. RIPE NCC, RIPEstat network-info, whois, as-overview, 10월 30일 주소와 그 AS에 대해 2026년 11월 3일 14:10:34와 14:11:34 UTC에 가져옴: AS34534, 보유자 “BULLIONET FBW NETWORKS SAS”, 국가 FR로 등록된 할당. ↩

  31. 글쓴이의 데이터. 10월 30일 주소에 대해 경로, 메서드, 상태별로 묶은 Cloudflare의 요청 분석, 2026년 11월 3일 쿼리. 쿼리가 3,000행 한도에 이르러 요청 76,429건 중 74,125건을 다루었으므로 경로 건수는 하한: 서로 다른 경로 최소 2,881개, 경로에 SQL 인젝션 페이로드가 있는 요청 최소 2,680건으로 서로 다른 경로 최소 810개. 페인트 공유 경로에 POST 320건, 모두 거부(422 또는 400), 8일 동안 다른 주소에서는 없음. 2절에서 인용한 공유 경로의 페이로드 변형은 404로 응답. ↩↩↩↩

  32. 글쓴이의 데이터. 10월 30일 주소에 대해 사용자 에이전트별로 묶은 Cloudflare의 요청 분석, 2026년 11월 3일 쿼리. 쿼리가 500행 한도에 이르러 요청 76,429건 중 76,095건을 다루었으므로 여기의 모든 건수는 하한: Windows용 평범한 Chrome 141 문자열 하나를 쓴 요청 최소 69,322건, Googlebot을 자처한 것 최소 2,755건, 서로 다른 문자열 최소 396개로 SQL 인젝션 페이로드를 담은 것 최소 3,154건. ↩↩↩↩↩↩

  33. PortSwigger Web Security Academy, “SQL injection cheat sheet”, 2026년 11월 3일 14:10:34 UTC에 가져옴. Oracle(dbms_pipe.receive_message), Microsoft(WAITFOR DELAY), PostgreSQL(pg_sleep), MySQL(SLEEP)의 시간 지연. 조건부 IF(condition, SLEEP(10), 'a') 형태. Microsoft와 MySQL용 SELECT @@version. Oracle의 FROM dual. ↩↩↩↩↩

  34. sqlmap 프로젝트, “time_blind.xml”, 이 도구의 시간 기반 블라인드 페이로드 정의, 2026년 11월 3일 14:12:27 UTC에 가져옴. SLEEP, PG_SLEEP, WAITFOR DELAY, DBMS_PIPE.RECEIVE_MESSAGE 페이로드를 포함. 계열을 보이기 위해 인용한 것이며, 어느 스캐너가 쓴 도구로서 인용한 것이 아님. ↩

  35. PortSwigger Web Security Academy, “Blind SQL injection”, 2026년 11월 3일 14:10:34 UTC에 가져옴. 쿼리 안의 시간 지연이 왜 HTTP 응답을 지연시키는지, 인용한 대로. ↩

  36. OWASP Automated Threats to Web Applications, “OAT-014 Vulnerability Scanning”, 2026년 11월 3일 14:13:41 UTC에 가져옴. 요약, 인용한 대로. ↩

  37. 글쓴이의 데이터, 원장 부록. 두 스캐너의 양식 POST를 엣지 상태와 오리진 상태별로 묶은 Cloudflare의 요청 분석, 2026년 11월 3일 쿼리: 10월 30일에는 429 830건 중 395건에 오리진 상태 429가 있고 나머지 435건은 기록 없음. 11월 2일에는 429 31건 중 16건에 오리진 429가 있고 15건은 기록 없음. 오리진의 상태가 기록된 429 중 엣지 전용 상태를 가진 것은 없음. ↩↩

  38. 글쓴이의 시험, 2026년 11월 3일: 사이트 자체 환경(FastAPI 0.128.0)에서 실행한 열 줄짜리 FastAPI 앱으로, 엔드포인트에 리미터 형태의 데코레이터를 붙인 것. 필수 필드가 빠진 POST는 래퍼를 한 번도 호출하지 않고 422를 돌려주었고, 완전한 POST는 래퍼에 도달함. ↩

  39. 글쓴이의 데이터. 2026년 10월 30일 제 시험 주소에 대한 Cloudflare의 방화벽 이벤트와 요청 분석, 11월 3일 쿼리: 속도 제한 규칙의 이벤트 3건(첫 건은 18:52:38 UTC, 브라우저 사용자 에이전트)과, 제 curl 제출에 대해 사용자 에이전트 챌린지 규칙이 낸 관리형 챌린지 24건. 모든 사고 건수에서 제외. ↩↩↩↩↩

  40. RIPE NCC, RIPEstat network-info, whois, as-overview, 11월 2일 주소와 그 AS에 대해 2026년 11월 3일 14:10:34와 14:11:34 UTC에 가져옴: AS24875, 보유자 “NOVOSERVE-AS NovoServe B.V.”, NovoServe B.V.에 등록된 경로, 국가 NL. 하위 할당의 설명에는 개인의 이름이 있어 여기서 반복하지 않음. ↩

  41. 글쓴이의 데이터. 11월 2일 주소에 대해 사용자 에이전트별로 묶은 Cloudflare의 요청 분석, 2026년 11월 3일 쿼리, 완전함(문자열 73개로 요청 10,909건 전부를 다룸): Windows용 평범한 Chrome 141 문자열 하나를 쓴 요청 9,725건, Googlebot을 자처한 것 942건, 요청 149건에 서로 다른 SQL 인젝션 페이로드 54개, 78건에 따옴표를 깨는 문자열 3개, 12건에 @@ 마커 토큰 12개, 그리고 평범한 기타 2개. ↩↩↩↩↩↩

  42. 글쓴이의 데이터. 2026년 11월 3일 원장 스크립트가 만든 두 날의 사용자 에이전트 표: Windows용 평범한 Chrome 141 문자열과 Googlebot 문자열은 10월 30일과 11월 2일에 바이트 단위로 동일. ↩

  43. 2026년 11월 3일 글쓴이의 재현. 11월 2일 18:58:23부터 19:03:23 UTC까지의 완전한 Cloudflare 데이터에 대해 제 모니터 자체의 탐지기를 실행한 것으로, 11:02:46 PST에 시작된 실행의 창에 해당. 15:55:04 UTC에 실행했고, 정확한 명령과 탐지기의 전체 출력과 함께 재현 기록으로 저장. 원장 부록의 다섯 행은 그 기록으로부터 스크립트로 도출: 요청 플러드 탐지 결과의 주소 요청 2,143건, 스캐너 탐지 결과의 거부된 요청 1,055건, 그리고 제출 39건(그중 23건 거부)에서 나온 높은 심각도의 양식 플러드 탐지 결과로, 이는 일일 다이제스트로 가며 이메일도 차단도 하지 않음. 실시간 실행의 1,914건은 실행이 쿼리한 순간에 끝난 창에서 나온 것으로 제 모니터 자체의 로그가 기록한 수이며, 실시간 실행은 양식 플러드 탐지 결과를 내지 않음. 같은 날 한 창 변경: 이제 각 실시간 창은 실행 90초 전에 끝나고 이전 창이 끝난 곳에서 시작하며, 이전 기록이 없거나 오래되었으면 단순히 5분을 되돌아봄. ↩↩↩

  44. 글쓴이의 데이터. 11월 2일 주소에 대해 경로별로 묶은 Cloudflare의 요청 분석, 2026년 11월 3일 쿼리, 완전함(2,318행으로 요청 10,909건 전부를 다룸): 서로 다른 경로 2,173개, 그리고 SQL 인젝션 페이로드가 있는 서로 다른 경로 542개에 대한 요청 778건. ↩↩

  45. OWASP Web Security Testing Guide v4.2, “Testing for SQL Injection”, 2026년 11월 3일 14:12:12 UTC에 가져옴. 오류 기반 기법과 시간 지연 기법, 인용한 대로. ↩

  46. 2026년 11월 3일 세 번째 변경에 대한 글쓴이의 기록으로, 이 글 초안의 검토 후에 한 것: 16:12 UTC에 두 위젯의 허용 도메인을 사이트와 그 www 호스트로 설정하고 localhost를 제거. 두 검증기의 기본 호스트 이름 목록을 같은 두 호스트 이름으로 설정하고, 로컬 개발은 자체 목록을 설정. 이 사이트의 테스트는 기본값에서 localhost가 거부됨을 확인(11개 통과). 스튜디오 사이트를 다시 배포했고, Cloudflare의 항상 통과하는 테스트 키로 로컬에서 실행하면 더미 토큰은 그 키의 자리표시자 호스트 이름을 목록에 넣었을 때 여전히 검증 단계에 이르렀고, 넣지 않았을 때는 거부됨. 16:13 UTC에 위협 점수를 키로 한 관리형 챌린지 규칙 두 개(존마다 하나)를 설명에 이유를 적어 비활성화. 사용자 에이전트 챌린지는 유지. 이 변경 후의 실시간 검사: 스튜디오 사이트는 16:15 UTC(위젯, 토큰 없는 제출과 엉터리 토큰 제출은 403, 허니팟은 200), 이 사이트는 16:18 UTC(위젯, 토큰 없는 제출은 403). 어느 것도 호스트 이름 규칙을 시험하지 않음. ↩↩↩

  47. Cloudflare, “Custom rules”, Cloudflare WAF 문서, 2026년 11월 3일 14:10:34 UTC에 가져옴. 사용자 지정 규칙이 하는 일, 인용한 대로. 그리고 Log를 제외한 모든 동작을 갖춘 Free 요금제의 규칙 다섯 개. ↩

  48. Cloudflare, “Rate limiting rules”, Cloudflare WAF 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. 규칙 매개변수. 정밀도 단서와 카운터 갱신 지연, 인용한 대로. 제공 현황 표에 따르면 Free 요금제에는 10초 카운트 기간, 10초 완화 타임아웃, IP 기준 카운트, 그리고 규칙 하나가 주어짐. ↩↩↩↩↩↩

  49. Ned Batchelder, “Stopping spambots with hashes and honeypots”, 2007년 1월 21일 작성, 2026년 11월 3일 14:10:34 UTC에 가져옴. 허니팟의 정의, 봇이 보이지 않는 필드에 걸려드는 이유, 재생형과 양식 채우기형의 분류, 그리고 봇이 대책을 무력화하는 것과 CAPTCHA로 되돌아가는 것에 관한 그의 두 문장. 모두 인용. 이 페이지는 이 기법을 그가 자기 사이트를 보호하는 방법으로 설명할 뿐, 그가 발명했다고 주장하지 않음. ↩↩↩↩↩↩

  50. OWASP Cheat Sheet Series, “Credential Stuffing Prevention Cheat Sheet”, 2026년 11월 3일 14:10:34 UTC에 가져옴. 예측 가능한 단일 양적 한도에 의존하지 않기. 주소당 낮은 양이 차단 목록과 속도 제한을 무력화하는 것. JavaScript를 실행하지 않고 직접 제출하는 도구. 모두 인용. ↩↩↩

  51. Cloudflare, “Cloudflare HTTP headers”, Cloudflare Fundamentals 문서, 2026년 11월 3일 14:14:20 UTC에 가져옴. CF-IPCountry 헤더의 특수 코드로, “T1 - Used for clients using the Tor network.”(T1: Tor 네트워크를 사용하는 클라이언트에 사용)를 포함. ↩

  52. Cloudflare, “IP Access rules parameters”, Cloudflare WAF 문서, 2026년 11월 3일 14:14:20 UTC에 가져옴. “T1: Tor exit nodes (country name: Tor)”(T1: Tor 출구 노드(국가 이름: Tor)). ↩

  53. The Tor Project, “Bulk Tor Exit Exporter”, check.torproject.org, 2026년 11월 3일 14:10:34 UTC에 가져옴. 목록의 설명(인용)과, 과거 데이터에 대해 CollecTor를 안내하는 부분. 목록 자체는 같은 시각에 https://check.torproject.org/torbulkexitlist 에서 가져왔으며 주소 1,239개를 담고 있었음. ↩↩

  54. 글쓴이의 데이터, 원장 부록. 8일 쿼리에서 스튜디오 사이트 양식에 제출한 서로 다른 모든 주소를 Cloudflare의 국가 코드로 나누고, 2026년 11월 3일 14:10:34 UTC에 가져온 Tor Project의 일괄 출구 목록(주소 1,239개)과 스크립트로 비교한 것: T1으로 표시된 주소 23개 중 23개가 목록에 있었고, 나머지 12개 중에서는 0개. 11월 3일의 목록만 해당하며, 사건 당일은 아카이브와 대조하지 않음. ↩

  55. The Tor Project, “Sources”, Tor Metrics, 2026년 11월 3일 14:10:34 UTC에 가져옴. “containing the IP addresses of relays that it found when exiting through them”(그것들을 거쳐 나갈 때 발견한 릴레이의 IP 주소를 담은) TorDNSEL 출구 목록과 그 아카이브. ↩↩

  56. OWASP Automated Threats to Web Applications, “OAT-017 Spamming”, 2026년 11월 3일 14:10:34 UTC에 가져옴. 요약, 양식 스팸을 포함한 다른 이름들, 그리고 의도하지 않은 수신자를 겨냥한 양식-이메일 악용의 제외, 인용한 대로. ↩↩

  57. 글쓴이의 분석. 스튜디오 사이트에 저장된 메시지 108건을 고정된 규칙으로, 처음 일치하는 규칙을 따라 분류한 것(가격 질문과 경품 미끼는 발신자 이름 일치, 나머지는 메시지 본문, 한 행은 진짜 문의로 수작업 분류), 2026년 11월 3일: 5절 표의 건수. 가격 질문의 언어는 테이블 전체에서 25개, 10월에 9개이며, 각 고정 문장에 대한 제 해석에 따름. 크로아티아어(또는 보스니아어)와 인도네시아어(또는 말레이어)는 아슬아슬한 판정. 쿼리 이후 기간의 모든 행을 스팸으로 분류. ↩↩↩↩↩

  58. Cloudflare, “Turnstile widgets”, Turnstile 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. 관리형, 비상호작용형, 보이지 않는 모드와 각각에서 방문자가 보는 것(관리형은 인용). 보이지 않는 모드의 개인정보 처리방침 조건. challenges.cloudflare.com 오리진. ↩↩↩

  59. Cloudflare, “Cloudflare Turnstile plans”, Turnstile 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. Free와 Enterprise. 위젯 최대 20개. 무제한 챌린지. 다른 Cloudflare 서비스 없이 사용, 인용한 대로. ↩

  60. Cloudflare, “Validate the token”, Turnstile 서버 측 검증 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. siteverify를 호출해야 한다는 요건. 위조된 토큰. 엔드포인트와 그 secret, response, remoteip 필드. 300초의 유효 기간과 일회용. 최대 2,048자. action과 hostname 확인. 오류 코드 표와 만료에 대한 두 가지 설명. 검증 플러드에 대한 속도 제한. 인용한 구절은 모두 이 페이지에서 나옴. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩

  61. Cloudflare, “Cloudflare Turnstile - Easy CAPTCHA Alternative”, 제품 페이지, 2026년 11월 3일 14:08:57 UTC에 가져옴. “Turnstile never harvests data for ad retargeting.”(Turnstile은 광고 리타기팅을 위해 데이터를 수집하는 일이 결코 없다) ↩

  62. Cloudflare, “Content Security Policy”, Turnstile 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. https://challenges.cloudflare.com 에 대한 script-src와 frame-src. nonce 기반 방식. 사전 허가를 위한 connect-src 'self' 요건, 인용한 대로. ↩↩↩

  63. Cloudflare, “Interstitial Challenge Pages”, Cloudflare Challenges 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. 챌린지 페이지가 요청을 붙잡는 방식. 대부분의 WAF 규칙에 관리형 챌린지 권장. AJAX와 XHR(fetch)의 한계와 Turnstile 사전 허가 권장, 둘 다 인용. ↩↩↩↩↩↩

  64. Cloudflare, “Detect a Challenge Page response”, Cloudflare Challenges 문서, 2026년 11월 3일 14:10:34 UTC에 가져옴. challenge로 설정되는 cf-mitigated 헤더와 모든 챌린지의 text/html content-type, 인용한 대로. ↩

  65. Cloudflare, “Clearance”, Cloudflare Challenges 문서, 2026년 11월 3일 14:10:34 UTC에 가져옴. cf_clearance 쿠키와 사전 허가, 인용한 대로: 쿠키는 기본 일회용 토큰에 더해 발급되고, Siteverify는 여전히 필수이며, 쿠키는 위젯에 설정된 허가 수준까지 챌린지를 우회함. ↩↩↩

  66. 2026년 11월 3일 두 번째 변경에 대한 글쓴이의 기록으로, 같은 날 아침에 한 것: 스튜디오 사이트는 15:19 UTC에 배포하고 15:22 UTC에 실시간으로 확인(위젯, 스크립트, 사이트 키, 그리고 실제 frame-src의 챌린지 오리진. 토큰 없는 제출과 엉터리 토큰 제출은 403으로 거부. 허니팟 제출은 200으로 응답). 이 사이트는 15:20 UTC부터 빌드하고 캐시를 비운 뒤 15:25 UTC에 실시간으로 확인(같은 페이지 검사, 토큰 없는 제출은 403으로 거부, 이어서 실제 페이지의 유효한 CSRF 토큰을 갖고 Turnstile 토큰은 없는 제출도 다시 403으로 거부. 이는 배포 후에도 양식이 닫힌 상태로 실패함을 보이지만 호스트 이름 검사는 시험하지 못함). 두 검증기는 이제 Cloudflare 응답의 호스트 이름이 사이트 자체의 것 중 하나일 것을 요구하고(호스트 이름이 없는 응답은 거부), Cloudflare가 action을 돌려주는 경우 위젯 자신의 것일 것을 요구함. 이 사이트의 테스트 11개가 통과하며, 새 테스트는 사이트의 호스트 이름을 받아들이고 다른 호스트 이름, 다른 action, 호스트 이름 누락을 거부함. 스튜디오 사이트에서는, 자리표시자 호스트 이름을 돌려주고 action은 돌려주지 않는 Cloudflare의 항상 통과하는 테스트 키로 로컬에서 실행하면, 더미 토큰은 그 호스트 이름을 목록에 넣었을 때 검증 단계에 이르렀고 넣지 않았을 때는 거부됨. ↩↩↩↩

  67. Cloudflare, “Test your Turnstile implementation”, Turnstile 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. 항상 통과하는 테스트 키와 항상 실패하는 테스트 키, 그리고 더미 토큰. 프로덕션 비밀 값은 더미 토큰을 거부함. 자동화된 테스트 스위트는 봇으로 감지됨, 인용한 대로. ↩↩↩↩↩

  68. 2026년 11월 3일 네 번째 변경에 대한 글쓴이의 기록으로, 이 글 초안의 두 번째 검토 후에 한 것: 첫 버전의 HTMX before-swap 이벤트 핸들러(4xx 응답을 끼워 넣지 않는 HTMX의 기본 동작에 맞서 403 조각을 통과시키는 것)는 문의 양식에 붙어 있었지만, HTMX는 그 이벤트를 끼워 넣기 대상, 즉 양식의 형제 요소에서 발생시키므로 403 조각은 한 번도 표시되지 않음. 검토는 번들된 HTMX와 템플릿을 읽고 그것을 찾아냄. 수정은 응답 대상의 id로 범위를 한정한 페이지 body의 리스너이며, 홈페이지 테스트가 그것이 렌더링되는지 확인함(11개 통과). 16:18 UTC에 배포를 확인한 뒤 16:21 UTC에 Playwright Chromium에서 브라우저 검사를 했고, 위젯의 730자 토큰을 엉터리 문자열로 바꾸고, 필수 필드를 자리표시자 값으로 채우고, HTMX를 통해 양식을 제출함. 응답 대상에는 오류 조각 “error: Verification failed. Reload the page and try again.”이 표시되었고 성공 조각은 없었으며, 아무것도 발송되지 않음. 하지 않은 것: 사람의 종단 간 제출. ↩↩

  69. Cloudflare, “Embed the widget”, Turnstile 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. api.js 스크립트, 위젯 컨테이너, 양식 안에 만들어지는 숨겨진 cf-turnstile-response 입력, 그리고 시간 초과되거나 만료된 위젯을 위한 turnstile.reset(widgetId), 인용한 대로. ↩↩↩

  70. 2026년 11월 3일, 각 사이트 자체의 배포가 성공한 뒤, 그리고 캐시를 비운 뒤에 한 실시간 검사에 대한 글쓴이의 기록: 06:35:24와 06:35:54 PST에 각 홈페이지에 위젯 div 하나, 스크립트 태그 하나, 사이트 키가 있었고, 각 실제 frame-src에 챌린지 오리진이 있었음. 이 사이트에서 모든 필드를 갖추고 엉터리 CSRF 토큰을 단 제출은 CSRF 검사에 의해 403으로 거부. 14:43 UTC에 이 사이트에서, 실제 페이지에서 가져온 108자짜리 유효한 CSRF 토큰을 갖고 Turnstile 토큰은 없는 제출이 “Verification failed. Reload the page and try again.”과 함께 403으로 거부되었고, 같은 제출에 엉터리 Turnstile 토큰을 단 것도 403으로 거부됨. 스튜디오 사이트에서는 토큰 없는 제출과 엉터리 토큰 제출이 403으로 거부되었고, 허니팟 제출은 200으로 응답했으며 아무것도 저장되거나 메일로 보내지지 않음. 14:39:50과 14:40:04 UTC에 Playwright Chromium에서 위젯이 렌더링되었고(602×73픽셀과 557×71픽셀), Turnstile API 객체가 로드되었으며, 두 페이지 모두에서 숨겨진 응답 입력이 6초 안에 730자짜리 토큰을 담음. 하지 않은 것: 사람의 종단 간 제출. ↩↩↩

  71. Cloudflare, “Rate limiting parameters”, Cloudflare WAF 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. 매개변수 이름. Free, Pro, Business 요금제에서 챌린지 동작은 항상 스로틀링이 된다는 것(요약). 챌린지를 통과하면 카운터가 초기화된다는 것(인용). ↩

  72. OWASP Cheat Sheet Series, “Authentication Cheat Sheet”, 2026년 11월 3일 14:10:34 UTC에 가져옴. 심층 방어 통제로서의 CAPTCHA, 인용한 대로. ↩

  73. Resend, “Retrieve Sent Email”, Resend API 참조, 2026년 11월 3일 14:14:47 UTC에 가져옴. 조회한 이메일의 last_event 필드, 예를 들어 “delivered”. ↩↩

  74. Resend, “View and manage emails”, Resend 문서, 2026년 11월 3일 14:15:12 UTC에 가져옴. “sent”와 “delivered”를 포함한 이메일 이벤트 목록(인용), 그리고 Broadcasts와 Batches에만 쓰이는 “queued”. ↩

  75. Cloudflare, “Onion Routing and Tor support”, Cloudflare Network 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. 모든 요금제의 Onion Routing과, 사람인 Tor 사용자와 봇의 구별, 인용한 대로. ↩

  76. Cloudflare, “Challenges”, Cloudflare Challenges 문서, 2026년 11월 3일 14:08:57 UTC에 가져옴. “Cloudflare does not use CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters.”(Cloudflare는 CAPTCHA 퍼즐이나, 사물을 고르거나 일그러진 문자를 입력하는 것 같은 시각적 테스트를 쓰지 않는다) ↩

  77. OWASP Community, “Blind SQL Injection”, 2026년 11월 3일 14:10:34 UTC에 가져옴. 데이터베이스의 일시 정지에 기대는 시간 기반 블라인드 인젝션으로, 예시는 WAIT FOR DELAY와 pg_sleep(). ↩

관련 게시물

알림 피로: 보안 모니터가 하루에 보낸 이메일 82통

하루 한 통의 다이제스트로 보안 모니터의 이메일이 하루 82통에서 다음 날 19통으로 줄었습니다. 그 정책, 알림 이메일 0통으로 차단된 스캐너, 그래도 19통이 온 이유.

48 분 소요

포크 폭탄이 우리를 구했다

LiteLLM 공격자는 구현에서 실수 하나를 저질렀습니다. 47,000건의 설치가 46분 만에 발각된 유일한 이유가 바로 그 실수였습니다.

7 분 소요

제가 글로 쓰지 않는 것들

블로그 클러스터의 목소리는 발행하는 글이 아니라 발행을 거부하는 글에서 나옵니다. 범주적 거부, 패턴 거부, 그리고 흥미로운 거부가 각각 클러스터의 정체성을 형성합니다.

9 분 소요