Апокриф 02

История грехов и перехват агента SSH

История грехов и перехват агента SSH

Тема 01 заканчивалась на том, как подменить функцию в чужом процессе. Но большинство компрометаций начинается не с этого, а с чтения: человек сам записал в файлы то, что не должен был, и оставил это лежать в домашнем каталоге с правами 0600, которые защищают от соседа, но не от root и не от того, кто уже получил доступ. Разведка артефактов — самый дешёвый этап операции 7: он не требует ни эксплойта, ни прав, ни сети, только умения читать форматы.

Второй сюжет темы — агент SSH. Он устроен так, что обладание сокетом эквивалентно обладанию ключами, которые в нём загружены: агент не проверяет, кто просит подпись, он проверяет только права на файл сокета. Отсюда две крайности: либо агент вообще не используется (ключ с парольной фразой вводится каждый раз), либо используется с пробросом на чужие хосты, где любой root бастиона получает беспарольный вход на все машины, куда этот ключ пускает.

Разбирается всё на уровне форматов. История команд — не «файл со строками», а структура с HISTTIMEFORMAT, histappend и правилами фильтрации; журналы входов — не «текстовые логи», а бинарные записи struct utmp, которые читаются utmpdump; протокол агента — не «магия OpenSSH», а length-prefixed сообщения с номерами типов, и клиента к нему можно написать на двадцати строках Python. Понимание форматов даёт то, чего не даёт заучивание команд: возможность ответить на вопрос «а что ещё здесь может быть».

Границы с соседями: тема 01 — окружение и загрузчик; тема 03 — SUID и capabilities; тема 05 — автозапуск; тема 06 — метки времени и анти-форензика на уровне inode. Здесь метки времени разбираются ровно настолько, насколько нужно для корреляции сессий, а их подделка — предмет темы 06.

На рисунке 1 сведены три группы артефактов и то, как они сходятся по времени: файлы истории, журналы входов и метки inode.

Корреляция артефактов пользователя
Рисунок 1. Корреляция артефактов пользователя: журналы входов, файлы истории и временные метки

История команд: как bash её пишет и что она выдаёт

Что попадает в файл и когда

Bash хранит историю в памяти сессии 4 и записывает её в файл, заданный HISTFILE (по умолчанию ~/.bash_history), при штатном завершении. Ключевое слово — «штатном»: если оболочка убита сигналом, история не сохранится; если сессия живёт, файл содержит только то, что записали предыдущие сессии. Именно поэтому вопрос «почему в истории нет последних команд» имеет технический ответ, а не мистический.

Второй важный механизм — shopt -s histappend. По умолчанию bash при выходе перезаписывает файл содержимым памяти, а не дописывает. Если у пользователя открыто три терминала, каждый при выходе перезапишет файл своим списком, и в итоге останется история последнего закрытого. С histappend файл дополняется, и тогда история — объединение всех сессий, но без указания, какая команда из какой сессии.

Третий механизм — PROMPT_COMMAND='history -a': запись после каждой команды, а не при выходе. Это частая настройка у администраторов, которые боятся потерять историю при обрыве связи. Побочный эффект: файл обновляется постоянно, и его mtime перестаёт означать «конец сессии».

Проверить всё это можно на своей машине, не трогая настоящую историю:

export HISTFILE=$PWD/hist_test
export HISTTIMEFORMAT='%F %T '
bash --norc -i

Внутри интерактивной оболочки — несколько команд, затем exit. Файл будет выглядеть так:

#1792422679
echo alpha
#1792422679
echo beta
#1792422679
exit

Строки с # — не комментарии, а временные метки в формате «эпоха UNIX». HISTTIMEFORMAT влияет только на отображение при history, а в файл пишется именно эпоха — целое число секунд. Отсюда практический приём: одна и та же команда может иметь одинаковую метку, если выполнена в одну секунду, — точность истории ограничена секундой, а не наносекундами, как у stat.

Переменные, которые меняют след

ПеременнаяЧто делаетЧто это значит для разведки
HISTFILEпуть к файлуHISTFILE=/dev/null — истории не будет вовсе
HISTSIZEсколько команд держать в памятипри переполнении старые вытесняются из памяти, но файл может остаться
HISTFILESIZEсколько строк держать в файлепри выходе файл обрезается до этого числа
HISTCONTROLignorespace, ignoredups, erasedupsкоманда с ведущим пробелом не попадёт в файл при ignorespace
HISTIGNOREшаблоны команд, которые не записываютсяHISTIGNORE='ls:cd:exit' убирает рутину
HISTTIMEFORMATформат метокбез него в файле нет времени вовсе
shopt histappendдописывать или перезаписыватьвлияет на «чьи команды остались»

Практический вывод для разведки: наличие HISTTIMEFORMAT в /etc/bash.bashrc или ~/.bashrc — подарок, потому что даёт время. Отсутствие — не приговор: mtime файла и журналы входов всё ещё позволяют восстановить хронологию, просто с меньшей точностью.

Отдельно стоит set +o history: команда отключает запись в текущей сессии. Она не стирает уже записанное и не мешает history -a записать накопленное, но новые команды в файл не попадут. Это первое, что делает человек, который знает, что делает, — и потому отсутствие подозрительных команд в истории не означает их отсутствия в системе.

Что история не сохраняет

Три вещи, которые важно понимать, чтобы не строить ложных выводов.

Команды, выполненные не в интерактивной оболочке. bash -c '...', скрипты, задания cron, ssh host command — всё это не попадает в ~/.bash_history, потому что история ведётся только для интерактивных сессий.

Команды, введённые в другом терминале, пока сессия жива. Файл обновляется при выходе (или по history -a), поэтому живая сессия видна только через history в самой сессии — то есть только её владельцу или тому, кто уже в этой сессии.

