因为某些原因,网络传输的中间NE设备需要在客户的https请求里加上一些自定义信息,想要把这些信息传递给后端Web服务器。具体采取的方法是在https的TLS握手最开始的未加密ClientHello包里加一些信息,服务端从ClientHello包里解析出来自定义信息。
1. Bad Record MAC问题
如果把加过自定义的信息的包,直接传递给后端Web服务器,以Nginx为例,因为ClientHello包被篡改的原因,会导致nginx无法完成https握手,在进行到验证Encrypted Handshake Message时报错Bad Record MAC。无法握手成功后客户端会显示请求失败。
简单回顾TLS握手过程(以TLS1.2为例,TLS1.3也是类似只是省略了一些步骤)
Client -> Server:Client Hello 传递Client的基本信息,支持的算法、版本等
Server -> Client:ServerHello 返回Server选定的算法和版本等
Server -> Client:Certificate、Server Key Exchange、Server Hello Done 传递证书密钥等Server Key信息
Client -> Server:Client Key Exchange 客户端验证证书后,传递随机数,EC公私钥等Client Key信息协商密钥
Client -> Server:Encrypted Handshake Message (Finished) 使用协商好的密钥加密和签名之前的未加密包内容
Server -> Client:Encrypted Handshake Message (Finished) 根据选择算法不同,这步位置可能不一样,作用都是Server使用协商好的密钥加密和签名之前的未加密包内容
Server -> Client:NewSessionTicket 算法不同这部可能没有,Server开始传输会话,为了之后快速恢复会话使用
后面交换的都是加密的Application Data信息
Bad Record MAC的问题出在Encrypted Handshake Message这步,Client和Server协商密钥是正确的,但是加密的明文数据并不一样。
Client用自己发出的ClientHello(1)明文加密计算MAC,Server用收到的ClientHello(2)进行明文加密计算MAC,因为网络中ClientHello被加了自定义信息,导致Server收到的内容和Client发出的内容不一样,计算出的MAC自然也不一样了。Server无法通过Encrypted Handshake Message的验证。
2. Nginx处理
为了使TLS握手成功进行,需要在Server端对ClientHello还原成原始包,TLS验证才可以通过。在nginx进行ssl之前要对包进行处理。
nginx这里采用stream模块进行转发,把请求先接收到stream模块,stream模块是TCP层面的,不会进行HTTPS握手操作,在stream模块处理完后,在代理转发到nginx的http模块进行SSL握手和后续逻辑。
nginx.conf关键代码:
# stream模块
stream {
server {
listen 443;
# 这里将ClientHello包还原
proxy_pass 127.0.0.1:4443; # 转发ssl服务器地址
}
}
# http 模块
http {
server {
listen 4443 ssl;
# nginx原逻辑
# ...
}
}
如果nginx不支持stream模块,需要在编译时加上--with-stream参数
./configure --prefix=/usr/local/nginx --with-stream
3. Lua处理ClientHello包
因为不同NE对ClientHello包附加信息的方式不同,这里需要使用代码对ClientHello包进行字节级别拆解,在根据设备对ClientHello加信息的方式,反向操作把加的信息删除,重新计算相关的长度信息。
这里以删除某个Extension为例的关键代码:
stream {
server {
listen 443;
content_by_lua_block {
local function read_full_tls_record(sock)
sock:settimeout(2000)
local header, err = sock:receiveany(5)
local record_len = (string.byte(header, 4) * 256) + string.byte(header, 5)
local payload, err = sock:receive(record_len)
return header .. payload, nil
end
local function process_client_hello(record_data)
# 按字节解析出ClientHello每个字段,包括ver random sessionID cipher compression Extensions等
# 找到自定义Extension,保存自定义信息
# 删除自定义Extension部分
# 重新计算ExtensionsLength HandshakeLength TlsLength 3个长度值
end
-- ===================== 主逻辑:客户端连接处理 =====================
local client_sock = ngx.req.socket(true)
client_sock:settimeout(2000)
local backend_sock = ngx.socket.tcp()
local ok, err = backend_sock:connect("127.0.0.1",4443)
local client_record, err = read_full_tls_record(client_sock)
local modified_record, is_client_hello, err = process_client_hello(client_record)
if not modified_record then
ngx.log(ngx.ERR, "处理TLS记录失败: ", err)
client_sock:close()
backend_sock:close()
return
end
local bytes, err = backend_sock:send(modified_record)
-- 双向转发(优化超时和错误处理)
local function forward(src, dst)
if not src or not dst then
ngx.log(ngx.ERR, "forward参数为nil,终止")
return
end
src:settimeout(5000) -- 转发超时 5 秒
while true do
local data, err = src:receiveany(8192)
if not data then
break
end
local bytes, err = dst:send(data)
if not bytes then
break
end
end
end
-- 启动双向转发
local co = ngx.thread.spawn(forward, client_sock, backend_sock)
forward(backend_sock, client_sock)
ngx.thread.wait(co)
client_sock:close()
backend_sock:close()
}
}
}
在nginx使用lua和ngx库,需要编译ngx和lua相关的模块,比较麻烦。这里推荐使用openresty的nginx,所有模块一步到位。
wget https://openresty.org/package/centos/openresty.repo
mv openresty.repo /etc/yum.repos.d/
yum check-update
yum -y install openresty
systemctl start openresty.service
4. 关联请求和自定义信息
这里推荐使用ClientHello中的random和自定义信息关联。
在openresty可以在http直接获取到Client random。
关键代码:
http {
server {
listen 4443 ssl;
# ...
location / {
access_by_lua_block {
local ssl = require "ngx.ssl"
local raw, err = ssl.get_client_random()
local hex = (raw:gsub('.', function(c)
return string.format('%02x', string.byte(c))
end))
ngx.req.set_header("X-Client-Random", hex)
}
proxy_pass http://1.2.3.4:8080; # 转发到业务后端
# ...
}
}
}
如果读者有更好的思路和方法,欢迎评论交流。
Last modified on 2025-10-01