Pull to refresh

Безопасное криптопрограммирование. Часть 2, заключительная

Reading time12 min
Views19K
Продолжаем перевод набора правил безопасного криптопрограммирования от Жана-Филлипа Омассона…

Предотвращайте вмешательство компилятора в части кода, критическим образом влияющие на безопасность


Проблема


Некоторые компиляторы оптимизируют операции, которые они считают бесполезными.

Например, компилятор MS Visual C++ посчитал лишним оператор |memset| в следующем фрагменте кода реализации анонимной сети Tor:

int
crypto_pk_private_sign_digest(...)
{
  char digest[DIGEST_LEN];
  (...)
  memset(digest, 0, sizeof(digest));
  return r;
}

Однако роль этого оператора |memset| заключается в очистке буфера |digest| от конфиденциальных данных, таким образом, чтобы при любых последующих считываниях данных из неинициализированного стека невозможно было получить конфиденциальную информацию.

Некоторые компиляторы считают, что они могут удалять проверки условий, считая код ошибочным где-либо в программе. Например, обнаружив следующий фрагмент кода

  call_fn(ptr); // всегда разыменовывает ptr.
 
  // много много строк
 
  if (ptr == NULL) { error("ptr must not be NULL"); }

некоторые компиляторы решат, что условие |ptr == NULL| всегда должно принимать значение ЛОЖЬ, поскольку в противном случае было бы некорректным разыменовывать его в функции |call_fn()|.

Решение


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

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

Когда это возможно, подумайте об отключении той оптимизации при компиляции, которая удаляет или ослабляет проверку условий, влияющих на безопасность.

Чтобы предотвратить удаление инструкций посредством оптимизации, функция может быть переопределена с использованием ключевого слова volatile. Это например используется в libottery при переопределении |memset|:

void * (*volatile memset_volatile)(void *, int, size_t) = memset;

В C11 введен вызов memset_s, для которого запрещено удаление при оптимизации.

#define __STDC_WANT_LIB_EXT1__ 1
#include <string.h>
...
memset_s(secret, sizeof(secret), 0, sizeof(secret));


Не допускайте смешения безопасных и небезопасных программных интерфейсов


Проблема


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

Эта проблема характерна для датчиков случайных чисел: в OpenSSL есть |RAND_bytes()| и |RAND_pseudo_bytes()|, в C-библиотеках BSD есть |RAND_bytes()| и |RAND_pseudo_bytes()|, в Java – |SecureRandom| и |Random|

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

Плохие решения


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

В системах, которые разрешают переопределение платформозависимых функций, некоторые программисты переопределяют небезопасные функции безопасными и пишут программы, используя программный интерфейс, который в обычной ситуации является небезопасным. Это достаточно спорный подход, поскольку приводит к тому, что программист пишет код, который выглядит небезопасным. Более того, если переопределенный метод не сработает когда-либо, программа станет небезопасной и это нельзя будет определить. И наконец, это приведет к тому, что фрагменты кода таких программ будут небезопасны, если их скопировать в другие проекты.

Решение


По возможности не используйте небезопасные варианты безопасных функций. Например, ПДСЧ на базе стойкого поточного шифра со случайным начальным заполнением достаточно быстр для большинства приложений. Независимая от типа данных замена memcmp также достаточно быстра, чтобы быть использованной для всех операций сравнения областей памяти.

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

Если необходимо оставить оба варианта (безопасный и небезопасный) удостоверьтесь, что имена функций различны настолько, что будет затруднительно случайно использовать небезопасный вариант. Например, если у вас есть безопасный и небезопасный ПДСЧ, не называйте небезопасный вариант «Random», «FastRandom», «MersenneTwister» или «LCGRand» – вместо этого назовите его, например, «InsecureRandom». Разрабатывайте свои программные интерфейсы таким образом, чтобы использование небезопасных функций всегда немного пугало.

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

Если функция безопасна на одних платформах и небезопасна на других, не используйте функцию непосредственно: вместо этого определите и используйте безопасную обертку.

Избегайте смешения уровней безопасности и абстракции криптографических примитивов на одном уровне API


Проблема


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

Рассмотрим следующий пример (придуманный, но похожий на те, которые встречаются в реальной жизни) программного интерфейса RSA:

enum rsa_padding_t { no_padding, pkcs1v15_padding, oaep_sha1_padding, pss_padding };
int do_rsa(struct rsa_key *key, int encrypt, int public, enum rsa_padding_t padding_type, uint8_t *input, uint8_t *output);