Полные командные строки после редактирования. Если пользователь исправил команду в редакторе (Ctrl-X Ctrl-E), в историю попадёт итоговый вариант, а не исходный. Черновики не сохраняются.

Как читать историю: три запроса и разбор меток

Первое, что делается с найденным файлом, — поиск учётных данных и адресов. Три шаблона покрывают большую часть:

grep -Ein 'pass|passwd|token|secret|api[_-]?key|BEGIN [A-Z ]*PRIVATE KEY' ~/.bash_history
grep -Ein 'ssh|scp|rsync|mysql|psql|redis-cli|curl|wget' ~/.bash_history | head -20
grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' ~/.bash_history | sort | uniq -c | sort -rn | head

Первый запрос ищет секреты, второй — внешние взаимодействия, третий — карту адресов, с которыми работал пользователь. Третий запрос особенно полезен: он даёт список хостов, к которым стоит присмотреться, даже если команды были безобидными.

Если в файле есть метки #<эпоха>, их надо развернуть в даты. Однострочник на Python читает файл и печатает пары «время — команда»:

#!/usr/bin/env python3
"""hist_time.py — печатает историю bash с расшифрованными метками времени."""
import sys
from datetime import datetime, timezone

path = sys.argv[1] if len(sys.argv) > 1 else None
if not path:
    sys.exit("usage: hist_time.py ~/.bash_history")

stamp = None
with open(path, encoding="utf-8", errors="replace") as fh:
    for line in fh:
        line = line.rstrip("\n")
        if line.startswith("#") and line[1:].isdigit():
            stamp = datetime.fromtimestamp(int(line[1:]), tz=timezone.utc)
            continue
        when = stamp.strftime("%Y-%m-%d %H:%M:%S UTC") if stamp else "(без метки)"
        print(f"{when}  {line}")
python3 hist_time.py hist_test
2026-10-19 15:11:19 UTC  echo alpha
2026-10-19 15:11:19 UTC  echo beta
2026-10-19 15:11:19 UTC  exit

Обратите внимание: все три команды получили одну метку, потому что выполнены в одну секунду. При разборе реальной истории метки обычно разнесены на минуты, и именно интервалы между ними дают представление о темпе работы: серия команд за десять секунд — это скрипт или вставка из буфера, ровные интервалы по пять минут — ручная работа с паузами на чтение вывода.

Остальные артефакты: редакторы, клиенты, сеть

.viminfo: секции и что в них лежит

Vim хранит состояние в ~/.viminfo 5 (Neovim — в ~/.local/state/nvim/shada/main.shada). Файл — не «список файлов», а набор секций, каждая со своим форматом:

  • # This viminfo file was generated by Vim 9.0 — заголовок с версией;
  • # Command Line History (newest to oldest) — история команд :;
  • # Search String History (newest to oldest) — история поиска / и ?;
  • # Expression History — история =;
  • # Input Line History — история ввода;
  • # Debug Line History — отладочные строки;
  • # Registers — содержимое регистров, включая "" (последний yank), "0–"9, "-, ":, "/;
  • # File marks — метки файлов с номерами строк и колонками;
  • # Jumplist — список переходов.

Практическая ценность секций неодинакова. File marks даёт список файлов, которые человек открывал, — часто тех, о которых он не писал в терминале. Registers может содержать фрагменты вырезанного текста: строки конфигурации, ключи, пароли. Search String History показывает, что человек искал, — а ищут обычно то, что не знают наизусть.

sed -n '/# File marks:/,/^$/p' ~/.viminfo | head -10
sed -n '/# Registers:/,/^$/p' ~/.viminfo | head -20
# File marks:
'0  14  0  /etc/wireguard/wg0.conf
'1  1   0  /opt/backup/credentials.env
'2  82  12  /etc/postgresql/16/main/pg_hba.conf
'3  5   0  ~/scripts/deploy-api.sh

# Registers:
""1	LINE	0
	API_KEY="sec_984f7b2a9e1041cd"
	DB_PASSWORD="pg_cluster_secret_token"

Первая строка ""1 означает: регистр "1 (первый из нумерованных), тип LINE, длина 0. Дальше идёт содержимое. Для разведки важно, что регистры не шифруются и не очищаются автоматически — они живут, пока файл не перезаписан.

Клиенты: mysql, psql, python, less, wget

Каждый интерактивный клиент ведёт свой журнал, и все они лежат в домашнем каталоге:

ФайлЧто содержитОсобенность
~/.mysql_historySQL-запросы клиента MySQL/MariaDBпароли, если вводились в IDENTIFIED BY
~/.psql_historyзапросы psqlполные тексты, включая CREATE ROLE ... PASSWORD
~/.python_historyстроки интерактивного Pythonтокены API, вызовы requests
~/.lesshstистория lessимена файлов, которые читали
~/.wget-hstsHSTS-база wgetдомены, к которым обращались
~/.netrcлогины и пароли для ftp/curlоткрытый текст, права 0600

.netrc — отдельный случай: это не история, а конфигурация с паролями в открытом виде. Формат простой: machine <host> login <user> password <pass>. Если файл есть, он почти всегда содержит действующие учётные данные, потому что иначе его бы не держали.

ls -la ~/.netrc ~/.mysql_history ~/.psql_history ~/.python_history ~/.lesshst ~/.wget-hsts 2>&1
grep -c . ~/.python_history 2>/dev/null
-rw------- 1 alice alice 214 окт 12 09:14 /home/alice/.netrc
-rw------- 1 alice alice 892 окт 18 17:02 /home/alice/.mysql_history
...

known_hosts: карта сети и её хеширование

