C2 Development from Scratch · Part 3
Borrowing from Internet Censorship Bypass Techniques: Adding VLESS, REALITY, and uTLS to C2 Egress
Finding C2 Inspiration in Anti-Censorship
I originally wasn’t looking into my C2 development since I’ve been taking a break from it to mainly focus on my CPTS course. At first I was watching a YouTube video purely for entertainment about OPSEC/PERSEC (Operational Security/Personal Security) and the topic of evading internet censorship popped up. When it comes to internet censorship there are very few comparisons to the Great Firewall, it is objectively one of the most advanced forms of internet censorship. As the video progressed it talked about the specific evasion techniques currently used for this level of censorship, and the technique that interested me the most is VLESS + REALITY + uTLS. For whatever reason the first thought that came to my head is “why can’t I use this to hide my C2 communication?” Turns out there’s nothing really stopping me, there are some cons and barriers which I will talk about later. At some point I found myself reading research papers about this entire topic, safe to say I was in the rabbit hole (which is where I learn best).
Looking into Layer 4 Evasion
The current evasion technique tackles a layer 7 HTTP (Application layer) problem. As an overview explanation, by default, Go’s standard net/http package transmits default headers that are an immediate dead giveaway on the wire (such as User-Agent: Go-http-client/1.1). To any network monitor, firewall, or Web Application Firewall (WAF), this immediately announces that the traffic is coming from a compiled Go executable rather than a user browsing the web. On top of that, modern browsers send complex secondary metadata (Sec-Ch-Ua, Sec-Fetch-*) that can trigger anomaly alerts if omitted or improperly structured. With this being said, my agent has a mitigation for this specific case, it will intercept every single HTTP request leaving the application right before transmission. The default Go headers are stripped away, and tailored browser headers are injected automatically in one centralized place. It will also mimic “Client Hints” sent with a chrome browser as well to improve overall spoofing. This is the basic overview explanation of the currently used evasion by my C2 agent.
However, there is a blind spot here and it is in layer 4, I am specifically talking about the TCP three-way handshake which once finished the TLS 1.3 ClientHello is exchanged in the clear. Go’s crypto/tls has its distinct signature, specifically cipher suite ordering, extension lists, supported curves, and an absence of GREASE (Generated Random Extensions And Sustain Extensibility) values in specific locations. If a network defense system sees a ClientHello that fingerprints as a standard Go executable, which also claims to be chrome in its headers, that discrepancy is flagged.
┌────────────────────────────────────────────────────────┐
│ Layer 7 (HTTP): Spoofed Browser Identity │
│ User-Agent: Chrome/147.0.7727.55 | Sec-Fetch-Mode: cors│
└───────────────────────────▲────────────────────────────┘
│ Mismatch Flags Anomaly!
┌───────────────────────────▼────────────────────────────┐
│ Layer 4 (TLS ClientHello): Default Go Runtime │
│ Order: Go Ciphers | No GREASE | JA4: t13d... (Go Lang) │
└────────────────────────────────────────────────────────┘
Past the spoofing inconsistency, if an Intrusion Detection and Prevention System (IDPS), threat hunter, or scanners like Censys or shodan attempts active probing the server will provide a self signed certificate which is immediately logged as anomalous. For example, Censys indexes every single certificate presented on port 443 across the entire IPv4 space, this makes it a lot easier to cross reference the specific C2 server IP with a self-signed certificate.
Even before the HTTP request is sent, a prober might record the JARM fingerprint of the listener (the C2 server in this case). JARM works by actively sending 10 TLS ClientHello packets to a target TLS server and capturing specific attributes of the TLS Server Hello responses. Since the C2 server uses python’s ssl module configuration, which negotiates ciphers and extensions in a way that is different from a real enterprise Nginx deployment (which is what my spoofing at the header level says is running).
Now suppose an EDR on a monitored endpoint alerts on suspicious network activity. It will report to the SOC that there is a connection being made to a specific IP (e.g. 203.0.110.6). The SOC could curl the IP, they will see no legitimate landing page, the certificate doesn’t match anything recognized and is self-signed, and any direct requests return 404 errors. This connection will immediately be severed and the endpoint will be locked down.
The Solution to Advanced Fingerprinting and Active Probing
This is where VLESS + REALITY + uTLS come into place, this stack working together will theoretically tackle every problem I listed above. Here is how each part of the stack tackle the problems:
uTLS: As we know the TLS signature is a massive problem since no matter how good the headers spoofing is, the underlying socket is driven by Go’s standard crypto/tls. This means the ClientHello will be a dead-giveaway of a Go runtime JA3/Ja4 signature. This is where uTLS comes in, it is a fork of Go’s crypto/tls engineered to provide low-level control over the ClientHello handshake. Instead of using Go’s to assemble hardcoded cipher lists and extension sequences, uTLS gives pre-configured browser signatures. It mimics the exact extension ordering, elliptic curve preferences, padding lengths, and Chromium GREASE values.
REALITY: REALITY targets the server, traditional C2 listeners rely on domain-validation certificates like Let’s Encrypt or self-signed certs. If an active scanner like Shodan, Censys, or a network analyst connects directly to your VPS IP on port 443, the listener either presents a custom certificate or abruptly resets the connection, which can lead to the infrastructure being flagged pretty quickly. REALITY solves this by being pointed to a legitimate, highly trusted domain that supports TLS 1.3. When the agent connects, it submits an embedded (specifically in the SessionID) ephemeral X25519 public key and an HMAC tag, encrypted with a pre-shared server key. The REALITY listener extracts the auth token, verifies it using its private key, completes the TLS tunnel directly with the agent, and routes the traffic to the C2 backend. If a scanner connects, the auth token will either be missing or invalid, so instead of dropping the packet or returning the self-signed cert, REALITY acts as a proxy and relays the scanner’s handshake directly to the borrowed legitimate IP. The scanner will then complete a full, valid TLS handshake with Microsoft for example.
┌─────────────────────────┐
│ Incoming TLS Hello │
│ (SNI: www.microsoft.com)│
└────────────┬────────────┘
│
Valid X25519 Key?
/ \
Yes / \ No (Shodan / Censys / Blue Team)
/ \
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ Terminate to C2 │ │ Transparent TCP Relay to │
│ (Forward to Flask) │ │ real www.microsoft.com:443 │
└──────────────────────┘ └──────────────────────────────┘
VLESS: VLESS is simply a lightweight, stateless transmission protocol designed to do one simple job, it is built to identify a client and route its traffic with zero overhead. VLESS operates on a simple principle, which is basically allowing the underlying transport handle the encryption. Since the outer TLS layer (REALITY), that’s what covers confidentiality and integrity, VLESS doesn’t touch or encrypt the payload. An easy way to think about it is a shipping label, it adds a compact header containing a UUID and a destination address, telling the edge listener who is connecting and where to hand off.
Reasoning for the Exclusion of XTLS
When I was looking into VLESS + Reality, I saw a lot of mentions of XTLS (specifically XTLS Vision) mentioned alongside them. I originally had it in my plan. Honestly, after thinking about it I realized that it doesn’t make sense to add. XTLS Vision was created to solve a very specific problem being the TLS inside TLS. If this stack was being used for the purpose of VPN obfuscation, since with a VPN connection the user is sending HTTPS traffic (TLS) inside the proxy’s encrypted tunnel. Advanced inspection can identify the double TLS pattern by looking at packet length distributions. XTLS basically performs zero-copy splicing on the packet to avoid double encryption. For a C2 agent, simply disabling the HTTPS communication I have built in is enough to avoid TLS inside TLS without adding more to the binary.
Where this Stack Breaks
The key to all of this is understanding where this fails, and the mechanisms that can defeat this evasion technique. No single tool or option is a silver bullet, there should be options for the operator that they choose based on recon and enumeration not “whats most secure?” There is something very common that many enterprise networks add to their security that can break this form of evasion. That being an explicit forward proxy combined with full TLS decryption using internal CA injection. The proxy will bypass the encryption by performing a Man-in-the-Middle decryption, this relies on a trusted relationship between the company’s computer and the proxy server. The proxy terminates the TLS connection locally using an internal corporate root CA. But because REALITY relies on an X25519 key exchange directly with our listener, the proxy cannot fulfill the handshake. The connection is dropped right at the TLS layer before an HTTP request or our AES-encrypted payload can ever be sent.
Therefore, if an explicit forward proxy was to run this kind of check on our packet the communication would be flagged and cease. One thing worth noting is that these enterprise proxies do not scan all packets, since its very resource intensive, could be against privacy regulations (e.g. banking sites), or the packet comes from or to a trusted source (e.g. calendar.google.com). So we could use this to our advantage in different methods, these will be discussed in the next section.
Current Plan & What’s Next in Part 2
Keeping the Agent Compact
When people use VLESS and REALITY they usually compile the full proxy with Xray or Sing-box. The problem with dragging in those big ecosystems it drags in a bunch of other tools/protocols that will not be used by my C2 which heavily inflates the Go binary. Go is already a fairly large binary so increasing it even more would defeat the attempt to keep the agent compact and covert. So instead I will be writing a purpose built, client directly into my Go agent. I will be doing this with refraction-networking/utls, golang.org/x/crypto/curve25519, and manual byte framing for the compact VLESS header. On the VPS a headless Xray instance listening on port 443, proxying scanners to trusted domains and relaying authenticated VLESS streams locally. The VPS can hold a large instance like Xray because being compact isn’t as important as the agent.
Other Methods Against Explicit Forward Proxy
While REALITY crushes active internet scanners and transparent firewalls, highly locked-down corporate networks with explicit forward proxies and MITM decryption require different methods. One of these different methods could be:
Living Off Trusted Cloud (Saas & Webhook C2): Instead of communicating with an external VPS or custom domain, the implant routes all tasking and exfil through business-critical third-party API (e.g. Microsoft Graph API, Google Drive, Slack, or Notion). Since the proxy can terminate and inspect the TLS session without breaking anything, the upstream certificate belongs to a trusted source. It is easier to blend in with normal daily business operations, which means it’s less likely to go through the complete decryption scan.
What to Expect in Part 2
I will be creating a part 2 to this blog post diving into the actual implementation. I will go into deeper into how I built and kept the agent compact, and proof that the problems I listed are solved with these features using Wireshark captures and/or (maybe/hopefully) active probe testing.
The reason I’m splitting these up is because I’m expecting problems to pop up while actually implementing this, so that will be also covered in part 2.