Дисклеймер: Описанная ниже потенциальная уязвимость на данный момент исправлена: 18 декабря 2014 была обновлена версия на Google Play, 3 января 2015 были внесены правки в публичный код на GitHub.
Так сложилось, что мне необходимо было изучить исходные коды механизма шифрования, передачи и дешифрования сообщений в Telegram для мобильных платформ iOS и Android. То есть речь идет о клиентских приложениях, именно их исходники (iOS, Android) находятся в свободном доступе.
Так как я больше специализируюсь в iOS, то в первую очередь приступил к изучению версии для этой платформы. Потратив около дня на чтение исходников и на работу с отладчиком, я сообразил что к чему и приступил к Android версии. Несложно догадаться, что механизмы и принципы работы должны быть идентичны в силу совместимости всех платформ между собой. Но к своему удивлению я обнаружил несколько отличий в алгоритме дешифрования сообщений в Android версии, что и породило уязвимость, если можно так выразиться. Общая суть уязвимости заключается в том, что в клиентском приложении отсутствует сравнение хеша дешифрованного сообщения с оригинальным хешем, передаваемым вместе с зашифрованным сообщением. По сути отсутствует проверка подписи сообщения. Отсутствие такой проверки может позволить третьим лицам, имеющим доступ к серверу, создавать рандомную активность от лиц участвующих в секретном чате. При этом доступ к общему секретному ключу не требуется, и он остается неуязвим для третьих лиц.
Чтобы разобраться в сути, давайте для начала рассмотрим принцип обмена сообщениями. Он состоит из трех основных этапов:
Замечание: Я здесь намеренно опустил этапы клиент-серверного взаимодействия (установка соединения, передача/прием сообщений), так как они представляют собой точно такие же 3 этапа. То есть для шифрования/дешифрования отдельного сообщения и для передачи данных между клиентом и сервером используется один и тот же принцип защиты.
Принцип генерации общего секретного ключа построен на протоколе Диффи-Хеллмана.
Шифрование:
Дешифрование:
С теорией разобрались. Пришло время перейти к практике.
Рассмотрим код дешифрования сообщения для обеих платформ (в коде генерации общего секретного ключа и шифрования сообщения отличий либо ошибок найдено не было, поэтому мы его опустим). Код соответствует последней ревизии ветки master. Принципиально важные проверки пронумерованы в комментариях (1, 2 ,3).
Telegram iOS: TGUpdateStateRequestBuilder.mm
Telegram Android: SecretChatHelper.java
Как видно из кода, в iOS версии выполняются следующие проверки:
В Android версии проверки 2 и 3 отсутствуют.
Рассмотрим ситуацию, в которой отсутствие этих проверок может повлиять на секретный чат:
Для конструктивного диалога позовем Алису и Боба.
И так, действующие лица:
Сценарий:
Вероятность успешного создания объекта примерно равна 382 / 2^32 ≃ 8.9 * 10^-8, где
382 — количество классов содержащихся в словаре;
32 — длина идентификатора класса в битах.
Вероятность, конечно, невысокая, но так как неуспешные случаи проходят незаметно для пользователя, то злоумышленник может непрерывно отправлять сообщения, ограничиваясь только шириной канала подключения клиента к серверу. В таком случае атака может быть вполне осуществимой. Если предположить, что минимальный трафик на одно сообщение может составлять около 100 байт, то потребуется около 1 ГБ трафика для гарантированного создания объекта.
Попробуем прикинуть вероятность успешной атаки в случае наличия хотя бы одной из пропущенных проверок:
При наличии проверки длины сообщения: (2^10 / 2^32) * (382 / 2^32) ≃ 2.1 * 10^-18, где
2^10 = 1024 — максимальная валидная длина сообщения, примерно столько памяти занимает обычное сообщение;
32 = 4 байта, столько памяти занимает длина сообщения.
При наличии проверки ключа (хеша) сообщения: (1 / 2^128) * (382 / 2^32) ≃ 2.6 * 10^-46, где
128 — длина ключа (хеша) сообщения.
Стоит отметить, что на других уровнях защиты проверка подписи сообщения присутствует. Например, при установке клиент-серверного соединения (используется тот же принцип, что и при обмене сообщениями): ConnectionsManager.java
Хоть это и выглядит немного странно, но я все-таки не думаю, что в отсутствии проверки подписи спрятан какой-то злой умысел, так как уязвимость не является критической. С другой стороны, возможно, есть и другие уязвимости, которые в паре с этой дают больший профит.
Тем не менее, на данный момент разработчики внесли необходимые правки в Dev ветку и обновили сборку в Google Play. Также хочется отметить тот факт, что за найденные мной недочеты разработчики выплатили вознаграждение в размере 5000$. Как говорится «не мелочь и приятно».
Так сложилось, что мне необходимо было изучить исходные коды механизма шифрования, передачи и дешифрования сообщений в Telegram для мобильных платформ iOS и Android. То есть речь идет о клиентских приложениях, именно их исходники (iOS, Android) находятся в свободном доступе.
Так как я больше специализируюсь в iOS, то в первую очередь приступил к изучению версии для этой платформы. Потратив около дня на чтение исходников и на работу с отладчиком, я сообразил что к чему и приступил к Android версии. Несложно догадаться, что механизмы и принципы работы должны быть идентичны в силу совместимости всех платформ между собой. Но к своему удивлению я обнаружил несколько отличий в алгоритме дешифрования сообщений в Android версии, что и породило уязвимость, если можно так выразиться. Общая суть уязвимости заключается в том, что в клиентском приложении отсутствует сравнение хеша дешифрованного сообщения с оригинальным хешем, передаваемым вместе с зашифрованным сообщением. По сути отсутствует проверка подписи сообщения. Отсутствие такой проверки может позволить третьим лицам, имеющим доступ к серверу, создавать рандомную активность от лиц участвующих в секретном чате. При этом доступ к общему секретному ключу не требуется, и он остается неуязвим для третьих лиц.
Чтобы разобраться в сути, давайте для начала рассмотрим принцип обмена сообщениями. Он состоит из трех основных этапов:
- Генерация общего секретного ключа;
- Шифрование исходящего сообщения;
- Дешифрование входящего сообщения.
Замечание: Я здесь намеренно опустил этапы клиент-серверного взаимодействия (установка соединения, передача/прием сообщений), так как они представляют собой точно такие же 3 этапа. То есть для шифрования/дешифрования отдельного сообщения и для передачи данных между клиентом и сервером используется один и тот же принцип защиты.
Принцип генерации общего секретного ключа построен на протоколе Диффи-Хеллмана.
Шифрование:
- Формируем объект, представляющий исходное сообщение;
- В спец. поле записываем массив от 1 до 16 рандомных байт;
- Исходный объект сериализуем в массив байт;
- С нулевой позиции массива выделяем 4 байта и записываем длину данных в массиве;
- Рассчитываем хеш (sha1) получившегося массива данных;
- Рассчитываем ключ сообщения (последние 16 байт хеша);
- На основе общего секретного ключа и ключа сообщения рассчитываем параметры для AES-256 шифрования;
- В исходный массив данных дописываем рандомные данные до тех пор, пока длина получившегося массива не будут кратна 16 (AES требует блоки данных размером 128 бит);
- Получившийся массив шифруем с помощью AES-256;
- Рассчитываем хеш (sha1) общего секретного ключа;
- Рассчитываем идентификатор общего секретного ключа (последние 8 байт хеша);
- Формируем конечный массив данных состоящий из идентификатора общего секретного ключа (8 байт), ключа сообщения (16 байт) и зашифрованного массива данных (размер как получится).
Дешифрование:
- Рассчитываем хеш (sha1) общего секретного ключа, который хранится локально;
- Рассчитываем идентификатор общего секретного ключа (последние 8 байт хеша);
- Считываем идентификатор общего секретного ключа из полученного массива данных (первые 8 байт);
- Сравниваем с локально рассчитанным идентификатором. В случае равенства переходим к следующему пункту, иначе игнорируем сообщение;
- Считываем ключ сообщения из полученного массива данных (следующие 16 байт);
- На основе общего секретного ключа и ключа сообщения рассчитываем параметры для AES-256 дешифрования;
- Считываем оставшиеся байты из полученного массива данных и дешифруем их с помощью AES-256;
- Считываем длину сообщения из дешифрованного массива данных (первые 4 байта);
- Проверяем длину сообщения: значение должно быть больше нуля и меньше длины оставшегося дешифрованного массива данных. Если длина валидна, то переходим к следующему пункту, иначе игнорируем сообщение;
- В дешифрованном массиве оставляем только полезные данные (удаляем первые 4 байта и байты в конце, если длина массива превышает длину сообщения);
- Рассчитываем хеш (sha1) дешифрованного массива данных;
- Рассчитываем ключ сообщения (последние 16 байт хеша);
- Сравниваем рассчитанный ключ сообщения с ключом, считанным из полученного массива данных. В случае равенства переходим к следующему пункту, иначе игнорируем сообщение;
- Десериализуем дешифрованный массив данных в объект, представляющий полученное сообщение.
С теорией разобрались. Пришло время перейти к практике.
Рассмотрим код дешифрования сообщения для обеих платформ (в коде генерации общего секретного ключа и шифрования сообщения отличий либо ошибок найдено не было, поэтому мы его опустим). Код соответствует последней ревизии ветки master. Принципиально важные проверки пронумерованы в комментариях (1, 2 ,3).
Telegram iOS: TGUpdateStateRequestBuilder.mm
//———————————————————————Cut———————————————————————
int64_t keyId = 0;
[encryptedMessage.bytes getBytes:&keyId range:NSMakeRange(0, 8)];
NSData *messageKey = [encryptedMessage.bytes subdataWithRange:NSMakeRange(8, 16)];
int64_t localKeyId = 0;
NSData *key = nil;
bool keyFound = false;
if (cachedKeys != NULL)
{
auto it = cachedKeys->find(conversationId);
if (it != cachedKeys->end())
{
keyFound = true;
localKeyId = it->second.first;
key = it->second.second;
}
}
if (!keyFound)
{
key = [TGDatabaseInstance() encryptionKeyForConversationId:conversationId keyFingerprint:&localKeyId];
if (cachedKeys != NULL)
(*cachedKeys)[conversationId] = std::pair<int64_t, NSData *>(localKeyId, key);
}
if (key != nil && keyId == localKeyId) // 1)
{
MessageKeyData keyData = [TGConversationSendMessageActor generateMessageKeyData:messageKey incoming:false key:key];
NSMutableData *messageData = [[encryptedMessage.bytes subdataWithRange:NSMakeRange(8 + 16, encryptedMessage.bytes.length - (8 + 16))] mutableCopy];
encryptWithAESInplace(messageData, keyData.aesKey, keyData.aesIv, false);
int32_t messageLength = 0;
[messageData getBytes:&messageLength range:NSMakeRange(0, 4)];
if (messageLength < 0 || messageLength > (int32_t)messageData.length - 4) // 2)
TGLog(@"***** Ignoring message from conversation %lld with invalid message length", encryptedMessage.chat_id);
else
{
NSData *localMessageKeyFull = computeSHA1ForSubdata(messageData, 0, messageLength + 4);
NSData *localMessageKey = [[NSData alloc] initWithBytes:(((int8_t *)localMessageKeyFull.bytes) + localMessageKeyFull.length - 16) length:16];
if (![localMessageKey isEqualToData:messageKey]) // 3)
TGLog(@"***** Ignoring message from conversation with message key mismatch %lld", encryptedMessage.chat_id);
else
{
NSInputStream *is = [[NSInputStream alloc] initWithData:messageData];
[is open];
[is readInt32];
int32_t signature = [is readInt32];
id decryptedObject = TLMetaClassStore::constructObject(is, signature, nil, nil, nil);
//———————————————————————Cut———————————————————————
Telegram Android: SecretChatHelper.java
//———————————————————————Cut———————————————————————
ByteBufferDesc is = BuffersStorage.getInstance().getFreeBuffer(message.bytes.length);
is.writeRaw(message.bytes);
is.position(0);
long fingerprint = is.readInt64();
byte[] keyToDecrypt = null;
boolean new_key_used = false;
if (chat.key_fingerprint == fingerprint) { // 1)
keyToDecrypt = chat.auth_key;
} else if (chat.future_key_fingerprint != 0 && chat.future_key_fingerprint == fingerprint) {
keyToDecrypt = chat.future_auth_key;
new_key_used = true;
}
if (keyToDecrypt != null) {
byte[] messageKey = is.readData(16);
MessageKeyData keyData = Utilities.generateMessageKeyData(keyToDecrypt, messageKey, false);
Utilities.aesIgeEncryption(is.buffer, keyData.aesKey, keyData.aesIv, false, false, 24, is.limit() - 24);
int len = is.readInt32();
TLObject object = TLClassStore.Instance().TLdeserialize(is, is.readInt32());
//———————————————————————Cut———————————————————————
Как видно из кода, в iOS версии выполняются следующие проверки:
- Сравниваем идентификатор (хеш) общего секретного ключа из тела входящего сообщения с идентификатором (хешем) локального общего секретного ключа;
- Сравниваем переданную длину дешифрованного сообщения с минимальной и максимальной допустимой длиной;
- Сравниваем ключ (хеш) полученного дешифрованного сообщения с ключом (хешом) оригинального сообщения, который был передан отправителем.
В Android версии проверки 2 и 3 отсутствуют.
Рассмотрим ситуацию, в которой отсутствие этих проверок может повлиять на секретный чат:
Для конструктивного диалога позовем Алису и Боба.
И так, действующие лица:
- Боб — собеседник №1. Для обмена сообщениями использует Telegram Android;
- Алиса — собеседник №2. Для обмена сообщениями использует любой клиент Telegram;
- Злоумышленник — разработчик или иное лицо имеющее физический доступ к серверу Telegram.
Сценарий:
- Боб инициирует секретный чат с Алисой, чтобы сгенерировать общий секретный ключ по Диффи-Хеллману (запрашивает p и g с сервера; выполняет проверки; генерирует а и ga; передает ga Алисе);
- Алиса принимает секретный чат с Бобом (запрашивает p и g с сервера, выполняет проверки, генерирует b, gb; генерирует общий секретный ключ на основе b, ga и p; передает Бобу идентификатор (хеш) общего секретного ключа и gb);
- Боб подтверждает секретный чат с Алисой (генерирует общий секретный ключ на основе a, gb и p; сравнивает идентификатор (хеш) своего ключа с идентификатором (хешем) ключа, полученного от Алисы);
- Алиса отправляет зашифрованное сообщение Бобу;
- Боб получает сообщение и успешно его дешифрует;
- Злоумышленник видит зашифрованное сообщения Алисы, отправленное Бобу. Злоумышленник не может расшифровать сообщение, так как не имеет доступа к общему секретному ключу;
- Злоумышленник извлекает следующие данные из перехваченного зашифрованного сообщения: идентификатор (хеш) общего секретного ключа (первые 8 байт ), ключ (хеш) дешифрованного сообщения (следующие 16 байт);
- Злоумышленник формирует новое сообщение от лица Алисы следующим образом:
- Первые 8 байт равны идентификатору (хешу) общего секретного ключа из перехваченного сообщения;
- Далее записывается массив рандомных данных длиной не менее 32 байт (16 байт — ключ (хеш) сообщения, 4 байта — длина сообщения, 4 байта — идентификатор класса (ниже станет понятно, что это), 8 байт — дополнительные данные, чтобы сформировать блок, корректной с точки зрения АES-256 длины).
- Злоумышленник отправляет новое сообщение Бобу от лица Алисы;
- Боб получает новое сообщение от Алисы, отправленное злоумышленником, и пытается его дешифровать:
- Считывает идентификатор (хеш) общего секретного ключа (первые 8 байт) и успешно сравнивает с идентификатором, рассчитанным локально;
- Считывает ключ (хеш) дешифрованного сообщения (следующие 16 байт);
- Рассчитывает параметры симметричного шифрования AES-256 с помощью общего секретного ключа и полученного ключа (хеша) сообщения. Полученные параметры представляет собой рандомные наборы байтов и не соответствует оригинальным параметрам шифрования;
- Полученные параметры используются для дешифрования сообщения (оставшиеся байты). Полученное на выходе сообщение представляет собой рандомный набор байт и не соответствует оригинальному сообщению. Так как на этом этапе отсутствует проверка длины и ключа (хеша) получившегося сообщения, то данные передаются для дальнейшей обработки, несмотря на их заведомую ложность;
- Из получившегося сообщения вырезаются первые 4 байта (в оригинальном сообщении эти данные представляют собой длину исходного сообщения). Далее в коде эти 4 байта нигде не используются;
- Оставшаяся часть сообщения передается в десериализатор: TLObject object = TLClassStore.Instance().TLdeserialize(is, is.readInt32());
- Первые 4 байта оставшегося сообщения интерпретируются как идентификатор класса (второй параметр в методе TLdeserialize). Класс TLClassStore содержит словарь, в котором значения представляют собой классы различных типов сообщений, а ключи — идентификаторы классов (константы длиной в 4 байта). Полное содержание словаря представлено в классе TLClassStore.java.
TLClassStore пытается найти класс соответствующий переданным 4 рандомным байтам. Если соответствие найдено, то возвращается новый объект соответствующего класса, иначе возвращается null и входящее сообщение полностью игнорируется (то есть Боб этого не заметит). В случае успеха оставшаяся часть сообщения используется для инициализации параметров созданного объекта. Далее полученный объект используется по назначению. Для Боба это будет выглядеть как рандомная активность со стороны Алисы (например, новое текстовое сообщение с рандомным содержанием).
Вероятность успешного создания объекта примерно равна 382 / 2^32 ≃ 8.9 * 10^-8, где
382 — количество классов содержащихся в словаре;
32 — длина идентификатора класса в битах.
Вероятность, конечно, невысокая, но так как неуспешные случаи проходят незаметно для пользователя, то злоумышленник может непрерывно отправлять сообщения, ограничиваясь только шириной канала подключения клиента к серверу. В таком случае атака может быть вполне осуществимой. Если предположить, что минимальный трафик на одно сообщение может составлять около 100 байт, то потребуется около 1 ГБ трафика для гарантированного создания объекта.
Попробуем прикинуть вероятность успешной атаки в случае наличия хотя бы одной из пропущенных проверок:
При наличии проверки длины сообщения: (2^10 / 2^32) * (382 / 2^32) ≃ 2.1 * 10^-18, где
2^10 = 1024 — максимальная валидная длина сообщения, примерно столько памяти занимает обычное сообщение;
32 = 4 байта, столько памяти занимает длина сообщения.
При наличии проверки ключа (хеша) сообщения: (1 / 2^128) * (382 / 2^32) ≃ 2.6 * 10^-46, где
128 — длина ключа (хеша) сообщения.
Стоит отметить, что на других уровнях защиты проверка подписи сообщения присутствует. Например, при установке клиент-серверного соединения (используется тот же принцип, что и при обмене сообщениями): ConnectionsManager.java
//———————————————————————Cut———————————————————————
byte[] realMessageKeyFull = Utilities.computeSHA1(data.buffer, 24, Math.min(messageLength + 32 + 24, data.limit()));
if (realMessageKeyFull == null) {
return;
}
if (!Utilities.arraysEquals(messageKey, 0, realMessageKeyFull, realMessageKeyFull.length - 16)) { // 3)
FileLog.e("tmessages", "***** Error: invalid message key");
connection.suspendConnection(true);
connection.connect();
return;
}
//———————————————————————Cut———————————————————————
Хоть это и выглядит немного странно, но я все-таки не думаю, что в отсутствии проверки подписи спрятан какой-то злой умысел, так как уязвимость не является критической. С другой стороны, возможно, есть и другие уязвимости, которые в паре с этой дают больший профит.
Тем не менее, на данный момент разработчики внесли необходимые правки в Dev ветку и обновили сборку в Google Play. Также хочется отметить тот факт, что за найденные мной недочеты разработчики выплатили вознаграждение в размере 5000$. Как говорится «не мелочь и приятно».