Sorun
Bazı ağlarda DPI cihazları var. TLS bağlantısı kurulurken ClientHello paketinin içindeki SNI alanını okuyorlar. Engelli bir siteye bağlanmaya çalışıyorsan bağlantıyı kesiyorlar. Sayfa açılmıyor, tarayıcı dönüp duruyor.
DNS tarafı da sorunlu. DNS sunucusu sahte IP döndürüyor, seni engelleme sayfasına yönlendiriyor. İki problemi birden çözmek lazım.
VPN ile çözülür tabii ama VPN tüm trafiği uzak sunucudan geçirir. Bu sorun için fazla. Proxy de var ama uygulama bazlı çalışıyor, Discord gibi uygulamalar proxy ayarlarını takmıyor. Ben sistem seviyesinde, şeffaf, tek komutla çalışan bir şey istiyordum.
sudo gecit run
Fikir
Gerçek ClientHello DPI’a ulaşmadan önce sahte bir tane gönder.
Sahte paketin SNI’ı www.google.com, TTL’i düşük. TTL yeterince yüksek ki DPI cihazına ulaşsın ama yeterince düşük ki sunucuya ulaşamasın. DPI sahte paketi görüyor, “google.com” kaydediyor, bağlantıya izin veriyor. Sunucu sahte paketi hiç görmüyor çünkü yolda ölüyor.
Arkasından gerçek ClientHello geçiyor. DPI zaten kararını vermiş. İşlem tamam.
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 de eBPF programı TCP MSS’i 40 byte’a düşürüyor. Kernel gerçek ClientHello’yu küçük parçalara bölüyor. Bazı DPI’lar sadece ilk segmenti inceliyor, SNI birden fazla segmente yayılınca okuyamıyorlar.
DNS için gecit 127.0.0.1:53’te yerel DoH sunucusu çalıştırıyor. Sorgular şifreli HTTPS üzerinden gidiyor.
Linux: eBPF sock_ops
Burayı anlatmak istiyordum aslında.
Sahte paketin uygulama veri göndermeden önce çıkması lazım. Sonra değil, eş zamanlı değil. Önce. Gerçek ClientHello sahteden önce DPI’a ulaşırsa her şey boşa gider.
eBPF sock_ops tam bunu sağlıyor. BPF programını cgroup’a bağlıyorsun. 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ü handshake tamamlandığında tetikleniyor. Bağlantı kurulmuş, uygulama henüz veri göndermemiş. Tam istediğim 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 bağlantı kurulduğunda handle_established iki iş 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;
}
Birincisi bpf_setsockopt ile TCP_MAXSEG’i 40 byte’a çekiyor. Kernel içinde, bağlantı bazında. Uygulama habersiz. ClientHello gönderildiğinde kernel onu küçük segmentlere bölüyor.
İkincisi perf event fırlatıyor. İçinde kaynak/hedef IP, port ve TCP seq/ack numaraları var. seq/ack önemli. Sahte paketin gerçek bağlantıyla birebir aynı değerleri taşıması şart. Yoksa DPI paketi yok sayar.
Go tarafında bir goroutine bu event’leri okuyup sahte paketi gönderiyor:
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 minimal bir TLS ClientHello, SNI’ı www.google.com. Raw socket üzerinden, gerçek bağlantıyla aynı 5-tuple ve eBPF’ten gelen seq/ack değerleriyle gönderiliyor. Tek fark TTL: 64 yerine 8.
Kernel’de kalan kısım: bağlantı tespiti ve MSS ayarı. Userspace’e çıkan kısım: sahte paket oluşturma ve gönderme. Sadece handshake sırasında userspace devreye giriyor. Sonrasında veri kernel’de tam hızda akıyor. gecit veri aktarımına sıfır yük ekliyor.
macOS: eBPF yok, şimdi ne olacak?
macOS’ta eBPF yok. Kernel hook yok. Apple kernel extension’ları kaldırdı, Network Extensions’a yönlendirdi. O da developer hesabı, entitlement, App Store istiyor. İndir ve çalıştır diyemezsin.
İlk denediğim HTTP CONNECT proxy’ydi. Sistem proxy’sini ayarlıyorsun, CONNECT isteklerini yakalıyorsun, sahte paketi enjekte ediyorsun. Tarayıcılarda çalıştı. Sonra biri Discord’la denedi. Discord proxy ayarlarını takmıyormuş. Birçok uygulama takmıyor.
TUN’a geçtim. Sanal ağ arayüzü. Trafiği oraya yönlendiriyorsun, programın ham IP paketlerini okuyor. Tüm trafik oradan geçiyor, hiçbir uygulama atlayamıyor.
sing-tun ve gVisor’un userspace TCP stack’ini kullanıyorum. Port 443’e gelen TCP bağlantısını gVisor sonlandırıyor. gecit sunucuya gerçek bağlantı açıyor, ClientHello’yu okuyor, sahte enjekte ediyor, 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 yakalanıyor. Ama bedeli var. Tüm trafik userspace’ten geçiyor. Her paket kernel-kullanıcı sınırını iki kez aşıyor. Linux’ta sadece handshake çıkıyordu userspace’e. macOS’ta her şey çıkıyor. VPN ile aynı overhead, uzak sunucu olmadan.
seq/ack çıkarmak da ayrı bir mesele. Linux’ta eBPF snd_nxt ve rcv_nxt‘i doğrudan okuyor. macOS’ta böyle bir API yok. Fiziksel NIC üzerinde pcap ile SYN-ACK yakalayıp sequence numaralarını oradan çıkarıyorum.
Windows: aynı mantık, farklı dertler
Windows da TUN + gVisor kullanıyor. Aynı mimari, farklı sorunlar.
Raw socket meselesi: Windows Vista’dan beri TCP raw socket’e izin vermiyor. 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 kendin oluşturuyorsun:
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’te. Kernel Ethernet header’ı, ARP, IP checksum hepsini hallediyor. Windows’ta layer 2’desin. Her şey 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 araç ama kod imzalama sertifikası 2023’te bitmiş. Defender onu işaretliyor, bazı sistemler driver’ı yüklemeyi reddediyor. gecit WireGuard’ın WinTUN’unu kullanıyor. Düzgün imzalı, aktif bakımlı.
Npcap da ayrı bir konu. OEM lisansı olmadan dağıtılamıyor. Kullanıcı npcap.com‘dan ayrıca kuruyor. “İndir ve çalıştır” için ideal değil ama Windows’ta başka seçenek yok.
eBPF farkı
Aynı şeyi üç platformda yapmak farkı net gösteriyor.
Linux (eBPF): Kernel’in TCP stack’ine senkron bağlanıyor. Tam doğru anda tetikleniyor. MSS ayarı kernel’de oluyor. seq/ack doğrudan erişilebilir. Sadece sahte paket gönderimi userspace’e çıkıyor. Veri aktarımında 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, DNS için network service tespiti. Tüm trafik userspace’ten geçiyor.
Windows (TUN + Npcap): macOS’taki her şey, artı Ethernet frame oluşturma, ARP tablosundan gateway MAC bulma, IP checksum hesaplama, Npcap bağımlılığı, Defender false positive.
Karmaşıklık farkı artan bir şey değil. Kategorik. eBPF kernel’in TCP stack’inde tam doğru noktaya müdahale etmeni sağlıyor. Etrafında altyapı kurmana gerek kalmıyor. Diğer platformlarda kısa bir BPF programının yaptığını başarmak için küçük bir VPN inşa ediyorsun.
TUN’un bir avantajı var: tüm trafiği IP katmanında yakalıyor. Proxy ayarlarını atlayan uygulamalar dahil. Linux’ta eBPF sock_ops cgroup’a bağlı, o cgroup’taki her process ağ yapılandırmasından bağımsız olarak kapsanıyor.
Takılan noktalar
Farklı ağlarda DPI farklı davranıyor. Bazıları RST enjekte ediyor, bazıları paket drop ediyor. TTL’in DPI’a ulaşacak kadar yüksek ama sunucuya ulaşamayacak kadar düşük olması lazım. Varsayılan 8. traceroute ile doğru değeri bulabilirsin.
DPI sahte pakette doğru seq/ack istiyor. Placeholder değerler reddediliyor. pcap SYN-ACK’ı yakalayamazsa sahte paket placeholder’larla gider ve DPI onu yok sayar.
DoH’ta tavuk-yumurta sorunu var. gecit DNS’i kendi sunucusuna yönlendiriyor ama DoH istemcisi upstream’in hostname’ini çözmek zorunda. Upstream domain bazlıysa, gecit DNS’i devralmadan önce çözülmesi lazım. gecit başlarken tüm upstream hostname’lerini çö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’tan tespit ediyor.
Linux’ta Flatpak uygulamaları kendi sandbox’larında çalışıyor, kendi DNS çözümlemeleri var. gecit /etc/resolv.conf’u değiştiriyor ama Flatpak bunu görmüyor. DPI bypass yine çalışıyor, eBPF kernel’de, her sandbox’ın altında. Ama DNS bypass çalışmıyor. Flatpak için DNS’i elle ayarlamak gerekiyor. Kernel hook’larının başarılı olup userspace değişikliklerinin yetersiz kaldığı güzel bir örnek.
Bağlantılar
gecit GitHub’da. GPL-3.0. Linux, macOS ve Windows.
sudo gecit run
Tek bir şey yapıyor. IP adresini gizlemiyor, trafiği şifrelemiyor, anonimlik sağlamıyor. DPI’ın TLS handshake’teki SNI’ı okumasını engelliyor. O kadar.