Анализ инцидента на работающей Linux системе. Часть вторая.

Анализ инцидента на работающей Linux системе. Часть вторая.

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

Мариус Бурдах, перевод Владимир Куксенок 
1. Введение

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

Примечание:
Некоторые читатели после прочтения первой части этой статьи указали, что перед передачей любых цифровых данных со скомпрометированной машины, должен быть запущен доверенный шел. Я думаю, это хорошая идея, поэтому вначале мы должны статически скомпилировать, например bash, а затем скопировать его на наш сменный носитель информации. Шаг 2 должен выглядеть следующим образом:
(compromised)# /mnt/cdrom/bash 
Мы должны помнить, что почти любой командный интерпретатор пишет каждую напечатанную команду в файл истории. Так как мы не хотим изменить локальную файловую системы, лучшим решением будет выключить запись истории команд.

Пожалуйста ознакомьтесь с первой частью этой статьи, а именно с разделами 2, 2.1, 2.2 и 2.3 (шаги с первого по пятый) прежде чем продолжить чтение.

2.3 Сбор информации о системе - продолжение

Шаг 6 - Образ физической памяти

Один из шагов в процессе сбора цифровой информации о системе это полное копирование памяти системы. Мы можем получить доступ к физической памяти напрямую копируя устройство /dev/mem или kcore файл. Файл kcore это RAM (Random Access Memory, Память со случайным доступом -- примечание переводчика) в операционной системе Linux. Он может быть найден в псевдо файловой системе, которая примонтирована в директории /proc. Размер этого файла равен размеру физической памяти, расширенной примерно до 4 Кб. Преимущество использования kcore состоит в том, что он представлен в формате ELF и поэтому может быть отлажен при помощи отладчика gdb. gdb - отличный инструмент, который позволяет трассировать и анализировать небольшие части кода или памяти. В разделе 2.7 я покажу, как использовать эту утилиту для более глубокого автономного анализа. Но в основном мы будем использовать более универсальные средства, типа grep, string и шестнадцатеричных редакторов.

В дополнение, чтобы должным образом изучать физическую память, мы должны иметь кое-какую информацию из таблицы страниц - структуры данных, использующиеся при преобразовании виртуального адреса в физический. Мы должны знать, что физическая память делится на страницы виртуальной памяти. В таблицах страниц мы можем найти информацию о порядке страниц (4 Кб на страницу в процессорах от Intel) записанных в физическую память.

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

В примере ниже я скопировал образ памяти, используя псевдо файловую систему /proc:
(remote)#nc -l -p port > kcore_compromised
(compromised)#/mnt/cdrom/dd < /proc/kcore | /mnt/cdrom/nc (remote) port
(remote)#md5sum kcore_compromised > kcore_compromised.md5
При полном копирование памяти системы мы копируем и размещенные (allocated) и не размещенные (unallocated) данные, потому что процесс копирует полный образ физической памяти. В девятом шаге мы попробуем скопировать определенный процесс, используя /proc. Должен упомянуть, что, используя /proc, мы можем изменить раздел подкачки (swap). Копируя подозрительный процесс, какие-то страницы мы заставим считаться из файла подкачки в память, а какие-то записаться в него. Другая важная особенность состоит в том, что мы копируем только ту память, в которой размещены данные процесса.

Шаг 7 - Список модулей загруженных в адресное пространство ядра операционной системы

