DEV Community

Alexander Gusev
Alexander Gusev

Posted on

Making TLS client with Chrome-like SSL Handshake (Rust, Boring SSL, H2)

An overview guide to building a TLS client in Rust that can simulate Chrome's SSL handshake. Plus nodejs module and proxy support in the repo.

The source code is available here — github.com/gssvv/rust-boring-ssl-client

Tools used

  1. Rust (JavaScript cannot handle such low-level operations)
  2. BoringSSL bindings for the Rust (Chromium uses BoringSSL)
  3. H2 (HTTP/2 client)
  4. Neon (to create native Node.js modules)

What's the result?

Let's take an opensea.io GraphQL for example, which uses Cloudflare WAF.

Regular axios request won't work:

const config = {
  uri: "https://opensea.io/__api/graphql/",
  host: "opensea.io",
  method: "POST",
  headers: [
    ["authority", "opensea.io"],
    ["content-type", "application/json"],
    // ...
  ],
  body: `{
    \"id\": \"NavbarQuery\",
    \"query\": \"query NavbarQuery(\\n  $identity: AddressScalar!\\n) {\\n  getAccount(address: $identity) {\\n    imageUrl\\n    id\\n  }\\n}\\n\",
    \"variables\": {
      \"identity\": \"0xf8e33110b8757e05e1db570a4528412cd907f29d\"
    }
  }`,
};

const axios = require("axios");

axios({
  ...config,
  url: config.uri,
  headers: Object.fromEntries(config.headers),
  data: config.body,
  validateStatus: () => true,
}).then((e) => console.log({ status: e.status, body: e.data }));
// {
//   status: 403,
//   body: '<!DOCTYPE html>\n' +
//     '<html lang="en-US">\n' +
//     '   <head>\n' +
//     '      <title>Access denied</title>\n' +
//     '      <meta http-equiv="X-UA-Compatible" content="IE=Edge" />\n' +
// ...
Enter fullscreen mode Exit fullscreen mode

Let's try our module:

const { request } = require("./build/macos.node");

request(config, "", "").then(console.log);
// {
//   status: 200,
//   bodyJson: '{"data":{"getAccount":{"imageUrl":"https://i.seadn.io/gcs/files/27554692030796c8858c08ff5b6615a2.jpg?w=500&auto=format","id":"QWNjb3VudFR5cGU6ODY3MDYyNDQw"}}}'
// }
Enter fullscreen mode Exit fullscreen mode

You can also use it in Rust:

mod lib;
use tokio;

