Nginx移除ClientHello包中的自定义信息

因为某些原因,网络传输的中间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