Opt-In Software Blog

Software, dvelopment, and practical insights

ProxyMapService: Chrome-Like TLS Fingerprinting

Read in: English | Русский

When ProxyMapService decrypts HTTPS traffic, it works as a man-in-the-middle: the TLS connection from the browser ends at the proxy, and the proxy opens its own TLS connection to the target server. The certificate the proxy presents is generated on the fly from its own root CA — but even with a valid certificate chain, there is one thing the server sees that cannot be disguised that easily: the TLS handshake itself.

A TLS ClientHello is very characteristic of the software that sent it. The .NET SslStream offers its own set of cipher suites and extensions, in its own order, with no GREASE. To anyone who looks — anti-bot systems, CDNs, CAPTCHA services — such a connection does not look like a browser. Fingerprint formats such as JA3 and JA4 compress the ClientHello into a short hash that identifies the client software, and this hash travels with every TLS connection the proxy opens.

What changed

A new version of ProxyMapService adds the UseBoringSsl option to SslClientOptions. When it is enabled, the upstream TLS handshake is performed not by SslStream, but by BoringSslStream — a TLS stream backed by BoringSSL, the TLS library used by Chromium.

BoringSslStream replicates Google Chrome’s ClientHello byte-for-byte: the same cipher suites, the same extensions in the same order, GREASE values, post-quantum key groups, ALPN and ALPS. From the server’s point of view, the connection looks like it was made by a real Chrome browser.

The difference is easy to see in the JA4 fingerprint of the ClientHello (both values are produced by the integration test bundled with ProxyMapService, with an HTTP/1.1 client behind the proxy):

Client JA4 fingerprint
.NET SslStream t13d311100_e8f1e7e78f70_f6eeaf393d2e
BoringSslStream (Chrome) t13d1517h2_8daaf6152771_cb7bf5808d99

The first part of the fingerprint decodes as: TCP, TLS 1.3, SNI present, then the number of cipher suites, the number of extensions and the ALPN protocol. The .NET handshake offers 31 ciphers and 11 extensions and does not look like any browser. The BoringSslStream handshake offers 15 ciphers and 17 extensions — and the cipher-suite hash 8daaf6152771 is exactly the hash Google Chrome itself produces.

How to enable it

Two conditions must hold:

  • SSL decryption is enabled (DecryptSSL), so the proxy opens its own TLS connections upstream — how to set that up is covered in the post about SSL/TLS Traffic Decryption.
  • UseBoringSsl is enabled in SslClientOptions:
"SslClientOptions": {
    "EnabledSslProtocols": "Tls12,Tls13",
    "CheckCertificateRevocation": true,
    "ApplicationProtocols": "http/1.1,h2",
    "UseBoringSsl": true
}

Restart the service for the change to take effect. The option affects only the outgoing (upstream) connections of decrypted traffic; it does not change how the proxy accepts client connections.

What stays the same

  • ALPN. The negotiated application protocol still follows SslClientOptions.ApplicationProtocols, so HTTP/2 to the server keeps working exactly as described in the post about HTTP/1.1 → HTTP/2 Translation.
  • Certificate validation. CheckCertificateRevocation, the TrustedCAs list and the IgnoreCertificateErrors mapping option remain fully effective with BoringSslStream.
  • No extra dependencies. The native BoringSSL binary ships inside the NuGet package for Windows x64, Linux x64 and macOS ARM64; nothing needs to be installed separately.

One setting intentionally stops playing a role: EnabledSslProtocols does not apply to BoringSslStream, because the TLS versions and cipher suites are chosen the same way Chrome chooses them — that is the whole point.

Quieter logging by default

In the same version, the Event Log became quieter. Many Information-level messages that used to be written for every connection are now disabled by default, so the log contains mostly what matters: requests, responses, warnings and errors. The verbose messages did not go anywhere — they are enabled in the DetailedLogging section:

"DetailedLogging": {
    "LogStep": false,
    "Http2Logging": false,
    "LogConnections": false,
    "LogTlsHandshake": false,
    "LogProtocolSwitch": false,
    "LogResponseSource": false
}
Setting Messages it enables
LogConnections Connection lifecycle: client connected, connected to server, disconnected. Warnings (connection failed, etc.) are always logged.
LogTlsHandshake Successful TLS handshakes. Failed handshakes are warnings and are always logged.
LogProtocolSwitch Protocol negotiation: h2 detection, HTTP/1.1 downgrade for host override and cache, HTTP/1.1 → HTTP/2 translation.
LogResponseSource Where a response came from: the server, the cache, or a file served by the proxy.
Http2Logging HTTP/2 diagnostics: aborted streams (RST_STREAM) and connection shutdown (GOAWAY) with error codes.
LogStep Session processing steps: one line per handler step in the state machine.

When you are diagnosing a connection problem, LogConnections and LogTlsHandshake are usually the first two to turn on; LogStep gives the full picture of how a session moved through the handlers.

Verifying the fingerprint

The repository includes an integration test that captures the TLS ClientHello the proxy sends upstream and computes its JA4 fingerprint. Start the test proxy instance (start_proxymapservice_test.sh / .ps1) and run:

dotnet test ProxyMapService.Tests --filter "FullyQualifiedName~Ja4Fingerprint" -l "console;verbosity=detailed"

The test prints the JA4 fingerprint together with the full list of offered cipher suites and extensions, and asserts that with UseBoringSsl: true the cipher-suite hash matches Chrome’s 8daaf6152771 — and that with the option disabled it does not.

Search