В комментариях к моей недавней статье про фейл с ГОСТ сертификатом сайта ГосУслуг возникла дискуссия о доверии к российским корневым сертификатам Минцифры и о рисках их установки (в систему, в отдельный браузер, или риск использования Яндекс браузера, где они встроены).Здесь вдаваться в подробности и повторять сказанное много раз не буду, включу только некое саммари из основных тезисов для тех, кто не погружен в тему: Читать далее
В комментариях к моей недавней статье про фейл с ГОСТ сертификатом сайта ГосУслуг возникла дискуссия о доверии к российским корневым сертификатам Минцифры и о рисках их установки (в систему, в отдельный браузер, или риск использования Яндекс браузера, где они встроены).
Здесь вдаваться в подробности и повторять сказанное много раз не буду, включу только некое саммари из основных тезисов для тех, кто не погружен в тему:
Сами корневые сертификаты условно безопасны
Извините, этот пункт для домохозяек — сами сертификаты ваш компьютер не взломают. Установка их в доверенное хранилище открывает путь к потенциальным атакам (см. ниже).
Если вы их поместили в доверенное хранилище, потенциально ваш трафик определенные лица могут расшифровать и даже подделать
Пояснения: лица, имеющие доступ к трафику (например, через печально известные ТСПУ), могут с помощью сертификата промежуточного УЦ (подписанного корневым) выпустить в онлайн режиме поддельный сертификат для сайта, с которым вы намереваетесь связаться — допустим, gmail.com или telegram.org. Это называется MitM‑атака, на промежуточном сетевом узле трафик можно читать или изменять по желанию атакующего.
Хоть и не хочу ссылаться на этого нейрослопового школотрона (zarazaexe), но свежее и доходчивее ничего не нашел — почитайте.
Защититься от такой атаки сложно
Можно попытаться ограничить потенциальный ущерб. Скажем, установить корневые отечественные сертификаты в отдельный браузер или профиль и использовать его только, чтобы заходить на российские сайты, на которые иначе зайти нельзя.
Также с помощью диких костылей (уж простите, @outlingo) можно ограничить зону действия корневых сертификатов (скажем, до *.ru).
Certificate Transparency логи и Яндекс браузер — неплохое решение в плане безопасности
Про Certificate Transparency можно почитать тут (ну или в википедии). Вкратце: запись о выданном сертификате включается в типа‑блокчейн, а в сам серт — пометки об этом.
У меня мало хороших слов в адрес Яндекс браузера и Яндекса вообще, но тут им надо отдать должное: они инициировали и продавили решение все выпускаемые отечественные сертификаты записывать в CT‑логи (https://habr.com/ru/companies/yandex/articles/667300/).
Яндекс браузер (если не врёт) без записи в CT‑логе сертификат не примет. Это очень осложняет онлайн MitM‑атаки и оставляет следы от попыток.
Другие браузеры при добавлении внешних корневых сертификатов проверку CT‑логов для соответствующих цепочек отключают (ну типа думают, что они в корпоративной среде).
Цель этой статьи — кратко рассказать об CT‑логах для отечественных сертификатов и инструментах работы с ними.
CT‑лог отечественныйНу собственно их три:
Yandex Agate Log
VK NCA Log
The Ministry of Digital Development and Communications Log
(может есть и другие, но Яндекс доверяет только этим).
Тут список доверенных логов Яндекс браузера со ссылками и хешами ключей в формате JSON — https://browser‑resources.s3.yandex.net/ctlog/ctlog.json.
Несколько неприятный факт состоит в том, что ссылки на логи не статичны, и их обновляют каждый год.
Инструменты для работы с CT‑логамиТо, что Яндекс проверяет наличие записи в логах, конечно, хорошо... А вот как бы самому руками все это пощупать?
Сами логи устроены максимально человеконенавистнически, и просто проверить по ссылочке, все ли хорошо у сертификата с включением в лог, нельзя.
Вообще, добрые люди придумали сервисы мониторинга (наподобие визуализаторов блокчейна), которые парсят эти логи и позволяют поискать и покликать. Но глобальные сервисы с российскими логами не работают (а самый известный https://crt.sh/ — так по‑моему вообще сдох). Есть отечественный аналог — https://ct.tlscc.ru/. И снова «но» — похоже, что он тоже слегка заброшен: так, некоторых сертификатов (например, актуальный для db.vtb.ru) я там не нашел, и вообще, кажется, что он не работал с новыми адресами CT‑логов 2027 года (см. выше, например: https://ct‑agate.yandex.net/2027/).
Update: нашелся приличный поисковик по CT-логам — https://precert.ru. Вот, например, запись для онлайн-банка Сбера — https://precert.ru/19253/.
Так что я, смирив стыд, помучил ChatGPT и родил от него следующий скрипт для проверки сертификатов по всем российским логам:
Код (убрал под спойлер)#!/usr/bin/env python3
import sys
import json
import base64
import hashlib
import struct
import requests
from asn1crypto import x509, pem
# ============================================================
# Yandex CT log list
# Static snapshot:
# version: 1.229
# log_list_timestamp: 2026-08-12T22:00:21Z
# ============================================================
REMOTE_LOG_LIST_URL = \
"https://browser-resources.s3.yandex.net/ctlog/ctlog.json"
STATIC_LIST_VERSION = "1.229"
STATIC_LIST_TIMESTAMP = "2026-08-12T22:00:21Z"
LOGS = [
{
"operator": "Yandex",
"description": "Yandex Agate-2026 log",
"url": "https://ct-agate.yandex.net/2026/",
"log_id": "Vh7HRN8a9wLmJoOt38SVz3WjGwxs6KDH6gEDMJC3SIU=",
"start": "2026-01-01T00:00:00Z",
"end": "2027-01-01T00:00:00Z",
},
{
"operator": "Yandex",
"description": "Yandex Agate-2027 log",
"url": "https://ct-agate.yandex.net/2027/",
"log_id": "GukWh2bX94qcsIb4xJw8Xc64yZMAqa9L/7CbB0+BLBc=",
"start": "2027-01-01T00:00:00Z",
"end": "2028-01-01T00:00:00Z",
},
{
"operator": "VK LLC",
"description": "VK 'NCA2026' Log",
"url": "https://ctlog2026.mail.ru/nca2026/",
"log_id": "eNkSvkIKwAIcul2iVDKRU9tIywYr/EMBJB4Qq8cWnmc=",
"start": "2026-01-01T00:00:00Z",
"end": "2027-01-01T00:00:00Z",
},
{
"operator": "VK LLC",
"description": "VK 'NCA2027' Log",
"url": "https://ctlog2027.mail.ru/nca2027/",
"log_id": "1LN/8nNPJvHlUHXlhVtwT95xu30Fx1fT3OsTf2JDrSQ=",
"start": "2027-01-01T00:00:00Z",
"end": "2028-01-01T00:00:00Z",
},
{
"operator":
"The Ministry of Digital Development and Communications",
"description":
"The Ministry of Digital Development and Communications "
"'2026' Log",
"url": "https://26.ctlog.digital.gov.ru/2026/",
"log_id": "ybgRvMAcYl3KJ/TWxEfLX34Ov3LLWZGoMy+Zv5oqPko=",
"start": "2026-01-01T00:00:00Z",
"end": "2027-01-01T00:00:00Z",
},
{
"operator":
"The Ministry of Digital Development and Communications",
"description":
"The Ministry of Digital Development and Communications "
"'2027' Log",
"url": "https://27.ctlog.digital.gov.ru/2027/",
"log_id": "TJig3bv0dZNmDDboZiaHFAn5fnh4TpGeqWr4HFMnXNY=",
"start": "2027-01-01T00:00:00Z",
"end": "2028-01-01T00:00:00Z",
},
]
SCT_OID = "1.3.6.1.4.1.11129.2.4.2"
# ============================================================
# Helpers
# ============================================================
def read_cert(path):
with open(path, "rb") as f:
data = f.read()
if pem.detect(data):
_, _, data = pem.unarmor(data)
return x509.Certificate.load(data)
def u16(b, off):
return struct.unpack(">H", b[off:off + 2])[0]
def u64(b, off):
return struct.unpack(">Q", b[off:off + 8])[0]
def uint24(n):
return n.to_bytes(3, "big")
def b64(data):
return base64.b64encode(data).decode("ascii")
# ============================================================
# Check remote log list for updates
# ============================================================
def flatten_remote_logs(data):
result = []
for operator in data.get("operators", []):
operator_name = operator.get("name", "?")
for log in operator.get("logs", []):
interval = log.get("temporal_interval", {})
result.append({
"operator": operator_name,
"description": log.get("description", "?"),
"url": log.get("url"),
"log_id": log.get("log_id"),
"start": interval.get("start_inclusive"),
"end": interval.get("end_exclusive"),
})
return result
def log_identity(log):
return log.get("log_id")
def check_log_list_update():
"""
Check Yandex ctlog.json.
Failure to download the list is NOT fatal.
The embedded static list will still be used.
"""
print("Checking CT log list...")
print(" URL:", REMOTE_LOG_LIST_URL)
try:
r = requests.get(
REMOTE_LOG_LIST_URL,
timeout=5,
)
r.raise_for_status()
data = r.json()
except Exception as e:
print(" Remote list unavailable:", e)
print(
" Using built-in list version",
STATIC_LIST_VERSION
)
return
remote_version = str(data.get("version", "?"))
remote_timestamp = data.get("log_list_timestamp", "?")
print(" Built-in version:", STATIC_LIST_VERSION)
print(" Remote version: ", remote_version)
print(" Remote timestamp:", remote_timestamp)
remote_logs = flatten_remote_logs(data)
static_by_id = {
log_identity(x): x
for x in LOGS
}
remote_by_id = {
log_identity(x): x
for x in remote_logs
}
changed = False
# --------------------------------------------------------
# Added logs
# --------------------------------------------------------
added = (
set(remote_by_id)
- set(static_by_id)
)
if added:
changed = True
print()
print(" NEW LOGS in remote list:")
for log_id in sorted(added):
x = remote_by_id[log_id]
print()
print(" +", x["description"])
print(" URL: ", x["url"])
print(" Log ID:", x["log_id"])
# --------------------------------------------------------
# Removed logs
# --------------------------------------------------------
removed = (
set(static_by_id)
- set(remote_by_id)
)
if removed:
changed = True
print()
print(" LOGS REMOVED from remote list:")
for log_id in sorted(removed):
x = static_by_id[log_id]
print()
print(" -", x["description"])
print(" URL: ", x["url"])
print(" Log ID:", x["log_id"])
# --------------------------------------------------------
# Changed properties for same LogID
# --------------------------------------------------------
common = (
set(static_by_id)
& set(remote_by_id)
)
fields = [
"url",
"description",
"operator",
"start",
"end",
]
for log_id in sorted(common):
old = static_by_id[log_id]
new = remote_by_id[log_id]
differences = []
for field in fields:
if old.get(field) != new.get(field):
differences.append(
(
field,
old.get(field),
new.get(field),
)
)
if differences:
changed = True
print()
print(
" CHANGED:",
old["description"]
)
print(
" Log ID:",
log_id
)
for field, old_value, new_value in differences:
print(
f" {field}:"
)
print(
f" built-in: {old_value}"
)
print(
f" remote: {new_value}"
)
# --------------------------------------------------------
# Version itself changed
# --------------------------------------------------------
if remote_version != STATIC_LIST_VERSION:
changed = True
print()
if changed:
print(
" *** WARNING: built-in CT log list "
"may be outdated ***"
)
print(
" The script will continue using the "
"built-in list."
)
else:
print(
" CT log list is up to date."
)
# ============================================================
# SCT parsing
# ============================================================
def parse_sct(sct):
if len(sct) < 43:
raise RuntimeError("SCT is too short")
p = 0
version = sct[p]
p += 1
log_id = sct[p:p + 32]
p += 32
timestamp = u64(sct, p)
p += 8
ext_len = u16(sct, p)
p += 2
if p + ext_len > len(sct):
raise RuntimeError(
"Malformed SCT extensions"
)
extensions = sct[p:p + ext_len]
p += ext_len
signature = sct[p:]
return {
"version": version,
"log_id": log_id,
"timestamp": timestamp,
"extensions": extensions,
"signature": signature,
"raw": sct,
}
def get_scts(cert):
extensions = cert["tbs_certificate"]["extensions"]
raw = None
for ext in extensions:
if ext["extn_id"].dotted == SCT_OID:
raw = ext["extn_value"].native
break
if raw is None:
raise RuntimeError(
"Certificate has no embedded SCT extension"
)
if len(raw) < 2:
raise RuntimeError("Malformed SCT list")
list_len = u16(raw, 0)
if list_len != len(raw) - 2:
raise RuntimeError(
f"Bad SCT list length: "
f"declared={list_len}, "
f"actual={len(raw) - 2}"
)
pos = 2
result = []
while pos < len(raw):
if pos + 2 > len(raw):
raise RuntimeError(
"Malformed SCT list"
)
sct_len = u16(raw, pos)
pos += 2
if pos + sct_len > len(raw):
raise RuntimeError(
"Malformed SCT length"
)
sct = raw[pos:pos + sct_len]
pos += sct_len
result.append(
parse_sct(sct)
)
return result
# ============================================================
# Match LogID -> CT log
# ============================================================
def find_log_by_id(log_id_bytes):
wanted = b64(log_id_bytes)
for log in LOGS:
if log["log_id"] == wanted:
return log
return None
# ============================================================
# Reconstruct precertificate
# ============================================================
def remove_sct_extension(cert):
tbs_der = cert["tbs_certificate"].dump()
tbs = x509.TbsCertificate.load(tbs_der)
extensions = tbs["extensions"]
found_index = None
for i, ext in enumerate(extensions):
if ext["extn_id"].dotted == SCT_OID:
found_index = i
break
if found_index is None:
raise RuntimeError(
"SCT extension not found"
)
del extensions[found_index]
return tbs.dump()
def issuer_key_hash(issuer):
spki = (
issuer["tbs_certificate"]
["subject_public_key_info"]
.dump()
)
return hashlib.sha256(spki).digest()
def make_precert_leaf(cert, issuer, sct):
tbs = remove_sct_extension(cert)
ikh = issuer_key_hash(issuer)
leaf = bytearray()
# Version v1
leaf += b"\x00"
# MerkleLeafType = timestamped_entry
leaf += b"\x00"
# timestamp
leaf += struct.pack(
">Q",
sct["timestamp"]
)
# LogEntryType = precert_entry
leaf += struct.pack(">H", 1)
# issuer_key_hash
leaf += ikh
# TBSCertificate
leaf += uint24(len(tbs))
leaf += tbs
# CtExtensions
leaf += struct.pack(
">H",
len(sct["extensions"])
)
leaf += sct["extensions"]
return bytes(leaf)
# ============================================================
# Merkle hashing
# ============================================================
def leaf_hash(leaf):
return hashlib.sha256(
b"\x00" + leaf
).digest()
def node_hash(left, right):
return hashlib.sha256(
b"\x01" + left + right
).digest()
def verify_inclusion(
leaf_hash_value,
index,
tree_size,
audit_path,
):
if tree_size <= 0:
return None
if index < 0 or index >= tree_size:
return None
fn = index
sn = tree_size - 1
r = leaf_hash_value
for p in audit_path:
if fn == sn or (fn & 1):
r = node_hash(p, r)
while (
fn != 0
and (fn & 1) == 0
):
fn >>= 1
sn >>= 1
else:
r = node_hash(r, p)
fn >>= 1
sn >>= 1
if sn != 0:
return None
return r
# ============================================================
# CT HTTP API
# ============================================================
def api_url(log_url, endpoint):
return (
log_url.rstrip("/")
+ "/ct/v1/"
+ endpoint
)
def get_sth(log_url):
r = requests.get(
api_url(log_url, "get-sth"),
timeout=20,
)
r.raise_for_status()
return r.json()
def get_proof(log_url, h, tree_size):
return requests.get(
api_url(
log_url,
"get-proof-by-hash"
),
params={
"hash": b64(h),
"tree_size": tree_size,
},
timeout=20,
)
def get_entry(log_url, index):
r = requests.get(
api_url(
log_url,
"get-entries"
),
params={
"start": index,
"end": index,
},
timeout=20,
)
r.raise_for_status()
data = r.json()
entries = data.get(
"entries",
[]
)
if len(entries) != 1:
raise RuntimeError(
f"Expected 1 entry, "
f"got {len(entries)}"
)
return entries[0]
# ============================================================
# Check a matched SCT
# ============================================================
def check_sct(
log,
cert,
issuer,
sct,
):
print(" Getting STH...")
sth = get_sth(log["url"])
tree_size = sth["tree_size"]
advertised_root = base64.b64decode(
sth["sha256_root_hash"]
)
print(
" tree_size:",
tree_size
)
leaf = make_precert_leaf(
cert,
issuer,
sct
)
h = leaf_hash(leaf)
print(
" Leaf hash:",
b64(h)
)
response = get_proof(
log["url"],
h,
tree_size,
)
print(
" get-proof-by-hash HTTP:",
response.status_code
)
if response.status_code != 200:
print(" NOT FOUND")
text = response.text.strip()
if text:
print(
" Response:",
text[:500]
)
return False
proof = response.json()
index = proof["leaf_index"]
path = [
base64.b64decode(x)
for x in proof["audit_path"]
]
print(" FOUND")
print(
" leaf_index:",
index
)
print(
" proof nodes:",
len(path)
)
calculated_root = verify_inclusion(
h,
index,
tree_size,
path,
)
if calculated_root is None:
print(
" Merkle proof: INVALID"
)
return False
if calculated_root != advertised_root:
print(
" Merkle root: MISMATCH"
)
print(
" calculated:",
calculated_root.hex()
)
print(
" advertised:",
advertised_root.hex()
)
return False
print(
" Merkle proof: VALID"
)
# --------------------------------------------------------
# Fetch actual CT entry
# --------------------------------------------------------
entry = get_entry(
log["url"],
index
)
server_leaf = base64.b64decode(
entry["leaf_input"]
)
if server_leaf != leaf:
print(
" leaf_input comparison: DIFFERENT"
)
print(
" local length: ",
len(leaf)
)
print(
" server length:",
len(server_leaf)
)
return False
print(
" leaf_input comparison: IDENTICAL"
)
print()
print(
" *** INCLUSION FULLY VERIFIED ***"
)
return True
# ============================================================
# Main
# ============================================================
def main():
if len(sys.argv) != 3:
print(
f"Usage: {sys.argv[0]} "
"cert.pem issuer.pem",
file=sys.stderr,
)
sys.exit(2)
# --------------------------------------------------------
# Check whether embedded CT log list is current
# --------------------------------------------------------
check_log_list_update()
print()
print("=" * 72)
cert = read_cert(
sys.argv[1]
)
issuer = read_cert(
sys.argv[2]
)
print()
print("Reading SCTs...")
scts = get_scts(cert)
print(
f"Found {len(scts)} "
"embedded SCT(s)"
)
verified = 0
for n, sct in enumerate(scts):
print()
print("=" * 72)
print(f"SCT #{n}")
print("=" * 72)
log_id = b64(
sct["log_id"]
)
print(
"Log ID:",
log_id
)
print(
"Timestamp:",
sct["timestamp"]
)
# ----------------------------------------------------
# Find CT log from LogID
# ----------------------------------------------------
log = find_log_by_id(
sct["log_id"]
)
if log is None:
print()
print(
"CT log: UNKNOWN"
)
print(
"This LogID is not present "
"in the built-in Yandex CT log list."
)
continue
print()
print(
"CT log:",
log["description"]
)
print(
"Operator:",
log["operator"]
)
print(
"URL:",
log["url"]
)
print(
"Temporal interval:",
log["start"],
"..",
log["end"]
)
try:
ok = check_sct(
log,
cert,
issuer,
sct,
)
except Exception as e:
print()
print(
"ERROR:",
type(e).__name__ + ":",
e
)
ok = False
if ok:
verified += 1
# --------------------------------------------------------
# Summary
# --------------------------------------------------------
print()
print("=" * 72)
print("FINAL RESULT")
print("=" * 72)
print(
"Embedded SCTs:",
len(scts)
)
print(
"Fully verified:",
verified
)
if verified == len(scts):
print(
"All SCT log entries were "
"successfully verified."
)
sys.exit(0)
elif verified:
print(
"Some SCT log entries were "
"successfully verified."
)
sys.exit(0)
else:
print(
"No SCT log entry was "
"successfully verified."
)
sys.exit(1)
if __name__ == "__main__":
main()Скрипт требует некоторые зависимости:
pip install requests asn1cryptoДля работы ему нужен сам сертификат и выпускающий сертификат CA (подойдут файлы промежуточных CA из архива — https://gu‑st.ru/content/lending/russian_trusted_sub_ca.zip).
Как использовать:
# ct.py - сам скрипт (для Python 3)
# cert.pem - сертификат нужного домена в формате PEM (файлы .crt внутри часто тоже идут с PEM)
# subca.pem - сертификат промежуточного CA
python ct.py cert.pem subca.pemПример работы:
python ct.py _.online.sberbank.ru.crt subca.pem
В логе VK запись есть

2 из 3-х логов подтвердили (к логу Минцифры скрипт законнектиться не смог)
Ложка дёгтяИли скорее ведро помоев.
С сертификатами ГОСТ все это на практике не работает.
Во‑первых, почему‑то в сертификатах ГОСТ часто (всегда?) не содержатся записи SCT (это пометки о включении в CT‑логи, по которым потом и происходит проверка). Так, например, злосчастный сертификат сайта Госуслуг из прошлой статьи их не имеет:
Reading SCTs...
Traceback (most recent call last):
File "/***/ct.py", line 998, in <module>
main()
File "/***/ct.py", line 852, in main
scts = get_scts(cert)
File "/***/ct.py", line 381, in get_scts
raise RuntimeError(
RuntimeError: Certificate has no embedded SCT extensionЯ пока не нашел сайта, который был поддерживал подключение по ГОСТ и имел сертификат, подписанный актуальными корневыми Минцифры. Поэтому проверить поведение Яндекса в ситуации отсутствия SCT/CT‑логов не могу. Если кто‑то найдет пример, поделитесь.
(тут было «во‑вторых», но я лопухнулся... оставлю для истории).
Надеюсь, было небесполезно.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | От чего защищает Certificate Transparency? | 0 | 7.8 | 06-08-2026 |
| 2 | Сертификаты Минцифры — как им не доверять | 0 | 7 | 17-08-2026 |
| 3 | Коротко о веб-сертификатах безопасности | 0 | 11.05 | 07-08-2026 |
| 4 | Сайты российских банков перестали открываться в браузерах вроде Chrome и Safari | -1 | 9.8 | 04-08-2026 |
| 5 | Про запрет сертификатов для России. Чем это грозит нашей стране | 0 | 22.86 | 15-06-2026 |
| 6 | Госхакинг. Почему российские «сертификаты безопасности» небезопасны? И что делать, если без них никак? | 0 | 8.02 | 10-08-2026 |
| 7 | Переход с иностранных сертификатов на российские обойдется компаниям в десятки миллионов рублей | 0 | 11.34 | 07-08-2026 |
| 8 | Россиянам посоветовали установить российские сертификаты безопасности | 0 | 12.41 | 24-07-2026 |
| 9 | Власти Москвы опубликовали инструкции по установке сертификатов безопасности | 0 | 7.7 | 14-08-2026 |
| 10 | Пользовательское соглашение на сайт: когда нужно и что учесть, чтобы защитить владельца сайта? | 0 | 6.87 | 19-05-2026 |