Sorun
Bir siteyi açmaya çalışıyordum, sayfa bir türlü gelmiyordu. TLS handshake yarıda kalıyordu. Bir yerlerdeki DPI cihazı, ClientHello’nun içindeki SNI alanını okuyup bağlantıyı kesiyordu.
Bir de DNS tarafı var. DPI’ı atlatsan bile DNS sunucusu sahte IP döndürüp seni engelleme sayfasına yolluyor. İki tarafı da çözmek lazım.
VPN ile çözülür tabii. Ama VPN tüm trafiği uzak sunucudan geçiriyor, bu iş için fazla. Proxy de var, fakat uygulama bazlı çalışıyor; bazı uygulamalar proxy ayarlarını takmıyor bile. Benim aradığım şey sistem seviyesinde, şeffaf, tek komutla çalışan bir araçtı.
sudo gecit run
Fikir
Mantık basit. Gerçek ClientHello DPI’a varmadan önce sahte bir tane yolla.
Sahte paketin SNI’ı www.google.com, TTL’i düşük. TTL öyle ayarlı ki DPI cihazına yetişiyor ama sunucuya ulaşmadan ölüyor. DPI bakıyor, “google.com” diyor, geçişe izin veriyor. Sunucu sahte paketi hiç görmüyor, çünkü yolda kayboluyor.
Hemen ardından gerçek ClientHello geçiyor. DPI çoktan kararını vermiş. Sahte paket onu yanıltmış oldu.
Uygulama hedef:443'e bağlanır
|
gecit bağlantıyı yakalar
Linux: eBPF sock_ops tetiklenir (kernel içinde, uygulama veri göndermeden önce)
macOS: TUN cihazı paketi yakalar, gVisor netstack TCP'yi sonlandırır
|
Düşük TTL ile sahte ClientHello gönderilir (SNI: "www.google.com")
|
Sahte paket DPI'a ulaşır -> DPI "google.com" kaydeder -> bağlantıya izin verir
Sahte paket sunucuya ulaşamaz (düşük TTL) -> sunucu görmez
|
Gerçek ClientHello geçer -> DPI zaten yanıltılmış
Bir taşı daha var işin: eBPF programı TCP MSS’i 88 byte’a düşürüyor. Böylece kernel gerçek ClientHello’yu küçük parçalara bölüyor. Bazı DPI’lar yalnızca ilk segmenti inceliyor; SNI birden fazla segmente yayılınca okuyamadan kalıyorlar.
DNS tarafı için gecit 127.0.0.1:53’te kendi DoH sunucusunu çalıştırıyor. Sorgular HTTPS üzerinden, şifreli olarak gidiyor.
Linux: eBPF sock_ops
Aslında en çok bu kısmı anlatmak istiyordum.
Sahte paketin uygulama veri göndermeden önce yola çıkması şart. Sonra değil, eş zamanlı değil. Önce. Yoksa gerçek ClientHello DPI’a sahteden önce ulaşır ve her şey boşa gider.
eBPF sock_ops tam burada devreye giriyor. BPF programını cgroup’a bağlıyorsun ve kernel, TCP yaşam döngüsünün belirli noktalarında bu programı çağırıyor. BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB, giden bir TCP bağlantısında üç yönlü el sıkışma biter bitmez tetikleniyor. Bağlantı kurulmuş, uygulama henüz hiçbir şey göndermemiş. Tam aradığım an.
SEC("sockops")
int gecit_sockops(struct bpf_sock_ops *skops)
{
__u32 key = 0;
struct gecit_config_t *cfg = bpf_map_lookup_elem(&gecit_config, &key);
if (!cfg || !cfg->enabled)
return 1;
switch (skops->op) {
case BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB:
return handle_established(skops, cfg);
case BPF_SOCK_OPS_HDR_OPT_LEN_CB:
return handle_hdr_opt_len(skops);
case BPF_SOCK_OPS_WRITE_HDR_OPT_CB:
return handle_write_hdr_opt(skops, cfg);
}
return 1;
}
Port 443’e bir bağlantı kurulduğunda handle_established iki iş birden yapıyor:
static __always_inline int handle_established(struct bpf_sock_ops *skops,
struct gecit_config_t *cfg)
{
__u32 dst_ip = skops->remote_ip4;
if (bpf_map_lookup_elem(&exclude_ips, &dst_ip))
return 1;
__u16 dst_port = (__u16)bpf_ntohl(skops->remote_port);
if (!bpf_map_lookup_elem(&target_ports, &dst_port))
return 1;
// Küçük MSS ile ClientHello fragmentasyonu.
int mss = cfg->mss;
bpf_setsockopt(skops, IPPROTO_TCP, TCP_MAXSEG, &mss, sizeof(mss));
// Perf event ile userspace'e bildir.
struct conn_event evt = {};
evt.src_ip = skops->local_ip4;
evt.dst_ip = skops->remote_ip4;
evt.src_port = skops->local_port;
evt.dst_port = dst_port;
evt.seq = skops->snd_nxt;
evt.ack = skops->rcv_nxt;
bpf_perf_event_output(skops, &conn_events, BPF_F_CURRENT_CPU,
&evt, sizeof(evt));
// ... MSS geri yükleme takibi kısaltıldı
return 1;
}
Önce bpf_setsockopt ile TCP_MAXSEG’i 88 byte’a çekiyor. Bağlantı bazında, kernel içinde. Uygulamanın haberi bile yok. ClientHello gönderildiğinde kernel onu küçük segmentlere bölüyor.
Sonra perf event fırlatıyor. İçinde kaynak/hedef IP, port ve TCP seq/ack numaraları var. Bu seq/ack kritik. Sahte paketin gerçek bağlantıyla aynı değerleri taşıması gerekiyor; aksi halde DPI paketi görmezden geliyor.
Go tarafında bir goroutine bu event’leri okuyup sahte paketi yola çıkarıyor:
func (m *Manager) readEvents(ctx context.Context) {
defer m.wg.Done()
for {
record, err := m.reader.Read()
if err != nil {
select {
case <-ctx.Done():
return
default:
}
return
}
if len(record.RawSample) < 20 {
continue
}
var evt connEvent
evt.SrcIP = binary.NativeEndian.Uint32(record.RawSample[0:4])
evt.DstIP = binary.NativeEndian.Uint32(record.RawSample[4:8])
evt.SrcPort = binary.NativeEndian.Uint16(record.RawSample[8:10])
evt.DstPort = binary.NativeEndian.Uint16(record.RawSample[10:12])
evt.Seq = binary.NativeEndian.Uint32(record.RawSample[12:16])
evt.Ack = binary.NativeEndian.Uint32(record.RawSample[16:20])
m.injectFake(evt)
}
}
Sahte paket aslında minimal bir TLS ClientHello. SNI alanında www.google.com yazıyor. Raw socket üzerinden, gerçek bağlantıyla aynı 5-tuple ve eBPF’in verdiği seq/ack ile gönderiliyor. Tek farkı TTL: 64 yerine 8.
Yani kernel’de kalan kısım: bağlantı tespiti ve MSS ayarı. Userspace’e çıkan kısım: sahte paketi oluşturup yollamak. Sadece handshake sırasında userspace devreye giriyor; sonrasında veri kernel’de tam hızda akıyor. gecit asıl trafiğe sıfır yük getiriyor.
macOS: eBPF yok, peki ne yapacağız?
macOS’ta eBPF yok. sock_ops gibi programlanabilir paket hook’ları da yok. DTrace var, Endpoint Security var, MAC framework var; ama hiçbiri canlı bir TCP bağlantısına sahte paket sokmana izin vermiyor. En yakın seçenek Network Extension, o da Apple onaylı bir entitlement’ın yanında Developer ID imzası istiyor. Küçük bir açık kaynak proje için gerçekçi değil.
İlk denediğim HTTP CONNECT proxy’siydi. Sistem proxy’sini ayarlıyorsun, CONNECT isteklerini yakalayıp sahte paketi enjekte ediyorsun. Tarayıcılarda gayet güzel çalıştı. Ama bazı uygulamaların sistem proxy ayarlarını takmadığını fark ettim. Bir baktım, hatırı sayılır sayıda uygulama böyle.
TUN’a geçtim. TUN dediğin sanal bir ağ arayüzü. Trafiği oraya yönlendiriyorsun, programın da o arayüzden ham IP paketlerini okuyor. Tüm trafik buradan geçtiği için hiçbir uygulama atlatamıyor.
Altyapı olarak sing-tun ve gVisor’ün userspace TCP stack’ini kullandım. Port 443’e gelen TCP bağlantısını gVisor sonlandırıyor; gecit gerçek sunucuya kendi bağlantısını açıyor, ClientHello’yu okuyor, sahteyi enjekte ediyor, ardından gerçeği iletiyor:
func (h *handler) injectAndForward(appConn, serverConn net.Conn, dst string) {
appConn.SetReadDeadline(time.Now().Add(5 * time.Second))
clientHello := make([]byte, 16384)
n, err := appConn.Read(clientHello)
if err != nil {
return
}
clientHello = clientHello[:n]
appConn.SetReadDeadline(time.Time{})
if sni := fake.ParseSNI(clientHello); sni != "" {
dst = fmt.Sprintf("%s:%d", sni, serverConn.RemoteAddr().(*net.TCPAddr).Port)
}
seq, ack := seqtrack.GetSeqAck(serverConn)
// ... ConnInfo seq/ack ile oluşturuluyor ...
for i := 0; i < 3; i++ {
h.mgr.rawSock.SendFake(connInfo, fake.TLSClientHello, h.mgr.cfg.FakeTTL)
}
time.Sleep(2 * time.Millisecond)
serverConn.Write(clientHello)
pipe(appConn, serverConn)
}
Çalışıyor, her uygulama tuzağa düşüyor. Ama bedeli var: tüm trafik artık userspace’ten geçiyor. Her paket kernel ile kullanıcı sınırını iki kez aşıyor. Linux’ta sadece handshake çıkıyordu, şimdi her şey çıkıyor. VPN’le aynı overhead, ama uzak sunucu olmadan.
Bir de seq/ack derdi var. Linux’ta eBPF snd_nxt ve rcv_nxt‘i doğrudan veriyordu. macOS’ta öyle bir API yok. Bu yüzden fiziksel NIC üzerinden pcap ile SYN-ACK’ı yakalayıp sequence numaralarını oradan çıkarıyorum.
Windows: aynı yol, farklı dertler
Windows’ta da TUN + gVisor mantığı aynı. Mimari aynı, sorunlar farklı.
Önce raw socket meselesi. Windows Vista’dan beri TCP raw socket’e izin vermiyor. Yani Winsock ile spoofed paket gönderemiyorsun. Çözüm Npcap. pcap_sendpacket ile Ethernet frame’lerini kernel driver’ı üzerinden enjekte ediyorsun. Gateway MAC adresi dahil her şeyi sen kuruyorsun:
func (s *pcapRawSocket) SendFake(conn ConnInfo, payload []byte, ttl int) error {
ipTcp := BuildPacket(conn, payload, ttl)
frame := make([]byte, 14+len(ipTcp))
copy(frame[0:6], s.dstMAC) // gateway MAC
copy(frame[6:12], s.srcMAC) // bizim MAC
frame[12] = 0x08 // EtherType: IPv4
frame[13] = 0x00
copy(frame[14:], ipTcp)
return s.handle.WritePacketData(frame)
}
Linux ve macOS’ta raw socket layer 3’teydi. Ethernet header, ARP, IP checksum hepsini kernel hallediyordu. Windows’ta bir anda layer 2’desin. Hepsi senin sorumluluğunda. IP checksum’ı elle hesaplamayı unutunca router paketleri sessizce drop etti. Bunu zor yoldan öğrendim.
Windows’ta DPI bypass araçlarının çoğu WinDivert kullanıyor. İyi bir araç ama 2023’te kod imzalama sertifikası bitmiş. Defender da onu işaretliyor, bazı sistemler driver’ı yüklemeyi reddediyor. gecit bu yüzden WireGuard’ın WinTUN’unu kullanıyor; düzgün imzalı, aktif bakımlı.
Npcap’in de ayrı hikayesi var. OEM lisansı olmadan ürünle birlikte dağıtılamıyor. Kullanıcının npcap.com‘dan ayrıca kurması gerekiyor. “İndir ve çalıştır” deneyimi için ideal değil ama Windows’ta başka seçenek yok.
eBPF’in farkı
Aynı işi üç farklı platformda yapmak, eBPF’in ne kazandırdığını çok net gösteriyor.
Linux (eBPF): Kernel’in TCP stack’ine senkron şekilde bağlanıyorsun. Tam doğru anda tetikleniyor. MSS ayarı kernel içinde oluyor. seq/ack doğrudan elinin altında. Sadece sahte paket gönderimi userspace’e çıkıyor. Asıl trafiğe sıfır yük.
macOS (TUN): Sanal ağ arayüzü, userspace TCP stack (gVisor), routing tablosu yönetimi, pcap ile seq/ack çıkarma, mDNSResponder yönetimi, network service tespiti. Tüm trafik userspace’ten geçiyor.
Windows (TUN + Npcap): macOS’taki her şey + Ethernet frame oluşturma, ARP tablosundan gateway MAC bulma, IP checksum hesabı, Npcap bağımlılığı, Defender false positive’leri.
Aradaki fark artan değil, kategorik. eBPF kernel’in TCP stack’inde tam istediğin yere müdahale etmeni sağlıyor; etrafına altyapı kurmaya gerek bırakmıyor. Diğer platformlarda kısacık bir BPF programının yaptığını yapabilmek için adeta küçük bir VPN inşa ediyorsun.
TUN’un da bir avantajı var aslında: tüm trafiği IP katmanında yakalıyor. Proxy ayarlarını takmayan uygulamalar bile kapsanıyor. Linux’ta da eBPF sock_ops cgroup’a bağlı; o cgroup’taki her process, ağ yapılandırmasından bağımsız olarak içeride.
Yolda yaşananlar
Farklı ağlardaki DPI’lar farklı davranıyor. Bazısı RST enjekte ediyor, bazısı doğrudan paket düşürüyor. TTL’in DPI’a varacak kadar yüksek ama sunucuya yetişmeyecek kadar düşük olması lazım. Varsayılan 8. traceroute ile kendi ağına göre doğru değeri bulabilirsin.
DPI sahte pakette doğru seq/ack istiyor. Placeholder gönderirsen yutmuyor. pcap SYN-ACK’ı yakalayamazsa sahte paket placeholder’larla gidiyor ve DPI onu görmezden geliyor.
DoH’ta ufak bir tavuk-yumurta sorunu var. gecit DNS’i kendi sunucusuna yönlendiriyor, ama DoH istemcisinin upstream sunucunun hostname’ini çözmesi gerekiyor. Upstream domain bazlıysa, gecit DNS’i devralmadan önce çözülmüş olması lazım. Bu yüzden gecit başlarken tüm upstream hostname’lerini önceden çözüp IP’ye sabitliyor.
macOS’ta DNS ayarları network service bazında. USB tethering ile bağlıysan, gecit’in DNS’i Wi-Fi’da değil tethering servisinde değiştirmesi gerekiyor. Aktif servisi default route üzerinden tespit ediyor.
Linux’taki Flatpak meselesi de ilginç. Flatpak uygulamaları kendi sandbox’larında, kendi DNS çözümlemesiyle çalışıyor. gecit /etc/resolv.conf’u değiştiriyor ama Flatpak bunu görmüyor. Yine de DPI bypass çalışmaya devam ediyor; çünkü eBPF kernel’de, her sandbox’ın altında. DNS bypass ise çalışmıyor, onu Flatpak için elle ayarlamak gerekiyor. Kernel hook’larının iş gördüğü, userspace değişikliklerinin yetersiz kaldığı güzel bir örnek.
Bağlantılar
gecit GitHub’da. Lisans GPL-3.0. Linux, macOS ve Windows destekleniyor.
sudo gecit run
gecit tek bir iş yapıyor. IP adresini gizlemiyor, trafiği şifrelemiyor, anonimlik sağlamıyor. Sadece DPI’ın TLS handshake sırasında SNI’ı okumasını engelliyor. Hepsi bu.