This is Alex Zhang     About Me

ngx_lua 问题归档(1)

前段时间在公司引流机器上偶然发现我们的 OpenResty/Nginx 服务存在进程崩溃的情况,错误日志里记录了如下的堆栈:

ngx_http_lua_ssl_cert_handler
tls_post_process_client_hello+0x1be)
ossl_statem_server_post_process_message
read_state_machine
state_machine
ossl_statem_accept
ssl3_read_bytes
ssl3_read_internal
ssl3_read
ssl_read_internal
SSL_read
ngx_ssl_recv
ngx_http_v2_read_handler
ngx_http_v2_idle_handler
ngx_epoll_process_events
ngx_process_events_and_timers
ngx_worker_process_cycle
ngx_spawn_process
ngx_start_worker_processes
ngx_master_process_cycle
main

(在崩溃时打印其堆栈的功能是我们自己加入到 Nginx 中的,官方版本并不支持。)

从堆栈信息来看,当前正在处理的连接,HTTP 协议已经升级到了 HTTP/2 (ngx_http_v2_idle_handler)。而从偏顶部的函数来看,当前连接似乎又进入到了 SSL/TLS 握手协议的处理,既然堆栈上出现了 HTTP/2 相关的函数,说明当前一定不是第一次 SSL/TLS 握手(否则 HTTP 协议不可能为 HTTP/2),这让我联想到 SSL renegotation,由于没有 coredump 文件的支持,从堆栈信息里看出来的仅限于这么多。

问题至此,便只能带着问题去看源码了。既然问题还和 HTTP/2 有关,那么我猜测问题可能出现在某些地方没有兼容好 HTTP/2 相关逻辑有关,经过查看 ngx_http_lua_ssl_cert_handler 函数(这是实现 ssl_certificate_by_lua 功能的基础函数)和 HTTP/2 相关的逻辑,我发现问题出在 c->data 这个成员上,在协议升级到 HTTP/2 后,函数 ngx_htp_v2_init会被调用到,这个函数将 c->data 赋值为了 ngx_http_v2_connection_t 的一个实例,而在通常(非 HTTP/2) 的处理下,c->data 则是 ngx_http_connection_t 的实例,ngx_http_lua_ssl_cert_handler并没有区分这一情况,导致将实际按 ngx_http_v2_connection_t 布局的数据按 ngx_http_connection_t的结构来操作,从而引发了 segmentation fault。

void
ngx_http_v2_init(ngx_event_t *rev)
{
    ......
      
    c->data = h2c; /* 赋值为 ngx_http_v2_connection_t 实例 */

    rev->handler = ngx_http_v2_read_handler;
    c->write->handler = ngx_http_v2_write_handler;

    c->idle = 1;

    ngx_http_v2_read_handler(rev);
}
int
ngx_http_lua_ssl_cert_handler(ngx_ssl_conn_t *ssl_conn, void *data)
{
    ......
    
    hc = c->data; /* 此时应该是 h2c (ngx_http_v2_connection_t) = c->data */
  
    ......
      
    r->main_conf = hc->conf_ctx->main_conf; /* 可能导致 segmentation fault */
    r->srv_conf = hc->conf_ctx->srv_conf;   /* 可能导致 segmentation fault */
    r->loc_conf = hc->conf_ctx->loc_conf;   /* 可能导致 segmentation fault */
    
  ......
}
    

原因似乎找到了,我尝试通过 openssl s_client 这个工具来复现问题,但是没有成功:

echo R | openssl s_client -connect ip:port -alpn h2

(这里键入 “R” 是为了触发 SSL renegotation。)

我想可能遗漏了一些重要细节,通过 gdb 单步跟踪,我发现在执行到 ngx_http_lua_ssl_cert_handler 时,并没有执行到上述逻辑,而是走到了以下的逻辑:

int
ngx_http_lua_ssl_cert_handler(ngx_ssl_conn_t *ssl_conn, void *data)
{
    .......

    if (cctx && cctx->entered_cert_handler) {
        /* not the first time */

        if (cctx->done) {
            ngx_log_debug1(NGX_LOG_DEBUG_HTTP, c->log, 0,
                           "lua_certificate_by_lua: cert cb exit code: %d",
                           cctx->exit_code);

            dd("lua ssl cert done, finally");
            return cctx->exit_code;
        }

        return -1;
    }
  
     .......
}

因为早在第一次 SSL/TLS 握手时,cctx->entered_cert_handler就已经置为 1。所以第二次重新协商时,并没有处理我们期望的逻辑。

反过来说,要出现这个问题,那么第一次 SSL/TLS 握手一定不能走到 ngx_http_lua_ssl_cert_handler 的逻辑,这个函数实际设置在 OpenSSL 内部的 s->cert->cert_cb 上,如果要该函数不被调用,说明第一次握手服务端没有发送证书链到客户端,即触发了SSL Session Reuse 机制。

幸运的是,这仍然可以通过 openssl s_client 来验证猜测:

echo R | openssl s_client -connect ip:port -reconnect -alpn h2

执行后观察错误日志,发现进程果然崩溃,问题已经成功定位到!

我已经将问题反馈到了 OpenResty 官方并提交了 PR 进行修复。值得注意的是,Nginx/1.15.2 已经彻底避免了这个问题,见 dcab86115261;另外,如果 OpenSSL 版本低于 1.1.0,也不会有这个问题。