Some checks failed
Published content check / validate (push) Failing after 6s
По материалам мейнтейнера amneziawg-installer (проверено по коду и GitHub API): - internals: AWG-параметр нельзя убрать через awg setconf/syncconf — обе реализации обрабатывают только присутствующие ключи; команда включения dynamic debug для пояснений к отказам модуля ядра; - reference: поддержка AWG 3.0 в mihomo v1.19.30 (16 августа), статусы официальных клиентов amneziawg-android (3.x — только предрелизы) и amneziawg-windows-client (стабильный 2.0.2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
309 lines
53 KiB
Markdown
309 lines
53 KiB
Markdown
---
|
||
date: 2026-08-04
|
||
tags:
|
||
- amneziawg
|
||
- wireguard
|
||
- обфускация
|
||
- dpi
|
||
- reverse-engineering
|
||
aliases:
|
||
- AmneziaWG 3.0 внутреннее устройство
|
||
- AWG 3.0 wire-format
|
||
- Header protection AmneziaWG
|
||
- Как устроена Амнезия 3.0 изнутри
|
||
- HeaderProtectionKey что это и как сгенерировать
|
||
- Почему S1-S4 должны быть не меньше 12
|
||
- ContentPaddingAddition в AmneziaWG
|
||
- Амнезия ВПН шифрование заголовков
|
||
- AmneziaWG рукопожатие каждые 15 секунд
|
||
- unknown tag c AmneziaWG
|
||
link: https://github.com/amnezia-vpn/amneziawg-go/tree/v3.0.3
|
||
---
|
||
|
||
# 🔬 AmneziaWG 3.0: внутреннее устройство протокола
|
||
|
||
> [!info] О чём заметка
|
||
> Побайтовый разбор третьего поколения AmneziaWG по исходному коду: как собирается пакет, что именно шифрует защита заголовков, откуда берётся одноразовое число, как приёмная сторона опознаёт тип сообщения, не расшифровав его целиком, и какие следы протокол всё ещё оставляет в сети. Обзорная часть — назначение, доступность, история версий — в заметке [[amnezia-3-0/reference|AmneziaWG 3.0]]; параметры предыдущего поколения (`Jc`, `S1`–`S4`, `H1`–`H4`, язык CPS) подробно разобраны в [[amnezia-2-0/reference|справочнике по AmneziaWG 2.0]] и здесь не повторяются.
|
||
|
||
## TL;DR
|
||
|
||
- Третье поколение добавляет к обфускации ровно три механизма: **защита заголовков** (ChaCha20 поверх готовых сообщений WireGuard), **content padding** (случайное удлинение полезной нагрузки вместо выравнивания по 16 байт) и **диапазонные тайминги** (новое случайное значение при каждом взводе таймера).
|
||
- Одноразовое число (nonce) для шифра **не передаётся отдельно** — им служат первые 12 байт того самого случайного паддинга `S1`–`S4`, который в 2.0 был просто мусором. Отсюда требование `S1`–`S4` ≥ 12 при включённой защите.
|
||
- Приёмная сторона использует изящный приём: шифрует блок нулей, получая первые 4 байта потока ключа, и этим «хэшем типа» расшифровывает только поле типа — чтобы опознать сообщение до разбора остального пакета.
|
||
- **Тихое улучшение, не отмеченное нигде в документации**: keepalive-пакеты теперь получают и паддинг `S4`, и content padding. В [[amnezia-2-0/reference|AWG 2.0]] они шли без `S4` и имели постоянный размер 32 байта — это была заметная сигнатура. У улучшения оказался побочный эффект: до исправления 5 августа 2026 года западдженный keepalive засчитывался как пакет с данными, и простаивающий туннель делал рукопожатие каждые ~15 секунд — примерно в десять раз чаще нормы; разбор ниже.
|
||
- **Что 3.0 не закрывает**: размеры рукопожатий остаются постоянными для конкретной конфигурации (`S1`+148 и `S2`+92 байта), потому что content padding применяется только к транспортным пакетам. Пара «фиксированный запрос → фиксированный ответ → поток» по-прежнему видна наблюдателю. Именно этот признак закрывает линия 3.1 от 12 августа 2026 года параметром `RandomTrailers` — см. [[amnezia-3-0/reference|обзорную заметку]].
|
||
|
||
## Как проверялись факты
|
||
|
||
Всё ниже прочитано в исходниках движка [amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) на теге **`v3.0.3`** (коммит `cf9d2dd`, 31 июля 2026 года) — это последняя на 4 августа 2026 года версия третьего поколения. Ключевые файлы: `device/noise-types.go` (константы и тип диапазона), `device/noise-protocol.go` (выдача шифра), `device/send.go` (сборка исходящих пакетов), `device/receive.go` (разбор входящих), `device/uapi.go` (параметры и их проверка), `device/timers.go` (тайминги).
|
||
|
||
После 4 августа линия получила продолжение: 5 августа 2026 года в go и в модуле ядра вышли теги `v3.0.20260805` с исправлением keepalive-дефекта (разобран ниже), а 12 августа — линия 3.1 с параметрами `RandomTrailers` и `DisableCookies`, обзор которой — в [[amnezia-3-0/reference|заметке про AmneziaWG 3.0]]. Описанную здесь механику 3.0 эти теги не меняют: формат пакетов и разбор тегов CPS в 3.1 идентичны 3.0 (сверено диффом 20 августа 2026 года). Раздел о наборах тегов сигнатур дополнительно сверен с модулем ядра (`src/junk.c`, теги `v3.0.20260805` и `v3.1.20260812`).
|
||
|
||
> [!warning] Первоисточник здесь — код, а не документация
|
||
> Официального описания механики третьего поколения не существует: к 20 августа 2026 года страница docs.amnezia.org об AmneziaWG обзавелась таблицей параметров вплоть до 3.0, но с оговоркой, что подробности добавят после выхода self-hosted-поддержки; обещанная статья в блоге так и не вышла. README репозитория покрывает список параметров, но не механику. Поэтому разбор ниже — чтение исходников, и любые расхождения с будущей официальной документацией следует трактовать в её пользу. Отдельно предупреждение о ходящем по сети «разборе под капотом» из GitHub Discussions: значительная часть его утверждений кодом не подтверждается, подробности — в [[amnezia-3-0/reference|обзорной заметке]].
|
||
|
||
## Полный список параметров устройства
|
||
|
||
Третье поколение AmneziaWG (*AWG 3, «амнезия 3.0», «АмнезияВГ 3»*) не переизобретает конфигурацию, а дописывает к ней восемь новых ключей. Полная картина того, что понимает движок `v3.0.3` (имена в конфигурационном файле и соответствующие им ключи внутреннего интерфейса UAPI, через который утилиты общаются с движком):
|
||
|
||
| В конфиге | Ключ UAPI | Тип | Появился | Сторона |
|
||
|---|---|---|---|---|
|
||
| `Jc`, `Jmin`, `Jmax` | `jc`, `jmin`, `jmax` | int | 1.0 | клиентская |
|
||
| `S1`–`S4` | `s1`–`s4` | int | 1.0 (`S3`, `S4` — в 2.0) | серверная |
|
||
| `H1`–`H4` | `h1`–`h4` | диапазон uint32 | 1.0 (диапазоны — в 2.0) | серверная |
|
||
| `I1`–`I5` | `i1`–`i5` | строка на языке CPS | 1.5 | клиентская |
|
||
| **`HeaderProtectionKey`** | `header_protection_key` | 32 байта, base64 | **3.0** | серверная |
|
||
| **`ContentPaddingAddition`** | `content_padding_addition` | диапазон uint32 | **3.0** | клиентская |
|
||
| **`RekeyAfterTime`** | `rekey_after_time` | диапазон uint32, секунды | **3.0** | клиентская |
|
||
| **`RekeyTimeout`** | `rekey_timeout` | диапазон uint32, секунды | **3.0** | клиентская |
|
||
| **`RejectAfterTime`** | `reject_after_time` | диапазон uint32, секунды | **3.0** | клиентская |
|
||
| **`KeepaliveTimeout`** | `keepalive_timeout` | диапазон uint32, секунды | **3.0** | клиентская |
|
||
| **`MaxHandshakeAttempts`** | `max_handshake_attempts` | диапазон uint32, попытки | **3.0** | клиентская |
|
||
| `PersistentKeepalive` | `persistent_keepalive_interval` | стал диапазоном | изменён в **3.0** | клиентская |
|
||
|
||
Разделение на «стороны» в терминологии README означает буквально следующее: **серверные** параметры обязаны совпадать на обоих концах туннеля, иначе стороны не поймут друг друга; **клиентские** можно задавать только на одной стороне — они влияют на то, как эта сторона отправляет, и не требуют согласования.
|
||
|
||
Проще говоря: ключ защиты заголовков и значения паддинга должны быть одинаковыми у клиента и сервера, а мусорные пакеты, content padding и тайминги каждый настраивает под себя.
|
||
|
||
Формат диапазона — `a-b`, либо одиночное число (тогда границы совпадают), либо `(off)`. Разбирается он в тип `UintRange`, где обе границы упакованы в одно 64-битное число: младшие 32 бита — нижняя граница, старшие — верхняя. Такая упаковка позволяет менять диапазон атомарно, без блокировок, прямо во время работы туннеля.
|
||
|
||
## Слои обфускации: как собирается исходящий пакет
|
||
|
||
Полезно держать в голове порядок, в котором данные обрастают слоями. Для транспортного пакета он такой:
|
||
|
||
1. **Прикладные данные** приходят из виртуального сетевого интерфейса.
|
||
2. **Content padding** — в хвост дописываются нулевые байты: случайное количество из диапазона `ContentPaddingAddition`, а если параметр не задан — ровно столько, чтобы длина стала кратна 16 (штатное поведение WireGuard).
|
||
3. **Шифрование полезной нагрузки** — ChaCha20-Poly1305 сеансовым ключом, штатный механизм WireGuard. На выходе получается шифртекст плюс 16-байтовая метка подлинности.
|
||
4. **Заголовок WireGuard** — 16 байт: тип сообщения (значение из диапазона `H4`), индекс получателя, счётчик пакетов.
|
||
5. **Криптопаддинг `S4`** — перед заголовком в буфере лежат `S4` случайных байт.
|
||
6. **Защита заголовков** — 16 байт заголовка шифруются ChaCha20 с одноразовым числом из первых 12 байт паддинга.
|
||
|
||
Ключевая перестановка относительно [[amnezia-2-0/reference|второго поколения]] — в шаге 5. Раньше паддинг `S4` дописывался в самом конце, сдвигом уже готового зашифрованного пакета вправо по буферу. Теперь место под него резервируется заранее (`elem.padding` выставляется при создании исходящего элемента), и заполняется он до шифрования заголовка — иначе неоткуда было бы взять одноразовое число.
|
||
|
||
Для рукопожатия порядок другой и полностью сохраняет логику 2.0: сначала уходят сигнатурные пакеты `I1`–`I5` (каждый — отдельная датаграмма), затем `Jc` мусорных пакетов, и лишь потом само сообщение инициации. Всё это отправляется одним системным вызовом.
|
||
|
||
## Header protection побайтово
|
||
|
||
### Ключ
|
||
|
||
Параметр `HeaderProtectionKey` — 32 байта, в конфигурационном файле записывается в base64, как обычный ключ WireGuard; шестнадцатеричная форма используется только на внутреннем интерфейсе UAPI. Генерируется командой `awg genkey`.
|
||
|
||
Ключ хранится в устройстве под отдельной блокировкой чтения-записи и **живёт всё время работы интерфейса**: он не участвует в выработке сеансовых ключей и не меняется при обновлении сессии каждые пару минут. Это осознанный размен — заголовки нужно уметь расшифровать до того, как станет понятно, к какой сессии относится пакет.
|
||
|
||
Если ключ нулевой, функция выдачи шифра возвращает пустое значение, и весь механизм отключается — движок ведёт себя ровно как AWG 2.0. Именно поэтому «несовместимость с 2.0» на самом деле означает «несовместимость конфигураций с включённой защитой заголовков»: сам движок умеет работать в обоих режимах.
|
||
|
||
### Одноразовое число
|
||
|
||
Шифр — ChaCha20 в варианте IETF, без аутентификации (`chacha20.NewUnauthenticatedCipher`). Его одноразовое число занимает 12 байт, и берётся оно не из отдельного поля, а **из начала криптопаддинга**:
|
||
|
||
```
|
||
Отправка (сообщение инициации):
|
||
buf = [ S1 байт ] ← crypto/rand.Read по всей длине
|
||
[ 148 байт сообщения ]
|
||
nonce = buf[:12] ← первые 12 байт паддинга
|
||
cip = ChaCha20(key, nonce)
|
||
cip.XORKeyStream(packet, packet) ← шифруется всё сообщение целиком
|
||
```
|
||
|
||
Отсюда единственное жёсткое ограничение третьего поколения: при непустом ключе **все четыре значения `S1`–`S4` должны быть не меньше 12**, иначе одноразовое число просто не поместится. Проверка стоит в обработчике настроек и отвергает всю конфигурацию целиком.
|
||
|
||
Обратите внимание на побочный эффект: `S4` ≥ 12 означает, что 12 с лишним лишних байт добавляются **к каждому транспортному пакету**, а не только к рукопожатиям. Отключить паддинг для потока данных, сохранив защиту заголовков, нельзя.
|
||
|
||
### Что именно шифруется
|
||
|
||
| Тип сообщения | Размер | Что покрывает шифр |
|
||
|---|---|---|
|
||
| Инициация рукопожатия | 148 байт | всё сообщение, включая MAC1 и MAC2 |
|
||
| Ответ на рукопожатие | 92 байта | всё сообщение целиком |
|
||
| Ответ с cookie | 64 байта | всё сообщение целиком |
|
||
| Транспортный пакет | 16 байт заголовка | только заголовок: тип, индекс получателя, счётчик |
|
||
|
||
Полезная нагрузка транспортных пакетов вторым слоем не шифруется, и это правильно: она уже зашифрована ChaCha20-Poly1305 и статистически неотличима от случайных данных, так что второй проход дал бы нулевой выигрыш в маскировке при заметной трате процессора.
|
||
|
||
Проще говоря: третье поколение прячет не содержимое — оно и так было спрятано, — а **служебную разметку**, по которой пакет опознавался как WireGuard.
|
||
|
||
### Приём: трюк с «хэшем типа»
|
||
|
||
Здесь самая любопытная часть реализации. Проблема очевидна: чтобы расшифровать заголовок, нужно знать длину паддинга, а она зависит от типа сообщения, который сам лежит в зашифрованном заголовке. Замкнутый круг.
|
||
|
||
Решение опирается на свойство потокового шифра — шифрование блока нулей даёт чистый поток ключа:
|
||
|
||
```
|
||
Приём (любой пакет):
|
||
nonce = packet[:12] ← первые 12 байт датаграммы
|
||
cip = ChaCha20(key, nonce)
|
||
typeHash = cip.XORKeyStream(0x00000000) ← первые 4 байта потока ключа
|
||
|
||
для каждого кандидата (init / response / cookie / transport):
|
||
если размер пакета == S_i + размер_сообщения:
|
||
тип = packet[S_i : S_i+4] XOR typeHash ← расшифровка только поля типа
|
||
если тип попадает в диапазон H_i → это оно
|
||
|
||
далее поток ключа продолжается с 5-го байта:
|
||
cip.XORKeyStream(packet[4:конец_заголовка])
|
||
```
|
||
|
||
Такая конструкция даёт две вещи. Во-первых, приёмник расшифровывает четыре байта вместо целого пакета и только потом решает, стоит ли возиться дальше. Во-вторых, поток ключа расходуется строго последовательно и в точности повторяет порядок, в котором шифровала отправляющая сторона: первые 4 байта ушли на поле типа, остальное — на всё, что за ним.
|
||
|
||
Отдельная приятная деталь: при выключенной защите заголовков «хэш типа» состоит из нулей, а операция «исключающее ИЛИ» с нулями ничего не меняет. Один и тот же код работает в обоих режимах без ветвлений — источник целого класса ошибок здесь просто отсутствует.
|
||
|
||
## Как выглядит пакет на проводе
|
||
|
||
**Транспортный пакет (третье поколение, защита включена):**
|
||
|
||
```
|
||
┌───────────┬────────────────┬──────────────────────┬───────────────────────────┬───────────┐
|
||
│ nonce │ остаток S4 │ заголовок (16 байт) │ шифртекст полезной │ метка │
|
||
│ 12 байт │ S4-12 байт │ ЗАШИФРОВАН ChaCha20 │ нагрузки + content padding│ Poly1305 │
|
||
│ случайных │ случайных │ тип│получатель│счётчик│ │ 16 байт │
|
||
└───────────┴────────────────┴──────────────────────┴───────────────────────────┴───────────┘
|
||
└── открытым текстом, но неотличимо от шума ──┘ └── и до, и после: сплошная псевдослучайность ──┘
|
||
```
|
||
|
||
**Сообщение инициации рукопожатия:**
|
||
|
||
```
|
||
┌───────────┬───────────────┬──────────────────────────────────────────────┐
|
||
│ nonce │ остаток S1 │ 148 байт сообщения, ЗАШИФРОВАННЫХ ЦЕЛИКОМ │
|
||
│ 12 байт │ S1-12 байт │ (тип, отправитель, ключи, метка времени, │
|
||
│ │ │ MAC1, MAC2 — всё) │
|
||
└───────────┴───────────────┴──────────────────────────────────────────────┘
|
||
Итоговый размер: S1 + 148 байт — постоянный для данной конфигурации
|
||
```
|
||
|
||
Для наблюдателя со стороны сети датаграмма целиком выглядит равномерным шумом: ни одного поля с предсказуемым значением, ни одной структуры, за которую можно зацепиться сигнатурой. В версии 2.0 первые четыре байта после паддинга были осмысленным числом из диапазона `H1`–`H4`, а дальше шли постоянный индекс получателя и монотонно растущий счётчик.
|
||
|
||
## Content padding: как считается добавка
|
||
|
||
Механизм устроен проще, чем можно подумать по названию, и умещается в один вспомогательный расчёт:
|
||
|
||
- если `ContentPaddingAddition` не задан, возвращается признак «нет добавки», и работает обычное выравнивание длины до кратности 16;
|
||
- если задан, из диапазона берётся случайное число;
|
||
- добавка ограничивается сверху свободным местом до MTU (максимального размера передаваемого блока), чтобы не спровоцировать фрагментацию;
|
||
- полученное количество **нулевых** байт дописывается в конец открытого текста, после чего всё вместе шифруется.
|
||
|
||
Два следствия, важных на практике. Первое: добавка попадает **внутрь** зашифрованной части, наблюдатель видит только изменившуюся длину пакета — сами байты паддинга он отличить от данных не может. Второе: заданный content padding **отменяет** штатное выравнивание по 16 байт. Именно в этом смысл механизма — предсказуемая сетка длин, кратных шестнадцати, сама по себе является признаком, по которому поток можно отнести к WireGuard-подобным.
|
||
|
||
> [!note] Keepalive тоже паддится — это скрытое улучшение
|
||
> Служебные пакеты поддержания соединения проходят тот же путь, что и обычные: паддинг `S4` им назначается при создании исходящего элемента, а content padding считается от нулевого размера полезной нагрузки. В [[amnezia-2-0/reference|версии 2.0]] keepalive шёл в обход `S4` и всегда весил ровно 32 байта — постоянный размер, повторяющийся строго по таймеру, то есть отличный опознавательный признак. Ни в README, ни в анонсах это изменение не упомянуто.
|
||
|
||
### Побочный эффект: рукопожатие каждые 15 секунд вместо двух минут
|
||
|
||
У паддинга keepalive обнаружился дефект, живший в релизах третьего поколения с 24 июля по 5 августа 2026 года. Обе реализации опознавали keepalive по длине пакета, а паддинг эту длину изменил. В `amneziawg-go` тега `v3.0.3` проверка «отправлены ли данные» сравнивает длину уже собранного пакета с 32 байтами (`len(elem.packet) != MessageKeepaliveSize` в `device/send.go`), но к этому моменту в пакет уже вшит префикс `S4` — при любом ненулевом `S4` каждый keepalive засчитывается как данные. Второй, независимый путь — content padding: расшифрованный keepalive у приёмника перестаёт быть пустым, проверка `len == 0` в `device/receive.go` не срабатывает, и пакет уходит в ветку «получены данные».
|
||
|
||
Следствие для простаивающего туннеля: отправка «данных» взводит таймер нового рукопожатия на `KeepaliveTimeout + RekeyTimeout` — по умолчанию 10 + 5 = 15 секунд, — а погасить его на молчащем туннеле нечем. Получается самоподдерживающийся цикл: рукопожатие → подтверждающий keepalive → таймер → новое рукопожатие, и так каждые ~15 секунд. Замеры в issue [#186](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/186) модуля ядра: медиана интервала между рукопожатиями 15,0 с при `S4 = 17` против 147–148 с у контроля без `S4` — примерно десятикратный рост. Перед каждым лишним рукопожатием, как обычно, уходят `Jc` мусорных и `I1`–`I5` сигнатурных пакетов, так что дефект не просто тратил трафик, а умножал самую заметную часть почерка протокола.
|
||
|
||
Границы дефекта по реализациям различаются. В модуле ядра он старше третьего поколения: issue #186 создано 15 июля 2026 года, за две недели до выхода 3.0 в модуле, — проверка `is_keepalive = skb->len == message_data_len(0)` в `src/send.c` выполнялась после добавления `S4`-мусора, то есть ошибка касалась и установок [[amnezia-2-0/reference|AWG 2.0]] с ненулевым `S4`. В go-движке дефект появился именно в линии 3.0 — на `v0.2.19` та же проверка шла до добавления паддинга, и keepalive распознавался корректно (полевые замеры в том же issue: медиана 15,0 с на `v3.0.3` против 132,0 с на `v0.2.19`).
|
||
|
||
Исправление: в модуле ядра — PR [#208](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/pull/208) (смержен 5 августа 2026; keepalive теперь помечается явным флагом, а приёмник считает keepalive-ом и пакет из одних нулевых байт), вошёл в теги `v3.0.20260805` и `v3.1.20260812`. В go исправление вышло в тот же день тегом `v3.0.20260805`. На более ранних сборках дефект лечился бы только нулевым `S4`, что при включённой защите заголовков невозможно (`S4` ≥ 12), — поэтому обновление обязательно.
|
||
|
||
## Тайминги: где берётся случайное значение, а где граница
|
||
|
||
Шесть таймеров стали диапазонами, но применяются они не одинаково, и разница принципиальна для устойчивости туннеля.
|
||
|
||
**Случайное значение при каждом взводе** (`PickOne` берёт новое число из диапазона всякий раз): интервал переустановки ключей, пауза перед повтором рукопожатия, таймер отправки keepalive, интервал `PersistentKeepalive`, максимальное число попыток рукопожатия. Именно эти вызовы и размывают ритм соединения — два подряд рукопожатия не совпадут по времени.
|
||
|
||
**Границы диапазона вместо случайного значения** — там, где протокол обязан сохранять внутренние инварианты. Например, срок жизни связки ключей вычисляется по **верхней** границе, а минимальный интервал между попытками рукопожатия — по **нижней**. Логика понятна: если бы движок брал случайные значения и здесь, он мог бы, например, посчитать ключ протухшим раньше, чем истёк допустимый срок его использования, и туннель начал бы рвать сам себя.
|
||
|
||
Проще говоря: случайность добавлена там, где она видна снаружи и не ломает протокол, а во внутренних проверках используются осторожные крайние значения.
|
||
|
||
## Что изменилось относительно AWG 2.0
|
||
|
||
| Аспект | AWG 2.0 | AWG 3.0 |
|
||
|---|---|---|
|
||
| Поле типа сообщения | значение из диапазона `H1`–`H4`, открыто | зашифровано |
|
||
| Индекс получателя и счётчик | открыты, предсказуемы | зашифрованы |
|
||
| MAC1 и MAC2 в рукопожатиях | открыты | зашифрованы вместе со всем сообщением |
|
||
| Паддинг `S1`–`S4` | чистый мусор | мусор плюс источник одноразового числа |
|
||
| Минимум `S1`–`S4` | 0 (любое) | 12 при включённой защите заголовков |
|
||
| Момент добавления `S4` | сдвиг готового пакета вправо | место резервируется заранее |
|
||
| Keepalive | без `S4`, ровно 32 байта | с `S4` и content padding |
|
||
| Длина полезной нагрузки | выравнивание до 16 байт | случайная добавка из диапазона |
|
||
| Тайминги | константы WireGuard | диапазоны, новое значение на каждый взвод |
|
||
| Мусорные и сигнатурные пакеты | `Jc`/`Jmin`/`Jmax`, `I1`–`I5`, язык CPS | без изменений |
|
||
| Криптография WireGuard | не менялась | не менялась |
|
||
|
||
Последнюю строку стоит проверить отдельно, поскольку вокруг неё много домыслов: файл с криптографическими примитивами (`device/noise-helpers.go`) в теге `v3.0.3` **побайтно совпадает** с оригиналом из `wireguard-go`. Рукопожатие Noise IKpsk2, эллиптическая кривая Curve25519, хэш Blake2s — всё нетронуто. Защита заголовков надстроена поверх готовых сообщений и в выработке ключей не участвует.
|
||
|
||
## Анализ: что протокол закрывает, а что оставляет видимым
|
||
|
||
Раздел ниже — разбор по коду, а не позиция команды Amnezia: официального описания модели угроз третьего поколения не публиковалось.
|
||
|
||
### Что закрыто
|
||
|
||
**Сигнатуры по содержимому пакета.** После шифрования заголовков в датаграмме не остаётся ни одного поля с предсказуемым или медленно меняющимся значением. Правило вида «четыре байта в начале равны 4, следующие четыре постоянны в рамках потока, дальше восьмибайтовый счётчик растёт на единицу» — а это и есть классический способ опознать WireGuard — больше не срабатывает.
|
||
|
||
**Сигнатура keepalive.** Постоянный 32-байтовый пакет, приходящий строго по таймеру, был удобной зацепкой даже при полностью зашифрованном содержимом: важен не смысл байтов, а сам факт периодического повтора одинакового размера. Теперь размер плавает за счёт `S4` и content padding, а момент отправки — за счёт диапазонного таймера.
|
||
|
||
**Сетка длин, кратных 16.** Content padding убирает регулярность, которая выдавала внутреннее выравнивание.
|
||
|
||
### Что осталось
|
||
|
||
**Постоянные размеры рукопожатий.** Content padding применяется только к транспортным пакетам; сообщения инициации и ответа собираются в буфер фиксированной длины — `S1`+148 и `S2`+92 байта соответственно. Для конкретной конфигурации это две константы, и характерный обмен «датаграмма размера X → в ответ датаграмма размера Y → следом поток» никуда не делся. Универсальной сигнатуры здесь нет, поскольку `S1` и `S2` у каждой установки свои, но **структурный** признак — короткий двухшаговый обмен фиксированных размеров перед началом потока — наблюдаемый. Ровно этот признак закрывает вышедшая 12 августа 2026 года линия 3.1: параметр `RandomTrailers` дописывает к пакетам рукопожатия хвост случайной длины, но требует включения на обеих сторонах — разбор в [[amnezia-3-0/reference|обзорной заметке]].
|
||
|
||
**Молчание в ответ на активное зондирование.** Пакет, не подошедший ни под один размер и диапазон заголовков, просто отбрасывается без ответа. Для системы, которая проверяет подозрительный адрес отправкой мусора, «сервер, который не отвечает вообще ничем на порт UDP» — тоже поведенческий признак. Механизм отката на локальный веб-сервис, который решал бы эту задачу по образцу REALITY, в репозитории существует, но живёт в экспериментальной ветке и в релиз третьего поколения не вошёл.
|
||
|
||
**Профиль потока данных.** Объём трафика, ритм пакетов приложения, длительность сессий — обфускация уровня протокола на это не влияет. В описанной разработчиком Amnezia балльной системе, где сервер блокируется по сумме признаков, объём трафика назван одним из ключевых факторов.
|
||
|
||
**Блокировка по адресу.** Если адрес сервера уже в списках, смена версии протокола не поможет. Это ограничение общее для всех решений такого класса, см. [[DPI/ru-network-blocklists|обзор сетевых блокировок]] и [[DPI/vpn-blocking-wave-forecast-summer-2026|прогноз по волнам блокировок]].
|
||
|
||
### Криптографические заметки
|
||
|
||
Не уязвимости, а свойства конструкции, о которых стоит знать.
|
||
|
||
Ключ защиты заголовков **статичен** на всё время жизни интерфейса, а одноразовое число имеет длину 96 бит и берётся из криптостойкого генератора случайных чисел. Повтор одноразового числа означал бы повторное использование потока ключа — для потокового шифра это ведёт к утечке: исключающее ИЛИ двух заголовков раскрывается. Оценка запаса по «парадоксу дней рождения»: пятидесятипроцентная вероятность совпадения достигается примерно после 2⁴⁸ пакетов — это порядка 10¹⁴ штук, то есть годы непрерывной работы канала на гигабитной скорости. Практического риска нет, но и бесконечным запас не является, а ротации этого ключа в протоколе не предусмотрено.
|
||
|
||
Шифр применяется **без аутентификации**, и это корректно: подлинность обеспечивают штатные механизмы WireGuard (MAC1 и MAC2 в рукопожатиях, метка Poly1305 в транспортных пакетах), которые остались на месте. Расшифровка заголовка сама по себе ничего не подтверждает — она лишь позволяет опознать пакет, а решение о доверии принимается ниже по конвейеру.
|
||
|
||
### Накладные расходы
|
||
|
||
К каждому транспортному пакету добавляется минимум 12 байт паддинга плюс случайная добавка content padding. На типичном пакете в 1400 байт минимальный обязательный оверхед составляет около 0,9% от полосы, что для практических целей несущественно; настроенный на широкий диапазон content padding способен добавить заметно больше — это осознанный размен скорости на маскировку. Вычислительная нагрузка мала: ChaCha20 обрабатывает 16 байт заголовка на пакет, а рукопожатия происходят раз в пару минут.
|
||
|
||
## Ловушки, замеченные в коде
|
||
|
||
> [!warning] Сообщение об ошибке нумерует параметры с нуля
|
||
> Если движок отвергает конфигурацию, он пишет `S0 must be more then 12 to use headerProtection`. Параметра `S0` не существует: проверка перебирает четыре значения в цикле и подставляет индекс массива, начинающийся с нуля, так что `S0` в сообщении означает **`S1`**, `S1` означает `S2` и так далее. При диагностике прибавляйте единицу. В той же строке живёт опечатка «more then» вместо «more than», а формулировка «больше 12» неточна — проверка отвергает значения **меньше** 12, само число 12 допустимо.
|
||
|
||
Ещё три момента, на которых легко споткнуться:
|
||
|
||
**Снять параметр обфускации «на живую» нельзя — только пересозданием интерфейса.** Команды `awg setconf` и `awg syncconf` для AWG-параметров работают на добавление и изменение: обе реализации трогают только те ключи, которые присутствуют в переданной конфигурации, а про отсутствующие ничего не знают — удалённая из файла строка не обнуляет значение на работающем интерфейсе. Изменить значение так можно, убрать совсем — нет: интерфейс придётся пересоздать (`awg-quick down`/`up` или `systemctl restart awg-quick@awg0`), что на сервере оборвёт соединения всех клиентов. Наблюдение мейнтейнера стороннего установщика [amneziawg-installer](https://github.com/bivlked/amneziawg-installer), согласуется с устройством обеих реализаций: и netlink-обработчик модуля ядра, и UAPI-парсер go обрабатывают только присутствующие атрибуты.
|
||
|
||
**Требование к паддингу распространяется на все четыре значения сразу.** Даже если вы, скажем, не собираетесь получать cookie-ответы под нагрузкой, `S3` всё равно обязан быть не меньше 12 — проверка не смотрит, какие типы сообщений реально используются.
|
||
|
||
**README до 31 июля 2026 года указывал порог 8.** В коде порог всегда был 12; расходились именно документация и текст ошибки, исправленные коммитом `ce7cf103` в составе тега `v3.0.3`. Конфигурации, собранные по более ранней инструкции, работать не будут.
|
||
|
||
**Набор тегов сигнатур зависит от реализации.** README перечисляет пять тегов языка CPS, но фактические наборы двух реализаций расходятся в обе стороны (сверено 20 августа 2026 года: `device/obf.go` движка go на тегах `v3.0.3`/`v3.1.20260814`, `src/junk.c` модуля ядра на `v3.0.20260805`/`v3.1.20260812`):
|
||
|
||
| Тег | amneziawg-go | Модуль ядра |
|
||
|---|:--:|:--:|
|
||
| `<b>`, `<r>`, `<rc>`, `<rd>`, `<t>` | есть | есть |
|
||
| `<d>`, `<ds>`, `<dz>` — недокументированные | есть | **нет** — конфиг отвергается с `-EINVAL` |
|
||
| `<c>` — недокументированный счётчик спецпакетов (u32 big-endian, стартует со случайного значения при инициации рукопожатия) | **нет** | есть (`parse_c_tag` в `src/junk.c`) |
|
||
|
||
Практическое следствие: конфигурация с `<c>` работает на узле с модулем ядра, но не поднимется на userspace-движке — то есть в LXC и Docker, на роутерах, на macOS; конфигурация с `<d>`/`<ds>`/`<dz>` — ровно наоборот. Отказ в обоих случаях жёсткий, но заметен по-разному. Движок go пишет в журнал демона строку вида `failed to parse I1: unknown tag <c>` (клиенту по UAPI уходит только код ошибки). Модуль ядра возвращает голый `-EINVAL`, а поясняющую строку `I1-packet invalid format` печатает через `net_dbg_ratelimited()` — без включённого dynamic debug в `dmesg` пусто; отсюда жалобы вида «интерфейс просто не поднимается, в логе ничего». Правило для рецептов `I1`–`I5`, которые предлагаются другим людям: использовать только пересечение `{b, r, rc, rd, t}`. Теги `<d>`/`<ds>`/`<dz>` в пакетах `I1`–`I5` и так почти бесполезны — они получают на вход пустые данные; полный разбор go-набора — в [[amnezia-2-0/reference|справочнике по AmneziaWG 2.0]]. В линиях 3.0 и 3.1 наборы обеих реализаций идентичны, go-набор не менялся с 2.0.
|
||
|
||
## Практика: как это отлаживать
|
||
|
||
- [ ] Сгенерировать ключ защиты заголовков: `awg genkey` из пакета `amneziawg-tools` версии `v3.0.20260730` или новее — более старые утилиты не знают новых ключей конфигурации и молча их не передадут.
|
||
- [ ] Проверить, что все четыре значения `S1`–`S4` не меньше 12; при ошибке про `S0` смотреть на `S1`.
|
||
- [ ] Убедиться, что `HeaderProtectionKey` побайтно одинаков на сервере и клиенте — при расхождении соединение не установится, а в журнале не будет ничего осмысленного: пакеты просто отбрасываются как неопознанные.
|
||
- [ ] Поднять уровень журналирования переменной окружения `LOG_LEVEL=debug` — сообщения о неопознанных пакетах и об инициализации шифра идут именно туда.
|
||
- [ ] На модуле ядра пояснения к отказам (`I1-packet invalid format`, `S0 must be more then 12…`) по умолчанию не печатаются: `awg` показывает голое `Unable to modify interface: Invalid argument`. Включить отладку: `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`, повторить неудачную команду, посмотреть `dmesg | tail`, затем выключить той же командой с `-p`.
|
||
- [ ] На Linux с модулем ядра брать ревизию не ниже `v3.0.20260805`: в ней исправлен keepalive-дефект с рукопожатиями каждые 15 секунд, а в ревизиях до `v3.0.20260731-04` вдобавок падала сборка на ядрах старше 6.7 и мог молча не применяться ключ защиты заголовков.
|
||
- [ ] Ревизию загруженного модуля смотреть командой `cat /sys/module/amneziawg/version`: у DKMS-сборки там строка тега (например, `3.1.20260812`), у модуля, собранного вручную через `make`, — `1.0.0`. Версия apt-пакета для проверки не годится: она всегда начинается с `1.0.0` (версия debian-упаковки, заморожена с 2023 года), линия кода видна только по git-хешу в конце строки.
|
||
- [ ] Если конфигурация должна работать и с модулем ядра, и с userspace-движком (LXC, Docker, роутеры, macOS) — собирать `I1`–`I5` только из тегов `{b, r, rc, rd, t}`: наборы тегов реализаций расходятся, подробности в «Ловушках» выше.
|
||
- [ ] Не копировать значения `I1`–`I5` из примеров: повторяющаяся у тысяч установок сигнатура работает против вас, чему посвящён отдельный разбор в [[amnezia-3-0/reference|обзорной заметке]].
|
||
|
||
## 📚 См. также
|
||
|
||
- [[amnezia-3-0/reference|AmneziaWG 3.0]] — обзор: зачем выпущен, кому доступен, история поколений и разбор мифов вокруг релиза.
|
||
- [[amnezia-2-0/reference|AmneziaWG 2.0: справочник параметров]] — база, поверх которой надстроено третье поколение: мусорные пакеты, паддинг, диапазонные заголовки, язык CPS и все восемь его тегов.
|
||
- [[amnezia-3-0/client-5-0-0-5|AmneziaVPN 5.0.0.5]] — приложение, в котором третье поколение приехало пользователям.
|
||
- [[DPI/statistical-morphing-concept|Статистический морфинг трафика]] — общая идея правки длин и таймингов, частным случаем которой является content padding.
|
||
- [[protocols/00-overview|Карта протоколов обхода блокировок]] — место AmneziaWG среди остальных решений.
|
||
- 🔗 [amneziawg-go, тег v3.0.3](https://github.com/amnezia-vpn/amneziawg-go/tree/v3.0.3) — исходники, по которым сделан разбор.
|
||
|
||
---
|
||
|
||
> [!quote] 🤖 Эти статьи открыты — можно обучать на них ИИ
|
||
> При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование и скачивание всего репозитория одним zip-архивом доступны в Forgejo: [исходник этой заметки](https://git.zapret.moe/zapretdiscordyoutube/todo/src/branch/main/amnezia-3-0/internals.md) · [весь репозиторий](https://git.zapret.moe/zapretdiscordyoutube/todo/src/branch/main).
|