~/.ssh/known_hosts — список хостов 1, к которым пользователь подключался, с их открытыми ключами. По умолчанию в современных дистрибутивах имена хешируются (HashKnownHosts yes), и строка выглядит как |1|<base64>|<base64>. Хеш — HMAC-SHA1 от имени хоста с солью; восстановить имя перебором можно, но для типичного списка хостов это дороже, чем посмотреть в другие артефакты.

Проверить конкретный хост:

ssh-keygen -F 192.0.2.10
ssh-keygen -F db-primary.nsu.example
# Host 192.0.2.10 found: line 1
|1|AbCdEf123456=|XyZ987654321= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey1

Если хеширование отключено (HashKnownHosts no), файл читается как список: адрес, тип ключа, ключ. Тогда это готовая карта инфраструктуры, включая внутренние имена, которых нет в DNS снаружи.

~/.ssh/config дополняет картину: алиасы, пользователи, порты, ProxyJump, IdentityFile. Одна строка Host db-internal с ProxyJump bastion рассказывает и о топологии, и о том, что доступ идёт через бастион.

grep -E '^(Host|HostName|User|Port|ProxyJump|IdentityFile)' ~/.ssh/config 2>/dev/null | head -20

Временные метки и восстановление хронологии

Четыре времени inode

У каждого inode четыре отметки времени 6, и путать их — самая частая ошибка при разборе:

  • atime — время последнего чтения содержимого;
  • mtime — время последнего изменения содержимого;
  • ctime — время последнего изменения метаданных: прав, владельца, ссылок, а также любое изменение содержимого (потому что mtime — тоже метаданные);
  • btime — время создания inode (birth time), доступно через statx и поддерживается ext4, XFS, btrfs; на некоторых ФС отсутствует.

Посмотреть все четыре:

touch probe.txt
stat -c 'atime=%x%nmtime=%y%nctime=%z%nbtime=%w' probe.txt
atime=2026-10-19 18:11:19.210410720 +0300
mtime=2026-10-19 18:11:19.210410720 +0300
ctime=2026-10-19 18:11:19.210410720 +0300
btime=2026-10-19 18:11:19.210410720 +0300

Точность — наносекунды, но реальная гранулярность зависит от ФС и ядра. На ext4 с nodelalloc и стандартными настройками метки обновляются с точностью до наносекунды, но квантование может быть грубее.

Практическое правило корреляции: mtime файла истории — момент последней записи, ctime — момент, когда изменились права или содержимое, btime — когда файл создан. Если ctime > mtime, значит после последней записи файл трогали метаданными (например, chmod), — это признак вмешательства. Если btime свежее, чем содержимое (файл создан только что, а внутри «старая» история), это признак подделки, и разбирается в теме 06.

relatime и почему atime врёт

Исторически atime обновлялось при каждом чтении, что убивало производительность. Поэтому появились режимы монтирования:

  • strictatime — обновлять всегда (редко используется);
  • relatime — обновлять, если прошлое atime старше mtime/ctime или прошло больше суток (по умолчанию в большинстве дистрибутивов);
  • noatime — не обновлять вовсе;
  • lazytime — обновлять в памяти, сбрасывать на диск периодически.

Следствие для разведки: atime у файла, который читали вчера, может остаться недельной давности. Поэтому «файл не читали» по atime — не факт. Проверить режим:

findmnt -no OPTIONS / | tr ',' '\n' | grep -E 'atime|relatime|noatime'
mount | grep ' / ' | head -1
relatime
/dev/nvme0n1p2 on / type btrfs (rw,relatime,ssd,space_cache=v2)

Ещё одна тонкость: чтение через mmap и через sendfile может не обновлять atime вообще, потому что страницы уже в page cache. Поэтому atime — вспомогательный признак, а не доказательство.

Корреляция: рецепт из четырёх шагов

Задача: понять, кто и когда работал на машине, и что именно делал. Рецепт, который работает без специальных средств:

  1. Список сессий. last -F -n 20 — успешные входы с временем начала и конца; lastb -n 20 — неудачные (нужен root); who и w — активные сейчас.
  2. Метки артефактов. stat по .bash_history, .viminfo, .mysql_history — mtime и ctime.
  3. Содержимое. Метки #<эпоха> в истории, если есть; иначе — интервалы между строками по смыслу.
  4. Сверка. Сессия из wtmp с 14:10 до 14:40 и mtime истории 14:40:10 — это одна и та же сессия; команды внутри относятся к этому интервалу.

Журнал last на реальной машине выглядит так:

last -F -n 4
noviui   pts/6        :1               Mon Oct 19 16:21:37 2026   gone - no logout
noviui   pts/5        :1               Mon Oct 19 15:49:01 2026   gone - no logout
noviui   pts/5        :1               Mon Oct 19 15:22:21 2026 - Mon Oct 19 15:27:35 2026  (00:05)

gone - no logout — важный маркер: сессия не завершилась штатно, запись о выходе не была создана. Причины бывают разные (обрыв SSH, закрытие терминала, kill -9), но для разбора это сигнал, что окончание сессии восстанавливать придётся по косвенным признакам.

Активные сессии:

w
 18:11:27 up  3:43,  1 user,  load average: 1,83, 2,33, 2,63
USER     TTY       LOGIN@   IDLE   JCPU   PCPU  WHAT

Пустая строка после заголовка — не ошибка: w показывает процессы текущего терминала, а если сессия одна и это сама w, список может быть пуст. Смотреть надо на строку up и число пользователей.

utmp, wtmp, btmp: устройство журналов входов

struct utmp и типы записей

Три файла — три назначения: /run/utmp 3 (текущее состояние), /var/log/wtmp (история успешных входов и перезагрузок), /var/log/btmp (неудачные попытки). Все три — последовательности записей фиксированного размера, структура struct utmp (в glibc — struct utmp, размер 384 байта на x86_64).

