The standard SslStream in .NET is good in every way except one: it doesn’t let you control what your ClientHello looks like. The set of ciphers, the order of extensions, GREASE, ALPS, post-quantum groups are determined by the platform (SChannel, OpenSSL) and are almost non-configurable. Usually this isn’t a problem. But if a client needs to look like Chrome in TLS fingerprints (JA3/JA4), SslStream won’t do.
Chrome has its own TLS stack, BoringSSL, Google’s fork of OpenSSL. I built a proof-of-concept BoringSslConsole: a .NET 8 console application and a BoringSslStream wrapper that exposes BoringSSL as an ordinary Stream.
Sources: https://github.com/optinsoft/BoringSslConsole
The main architectural requirement for the wrapper is this: BoringSSL must not perform any network operations. All I/O goes through the managed innerStream. Below I’ll explain why this is needed and how it’s structured.
Why a “networkless” BoringSSL
BoringSSL can work with the network itself: it has socket and file BIOs (BIO_new_socket, BIO_new_fd). If I had used them, the library would drive data through the socket itself, and there would be no way to implement asynchrony or substitute my own transport.
I needed something different: for the TLS layer to work on top of any Stream. This could be a NetworkStream, a tunnel through a proxy (CONNECT, SOCKS), a MemoryStream in tests, or another layer on top of TLS. Therefore the native part is responsible only for TLS state: handshake, encryption, and decryption of records. And C# moves the bytes to and from the network.
Architecture
TcpClient
|
NetworkStream (innerStream)
|
BoringSslStream (C#): pumps bytes between the stream and the buffers
|
BoringSSL, memory BIO (pure TLS state, no network)
|
HTTP/1.1 or HTTP/2
Between C# and BoringSSL there are two in-memory buffers (BIO_s_mem):
- input: C# puts bytes read from
innerStreamhere (pms_ssl_feed_read); - output: C# takes bytes from here that need to be sent to
innerStream(pms_ssl_take_write, the size can be queried viapms_ssl_pending_write).
The native DLL (proxymap_boringssl.dll) exports a small flat C API: pms_ssl_create, pms_ssl_connect, pms_ssl_read, pms_ssl_write, plus buffer-pumping functions and a few getters (protocol version, cipher, selected ALPN, server DER certificate, last error). All ClientHello parameters are passed from C# as strings and numbers.
BoringSslStream inherits from Stream, so it can be substituted anywhere an ordinary stream is expected. Everything is fully asynchronous, with CancellationToken.
The main loop: pumping bytes
The whole mechanism fits into a single pattern. We call a BoringSSL operation, flush the accumulated output to the network, and if the library asks for data, we read it from the network and feed it back. Here is the handshake:
while (true)
{
int result = Native.pms_ssl_connect(_connection);
await FlushWriteBioAsync(cancellationToken);
switch (result)
{
case Native.PMS_SSL_OK:
ValidateServerCertificate(_hostname);
_authenticated = true;
return;
case Native.PMS_SSL_WANT_READ:
await ReadFromNetworkAsync(cancellationToken);
break;
case Native.PMS_SSL_WANT_WRITE:
break; // output has already been flushed to the network
}
}
FlushWriteBioAsync takes everything accumulated in the output buffer and writes it to innerStream. ReadFromNetworkAsync reads from innerStream and feeds the bytes to the input buffer. The same loop is repeated in ReadAsync and WriteAsync.
The output must be flushed after every BoringSSL call, not just during the handshake: even SSL_read can itself generate TLS records (for example, KeyUpdate or alerts).
Buffers are taken from ArrayPool<byte>, and pointers are passed to native code via Memory<T>.Pin(), without unnecessary copies.
Synchronization
The class has four SemaphoreSlims: _readLock and _writeLock serialize reads and writes respectively, while _networkReadLock and _networkWriteLock protect the exchange with innerStream. Thanks to this, concurrent ReadAsync and WriteAsync calls don’t break each other’s work with the stream and buffers. The handshake takes both outer locks at once.
A caveat: this is sufficient for a PoC, but a full-fledged full-duplex over a single SSL* requires care on the native library side as well, not just in C#. I’ll mention this again in the limitations.
The “like Chrome” preset
The BoringSslStream constructor accepts a set of parameters, filled by default with Chrome’s values:
- cipher list: ECDHE variants of AES-GCM and ChaCha20-Poly1305, plus a legacy tail;
- GREASE and ECH GREASE;
- ALPN
h2:http/1.1and ALPSh2; - signature algorithms, including post-quantum
ML-DSA-44/65/87; - SCT,
status_request, Brotli certificate compression; trust_anchorsas a hex string.
The fine-grained work with extensions (reordering, dynamic formation of ALPN/ALPS, padding of status_request) is done on the C++ side. Brotli is linked statically so that everything builds into a single self-contained DLL and there’s no DllNotFoundException at runtime.
On tls.peet.ws the JA4 fingerprint matched Chrome. But JA3 does not match, and that’s how it should be. Starting with Chrome 110, the browser randomly shuffles the order of TLS extensions in ClientHello on every connection. JA3 is built from the lists of ciphers, extensions, groups, and point formats in the order they arrived, so a real Chrome’s JA3 hash is different every time, and comparing it to some “reference” is meaningless. Our client does the same: the native layer shuffles extensions, and JA3 changes from connection to connection, just like in the browser.
JA4 is structured differently: before hashing, it sorts ciphers and extensions (and discards GREASE), so shuffling doesn’t affect it. That’s exactly why JA4 is stable and suitable for verification: if it matches, then the set of ciphers, extensions, signature algorithms, and ALPN is the same as Chrome’s.
Certificate verification
Here BoringSSL only negotiates the channel, while the trust decision is made by .NET. Immediately after a successful handshake, the native layer returns the server’s DER certificate, and then:
using var certificate = new X509Certificate2(memory.Span);
using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
chain.ChainPolicy.ApplicationPolicy.Add(new Oid("1.3.6.1.5.5.7.3.1")); // Server Auth
if (!chain.Build(certificate))
throw new AuthenticationException(...);
if (!certificate.MatchesHostname(hostname))
throw new AuthenticationException(...);
The system root certificate store is used, online revocation checking is enabled, and the hostname is verified via MatchesHostname (available in .NET 8, takes SAN and wildcard rules into account).
It’s easy to test on badssl.com: expired.badssl.com fails on chain building, untrusted-root.badssl.com on an unknown root, revoked.badssl.com on revocation checking. The relevant hosts are commented out in Program.cs; just change one constant.
HTTP/2 by hand
Since ALPN negotiates h2, I had to write a minimal HTTP/2 client right in Program.cs, without third-party libraries.
Sending. First a 24-byte connection preface, then an empty SETTINGS. Then the headers: a tiny HpackEncoder writes indexed fields from the static table (:method: GET is index 2, :scheme: https is 7) and literals without indexing for :authority, :path, and the rest. The block is wrapped in a 9-byte HEADERS frame header with flags END_STREAM | END_HEADERS on stream 1.
Receiving. We read strictly 9 bytes of frame header, parse the length (24 bits), type, flags, and stream id, then read exactly that many bytes of payload. Then we parse by type:
DATAis written to a file and to the console, and onEND_STREAMwe exit;HEADERSprints the status (see below) and also checks forEND_STREAM;SETTINGSwithout the ACK flag is acknowledged withSETTINGS ACK, as RFC requires;RST_STREAMandGOAWAYare printed with a decoded error code;WINDOW_UPDATEis ignored.
There’s no full HPACK decoder, so the response status is determined by a heuristic: we look in the block for bytes 0x88–0x8E, corresponding to indexed statuses from the static table (0x88 is 200, 0x8D is 404). For a demo this is enough, but it’s precisely a hack.
If ALPN returns http/1.1, everything is simpler: an ordinary text request with Connection: close goes out, and the response is read until the stream closes.
Running
git clone --recursive https://github.com/optinsoft/BoringSslConsole.git
cd Native
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
cd ..
dotnet run --project BoringSslConsole
To build on Windows you need the .NET 8 SDK, Visual Studio 2022 with C++ tools, CMake 3.22+, Ninja, Git, NASM, and Go (the last two are required by BoringSSL itself). BoringSSL and Brotli are included as git submodules; after building, the DLL is copied to the .NET output directory. The response is printed to the console and saved to ./output/<host>.txt.
Results and limitations
The result is a compact asynchronous Stream with a Chrome-like TLS stack inside, with real certificate verification, while the network is entirely in .NET’s hands. On top of it you can build proxies, an HTTP client, or testing tools, and the transport can be swapped without touching the TLS layer.
An honest list of what’s missing here:
- HTTP/2 is minimal: no HPACK decoder, no
CONTINUATION, no flow control, no multiplexing. - The TLS fingerprint matches, not the whole client. HTTP/2 has its own fingerprint (
SETTINGSvalues,WINDOW_UPDATE, pseudo-header order), and here it differs from Chrome’s. - Thread safety needs separate verification. The locks in C# separate reads, writes, and network operations, but the safety of concurrent
SSL_readandSSL_writeon a singleSSL*also depends on the native layer. Freeing theSSL*inDisposeshould also be synchronized with in-flight operations. - Chain validation: only the leaf certificate is taken from the native layer, so chain building may depend on AIA fetching of intermediates, and online revocation checking blocks the thread inside
asynccode. - Platform: building requires a heavy toolchain, and the native part is currently Windows x64 only.
This is a PoC, but the architectural idea — “BoringSSL as a pure TLS machine, with I/O outside” — turned out well, in my opinion, and easily transfers to other transports.
The project on GitHub: optinsoft/BoringSslConsole