Go로 웹 스크레이퍼나 자동화 클라이언트를 구축하다 보면, 코드는 완벽한데 Cloudflare·Akamai·Imperva 같은 WAF가 403 Forbidden을 내리는 경우가 반복됩니다. 원인은 단순한 속도 제한이 아니라 TLS 핑거프린트입니다. Go 표준 라이브러리 net/http와 crypto/tls가 생성하는 ClientHello 메시지는 실제 Chrome과 확연히 다르기 때문에, 서버 측에서 JA3/JA4 해시만 보고도 비인간 트래픽으로 분류합니다. 이 글은 Go net/http TLS 핑거프린트 문제를 진단하고, refraction-networking/utls로 ClientHello를 Chrome에 맞추는 방법, 그리고 ProxyHat 주거형 프록시로 네트워크 정체성까지 일관되게 보호하는 실전 구성을 다룹니다.
Go net/http TLS 핑거프린트가 차단되는 이유
TLS 핑거프린트는 ClientHello 메시지에 포함된 필드들의 순서와 값을 해시한 결과입니다. IETF TLS Working Group 문서와 실제 WAF 벤더 문서에 따르면, ClientHello의 cipher_suites, extensions, supported_groups, signature_algorithms 필드의 정확한 순서가 클라이언트 식별에 사용됩니다. Go의 crypto/tls는 이 순서를 컴파일 타임에 고정하며, 다음과 같은 정적 특성을 갖습니다:
- 고정된 암호화 제품군 순서 — Go는 자체적으로 정렬한 순서를 사용하며 Chrome의 우선순위와 다릅니다.
- GREASE 미사용 — Chrome은 RFC 8701에 정의된 GREASE(Generating Random Extensions And Sustaining Extensibility) 값을 cipher_suites, extensions, supported_groups 등에 무작위로 삽입하지만, Go는 전혀 사용하지 않습니다.
- 확장 누락 —
application_settings,delegated_credentials,encrypted_client_hello등 Chrome이 최신 버전에서 보내는 확장이 빠져 있습니다. - key_share 그룹 순서 차이 — X25519과 P-256의 제시 순서가 Chrome과 반대이거나 누락됩니다.
결과적으로 Go의 기본 ClientHello는 매우 안정적이고 고유한 JA3/JA4 해시를 만들어냅니다. Cloudflare는 TLS 핑거프린팅 공식 블로그 포스트에서 이 방식이 봇 탐지의 핵심 신호임을 명확히 설명합니다. 실제로 tls.peet.ws/api/all에 요청을 보내면 Go의 JA3 해시가 Chrome의 것과 완전히 다르다는 것을 한 번에 확인할 수 있습니다.
본인의 JA3/JA4 직접 캡처하기
문제를 진단하려면 먼저 자기 클라이언트의 핑거프린트를 확인해야 합니다. 다음은 Go 기본 클라이언트로 핑거프린트를 수집하는 최소 예제입니다:
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
resp, err := http.Get("https://tls.peet.ws/api/all")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
응답 JSON에서 tls.ja3와 tls.ja4 필드를 확인하세요. Go 1.22 기준 JA3 해시는 771,4865-4866-4867-49195-49199-...-255 같은 형태로 Chrome의 771,4865-4867-4866-... 패턴과 확연히 다릅니다. 특히 cipher_suites의 순서가 다르고 GREASE 값이 전혀 없는 것이 핵심입니다.
Go vs Chrome ClientHello 신호 비교
핑거프린트 차이를 구체적으로 이해하려면 ClientHello의 주요 필드를 비교해야 합니다. 아래 표는 Go 1.22 기본 클라이언트와 Chrome 124의 차이를 요약한 것입니다.
| 필드 | Go net/http (기본) | Chrome 124 | 차이 영향 |
|---|---|---|---|
| cipher_suites 순서 | 고정, GREASE 없음 | GREASE + X25519Kyber768Draft00 우선 | JA3 해시 전체 변경 |
| supported_groups | x25519, secp256r1, secp384r1 | GREASE, x25519, secp256r1, secp384r1, x25519kyber768draft00 | 키 교환 단계에서 식별 |
| signature_algorithms | 고정 8개 | Chrome 순서 + ecdsa_secp256r1_sha256 우선 | 서명 알고리즘 협상 차이 |
| ALPN | h2, http/1.1 | h2, http/1.1 (동일) | 영향 적음 |
| key_share | x25519 단일 | x25519 + GREASE + Kyber | 키 공유 그룹 노출 |
| GREASE 값 | 없음 | cipher, extensions, groups, versions에 무작위 삽입 | 가장 강력한 Chrome 식별 신호 |
특히 GREASE는 RFC 8701이 정의한 메커니즘으로, 클라이언트가 무작위로 예약된 값을 여러 필드에 섞어 넣어 중간자(MITM) 장비가 확장을 잘못 해석하는 것을 방지합니다. Chrome은 이 값을 사용하지만 Go는 사용하지 않으므로, Go tls fingerprint differs from chrome라는 문제가 단순한 cipher 순서 문제가 아니라 구조적 차이로 발생합니다.
uTLS로 ClientHello 교정하기
refraction-networking/utls는 Go의 crypto/tls를 포크하여 ClientHello를 원하는 브라우저 프로필로 완전히 제어할 수 있게 만든 라이브러리입니다. 핵심은 utls.UClient에 HelloChrome_Auto 같은 미리 정의된 ClientHelloSpec을 전달하는 것입니다.
기본 uTLS 클라이언트 예제
package main
import (
"context"
"fmt"
"net"
"net/http"
"time"
utls "github.com/refraction-networking/utls"
)
type uTLSRoundTripper struct {
clientHelloID utls.ClientHelloID
proxyURL string
client *http.Client
}
func newUTLSClient(clientHelloID utls.ClientHelloID, proxyURL string) *http.Client {
dialer := &net.Dialer{Timeout: 30 * time.Second}
transport := &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
rawConn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
host, _, _ := net.SplitHostPort(addr)
uConn := utls.UClient(rawConn, &utls.Config{ServerName: host}, clientHelloID)
if err := uConn.HandshakeContext(ctx); err != nil {
rawConn.Close()
return nil, err
}
return uConn, nil
},
}
return &http.Client{Transport: transport, Timeout: 60 * time.Second}
}
func main() {
client := newUTLSClient(utls.HelloChrome_Auto, "")
resp, err := client.Get("https://tls.peet.ws/api/all")
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Printf("Status: %d\n", resp.StatusCode)
}
HelloChrome_Auto는 최신 Chrome ClientHelloSpec을 자동으로 선택하며, GREASE 값과 cipher 순서, key_share 그룹까지 Chrome과 일치시킵니다. Safari가 필요하면 HelloSafari_16_0을 사용하면 됩니다. 이렇게 하면 uTLS HelloChrome_Auto를 통해 go ja3 ja4 spoof가 가능해집니다.
상위 수준 대안: CycleTLS와 azuretls-client
uTLS를 직접 Transport에 연결하는 것은 유연하지만 코드가 길어집니다. 더 높은 수준의 추상화가 필요하면 두 라이브러리가 유용합니다.
CycleTLS
CycleTLS는 Go와 Node.js 바인딩을 제공하며, 내부적으로 uTLS를 감싸 JA3 문자열과 User-Agent를 함께 설정할 수 있습니다. 간단한 스크레이퍼에 적합하지만, 커스텀 확장이나 최신 Chrome 프로필 업데이트 속도는 refraction-networking/utls 본체가 더 빠릅니다.
azuretls-client
azuretls-client는 uTLS 위에 HTTP/2 프레이밍까지 Chrome과 유사하게 맞추는 고급 클라이언트입니다. HTTP/2 SETTINGS 프레임 순서와 WINDOW_UPDATE 크기까지 조정하므로, 단순 JA3을 넘어 HTTP/2 핑거프린트까지 교정하려는 경우에 강력합니다. 다만 의존성이 무겁고 API가 상대적으로 고정되어 있어 세밀한 제어가 필요한 경우에는 직접 uTLS를 쓰는 것이 낫습니다.
| 라이브러리 | 추상화 수준 | HTTP/2 핑거프린트 | 적합한 용도 |
|---|---|---|---|
| refraction-networking/utls | 낮음 (직접 Transport 구성) | 미지원 (별도 처리 필요) | 정밀 제어, 최신 프로필 |
| CycleTLS | 중간 | 부분 | 빠른 프로토타입 |
| azuretls-client | 높음 | 지원 | 고급 봇 탐지 회피 |
주거형 프록시로 네트워크 정체성 보호
핑거프린트 모방만으로는 충분하지 않습니다. 아무리 ClientHello가 Chrome과 동일해도, 요청이 데이터센터 IP에서 나오면 IP 평판이 즉시 비인간 신호가 됩니다. Cloudflare는 IP ASN이 AWS·DigitalOcean·Hetzner 같은 데이터센터 대역인지 확인하고, 데이터센터 IP에서 오는 Chrome 핑거프린트는 여전히 의심스럽게 평가합니다. 따라서 utls chrome fingerprint go 설정과 함께 주거형 프록시를 결합해야 JA3 + IP 평판이 모두 인간처럼 보입니다.
ProxyHat 주거형 프록시는 gate.proxyhat.com 게이트웨이를 통해 HTTP 8080 포트로 접속하며, 사용자명에 국가·세션 플래그를 넣어 지리적 타겟팅과 스티키 세션을 제어할 수 있습니다. 아래는 uTLS 클라이언트를 ProxyHat 주거형 프록시로 라우팅하는 완전한 예제입니다.
ProxyHat 주거형 프록시 + uTLS 예제
package main
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"time"
utls "github.com/refraction-networking/utls"
)
func newProxiedUTLSClient(clientHelloID utls.ClientHelloID, proxyAddr, proxyUser, proxyPass string) *http.Client {
proxyURL, _ := url.Parse(fmt.Sprintf("http://%s:%s@%s", proxyUser, proxyPass, proxyAddr))
dialer := &net.Dialer{Timeout: 30 * time.Second}
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// Proxy를 통해 대상 서버로 터널 연결
proxyConn, err := dialer.DialContext(ctx, "tcp", proxyAddr)
if err != nil {
return nil, err
}
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\n\r\n",
addr, addr, basicAuth(proxyUser, proxyPass))
if _, err := proxyConn.Write([]byte(connectReq)); err != nil {
proxyConn.Close()
return nil, err
}
// 200 응답 읽기 (간소화)
buf := make([]byte, 1024)
n, _ := proxyConn.Read(buf)
if n < 12 || string(buf[:12]) != "HTTP/1.1 200" {
proxyConn.Close()
return nil, fmt.Errorf("proxy connect failed: %s", string(buf[:n]))
}
host, _, _ := net.SplitHostPort(addr)
uConn := utls.UClient(proxyConn, &utls.Config{ServerName: host}, clientHelloID)
if err := uConn.HandshakeContext(ctx); err != nil {
proxyConn.Close()
return nil, err
}
return uConn, nil
},
}
return &http.Client{Transport: transport, Timeout: 60 * time.Second}
}
func basicAuth(user, pass string) string {
return "Basic " + base64Encode(user + ":" + pass)
}
func base64Encode(s string) string {
// 실제 구현에서는 encoding/base64 사용
return s // 간소화
}
func main() {
// ProxyHat 주거형 프록시, 미국 IP + 스티키 세션
client := newProxiedUTLSClient(
utls.HelloChrome_Auto,
"gate.proxyhat.com:8080",
"user-country-US-session-abc123",
"pass",
)
resp, err := client.Get("https://tls.peet.ws/api/all")
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Printf("Status: %d\n", resp.StatusCode)
}
이 구성에서 user-country-US-session-abc123 사용자명은 미국 주거형 IP를 할당하고 동일 세션 ID로 여러 요청에 같은 IP를 유지합니다. 결과적으로 서버가 보는 신호는 다음과 같이 정렬됩니다:
- JA3/JA4 — Chrome 124 프로필과 일치
- IP ASN — 미국 주거형 ISP 대역 (데이터센터 아님)
- 세션 일관성 — 스티키 세션으로 자연스러운 재방문 패턴
- 지리적 일관성 — IP 국가와 Accept-Language 헤더가 일치
ProxyHat은 주거형·모바일·데이터센터 프록시를 모두 제공하므로, 용도에 따라 프록시 가격 페이지에서 적합한 플랜을 선택할 수 있습니다. 주거형 프록시는 가장 강력한 IP 평판을 제공하며, 프록시 위치 목록에서 지원 국가를 확인할 수 있습니다.
실제 사용 사례와 내부 링크
이 구성은 합법적인 자동화와 보안 연구에 사용됩니다:
- SERP 추적 — 검색 결과 순위 모니터링. SERP 추적 사용 사례에서 자세한 구성을 확인하세요.
- 웹 스크레이핑 — 공개 데이터 수집. 웹 스크레이핑 사용 사례를 참조하세요.
- 보안 연구 — 인가된 침투 테스트에서 대상 WAF의 동작을 정확히 재현해야 할 때 uTLS + 주거형 프록시 조합이 필요합니다.
- QA 자동화 — 지리적 분산 환경에서 서비스 동작을 검증할 때.
ProxyHat 연결 상세 문서는 docs.proxyhat.com에서 확인할 수 있습니다.
흔한 실수와 엣지 케이스
1. HTTP/2 핑거프린트 무시
uTLS는 ClientHello만 교정합니다. HTTP/2 연결이 성립한 후 SETTINGS 프레임 순서, WINDOW_UPDATE 크기, 헤더 프레임 인코딩 순서는 여전히 Go 기본값을 사용합니다. 일부 고급 WAF는 HTTP/2 핑거프린트까지 검사하므로, 이 경우 azuretls-client를 사용하거나 HTTP/2 프레임을 직접 제어해야 합니다.
2. User-Agent와 TLS 핑거프린트 불일치
ClientHello는 Chrome 124인데 User-Agent가 Chrome 120이면 의심스럽습니다. 항상 uTLS 프로필 버전과 User-Agent 문자열을 일치시키세요.
3. 세션 스티키 과용
동일 세션 ID로 수천 요청을 보내면 결국 속도 제한에 걸립니다. 세션 ID를 주기적으로 변경하거나, 요청당 회전을 사용하세요. ProxyHat은 user-session-{id} 플래그로 세션을 제어합니다.
4. JA4 업데이트 미반영
JA4는 JA3을 대체하는 더 정밀한 핑거프린트로, FoxIO JA4 프로젝트에서 정의됩니다. JA4는 cipher 수, extension 수, ALPN 값을 별도 세그먼트로 분리해 더 안정적인 식별을 제공합니다. uTLS 라이브러리를 정기적으로 업데이트하고, 새 Chrome 버전이 출시되면 HelloChrome_Auto가 최신 프로필을 반영하는지 확인해야 합니다.
주요 요점
Go net/http TLS 핑거프린트 문제는 구조적입니다. GREASE 부재, 고정 cipher 순서, 누락된 확장이 결합되어 Cloudflare·Akamai가 즉시 탐지하는 JA3/JA4 해시를 만듭니다. 해결은 두 단계입니다:
- uTLS로 ClientHello 교정 —
HelloChrome_Auto로 Chrome과 동일한 핑거프린트 생성.- 주거형 프록시로 IP 평판 보호 —
gate.proxyhat.com:8080을 통해 데이터센터가 아닌 주거형 IP로 라우팅.두 신호가 모두 일치해야 WAF를 통과할 수 있습니다. 하나만 맞추면 충분하지 않습니다.
FAQ
Go net/http TLS 핑거프린트란 무엇인가?
Go 표준 라이브러리 crypto/tls가 생성하는 ClientHello 메시지의 특성을 의미합니다. Go는 cipher_suites 순서를 고정하고 GREASE 값을 사용하지 않으며 일부 Chrome 확장이 누락되어, 결과적으로 Chrome과 확연히 다른 JA3/JA4 해시를 만듭니다. 이 해시가 WAF 탐지 신호로 사용됩니다.
왜 프록시 사용자에게 TLS 핑거프린트가 중요한가?
아무리 주거형 프록시로 IP 평판을 보호해도, TLS 핑거프린트가 Go 기본값이면 데이터센터 IP가 아님에도 비인간 트래픽으로 분류될 수 있습니다. WAF는 IP 평판과 TLS 핑거프린트를 동시에 평가하므로, 두 신호가 모두 인간처럼 보여야 차단을 피할 수 있습니다.
어떤 프록시 유형이 TLS 핑거프린트 교정에 가장 적합한가?
주거형 프록시가 가장 적합합니다. 데이터센터 IP는 ASN만으로 의심 신호가 되지만, 주거형 IP는 실제 ISP 대역을 사용하므로 IP 평판이 자연스럽습니다. ProxyHat 주거형 프록시는 gate.proxyhat.com:8080으로 접속하며 국가·세션 타겟팅을 지원합니다.
Go에서 TLS 핑거프린트 차단을 피하려면 어떻게 해야 하나?
refraction-networking/utls를 사용해 utls.UClient에 HelloChrome_Auto를 전달하고, 이를 http.Transport의 DialTLSContext에 연결하세요. 그 후 ProxyHat 주거형 프록시로 라우팅하여 JA3/JA4와 IP 평판을 모두 Chrome + 주거형 ISP로 일치시킵니다. HTTP/2 핑거프린트까지 교정하려면 azuretls-client를 고려하세요.
JA3와 JA4의 차이는 무엇인가?
JA3는 ClientHello의 cipher_suites, extensions, supported_groups, signature_algorithms를 하나의 문자열로 연결해 해시합니다. JA4는 이를 더 세분화해 cipher 수, extension 수, ALPN을 별도 세그먼트로 분리해 더 안정적이고 정밀한 식별을 제공합니다. JA4가 점차 표준으로 자리 잡고 있으므로 uTLS 라이브러리를 최신 상태로 유지해야 합니다.






