Что может объяснить повреждение кучи при вызове free()?

Я отлаживал крах в течение нескольких дней, что происходит в глубинах OpenSSL (обсуждение с сопровождающими здесь). Я потратил некоторое время на исследование, поэтому постараюсь сделать этот вопрос интересным и информативным.

Во-первых, и чтобы дать некоторый контекст, моя минимальная выборка, которая воспроизводит краш, следующая:

#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 и в Wikis онлайн. Он отлично работает на Linux (valgrind вообще не сообщает об ошибке). Он только сбой в Windows (Visual Studio 2013 - x64). Я убедился, что правильное время работы связано с (/MD в моем случае, для всех зависимостей).

Не боясь зла, я перекомпилировал OpenSSL в x64-debug (на этот раз, связав все в /MDd), и /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 -loop, заключается в том, что он проходит через все элементы последовательности (начиная с последнего элемента) и "удаляет" их (подробнее об этом позже).

Прямо перед сбоем происходит удаление из трех элементов (при *pval, который равен 0x00000053379575E0). Глядя на память, можно увидеть следующее:

memory dump #1

Последовательность имеет длину 12 байт, каждый элемент имеет длину 4 байта (в данном случае 2, 5 и 10). На каждой итерации цикла элементы "удаляются" OpenSSL (в этом контексте не delete ни delete ни free: они просто установлены на определенное значение). Вот как выглядит память после одной итерации:

memory dump #2

Последний элемент здесь был установлен как ff ff ff 7f который, как я полагаю, является способом OpenSSL для обеспечения отсутствия утечки информации о ключах, когда память не будет распределена позже.

Сразу после цикла (и до вызова OPENSSL_free()) память выглядит следующим образом:

memory dump #3

Все элементы были установлены в 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()?

Ответ 1

В целом, возможности включают:

  1. Дублируйте бесплатно.
  2. До дублирования бесплатно.
  3. (Наиболее вероятно). Ваш код написал за пределами выделенного фрагмента памяти либо до начала, либо после конца. malloc() и друзья добавляют дополнительную информацию о бухгалтерском учете здесь, например, размер и, вероятно, проверку на работоспособность, которую вы не сможете перезаписать.
  4. Освободить то, что не было malloc() -ed.
  5. Продолжая писать в кусок, который уже был free() -d.

Ответ 2

Я мог бы, наконец, найти проблему и решить ее.

Оказалось, что какая-то инструкция записывала байты через выделенный буфер кучи (следовательно, 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. Хорошая вещь - это добавление параметров const char *, int которые в основном заполняются для вас OpenSSL и содержат имя файла и номер строки выделения.

Вооружившись этой информацией, было легко узнать место, где был выделен блок памяти. Затем я мог бы пройти через код, глядя на инспектор памяти, ожидая, что блок памяти будет поврежден.

В моем случае, что случилось:

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;
}

ASN1_template_new() вызывается для трех элементов последовательности для их инициализации.

ASN1_template_new() вызовы в свою очередь asn1_item_ex_combine_new() который делает это:

if (!combine)
    *pval = NULL;

pval является ASN1_VALUE**, эта инструкция устанавливает 8 байтов в системах Windows x64 вместо предполагаемых 4 байтов, что приводит к повреждению памяти для последнего элемента списка.

Для полного обсуждения того, как эта проблема была решена выше, см. Эту ветку.