什么可以解释调用 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 个字节(在本例中,2
、5
和 10
)。在每次循环迭代中,元素由 OpenSSL "deleted"(在这种情况下,delete
或 free
都不会被调用:它们只是设置为特定值)。这是一次迭代后内存的样子:
此处的最后一个元素设置为 ff ff ff 7f
,我认为这是 OpenSSL 确保在稍后未分配内存时不会泄漏关键信息的方式。
循环之后(调用OPENSSL_free()
之前),内存如下:
所有元素都设置为ff ff ff 7f
,asn1_cb
是NULL
所以没有调用。接下来的事情是调用 OPENSSL_free(*pval)
.
在看似有效且已分配的内存上对 free()
的调用失败并导致执行中止并显示消息:"HEAP CORRUPTION DETECTED"。
对此感到好奇,我连接到 malloc
、realloc
和 free
(在 OpenSSL 允许的情况下)以确保这不是双重免费或从不免费 -分配的内存。事实证明 0x00000053379575E0
处的内存确实是一个 12 字节的块,确实已分配(之前从未释放过)。
我不知道这里发生了什么:根据我的研究,似乎 free()
在通常由 malloc()
返回的指针上失败。除此之外,这个内存位置之前被写入了几条指令,没有任何问题,这证实了内存被正确分配的假设。
我知道在没有所有信息的情况下远程调试即使不是不可能也很难,但我不知道下一步应该做什么。
所以我的问题是:这个 "HEAP CORRUPTION" 究竟是如何被 Visual Studio 的调试器检测到的?源自对 free()
的调用时,所有可能的原因是什么?
一般来说,可能性包括:
- 免费重复。
- 之前重复免费。
- (最有可能)您的代码在开始之前或结束之后超出了分配的内存块的限制。
malloc()
和朋友们在这里放了额外的簿记信息,比如大小,可能还有完整性检查,你会因为覆盖而失败。
- 释放未被
malloc()
编辑的内容。
- 继续写入已经
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 为您填充并包含 filename 和 line 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;
pval
是 ASN1_VALUE**
,此指令在 Windows x64 系统上设置 8 个字节而不是预期的 4 个字节,导致列表最后一个元素的内存损坏。
有关上游如何解决此问题的完整讨论,请参阅 this thread。
几天来我一直在调试一个崩溃,它发生在 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 个字节(在本例中,2
、5
和 10
)。在每次循环迭代中,元素由 OpenSSL "deleted"(在这种情况下,delete
或 free
都不会被调用:它们只是设置为特定值)。这是一次迭代后内存的样子:
此处的最后一个元素设置为 ff ff ff 7f
,我认为这是 OpenSSL 确保在稍后未分配内存时不会泄漏关键信息的方式。
循环之后(调用OPENSSL_free()
之前),内存如下:
所有元素都设置为ff ff ff 7f
,asn1_cb
是NULL
所以没有调用。接下来的事情是调用 OPENSSL_free(*pval)
.
在看似有效且已分配的内存上对 free()
的调用失败并导致执行中止并显示消息:"HEAP CORRUPTION DETECTED"。
对此感到好奇,我连接到 malloc
、realloc
和 free
(在 OpenSSL 允许的情况下)以确保这不是双重免费或从不免费 -分配的内存。事实证明 0x00000053379575E0
处的内存确实是一个 12 字节的块,确实已分配(之前从未释放过)。
我不知道这里发生了什么:根据我的研究,似乎 free()
在通常由 malloc()
返回的指针上失败。除此之外,这个内存位置之前被写入了几条指令,没有任何问题,这证实了内存被正确分配的假设。
我知道在没有所有信息的情况下远程调试即使不是不可能也很难,但我不知道下一步应该做什么。
所以我的问题是:这个 "HEAP CORRUPTION" 究竟是如何被 Visual Studio 的调试器检测到的?源自对 free()
的调用时,所有可能的原因是什么?
一般来说,可能性包括:
- 免费重复。
- 之前重复免费。
- (最有可能)您的代码在开始之前或结束之后超出了分配的内存块的限制。
malloc()
和朋友们在这里放了额外的簿记信息,比如大小,可能还有完整性检查,你会因为覆盖而失败。 - 释放未被
malloc()
编辑的内容。 - 继续写入已经
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 为您填充并包含 filename 和 line 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;
pval
是 ASN1_VALUE**
,此指令在 Windows x64 系统上设置 8 个字节而不是预期的 4 个字节,导致列表最后一个元素的内存损坏。
有关上游如何解决此问题的完整讨论,请参阅 this thread。