Мы должны убедиться, что собранные данные полны и что результаты работы команд netcat и lsof не изменяются на уровне ядра. Итак, мы должны выяснить, какие модули в настоящее время загружены в память.
(remote)# nc -l -p port > lkms_compromised
(compromised)#/mnt/cdrom/cat /proc/modules | /mnt/cdrom/nc (remote) port
(remote)# nc -l -p port > lkms_compromised.md5
(compromised)# /mnt/cdrom/md5sum /proc/modules | /mnt/cdrom/nc (remote) port
К сожалению, некоторые из злонамеренных модулей не могут быть выявлены таким способом. Чтобы проверить информацию о загруженных модулях, полученную из /proc/modules, я воспользуюсь методом, описанном в недавнем номере Phrack Magazine, в статье "Finding hidden kernel modules (the extrem way)". Модуль hunter.o проверяет цепочки модулей, загруженных в адресное пространство ядра.
(compromised)#/mnt/cdrom/insmod -f /mnt/cdrom/hunter.o 
В том случае если необходимо произвести принужденную загрузку hunter.o из-за несоответствия версий, я буду использовать флаг "-f switch". Это случается когда версия ядра скомпрометированной системы отличается от версии ядра системы, на которой был скомпилирован модуль hunter.o . Лучшее решение состоит в том, чтобы пересобрать модуль для каждой версии ядра, в адресное пространство которого hunter.o будет загружен. Если мы знаем версию ядра, которое используется на скомпрометированной машине, мы может загрузить соответствующий исходный код с www.kernel.org и включить определенные заголовочные файлы того ядра в наш модуль.
(remote)#nc -l -p port > modules_hunter_compromised
(compromised)#/mnt/cdrom/cat /proc/showmodules && /mnt/cdrom/dmesg | /mnt/cdrom/nc (remote) port
(remote)#md5sum modules_hunter_compromised > modules_hunter_compromised.md5
Теперь мы можем сравнить результаты. Также мы должны обратить внимание на размер модулей. Иногда злонамеренный код может быть внедрен в тело "легального" модуля. Последнее, что мы должны сделать - копии таблиц экспорта модулей ядра. Иногда некоторые простые lkm rootkit'ы экспортируют свои функции. Изучая файл ksyms, мы можем обнаружить присутствие злоумышленника в системе.

(remote)#nc -l -p port > ksyms_compromised
(compromised)#/mnt/cdrom/cat /proc/ksyms | /mnt/cdrom/nc (remote) port
(remote)# nc -l -p port > ksyms_compromised.md5
(compromised)#/mnt/cdrom/md5sum /proc/ksyms | /mnt/cdrom/nc (remote) port
Мы также можем использовать другие средства для обнаружения злонамеренных модулей (такие как kstat или kern_check), но, к сожалению, все они используют файл System.map . Этот файл генерируется после сборки ядра. Если администратор не скопирует этот файл и не сохранит его контрольную сумму, мы не должны доверять полученным адресам системных вызовов. Даже если адреса системных вызовов правильны, злоумышленник может использовать другие изощренные методы сокрытия злонамеренного модуля в памяти ядра. Например, adore-ng rootkit заменяет подпрограмму обработчик, осуществляющий вывод листинга директории.

Если мы уверены, что адреса системных вызовов в файле System.map или ksyms не изменялись, мы можем проверить, не изменены ли злоумышленником некоторые из адресов системных вызовов в таблице системных вызовов. Более подробно мы рассмотрим это в разделе 2.7 .

Шаг 8 - Список активных процессов

Информация обо всех процессах, открытых портах и файлах может быть собрана с помощью утилиты lsof. Естественно, этой информации можно доверять только в том случае, если мы не обнаружим в памяти lkm rootkit'ов. В дополнение, на следующем шаге мы сделаем копии подозрительных процессов. Копируя определенный процесс, мы можем отделить злонамеренные данные от памяти скомпрометированной системы, которую мы скопировали полностью. Как вы помните, мы сделали образ всей памяти системы (kcore или /dev/mem) в шаге 6.
(remote)#nc -l -p port > lsof_compromised
(compromised)#/mnt/cdrom/lsof -n -P -l | /mnt/cdrom/nc (remote) port
(remote)#md5sum lsof_compromised > lsof_compromised.md5
Теперь мы должны проанализировать результаты работы lsof. Если хотя бы один из активных процессов кажется подозрительным, сейчас самое время сделать его копию. Также, если из результатов работы lsof мы видим, что программа, которая создала процесс, была удалена злоумышленником, у нас еще есть шансы восстановить ее. Я покажу, как сделать это в следующем разделе.

Ниже, я перечислил несколько примеров подозрительных процессов:

