Почему SSH отправляет 100 пакетов по одному нажатию клавиши?

И почему меня это волнует?

Вот небольшая выдержка из обобщённого вывода tcpdump для ssh-сеанса, в рамках которого я всего один раз нажал на клавишу:

$ ./first_lines_of_pcap.sh single-key.pcap
  1   0.000s  CLIENT->SERVER   36 bytes
  2   0.007s  SERVER->CLIENT  564 bytes
  3   0.015s  CLIENT->SERVER    0 bytes
  4   0.015s  CLIENT->SERVER   36 bytes
  5   0.015s  SERVER->CLIENT   36 bytes
  6   0.026s  CLIENT->SERVER    0 bytes
  7   0.036s  CLIENT->SERVER   36 bytes
  8   0.036s  SERVER->CLIENT   36 bytes
  9   0.046s  CLIENT->SERVER    0 bytes
 10   0.059s  CLIENT->SERVER   36 bytes

Действительно «небольшая», так как на самом деле строк там было очень много.

$ ./summarize_pcap.sh single-key.pcap
Total packets: 270

  36-byte msgs:   179 packets ( 66.3%)   6444 bytes
  Other data:       1 packet  (  0.4%)    564 bytes
  TCP ACKs:        90 packets ( 33.3%)

  Data sent:      6444 bytes in 36-byte messages,  564 bytes in other data
  Ratio:          11.4x more data in 36-byte messages than other data

  Data packet rate: ~90 packets/second (avg 11.1 ms between data packets)

Очень много п��кетов, учитывая, что клавиша была нажата всего один раз. Что здесь происходит? И почему меня это заинтересовало?

Вот весь скрипт, если вам интересно:

# первые_строки_из_pcap.sh
tshark -r "$1" 
  -T fields -e frame.number -e frame.time_relative -e ip.src -e ip.dst -e tcp.len | 
  awk 'NR<=10 {dir = ($3 ~ /71.190/ ? "CLIENT->SERVER" : "SERVER->CLIENT");
       printf "%3d  %6.3fs  %-4s  %3s bytesn", $1, $2, dir, $5}'
# обобщение_pcap.sh
tshark -r "$1" -Y "frame.time_relative <= 2.0" -T fields -e frame.time_relative -e tcp.len | awk '
  {
      count++
      payload = $2

      if (payload == 0) {
          acks++
      } else if (payload == 36) {
          mystery++
          if (NR > 1 && prev_data_time > 0) {
              delta = $1 - prev_data_time
              sum_data_deltas += delta
              data_intervals++
          }
          prev_data_time = $1
      } else {
          game_data++
          game_bytes = payload
          if (NR > 1 && prev_data_time > 0) {
              delta = $1 - prev_data_time
              sum_data_deltas += delta
              data_intervals++
          }
          prev_data_time = $1
      }
  }
  END {
      print "Total packets:", count
      print ""
      printf "  36-byte msgs:   %3d packets (%5.1f%%)  %5d bytesn", mystery, 100*mystery/count, mystery*36
      printf "  Other data:     %3d packet  (%5.1f%%)  %5d bytesn", game_data, 100*game_data/count, game_bytes
      printf "  TCP ACKs:       %3d packets (%5.1f%%)n", acks, 100*acks/count
      print ""
      printf "  Data sent:      %d bytes in 36-byte messages,  %d bytes in other datan", mystery*36, game_bytes
      printf "  Ratio:          %.1fx more data in 36-byte messages than other datan", (mystery*36)/game_bytes
      print ""
      avg_ms = (sum_data_deltas / data_intervals) * 1000
      printf "  Data packet rate: ~%d packets/second (avg %.1f ms between data packets)n", int(1000/avg_ms + 0.5), avg_ms
  }'

Проблема

Я разрабатываю высокопроизводительную игру, которая работает по ssh. Интерфейс TUI для этой игры написан при помощи bubbletea, и информацию я отправлял через ssh при помощи wish.

Игра воспроизводится в окне размером 80×60, которое обновляется 10 раз в секунду. Я намерен обслуживать не менее 2000 пользователей, играющих одновременно. Таким образом, мне требуется обновлять ~100 миллионов ячеек в секунду. Поэтому меня интересует производительность.

Итак, у меня есть скрипт, соединяющий друг с другом несколько сотен ботов по ssh, а затем приказывающий ботам сделать ход. Затем я пользуюсь отличными профилировочными инструментами, имеющимися в Go, и смотрю, что происходит.

Вчера я случайно повредил мою тестовую обвязку. Вместо регулярной отправки игровых данных мой сервер стал с равным интервалом отправлять ботам единственное сообщение: «ваш экран слишком мал». На это потратилась половина процессорной мощности, доступной для моей игры, и полоса передачи данных также сузилась наполовину.