Предположим, что параметр “key” содержит компоненты реквизитов, тогда функция может быть вызвана 16-ю способами, многие из которых бессмысленны, а некоторые небезопасны.
шифрование/расшифрование симметричное/асимметричное
тип паддинга
замечания
0 0 none Расшифрование без паддинга. Возможность подделки.
0 0 pkcs1v15 Расшифрование PKCS1 v1.5. Возможно, подвержено атаке Блейнбахера.
0 0 oaep Расшифрование OAEP. Хороший вариант.
0 0 pss Расшифрование PSS. Достаточно странный вариант, возможно, приводит к непреднамеренным ошибкам
0 1 none Подпись без паддинга. Возможность подделки.
0 1 pkcs1v15 Подпись PKCS1 v1.5. Подходит для некоторых приложений, но лучше использовать подпись PSS.
0 1 oaep Подпись OAEP. Подходит для некоторых приложений но лучше использовать подпись PSS.
0 1 pss Подпись PSS. Очень хороший вариант.
... ... ... оставшиеся варианты (шифрование и проверка подписи).


Отметим, что только 4 из 16-ти возможных способов вызова этой функции безопасны, еще 6 небезопасны, а оставшиеся 6 в некоторых случаях могут вызвать проблемы при применении. Такой API подходит только для тех разработчиков, кто понимает последствия применения различных способов дополнения в системе RSA.

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

Решение


  • Предоставляйте высокоуровневые программные интерфейсы. Например, предоставьте функции, реализующие шифрование и аутентификацию данных, которые используют только стойкие алгоритмы и при этом безопасным образом. Когда вы пишете функцию, которая предоставляет различные комбинации симметричных и асимметричных алгоритмов и их режимов работы, удостоверьтесь, что эта функция не позволяет использовать небезопасные алгоритмы и их небезопасные комбинации.
  • Когда это возможно, избегайте низкоуровневых API. Большинству пользователей нет необходимости использовать RSA без дополнения, использовать блочный шифр в режиме ECB или использовать подпись DSA с выбранным пользователем случайным значением. Эти функции могут быть использованы как строительные блоки для того, чтобы реализовать что-нибудь стойкое – например, сделать OAEP-паддинг до вызова RSA без дополнения, использовать шифрование в режиме ECB для блоков 1,2,3,…, чтобы реализовать режим счетчика или использовать случайную или непредсказуемую байтовую последовательность для случайного значения DSA, но практика показывает, что они чаще будут использоваться неправильно, нежели правильно.

    Некоторые другие примитивы необходимы для реализации определенных протоколов, но скорее всего не будут подходящими для реализации новых протоколов. Например, вы не можете реализовать в настоящее время совместимый с браузером TLS без CBC, PKCS1 v1.5 и RC4, но любой из данных примитивов не является хорошим вариантом.

    Если вы предоставляете криптографический модуль для использования неопытными программистами, будет лучше избегать таких функций полностью и выбирать (для API) только функции, которые реализуют хорошо описанные высокоуровневые безопасные операции.
  • Если же вы все-таки должны предоставлять интерфейс и опытным, и неопытным пользователям, четко разделите высокоуровневый и низкоуровневые программные интерфейсы. Функция «безопасного шифрования» не должна быть той же самой функцией, что и «некорректное шифрование» с несколько измененными аргументами. В языках, которые разделяют функции и типы в пакеты и заголовки, безопасные и небезопасные криптофункции не должны содержаться в одних и тех же пакетах и заголовках. В языках с подтипами, должны быть отдельные типы для безопасных криптореализаций.


Используйте беззнаковые типы для представления двоичных данных


Проблема


В некоторых C-подобных языках знаковые и беззнаковые целочисленные типы являются различными. В частности, в C вопрос о том является ли тип |char| знаковым зависит от реализации. Это может привести к появлению проблемного кода – такого, например, как приведенный далее:

int decrypt_data(const char *key, char *bytes, size_t len);
 
void fn(...) {
    //...
    char *name;
    char buf[257];
    decrypt_data(key, buf, 257);
 
    int name_len = buf[0];
    name = malloc(name_len + 1);
    memcpy(name, buf+1, name_len);
    name[name_len] = 0;
    //...
}