#[tokio::main]
async fn main() {
    let body = String::from(
        "{
            \"id\": \"NavbarQuery\",
            \"query\": \"query NavbarQuery(\\n  $identity: AddressScalar!\\n) {\\n  getAccount(address: $identity) {\\n    imageUrl\\n    id\\n  }\\n}\\n\",
            \"variables\": {
              \"identity\": \"0xf8e33110b8757e05e1db570a4528412cd907f29d\"
            }
          }",
    );

    let config = lib::RequestConfig {
        body,
        method: "POST".to_string(),
        host: "opensea.io".to_string(),
        uri: "https://opensea.io/__api/graphql/".to_string(),
        headers: vec![
            vec!["authority".to_string(), "opensea.io".to_string()],
            vec!["content-type".to_string(),"application/json".to_string()],
            vec!["origin".to_string(), 
        // ...
    };

    let res = lib::request(config).await.unwrap();
    println!("res: {:?}", res);
    // res: (200, "{\"data\":{\"getAccount\":{\"imageUrl\":\"https://i.seadn.io/gcs/files/27554692030796c8858c08ff5b6615a2.jpg?w=500&auto=format\",\"id\":\"QWNjb3VudFR5cGU6ODY3MDYyNDQw\"}}}")
}

Enter fullscreen mode Exit fullscreen mode

Let's build

I will cover the key points of the TLS client implementation. I will not describe the Neon interface building, Tokio runtime setup and other secondary aspects in detail.

So, here're the dependencies we need:

use h2::client;
use http::Request;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;

use boring::ssl::{ConnectConfiguration, SslConnector, SslMethod};

use once_cell::sync::OnceCell;
use std::error::Error;
use std::net::ToSocketAddrs;

use bytes::{BufMut, Bytes, BytesMut};

use neon::context::{Context, FunctionContext, ModuleContext};
use neon::prelude::*;

use tokio::runtime::Runtime;
Enter fullscreen mode Exit fullscreen mode

Now we can start writing the main request function that opens a TCP connection:

pub struct RequestConfig {
    pub method: String,
    pub body: String,
    pub host: String,
    pub uri: String,
    pub headers: Vec<Vec<String>>,
}

pub async fn request(request_config: RequestConfig) -> Result<(u16, String), Box<dyn Error>> {
    let addr = format!("{}:443", request_config.host)
        .to_socket_addrs()
        .unwrap()
        .next()
        .unwrap();
    let tcp = TcpStream::connect(&addr).await?;

    connect_and_send_request(tcp, request_config).await
}
Enter fullscreen mode Exit fullscreen mode

We'll get to connect_and_send_request later.
The next step is to create SSL configuration. This is where we specify ciphers and TLS extensions settings.

pub fn get_connect_config() -> ConnectConfiguration {
    let cipher_list = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256-SHA";
    let mut builder = SslConnector::builder(SslMethod::tls()).unwrap();
    builder.set_verify(boring::ssl::SslVerifyMode::NONE);
    builder.set_grease_enabled(true);
    builder.enable_ocsp_stapling();
    builder.set_cipher_list(&cipher_list).unwrap();
    builder
        .set_alpn_protos(&[2, 104, 50, 8, 104, 116, 116, 112, 47, 49, 46, 49])
        .unwrap();
    builder.enable_signed_cert_timestamps();
    let connector = builder.build();

    let mut connect_config = connector.configure().unwrap();
    connect_config.set_verify_hostname(false);

    connect_config
}
Enter fullscreen mode Exit fullscreen mode

Now we can use that config to perform a SSL handshake and initialize a client:

async fn connect_and_send_request(
    tcp: TcpStream,
    request_config: RequestConfig,
) -> Result<(u16, String), Box<dyn Error>> {
    let connect_config = get_connect_config();

    let res = tokio_boring::connect(connect_config, request_config.host.as_str(), tcp).await;
    let tls = res.unwrap();

    let (mut client, h2) = client::Builder::new()
        .initial_connection_window_size(1024 * 1024 * 1024)
        .initial_window_size(1024 * 1024 * 1024)
        .handshake::<_, Bytes>(tls)
        .await
        .unwrap();
    // ...
Enter fullscreen mode Exit fullscreen mode

Now we can create our request and send it:

    // ...
    let mut request = Request::builder()
        .version(http::version::Version::HTTP_2)
        .method(request_config.method.as_str())
        .uri(request_config.uri);

    let mut i = 0;

    while i < request_config.headers.len() {
        request = request.header(
            request_config.headers[i][0].as_str(),
            request_config.headers[i][1].as_str(),
        );
        i = i + 1;
    }

    let has_body = request_config.body.len() > 0;

    if has_body {
        request = request.header("content-length", request_config.body.len());
    }

    let request = request.body(()).unwrap();
    let (response, mut send_stream) = client.send_request(request, !has_body).unwrap();

    if has_body {
        send_stream
            .send_data(Bytes::from(request_config.body), true)
            .unwrap();
    }
    // ...
Enter fullscreen mode Exit fullscreen mode

When the request is sent, we can get and return its result:

    // ...
    tokio::spawn(async move {
        if let Err(e) = h2.await {
            println!("GOT ERR={:?}", e);
        }
    });

    let (res_parts, mut body) = response.await?.into_parts();
    let mut response_buf = BytesMut::new();

    while let Some(chunk) = body.data().await {
        response_buf.put(chunk?);
    }

    Ok((
        res_parts.status.as_u16(),
        String::from_utf8(response_buf.to_vec())?,
    ))
}
Enter fullscreen mode Exit fullscreen mode

Now we have a working TLS client!

Proxy implementation

To make it work with proxy, we have to send extra headers before the SSL handshake. Everything else is the same:

pub async fn request_with_proxy(
    request_config: RequestConfig,
    proxy_addr: String,
    proxy_auth_in_base64: String,
) -> Result<(u16, String), Box<dyn Error>> {
    let addr = proxy_addr.to_socket_addrs().unwrap().next().unwrap();
    let mut tcp = TcpStream::connect(&addr).await?;

    let connect_request = [
        format!("CONNECT {}:443 HTTP/1.1", request_config.host).to_string(),
        format!("Host: {}:443", request_config.host).to_string(),
        format!("Proxy-Authorization: Basic {}", proxy_auth_in_base64),
        "User-Agent: curl/7.81.0".to_string(),
        "Connection: keep-alive".to_string(),
        "\r\n".to_string(),
    ]
    .join("\r\n");

    tcp.write_all(connect_request.as_bytes()).await.unwrap();
    let mut msg = vec![0; 1024];

    loop {
        tcp.readable().await?;

        match tcp.try_read(&mut msg) {
            Ok(n) => {
                msg.truncate(n);
                break;
            }
            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                continue;
            }
            Err(e) => {
                return Err(e.into());
            }
        }
    }

    connect_and_send_request(tcp, request_config).await
}
Enter fullscreen mode Exit fullscreen mode

That's it!

I couldn't find a solution like that on the internet and collected information bit by bit.

I really hope this quickly-made article will save someone's time just like it would save mine.

P.S. I am not a Rust developer, so be careful using this code in production.

Top comments (0)