- имя раздачи нормализуется на границе разбора: вырожденное `-` (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки вывода имени - контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и удерживается структурно; три транспорта перестали обещать идентификатор, которого нет, и коррелируют отказ по request_id - scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах воркера — записи об отказе qBittorrent и метабаз получили download_id и infohash; граница разбора bencode записана в docs/research
11 KiB
Границы разбора .torrent в anacrolix/torrent
Наблюдения о том, как ведёт себя библиотека разбора на недоверенных байтах
.torrent. В отличие от соседней записки про формат сообщения торрент-бота, это
наблюдение о чужом коде, а не о чужих данных, и потому у него есть срок
годности.
Условие устаревания: перепроверить при обновлении anacrolix/torrent.
Числа и ссылки ниже сняты на v1.61.0 (версия зафиксирована в go.mod); ссылки
вида файл:строка относятся к ней и после бампа могут указывать не туда.
Аллокация объявленной длины строки
bencode аллоцирует строку по длине, объявленной во входе, до того как эти
байты прочитаны: parseString делает make([]byte, length) и только потом
io.ReadFull (bencode/decode.go:250 и :258). Ограничитель один — потолок
DefaultDecodeMaxStrLen = 1<<27 - 1 ≈ 128 MiB (decode.go:17), проверяемый в
parseStringLength (decode.go:223) до аллокации. metainfo.Load создаёт
декодер, не переопределяя MaxStrLen (metainfo/metainfo.go:35-37), то есть
работает с потолком по умолчанию.
Замер. Вход — верхнеуровневый словарь d7:comment<N>:xxxx, где <N>
объявляет длину, а байтов за ней нет. Мерилось дельтой
runtime.MemStats.TotalAlloc вокруг вызова, Go 1.26.5. Программа целиком
(положить в tmp/bencodealloc/main.go, запустить go run ./tmp/bencodealloc,
каталог после замера удалить — tmp/ в .gitignore):
package main
import (
"bytes"
"fmt"
"runtime"
"github.com/anacrolix/torrent/metainfo"
)
func craft(declared int64, tail int) []byte {
var b bytes.Buffer
b.WriteString("d7:comment")
fmt.Fprintf(&b, "%d:", declared)
b.Write(bytes.Repeat([]byte("x"), tail))
return b.Bytes()
}
func measure(name string, data []byte) {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
_, err := metainfo.Load(bytes.NewReader(data))
runtime.ReadMemStats(&after)
fmt.Printf("%-34s вход=%-4d байт аллоцировано=%8.2f MiB err=%v\n",
name, len(data), float64(after.TotalAlloc-before.TotalAlloc)/(1<<20), err)
}
func main() {
measure("объявлено 1 MiB", craft(1<<20, 16))
measure("объявлено 64 MiB", craft(64<<20, 16))
measure("объявлено 128 MiB - 1 (потолок)", craft(1<<27-1, 16))
measure("объявлено 128 MiB (выше потолка)", craft(1<<27, 16))
measure("объявлено 1 GiB (выше потолка)", craft(1<<30, 16))
}
| Объявленная длина | Размер входа | Аллоцировано | Исход |
|---|---|---|---|
| 1 MiB | 34 байта | 1.00 MiB | ошибка unexpected EOF |
| 64 MiB | 35 байт | 64.01 MiB | ошибка unexpected EOF |
| 128 MiB − 1 (потолок) | 36 байт | 128.00 MiB | ошибка unexpected EOF |
| 128 MiB (выше потолка) | 36 байт | 0.00 MiB | ошибка exceeds limit |
| 1 GiB (выше потолка) | 37 байт | 0.00 MiB | ошибка exceeds limit |
Что из этого следует:
-
Усиление огромное, и наш лимит размера от него не защищает. 36 байт входа дают 128 MiB транзиентной аллокации — это ×3.7 млн, а не «крафт-8 MiB даёт 128 MiB», как предполагала исходная нить ревью. Предел приёма
ingest.MaxTorrentSize(8 MiB) стоит до разбора и на эту величину не влияет вовсе: атакующему хватает трёх десятков байт. -
Но аллокация ограничена сверху и одна на попытку разбора. Выше потолка библиотека отказывает, не аллоцировав ничего; ниже — аллоцирует ровно объявленное и падает на чтении, обрывая разбор целиком. Дочитать несколько таких строк в одном входе нельзя: первая же необеспеченная строка роняет
Load. Верхняя граница на один принятый.torrent— примерно 128 MiB, после чего память возвращается. -
Числа выше — это
TotalAlloc, а не физическая память. Разграничение существенное, и без него вывод читается страшнее, чем есть. Деградационный путь —make([]byte, N), затем немедленно проваленныйio.ReadFull, — до страниц буфера не дотрагивается, а Linux отдаёт анонимную память zero-fill-on-demand. Замер RSS (/proc/self/status,VmRSS) на том же входе:Что мерили VmRSS 1000 одновременных разборов вырожденного входа, пик 7.4 MiB make([]byte, 128 MiB)без касания страниц+0.8 MiB то же с касанием одного байта +0.02 MiB то же с проходом по каждой странице +128 MiB То есть N одновременных приёмов не дают N × 128 MiB физической памяти: до RAM это не доходит вовсе. Формулировка «N × 128 MiB» верна только про логический счётчик запрошенных байт кучи.
-
Отсюда вывод сильнее, а не слабее. Пункт принят не потому, что «профиля нагрузки нет и авось обойдётся», а потому, что деградационный путь структурно не расходует физическую память. Возвращаться к нему с лимитом параллелизма повода нет; повод появится, только если найдётся путь, на котором объявленная строка действительно дочитывается.
-
Это вопрос устойчивости, а не безопасности. Отказ в обслуживании изнутри контура явно вынесен за модель угроз (
../security.md→ «Что вне модели»).
Паники разбора не выходят наружу — но гард у нас уже́е, чем кажется
bencode.Decoder.Decode ловит паники разбора и возвращает их ошибкой, кроме
runtime.Error — такую он пере-паникует (bencode/decode.go:38-46). То есть
арифметическая ошибка или выход за границы внутри библиотеки поднялись бы
паникой через metainfo.Load.
Наш recover-гард в internal/torrent стоит только на files() и покрывает
UpvertedFiles/FileTree. Он не покрывает metainfo.Load,
UnmarshalInfo и HashBytes — они вызываются вне гарда.
Достижимого panic-пути через них найти не удалось. Проверялась гипотеза про
отрицательную объявленную длину: parseStringLength её пропускает
(checkBufferedInt, decode.go:196-208, принимает -5), и make([]byte, -5)
дал бы runtime.Error. Путь недостижим — диспетчер значений входит в разбор
строки только по ведущей цифре, а - отсекается раньше:
"d7:comment-5:xxxxx" → bencode: syntax error (offset: 10): unknown value type '-'
"d4:infod4:name-5:xxxxxee" → bencode: syntax error (offset: 14): unknown value type '-'
Это отрицательный результат, а не гарантия: он говорит, что этот путь
закрыт, и ничего не говорит об остальных. Расширять гард на Load при
обновлении библиотеки — дешёвая страховка, если появится повод.
Библиотека не подставляет «имя-заглушку»
Смежное наблюдение о той же библиотеке, записанное потому, что его отсутствие стоило ложной нити в ревью приёма 2026-07-08.
metainfo.Info.BestName() (metainfo/info.go:200-205) возвращает NameUtf8,
иначе Name, иначе пустую строку. Константа NoName = "-"
(metainfo/info.go:44) присваивается только в BuildFromFilePath — то есть при
авторинге раздачи из вырожденного пути (., .., /), и в разборе не
участвует.
Значит - доходит до нас исключительно тогда, когда раздача сама объявила
его полем name. Это конвенция «имени нет», принятая ради совместимости с
Transmission (комментарий у константы ссылается на transmission#1775), и
библиотека экспортирует константу именно затем, чтобы на неё ссылались.
Практический вывод: «раздача без имени» и «раздача с именем -» — разные
входы, и путать их нельзя. Первый даёт пустую строку сам, второй нормализуется
нами на границе разбора (internal/torrent, displayName).