Поля, которые имеют значение:

  • ut_type — тип записи: BOOT_TIME (2), INIT_PROCESS (5), LOGIN_PROCESS (6), USER_PROCESS (7), DEAD_PROCESS (8), RUN_LVL (1);
  • ut_pid — PID процесса;
  • ut_line[32] — терминал (pts/0, tty1);
  • ut_id[4] — идентификатор линии;
  • ut_user[32] — имя пользователя;
  • ut_host[256] — откуда вход;
  • ut_tv — время в секундах и микросекундах;
  • ut_addr_v6[4] — адрес в бинарном виде.

Записи только добавляются: wtmp не перезаписывается, а дополняется, и ротация делается через logrotate (создание нового файла). Именно поэтому wtmp — относительно надёжный источник: удалить из него запись можно только правкой файла, а это оставляет след в mtime и в журнале ротации.

utmpdump: чтение без библиотек

utmpdump переводит бинарные записи в текст и обратно. Прямое чтение:

utmpdump /var/log/wtmp | head -3
Utmp dump of /var/log/wtmp
[2] [00000] [~~  ] [reboot  ] [~           ] [6.14.9-arch1-1      ] [0.0.0.0        ] [2025-06-07T18:01:15,909554+00:00]
[5] [00073] [cons] [        ] [console     ] [                    ] [0.0.0.0        ] [2025-06-07T18:01:17,233293+00:00]

Формат строки: [тип] [PID] [линия] [пользователь] [хост] [терминал/ядро] [адрес] [время]. Первая запись — перезагрузка ([2]), вторая — инициализация консоли ([5]). Поле с версией ядра (6.14.9-arch1-1) появляется у BOOT_TIME и позволяет сопоставить перезагрузки с обновлениями.

Обратная операция — utmpdump -r — читает текстовый дамп и пишет бинарный файл. Это и инструмент восстановления, и инструмент подделки (тема 06): можно выгрузить wtmp, отредактировать строки и загрузить обратно.

Права на файлы:

ls -l /var/log/wtmp /var/run/utmp /var/log/btmp
-rw-rw-r-- 1 nobody nobody 4353792 окт 19 16:21 /var/log/wtmp
-rw-rw-r-- 1 nobody nobody    3456 окт 19 17:22 /var/run/utmp

nobody:nobody и права 0664 — стандартная настройка: писать может только группа utmp, читать — все. btmp обычно 0600 root:root, поэтому lastb без root отвечает «Отказано в доступе».

sshd и журнал: второй источник

wtmp фиксирует факт входа, но не способ аутентификации. Способ виден в журнале sshd: строки Accepted publickey for alice from 192.0.2.45 port 51234 ssh2: ED25519 SHA256:... и Failed password for .... В системах с systemd журнал читается так:

journalctl -n 5 _COMM=sshd --no-pager
сен 12 18:37:01 suprohub sshd[43448]: Server listening on :: port 22.
сен 13 00:36:19 suprohub sshd[43448]: Received signal 15; terminating.

Здесь видны только служебные сообщения — на этой машине не было входов по SSH за период. На сервере с активностью строки Accepted дают точную привязку: время, пользователь, адрес, тип ключа и его отпечаток. Сопоставление Accepted из журнала с записями wtmp — стандартный способ отличить вход по ключу от входа по паролю и от входа через sudo.

ssh-agent: протокол, сокет и проброс

Зачем агент и что он держит

Приватный ключ с парольной фразой расшифровывается при каждом использовании. Чтобы не вводить фразу на каждый ssh или git pull, ключ расшифровывается один раз и остаётся в памяти ssh-agent. Клиент не читает ключ с диска: он просит агента подписать данные.

Схема: сервер присылает случайные данные (challenge), клиент передаёт их агенту, агент подписывает приватным ключом, клиент отправляет подпись серверу, сервер проверяет её открытым ключом из authorized_keys. Приватный ключ не покидает память агента — но подпись получить может любой, кто имеет доступ к сокету.

Архитектура связки клиента, агента и сервера показана на рисунке 2.

Архитектура ssh-agent
Рисунок 2. Взаимодействие SSH-клиента и ssh-agent через локальный UNIX-сокет

Формат сообщений: length-prefixed и номера типов

Протокол агента описан в draft-miller-ssh-agent 2 и устроен просто: сообщение — это uint32 длины (сетевой порядок байтов) и тело; первый байт тела — тип. Строки внутри тела — тоже uint32 длины и байты.

Основные типы:

КодИмяНаправление
5SSH_AGENT_FAILUREагент → клиент
6SSH_AGENT_SUCCESSагент → клиент
11SSH2_AGENTC_REQUEST_IDENTITIESклиент → агент
12SSH2_AGENT_IDENTITIES_ANSWERагент → клиент
13SSH2_AGENTC_SIGN_REQUESTклиент → агент
14SSH_AGENT_SIGN_RESPONSEагент → клиент
17SSH_AGENTC_ADD_IDENTITYклиент → агент
18SSH_AGENTC_REMOVE_IDENTITYклиент → агент
19SSH_AGENTC_REMOVE_ALL_IDENTITIESклиент → агент
22SSH_AGENTC_LOCKклиент → агент
23SSH_AGENTC_UNLOCKклиент → агент

Клиент на двадцати строках Python показывает, что «магия» — это два сообщения:

#!/usr/bin/env python3
"""agent_list.py — список ключей в ssh-agent без ssh-add."""
import os
import socket
import struct
import sys

sock_path = os.environ.get("SSH_AUTH_SOCK")
if not sock_path:
    sys.exit("SSH_AUTH_SOCK не задан")

