openlang

memset перед free проходит с флагами по умолчанию, падает с OPT=-O2

Пишу небольшой хранитель учётных данных на C (WSL, Neovim, ничего особенного). vault_release() должна затирать секрет, а потом освобождать его:

void vault_release(vault_entry *e)
{
    if (!e)
        return;

    memset(e->secret, 0, e->secret_len);
    free(e->secret);
    e->secret = NULL;
    free(e);
}

memset стоит прямо там, на всю длину. Я перечитал три раза. Тут нет ничего хитрого.

Мой тест, tests/wipe_check.c, сохраняет указатель на секрет, вызывает vault_release(), а потом читает освобождённый блок. Да, это UB. Тест одноразовый, и мне правда нужно только посмотреть, что осталось в памяти. Первые 16 байт я пропускаю, потому что glibc хранит там свою служебную информацию.

Обычный make check печатает строку ok. Потом make release-check, который делает clean и пересобирает с OPT=-O2:

release-check:
	$(MAKE) clean
	$(MAKE) check OPT=-O2

И теперь падает. Строка FAIL говорит, что байты секрета уцелели, и когда я смотрю, затёрты только первые 16 байт. Всё остальное так и лежит там исходным секретом, читаемое.

Тот же исходник, тот же memset. Обычная сборка, насколько я знаю, уже была оптимизированной, раз это значение по умолчанию, так что я не понимаю, что меняет -O2. Так что же из двух? free() в glibc что-то записывает обратно в блок, или моя цель с -O2 собирает не то же самое, что обычная? Сгенерированный ассемблер я пока не смотрел.

Прежде чем кто-нибудь скажет «не читайте освобождённую память»: я знаю. Ответьте на настоящий вопрос.

Переведено с английскогоПоказать оригинал

Разговор продолжается 6

Следуя доводам
  1. Ссылка на ответ

    Оптимизации по умолчанию нет, это неверное предположение. GCC без флага -O это -O0, так что в обычной сборке вызов memset остаётся, а в сборке с -O2 нет.

    Первые 16 байт это указатель next и key от tcache в glibc, их пишет free(), поэтому меняются только они. Запустите gcc -O2 -S на файле и сделайте grep по memset в vault_release. Думаю, его там не будет: компилятор знает, что free() завершает время жизни объекта, и считает запись мёртвой.

    Используйте explicit_bzero() или memset_s(), где он есть, вместо обычного memset.

    Переведено с английскогоПоказать оригинал
  2. Ссылка на ответ

    Ладно, lathe прав, и я перепутал значение по умолчанию. Нет флага -O значит -O0, так что моя «обычная» сборка была неоптимизированной, а -O2 единственная, которая удаляет запись. Я винил glibc в том, что сделал компилятор.

    Стандарт это подтверждает. По правилу as-if из C11 5.1.2.3 компилятор должен сохранять только наблюдаемое поведение. Запись в объект, который больше нигде не читается до free(), ненаблюдаема, так что её можно убрать. Мой тест читает блок после free, но это чтение является UB, так что компилятор не обязан о нём заботиться. Тест никогда не мог защитить memset. Те 16 затёртых байт это просто free() занимается своим делом, как и сказал lathe.

    Решение это explicit_bzero(), он есть в glibc с версии 2.25. Страница man здесь: https://man7.org/linux/man-pages/man3/explicit_bzero.3.html

    memset_s относится к Annex K, который необязателен, и я бы очень не рассчитывал, что он есть в WSL.

    Я запущу gcc -O2 -S на vault.c и сделаю grep по memset, чтобы убедиться, что его нет, потом заменю вызов и снова запущу make release-check. После этого тест должен проходить на обоих уровнях.

    Переведено с английскогоПоказать оригинал
  3. Ссылка на ответ

    Дополнительный вопрос, раз чтение освобождённого блока всё равно UB: как разумно проверить, что затирание происходит в сборке с -O2? Затирать буфер, которым я всё ещё владею, и смотреть его содержимое? Или просто читать дизассемблированный код? Мне очень не хочется тест, который проходит только по везению.

    Переведено с английскогоПоказать оригинал

Другие стороны того же вопроса

  1. -race сообщает DATA RACE, хотя ack — это приём из небуферизованного канала. Виноват time.After?

    Может ли быть, что race detector здесь ошибается, или я неправильно понимаю, что мне даёт ack? Это pushgate, небольшой сервис…

    Ответы 4Последняя активность go
  2. Несовпадение HMAC только на одном вебхуке (с акцентами в имени)

    Написал небольшой чекер на Node (чистый JS, без сборки): он переигрывает сохранённые доставки вебхуков и проверяет HMAC-SHA25…

    Ответы 7Последняя активность typescriptjavascript
  3. Одна и та же проверка переполнения, разные ветки ошибки на -O0 и -O2

    Враждебный pack с count=65536 и rec_size=32768. На -O0 получаю "pack too large". На -O2 вместо этого получаю "out of memory".…

    Ответы 6Последняя активность c