https://github.com/kimgh06/VPN-for-GSM 을 비공식적으로 계승합니다.
이전에 쓰던 FxxkGSM도 사실 VPN으로 적혀있지만 VPN이 아닙니다.
내부는 goodbyedpi라는 차단 우회용 오픈소스를 일부 변형하여 구현 되어 있습니다.
FreeGSM은 소스코드가 상실된 FxxkGSM을 대체하고, DNS 차단과 SNI 기반 차단에 대한 이해도를 높이기 위한 학습을 목적으로 python으로 개발 되었습니다.
메모리 사용량과 성능이 소폭 개선된 Rustify 버전을 함께 제공합니다.
평문 DNS를 DNS-over-HTTPS(DoH) 로 자동 전환하고, SNI 기반 차단(DPI) 까지 우회하는 Windows용 프로그램입니다. 실행해 두면 동작하고, 끄면 원래대로 돌아옵니다. DNS 우회는 IPv4/IPv6 질의를 모두 처리하고, SNI/DPI 우회는 IPv4의 TCP/443·UDP/443 트래픽을 대상으로 합니다. VPN이 아니기 때문에 실질적으로 ip가 변경되거나 핑이 크게 튀지 않습니다. 따라서 ip가 차단당한 경우엔 사용할 수 없습니다.
- Windows 10/11, 64-bit
- 관리자 권한 (WinDivert가 커널 드라이버를 로드)
- 소스로 실행하거나 빌드하려면 Python 3.12+
Release에서 최신 버전의 exe를 다운로드하거나, 아래 방법을 통해 빌드하세요.
powershell -ExecutionPolicy Bypass -File .\build.ps1dist\FreeGSM.exe가 만들어집니다. WinDivert 드라이버가 내장되어 있고 실행 시
UAC 관리자 권한을 자동으로 요청하므로, 더블클릭만 하면 됩니다.
pip install -r requirements.txt
# 반드시 "관리자 권한"으로 연 터미널에서 실행:
python -m dohproxy.main창을 열어 두면 DNS가 DoH로 전환된 상태입니다. Ctrl+C 로 종료하면 평범한 DNS로 돌아갑니다.
설정 우선순위: 환경 변수 > config.yml > 내장 기본값
실행 파일 옆이나 프로젝트 루트에 config.yml을 두면, 재빌드·환경 변수 없이
값을 바꿀 수 있습니다. 파일이 없거나 항목이 없으면 기본값이 그대로 쓰입니다.
# 있는 항목만 기본값을 덮어씁니다
doh_url: https://8.8.8.8/dns-query # Google DoH
dpi_bypass: true # false로 설정하면 443 릴레이 비활성화
dns_cache_ttl_sec: 300 # DNS 응답 캐시 TTL 상한(응답의 최소 TTL까지 clamp). 0이면 캐시 비활성화지원 항목:
| 항목 | 기본값 | 설명 |
|---|---|---|
doh_url |
https://1.0.0.1/dns-query |
DoH 업스트림 URL (반드시 IP로 지정) |
dpi_bypass |
true |
SNI 우회 릴레이 활성화 여부 |
dns_cache_ttl_sec |
300 |
인메모리 DNS 캐시 유지 시간의 상한(실제 응답의 최소 TTL과 min()). 0이면 캐시 비활성화 |
# DoH 업스트림 바꾸기
set FREEGSM_DOH_URL=https://8.8.8.8/dns-query # Google
set FREEGSM_DOH_URL=https://9.9.9.9/dns-query # Quad9
# SNI 우회(443 릴레이) 끄고 DoH만 쓰기
set FREEGSM_DPI=0
# DNS 캐시 설정
set FREEGSM_DNS_CACHE_TTL_SEC=0웹사이트에 접속할 때 브라우저가 가장 먼저 하는 일은 두 가지입니다.
- 이름 풀이(DNS):
example.com같은 도메인 이름을 실제 IP 주소로 바꿉니다. 기본적으로 평문 UDP/53(가끔 TCP/53)으로 오갑니다. - TLS 핸드셰이크(SNI): 알아낸 IP의 443 포트로 TCP 연결을 맺고, 암호화가
시작되기 전에 "나는
example.com에 접속하려 한다"는 호스트 이름을 평문으로 보냅니다. 이 필드가 **SNI(Server Name Indication)**입니다.
학교·기관·ISP의 차단 장비(흔히 DPI, Deep Packet Inspection)는 바로 이 두 평문 지점을 노립니다. ① DNS 질의를 들여다보고 가짜 응답을 주입하고, ② TLS의 SNI를 읽어 차단 대상 호스트면 연결을 끊습니다. FreeGSM은 이 두 지점을 각각 우회합니다. — DNS는 암호화해서 들여다볼 수 없게, SNI는 조각내서 한 번에 읽을 수 없게 만듭니다.
구현상으로는 하나의 WinDivert 캡처 루프가 나가는 패킷을 모두 가로채고,
패킷의 포트와 방향에 따라 처리 경로가 갈립니다(divert.py).
차단은 이렇게 동작합니다. 평문 DNS는 보내는 사람도, 받는 IP(예: 8.8.8.8)도,
질문 내용("example.com의 IP는?")까지 전부 노출됩니다. 그래서 차단 장비는
- 질의 안의 도메인 이름을 그대로 읽고,
- 차단 대상이면 진짜 응답이 오기 전에 가짜 응답을 먼저 주입하거나(존재하지 않는 주소·차단 안내 페이지 IP로 위조 = DNS 스푸핑/하이재킹), 질의 자체를 버립니다.
UDP 질의는 응답을 검증할 길이 없어서, 먼저 도착한 위조 응답을 그대로 믿게 됩니다.
FreeGSM의 우회 — DoH(DNS over HTTPS)로 질문을 암호화합니다.
- UDP/53: 나가는 DNS 질의 패킷을 잡아, 그 페이로드를 그대로 DoH 서버에 HTTPS로 POST하고, 돌아온 응답을 그대로 다시 끼워 넣습니다. 질의가 TLS 안에 들어가므로 차단 장비는 도메인 이름은커녕 그것이 DNS 질의라는 사실조차 알 수 없어, 읽지도 위조하지도 못합니다. DNS 질의와 DoH 요청 본문은 같은 바이트 포맷(RFC 8484)이라 DNS를 따로 해석할 필요가 없습니다.
- TCP/53: TCP DNS는 길이 접두사가 붙은 스트림이라 단일 패킷으로 답할 수 없어,
연결을 로컬 프록시로 돌려서 처리합니다(
nslookup -vc가 쓰는 경로). 나가는 TCP/53 연결의 목적지를127.0.0.1:53533으로 바꿔 로컬 프록시가 받게 하고, 프록시는 DNS-over-TCP 질의를 읽어 같은 내용을 DoH로 보낸 뒤 응답을 다시 TCP DNS 형식(2바이트 길이 + DNS 응답)으로 돌려줍니다. 클라이언트가 보기에는 원래 DNS 서버가 TCP/53으로 답한 것처럼 보이도록 응답 패킷의 출발지도 다시 원래 서버 주소로 고쳐 넣습니다. - 부트스트랩 문제 해결: "DNS를 대신하는 서버의 주소를 또 DNS로 찾아야 하나?"라는
순환을 피하려고, DoH 서버의 IP에 직접 접속합니다(
https://1.0.0.1/...). 인증서에 IP SAN이 들어 있어 TLS 검증이 통과하므로, 호스트 이름 풀이가 아예 필요 없습니다. HTTP/2 연결을 유지해 질의마다 비용이 낮습니다. - DNS 캐시: 같은 DNS 질의가 반복될 때는 인메모리 캐시에 저장된 응답을 먼저
사용합니다. DNS 메시지의 앞 2바이트 질의 ID는 요청마다 달라질 수 있으므로,
캐시 키는 질의 ID를 제외한 DNS 본문으로 잡고, 캐시에서 꺼낸 응답에는 현재
요청의 질의 ID를 다시 붙입니다. 캐시 만료는
dns_cache_ttl_sec또는FREEGSM_DNS_CACHE_TTL_SEC로 바꿀 수 있습니다. TTL이 만료된 항목은 조회·저장 시점에 즉시 삭제됩니다.dns_cache_ttl_sec: 0으로 설정하면 캐시를 완전히 비활성화합니다. 캐시는 프로세스가 실행 중인 동안만 유지되며, 종료 시 사라집니다. - Fail-closed(기본값): DoH 서버에 닿지 못하면 질의를 평문으로 흘리지 않고 버립니다. 잠깐 실패했다고 평문으로 새어 나가면 우회의 의미가 없기 때문입니다. 대신 시작할 때 업스트림에 먼저 접속해 보고, 닿지 않으면 아예 실행을 거부합니다 (닿지 않는 업스트림으로 켜두면 모든 DNS가 끊기므로).
DNS만 풀어도 IP는 알아낼 수 있지만, 그 IP로 HTTPS 연결을 맺는 순간 아래의 SNI 검사에 다시 걸립니다. 그래서 다음 단계가 필요합니다.
차단은 이렇게 동작합니다. DNS를 암호화해도, TLS 핸드셰이크의 첫 메시지인 ClientHello 속 SNI는 여전히 평문입니다(서버가 어떤 인증서를 줄지 고르려면 암호화 키 교환 전에 호스트 이름을 알아야 하기 때문). 차단 장비는
- 나가는 TCP 스트림을 재조립해 ClientHello를 찾고,
- 그 안의 SNI 호스트 이름을 읽어,
- 차단 대상이면 즉시 TCP RST를 주입하거나 패킷을 버려 연결을 끊습니다.
그래서 DoH로 IP를 멀쩡히 알아내도, 그 사이트로의 HTTPS 연결이 SNI 단계에서 막혀 "DNS는 되는데 접속은 안 되는" 상태가 됩니다.
FreeGSM의 우회 — ClientHello를 두 TLS 레코드로 쪼갭니다.
핵심은 TLS 레코드 계층 분할입니다. 하나의 ClientHello 레코드를 두 개의 유효한 TLS 레코드로 다시 내보냅니다(하나의 핸드셰이크 메시지가 여러 레코드에 걸쳐도 명세상 정상입니다).
- 분할 지점을 SNI보다 앞쪽 몇 바이트에 둬서, 첫 레코드 안에는 호스트 이름이
들어가지 않게 합니다. SNI를 "첫 레코드 하나에서" 찾는 차단 장비는 빈손이 되고,
반면 진짜 서버는 명세대로 레코드를 재조립해 정상적으로 핸드셰이크를 끝냅니다.
(
SPLIT_MIN/SPLIT_MAX로 첫 레코드 크기를 무작위화)
왜 패킷을 직접 수정하지 않고 로컬 릴레이를 쓰는가: 레코드를 쪼개려면 두 번째 5바이트 레코드 헤더를 새로 끼워 넣어야 합니다. 그런데 WinDivert가 보는 원시 패킷 스트림에 바이트를 삽입하면 클라이언트 커널의 TCP 시퀀스 번호가 어긋나, 서버가 "보낸 적 없는 바이트"를 ACK하는 순간 커널이 RST를 쏩니다(README 상단의 "바이트를 넣거나 빼지 말 것" 불변식). 그래서 양쪽 소켓을 모두 소유한 사용자 영역 프로세스에서 연결을 종단(terminate)하고 다시 프레이밍합니다.
- 나가는
:443연결을, 목적지를자기자신:릴레이포트로 바꿔 INBOUND로 재주입해 로컬 릴레이로 돌립니다(TCP/53과 같은 리다이렉트 기법). 원래 목적지는(출발지 IP, 출발지 포트)로 기억해 둡니다. - 릴레이가 ClientHello를 받아
dpi.split_hello로 두 레코드로 쪼개 서버로 보낸 뒤, 그 다음부터는 단순 양방향 파이프로 중계합니다. 릴레이→서버 소켓은 캡처 필터가 제외하는 예약 포트 범위(30000~32047)를 써서 자기 트래픽을 다시 잡는 무한 루프를 피합니다.
우리가 의존하는 DoH 채널 자체(
DOH_SERVER_IP:443)는 분할 대상에서 제외됩니다 — 우회 통로를 우회하면 안 되니까요.
참고: QUIC 릴레이는 클라이언트 플로우가 idle timeout으로 종료되거나
upstream send가 실패하면 _sessions와 _conn_map을 함께 정리합니다.
그래서 프로세스가 살아 있는 동안 맵이 계속 커지지 않습니다.
브라우저는 HTTPS를 항상 TCP로만 쓰지 않습니다. HTTP/3이 켜져 있으면 같은 443 포트라도 UDP 기반 QUIC으로 접속을 시도할 수 있습니다. 이 트래픽도 그대로 두면 TCP/443 릴레이 경로를 지나지 않으므로, 환경에 따라 "TCP 사이트는 되는데 HTTP/3 경로만 따로 걸리는" 상황이 생길 수 있습니다.
FreeGSM의 처리 — UDP/443을 로컬 릴레이로 투명하게 돌립니다.
- 나가는 UDP/443 패킷의 원래 목적지를 기억하고, 목적지를
자기자신:53445로 바꿔 로컬 QUIC 릴레이가 받게 합니다. - 릴레이는 원래 서버로 새 UDP 소켓을 열어 페이로드를 그대로 전달합니다. 이때
릴레이가 서버로 보내는 소켓의 출발지 포트는 캡처 필터에서 제외한 예약 범위
(
30000~32047)를 사용해 자기 자신이 보낸 패킷을 다시 잡는 루프를 피합니다. - 서버에서 돌아온 응답은 다시 원래 서버의
:443에서 온 것처럼 출발지를 고쳐 클라이언트로 돌려보냅니다. 세션은 일정 시간 통신이 없으면 닫습니다.
다만 현재 QUIC 릴레이는 QUIC 페이로드를 조각내거나 변형하지 않습니다. 즉, 네트워크 장비가 QUIC Initial 패킷 안의 SNI/ALPN 정보를 직접 읽어 차단하는 환경에서는 이 경로만으로는 우회가 부족할 수 있습니다. 그런 경우 브라우저에서 HTTP/3을 끄면 TCP/443으로 폴백되고, 위의 TLS 레코드 분할 우회가 적용됩니다.
- 443 릴레이는 IPv4 TCP/443·UDP/443 트래픽을 파이썬 사용자 영역 파이프로 중계합니다. 일반적인
웹 브라우징엔 충분하지만 대용량 다운로드는 느릴 수 있습니다(
FREEGSM_DPI=0으로 끌 수 있음). - QUIC / HTTP-3(UDP/443)은 페이로드를 그대로 중계합니다. 네트워크 장비가 QUIC Initial 패킷에서 SNI를 읽어 차단하는 환경에서는 여전히 막힐 수 있습니다. 그런 경우엔 브라우저에서 HTTP/3을 꺼서 TCP로 폴백시키세요(TCP는 TLS 레코드 분할로 우회됩니다).
- SNI 차단 장비가 분할된 SNI 레코드를 조합한다면 차단을 우회할 수 없습니다. 이를 해결하기 위한 노력으로는 ESNI가 있습니다.