Разбираем баг с подменой DTLS-сертификата в aiortc: почему pc._certificates молча не работает и как правильно использовать name mangling. Читайте и чините свой код!
Вы когда-нибудь сталкивались с багом, который не выдаёт ошибок, но ломает безопасность? В этой статье я расскажу, как подмена DTLS-сертификата в библиотеке aiortc превратилась в тихую бомбу: тесты проходили, но реальные подключения падали. Вы узнаете, почему простое присваивание pc._certificates не работает, как это связано с name mangling в Python, и как написать тест, который ловит такие ошибки. Поехали!
WebRTC-Direct в libp2p использует хитрый трюк: в multiaddr пира содержится хеш его TLS-сертификата — /webrtc-direct/certhash/<...>. При установке соединения DTLS-рукопожатие предъявляет сертификат, вы его хешируете и сверяете с certhash из адреса. Никаких центров сертификации, никакого хранилища доверия — адрес сам по себе является пином.
Чтобы это работало, py-libp2p должен заставить aiortc использовать наш сертификат, а не тот, что aiortc генерирует сам. Поэтому код делает так:
config = RTCConfiguration(certificates=[rtc_cert])
return RTCPeerConnection(configuration=config)
Чисто, очевидно. И, начиная с aiortc ≥ 1.5, совершенно неправильно — причём двумя разными способами.
Первый способ был громким: aiortc ≥ 1.5 убрал параметр certificates= из RTCConfiguration. Его передача вызывает TypeError. Легко заметить, легко «исправить» — просто присвоить сертификат объекту после создания:
pc = RTCPeerConnection(configuration=config)
pc._certificates = [rtc_cert] # выглядит правильно, но это не так
return pc
Тесты позеленели. Тест эхо-запроса с петлёй — открыть канал данных, отправить данные, получить их обратно — прошёл. Я мог бы отправить это в продакшн прямо тогда. Но тихий способ — тот, который сжёг бы всех пользователей.
pc._certificates — это пустышкаВот что делает эту историю историей, а не однострочником. pc._certificates = [...] — это тихий no-op. aiortc никогда не читает _certificates. Внутри класса он хранит и читает сертификат как self.__certificates — атрибут с двойным подчёркиванием. И Python делает с такими именами внутри тела класса специальную вещь: искажает их (name mangling).
self.__certificates внутри класса RTCPeerConnection компилируется в self._RTCPeerConnection__certificates.
Итак, снаружи:
pc._certificates — новый атрибут, который я выдумал. aiortc его никогда не читает.pc._RTCPeerConnection__certificates — настоящий слот, который aiortc читает в момент createOffer/createAnswer, чтобы записать строку a=fingerprint в SDP (см. aiortc/rtcpeerconnection.py:295 и :1129).Я устанавливал первый. aiortc читал второй — в нём всё ещё лежал его собственный сертификат. Результат:
/webrtc-direct/certhash/<...> рекламировал хеш нашего libp2p-сертификата.Remote DTLS fingerprint does not match certhash.А тест эхо-запроса с петлёй всё ещё проходил — потому что петля не проверяет отпечаток DTLS против multiaddr. Она просто открывает канал и пересылает байты. Единственное свойство, которое было сломано, — то, которое счастливый путь теста никогда не проверял.
Этот баг живёт в шве между двумя системами:
__name внутри тела класса, чтобы подклассы не могли случайно затереть «приватные» атрибуты базового класса. Это языковая функция, делающая ровно то, для чего создана.__certificates, т.е. полагается на это искажение для своей инкапсуляции.Ни одна из них по отдельности не ошибочна. Но когда вы лезете в приватное состояние библиотеки извне класса, искажённое имя — единственное, которое работает. А неискажённое, которое вы естественно наберёте, — это no-op, который молча проваливается. Нет AttributeError, нет TypeError, нет предупреждения. Вы просто создаёте мусорный атрибут и идёте дальше, и всё ниже по конвейеру продолжает работать, кроме свойства безопасности.
Нужно напрямую установить искажённый атрибут после создания объекта, до любой операции SDP, которая инициирует рукопожатие:
config = RTCConfiguration(iceServers=list(ice_servers) if ice_servers else [])
pc = RTCPeerConnection(configuration=config)
# Заменяем автосгенерированный сертификат aiortc. Нужно использовать искажённое имя —
# aiortc читает только self.__certificates, которое преобразуется в это.
pc._RTCPeerConnection__certificates = [rtc_cert] # type: ignore[attr-defined]
return pc
Это уродливая строка, и docstring говорит об этом вслух — обращение к _RTCPeerConnection__certificates — это ровно тот вид связи с приватным состоянием, который ломается при обновлении библиотеки. Но это правильно и аннотировано, чтобы следующий человек знал, почему нельзя просто использовать pc._certificates.
Настоящее исправление — не однострочник, а тест, который делает тихий сбой громким. Счастливый путь эхо-теста не видит этот баг, поэтому я добавил тест, проверяющий инвариант: отпечаток закреплённого сертификата должен реально появиться в SDP.
@pytest.mark.trio
async def test_cert_pinning_lands_in_sdp_fingerprint() -> None:
"""
Защищает от класса багов с пином сертификата: если пин — no-op
(например, записан в публичный атрибут, который aiortc не читает,
потому что хранит сертификат под искажённым приватным слотом),
строка a=fingerprint в SDP отражает автосгенерированный сертификат aiortc,
а не наш.
"""
cert = WebRTCCertificate.from_aiortc()
pc = await bridge.run_coro(create_peer_connection(cert._rtc_certificate, ice_servers=[]))
# Нужен хотя бы один канал данных для SCTP m-line, иначе
# createOffer опустит отпечаток DTLS полностью.
await bridge.run_coro(_create_dummy_channel(pc))
sdp = await bridge.run_coro(_offer())
expected = _sdp_fingerprint_string(cert)
assert expected in sdp.upper(), "SDP fingerprint не совпадает с закреплённым сертификатом."
Обратите внимание на маленькую мину внутри исправления для теста: createOffer опускает отпечаток DTLS, если нет хотя бы одной SCTP m-line, поэтому канарейке сначала нужно создать фиктивный канал данных, иначе она будет проверять пустое предложение. У бага были слои даже в воспроизведении.
Откатите исправление с искажённым именем — и этот тест немедленно станет красным с несовпадением отпечатка. Оставьте старый no-op pc._certificates = [...] — тоже красный. В этом весь смысл: он падает закрыто.
Два урока, и это один и тот же урок с двух сторон.
Эхо-тест доказал, что байты перемещаются. Он ничего не сказал о том, какой сертификат их перемещал — а это было единственное, что имело значение для безопасности. Зелёный тест настолько хорош, насколько хороши свойства, которые он проверяет, и «работает из конца в конец» молча исключает все свойства, которые ваш путь из конца в конец не проверяет.
Если бы pc._certificates = [...] вызвало исключение, я бы исправил это за тридцать секунд. А поскольку оно сработало — создало реальный атрибут, не бросило ошибку — оно прошло ревью и тесты и отправило бы в продакшн транспорт, где каждое аутентифицированное подключение падает. Когда вы пересекаете границу инкапсуляции и лезете в приватное состояние библиотеки, предполагайте, что язык делает что-то хитрое с именем, и пишите тест, который доказывает, что ваша запись реально попала туда, где библиотека читает.
Если вы используете aiortc и подменяете сертификаты, немедленно проверьте, что вы обращаетесь к _RTCPeerConnection__certificates, а не к _certificates. Добавьте тест, который проверяет наличие отпечатка в SDP, как показано выше. И помните: любой зелёный тест, который не проверяет критичное свойство, — это просто красивый мираж.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →