最近遇到一个 bug,调用 OpenSSL API 出错时,返回的错误信息牛头不对马嘴,不知道为什么会有这种情况。
仔细分析后发现,发生这种情况时之前都会有一次 OpenSSL API 出错,而且后一次调用出错返回的错误信息跟
前一次很相似。呃,难道 OpenSSL API 出错时,会返回多条错误信息?

搜索一番后发现,OpenSSL 出错时还真可能返回多条信息。根据这篇文档的说法,OpenSSL 使用了一个 thread local 的队列来存储错误内容。当 OpenSSL 调用出错时,它会往这个队列里面放置错误内容。由于 OpenSSL 内部调用相当复杂,时常会出现这种情况:
函数 A 调用了函数 B,之后在函数 B 里面发生错误,放入一个错误;函数 A 发现 函数 B 调用失败,
又记录一条错误信息。这时候错误队列里面就有两个错误。

这有点像我们做错误处理时,把同一个错误信息从底到顶多次记入日志。要命的是,OpenSSL 并不会主动清空这个
错误。如果在错误处理时只处理第一个错误,就会留下一个不干净的队列。下一次调用时如果出错,返回的就是上一次发生错误时遗留在队列的信息。注意 OpenSSL 用的是队列而不是栈来记录错误信息。如果 OpenSSL 用的是栈,那还好一点,毕竟拿出来的都是最新的错误信息。然而它用的是队列,那就不得不保证错误处理后整个队列是干净的。

之前我们做错误处理时,都是用类似于 ERR_get_xxx 这一类的函数取错误信息。所以需要封装一个能够打扫干净
整个错误队列的函数。在编写这个函数时,我基本照搬了 Nginx 的 ngx_ssl_error,去掉或重新实现了其中 Nginx 特定的部分。新封装的函数如下:

static size_t
ssl_error(char *err, size_t max)
{
    int          flags;
    const char  *data;
    char        *p, *first, *last;
    size_t       n;

    p = err;
    first = err;
    last = err + max;

    for ( ;; ) {
        n = ERR_peek_error_line_data(NULL, NULL, &data, &flags);
        if (n == 0) {
            break;
        }

        if (p != first && p < last) {
            *p++ = ' ';
        }

        /* ERR_error_string_n() requires at least one byte */
        if (p >= last - 1) {
            goto next;
        }

        ERR_error_string_n(n, p, last - p);
        while (p < last && *p) {
            p++;
        }

        if (p < last && *data && (flags & ERR_TXT_STRING)) {
            *p++ = ':';

            while (p < last && *data) {
                *p = *data;
                p++;
                data++;
            }
        }

    next:
        (void) ERR_get_error();
    }

    return p - first;
}

需要解释下 if (p < last && *data && (flags & ERR_TXT_STRING)) 这个分支。在阅读了相关代码后,我发现 OpenSSL 调用出错时,除了会往队列里面放入个错误,有时还有额外附赠一个 err_data。这个 err_data 的实际类型,由对应的 flags 参数确定。如果 flags 表示该 err_data 是一条文本,我们也需要把它当作
错误信息合并起来。

在处理完公司代码里面的问题后,我也顺便给我维护的 lua-resty-rsa 项目加上对应的 Lua 封装函数:

local function ssl_err()
    local err_queue = {}
    local i = 1
    local data = ffi_new("const char*[1]")
    local flags = ffi_new("int[1]")

    while true do
        local code = C.ERR_get_error_line_data(nil, nil, data, flags)
        if code == 0 then
            break
        end

        local err = C.ERR_reason_error_string(code)
        if err ~= nil then
            err_queue[i] = ffi_str(err)
            i = i + 1

            if data[0] ~= nil and band(flags[0], ERR_TXT_STRING) > 0 then
                err_queue[i] = ffi_str(data[0])
                i = i + 1
            end
        end
    end

    return nil, tab_concat(err_queue, ": ", 1, i - 1)
end

我们在调用 OpenSSL API 出错时,有时并不会用 OpenSSL 错误处理函数去获取错误信息,而是直接返回诸如 XXX failed 这样的自定义信息。如果 OpenSSL 跟传统的 Unix errno 一样,只是设置一个错误码,那么返回自己
定义的错误信息也无可厚非。可惜 OpenSSL 用了一个队列来存储错误,所以即使我们不关心实际的错误信息,我们
还是得调用下 ERR_clear_error() 把错误队列清空,不然下一个调用出错的人就会踩坑了。

我本来也想把这个问题也解决了,但是在琢磨了一会儿之后,我选择放弃。针对前面的 ERR_get_XXX 只处理第一条错误的问题,我可以在代码库里搜索用到这种写法的地方,通通替换成我封装之后的函数。但是对于返回自定义信息而没有清空错误队列的问题,没办法用搜索-替换的方式解决,只能去读每一个用到 OpenSSL 的函数。即使我能够把有问题的地方全部揪出来,改动面也太大了。毕竟错误处理的逻辑,相对而言会更加缺乏测试覆盖,就怕一不小心改出更诡异的 bug。

最后一个问题,既然我们没法确定调用 OpenSSL API 时,错误队列是否是干净的,那么出于防御性编程的目的,
是否应该在调用前主动用 ERR_clear_error()

我看了下 Nginx 的代码,貌似连以健壮出名的 Nginx 也没有小心谨慎到这种地步。当然根据 SSL_get_error 的文档,如果想调用这个函数获取 ssl handshake 时错误,就需要保证错误队列是空的,因为这个函数会调用
ERR_peek_error() 获取队列里面第一个错误。据我了解,目前为止 Nginx 也只有在调用这个函数前才会清空
错误队列。

总结一下 OpenSSL 的错误处理:

  1. 如果要获取错误信息,需要处理整个错误队列,而不能像处理 errno 一样只获取一次错误信息。
  2. 注意错误里面可能会附带 ERR_TXT_STRING 类型的额外数据,这个也应该包含到错误信息中来。
  3. 如果不在意错误信息,一旦出错时也应该调用 ERR_clear_error(),避免污染到后面的操作。
  4. 在调用 SSL_get_error 之前,记得清空错误队列。

spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.


引用和评论

0 条评论