s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(sock_path)

# SSH2_AGENTC_REQUEST_IDENTITIES = 11
s.sendall(struct.pack(">I", 1) + b"\x0b")

header = s.recv(4)
if len(header) < 4:
    sys.exit("агент закрыл соединение")
length = struct.unpack(">I", header)[0]

body = b""
while len(body) < length:
    chunk = s.recv(length - len(body))
    if not chunk:
        break
    body += chunk

if not body or body[0] != 12:
    sys.exit(f"неожиданный тип ответа: {body[0] if body else 'нет'}")

count = struct.unpack(">I", body[1:5])[0]
print(f"ключей в агенте: {count}")

off = 5
for _ in range(count):
    blob_len = struct.unpack(">I", body[off:off + 4])[0]
    off += 4
    blob = body[off:off + blob_len]
    off += blob_len

    type_len = struct.unpack(">I", blob[0:4])[0]
    key_type = blob[4:4 + type_len].decode()

    comment_len = struct.unpack(">I", body[off:off + 4])[0]
    off += 4
    comment = body[off:off + comment_len].decode(errors="replace")
    off += comment_len

    print(f"  {key_type}  {comment}")

Запуск с ключом, добавленным через ssh-add:

ssh-agent -a $PWD/agent.sock
SSH_AUTH_SOCK=$PWD/agent.sock ssh-add ./id_lab
SSH_AUTH_SOCK=$PWD/agent.sock python3 agent_list.py
Identity added: /home/alice/lab/id_lab (lab@nsu.example)
ключей в агенте: 1
  ssh-ed25519  lab@nsu.example

Три строки вывода — это весь протокол в действии: тип 12, счётчик, блоб ключа, комментарий. Отпечаток (SHA256:...) считается как SHA-256 от блоба и в протоколе не передаётся — его вычисляет клиент.

Сокет: где лежит и какие права

Путь к сокету — в SSH_AUTH_SOCK. Три варианта размещения:

  • /tmp/ssh-XXXXXXXXXX/agent.<PID> — традиционный, создаётся ssh-agent без опций; каталог 0700, сокет 0600;
  • /run/user/<UID>/keyring/ssh — через gnome-keyring или systemd;
  • /run/user/<UID>/ssh-agent.socket — сокет-активация systemd.
ls -l "$SSH_AUTH_SOCK"
srw------- 1 alice alice 0 окт 19 18:10 /home/alice/lab/agent.sock

Первый символ s — сокет, права 0600 — только владелец. Ключевой момент: права проверяет ядро при connect, а не агент. Если файл сокета доступен на запись другому пользователю, тот сможет подключиться и попросить подпись — агент не спросит, кто он.

Схема перехвата при небезопасном пробросе показана на рисунке 3: сокет на бастионе — это точка, через которую подпись уходит к агенту.

Схема перехвата SSH-агента
Рисунок 3. Вектор перехвата сессии при небезопасном пробросе агента

Проброс: -A и -R /path

ssh -A (или ForwardAgent yes) просит сервер создать локальный сокет и пробросить его обратно к клиенту. На сервере появляется /tmp/ssh-XXXXXX/agent.NNNN, а в сессию экспортируется SSH_AUTH_SOCK на него. Всё, что делает клиент на сервере, проходит через этот сокет к агенту на рабочей станции.

ssh -R /remote/sock:/local/sock делает то же самое, но явно: указывает, какой локальный сокет пробросить и куда его положить на сервере. Это универсальный механизм: пробросить можно не только агент, но и, например, сокет Docker или базы данных.

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

Проверить, что именно проброшено, можно на сервере:

echo "$SSH_AUTH_SOCK"
ls -l /tmp/ssh-*/agent.* 2>/dev/null
ss -xl 2>/dev/null | grep -c ssh

Что именно подписывает агент

SSH2_AGENTC_SIGN_REQUEST (13) — второе сообщение, которое имеет смысл разобрать. Тело: строка с блобом ключа, строка с данными, uint32 флагов. Ответ — SSH_AGENT_SIGN_RESPONSE (14) с подписью в формате SSH: строка с типом ключа и строка с байтами подписи.

Продолжение того же клиента:

data = b"nsu.example challenge"
body = (b"\x0d"
        + struct.pack(">I", len(blob)) + blob
        + struct.pack(">I", len(data)) + data
        + struct.pack(">I", 0))
response = req(body)

print("тип ответа:", response[0])          # 14
sig_len = struct.unpack(">I", response[1:5])[0]
sig = response[5:5 + sig_len]
type_len = struct.unpack(">I", sig[0:4])[0]
print("тип подписи:", sig[4:4 + type_len].decode())
тип ответа: 14
тип подписи: ssh-ed25519 длина: 83

Смысл: любой процесс, который может подключиться к сокету, получает подпись на произвольные данные. Ему не нужен приватный ключ, не нужен доступ к диску, не нужна парольная фраза. Для SSH-аутентификации этого достаточно: подпись, полученная от агента, неотличима от подписи, сделанной владельцем, потому что криптографически она и есть подпись владельца.

Флаги в запросе влияют на формат подписи: для RSA — SSH_AGENT_RSA_SHA2_256 (2) и SSH_AGENT_RSA_SHA2_512 (4). Для ed25519 флаги не используются. При разборе чужого клиента эти числа помогают понять, что именно он просил.

Ограничения, которые можно навесить на ключ

ssh-add умеет добавлять ключ с ограничениями через расширение SSH_AGENTC_ADD_ID_CONSTRAINED (25):

Код ограниченияИмяЧто делает
1SSH_AGENT_CONSTRAIN_LIFETIMEключ удаляется через N секунд
2SSH_AGENT_CONSTRAIN_CONFIRMкаждая подпись требует подтверждения
3SSH_AGENT_CONSTRAIN_MAXSIGNне больше N подписей

