История грехов и перехват агента 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.
История команд: как 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 | сколько строк держать в файле | при выходе файл обрезается до этого числа |
HISTCONTROL | ignorespace, 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_history | SQL-запросы клиента MySQL/MariaDB | пароли, если вводились в IDENTIFIED BY |
~/.psql_history | запросы psql | полные тексты, включая CREATE ROLE ... PASSWORD |
~/.python_history | строки интерактивного Python | токены API, вызовы requests |
~/.lesshst | история less | имена файлов, которые читали |
~/.wget-hsts | HSTS-база 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 — вспомогательный признак, а не доказательство.
Корреляция: рецепт из четырёх шагов
Задача: понять, кто и когда работал на машине, и что именно делал. Рецепт, который работает без специальных средств:
- Список сессий.
last -F -n 20— успешные входы с временем начала и конца;lastb -n 20— неудачные (нужен root);whoиw— активные сейчас. - Метки артефактов.
statпо.bash_history,.viminfo,.mysql_history—mtimeиctime. - Содержимое. Метки
#<эпоха>в истории, если есть; иначе — интервалы между строками по смыслу. - Сверка. Сессия из
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.
Формат сообщений: length-prefixed и номера типов
Протокол агента описан в draft-miller-ssh-agent 2 и устроен просто: сообщение — это uint32 длины (сетевой порядок байтов) и тело; первый байт тела — тип. Строки внутри тела — тоже uint32 длины и байты.
Основные типы:
| Код | Имя | Направление |
|---|---|---|
| 5 | SSH_AGENT_FAILURE | агент → клиент |
| 6 | SSH_AGENT_SUCCESS | агент → клиент |
| 11 | SSH2_AGENTC_REQUEST_IDENTITIES | клиент → агент |
| 12 | SSH2_AGENT_IDENTITIES_ANSWER | агент → клиент |
| 13 | SSH2_AGENTC_SIGN_REQUEST | клиент → агент |
| 14 | SSH_AGENT_SIGN_RESPONSE | агент → клиент |
| 17 | SSH_AGENTC_ADD_IDENTITY | клиент → агент |
| 18 | SSH_AGENTC_REMOVE_IDENTITY | клиент → агент |
| 19 | SSH_AGENTC_REMOVE_ALL_IDENTITIES | клиент → агент |
| 22 | SSH_AGENTC_LOCK | клиент → агент |
| 23 | SSH_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: сокет на бастионе — это точка, через которую подпись уходит к агенту.
Проброс: -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):
| Код ограничения | Имя | Что делает |
|---|---|---|
| 1 | SSH_AGENT_CONSTRAIN_LIFETIME | ключ удаляется через N секунд |
| 2 | SSH_AGENT_CONSTRAIN_CONFIRM | каждая подпись требует подтверждения |
| 3 | SSH_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 — кто угодно. Если каталог, в котором лежит сокет, доступен на запись другому пользователю, он может удалить сокет и создать свой на том же месте — это уже подмена, а не перехват.
Отсюда три сценария, которые стоит различать:
- Свой пользователь, чужой процесс. Если злоумышленник уже работает как
alice(украл сессию, получил RCE в её процессе), сокет агента для него открыт: он подключается и подписывает. Никаких дополнительных прав не нужно. - Root на бастионе. Root игнорирует права файлов и подключается к любому сокету. Проброшенный агент на бастионе означает, что root бастиона получает подписи от имени администратора.
- Сосед по системе с правом записи в каталог. Может подменить сокет: удалить и создать свой. Дальше администратор думает, что говорит с агентом, а на самом деле — с поддельным.
Проверка доступных сокетов:
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 в audit | HashKnownHosts 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:04 | wtmp / journalctl | вход alice по ключу с 192.0.2.45 |
| 02:11:09 | /tmp/ssh-* | создан сокет агента (проброс -A) |
| 02:14:22 | .bash_history | mysql -u root -p... |
| 02:44:10 | .viminfo | открыт /etc/wireguard/wg0.conf |
| 02:47:31 | wtmp | сессия закрыта, mtime истории совпадает |
| 03:05:12 | journalctl (целевой хост) | вход 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.
Литература
- Barrett D. J., Silverman R. E., Byrnes R. G. SSH, The Secure Shell: The Definitive Guide. 2nd ed. — O'Reilly Media, 2005.
- OpenSSH Manual Pages. — https://www.openssh.com/manual.html
- Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
- Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
- The Linux man-pages project. — https://man7.org/linux/man-pages/
- Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
- NIST SP 800-86. Guide to Integrating Forensic Techniques into Incident Response. — 2006.
- MITRE ATT&CK: Linux techniques. — https://attack.mitre.org/