什么可以解释调用 free() 时的堆损坏?

What can explain heap corruption on a call to free()?

几天来我一直在调试一个崩溃,它发生在 OpenSSL 的深处(与维护者讨论 here)。我花了一些时间进行调查,所以我会尽量让这个问题变得有趣和有用。

首先,为了提供一些背景信息,我重现崩溃的最小样本如下:

#include <openssl/crypto.h>
#include <openssl/ec.h>
#include <openssl/objects.h>
#include <openssl/pem.h>
#include <openssl/err.h>
#include <openssl/engine.h>

int main()
{
    ERR_load_crypto_strings(); OpenSSL_add_all_algorithms(); 
    ENGINE_load_builtin_engines();

    EC_GROUP* group = EC_GROUP_new_by_curve_name(NID_sect571k1);
    EC_GROUP_set_point_conversion_form(group, POINT_CONVERSION_UNCOMPRESSED);
    EC_KEY* eckey = EC_KEY_new();
    EC_KEY_set_group(eckey, group);
    EC_KEY_generate_key(eckey);
    BIO* out = BIO_new(BIO_s_file());
    BIO_set_fp(out, stdout, BIO_NOCLOSE);
    PEM_write_bio_ECPrivateKey(out, eckey, NULL, NULL, 0, NULL, NULL); // <= CRASH.
}

基本上,此代码会生成一个椭圆曲线密钥并尝试将其输出到 stdout。类似的代码可以在 openssl.exe ecparam 和在线维基上找到。它在 Linux 上运行良好(valgrind 完全没有报告错误)。 它只会在 Windows(Visual Studio 2013 - x64)上崩溃。 我确保链接了正确的运行时(/MD 在我的例子中,对于所有依赖项)。

无所畏惧,我在 x64-debug 中重新编译了 OpenSSL(这次链接了 /MDd 中的所有内容),并逐步执行代码以找到有问题的指令集。我的搜索引导我找到这段代码(在 OpenSSL 的 tasn_fre.c 文件中):

static void asn1_item_combine_free(ASN1_VALUE **pval, const ASN1_ITEM *it, int combine)
{
    // ... some code, not really relevant.
    tt = it->templates + it->tcount - 1;

    for (i = 0; i < it->tcount; tt--, i++) {
        ASN1_VALUE **pseqval;
        seqtt = asn1_do_adb(pval, tt, 0);
        if (!seqtt) continue;

        pseqval = asn1_get_field_ptr(pval, seqtt);
        ASN1_template_free(pseqval, seqtt);
    }
    if (asn1_cb)
        asn1_cb(ASN1_OP_FREE_POST, pval, it, NULL);
    if (!combine) {
        OPENSSL_free(*pval); // <= CRASH OCCURS ON free()
        *pval = NULL;
    }
    // Some more code...
}

对于那些不太熟悉 OpenSSL 及其 ASN.1 例程的人来说,这个 for 循环所做的基本上是遍历序列的所有元素(从最后一个元素开始)和 "deletes" 他们(稍后会详细介绍)。

就在崩溃发生之前,删除了一个包含 3 个元素的序列(在 *pval,即 0x00000053379575E0)。查看内存,可以看到发生以下情况:

序列长 12 个字节,每个元素长 4 个字节(在本例中,2510)。在每次循环迭代中,元素由 OpenSSL "deleted"(在这种情况下,deletefree 都不会被调用:它们只是设置为特定值)。这是一次迭代后内存的样子:

此处的最后一个元素设置为 ff ff ff 7f,我认为这是 OpenSSL 确保在稍后未分配内存时不会泄漏关键信息的方式。

循环之后(调用OPENSSL_free()之前),内存如下:

所有元素都设置为ff ff ff 7fasn1_cbNULL所以没有调用。接下来的事情是调用 OPENSSL_free(*pval).

在看似有效且已分配的内存上对 free() 的调用失败并导致执行中止并显示消息:"HEAP CORRUPTION DETECTED"

对此感到好奇,我连接到 mallocreallocfree(在 OpenSSL 允许的情况下)以确保这不是双重免费或从不免费 -分配的内存。事实证明 0x00000053379575E0 处的内存确实是一个 12 字节的块,确实已分配(之前从未释放过)。

我不知道这里发生了什么:根据我的研究,似乎 free() 在通常由 malloc() 返回的指针上失败。除此之外,这个内存位置之前被写入了几条指令,没有任何问题,这证实了内存被正确分配的假设。

我知道在没有所有信息的情况下远程调试即使不是不可能也很难,但我不知道下一步应该做什么。

所以我的问题是:这个 "HEAP CORRUPTION" 究竟是如何被 Visual Studio 的调试器检测到的?源自对 free() 的调用时,所有可能的原因是什么?

一般来说,可能性包括:

  1. 免费重复。
  2. 之前重复免费。
  3. (最有可能)您的代码在开始之前或结束之后超出了分配的内存块的限制。 malloc() 和朋友们在这里放了额外的簿记信息,比如大小,可能还有完整性检查,你会因为覆盖而失败。
  4. 释放未被 malloc() 编辑的内容。
  5. 继续写入已经 free()-d 的块。

我终于找到问题并解决了。

原来一些指令正在将字节写入分配的堆缓冲区(因此 0x00000000 而不是预期的 0xfdfdfdfd)。

在调试模式下,直到使用 free() 释放内存或使用 realloc() 重新分配内存之前,都不会检测到这种内存保护覆盖。这就是导致我遇到 HEAP CORRUPTION 消息的原因。

我预计在发布模式下,这可能会产生戏剧性的效果,例如覆盖应用程序中其他地方使用的有效内存块。


为了日后遇到类似问题的人参考,我是这样做的:

OpenSSL 提供了一个CRYPTO_set_mem_ex_functions()函数,定义如下:

int CRYPTO_set_mem_ex_functions(void *(*m) (size_t, const char *, int),
                                void *(*r) (void *, size_t, const char *,
                                            int), void (*f) (void *))

此函数允许您挂接和替换 OpenSSL 中的内存 allocation/freeing 函数。好的是添加了 const char *, int 参数,这些参数基本上由 OpenSSL 为您填充并包含 filenameline number分配。

有了这些信息,很容易找出分配内存块的地方。然后,我可以在查看等待内存块损坏的内存检查器的同时单步执行代码。

在我的例子中发生的事情是:

if (!combine) {
    *pval = OPENSSL_malloc(it->size); // <== The allocation is here.

    if (!*pval) goto memerr;

    memset(*pval, 0, it->size);
    asn1_do_lock(pval, 0, it);
    asn1_enc_init(pval, it);
}
for (i = 0, tt = it->templates; i < it->tcount; tt++, i++) {
    pseqval = asn1_get_field_ptr(pval, tt);

    if (!ASN1_template_new(pseqval, tt))
        goto memerr;
}
在 3 个序列元素上调用

ASN1_template_new() 来初始化它们。

结果 ASN1_template_new() 依次调用 asn1_item_ex_combine_new() 这样做:

if (!combine)
    *pval = NULL;

pvalASN1_VALUE**,此指令在 Windows x64 系统上设置 8 个字节而不是预期的 4 个字节,导致列表最后一个元素的内存损坏。

有关上游如何解决此问题的完整讨论,请参阅 this thread