Поначалу я был разочарован. (Ненадолго) я подумал, что мне прямо в руки даром попал способ кардинально ускорить игру, а оказалось, что это просто ошибка, возникшая при тестировании.

Но подождите.

Если я не отправлял игровые данные ботам обратно, то почему использование ЦП падало на 50%, а не на 100%?

Разбор

Устраняя эту проблему с тестовой обвязкой, я логировал при помощи tcpdump игровой трафик как с разрушающими изменениями, так и без них. В таком роде:

# Игра работает через порт 22
timeout 30s tcpdump -i eth0 'port 22' -w with-breaking-change.pcap
# Обратить изменение
timeout 30s tcpdump -i eth0 'port 22' -w without-breaking-change.pcap

При разрушающем изменении исчезала возможность отрисовывать игру через ssh. Так что with-breaking-change.pcap содержит те пакеты, которые являются накладными расходами при каждом соединении, а отображать игровое поле эти пакеты не позволяют.

Отладку я выполнял при помощи Claude Code, так что я попросил эту модель резюмировать, что она видит в pcap.

Может быть, посмотришь сам? Я положил with-breaking-change.pcap в этот каталог

--

Вау! Вот что я нашёл:

Распределение пакетов по размеру (всего 413 703 пакета):
274 907 пакетов (66%): ровно по 36 байт
138 778 пакетов (34%): по 0 байт (операции TCP ACK)
18 пакетов (<0,1%): по 72 байта

Когда я затем проанализировал другой pcap, поменьше, оказалось, что эти таинственные пакеты поступают с интервалом примерно по 20 мс.

Меня это изрядно озадачило (как и Claude Code). Мы накидали несколько идей о том, что бы это могло быть:

  • Сообщения, связанные с управлением потоком по SSH

  • Опрос о размере PTY или другие проверки состояния

  • Какие‑то причуды bubbletea или wish

Выделялось одно обстоятельство: эти проверки инициировал – мой ssh-клиент (то есть, заводской ssh, установленный на MacOS), а не мой сервер.

Как по наитию я сделал tcpdump обычного ssh-сеанса.

# у меня на mac, в одной вкладке
sudo tcpdump -ien0 'port 22'

# у меня на mac, в другой вкладке
ssh $some_vm_of_mine

Я дождался, пока уляжется шум, характерный для начального этапа соединения, отправил на удалённую виртуальную машину сигнал ровно с одной клавиши и посмотрел вывод tcpdump.

Возник точно такой же паттерн! Что же происходит?

Отладка

Стоило мне осознать, что это свойство присуще заводскому ssh, а не моей игре, отладка значительно упростилась.

Выполнив ssh -vvv, я составил достаточно полное впечатление о том, что же происходит:

debug3: obfuscate_keystroke_timing: starting: interval ~20ms
debug3: obfuscate_keystroke_timing: stopping: chaff time expired (49 chaff packets sent) 
debug3: obfuscate_keystroke_timing: starting: interval ~20ms
debug3: obfuscate_keystroke_timing: stopping: chaff time expired (101 chaff packets sent)

Именно эти 20 мс выдавали проблему. Такой период в точности соответствует тому загадочному паттерну, который мы наблюдали ранее! Оставшаяся часть сообщения также весьма информативна: мы отправляем 49 «chaff» пакетов при первом нажатии клавиши и  101 «chaff» при втором.

В 2023 году в ssh была добавлена обфускация длительности нажатий на клавиши. Смысл в том, что скорость, с которой набираются различные буквы, выдаёт часть информации о том, какой именно текст вы вводите. Поэтому ssh вместе с сигналами от нажимаемых вами клавиш отправляет множество chaff-пакетов, из-за которых злоумышленнику становится сложнее определить, какие именно нажатия соответствуют смысловому вводу.  

Это весьма целесообразно для обычных ssh-сеансов, при которых критически важно соблюдать приватность. Но влечёт серьёзнейшие издержки для игры, распахнутой на весь Интернет, тем более, что критически важное требование к этой игре — минимизировать задержку.

Устранение проблемы

Обфускацию нажатий клавиш можно отключить на стороне клиента. Откатив принципиальные изменения, внесённые ранее, я попытался обновить тестовую обвязку так,  чтобы она проходила ObscureKeystrokeTiming=no при запуске ssh-сеансов.

Сработало отлично. Расход ресурса ЦП резко упал, а боты всё равно получали корректные данные.

