PoC: Sultry-- TLS Proxy with SNI Concealment
partially inspired by https://github.com/net4people/bbs/issues/412 , I wanted to share a little proof of concept I've been tinkering recently with called "Sultry"(Or "Kiki" by one tester inside GFW). It's my attempt at a different approach to SNI-based censorship that I'm hoping might spark some interesting discussions or collaborations.
The basic idea is pretty simple: instead of routing all traffic through a proxy (which creates obvious traffic patterns), I'm only hiding the SNI part of the handshake. After that, traffic flows directly between the client and target server IP, which I'm hoping makes it harder to detect as proxy traffic.
flowchart LR
subgraph Clients
C[Client\nBrowser/curl]
end
subgraph SultryProxySystem
CC[Client Component\nclient.go]
SC[Server Component\nserver.go]
end
subgraph Network
NW[Network\nCensor]
FW[Firewall/\nDPI]
end
subgraph Targets
TS[Target Server\nWebsite]
DNS[DNS Servers]
end
C <-->|HTTP/HTTPS Proxy Protocol| CC
CC -->|OOB: SNI Hostname| SC
SC -->|DNS Resolution| DNS
DNS -->|IP Address| SC
SC -->|Return Target IP| CC
CC -.->|Direct TLS Connection| NW
NW --- FW
FW <-->|TCP/TLS| TS
style CC fill:#f9f,stroke:#333,stroke-width:1px
style SC fill:#f9f,stroke:#333,stroke-width:1px
style FW fill:#ff9,stroke:#333,stroke-width:1px
style DNS fill:#9cf,stroke:#333,stroke-width:1px
It's absolutely just a PoC at this stage - more of a "what if we tried this?" experiment than anything production-ready. I'm genuinely curious if this approach has any merit or if I'm missing something obvious that would make it easy to detect.
The code's up at https://github.com/immartian/sultry if any Go programmers want to take a peek. I'd be grateful for any scrutiny from folks who really understand censorship tactics - how would you try to detect or block this? Are there fundamental flaws I'm not seeing? I'm all ears to all feedback.
The basic idea is pretty simple: instead of routing all traffic through a proxy (which creates obvious traffic patterns), I'm only hiding the SNI part of the handshake. After that, traffic flows directly between the client and target server IP, which I'm hoping makes it harder to detect as proxy traffic.
This is great,nice job, i just saw in ur readme that it allows tls1.2 traffic as well,this works good in tls 1.3 but in tls 1.2 certificate will be sent in plain text,so u either have to block tls1.2 entirely or somehow fake the certificate as well(see how xtls vision handles it,if client uses 1.3 it will be almost pure io copy but for older tls vision will use normal tls tunnel) or else it will be chaos
Hi there! I find this idea very interesting, but I am having difficulty fully understanding the proposed implementation. In the diagram you provided, it appears that the Server Component performs a DNS query to resolve the IP address based on the Server Name Indication (SNI), yet it does not forward the SNI itself. Consequently, does this imply that the client establishes a direct connection to the server without including the SNI in the ClientHello message? If this is the case, would this approach be limited to websites or content delivery networks (CDNs) that already support domain fronting? Are you able to access websites that actually rely on the SNI for virtual hosting?
I find your idea fascinating because if you had access to a host capable of sending spoofed IP packets, it might be possible to forward the ClientHello with SNI itself as a packet with a spoofed source IP from outside the firewall. However, finding a server that permits IP spoofing is quite challenging, as the so-called "IPHM" (IP header manipulation) hosting space is filled with scammers.
Fortunately, there's an alternative. Numerous hosts on the internet will forward any packet encapsulated in IP-in-IP, GRE, and GUE protocols, with millions of them operating on networks that support IP spoofing. Your idea sparked my curiosity about the potential to create a parasitic anonymity network, where the "server component" outside the firewall could forward a ClientHello encapsulated in IPIP or GRE to one of these hosts.
If you're interested, you can check out my repository, "ipeeyoupeewepee," which implements this type of IP spoofing.
五年前 XTLS 推出后我也想过要不要只 hide sni,没实践是因为要另开一条连接去传递真正的 SNI,且 server response 形态各异容易露馅,TLSv1.2 更是不适用,不过现在 ECH 开始推广了,填入 ECH 的 SNI 说不定有希望,优点是真实客户端指纹、无 TLS in TLS 握手
说起来最近 uTLS 疏于维护,我想了另一个东西,就是 REALITY 不是能 clone 服务端 TLS 指纹吗,我想让它也 clone 客户端 TLS 指纹
你说的那个 IP-in-IP 是否是把真实 IP 藏到 Client Hello 的字段里?~~我也想过这个魔法但一时没找到链接~~,应该是在 channel 解释过运营商出个公钥什么的,没实践是因为这需要运营商的配合,但这一点几乎无法做到,还有我感觉它不太适合 TCP,但 QUIC 可以
Five years ago, after the release of XTLS, I also considered whether to just hide SNI. I didn't do it because I would have to open a separate connection to pass the real SNI, and the server response formats are different, which would make it easy to expose the secret. TLSv1.2 is even less suitable. However, now that ECH is starting to be promoted, there may be hope in filling in the ECH SNI. The advantages are a real client fingerprint and no TLS in TLS handshake.
Speaking of which, uTLS has been neglected recently. I thought of another thing, that is, can REALITY clone the server TLS fingerprint? I want it to clone the client TLS fingerprint as well.
Is the IP-in-IP you mentioned hiding the real IP in the Client Hello field? I've thought about this magic trick too, but I couldn't find the link for the time being. It should be explained in the channel that the operator issues a public key or something. The reason it hasn't been put into practice is that it requires the cooperation of the operator, but this is almost impossible to achieve. I also feel that it is not very suitable for TCP, but QUIC can.
This is great,nice job, i just saw in ur readme that it allows tls1.2 traffic as well,this works good in tls 1.3 but in tls 1.2 certificate will be sent in plain text,so u either have to block tls1.2 entirely or somehow fake the certificate as well(see how xtls vision handles it,if client uses 1.3 it will be almost pure io copy but for older tls vision will use normal tls tunnel) or else it will be chaos
I'm thinking of enforcing TLS 1.3 for connections that need SNI concealment. For now, I'll probably add a config option to enforce TLS 1.3 when SNI concealment is active, with a clear warning about the TLS 1.2 certificate issue in the docs.
I find your idea fascinating because if you had access to a host capable of sending spoofed IP packets, it might be possible to forward the ClientHello with SNI itself as a packet with a spoofed source IP from outside the firewall. However, finding a server that permits IP spoofing is quite challenging, as the so-called "IPHM" (IP header manipulation) hosting space is filled with scammers.
To clarify how this works: the client still sends the SNI in the ClientHello to the target server - we don't strip it out. What we're hiding is the SNI from network censors. This means virtual hosting works correctly because the server still gets the SNI information it needs. It's just that censors monitoring the network can't use SNI filtering because we're connecting directly to IPs rather than relying on SNI-based filtering rules.
This is actually different from domain fronting - Sultry is not replacing the real SNI with a fake one, just bypassing the censors' ability to see the SNI before connection by pre-resolving the domain through our OOB channel. But it's still open to the idea of "fooling" the DPI that there's a normal SNI request with a cover SNI(next step).
Fortunately, there's an alternative. Numerous hosts on the internet will forward any packet encapsulated in IP-in-IP, GRE, and GUE protocols, with millions of them operating on networks that support IP spoofing. Your idea sparked my curiosity about the potential to create a parasitic anonymity network, where the "server component" outside the firewall could forward a ClientHello encapsulated in IPIP or GRE to one of these hosts.
If you're interested, you can check out my repository, "ipeeyoupeewepee," which implements this type of IP spoofing.
I will check it out. Thanks.
五年前 XTLS 推出后我也想过要不要只 hide sni,没实践是因为要另开一条连接去传递真正的 SNI,且 server response 形态各异容易露馅,TLSv1.2 更是不适用,不过现在 ECH 开始推广了,填入 ECH 的 SNI 说不定有希望,优点是真实客户端指纹、无 TLS in TLS 握手
you're right that TLS 1.2 is problematic for this approach because of certificate plaintext. I'm leaning toward enforcing TLS 1.3 for connections that use SNI concealment, as it provides much cleaner protection.
现在是五年后了,TLSv1.3 比当时更加普及,可能直接禁掉 TLSv1.2 也不会有太大影响
即使是 TLSv1.3,SNI 确定时服务器发的 Server Hello 和后续握手消息的长度基本不变,所以用不同的 SNI、表现为 SNI 分流更好
用 ECH 的 SNI 不知道是否能解决问题,因为即使是 ECH,处理 TLS 握手的基本上还是同一个服务端
~~更深入的问题就是如果你这个 SNI 被 GFW 改了怎么办?正常的 TLS 会被服务端弹 alert,所以“另一条连接”还需要发 hash 以供验证~~ 似乎不是必要的
还有主动探测什么的,总之实践起来问题挺复杂的,或许把内层 TLS hello 作为 ECH 数据藏进外层 TLS hello 的 extension 是个更优解
It's now five years later, and TLSv1.3 is even more popular than it was back then. It's probably not going to have a big impact if you just disable TLSv1.2.
Even with TLSv1.3, the length of the Server Hello and subsequent handshake messages sent by the server when SNI is determined remains basically the same. So it's better to use different SNIs and perform SNI splitting.
Using ECH SNI is not sure if it can solve the problem, because even with ECH, the server that handles the TLS handshake is basically the same.
~~The more in-depth question is what if your SNI is changed by the GFW? Normal TLS will be alerted by the server, so the “other connection” also needs to send a hash for verification~~ It seems unnecessary
There is also active probing and so on. In short, the problem is quite complicated in practice. Perhaps hiding the inner TLS hello as ECH data in the extension of the outer TLS hello is a better solution.
或许把内层 TLS hello 作为 ECH 数据藏进外层 TLS hello 的 extension 是个更优解
补充一下,这样的话要给浏览器本身关掉 ECH extension 不然数据量异常大,而更多 APP 是关不掉的,所以可能不能广泛适用
还有服务端会表现为每次都先发一些数据,可能会有点怪,~~还是 Switch 或完全多路复用吧~~
Perhaps hiding the inner TLS hello as ECH data in the extension of the outer TLS hello is a better solution
To add, in this case, the browser itself needs to turn off the ECH extension, otherwise the amount of data will be unusually large, and many apps cannot be turned off, so it may not be widely applicable.
Also, the server will appear to send some data first each time, which may be a bit strange. ~~It's better to use Switch or complete multiplexing.~~
Sounds interesting! My Go is very poor and I'm not super familiar with proxies so apologies if these are dumb questions:
-
In Step 8 "Forward ClientHello", is it forwarding the original ClientHello that includes an SNI or a modified version with the SNI removed? If it is the original why is it not blocked by the censor? If it is a modified version, why does that not trigger a protocol error in the client?
-
Is the direct TLS connection established using a raw IP, ESNI, or the "cover_sni"? It seems like all three options would be a recognizable fingerprint. Or is the idea to eventually use such a wide variety of cover SNIs that the distribution is indistinguishable from normal traffic? Would the cover SNI lists then have to be tailored to different environments?
-
DNS resolution is done outside of GFW, right? Are there any issues where the SNI resolves to an IP intended to serve clients in the resolver's location, and refuses to serve the connection coming from within GFW?
~~If I read it right, you just strip or replace the Server Name Indication from Client Hello? It will require an MITM and custom CA trusted by applications like the web browser.~~
Yes, it's confirmed to work well, at least in China.
It's already been implemented by many:
- https://github.com/net4people/bbs/issues/454#issuecomment-2680207378
The OOB DNS querying is not necessary sometimes, they have a built-in hosts (domain to IP mapping) configuration.
The only challenges are IP blocking and the compatibility of target web servers.
the client still sends the SNI in the ClientHello to the target server - we don't strip it out. What we're hiding is the SNI from network censors. This means virtual hosting works correctly because the server still gets the SNI information it needs. It's just that censors monitoring the network can't use SNI filtering because we're connecting directly to IPs rather than relying on SNI-based filtering rules.
This is actually different from domain fronting - Sultry is not replacing the real SNI with a fake one, just bypassing the censors' ability to see the SNI before connection by pre-resolving the domain through our OOB channel. But it's still open to the idea of "fooling" the DPI that there's a normal SNI request with a cover SNI(next step).
I don't think the censor only starts to monitor when seeing a DNS query; it's just part of it. It also starts to monitor when seeing a TCP SYN. DNS injector and TCP injector work independently. Hiding the blocked.com from DNS querying is not enough.
The 'directly to IPs' or 'direct TLS connection' won't immune exposing blocked.com to censors as long as it had a SNI extension containing blocked.com.
And about the next step, see my last message.
In our recent exploration (at branch https://github.com/immartian/sultry/tree/feature/full-clienthello-concealment) , we've switched to full OOB relay for application data, meaning that after the TLS handshake is completed via OOB relay, we establish a direct connection to the target for data transfer. This gives us both censorship resistance (during handshake) and performance (during data transfer). The direct connection uses the actual IP address of the target, which we obtain through remote DNS resolution happening outside censored networks.
- In Step 8 "Forward ClientHello", is it forwarding the original ClientHello that includes an SNI or a modified version with the SNI removed? If it is the original why is it not blocked by the censor? If it is a modified version, why does that not trigger a protocol error in the client?
The full ClientHello concealment works differently than just SNI concealment. We're not modifying the ClientHello at all - we relay the entire original ClientHello (with SNI intact) through our OOB channel to a remote peer outside censored networks.
I don't think the censor only starts to monitor when seeing a DNS query; it's just part of it. It also starts to monitor when seeing a TCP SYN. DNS injector and TCP injector work independently. Hiding the blocked.com from DNS querying is not enough.
You're right - we believe censors don't just watch DNS queries. They also monitor TCP connections and inspect TLS handshakes for SNI. This approach aims to handles this by sending the entire ClientHello (with the SNI) through our encrypted OOB channel, completely bypassing the censor's visibility. The censor never sees the SNI at all, even when the TCP connection is established. After that, censor may care about the traffic, but it may not easy to tell from others.