- Процесс слушает нетипичный TCP/UDP порт или открытый raw сокет
- Процесс имеет соединение с удаленным хостом
- Программа, удаленная после выполнения
- Файл, открытый процессом был удален (например: лог файл)
- Необычное имя процесса
- Процесс был создан несуществующим или непривилегированным пользователем

Шаг 9 - Создание копий подозрительных процессов

Я буду использовать утилиту pcat для создания образа памяти выделенной процессу.

(remote)#nc -l -p port > proc_id_compromised
(compromised)#/mnt/cdrom/pcat proc_id | /mnt/cdrom/nc (remote) port
(remote)#md5 proc_ip_compromised > proc_ip_compromised.md5
Кроме этого, мы можем сделать копии лишь некоторых выборочных данных процесса. Более подробная информация об этом в следующем разделе.

Шаг 10 - Полезная информация о скомпрометированной системе

Наш следующий шаг - сбор некоторой полезной информации о скомпрометированной системе. Эта информация нужна для того, чтобы выполнить надлежащее описание инцидента и создать копии всех системных файлов. Помните, что все результаты вы должны отослать на доверенный хост. В таблице 3 находятся команды, которые должны быть выполнены на скомпрометированной системе.

Таблица 3: Полезная информация о скомпрометированном хосте
Команда Описание
/mnt/cdrom/cat /proc/versionВерсия операционной системы
/mnt/cdrom/cat /proc/sys/kernel/nameИмя хоста
/mnt/cdrom/cat /proc/sys/kernel/domainameДоменное имя
/mnt/cdrom/cat /proc/cpuinfoИнформация о аппаратном обеспечении
/mnt/cdrom/cat /proc/swapsВсе разделы файлов подкачки
/mnt/cdrom/cat /proc/partitionsВсе локальные файловые системы
/mnt/cdrom/cat /proc/self/mountsПримонтированные файловые системы
/mnt/cdrom/cat /proc/uptimeПродолжительность работы с момента загрузки системы (Uptime)


Шаг 11 - Текущее время

Последний шаг - сбор информации о текущем времени.
(remote)#nc -l -p port > end_time
(compromised)# /mnt/cdrom/date | /mnt/cdrom/nc (remote) port
Теперь мы достигли того момента, когда можем выключить скомпрометированную систему. Запомните, что для завершения работы системы нельзя использовать какие-либо стандартные команды. Мы должны выдернуть силовой кабель из системного блока или источника бесперебойного питания.

2.4 Образы файловой системы

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

Следующий шаг состоит в загрузки операционной системы со съемного носителя. Мы можем использовать любой дистрибутив Linux, по умолчанию не монтирующий все локальные файловые системы. Теперь мы можем сделать копии всех локальных разделов или всего жесткого диска.

2.5 Основы анализа данных

Сейчас давайте снова рассмотрим процессы, которые мы упоминали в Шаге 8, где мы использовали утилиту lsof. Давайте более подробно посмотрим на эту ситуацию и рассмотрим два примера:

Пример 1: Программа, которая создала процесс, была удалена.

В этом случае, файл, который инициировал процесс, все еще находится в памяти. Чтобы его восстановить, мы должны знать идентификатор процесса, созданного программой. В каталоге /proc мы найдем исполняемый файл. Этот файл - копия удаленного. Мы должны отправить этот файл на доверенный хост.

После восстановления удаленного файла мы можем извлечь из него информацию. Мы можем выбрать один из двух вариантов: статический анализ (дизассемблированием) или динамический анализ (исполнением этого файла в контролируемой среде).

Пример 2: Файл, открытый активным процессом был удален (например: лог файл).

Фрагмент результата работы lsof представлен ниже:
smbd       3137  root  rtd    DIR        8,1     4096          2 /
smbd       3137  root  txt    REG        8,1   672527      92030 /usr/bin/smbd -D
smbd       3137  root  mem    REG        8,1   485171      44656 /lib/ld-2.2.4.so
...
smbd       3137  root   16u  IPv4        976                 TCP *:https (LISTEN)
smbd       3137  root   17u  IPv4        977                 TCP *:http (LISTEN)
...
smbd       3137  root   20w   REG        8,1      253      46934 /var/log/httpd/access_log (deleted)
...
Чтобы восстановить этот файл мы должны просмотреть содержимое каталога fd (дескрипторы файлов), находящегося в директории /proc/3137.
(remote)# nc -l -p port > ls_from_proc_3137
(compromised)# /mnt/cdrom/ls -la /proc/3137/fd/ | /mnt/cdrom/nc (remote) port
(remote)# more ls_from_proc_3137