Отсюда ssh-add -t 1800 (lifetime) и ssh-add -c (confirm). Ограничение maxsign в CLI не выставлено, но протокол его поддерживает: если нужно ограничить число операций, это делается через прямое сообщение.

Практический вывод для защиты: -c — единственное ограничение, которое замечает владелец в момент атаки, потому что требует его участия. -t ограничивает окно, но не мешает использовать ключ, пока окно открыто.

Перехват агента: что реально даёт сокет

Кто может подключиться

Правила простые и жёсткие: connect к UNIX-сокету проверяется по правам на файл сокета. Если сокет 0600 alice:alice, подключиться может только alice и root. Если 0666 — кто угодно. Если каталог, в котором лежит сокет, доступен на запись другому пользователю, он может удалить сокет и создать свой на том же месте — это уже подмена, а не перехват.

Отсюда три сценария, которые стоит различать:

  1. Свой пользователь, чужой процесс. Если злоумышленник уже работает как alice (украл сессию, получил RCE в её процессе), сокет агента для него открыт: он подключается и подписывает. Никаких дополнительных прав не нужно.
  2. Root на бастионе. Root игнорирует права файлов и подключается к любому сокету. Проброшенный агент на бастионе означает, что root бастиона получает подписи от имени администратора.
  3. Сосед по системе с правом записи в каталог. Может подменить сокет: удалить и создать свой. Дальше администратор думает, что говорит с агентом, а на самом деле — с поддельным.

Проверка доступных сокетов:

find /tmp /run/user -maxdepth 3 -type s -name 'agent.*' 2>/dev/null
ls -ld /tmp/ssh-* 2>/dev/null
/tmp/ssh-9Xy2ab7Q/agent.1847
/run/user/1000/keyring/ssh
drwx------ 2 alice alice 4096 окт 19 18:10 /tmp/ssh-9Xy2ab7Q

ssh-add -l на чужом сокете

Самый простой способ проверить, что сокет живой и что в нём есть, — направить переменную на него:

SSH_AUTH_SOCK=/tmp/ssh-9Xy2ab7Q/agent.1847 ssh-add -l
256 SHA256:Zx9...Kq1 alice@workstation (ED25519)
4096 SHA256:Ab4...7Fc deploy@nsu (RSA)

Если команда вывела список — доступ есть. Если Could not open a connection to your authentication agent — сокет не тот, не живой или прав не хватает. Разница между этими двумя ответами и есть результат проверки.

Дальше — использование. Два способа: переменная окружения (наследуется ssh и git) или опция IdentityAgent:

SSH_AUTH_SOCK=/tmp/ssh-9Xy2ab7Q/agent.1847 ssh -o IdentitiesOnly=no deploy@192.0.2.10
ssh -o IdentityAgent=/tmp/ssh-9Xy2ab7Q/agent.1847 deploy@192.0.2.10

Второй способ удобнее для скриптов: он не меняет окружение и действует только на одну команду.

Границы приёма

Перехват работает, пока выполняются три условия: сокет существует, ключ в нём загружен, доступ к сокету есть. Каждое условие можно сломать:

  • время жизни: ssh-add -t выгружает ключ, и сокет становится бесполезным;
  • подтверждение: ssh-add -c требует участия владельца при каждой подписи;
  • изоляция: сокет в /run/user/<uid> с 0700 и без проброса на чужие хосты недоступен снаружи;
  • отсутствие проброса: без -A и без -R на сервере нет сокета, который можно перехватить.

Отсюда практическое правило: самый опасный сценарий — не «украли ключ», а «оставили проброс на хосте, куда может попасть кто-то ещё». Ключ при этом остаётся на рабочей станции, а доступ — уже нет.

Чтение utmp программой: те же поля, но без утилиты

utmpdump удобен, но он не единственный способ. Любая программа может прочитать те же записи через getutxent — и это полезно, когда нужно отфильтровать сессии по пользователю или собрать статистику.

/* readutmp.c — печатает записи utmp с расшифровкой полей */
#include <stdio.h>
#include <time.h>
#include <utmpx.h>

int main(void)
{
    struct utmpx *u;
    setutxent();
    while ((u = getutxent()) != NULL) {
        char t[32];
        time_t sec = (time_t)u->ut_tv.tv_sec;
        strftime(t, sizeof t, "%Y-%m-%d %H:%M:%S", localtime(&sec));
        printf("type=%d pid=%d line=%-8.8s user=%-12.12s host=%-20.20s %s\n",
               u->ut_type, u->ut_pid, u->ut_line, u->ut_user, u->ut_host, t);
    }
    endutxent();
    return 0;
}
gcc -O2 -Wall -Wextra -o readutmp readutmp.c
./readutmp | head -6
type=2 pid=0 line=~        user=reboot       host=7.2.4-zen2-1-zen     2026-10-19 14:27:48
type=7 pid=1501 line=tty1     user=noviui       host=                     2026-10-19 14:29:11
type=7 pid=1680 line=pts/0    user=noviui       host=:1                   2026-10-19 14:29:14
type=7 pid=3177 line=pts/1    user=noviui       host=:1                   2026-10-19 14:29:36
type=7 pid=44979 line=pts/2    user=noviui       host=:1                   2026-10-19 17:22:10
type=7 pid=3352 line=pts/3    user=noviui       host=:1                   2026-10-19 14:29:40

Читается так: type=2 — перезагрузка, и в поле host у неё лежит версия ядра; type=7 — вход пользователя, host=:1 означает локальную графическую сессию (дисплей :1), пустой host — консоль. Именно это различие важно при разборе: :0/:1 — локальный вход, IP-адрес — удалённый, пусто — консоль или неизвестно.

