TLS Client Hello — перехватываем и парсим — Nginx + Lua / Go
- среда, 17 января 2024 г. в 00:00:14
Возникла на днях достаточно интересная задачка - по образу сайта https://www.howsmyssl.com/ получить на страничке список Cipher Suites которые при TLS Handshake клиент передает в своем Client hello.
А заодно обдумать инструмент, который позволит работать с другими типами заголовков, в частности - Proxy-Connection.
В качестве основного веб сервера с которым я имею дело выступает Nginx, точнее его сборка Openresty с интерпретатором LuaJIT для скриптов Lua.
Основная проблематика связана с тем, что веб серверу напрямую доступно очень немногое что относится к процедуре TLS Handshake.
Соответственно и перехватывать процедуру хендшейка логично на более низком уровне.
Неизбежно возникают некоторые сопутствующие моменты, например что Session ID из клиентского заголовка не будет равняться Session ID серверного ответа и переменной ngx.var.ssl_session_id
В принципе, можно пойти и по совсем простому пути:
Перехватить пакеты через Tshark для клиентского приветствия и ответа сервера, и передавать вывод скрипту на Python:tshark -i eth0 -Y 'ssl.handshake.type == 1 or ssl.handshake.type == 2 ' -l -V | python3 tcpparse.py
В скрипте Python реализовать логику определения какой из двух пакетов нам пришел.
Взаимосвязь между клиентским и северным приветствием определять по Acknowledgment number клиентского пакета, который будет равен Sequence Number серверного пакета. Из серверного пакета брать Session ID и результаты кешировать.
Но так как задача предполагала некий задел на будущее, я решил не использовать tshark, а ограничиться модулем stream в Nginx:
stream {
server {
listen 443;
content_by_lua_file "/lua/simpleproxy.lua";
}
}
local function proxy_data(client_sock, server_sock)
local ok1, err = ngx.thread.spawn(read_from_socket_client, client_sock, server_sock)
if not ok1 then
ngx.log(ngx.ERR, "failed to spawn read_from_socket_client thread: ", err)
return
end
local ok2, err = ngx.thread.spawn(read_from_socket_server, server_sock, client_sock)
if not ok2 then
ngx.log(ngx.ERR, "failed to spawn read_from_socket_server thread: ", err)
return
end
ngx.thread.wait(ok1, ok2)
end
local function proxy_handler_stream()
local client_sock, err = ngx.req.socket(true)
if not client_sock then
ngx.log(ngx.ERR, "failed to get client socket: ", err)
return ngx.exit(ngx.ERROR)
end
local server_sock = ngx.socket.tcp()
local ok, err = server_sock:connect("127.0.0.1", 8443)
if not ok then
ngx.log(ngx.ERR, "failed to connect to the server: ", err)
return ngx.exit(ngx.ERROR)
end
proxy_data(client_sock, server_sock)
end
proxy_handler_stream()
Функция proxy_handler_stream инициирует чтение данных из клиентского сокета, инициализирует соединение с бекендом, и передает управление функции proxy_data, которая использует механизм "Light threads" который является одной из реализаций корутин в Lua
Минималистично, для пересылки пакетов между клиентом и сервером достаточно такого кода функций read_from_socket_client
и read_from_socket_server
:
local function read_from_socket_client(from_sock, to_sock)
from_sock:settimeout(0)
to_sock:settimeout(0)
while true do
local data, err, partial = from_sock:receive("1")
if not data and err == "closed" then
break
end
data = data or partial
if data and #data > 0 then
local bytes, send_err = to_sock:send(data)
if not bytes then
return false, "failed to send data: " .. send_err
end
end
end
end
local function read_from_socket_server(from_sock, to_sock)
from_sock:settimeout(0)
to_sock:settimeout(0)
while true do
local data, err, partial = from_sock:receive("1")
if not data and err == "closed" then
break
end
data = data or partial
if data and #data > 0 then
local bytes, send_err = to_sock:send(data)
if not bytes then
return false, "failed to send data: " .. send_err
end
end
end
end
Однако наша задача не ограничивается проксированием, поэтому начнем бубны с танцами.
Модифицируем read_from_socket_client
и read_from_socket_server
. Из данных передаваемых через сокеты нам нужно отбросить первые 5 байт (если честно - я не помню зачем они, сорри). 6 байт будет содержать код заголовка (01 - клиентский hello, 02 - серверный ответ), далее - 3 байта длины сообщения. Далее читать и агрегировать данные нам не нужно, и мы просто продолжаем пересылку:
local accumulated_data_client = ""
local accumulated_data_server = ""
local counter_client = -5
local counter_client_stop = 4096
local counter_server = -5
local counter_server_stop = 4096
local function read_from_socket_client(from_sock, to_sock)
from_sock:settimeout(0)
to_sock:settimeout(0)
while true do
local data, err, partial = from_sock:receive("1")
if not data and err == "closed" then
break
end
data = data or partial
if data and #data > 0 then
counter_client = counter_client + 1
if counter_client > 0 and counter_client <= counter_client_stop then
accumulated_data_client = accumulated_data_client .. data
if counter_client == 4 then
local last_three_bytes = accumulated_data_client:sub(2, 4)
local result = 0
for i = 1, #last_three_bytes do
result = result * 256 + last_three_bytes:byte(i)
end
counter_client_stop = result + 4
end
end
local bytes, send_err = to_sock:send(data)
if not bytes then
return false, "failed to send data: " .. send_err
end
end
end
end
local function read_from_socket_server(from_sock, to_sock)
from_sock:settimeout(0)
to_sock:settimeout(0)
while true do
local data, err, partial = from_sock:receive("1")
if not data and err == "closed" then
break
end
data = data or partial
if data and #data > 0 then
counter_server = counter_server + 1
if counter_server > 0 and counter_server <= counter_server_stop then
accumulated_data_server = accumulated_data_server .. data
if counter_server == 4 then
local last_three_bytes = accumulated_data_server:sub(2, 4)
local result = 0
for i = 1, #last_three_bytes do
result = result * 256 + last_three_bytes:byte(i)
end
counter_server_stop = result + 4
end
end
if counter_server == counter_server_stop then
infoFromHandshake(accumulated_data_client, accumulated_data_server)
end
local bytes, send_err = to_sock:send(data)
if not bytes then
return false, "failed to send data: " .. send_err
end
end
end
end
В коде выше, когда counter_server
достигает конца полезной нагрузки заголовка, происходит вызов функции infoFromHandshake
. Она наконец получает переменные, содержащие клиентский запрос и серверный ответ на него.
Хотя в серверном ответе и содержится SessionID в открытом виде, однако информации о Cipher Suites в клиентском запросе в явном виде не будет. И самое простое из известных мне решений для дальнейшего декодирования заголовков - использовать библиотеку cryptobyte, написанную на Go.
Поэтому покамест быстренько напишем передачу данных через пайп нашей будущей программе на Go, ответ запишем в Redis и оставим Lua в покое:
local function infoFromHandshake(data_client, data_server)
local timestamp = os.time()
local randomPart = math.random(10000, 99999)
local tempInputClientFileName = string.format("/tmp/hs_client_input_%d_%d.txt", timestamp, randomPart)
local tempInputServerFileName = string.format("/tmp/hs_server_input_%d_%d.txt", timestamp, randomPart)
local tempInputClientFile = io.open(tempInputClientFileName, "w")
tempInputClientFile:write(data_client)
tempInputClientFile:close()
local tempInputServerFile = io.open(tempInputServerFileName, "w")
tempInputServerFile:write(data_server)
tempInputServerFile:close()
local go_program_client = assert(io.popen("/ciphersuites/clienthello < " .. tempInputClientFileName, "r"))
local clienthello = assert(go_program_client:read("*a"))
go_program_client:close()
os.remove(tempInputClientFileName)
local go_program_server = assert(io.popen("/ciphersuites/clienthello < " .. tempInputServerFileName, "r"))
local sessionid = assert(go_program_server:read("*a"))
go_program_server:close()
os.remove(tempInputServerFileName)
if sessionid and clienthello then
setRedisKeys(sessionid, clienthello)
else
return
end
end
Функцию setRedisKeys
не привожу, она простая до безобразия.
В приложении на Go прочитаем данные из пайпа, и определим по первому байту тип заголовка:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
)
func main() {
var buf bytes.Buffer
_, err := io.Copy(&buf, os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "Ошибка при чтении:", err)
return
}
data := buf.Bytes()
if len(data) < 1 {
fmt.Fprintln(os.Stderr, "Нет данных для чтения")
return
}
switch data[0] {
case 0x01:
ClientDecode(data)
case 0x02:
ServerDecode(data)
default:
fmt.Fprintln(os.Stderr, "Неизвестный заголовок")
}
}
func ClientDecode(data []byte) {
clientInfo := UnmarshalClientHello(data)
clientInfoJson, err := json.Marshal(clientInfo)
if err != nil {
fmt.Println("Ошибка при кодировании в JSON:", err)
return
}
fmt.Printf(string(clientInfoJson))
}
func ServerDecode(data []byte) {
server_info := UnmarshalServerResponse(data)
serverSessionID := server_info.SessionIDHex
fmt.Printf(string(serverSessionID))
}
За основу кода на Go для дешифровки взята эта статья: https://www.agwa.name/blog/post/parsing_tls_client_hello_with_cryptobyte
package main
import (
"bytes"
"encoding/json"
"encoding/hex"
"crypto/cryptobyte"
"fmt"
"io"
"os"
)
func main() {
var buf bytes.Buffer
_, err := io.Copy(&buf, os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "Ошибка при чтении:", err)
return
}
data := buf.Bytes()
if len(data) < 1 {
fmt.Fprintln(os.Stderr, "Нет данных для чтения")
return
}
switch data[0] {
case 0x01:
ClientDecode(data)
case 0x02:
ServerDecode(data)
default:
fmt.Fprintln(os.Stderr, "Неизвестный заголовок")
}
}
func ClientDecode(data []byte) {
clientInfo := UnmarshalClientHello(data)
clientInfoJson, err := json.Marshal(clientInfo)
if err != nil {
fmt.Println("Ошибка при кодировании в JSON:", err)
return
}
fmt.Printf(string(clientInfoJson))
}
func ServerDecode(data []byte) {
server_info := UnmarshalServerResponse(data)
serverSessionID := server_info.SessionIDHex
fmt.Printf(string(serverSessionID))
}
Нам понадобятся следующие типы. Структуру для ServerHelloInfo
можно было бы и не создавать, так как нам нужен только SessionID / SessionIDHex, а ClientHelloInfo
упростить, но как задел на будущее пусть будет.
Массив CipherSuites для функции MakeCipherSuite генерируется парсингом csv из https://www.iana.org/assignments/tls-parameters/tls-parameters-4.csv
, пример генерации, как и полный пример реализации разбора можно посмотреть в коде из статьи "Parsing a TLS Client Hello with Go's cryptobyte Package". Здесь же сокращенный вариант разбора под мои нужды:
type ProtocolVersion uint16
func (v ProtocolVersion) Hi() uint8 {
return uint8(v >> 8)
}
func (v ProtocolVersion) Lo() uint8 {
return uint8(v)
}
func (v ProtocolVersion) MarshalJSON() ([]byte, error) {
return json.Marshal([2]uint8{v.Hi(), v.Lo()})
}
type CompressionMethod uint8
func (m CompressionMethod) MarshalJSON() ([]byte, error) {
return json.Marshal(uint16(m))
}
type CipherSuite struct {
Code [2]uint8 `json:"code"`
Name string `json:"name,omitempty"`
Grease bool `json:"grease,omitempty"`
}
func (c CipherSuite) CodeUint16() uint16 {
return (uint16(c.Code[0]) << 8) | uint16(c.Code[1])
}
func MakeCipherSuite(code uint16) CipherSuite {
hi := uint8(code >> 8)
lo := uint8(code)
return CipherSuite{
Code: [2]uint8{hi, lo},
Name: CipherSuites[code].Name,
Grease: CipherSuites[code].Grease,
}
}
type ClientHelloInfo struct {
Raw []byte `json:"raw"`
Version ProtocolVersion `json:"version"`
Random []byte `json:"random"`
SessionID []byte `json:"session_id"`
CipherSuites []CipherSuite `json:"cipher_suites"`
CompressionMethods []CompressionMethod `json:"compression_methods"`
Extensions []Extension `json:"extensions"`
Info struct {
ServerName *string `json:"server_name"`
SCTs bool `json:"scts"`
Protocols []string `json:"protocols"`
JA3String string `json:"ja3_string"`
JA3Fingerprint string `json:"ja3_fingerprint"`
} `json:"info"`
}
type ServerHelloInfo struct {
Raw []byte `json:"raw"`
Version ProtocolVersion `json:"version"`
Random []byte `json:"random"`
SessionID []byte `json:"session_id_string"`
SessionIDHex string `json:"session_id"`
CipherSuite CipherSuite `json:"cipher_suite"`
CompressionMethod CompressionMethod `json:"compression_method"`
Extensions []Extension `json:"extensions"`
Info struct {
SelectedProtocol *string `json:"selected_protocol"`
SCTs bool `json:"scts"`
} `json:"info"`
}
Собственно, разбор заголовков:
func UnmarshalClientHello(handshakeBytes []byte) *ClientHelloInfo {
info := &ClientHelloInfo{Raw: handshakeBytes}
handshakeMessage := cryptobyte.String(handshakeBytes)
var messageType uint8
if !handshakeMessage.ReadUint8(&messageType) || messageType != 1 {
return nil
}
var clientHello cryptobyte.String
if !handshakeMessage.ReadUint24LengthPrefixed(&clientHello) || !handshakeMessage.Empty() {
return nil
}
if !clientHello.ReadUint16((*uint16)(&info.Version)) {
return nil
}
if !clientHello.ReadBytes(&info.Random, 32) {
return nil
}
if !clientHello.ReadUint8LengthPrefixed((*cryptobyte.String)(&info.SessionID)) {
return nil
}
var cipherSuites cryptobyte.String
if !clientHello.ReadUint16LengthPrefixed(&cipherSuites) {
return nil
}
info.CipherSuites = []CipherSuite{}
for !cipherSuites.Empty() {
var suite uint16
if !cipherSuites.ReadUint16(&suite) {
return nil
}
info.CipherSuites = append(info.CipherSuites, MakeCipherSuite(suite))
}
if !clientHello.Empty() {
return nil
}
return info
}
func UnmarshalServerResponse(handshakeBytes []byte) *ServerHelloInfo {
info := &ServerHelloInfo{Raw: handshakeBytes}
handshakeMessage := cryptobyte.String(handshakeBytes)
var messageType uint8
if !handshakeMessage.ReadUint8(&messageType) || messageType != 2 {
return nil
}
// Пропускаем длину сообщения (3 байта)
if !handshakeMessage.Skip(3) {
return nil
}
// Чтение версии протокола (2 байта)
if !handshakeMessage.Skip(2) { // версия обычно пропускается
return nil
}
// Чтение и пропуск случайного числа (32 байта)
if !handshakeMessage.Skip(32) {
return nil
}
var sessionIDBytes cryptobyte.String
if !handshakeMessage.ReadUint8LengthPrefixed(&sessionIDBytes) {
return nil
}
info.SessionIDHex = hex.EncodeToString(sessionIDBytes)
return info
}
Итого, у нас есть каркас приложения, для чтения из пайпа байтов наших заголовков и возврат clientInfoJson
и serverSessionID
В Nginx выставляем:ssl_session_cache none;
ssl_session_tickets off;
keepalive_timeout 0;
таким образом избавиться от тикетов в сессиях ssl и гарантированно создавать сессию на каждый запрос. В противном случае, с curl все будет работать, а вот скажем тот же Chrome обязательно постучится за favicon и корректное значение Session ID мы не увидим.
Далее остается самая малость - вывести данные из Redis: в нужный нам локейшн нашего http server слушающего по ssl вставляем content_by_lua_block
или content_by_lua_file
примерно такого содержания:
local function getclientinfo()
local redis_key = ngx.var.ssl_session_id
local clientinfo
local redis_err
local ok, red = pcall(redis.connect, { host = redis_local_server, port = redis_local_port, timeout = redis_local_connect_timeout })
if not ok then
redis_err = true
end
if not redis_err then
local res, err = red:get(redis_key)
if err then
elseif not res then
else
clientinfo = res
end
end
local jsonData = json.decode(clientinfo)
ngx.header.content_type = 'application/json; charset=utf-8'
ngx.say(jsonData)
ngx.exit(200)
end
getclientinfo()
Если не нужен полный вывод - используем ngx.say(jsonData.cipher_suites)
Собственно вот и все. Постарался объяснить основные моменты и показать пути. Код писался на скорую руку и нуждается в допиливании - решение эскизное. Но при ресерче на просторах интернета, за исключением статьи Andrew Ayer, попадалось много вопросов и мало хоть сколько-нибудь систематизированного / полезного. Надеюсь, это кому-то поможет лишний раз не тратить время.