AmneziaWG 2.0 — полный справочник параметров
Составлен на основе анализа исходного кода amneziawg-go Верифицировано по исходникам: 2026-03-25
У протокола есть третье поколение
Справочник описывает AmneziaWG 2.0. 24 июля 2026 года вышло третье поколение — AmneziaWG 3.0 с шифрованием заголовков пакетов: обзор в заметке про AmneziaWG 3.0, побайтовый разбор по коду — в описании внутреннего устройства, сопутствующий релиз приложения — в AmneziaVPN 5.0.0.5. Для своих серверов третье поколение на 20 августа 2026 года всё ещё не выпущено (поддержка смержена в ветку разработки приложения 6 августа, но релиза с ней нет), поэтому параметры ниже остаются актуальными для self-hosted установок.
Обзор
AmneziaWG (AWG, АмнезияВГ 2.0, Амнезия ВПН VPN) — модифицированный WireGuard с обфускацией трафика для обхода DPI-блокировок.
Версия 1.0 (2023): мусорные пакеты, padding handshake, фиксированные заголовки. Версия 1.5 (июль 2025): signature packets i1-i5 — мимикрия под QUIC, DNS и SIP. Версия 2.0 (сентябрь 2025): range-based заголовки, padding для всех типов пакетов, CPS-язык для описания сигнатур. Версия 3.0 (июль 2026): шифрование заголовков, content padding, диапазонные тайминги — см. отдельную заметку.
Клиент: AmneziaVPN 4.8.12.9+ (desktop, Android). Self-hosted only для AWG 2.0.
Все параметры протокола (17 штук)
1. Junk Packets — мусорные пакеты перед handshake
| Параметр | Тип | Валидация | Описание |
|---|---|---|---|
| Jc | int | > 0, строго положительное | Количество мусорных пакетов |
| Jmin | int | > 0, строго положительное | Минимальный размер пакета (байт) |
| Jmax | int | > 0, строго положительное | Максимальный размер пакета (байт) |
Дефолт: 0 (отключено). Сериализуется в вывод только если != 0.
- Отправляются только при handshake initiation (~раз в 2 минуты)
- Размер каждого пакета:
crypto/rand.Int(Jmax - Jmin + 1) + Jmin - Данные:
crypto/rand.Read(криптографически безопасные) - Рекомендованный диапазон Jc: 4-12
Важно: в коде нет перекрёстной валидации Jmin <= Jmax — параметры проверяются независимо. Если задать Jmin > Jmax, поведение непредсказуемо.
Пример: Jc=7, Jmin=50, Jmax=1000
→ 7 пакетов, каждый 50-1000 байт случайных данных
2. Padding — случайные байты перед пакетами
| Параметр | Версия | Применяется к | Размер сообщения | Как часто |
|---|---|---|---|---|
| S1 | 1.0 | Handshake Initiation | 148 байт | Каждый handshake (~2 мин) |
| S2 | 1.0 | Handshake Response | 92 байта | Каждый handshake |
| S3 | 2.0 | Cookie Reply | 64 байта | Редко (только под нагрузкой) |
| S4 | 2.0 | Transport Data | переменный | Каждый data-пакет |
Валидация: int >= 0 (в отличие от Jc/Jmin/Jmax, допускается 0). Дефолт: 0 (отключено).
Механизм S1/S2/S3 — выделяется новый буфер, random prefix:
buf = make([]byte, padding + len(packet))
rand.Read(buf[:padding]) // Заполняем prefix случайными байтами
copy(buf[padding:], packet) // Копируем пакет после prefix
Механизм S4 — сдвиг данных в существующем буфере (отличается от S1-S3!):
// Сдвигаем зашифрованные данные ВПРАВО на padding байт
for i := len(elem.packet) - 1; i >= 0; i-- {
elem.buffer[i+padding] = elem.buffer[i]
}
rand.Read(elem.buffer[:padding]) // Заполняем начало случайными байтами
Приём (все типы) — DeterminePacketTypeAndPadding() в receive.go:
data = packet[padding:] // Пропускаем padding, читаем заголовок
header.Validate(LittleEndian.Uint32(data)) // Проверяем magic header
Особенности S4:
- Применяется к каждому data-пакету (основной трафик)
- НЕ применяется к keepalive-пакетам (проверка:
len(elem.packet) != MessageKeepaliveSize)MessageKeepaliveSize = 32байта (transport header 16B + Poly1305 tag 16B, нулевой payload)
- Добавляется поверх стандартного WireGuard-выравнивания до 16 байт (
PaddingMultiple = 16) - S1-S4 не обязаны быть кратны 16 — это любое целое >= 0;
PaddingMultiple— отдельный внутренний механизм WireGuard - При больших значениях может превысить MTU → фрагментация
3. Magic Headers — заголовки пакетов
| Параметр | Тип пакета | Формат | Byte order |
|---|---|---|---|
| H1 | Handshake Init | "N" или "N-M" (uint32 range) | little-endian |
| H2 | Handshake Response | "N" или "N-M" | little-endian |
| H3 | Cookie Reply | "N" или "N-M" | little-endian |
| H4 | Transport Data | "N" или "N-M" | little-endian |
Тип значения: uint32 (0 — 4 294 967 295).
Дефолтные значения (устанавливаются в NewDevice()):
device.headers.init = &magicHeader{start: 1, end: 1} // MessageInitiationType
device.headers.response = &magicHeader{start: 2, end: 2} // MessageResponseType
device.headers.cookie = &magicHeader{start: 3, end: 3} // MessageCookieReplyType
device.headers.transport = &magicHeader{start: 4, end: 4} // MessageTransportTypeТ.е. по умолчанию заголовки = стандартный WireGuard (1, 2, 3, 4). AWG без конфигурации H1-H4 полностью совместим с обычным WireGuard.
Реализация (magic-header.go):
type magicHeader struct {
start uint32
end uint32
}
func (h *magicHeader) Generate() uint32 {
high := int64(h.end - h.start + 1)
r, _ := rand.Int(rand.Reader, big.NewInt(high))
return h.start + uint32(r.Int64())
}
func (h *magicHeader) Validate(val uint32) bool {
return h.start <= val && val <= h.end
}Парсинг:
"42"→ start=42, end=42 (фиксированный заголовок, как в AWG 1.0)"471800590-471800690"→ start=471800590, end=471800690 (101 вариант)- Валидация:
end >= start, иначе ошибка"wrong range specified"
Критичное ограничение: диапазоны H1, H2, H3, H4 НЕ должны пересекаться.
Проверка происходит в ipcSetDevice.mergeWithDevice() — специальной функции, которая:
- Заполняет не указанные в текущем IPC-вызове заголовки из существующего конфига устройства
- Проверяет все 4 заголовка попарно на пересечение
- Если ОК — применяет к устройству
Это значит: можно обновить один заголовок (напр. только H1) без повторного указания H2-H4 — они возьмутся из текущего конфига.
// mergeWithDevice() — overlap check
headers := []*magicHeader{d.headers.init, d.headers.response, d.headers.cookie, d.headers.transport}
for i := 0; i < len(headers); i++ {
for j := i + 1; j < len(headers); j++ {
if left.start <= right.end && right.start <= left.end {
return errors.New("headers must not overlap")
}
}
}Где применяются:
- H1 →
CreateMessageInitiation()вnoise-protocol.go:msg.Type = device.headers.init.Generate() - H2 →
CreateMessageResponse():msg.Type = device.headers.response.Generate() - H3 →
SendHandshakeCookie():msgType := device.headers.cookie.Generate() - H4 →
RoutineEncryption():msgType := device.headers.transport.Generate()
ХОРОШО (не пересекаются):
H1 = 100-200
H2 = 300-400
H3 = 500-600
H4 = 700-800
ПЛОХО:
H1 = 100-200
H2 = 150-250 # Пересекается с H1 → ошибка "headers must not overlap"
4. Signature Packets (i1-i5) — мимикрия под протоколы (NEW в 2.0)
| Параметр | Тип | Индекс в массиве |
|---|---|---|
| i1 | string (CPS) | device.ipackets[0] |
| i2 | string (CPS) | device.ipackets[1] |
| i3 | string (CPS) | device.ipackets[2] |
| i4 | string (CPS) | device.ipackets[3] |
| i5 | string (CPS) | device.ipackets[4] |
- До 5 пакетов, отправляемых перед каждым WireGuard handshake
- Описываются на языке CPS (Custom Protocol Signature)
- Если пакет не настроен (
nil) — пропускается - Хранятся в
device.ipackets [5]*obfChain
Отправка (из SendHandshakeInitiation()):
for _, ipacket := range peer.device.ipackets {
if ipacket != nil {
buf := make([]byte, ipacket.ObfuscatedLen(0))
ipacket.Obfuscate(buf, nil) // src = nil, генерация из тегов
sendBuffer = append(sendBuffer, buf)
}
}CPS — язык описания сигнатур
Все 8 тегов amneziawg-go (из исходного кода)
Набор тегов различается между реализациями
Всё в этом разделе — про userspace-движок amneziawg-go. Модуль ядра Linux разбирает другой набор (сверено 20 августа 2026 года на тегах линий 3.0/3.1, функция
jp_parse_tags()вsrc/junk.c):<b>,<t>,<r>,<rc>,<rd>плюс собственный недокументированный<c>— 4 байта, счётчик спецпакетов (u32 big-endian, при инициации рукопожатия стартует со случайного значения и растёт с каждым отправленным пакетом). Тегов<d>,<ds>,<dz>модуль ядра не знает: конфигурация с ними отвергается целиком с-EINVAL, причём пояснениеI1-packet invalid formatпечатается черезnet_dbg_ratelimited()и без включённого dynamic debug вdmesgне появляется. Обратно, конфигурация с<c>не поднимется на go-движке (LXC, Docker, роутеры, macOS) — в журнале демона будетunknown tag <c>. Переносимые между реализациями рецептыI1–I5— только пересечение{b, r, rc, rd, t}. Контекст и таблица расхождений — в разборе внутреннего устройства AWG 3.0.
Зарегистрированы в obfBuilders map в obf.go:
var obfBuilders = map[string]obfBuilder{
"b": newBytesObf, // obf_bytes.go
"t": newTimestampObf, // obf_timestamp.go
"r": newRandObf, // obf_rand.go
"rc": newRandCharObf, // obf_randchars.go (файл с 's'!)
"rd": newRandDigitsObf, // obf_randdigits.go
"d": newDataObf, // obf_data.go
"ds": newDataStringObf, // obf_datastring.go
"dz": newDataSizeObf, // obf_datasize.go
}| Тег | Формат | Параметр | Размер вывода | Описание | Документирован? |
|---|---|---|---|---|---|
<b> | <b 0xDEADBEEF> | обязателен (hex) | len(hex)/2 байт | Фиксированные байты | Да |
<t> | <t> | игнорируется | 4 байта | Unix timestamp, big-endian uint32 | Да |
<r> | <r 100> | обязателен (int) | N байт | Криптографически случайные байты | Да |
<rc> | <rc 10> | обязателен (int) | N байт | Случайные буквы a-zA-Z (52 символа) | Да |
<rd> | <rd 5> | обязателен (int) | N байт | Случайные цифры 0-9 | Да |
<d> | <d> | игнорируется | = input | Pass-through (копия входных данных) | Нет |
<ds> | <ds> | игнорируется | ~133% input | Base64 RawStdEncoding (без ’=’ padding) | Нет |
<dz> | <dz 4> | обязателен (int) | N байт (фикс.) | Длина входных данных в big-endian байтах | Нет |
Синтаксис CPS
Формат: <тег параметр>
Цепочка: <тег1 параметр1><тег2 параметр2><тег3>...
Парсер (newObfChain() в obf.go):
- Ищет теги между
<и> - Имя тега — первый токен (до пробела), параметр — второй токен
- Теги обрабатываются последовательно слева направо
- Результаты конкатенируются в один пакет
- Ошибки не останавливают парсинг — собираются через
errors.Join()и возвращаются все разом
Ошибки парсера:
"missing enclosing >"— незакрытый тег"empty tag"— пустые скобки<>"unknown tag <X>"— тег не найден вobfBuilders"failed to build <X>: ..."— ошибка конструктора тега
Интерфейс обфускатора
type obf interface {
Obfuscate(dst, src []byte) // Записывает в dst
Deobfuscate(dst, src []byte) bool // Валидация + восстановление
ObfuscatedLen(srcLen int) int // Размер выхода
DeobfuscatedLen(srcLen int) int // Размер после деобфускации
}Детали реализации каждого тега
<b 0xHEX> — фиксированные байты (obf_bytes.go)
Вход: hex-строка, префикс "0x" опционален
Обработка префикса: strings.TrimPrefix(val, "0x") — только СТРОЧНЫЙ "0x"!
"0X" (заглавный) НЕ распознаётся и останется в строке → ошибка.
Примеры: "0xDEADBEEF" → "DEADBEEF", "DEADBEEF" → "DEADBEEF"
Выход: бинарные данные из hex.DecodeString()
Размер: len(hex_digits) / 2
Ошибки:
- "empty argument" — пустая строка (после trim)
- "odd amount of symbols" — НЕЧЁТНОЕ кол-во hex-цифр (каждый байт = 2 hex-цифры)
- hex.DecodeString error — невалидные hex-символы
Deobfuscate: проверяет ТОЧНОЕ побайтовое совпадение → false если не совпали
⚠ ВНИМАНИЕ: hex-строка ДОЛЖНА содержать ЧЁТНОЕ число hex-цифр!
"0xDEADBEEF" → 8 цифр → OK (4 байта)
"0xc7000000010" → 11 цифр → ОШИБКА "odd amount of symbols"!
"0xc70000000108" → 12 цифр → OK (6 байт)
<t> — timestamp (obf_timestamp.go)
Вход: параметр игнорируется (конструктор: newTimestampObf(_ string))
<t> и <t anything> — оба валидны
Выход: 4 байта = time.Now().Unix() в big-endian uint32
Deobfuscate: ВСЕГДА true (нет проверки значения!)
DeobfuscatedLen: 0
В коде комментарий: "replay attack check? requires time to be always synchronized"
→ защита от replay НЕ реализована
<r N> — случайные байты (obf_rand.go)
Вход: N — целое число (strconv.Atoi), обязательный параметр
Выход: N байт из crypto/rand.Read
Deobfuscate: ВСЕГДА true (невозможно проверить случайность)
DeobfuscatedLen: 0
В коде: "// there is no way to validate randomness :)"
<rc N> — случайные буквы (obf_randchars.go)
Вход: N — целое число
Алфавит: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" (52 символа)
Генерация: random_byte % 52 → индекс в алфавите
Deobfuscate: проверяет unicode.IsLetter() для каждого байта
DeobfuscatedLen: 0
<rd N> — случайные цифры (obf_randdigits.go)
Вход: N — целое число
Алфавит: "0123456789" (10 символов)
Генерация: random_byte % 10 → индекс
Deobfuscate: проверяет unicode.IsDigit() для каждого байта
DeobfuscatedLen: 0
<d> — pass-through (obf_data.go) — НЕ ДОКУМЕНТИРОВАН
Параметр: игнорируется (<d> и <d anything> — оба валидны)
Назначение: передаёт входные данные (src) без изменений в dst
ObfuscatedLen(n) = n (размер не меняется)
DeobfuscatedLen(n) = n
Deobfuscate: всегда true
⚠ В signature packets (i1-i5) src=nil → <d> выводит 0 байт (бесполезен)
<ds> — Base64 (obf_datastring.go) — НЕ ДОКУМЕНТИРОВАН
Параметр: игнорируется
Назначение: кодирует src в Base64 (encoding/base64.RawStdEncoding, без '=' padding)
ObfuscatedLen(n) = base64.RawStdEncoding.EncodedLen(n) (≈133% от входа)
DeobfuscatedLen(n) = base64.RawStdEncoding.DecodedLen(n)
Deobfuscate: декодирует Base64, но ИГНОРИРУЕТ ошибки декодирования (потенциальный баг)
⚠ В signature packets (i1-i5) src=nil → <ds> выводит 0 байт (бесполезен)
<dz N> — размер данных (obf_datasize.go) — НЕ ДОКУМЕНТИРОВАН
Вход: N — количество байт для кодирования размера (strconv.Atoi), ОБЯЗАТЕЛЕН
Назначение: записывает len(src) как big-endian число в N байт
Алгоритм:
for i := N-1; i >= 0; i-- {
dst[i] = byte(srcLen & 0xFF)
srcLen >>= 8
}
ObfuscatedLen(n) = N (фиксированный размер)
DeobfuscatedLen(n) = 0
Deobfuscate: всегда true
⚠ В signature packets (i1-i5) src=nil → len(nil)=0 → выводит N нулевых байт (0x00...00)
Пример: <dz 2> в i1 → всегда выдаёт 0x0000
Порядок отправки пакетов (верифицировано по send.go)
SendHandshakeInitiation() — полная последовательность
HANDSHAKE (каждые ~2 минуты, проверка RekeyTimeout):
┌─────────────────────────────────────────────────────────────┐
│ 1. Signature packets: i1 → i2 → i3 → i4 → i5 │
│ (nil пропускаются, каждый — отдельный UDP-пакет) │
├─────────────────────────────────────────────────────────────┤
│ 2. Junk packets: Jc штук │
│ Размер каждого: rand(Jmin..Jmax) байт │
│ Содержимое: crypto/rand.Read │
├─────────────────────────────────────────────────────────────┤
│ 3. Handshake Init message: │
│ a) CreateMessageInitiation() → msg.Type = H1.Generate() │
│ b) binary.Write(LittleEndian, msg) → 148 байт │
│ c) cookieGenerator.AddMacs(packet) → MAC1 + MAC2 │
│ d) Если S1 > 0: [S1 random bytes][packet] │
│ Итого: S1 + 148 байт │
└─────────────────────────────────────────────────────────────┘
Всё отправляется ОДНИМ вызовом peer.SendBuffers(sendBuffer)
SendHandshakeResponse() — отдельно
Signature packets (i1-i5) и junk packets НЕ отправляются с Response!
Они отправляются ТОЛЬКО с Init. Это подтверждено по коду: SendHandshakeResponse()
не содержит ссылок на device.ipackets или device.junk.
┌─────────────────────────────────────────────────────────────┐
│ 4. Handshake Response message: │
│ a) CreateMessageResponse() → msg.Type = H2.Generate() │
│ b) binary.Write(LittleEndian, msg) → 92 байта │
│ c) BeginSymmetricSession() → деривация ключей │
│ d) cookieGenerator.AddMacs(packet) │
│ e) Если S2 > 0: [S2 random bytes][packet] │
│ f) SendBuffers([][]byte{packet}) — один пакет │
│ Итого: S2 + 92 байта │
└─────────────────────────────────────────────────────────────┘
SendHandshakeCookie() — под нагрузкой
ПОД НАГРУЗКОЙ (DoS protection, редко):
┌─────────────────────────────────────────────────────────────┐
│ 5. Cookie Reply: │
│ a) msgType = H3.Generate() │
│ b) cookieChecker.CreateReply(..., msgType) │
│ c) binary.Write → 64 байта │
│ d) Если S3 > 0: [S3 random bytes][packet] │
│ Итого: S3 + 64 байта │
│ │
│ Отправляется через device.net.bind.Send() НАПРЯМУЮ │
│ (не через peer queue, в отличие от Init/Response) │
└─────────────────────────────────────────────────────────────┘
RoutineEncryption() + RoutineSequentialSender() — data трафик
DATA ТРАФИК (постоянно):
┌─────────────────────────────────────────────────────────────┐
│ 6. Transport packet: │
│ a) RoutineEncryption(): │
│ - H4.Generate() → первые 4 байта (little-endian) │
│ - calculatePaddingSize() → выравнивание до 16 байт │
│ - AEAD шифрование (ChaCha20-Poly1305) │
│ b) RoutineSequentialSender(): │
│ - Если НЕ keepalive И S4 > 0: │
│ сдвиг данных вправо на S4, random prefix │
│ Итого: S4 + 16B header + encrypted payload + 16B align │
│ │
│ Keepalive: S4 НЕ применяется (len == MessageKeepaliveSize)│
└─────────────────────────────────────────────────────────────┘
Приём пакетов — DeterminePacketTypeAndPadding()
Функция в receive.go определяет тип пакета по размеру + magic header:
// Для Init/Response/Cookie — ТОЧНОЕ совпадение размера:
if size == padding + MessageInitiationSize { ... } // S1 + 148
if size == padding + MessageResponseSize { ... } // S2 + 92
if size == padding + MessageCookieReplySize { ... } // S3 + 64
// Для Transport — больше или равно (переменный payload):
if size >= padding + MessageTransportHeaderSize { ... } // S4 + 16+Затем:
- Пропускает
paddingбайт - Читает uint32 из первых 4 байт (little-endian)
- Валидирует через
header.Validate(value) - При совпадении — убирает padding:
copy(packet, packet[padding:])+ truncate
Junk и Signature пакеты на приёме:
Явной обработки нет. Junk-пакеты и signature-пакеты не проходят проверку DeterminePacketTypeAndPadding() (возвращается MessageUnknownType) и молча отбрасываются — ни ошибок, ни логов. Это by design: они нужны только для обмана DPI на сетевом уровне.
Нет fallback к стандартному WireGuard:
Функция проверяет пакеты только через настроенные AWG-заголовки (H1-H4). Если H1-H4 изменены, стандартные WireGuard-пакеты (type=1,2,3,4) будут отброшены как MessageUnknownType. Обратной совместимости с обычным WireGuard при изменённых заголовках нет.
Примеры конфигураций
Минимальная конфигурация AWG 2.0
[Interface]
PrivateKey = YOUR_KEY
Address = 10.8.1.2/24
DNS = 1.1.1.1
# Junk
Jc = 5
Jmin = 50
Jmax = 500
# Padding
S1 = 40
S2 = 40
# Headers (фиксированные, совместимость с 1.0)
H1 = 123456789
H2 = 987654321
H3 = 111111111
H4 = 222222222
[Peer]
PublicKey = SERVER_KEY
Endpoint = server:51820
AllowedIPs = 0.0.0.0/0Полная конфигурация AWG 2.0
[Interface]
PrivateKey = YOUR_KEY
Address = 10.8.1.2/24
DNS = 1.1.1.1, 1.0.0.1
# --- Junk packets ---
Jc = 7
Jmin = 50
Jmax = 1000
# --- Padding (все типы) ---
S1 = 68
S2 = 149
S3 = 32
S4 = 16
# --- Range-based headers ---
H1 = 471800590-471800690
H2 = 1246894907-1246895000
H3 = 923637689-923637690
H4 = 1769581055-1869581055
# --- Signature packets (мимикрия под QUIC) ---
i1 = <b 0xc70000000108><rc 8><t><r 100>
i2 = <b 0xf6ab3267fa><t><rc 20><r 80>
[Peer]
PublicKey = SERVER_KEY
Endpoint = server:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25Примеры сигнатур для мимикрии
DNS-запрос:
i1 = <b 0x1a2d0100000100000000000109696e666572656e6365><t><r 15>
Побайтовый разбор hex (верифицировано по RFC 1035):
1a2d— Transaction ID (произвольный)0100— Flags: Standard Query, Recursion Desired0001— QDCOUNT: 1 вопрос0000 0000— ANCOUNT=0, NSCOUNT=00001— ARCOUNT: 1 additional record09— DNS label length = 9696e666572656e6365— ASCII “inference”
QUIC Initial (верифицировано по RFC 9000):
i1 = <b 0xc70000000108><rc 8><t><r 100>
Побайтовый разбор hex:
c7=11000111— Form=1 (Long), Fixed=1, Type=00 (Initial), Reserved=011100000001— Version = QUIC v108— DCID Length = 8 байт<rc 8>— 8 случайных букв имитируют Destination Connection ID<t>— timestamp добавляет уникальность каждому handshake<r 100>— 100 случайных байт заполняют payload
SIP INVITE (текстовый протокол):
i1 = <b 0x494e56495445207369703a><rc 12><b 0x4053697056504e2e636f6d20534950><r 50>
Hex = “INVITE sip:” + random user + “@SipVPN.com SIP” + random payload.
Многопакетная сигнатура:
i1 = <b 0xc70000000108><rc 8><t><r 100> # мимикрия под QUIC Initial
i2 = <b 0xf6ab3267fa><t><rc 20><r 80> # произвольные магические байты (не протокол)
i3 = <r 200> # чистый шум
3 пакета перед каждым handshake: QUIC-подобный + кастомный + чистый шум.
С недокументированным тегом <dz>:
i1 = <dz 2><r 50>
2 нулевых байта (т.к. src=nil в signature packets, len=0) + 50 случайных байт. Может имитировать length-prefixed протокол с пустым полем длины.
Примечание: теги
<d>и<ds>бесполезны в i1-i5, т.к. signature packets вызываютObfuscate(buf, nil)— src всегда nil, и эти теги выводят 0 байт. Они предназначены для возможного использования obfChain в других контекстах.
Подводные камни и ограничения
1. MTU overflow при S4
S4 добавляется поверх стандартного WireGuard-выравнивания и AEAD overhead.
Расчёт размера пакета на проводе (network MTU обычно = 1500):
IP header: 20 байт
UDP header: 8 байт
S4 random prefix: S4 байт
WG transport header: 16 байт (H4 type + receiver + nonce)
Encrypted payload: padded_plaintext байт (ChaCha20, размер = вход)
Poly1305 auth tag: 16 байт
─────────────────────────────
ИТОГО IP-пакет = 60 + S4 + padded_plaintext
Максимальный plaintext без фрагментации:
max_plaintext = network_MTU - 60 - S4
S4=0: max = 1500 - 60 = 1440 (стандартный WireGuard)
S4=16: max = 1500 - 76 = 1424
S4=50: max = 1500 - 110 = 1390
Рекомендуемый TUN MTU = network_MTU - 60 - S4
S4=0 → TUN MTU ≈ 1420 (стандартный WG дефолт)
S4=16 → TUN MTU ≈ 1400
S4=50 → TUN MTU ≈ 1370
IPv6: заголовок IPv6 = 40 байт (вместо 20 у IPv4). Формула для IPv6:
max_plaintext_ipv6 = network_MTU - 80 - S4 (40+8+16+16=80)
Важно: calculatePaddingSize() работает с TUN MTU (размер payload от TUN-устройства), а не с network MTU. Если TUN MTU не уменьшен для компенсации S4, пакеты будут фрагментироваться на уровне IP.
2. Дефолтные значения и совместимость
AWG-параметры по умолчанию:
- H1-H4: инициализированы стандартными WireGuard-типами (1, 2, 3, 4) в
NewDevice()— НЕ nil! - S1-S4, Jc/Jmin/Jmax: 0 (обфускация выключена)
- i1-i5: nil (signature packets отключены)
Без явной конфигурации AWG-параметров протокол полностью совместим с обычным WireGuard. Параметры клиента и сервера должны совпадать — иначе стороны не смогут декодировать пакеты.
3. Timestamp без replay-защиты
Тег <t> записывает time.Now().Unix(), но при deobfuscation всегда возвращает true. Код содержит комментарий: "replay attack check? requires time to be always synchronized" — защита не реализована.
4. Random нельзя валидировать
<r N> при deobfuscation возвращает true безусловно. DeobfuscatedLen = 0. Комментарий: "there is no way to validate randomness :)".
5. Header overlap
Диапазоны H1-H4 проверяются на пересечение при config merge (после установки всех 4-х заголовков). Ошибка: "headers must not overlap". Если пересекаются → невозможно определить тип пакета.
6. Keepalive без S4
S4 не применяется к keepalive (проверка len(elem.packet) != MessageKeepaliveSize). Это потенциальный fingerprint для DPI — keepalive-пакеты имеют предсказуемый размер.
7. Размер сигнатурных пакетов
- Минимум: 100+ байт (короткие подозрительны для DPI)
- Оптимум: 100-500 байт
- Максимум: ~1200 байт (UDP MTU)
- Нет жёсткого ограничения в коде, но > MTU → фрагментация
8. Jmin/Jmax не проверяются перекрёстно
В коде нет валидации Jmin <= Jmax. Каждый параметр проверяется только на > 0 независимо.
9. <ds> игнорирует ошибки декодирования
Deobfuscate() в obf_datastring.go вызывает base64.Decode(), но отбрасывает ошибку — потенциальный баг в реализации.
10. <b> принимает только строчный “0x”
strings.TrimPrefix(val, "0x") — убирает только 0x, но НЕ 0X. Если написать <b 0XDEAD>, hex-парсинг сломается.
11. Signature/junk только при Initiation
Signature packets (i1-i5) и junk packets отправляются только при SendHandshakeInitiation(). SendHandshakeResponse() не содержит ссылок на device.ipackets и device.junk — Response отправляется без маскировки (только S2 padding и H2 header).
12. Transport пакеты батчатся
RoutineSequentialSender() собирает несколько зашифрованных transport-пакетов в bufs (до maxBatchSize штук) и отправляет одним вызовом peer.SendBuffers(bufs). S4 padding применяется к каждому пакету в batch индивидуально.
13. Пробелы в hex-строках <b> молча обрезают данные
Парсер CPS использует strings.Fields() и берёт только parts[1]:
<b 0xDEADBEEF> → val="0xDEADBEEF" → OK, 4 байта
<b 0xDE AD BE EF> → val="0xDE" → ТОЛЬКО 1 байт! (AD BE EF потеряны)
Ошибки нет, данные молча теряются. Все hex-цифры должны быть слитно.
14. Текст между тегами CPS молча игнорируется
"hello<r 10>world<t>" → "hello" и "world" отброшены без ошибок
"<r 10> trailing text" → "trailing text" отброшен
Парсер ищет только содержимое внутри < >, всё остальное пропускается.
15. Только один динамический тег на цепочку
Теги <d> и <ds> имеют переменную длину выхода (зависит от входных данных). При Deobfuscate() длина вычисляется как:
dynamicLen := len(src) - c.ObfuscatedLen(0) // все динамические байтыЭта формула корректна только если в цепочке один динамический тег. Два динамических тега (напр. <d><ds>) вызовут buffer overrun — первый заберёт все байты, второму ничего не останется. Ограничение не документировано и не валидируется парсером.
В контексте signature packets (i1-i5) это неактуально — src=nil, динамических данных нет.
Рекомендации по выбору значений
Для обхода базовых DPI (Россия, 2026)
# Достаточно для большинства случаев
Jc = 5
Jmin = 50
Jmax = 500
S1 = 40
S2 = 40
S3 = 0
S4 = 0
H1 = 100000000-200000000
H2 = 300000000-400000000
H3 = 500000000-600000000
H4 = 700000000-800000000Для продвинутых DPI (полная мимикрия)
Jc = 7
Jmin = 50
Jmax = 1000
S1 = 68
S2 = 149
S3 = 32
S4 = 16
H1 = 471800590-471800690
H2 = 1246894907-1246895000
H3 = 923637689-923637690
H4 = 1769581055-1869581055
i1 = <b 0xc70000000108><rc 8><t><r 100>
i2 = <b 0xf6ab3267fa><t><rc 20><r 80>Баланс безопасность vs скорость
| Параметр | Влияние на скорость | Влияние на обфускацию | Когда работает |
|---|---|---|---|
| Jc | Минимальное | Среднее | Только handshake |
| S1, S2 | Минимальное | Среднее | Только handshake |
| S3 | Минимальное | Низкое | Редко (DoS) |
| S4 | Значительное | Высокое | Каждый пакет |
| H1-H4 ranges | Минимальное (1 rand/пакет) | Высокое | Каждый пакет |
| i1-i5 | Минимальное | Высокое | Только handshake |
S4 — единственный параметр с заметным влиянием на throughput. Начинайте с S4=0, увеличивайте при необходимости.
Структура в исходном коде
amneziawg-go/device/
├── device.go # Device struct: junk, paddings, headers, ipackets[5]
├── uapi.go # IPC парсер: jc/jmin/jmax, s1-s4, h1-h4, i1-i5
├── send.go # SendHandshakeInitiation/Response/Cookie, RoutineEncryption/Sender
├── receive.go # DeterminePacketTypeAndPadding(), RoutineReceiveIncoming
├── noise-protocol.go # CreateMessageInitiation/Response → H1/H2 headers
├── magic-header.go # magicHeader: newMagicHeader(), Generate(), Validate()
├── constants.go # PaddingMultiple=16, RekeyAfterTime=120s, etc.
├── obf.go # obfChain, obfBuilders map, newObfChain() парсер
├── obf_bytes.go # <b> — фиксированные hex-байты
├── obf_timestamp.go # <t> — Unix timestamp (4B big-endian)
├── obf_rand.go # <r> — crypto/rand случайные байты
├── obf_randchars.go # <rc> — случайные буквы a-zA-Z (52 символа)
├── obf_randdigits.go # <rd> — случайные цифры 0-9
├── obf_data.go # <d> — pass-through (НЕ ДОКУМЕНТИРОВАН)
├── obf_datastring.go # <ds> — Base64 RawStdEncoding (НЕ ДОКУМЕНТИРОВАН)
└── obf_datasize.go # <dz> — длина данных в байтах (НЕ ДОКУМЕНТИРОВАН)
Сравнение AWG 1.0 vs 2.0
| Возможность | AWG 1.0 | AWG 2.0 |
|---|---|---|
| Junk packets | Jc, Jmin, Jmax | Jc, Jmin, Jmax (без изменений) |
| Handshake padding | S1, S2 | S1, S2, S3, S4 |
| Заголовки | H1-H4 (фиксированные uint32) | H1-H4 (range-based, N-M) |
| Мимикрия | Нет | i1-i5 + CPS |
| CPS теги | Нет | 8 тегов (5 документированных + 3 скрытых) |
| DPI bypass | Signature-based only | Signature + statistical + protocol mimicry |
Побайтовая структура сообщений WireGuard
Размеры верифицированы по структурам в noise-protocol.go:
MessageInitiation — 148 байт
Offset Size Field
────── ──── ─────────────────────────────
0 4 Type (uint32, H1 magic header)
4 4 Sender (uint32, индекс отправителя)
8 32 Ephemeral (NoisePublicKey)
40 48 Static (NoisePublicKey 32 + Poly1305 Tag 16)
88 28 Timestamp (TAI64N 12 + Poly1305 Tag 16)
116 16 MAC1 (blake2s-128)
132 16 MAC2 (blake2s-128)
────── ────
148 ИТОГО
MessageResponse — 92 байта
Offset Size Field
────── ──── ─────────────────────────────
0 4 Type (uint32, H2 magic header)
4 4 Sender (uint32)
8 4 Receiver (uint32)
12 32 Ephemeral (NoisePublicKey)
44 16 Empty (Poly1305 Tag, encrypted empty)
60 16 MAC1 (blake2s-128)
76 16 MAC2 (blake2s-128)
────── ────
92 ИТОГО
MessageCookieReply — 64 байта
Offset Size Field
────── ──── ─────────────────────────────
0 4 Type (uint32, H3 magic header)
4 4 Receiver (uint32)
8 24 Nonce (XChaCha20-Poly1305 nonce)
32 32 Cookie (blake2s-128 16 + Poly1305 Tag 16)
────── ────
64 ИТОГО
MessageTransport — 16+ байт (переменный)
Offset Size Field
────── ──────── ─────────────────────────────
0 4 Type (uint32, H4 magic header)
4 4 Receiver (uint32)
8 8 Counter (uint64, nonce)
16 variable Ciphertext (encrypted payload + 16B Poly1305 auth tag)
────── ────────
16+ ИТОГО (заголовок фиксирован, payload переменный)
Полный wire-format transport пакета (с AWG обфускацией)
Порядок формирования (из кода):
1. RoutineEncryption():
- Генерирует H4 header → записывает в buffer[0:16]
- calculatePaddingSize() → добавляет нули к payload (выравнивание до 16 байт)
- AEAD Seal(header, nonce, padded_payload, nil):
ciphertext = Encrypt(padded_payload) + 16B Poly1305 tag
Результат: [16B header][ciphertext][16B tag]
2. RoutineSequentialSender() (если S4 > 0 и НЕ keepalive):
- Сдвигает весь зашифрованный пакет вправо на S4 байт
- Заполняет первые S4 байт случайными данными
Итоговый пакет на проводе:
┌──────────────┬──────────────────────────┬────────────────────────────┬───────────┐
│ S4 random │ WG Transport Header │ encrypted(payload + align) │ Poly1305 │
│ (S4 байт) │ H4(4B) + recv(4B) + nonce│ (переменный) │ (16B tag) │
│ │ (8B) = 16 байт │ │ │
└──────────────┴──────────────────────────┴────────────────────────────┴───────────┘
↑ header (16B, LE) ↑ ciphertext ↑
MessageKeepalive — 32 байта
= MessageTransport с нулевым payload:
16B header + 16B Poly1305 tag (шифрование пустых данных) = 32 байта
MessageKeepaliveSize = MessageTransportSize = 32
Стандартные константы WireGuard (constants.go)
RekeyAfterMessages = 2^60 сообщений
RejectAfterMessages = 2^64 - 2^13 - 1
RekeyAfterTime = 120 секунд ← интервал handshake (~2 мин)
RekeyAttemptTime = 90 секунд
RekeyTimeout = 5 секунд ← double-lock check в SendHandshakeInitiation
RekeyTimeoutJitterMaxMs = 334 мс
RejectAfterTime = 180 секунд
KeepaliveTimeout = 10 секунд
CookieRefreshTime = 120 секунд
HandshakeInitiationRate = 1/50 секунды ← rate limit
PaddingMultiple = 16 ← WireGuard внутреннее выравнивание payload
MaxTimerHandshakes = 90 / 5 = 18 ← макс. попыток handshake
Стандартные type values (заменяются H1-H4):
MessageInitiationType = 1
MessageResponseType = 2
MessageCookieReplyType = 3
MessageTransportType = 4
MessageUnknownType = 0 (пакет не опознан → отброс)
Дополнительные технические детали
Double-lock pattern в SendHandshakeInitiation()
// Быстрая проверка без блокировки (RLock):
peer.handshake.mutex.RLock()
if time.Since(peer.handshake.lastSentHandshake) < RekeyTimeout {
peer.handshake.mutex.RUnlock()
return nil // Слишком рано для нового handshake
}
peer.handshake.mutex.RUnlock()
// Полная блокировка с повторной проверкой (Lock):
peer.handshake.mutex.Lock()
if time.Since(peer.handshake.lastSentHandshake) < RekeyTimeout {
peer.handshake.mutex.Unlock()
return nil
}
peer.handshake.lastSentHandshake = time.Now()
peer.handshake.mutex.Unlock()Классический double-check locking для предотвращения дупликатов handshake.
calculatePaddingSize() — выравнивание payload
func calculatePaddingSize(packetSize, mtu int) int {
lastUnit := packetSize
if mtu == 0 {
// Нет MTU → выравнивание до ближайших 16 байт
return ((lastUnit + 16 - 1) & ^(16 - 1)) - lastUnit
}
if lastUnit > mtu {
lastUnit %= mtu // Берём остаток от деления на MTU
}
paddedSize := ((lastUnit + 16 - 1) & ^(16 - 1))
if paddedSize > mtu {
paddedSize = mtu // Не превышать MTU
}
return paddedSize - lastUnit
}Применяется ПЕРЕД AEAD-шифрованием в RoutineEncryption(). S4 добавляется ПОСЛЕ.
Примеры вычислений:
calculatePaddingSize(1480, 1500) = 8 # 1480 → 1488 (ближайшее кратное 16)
calculatePaddingSize(1500, 1500) = 0 # ceil(1500/16)*16=1504 > MTU → cap to 1500, pad=0
calculatePaddingSize(1501, 1500) = 15 # 1501 % 1500 = 1, ceil(1/16)*16=16, pad=15
calculatePaddingSize(0, 1500) = 0 # пустой payload (keepalive), 0 уже кратно 16
calculatePaddingSize(100, 0) = 12 # без MTU: ceil(100/16)*16=112, pad=12
Нет unit-тестов для CPS
Файл obf_test.go в репозитории отсутствует. Парсер CPS и все 8 обфускаторов не покрыты тестами. Это увеличивает риск скрытых багов (как <ds> Deobfuscate, игнорирующий ошибки).
UAPI: порядок параметров при сериализации (GET)
jc → jmin → jmax → s1 → s2 → s3 → s4 → h1 → h2 → h3 → h4 → i1 → i2 → i3 → i4 → i5
Параметры со значением 0/nil не включаются в вывод.
Ошибки в статье на Хабре (по результатам анализа кода)
| Что написано в статье | Что в коде | Комментарий |
|---|---|---|
| Init = 144 байта | MessageInitiationSize = 148 байт | Статья не учитывает 4 байта type field |
| Response = 88 байт | MessageResponseSize = 92 байта | Аналогично |
| Cookie Reply = 64 байта (в тексте), 60 байт (в схеме) | MessageCookieReplySize = 64 байта | Схема в статье неверна |
| CPS: 5 тегов | 8 тегов в obfBuilders | <d>, <ds>, <dz> не упомянуты |
<rc> = “случайные буквы/цифры [A-Za-z]“ | Только буквы a-zA-Z (52 символа) | В статье ошибочно написано “букв/цифр” |
QUIC пример: <b 0xc7000000010> | 11 hex-цифр = нечётное → ОШИБКА | Реальные конфиги: один <b> blob ~1250 байт (чётное) |
Сигнатуры из мелких тегов <b><rc><t><r> | Клиент генерирует один <b 0x...> blob | <rc>,<t>,<r> для ручных конфигов; клиент пакует всё в статический hex |
| Signature packets “перед каждым handshake” | Только перед Init, НЕ Response | SendHandshakeResponse() не содержит ipackets/junk |
Реальные конфиги AmneziaVPN vs примеры из статьи
Дефолтное значение I1 в клиенте AmneziaVPN
Найдено в исходниках клиента — client/protocols/protocols_defs.h:
constexpr char defaultSpecialJunk1[] =
"<r 2><b 0x858000010001000000000669636c6f7564036366726d0000010001c00c00010001000105a0004445837373>";
// I2-I5 по умолчанию пустые (отключены)Это составной формат:
<r 2>— 2 случайных байта (уникальность каждого handshake)<b 0x...>— 43 байта статического DNS-подобного пакета (домен “icloud.cfrm”)
I1-I5 в серверном скрипте configure_container.sh закомментированы (отключены по умолчанию).
Два формата конфигов
Формат 1 — составной (клиент AWG 2.0, protocols_defs.h):
I1 = <r 2><b 0x8580...DNS-пакет...>
↑ random ↑ статические байты
Несколько тегов, есть рандомизация через <r>, <t>, <rc>. Каждый handshake — уникальный пакет.
Формат 2 — монолитный blob (конфиги старых версий/WARP):
I1 = <b 0xc70000000108df2b1b...2500 hex-цифр...896>
↑ ОДИН тег <b>, 1250 байт статических данных
Весь пакет сгенерирован заранее как один hex-blob. Каждый handshake — одинаковые байты.
Статья Хабр (образовательный пример, сломанный):
I1 = <b 0xc7000000010><rc 8><t><r 100>
↑ 11 hex-цифр = нечётное → ошибка парсера "odd amount of symbols"
Какой формат правильный?
Оба формата валидны для парсера newObfChain() в amneziawg-go. Разница в DPI-устойчивости:
Составной (<r><b><t>) | Монолитный (<b> blob) | |
|---|---|---|
| Каждый handshake | Уникальный пакет | Одинаковые байты |
| DPI fingerprint | Сложнее | Проще (статический паттерн) |
| Размер конфига | Компактный | Огромный (2500+ hex-цифр) |
| Генерация | Ручная или клиент AWG 2.0 | Клиент AWG 1.x / pre-generated |
Что документирует README репозитория
README (amneziawg-go/README.md) документирует только эти CPS-теги:
<b 0x[seq]>, <r [size]>, <rd [size]>, <rc [size]>, <t>
Теги <d>, <ds>, <dz> не упомянуты ни в README, ни в статье на Хабре — обнаружены только анализом исходного кода (obfBuilders map в obf.go).
README рекомендует Jc = 4-12 и отмечает что все параметры дефолтятся в 0.
Метаданные репозитория
- Версия: 0.0.20250522 (из
version.go) - Go: >= 1.24.4
- AWG 2.0 merge: сентябрь 2025 (PR #91 — ranged H1-H4, S3/S4)
- Последний значимый коммит: 2025-12-01 — рефакторинг junk packets (#103)
- Unit-тесты CPS: отсутствуют (
obf_test.goне существует) - Примеры конфигов: нет (конфигурация только через IPC/UAPI)