Alexander Gusev
Posted on March 11, 2023
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
- Rust (JavaScript cannot handle such low-level operations)
- BoringSSL bindings for the Rust (Chromium uses BoringSSL)
- H2 (HTTP/2 client)
- 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' +
// ...
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"}}}'
// }
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\"}}}")
}
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;
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
}
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
}
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();
// ...
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();
}
// ...
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())?,
))
}
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
}
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.
Posted on March 11, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.