systemd-logind: сессии и их области

На системах с systemd параллельно с utmp ведётся реестр сессий logind. Он отвечает на вопрос «какие сессии активны и кто их лидер»:

loginctl list-sessions
loginctl show-session 2 -p Remote -p Type -p Active -p Leader
SESSION  UID USER   SEAT  LEADER CLASS   TTY  IDLE SINCE
      2 1000 noviui seat0 1428   user    tty1 no   -
      3 1000 noviui -     1437   manager -    no   -

Remote=no
Type=tty
Active=yes
Leader=1428

Поле Remote — прямой ответ на вопрос «локально или по сети»: yes означает удалённую сессию, и это надёжнее, чем ut_host, потому что заполняется самим logind. Поле Leader — PID ведущего процесса сессии, по нему видно, что именно запустило сессию (sshd, login, gdm).

Для разведки это ещё один источник: loginctl показывает сессии, которых может не быть в utmp (например, сессии manager без терминала), и даёт привязку к cgroup, а через неё — к процессам.

authorized_keys и ограничения на ключ

Последний артефакт в ~/.ssh/ — authorized_keys, но он интересен не содержимым (открытые ключи не секрет), а опциями перед ключом. Они показывают, что администратор пытался ограничить:

grep -oE '^(command="[^"]*"|from="[^"]*"|no-pty|no-port-forwarding|permitopen="[^"]*"|restrict)' ~/.ssh/authorized_keys | sort | uniq -c
      2 command="/usr/local/bin/backup.sh"
      1 from="192.0.2.0/24"
      3 no-port-forwarding

command= означает, что ключ может выполнить только указанную команду; from= ограничивает адреса; no-port-forwarding запрещает проброс. Если в файле есть ключи без опций, они дают полный доступ — и именно они интересны в первую очередь.

Обнаружение и защита

Что видит защитник

Журнал sshd. Строка Accepted publickey for alice from 192.0.2.45 port 51234 ssh2: ED25519 SHA256:... фиксирует вход по ключу: время, пользователь, адрес, отпечаток. Если один и тот же ключ используется с двух разных адресов одновременно — это признак перехвата или кражи.

wtmp. Запись USER_PROCESS с ut_host, не совпадающим с ожидаемым, — повод сверить с journalctl _COMM=sshd.

lsof и ss. Кто держит сокет открытым:

lsof -U 2>/dev/null | grep -E 'ssh|agent' | head
ss -xl 2>/dev/null | grep -E 'ssh|agent' | head

audit и eBPF. Подключение к UNIX-сокету не логируется audit по умолчанию 8, но его видно в eBPF-трейсере на sys_enter_connect с фильтром по AF_UNIX и пути сокета. Для сервера с пробросом агента такое правило даёт список всех, кто подключался к агенту:

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_connect /comm == "ssh"/ { printf("%s pid=%d\n", comm, pid); }'

Одна строка — и видно, что ssh подключается к сокету агента. Если к сокету подключается процесс с другим именем (python3, ssh-add), это повод разобраться.

Признаки на стороне артефактов. Чужой сокет в /tmp с датой создания, не совпадающей с сессией; SSH_AUTH_SOCK в /proc/<pid>/environ у процесса, который не является оболочкой; записи в known_hosts, которых не должно быть.

Матрица «приём — признак — контрмера»

ПриёмПризнакКонтрмера
чтение истории командmtime и atime файла историиHISTCONTROL=ignorespace, отказ от паролей в командной строке
разбор .viminfoфайл читали (при strictatime)set viminfo='0 или шифрование
.netrc с паролямифайл существуетубрать, использовать агент или credential helper
перебор known_hostsзапросы ssh-keygen -F в auditHashKnownHosts yes
подключение к чужому агентупроцесс не ssh/ssh-add у сокетаправа 0600, /run/user, без проброса
проброс агента на бастионсокет /tmp/ssh-* на сервереForwardAgent no, AllowAgentForwarding no
подмена сокетаmtime каталога сокетакаталог 0700, StreamLocalBindUnlink no
использование подписивход по ключу с неожиданного адреса-t, -c, сверка отпечатков в журнале

Защита: ProxyJump вместо проброса

Фундаментальное решение — не пробрасывать агент вообще. ProxyJump (OpenSSH 7.3+) делает бастион чистым TCP-прокси 1: аутентификация на целевом хосте идёт с рабочей станции, на бастионе сокет не создаётся.

Host bastion
    HostName bastion.nsu.example
    User alice
    ForwardAgent no

Host db-internal
    HostName 192.0.2.10
    User deploy
    ProxyJump bastion
ssh db-internal

На бастионе при этом не появляется ни SSH_AUTH_SOCK, ни /tmp/ssh-*, ни ключей. Проверить это можно, зайдя на бастион и выполнив find /tmp -name 'agent.*'.

Дополнительные меры: AllowAgentForwarding no в sshd_config (запрещает проброс всем), PermitOpen для ограничения целей, ssh-add -t и -c для ограничения времени и подтверждения, права 0700 на ~/.ssh и 0600 на ключи, отключение HashKnownHosts no там, где важна приватность.

Гигиена истории

Три правила, которые снижают утечку через историю: не передавать секреты в аргументах (mysql -p без пароля, read -s, файлы 0600), включать HISTCONTROL=ignorespace и ставить пробел перед чувствительной командой, чистить историю осознанно (history -c, shred -u, HISTFILE=/dev/null для одноразовой сессии).

export HISTCONTROL=ignorespace:ignoredups
export HISTIGNORE='ls:cd:pwd:exit'

