burterm/NOTES.md
2026-09-14 10:55:07 +03:00

22 KiB
Raw Blame History

Заметки по разработке burterm

Чистое описание архитектуры — в ARCHITECTURE.ru.md.

Этот файл — то, что не годится в ARCHITECTURE, потому что описывает не «как устроено», а «почему так вышло» и «на что обратить внимание». Весь код писался в среде без сети и без установленного Go — ни разу не проходил через компилятор.

Реальные баги, найденные и исправленные по ходу разработки

Не гипотетические — конкретные, с объяснением, как нашлись.

  • UTF-8 в §маркерах§. Маркер § (U+00A7) — два байта в UTF-8 (0xC2 0xA7). Самая ранняя версия ParseMarkers сравнивала байты напрямую и детектила маркер только по второму байту, оставляя первый (0xC2) мусором в очищенной строке. Баг жил с первого черновика, найден при разработке DetectParamName (умные заготовки payload'ов). Исправлено: сканирование через range по рунам.
  • O(N²) в Results. resultsModel на каждый входящий результат атаки заново парсил текст всех полей фильтра и пересобирал весь список отфильтрованных строк через strings.Join. На словаре в миллион строк это останавливало TUI намертво. Исправлено тремя механизмами: кэшированные предикаты (парсятся один раз при изменении текста поля, а не на каждый результат), инкрементальный strings.Builder вместо пересборки списка, дросселированный рендер во viewport по таймеру раз в 300мс — и только для той атаки, что видна на экране.
  • MaxIdleConnsPerHostбаг с самого начала, не только для больших словарей. Голый http.Transport даёт дефолт 2 соединения на хост. При Concurrency > 2 (а дефолт формы — 10) большинство воркеров закрывало и переоткрывало TCP/TLS-соединение на каждый запрос вместо переиспользования из пула. Исправлено в engine.New и в spider.New (там клиент был вообще голый — Transport==nil, то есть неявно общий пакетный http.DefaultTransport).
  • Прокси не читал тело ответа. Изначальная версия сохраняла только заголовки/статус — без тела полнотекстовый поиск по Project был бы пустым обещанием. Исправлено: io.ReadAll полного тела, капированная копия (8КБ) для хранения, resp.Body восстанавливается через io.NopCloser перед resp.Write() (тело можно прочитать только один раз).
  • Утечка горутин при параллельных атаках. Изначально Model хранила один resultsCh/cancel на всё приложение — вторая атака без дожидания первой тихо переключала отслеживание, а горутины первой оставались писать в канал, который больше никто не читает (небуферизованный канал — блокировка навечно). Исправлено переходом на []*attackRun (независимые атаки) и на select с ctx.Done() при записи в out внутри engine.send().
  • proxyModel.SetSize вообще не резервировал место под собственные заголовок/футер. View() всегда рисует "Перехвачено: N" + пустую строку сверху и пустую строку + строку подсказки снизу — 4 строки сверх viewport, а SetSize отдавал viewport.Height всю переданную высоту без остатка. Из-за этого вкладка Proxy стабильно вылезала за экран независимо от того, сколько высоты ей давал model.go — сам бюджет внутри файла никогда не учитывал собственный хром. Найдено построчным разбором View(), а не подбором чисел снаружи. Исправлено вычитанием фиксированных 4 строк внутри SetSize.
  • comparerModel: SetSize считал раскладку A/B side-by-side (делит ширину пополам через halfWidth), а View() рисовал их друг под другом. Из-за этого реальная высота была примерно вдвое больше того, что закладывал бюджет diff.Height — вкладка Comparer вылезала за экран независимо от общего бюджета высоты. Исправлено не подгонкой чисел, а починкой самого рассинхрона: View() теперь реально кладёт A и B рядом через lipgloss.JoinHorizontal, как и предполагала раскладка ширины с самого начала.
  • Футер Dashboard врал про pgup/pgdn. Подсказка гласила "pgup/pgdn — прокрутка", хотя после того как эти клавиши стали перехватываться глобально под переключение вкладок (см. раздел решений ниже), до viewport они больше не доходят и ничего не прокручивают. Текст поправлен на "↑/↓ — прокрутка".
  • Бюджет высоты был неверен на 10 из 12 вкладок одновременно — и по двум независимым причинам. Первая: строки-подсказки в футере доходили до 200+ символов (targetview.go — 425 символов исходно с учётом экранирования, repeater.go — 219) — терминал переносил их сам, а высота этого переноса нигде не учитывалась. Исправлено разбиением каждого длинного футера на гарантированные 2 (иногда 3) строки: специфичные подсказки вкладки отдельно, общая навигация f1-f12, pgup — вкладки отдельно — так поместится в одну физическую строку почти на любом терминале, а не будет угадывания, перенесётся или нет. Вторая причина — независимая от первой: у нескольких вкладок константа вычитания была просто арифметически неверна с самого начала, до всей этой истории с футерами (Fuzzer: -12 вместо нужных -20, Repeater: -2 вместо -8, Decoder/Sequencer: -4 вместо -8/-6, Spider/Project: -6/-8 вместо -11). Обе причины нашлись только построчным подсчётом каждого View() — при этом подсчёте я сам дважды ошибся на лету (Decoder сначала посчитал 5 вместо верных 8) и пересчитывал заново. Итоговые числа для всех 12 файлов сведены в одну таблицу для сверки:
    comparerview.go:  diff.Height = height - inputHeight - 6
    dashboardview.go: vh = height - 3
    decoderview.go:   output.Height = height - inHeight - 8
    fuzzerview.go:    vh = height - 20  (минимум для "чистого" состояния)
    projectview.go:   vh = height - 11
    proxyview.go:     vh = height - 6  (плюс ещё -5 снаружи, в model.go, под "Прокси:...CA:...")
    repeater.go:      response.Height = height - reqHeight - 8
    results.go:       vh = height - 11  (плюс ещё -6 снаружи, в model.go, под заголовок атаки)
    scannerview.go:   vh = height - taHeight - 15 - len(scanner.AllCheckNames())
    sequencerview.go: output.Height = height - inHeight - 6
    spiderview.go:    vh = height - 11
    targetview.go:    vh = height - 13
    
    Request Builder сюда не входит — он не считает высоту viewport по фиксированному бюджету (сайзинг пропорциональный, height/3), так что этот класс бага его не касается, хотя длинный футер разбил и там для единообразия.
  • Comparer вылезал за экран ещё дважды после починки JoinHorizontal (сначала -5, потом -6оба раза построчный подсчёт по коду казался верным, но на практике не сходилось). Рабочая гипотеза: textarea.View() (компонент bubbles), возможно, рендерит не ровно столько строк, сколько задано через SetHeight — то есть у неё есть какой-то собственный оверхед (рамка/индикатор/что-то ещё), которого нет в исходниках, доступных мне без реального терминала. Это касается не только Comparer — Decoder, Repeater, Scanner, Request Builder тоже используют textarea и МОГЛИ пострадать той же проблемой, просто не в такой явной форме (или ещё не протестированы так тщательно). Для Comparer вместо третьей попытки точного подсчёта поставлен солидный запас (-12 вместо расчётных 6, inputHeight урезан до height/4) — если и остальные текстовые вкладки продолжат вылезать за экран после всех починок из записи выше, это будет подтверждением гипотезы, и той же логике (запас вместо точного числа) нужно последовать и там.

Непроверенные места (порядок — от вероятных проблем к маловероятным)

  1. go.mod: modernc.org/sqlite v1.34.4 — версия по памяти, go mod tidy может подтянуть другую, go.sum пересоберётся с нуля.
  2. bubbles/bubbletea APIFocus() у textinput/textarea в некоторых версиях возвращает tea.Cmd, в некоторых — нет; сигнатуры spinner.Tick могли измениться между версиями.
  3. exitErr.Sys().(syscall.WaitStatus) в internal/fuzzer/fuzzer.go — platform-specific type assertion, работает только на Unix.
  4. Гонки данных, не проверенные race detector'ом. Весь проект писался с оглядкой на конкурентный доступ (sync.RWMutex в proxy.Server, независимые снапшоты sitemap.Scope, atomic в счётчиках фаззера), но ни разу не прогонялся под go test -race.
  5. pgup (вкладка Fuzzer, единственная за пределами F1-F12 после выделения Steganography/аудио в отдельный проект stegterm) — четвёртая попытка навигации на вкладку, не влезающую в F1-F12 (история первых трёх — в разделе решений ниже). PageUp кодируется терминалом так же атомарно, как F-клавиши (в отличие от Alt+буква, которая на практике расщепляется на ESC+символ) — по конструкции должен быть надёжнее, но конкретно в связке с Zellij ещё не подтверждён.

Сознательно отложенные и оставленные как есть решения

  • Навигация на Fuzzer — четыре попытки подряд, есть разобранная причина третьего провала. У физической клавиатуры нет F13/F14, а 13 вкладок в F1-F12 не помещаются (изначально этой проблеме были подвержены сразу две вкладки — Fuzzer и Steganography, — но Steganography с тех пор выделена в отдельный проект stegterm, и сейчас в лишней клавише нуждается только Fuzzer). История:
    1. f13/f14 — не существуют физически, отпало сразу.
    2. shift+f1 — по историческому терминальному соглашению это и есть F13 (kf13 в terminfo), но подтверждённо не сработало в связке с Zellij.
    3. alt+f — подтверждённо не сработало, и в этот раз причина найдена: терминал расщепляет Alt+буква на два отдельных байта (ESC, затем сама буква) вместо единой комбинации. Если между ними случается задержка (обычное дело через мультиплексор/SSH), bubbletea не успевает склеить их обратно и видит одинокую букву — а раз в фокусе текстовое поле (что почти всегда так по умолчанию), эта буква просто печатается как обычный ввод, а не распознаётся как комбинация. F-клавиши от этой болезни не страдают: кодируются терминалом атомарно, не могут расщепиться в печатный символ.
    4. pgup — та же атомарная категория кодирования, что и у F-клавиш, поэтому по конструкции не должен страдать той же болезнью, что Alt-комбинации. Компромисс: viewport (Results, Proxy, Scanner и т.д.) по умолчанию тоже вешает постраничную прокрутку на PageUp — раз эта клавиша перехватывается глобально на уровне корневой модели раньше, чем дойдёт до конкретной вкладки, постраничная прокрутка пропадает везде, остаются только стрелки (построчно). Осознанный выбор: надёжный доступ к вкладке важнее удобства постраничной прокрутки. Не проверено на практике конкретно в связке с Zellij.
  • Единая рамка вокруг тела каждой вкладки — откачена. Была попытка обернуть body любой вкладки (кроме Dashboard) в panel() для визуальной консистентности со всем приложением. Дважды подряд не смог вслепую подобрать для неё правильный бюджет высоты: сначала меню обрезалось снизу на нескольких вкладках, потом (после первой попытки исправить) сама рамка вылезала за край экрана на вкладках с длинным содержимым (Comparer, Scanner). Без реального терминала под рукой это угадывание, а не расчёт — после второго провала откатил саму обёртку, а не в третий раз стал подбирать числа. Остались только подтверждённо рабочие вещи: Dashboard со своими собственными панелями и стилизованный таб-бар. Если понадобится вернуться к идее единой рамки на каждой вкладке — делать это нужно с реальным терминалом под рукой для итеративной проверки, не вслепую.
  • Плагины. Обсуждалась возможность подключения внешних модулей. Решение: не строить сейчас, направление на будущее — подпроцессы поверх JSON Lines по stdin/stdout (не Go plugin-пакет — хрупкий, требует точного совпадения версии Go; не встроенный Lua — новая непроверяемая зависимость с тем же риском, что и sqlite). Подпроцесс живёт весь прогон (атаку/скан), не спавнится на каждый вызов. Три типа плагинов на будущее: payload-generator, scanner-check, decoder. В коде не реализовано ничего.
  • Побочный эффект рекурсии Intruder. У Model изначально было заложено поведение «после старта атаки переключиться на Results» — рекурсия тоже шлёт attackStartedMsg на каждую находку, значит при активной рекурсии с несколькими совпадениями пользователя может несколько раз подряд дёрнуть на вкладку Results. Решение неочевидно (нужно различать «юзер нажал ctrl+r» от «рекурсия сама что-то нашла»), оставлено как есть.
  • drainGenerator для Pitchfork/ClusterBomb вычитывает генератор целиком в память перед стартом. Для словарей на миллионы строк — разовая пауза и всплеск памяти перед первым запросом. Не исправлено: риск сломать корректность одометра выше выигрыша.
  • Только WAV, не MP3/FLAC. Сознательный выбор, не недоделка: сжатие с потерями убивает мелкую структуру спектра, в которую в CTF прячут сообщения — задачи почти всегда именно в PCM WAV.
  • Поля "второе изображение"/"длина LSB" видны и в аудио-режиме вкладки Steganography, хотя там ничего не делают. Условный layout ради их сокрытия усложнил бы View() заметнее, чем стоит того.
  • ASCII-логотип не в блочном стиле (как у референсных TUI-инструментов вроде Burp) — сознательно: точная посимвольная геометрия блочных букв без реального терминала для проверки выравнивания легко разъезжается на один символ. Вместо этого — разрядка текста акцентным цветом.
  • Заголовки панелей — не врезаны в саму рамку (┌─ TITLE ─┐), а первая строка жирным текстом внутри неё — по той же причине: точная ширина без права на ошибку.

Порядок отладки при первой сборке

  1. go mod tidy — разберётся с зависимостями и go.sum.
  2. go build ./... — почти наверняка всплывут точечные несовпадения сигнатур bubbles/bubbletea. Правятся по месту, по тексту ошибки.
  3. go vet ./... — статический анализ.
  4. go run -race ./cmd/burterm — особенно при активном Proxy + одновременной смене scope-правил.
  5. Дальше — по вкладкам, от простых к сложным: Decoder/Comparer/Sequencer (чистая логика, без сети) → Repeater (один запрос) → Intruder на маленьком словаре → Proxy → Scanner/Fuzzer последними — там наибольшая оставшаяся неопределённость.