서버는 당신이 봇이라는 걸 어떻게 아는가

User-Agent 문자열에는 아무 브라우저나 적어 넣을 수 있다. 그래서 아무도 그 필드를 믿지 않는다 — 그 아래에 깔린 TLS 핸드셰이크와 HTTP/2 설정은 당신이 아니라 당신이 쓴 라이브러리가 적은 것이기 때문이다.

서버는 당신이 봇이라는 걸 어떻게 아는가

이걸 실행하고 실패하는 걸 지켜보자.

curl -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0" https://example.com

서버에게 당신은 윈도우의 크롬이라고 말했다. 서버는 그 말을 읽고, 연결의 나머지 부분을 슬쩍 보고는, 그냥 차단한다. User-Agent 자체엔 틀린 게 없다 — 다만 그건 HTTP 요청에서 클라이언트가 마음대로 지어낼 수 있는 유일한 부분이고, 그래서 진지한 탐지 시스템은 거기에 아무 무게도 두지 않는다. 크레용으로 직접 쓴 이름표 같은 것이다.

흥미로운 질문은 서버가 그 대신 무엇을 보느냐다. 답은, 당신이 통제하는 부분 아래에 있는 모든 것이다.

당신이 쓰지 않은 핸드셰이크

HTTP 바이트가 한 개라도 오가기 전에, 클라이언트와 서버는 TLS 핸드셰이크를 한다. 그리고 그 첫 메시지인 ClientHello가 곧 상세한 자백서다. 지원하는 TLS 버전, 선호 순서대로 나열한 암호 스위트, 확장 목록, 타원곡선 — 전부 들어 있다. 그중 어느 것도 당신이 고른 콘텐츠가 아니다. 당신의 TLS 라이브러리가 컴파일해 넣은 그대로다.

2017년 Salesforce의 엔지니어 몇 명이 이게 자동차 번호판이나 다름없다는 걸 알아채고 JA3를 만들었다. ClientHello의 그 필드들을 이어 붙이고 MD5로 해시하면, 연결을 거듭해도 안정적이면서 진짜 크롬과 OpenSSL을 쓰는 스크립트 사이에서는 완전히 달라지는 짧은 문자열이 나온다. 크롬은 BoringSSL을, curl은 보통 OpenSSL을, 파이썬 requests는 또 다른 걸 싣는다. 같은 요청이라도 서로 다른 핸드셰이크가 나오는 이유는, 자동차 공장마다 다른 차대번호를 찍는 이유와 같다.

그러니 당신의 curl이 User-Agent에선 크롬이라고 우기면서 OpenSSL의 ClientHello를 내밀면, 서버는 속지 않는다 — 오히려 재미있어한다. 애플리케이션 계층은 크롬이라 하고 전송 계층은 curl이라 하는데, 이 모순은 정직한 curl/8.4.0보다 훨씬 더 큰 봇 신호다.

HTTP/2가 흘리는 단서

새는 건 TLS만이 아니다. HTTP/2에서 클라이언트는 SETTINGS 프레임으로 연결을 열며 자기 선호를 알린다 — 헤더 테이블 크기, 최대 동시 스트림 수, 초기 윈도 크기. 이어서 WINDOW_UPDATE가 오고, 결정적으로 :method, :authority, :scheme, :path 같은 의사 헤더(pseudo-header)를 보내는 순서가 드러난다. Akamai는 이 모든 걸 HTTP/2 수동 핑거프린팅 논문에서 정리했다. 크롬은 이 넷을 한 순서로 보내고, Go 프로그램은 다른 순서로 보낸다. 당신 눈엔 안 보이지만 서버 눈엔 또렷하다.

계층을 쌓아 보면 그림이 선명해진다. User-Agent는 크롬, TLS 핸드셰이크는 OpenSSL, HTTP/2 프레임은 Go인 요청은 정체성 혼란에 빠진 브라우저가 아니다. 그건 세 개의 분장을 동시에 걸친 봇이다.

자바스크립트의 고백

이 모든 걸 뚫고 연결이 살아남아 실제로 브라우저를 돌린다 해도, 브라우저는 스스로를 일러바친다. W3C WebDriver 명세는 자동화된 브라우저가 navigator.webdrivertrue로 설정하도록 요구한다 — 페이지가 자신이 스크립트에 조종당하고 있음을 알 수 있게 한 정직성 플래그다. 당연하게도 이 플래그를 다시 false로 패치하는 스텔스 플러그인 생태계가 통째로 생겨났는데, 이것 자체가 또 하나의 고백이다. 진짜 크롬은 자기가 진짜 크롬이라고 거짓말할 필요가 없다. 여기에 헤드리스 브라우저의 자잘한 흔적들 — 빠진 플러그인, 어색한 창 크기, 진짜 GPU 대신 SwiftShader라고 적힌 렌더러 문자열 — 을 더하면, 자바스크립트 환경은 또 하나의 지문이 된다.

군비 경쟁이 끝나지 않는 이유

여기서부터가 진짜 어렵고, 정직한 사람과 봇 운영자의 경계가 흐려지는 지점이다. curl-impersonate라는 도구가 있다. curl을 진짜 브라우저와 같은 TLS 라이브러리로, 같은 암호 순서로, 일치하는 HTTP/2 설정으로 다시 빌드해서, 핑거프린트가 들어맞게 만든다. 그리고 실제로 통한다. 이 게임의 전부는 모든 계층이 서로 입을 맞추게 하는 것이다.

브라우저들도 가만히 있지 않는데, 그 수가 양날이다. 크롬은 2023년 초 110 버전부터 연결할 때마다 TLS 확장의 순서를 무작위로 섞기 시작했다 — 일부는 프로토콜 경화(ossification)에 맞서려고, 일부는 핑거프린팅에 저항하려고. 이게 확장 순서에 의존하는 JA3를 깨뜨려서, 같은 크롬이 이제 매번 다른 JA3를 뱉는다. 그 대답이 JA4였다. 해시하기 전에 값을 정렬해서 순서가 더는 문제되지 않게 만든 것이다. GREASE(RFC 8701)는 같은 경화 방지 목적으로 핸드셰이크에 일부러 무작위 값을 뿌리고, 핑거프린터들은 그냥 그걸 걸러내는 법을 배웠다. 모든 수에는 받아치는 수가 따라붙고, 그 받아치는 수에는 또 받아치는 수가 따라붙는다.

실제로 측정되는 것

한 발 물러서면, 진짜 신호는 어떤 단일 핑거프린트가 아니다. 바로 일관성이다. 진짜 브라우저는 밑바닥까지 내부적으로 앞뒤가 맞는다. User-Agent, TLS 핸드셰이크, HTTP/2 프레임, 자바스크립트 환경이 정확히 어느 브라우저, 어느 버전, 어느 플랫폼인지에 대해 모두 같은 말을 한다. 봇은 서로 다른 라이브러리에서 끌어모은 계층들의 더미이고, 그 이음매가 드러난다.

그러니 봇처럼 보이지 않는 유일하게 정직한 방법은, 당신이 주장하는 그것이 실제로 되는 것뿐이다 — 같은 엔진, 같은 라이브러리, 같은 기본값으로 위에서 아래까지. 헤더 하나로 속여 넘길 수는 없다. 헤더는 애초에 읽히던 대상이 아니었으니까. 다음에 -A "Chrome"에 손을 뻗으며 왜 안 통하는지 궁금해질 때 기억해 둘 만한 대목이다. 당신은 아무도 보지 않던 그 한 필드를 고쳤을 뿐이다.

토론 참여

← 블로그로 돌아가기