Но едва ли такое решение применимо в реальной практике. Я хочу, чтобы ssh mygame Просто Работало, и мне не приходилось просить пользователей передавать мне опции, которых они, возможно, не понимают.

Claude Code исходно не верила, что нам удастся отключить эту функциональность и на стороне сервера.

Сгенерировано при помощи отличного инструмента claude-code-transcripts от Саймона Уилсона

Сгенерировано при помощи отличного инструмента claude-code-transcripts от Саймона Уилсона

К счастью, здесь описано, как именно устроена обфускация нажатий клавиш при работе с SSH. Поэтому мне не составило труда посмотреть соответствующий код в ssh-библиотеке, написанной на go (от которой я настроил транзитивную зависимость).

Сообщение лога:
Ввестьи возможность пингования на транспортном уровне 

В таком случае добавляется пара сообщений транспортного протокола SSH SSH2_MSG_PING/PONG
для реализации пингования. Эти сообщения используют числа, относящиеся к пространству номеров "локальные
расширения" и объявляются при помощи "[email protected]"
ext-info сообщения, в которой число "0" представлено как строка.

«Chaff»-сообщения, при помощи которых ssh маскирует нажатия клавиш – это сообщения   SSH2_MSG_PING. Причём, они отправляются на серверы, которые оповещают о доступности расширения [email protected]. Почему ms просто… не сообщать об [email protected]?

Я поискал в библиотеке для ssh на go, что там сказано о [email protected] и нашёл коммит, в рамках которого была добавлена поддержка этой функции. Коммит был крошечный, и казалось, что откатить его будет очень просто.

Я склонировал репозиторий go crypto, приказал Claude откатить это изменение и обновить наши зависимости так, чтобы в коде стал использоваться наш клон (благодаря директиве replace языка go сделать форк библиотеки не составляет труда).

Затем я повторно прогнал мою тестовую обвязку. Результаты были…очень хороши:

Total CPU  29.90%          -> 11.64%
Syscalls   3.10s           -> 0.66s
Crypto     1.6s            -> 0.11s
Bandwidth  ~6.5 Mbit/sec   -> ~3 Mbit/sec

Claude также был весьма воодушевлён:

Почему SSH отправляет 100 пакетов по одному нажатию клавиши? - 2

Разумеется, делать форк библиотеки crypto из go страшновато, и мне требовалось тщательно продумать, как организовать безопасную поддержку этого маленького патча.

Но улучшение было огромным. Большую часть предыдущей недели я потратил на то, чтобы выжимать дополнительную производительность по капле. Я просто представить себе не мог, что смогу сократить трату ресурсов более чем на 50%.

Отладка с применением БЯМ оказалась очень интересной

Я думал о том, смогут ли БЯМ частично взять на себя решение части задач, притом, что самому решать задачи мне очень нравится. Но должен сказать, отладка этой задачи с привлечением Claude Code оказалась суперинтересной.

Я достаточно хорошо знаком с tcpdumptshark и им подобными, знаю, на что они способны. Но я недостаточно регулярно ими пользуюсь, поэтому рука в обращении с ними не набита. Мне, в самом деле, понравилось, что можно сказать агенту: «вот тут какая-то странная фиговина — расскажи, что происходит». Наблюдая, как агент выполняет команды, я мог постоянно актуализировать в голове текущее состояние решаемой задачи.

Всё равно остаются пограничные случаи. В какой-то момент, запутавшись, я переключился на ChatGPT, и она очень уверенно сообщила, что получившийся у меня вывод tcpdump — это нормальное поведение ssh.

Аналогично, мне пришлось наводить Claude Code на идею — а не сделать ли форк библиотеки ssh на go. Причём, мне пришлось применить тот самый выход из плоскости: «подождите… если тестовая обвязка сбоит, то почему использование ЦП не падает до  0%»?

В ответ на «БЯМ не полностью справляются с этой задачей» некоторые отвечают: «вы неправильно их понимаете!»

Думаю, иногда так и есть! Взаимодействие с БЯМ — это совершенно новый навык, и взаимодействие с ними воспринимается очень странно, если вы привыкли писать код так, как это делалось в 2020. Возможно, более талантливый пользователь БЯМ запросто решил бы с её помощью такую задачу.

Любой навык всегда лучше всего нарабатывается на практике. Для меня это означает – самостоятельно выяснить, как привить мой интуитивный подход к решению задач тем инструментам, с которыми я работаю.

Кроме того. Быть частью системы круто. А иначе как бы я написал этот пост?

Спасибо, что дочитали!

Автор: Sivchenko_translate

Источник

  • Запись добавлена: 27.01.2026 в 20:26
  • Оставлено в
    Rambler's Top100