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