l-wx------ 1 root root 64 Aug 10 21:03 12 -> /var/log/httpd/access_log (deleted)
...
Как мы видим, первый файловый дескриптор удален. Все что мы должны сделать, это скопировать этот файл на доверенный хост, используя утилиту netcat.
(remote)# nc -l -p port > deleted_access_log
(compromised)# /mnt/cdrom/cat /proc/3137/fd/1 | /mnt/cdrom/nc (remote) port
Это не единственный способ восстановления этого файла. Мы также можем восстановить его путем анализа освобожденных i-nodes и блоков данных.

2.6 Поиск ключевых слов

В этой части статьи я хотел бы рассказать об одном из методов исследования процессов и образов памяти. Учтите, что все описанное происходит на удаленном доверенном хосте, после получения образов памяти. Самое большое преимущество этого метода состоит в том, что мы не должны знать используемый низкоуровневый язык (видимо речь идет об ассемблере -- примечание переводчика). Я буду использовать следующие простейшие утилиты для поиска признаков вторжения:

- strings
- less
- grep

Мы соберем все печатные символы из файла образа, используя утилиту strings. Настройки по умолчанию дают нам возможность просмотреть все последовательности печатных символов, состоящие не менее чем из 4 символов. Чтобы добавить смещение относительно начала файла нужно использовать флаг "-t".
$ strings -t d kcore > kcore_strings
$ md5sum kcore_strings > kcore_strings.md5
Утилита grep и регулярные выражения важны на этом начальном этапе анализа. Через несколько минут мы сможем найти доказательства вторжения. Мы должны подумать, какие данные мы собираемся искать, - например, мы ищем команды, напечатанные злоумышленником, IP адреса, пароли или даже расшифрованную часть злонамеренного кода?