Важно понимать, что чистка истории — это анти-форензика, а не защита: она не убирает следы из journalctl, wtmp, audit и eBPF. Подробно об этом — в теме 06.

Практика: разбор одной сессии от начала до конца

Соберём всё вместе на учебном примере. Дано: сервер nsu-bastion, пользователь alice, подозрение, что ночью кто-то работал под её учётной записью. Задача — восстановить хронологию и понять, что было сделано.

Шаг 1. Кто входил. last -F -n 20 показывает сессии, lastb — неудачные попытки, loginctl list-sessions — активные сейчас. Из last берём интервалы: 02:11–02:47 и 03:05–03:12.

Шаг 2. Чем входили. journalctl _COMM=sshd за этот интервал. Если видим Accepted publickey — вход по ключу; Accepted password — по паролю; session opened for user alice без Accepted — вход через su или локальную консоль. Способ входа определяет, что искать дальше: при входе по ключу сокет агента мог быть проброшен, при входе по паролю — нет.

Шаг 3. Что делали. stat по артефактам: .bash_history (mtime 02:47), .viminfo (mtime 02:44), .mysql_history (mtime 02:45). Совпадение говорит, что все три файла обновлялись в одну сессию. Читаем историю, разворачиваем метки, смотрим интервалы.

Шаг 4. Куда ходили. known_hosts и .ssh/config дают список хостов; journalctl по sshd на этих хостах — были ли входы с адреса бастиона в те же минуты. Если были — сессия использовалась для горизонтального перемещения.

Шаг 5. Что осталось в сокетах. find /tmp -name 'agent.*' -type s и ls -l на них: если сокет создан в 02:11 и жив до сих пор, значит сессия не закрыта, а ключ всё ещё в памяти. SSH_AUTH_SOCK=<путь> ssh-add -l покажет, что в нём.

Шаг 6. Сводка. Таблица, которая и есть результат разбора:

ВремяИсточникСобытие
02:11:04wtmp / journalctlвход alice по ключу с 192.0.2.45
02:11:09/tmp/ssh-*создан сокет агента (проброс -A)
02:14:22.bash_historymysql -u root -p...
02:44:10.viminfoоткрыт /etc/wireguard/wg0.conf
02:47:31wtmpсессия закрыта, mtime истории совпадает
03:05:12journalctl (целевой хост)вход deploy по ключу с адреса бастиона

Такой таблицы достаточно, чтобы ответить на три вопроса: кто, когда и что. Всё остальное — детали, которые добавляются по мере необходимости.

Ограничения разбора и типичные ошибки

Ошибка первая: считать atime доказательством чтения. relatime обновляет метку не всегда, mmap и sendfile могут не обновлять вовсе, а noatime отключает её полностью. atime — вспомогательный признак.

Ошибка вторая: считать, что в истории всё. Неинтерактивные команды, скрипты, задания cron и ssh host command в историю не попадают. Отсутствие команды в истории не означает, что её не было.

Ошибка третья: путать ctime и mtime. ctime меняется при любом изменении метаданных, включая chmod и chown, но не означает изменения содержимого. Обратное тоже верно: изменение содержимого обновляет и mtime, и ctime, поэтому «ctime свежее mtime» — признак вмешательства, а не наоборот.

Ошибка четвёртая: доверять ut_host как признаку удалённости. Локальная графическая сессия даёт :0/:1, консоль — пустое поле, и только IP означает сеть. Для точного ответа нужен loginctl show-session -p Remote.

Ошибка пятая: считать, что known_hosts даёт имена. При HashKnownHosts yes имена захешированы, и восстановление — отдельная задача. Проверять надо ssh-keygen -F, а не grep по файлу.

Ошибка шестая: думать, что агент проверяет клиента. Он проверяет только права на сокет. Если сокет доступен — доступен и ключ, и никакие «настройки безопасности агента» этого не меняют.

Эти шесть ошибок покрывают большинство неверных выводов в разборах; каждая из них — следствие того, что механизм понят не до конца, а команда выучена.

Итоги темы

Артефакты пользователя — самый дешёвый источник данных о системе: история команд даёт учётные данные и карту хостов, .viminfo — файлы и фрагменты текста, клиентские журналы — SQL и токены, known_hosts и .ssh/config — топологию сети. Всё это лежит в домашнем каталоге с правами, которые защищают от соседа, но не от root и не от того, кто уже получил доступ.

Хронология восстанавливается сопоставлением четырёх источников: stat по артефактам, метки #<эпоха> в истории, записи wtmp через utmpdump/last, строки Accepted в журнале sshd. Ключевые ограничения: atime врёт из-за relatime, история не содержит неинтерактивных команд, wtmp не различает способ аутентификации — всё это надо держать в голове, чтобы не строить ложных выводов.

Агент SSH устроен так, что подпись получает любой, кто имеет доступ к сокету: протокол — это два сообщения (11/12 для списка, 13/14 для подписи), а права проверяет ядро по файлу сокета. Отсюда правило: проброс агента на недоверенный хост равносилен передаче права подписи, а ProxyJump решает проблему архитектурно — на бастионе просто нет сокета.

Дальше — тема 03: SUID-биты, capabilities и то, как легально выданные полномочия превращаются в root.

Литература

  1. Barrett D. J., Silverman R. E., Byrnes R. G. SSH, The Secure Shell: The Definitive Guide. 2nd ed. — O'Reilly Media, 2005.
  2. OpenSSH Manual Pages. — https://www.openssh.com/manual.html
  3. Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
  4. Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
  5. The Linux man-pages project. — https://man7.org/linux/man-pages/
  6. Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
  7. NIST SP 800-86. Guide to Integrating Forensic Techniques into Incident Response. — 2006.
  8. MITRE ATT&CK: Linux techniques. — https://attack.mitre.org/