Если |char| беззнаковый, то данный код ведет себя так, как мы от него ожидаем. Но если |char| знаковый, |buf[0]| может принимать отрицательные значения, приводя к очень большим значениям аргументов функций |malloc| и |memcpy| и возможности повреждения кучи, если мы пытаемся установить значение последнего знака в 0. Ситуация может быть даже хуже, если |buf[0]| равен 255, тогда name_len будет равным -1. Таким образом, мы выделим в памяти буфер размера 0 байтов, а затем произведем копирование |(size_t)-1 memcpy| в данный буфер, что приведет к засорению кучи.

Решение


В языках, которые различают знаковые и беззнаковые байтовые типы, реализации должны использовать беззнаковые типы для представления байтовых строк в своих API.

Очищайте память от секретных данных


Проблема


В большинстве операционных систем память, используемая одним процессом, может быть использована другим процессом без предварительной очистки, потому что первый процесс остановлен или возвратил память системе. Если память содержит секретные ключи, они будут доступны другому процессу, что увеличивает шанс их компрометации. В многопользовательских системах это дает возможность определять ключи других пользователей системы. Даже внутри одной системы подобная ситуация может привести к тому, что ранее сравнительно «безопасные» уязвимости могут приводить к утечке секретных данных.

Решение


Очищайте все переменные, содержащие секретные данные, до того момента как вы о них забудете и предстанете пользоваться. Используя функцию |mmap()| помните, что запуск |munmap()| моментально освобождает память и вы теряете над ней контроль.

Для очистки памяти или уничтожения объектов, которые уходят из вашего поля зрения, используйте платформозависимые функции очистки памяти, где это возможно – такие как |SecureZeroMemory()| для win32 или |OPENSSL_cleanse()| для OpenSSL.

Более-менее универсальное решение для C может быть таким:

void burn( void *v, size_t n )
{
  volatile unsigned char *p = ( volatile unsigned char * )v;
  while( n-- ) *p++ = 0;
}


Используйте «сильную» случайность



Проблема


Многим криптографическим системам требуются источники случайности, при этом такие системы могут становиться небезопасными даже в случае небольших отклонений от случайности в таких источниках. Например, утечка даже одного случайного числа в DSA приведет к крайне быстрому определению секретного ключа. Недостаточную случайность бывает довольно тяжело определить: ошибка генератора случайных чисел Debian в OpenSSL оставалась незамеченной на протяжении двух лет, приведя к компрометации большого числа ключей. Требования к случайным числам для криптографических приложений очень жесткие: многие генераторы псевдослучайных чисел не удовлетворяют им.

Плохие решения


Для криптографических приложений

  • Не полагайтесь на предсказуемые источники случайности, такие как метки времени, идентификаторы, температурные датчики и т.д.
  • не полагайтесь на функции выработки псевдослучайных чисел общего пользования, такие как |rand()|,|srand()|,|random()| библиотеки |stdlib| или |random| языка Python
  • Не используйте генератор Вихрь Мерсенна (Mersenne Twister)
  • Не используйте ресурсы наподобие www.random.org (случайные данные могут стать известны третьим лицам или быть также использованы ими).
  • Не используйте свой собственный генератор случайных чисел, даже если он основан на стойком криптопримитиве (если только вы точно не знаете, что делаете).
  • Не используйте одни и те же случайные биты в различных местах приложения, для их «экономного» расходования.
  • Не делайте вывод о том, что генератор стойкий только по тому, что он проходит тесты Diehard или NIST.
  • Не делайте вывод о том, что криптографически стойкий генератор обязательно защищает от чтения вперед и чтения назад.
  • Никогда не используйте «случайность» в чистом виде в качестве случайных данных (аналоговые источники случайности зачастую имеют отклонения, поэтому N битов, полученных с такого источника, имеет меньше N битов случайности).


Решение


Минимизируйте использование случайности посредством выбора примитивов и их дизайна (например, Ed25519 позволяет получать кривые для электронной подписи детерминированным образом). Для выработки случайных чисел используйте источники, предоставляемые операционными системами и гарантированно удовлетворяющие криптографическим требованиям, такие как |/dev/random|. На платформах с ограниченными ресурсами рассмотрите возможность использования аналоговых источников случайного шума и хорошей процедуры замешивания.

Обязательно проверяйте значения, производимые вашим датчиком, чтобы быть уверенными, что получаемые байты такие, какие они должны быть, и что они были записаны должным образом.

Следуйте рекомендациям Нади Хенингер и др. в разделе 7 их статьи.

На процессорах Intel с архитектурой Ivy Bridge (и последующих поколений), встроенный генератор гарантирует высокою энтропию и скорость работы.