Ниже перечислены несколько примеров ключевых слов для поиска. Мы будем использовать их, чтобы найти доказательства вторжения в нашем файле kcore_strings.

  • Имя или префикс хоста скомпрометированной системы
  • $ grep "root@manhunt" kcore_strings
    $ grep "]#" kcore_strings
    
    11921096 [root@manhunt]#
    16643784 [root@manhunt root]#
    30692969 ]#]#
    
    Мы видим смещения некоторых строк относительно начала файла. Следующим шагом будет открытие файла в текстовом редакторе и переход на позицию файла, находящеюся рядом с полученными смещениями. Если нам повезет, мы найдем и другие команды, выполненные в прошлом. Но мы должны помнить, что страницы виртуальной памяти в физическую память и раздел подкачки пишутся неорганизованно, поэтому наши выводы могут быть полностью не верными.
    $ less kcore_strings
    /11921096
    
    11921096 [root@manhunt]#
    11921192 /usr/bin/perl
    11921288 perl apache_mod_exploit.pl
    ...
    
    Пример выше показывает несколько команд исполненных на скомпрометированной системе.

  • Имена файлов и директорий
  • $ grep -e "\/proc\/" -e "\/bin\/" -e "\/bin\/.*?sh" kcore_strings
    $ grep -e "ftp" -e "root" kcore_strings
    $ grep -e "rm -" kcore_strings
    $ grep -e ".tgz" kcore_strings
    
    
  • IP адреса и доменные имена
  • $ grep -e "[0-9]\+\.[0-9]\+\.[0-9]\+\.[0-9]\+" kcore_strings
    $ grep -e "\.pl" kcore_strings
    
    2.7 Подробный анализ вторжения

    В этом заключительном разделе я покажу, как нужно изучать файл kcore с помощью gdb. Вначале я опишу, как проверить правильность адресов системных вызовов в таблице системных вызовов. Изменения адреса системного вызова - самый простой путь установить lkm rootkit на скомпрометированную систему. В следующем примере я покажу, как просмотреть все процессы, который были запущенные на недавно скомпрометированной системе. Результаты этих примеров могут быть сравнены с результатами, полученными в шагах 7 и 8.

    Для начала анализа нам нужна следующая информация:

    - образ памяти в формате ELF (см. Шаг 6)
    - скомпилированный образ ядра (мы должны примонтировать образы файловых систем и скопировать файл vmlinux-* из директории /boot)
    - таблицы экспорта (System.map находится в директории /boot скомпрометированной системы, также мы можем использовать файл ksyms - см. Шаг 7)
    - исходные коды ядра, используемого на скомпрометированной машине (если исходные коды не доступны, мы должны загрузить необходимую версию со следующего веб сайта: www.kernel.org)

    Затем запустим gdb:
    #gdb vmlinux kcore
    GNU gdb Red Hat Linux (5.1.90CVS-5)
    Copyright 2002 Free Software Foundation, Inc.
    GDB is free software, covered by the GNU General Public License, and you are
    welcome to change it and/or distribute copies of it under certain conditions.
    Type "show copying" to see the conditions.
    There is absolutely no warranty for GDB. Type "show warranty" for details.
    This GDB was configured as "i386-redhat-linux"...
    
    warning: core file may not match specified executable file.
    Core was generated by `ro root=/dev/sda2 hdc=ide-scsi'.
    #0 0x00000000 in ?? ()
    (gdb)
    
    Теперь мы готовы начать наши исследования.

    Пример 1: Проверка адресов системных вызовов.

    Вначале нам нужно найти адрес таблицы системных вызовов (sys_call_table). Почти в каждой Linux системе эта информация экспортируется и может быть найдена в файле Symbol.map .
    # cat Symbol.map | grep sys_call_table
    c02c209c D sys_call_table 
    
    Теперь мы можем просматривать элементы таблицы системных вызовов. Каждый элемент это адрес системного вызова. Чтобы правильно их интерпретировать, рекомендую посмотреть файл entry.S из исходников ядра скомпрометированной системы. В этом файле находится упорядоченный список системных вызовов.
    ENTRY(sys_call_table)
         .long SYMBOL_NAME(sys_ni_syscall) /* 0 - old "setup()" system call*/
         .long SYMBOL_NAME(sys_exit)
         .long SYMBOL_NAME(sys_fork)
         .long SYMBOL_NAME(sys_read)
         .long SYMBOL_NAME(sys_write)
         .long SYMBOL_NAME(sys_open) /* 5 */
         .long SYMBOL_NAME(sys_close)
         ... 
    
    Та же последовательность и в sys_call_table. Чтобы просмотреть первые 10 адресов системных вызовов сделаем следующее:
    (gdb) x/10x 0xc02c209c
    0xc02c209c <sys_call_table>: 0xc01217c0 0xc011ac50 0xc0107510 0xc0138d50
    0xc02c20ac <sys_call_table+16>: 0xc0138e50 0xc0138880 0xc01389b0 0xc011b010
    0xc02c20bc <sys_call_table+32>: 0xc0138930 0xc01445c0
    
    Адрес 0xc01217c0 - это системный вызов sys_ni_call, адрес 0xc011ac50 - системный вызов sys_exit, и т.д.

    Если мы доверяем адресам из System.map или ksyms, мы можем сравнить почти каждый адрес. Конечно, злоумышленники изменяют адрес не каждого системного вызова, есть несколько популярных, таких как sys_read, sys_getdents или sys_write.

    Пример 2: Список активных процессов.

    Чтобы получить полный список запущенных процессов, мы должны узнать адрес структуры init_task_union. Эта структура указывает на первый дескриптор процесса, который называется "process 0" или "swapper".
    # cat Symbol.map | grep init_task_union
    c02da000 D init_task_union
    
    Теперь нам нужно узнать, как выглядит эта структура. Пример структуры init_task есть в заголовочном файле sched.h в исходниках ядра скомпрометированной системы. Структура task_struct также находится в этом файле.
    #define INIT_TASK(tsk) 	                           
    {                                                  
        state:      0,                        
        flags:      0,                         
        sigpending:	0,                         
        addr_limit:	KERNEL_DS,                   
        exec_domain:    &default_exec_domain,      
    ...
        run_list:   LIST_HEAD_INIT(tsk.run_list),  
        time_slice:	HZ,                      
        next_task:  &tsk,                       
        prev_task:  &tsk,                         
        p_opptr:   &tsk,                         
    ...
    
    Самые важные для нас поля - prev_task и next_task каждого дескриптора процесса (тип task_struct). Они помогут нам создать список активных процессов. Поле next_task это указатель на дескриптор следующего процесса, prev_task - указатель на дескриптор предыдущего процесса.

    В примере ниже, я выведу список фрагментов дескриптора процесса (тип task_struct), который находится по адресу 0xc514c000.
    (gdb) x/180x 0xc514c000
    ...
    0xc514c040:     0x00000000 		0xffffffff     0x00000004      0xc1be0000
    0xc514c050:     0xc4dac000		0xc5ea8e40     0xc5ea8e40      0xc02c56d4
    ...
    0xc514c070:     0x00000001		0x00001ace     0x00001ace      0x00000000
    ...
    0xc514c230:     0xffffffff 		0xffffffff     0x61620000      0x00006873
    0xc514c240:     0x00007974 		0x00000000     0x00000000      0x00000000
    ...
    
    где:
    0xc1be0000 - next_task
    0xc4dac000 - prev_task
    0x00001ace - идентификатор процесса = 6268 в десятеричной системе счисления
    0x61620000 и 0x00006873 - имя процесса (comm таблица в структуре) = "bash" в данном случае

    Чтобы вывести дескриптор следующего процесса (тип task_struct), сделаем так:
    (gdb) x/180x 0xc1be0000
    
    Получая информацию о каждом дескрипторе процесса, мы можем узнать полный список процессов.

    3. Резюме

    Цель этой статьи показать некоторые методы, используемые для сбора информации, простого и углубленного анализа данных. В частности, я сосредоточился на тех данных, которые были бы полностью потеряны после выключения системы. Эта часть анализа инцидента и процесса реагирования на инцидент очень сложна в реализации; в течение всех этапов мы должны быть очень осторожными и документировать каждое действие. Мы также посмотрели на некоторые преимущества и недостатки программного обеспечения для сбора информации с работающей системы. И наконец, несмотря на то, что я показал, как выглядит процедура сбора информации в Linux, мы также можем выполнить почти такие же шаги на других операционных системах. В следующей статье вы узнаете, как получить необходимую информацию с работающей Windows системы.

    Ссылки
  • Adore-ng rootkit, http://stealth.7350.org/rootkits.
  • Alessandro Rubini, Jonathan Corbet. Linux Device Drivers, 2nd Edition. O'Reilly; 2001.
  • Dan Farmer, Wietse Venema. Column series for the Doctor Dobb's Journal. http://www.porcupine.org/forensics/column.html.
  • Daniel P. Bovet, Marco Cesati. Understanding the Linux Kernel, 2nd Edition. O'Reilly; 2002.
  • Kernel source code. http://www.kernel.org.
  • Linux manual pages.
  • National Institute of Standards and Technology. Computer Security Incident Handling Guide. http://csrc.nist.gov.
  • PHRACK #61. Finding hidden kernel modules (the extrem way) by madsys. http://www.phrack.org.
  • RFC 3227. Guidelines for Evidence Collection and Archiving.
  • Smith Fred, Bace Rebecca. A guide to forensic testimony. Addison Wesley; 2003.
  • Symantec Corporation. CodeRed Worm. http://securityresponse.symantec.com.
  • The Honeynet Project. Scan 29. http://www.honeynet.org.
  • The SANS Institute. Incident Handling step by step. http://www.sans.org.
  • Ищем баги вместе! Но не те, что в продакшене...

    Разбираем кейсы, делимся опытом, учимся на чужих ошибках

    Зафиксируйте уязвимость своих знаний — подпишитесь!