golang

TLS Client Hello — перехватываем и парсим — Nginx + Lua / Go

  • среда, 17 января 2024 г. в 00:00:14
https://habr.com/ru/articles/786670/

Возникла на днях достаточно интересная задачка - по образу сайта 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, попадалось много вопросов и мало хоть сколько-нибудь систематизированного / полезного. Надеюсь, это кому-то поможет лишний раз не тратить время.