В Unix системах обычно используются |/dev/random| или |/dev/urandom|. Однако первый из них имеет свойство блокирования, т.е. он не возвращает значений в случае, если полагает, что накоплено недостаточно случайности. Это свойство ограничивает удобство
его использования, и поэтому |/dev/urandom| используется чаще. Использовать |/dev/urandom| достаточно просто:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
 
int main() {
  int randint;
  int bytes_read;
  int fd = open("/dev/urandom", O_RDONLY);
  if (fd != -1) {
    bytes_read = read(fd, &randint, sizeof(randint));
    if (bytes_read != sizeof(randint)) {
      fprintf(stderr, "read() failed (%d bytes read)\n", bytes_read);
      return -1;
    }
  }
  else {
    fprintf(stderr, "open() failed\n");
    return -2;
  }
  printf("%08x\n", randint); /* assumes sizeof(int) <= 4 */
  close(fd);
  return 0;
}

Однако этой простой программы может быть недостаточно для безопасной выработки случайности: более безопасным будет выполнение дополнительных проверок на ошибки как в функции |getentropy_urandom| LibreSSL

static int
getentropy_urandom(void *buf, size_t len)
{
	struct stat st;
	size_t i;
	int fd, cnt, flags;
	int save_errno = errno;
 
start:
 
	flags = O_RDONLY;
#ifdef O_NOFOLLOW
	flags |= O_NOFOLLOW;
#endif
#ifdef O_CLOEXEC
	flags |= O_CLOEXEC;
#endif
	fd = open("/dev/urandom", flags, 0);
	if (fd == -1) {
		if (errno == EINTR)
			goto start;
		goto nodevrandom;
	}
#ifndef O_CLOEXEC
	fcntl(fd, F_SETFD, fcntl(fd, F_GETFD) | FD_CLOEXEC);
#endif
 
	/* Lightly verify that the device node looks sane */
	if (fstat(fd, &st) == -1 || !S_ISCHR(st.st_mode)) {
		close(fd);
		goto nodevrandom;
	}
	if (ioctl(fd, RNDGETENTCNT, &cnt) == -1) {
		close(fd);
		goto nodevrandom;
	}
	for (i = 0; i < len; ) {
		size_t wanted = len - i;
		ssize_t ret = read(fd, (char *)buf + i, wanted);
 
		if (ret == -1) {
			if (errno == EAGAIN || errno == EINTR)
				continue;
			close(fd);
			goto nodevrandom;
		}
		i += ret;
	}
	close(fd);
	if (gotdata(buf, len) == 0) {
		errno = save_errno;
		return 0;		/* satisfied */
	}
nodevrandom:
	errno = EIO;
	return -1;
}

В Windows-системах |CryptGenRandom| из Win32 API вырабатывает псевдослучайные биты пригодные для использования в криптографии. Microsoft предлагает следующий вариант использования:

#include <stddef.h>
#include <stdint.h>
#include <windows.h>
 
#pragma comment(lib, "advapi32.lib")
 
int randombytes(unsigned char *out, size_t outlen)
{
  static HCRYPTPROV handle = 0; /* only freed when program ends */
  if(!handle) {
    if(!CryptAcquireContext(&handle, 0, 0, PROV_RSA_FULL,
                            CRYPT_VERIFYCONTEXT | CRYPT_SILENT)) {
      return -1;
    }
  }
  while(outlen > 0) {
    const DWORD len = outlen > 1048576UL ? 1048576UL : outlen;
    if(!CryptGenRandom(handle, len, out)) {
      return -2;
    }
    out    += len;
    outlen -= len;
  }
  return 0;
}

Если ориентироваться на использование в Windows XP или более поздних версиях, указанный выше код на CryptoAPI может быть заменен на |RtlGenRandom|

#include <stdint.h>
#include <stdio.h>
 
#include <Windows.h>
 
#define RtlGenRandom SystemFunction036
#if defined(__cplusplus)
extern "C"
#endif
BOOLEAN NTAPI RtlGenRandom(PVOID RandomBuffer, ULONG RandomBufferLength);
 
#pragma comment(lib, "advapi32.lib")
 
int main()
{
	uint8_t buffer[32] = { 0 };
 
	if (FALSE == RtlGenRandom(buffer, sizeof buffer))
		return -1;
 
	for (size_t i = 0; i < sizeof buffer; ++i)
		printf("%02X ", buffer[i]);
	printf("\n");
 
	return 0;
}
Tags:
Hubs:
Total votes 19: ↑19 and ↓0+19
Comments17

Articles