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

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/786670/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *