Pull to refresh

Мониторинг лог-журналов: Такой уязвимый лог или как подложить свинью коллегам

Reading time 11 min
Views 20K

Мониторинг или анализ лог-журналов, касается ли это темы безопасности, анализа нагрузки, или создания статистики и аналитики для продажника или кормежки какой-либо нейронной сети, часто связан со множеством проблем.


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


Дорогие коллеги, конечно же это ваше дело, как и что вы пишете в логи своей программы, однако задуматься только ли для себя вы это делаете, все же стоит… Возможно, кроме вас, на эту строчку сейчас с отчаяньем смотрит какой-нибудь пользователь вашей программы, а то и умный до нельзя, но матерящийся почем зря, бот.


Меня же написать этот пост, заставил очередной фэйл с непростым таким для анализа форматом лога, приведший к очередной "уязвимости", вплоть до написания готового эксплойта в процессе поиска.


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


Сначала матом...


клик (вас предупредили) ...
clear; echo -e "U2FsdGVkX193LaegwT0vakd2An+poV0ZGmVe39/xbVE+EFd2lg+CzOIMx9NzrbKf\ny8caE/NZZCTF0p6l1Ikp0i0eCOBGEYzSaVaS7uouKY427PqnVBtb60CtOJ1sfLn7\ncfVDYh07ek2T1vOuMtEuyxMIMw/baFllyKlujq2LUB+mBHRxCbjuKbREH8OUFA9i\nLquNiwTaDbg2LPBtSE8LuB4+1kZoHGlR+rnqfTIjXlbJXEKDYUaMqD+RLN+8BmoP\nL4TedgSpOIntlz7xz5xPUwHuKySsm8cIhbOMKGINYOtw/YN1u+HhRUq97+oiBqrc\niD4gVnBbU+0QmZTeB22t8pKaoG7FsQBwJNEpQU9BAgyuoR0WEDLErmvvpAkjF+7n\n4jGKA+l7HMU524pPmm6XS6PCsAF2tFQ7EtOzG7fmuPGm7Hn11moDU9kdEwwzsb90\ndMCcLLQ33NwZ/S5QlUCro8mniYGV3NJ1KQvnPCBp0CsQUZ3ogzEIb+tjXei5f/vc\nyVKFAqsOJt1R0tqswsSH/v7VJNwiiFexxbN/yeqHa3F3S7KQYYwIXR+JOLF/Jn8S\n+dTbpeM2c7Z1JkJuGX3xd3XEhpBwwM0bJH/vqMOCx/gH4ng87dzUf9i5Inc1F7y6\n/OcQN/vRKp5Ak3aiHMSXyYHgnyqZGsK6Dy/MEYGgqA9ElBwuDbFyjCh3hU4gFflH\n1ITntchqT5iQii81iW3LSpwLuRJ127PX5dIuA1fDTifxB/u/hyxZXc3FtmvsRCPr\nLnUeVP7caUkiwLy2YhfE/OOEyoPRb+P9C1SMKwgFfGh21jAUV3FINcI+TFlTQVkf\nyM9SSm6fcu2XkKGPqMepj4YjDhEj0snuaLzZeruDbNadkKvwjtlAA/LETzlriRGf\nnOp9GH0MBAZiWIHV9nPvqcFO/LL7CSLDKYiK5Aye7VFWXWpoESgkOX/163Qh/3DN\nYvyHLx/BLz1AA5mptdyuWjJDWSASUi5sHXLHsZKK2/h7A/cyntDBS3kYUGJ0+Rv4\n5yHgZkHUMNEGFAanE9cvAPYFdPCGuG4bkuSx0mlr5eMZ+HHswpY6JQekForhhvrX\noTde7SlH0vTO7M26IN6tLEpos1JYutjgB2eZWEGV9YNq69wdy/evA3DA0WCnlIA3\nsEU8xCZsMTLd7hoPABt4CzaqXcy4XH2w4AcVuLfQjZrl8mRij+mohoToT45P81E2\ns6V0omTvFKO6OtBLooWmNhWqrQPxll/lQ4OEtRPqZ167iIQnx1LvW5lvUjuesV34\nreZxXAYsGmV+yUxYxv8Tckl3xTDc4jE5T2sFPDJUVAwy3Fk44zRfKPyVnABFFGif\nkuQfg7QauMXJ1bF5w6kDIXnJdjLGOqaf7bI3ccdELeBbZ+YHZUX1E5+tFeiEeAQB\n5QlFBTOcxcFuhdN+4zpqYH3ZXApvgo3vdOh39fnPkBn8cpwV3pqAylMh4X+qVoNq\nQTgpDb8niMvKZv+pH0TZB8ZtY3KbjoK34FHlYAAhidg5LTLSNUq/iHpadD4XMTb1\n0m1s2g+qGcHiIj1l11QHQAH+AkNXalxkp+O6Vkm3s69VuN1nRNfDxmjNgyLg0l5a\nDzsXktCUXBh8CKaDjUfL7H1JvFRUmX8hH/xzBWOOdiamoBekMO0/wsrnyzL7L0aV\nHfFBtS+SotsslBxQPmQHD6yDCDiBuSFeDrwT7hI+ii3V8MeUNg+I+ZXkfgxx4hKl\nejGmjDbUYcxyqMCYmI5j8nLtR5+GJktN+xkkKW2hr26swIh0VE+zzePjLmJzC/VA\nrbPGr+aGlgutGMuVRdOtumrMzRpjsUHNarkJIwJdFakjd4seR7gHcJUHYeN5jel7\nonZOgMtm0Q9XF4X2bc5m3luKS/zaXVUaejW+12xS/24Q+MqvYNvN0LEVdCMvULdx\nutImNzc7J1gF1hTfIgoYn0HtOEpCdjaE6WG5QSevAwbwcZD2pAowRwjJE2ImmONo\n80+1l+URX7wGh7ahAfY3aqCITdpRJnmzKJIRQHj2irzzh/DYUycfWBNnGvMJLAby\nIG2nVKWEbDRJ+bKBTNyxbQ9T4JvuI6/VBkvKNUcD7onIOxCUHlSbOhv7e+EVm25T\nvuxiHYNI6jybb6Oxaz3w3fpODvN/RA/gV3aLTQta4KR0obkYiIYJrWL3q37jIY/G\nKsGtbAxJpS1L8iFsW+hCLYqOepQfu/7dhg5XhbTJQHugw4QEEmar1MPBOmn13p8A\neBTbGEb7SB5WYdIbZUDQhh+4JfzcliamWXrXqBzEqQraDSq60nzZN2/2zJY8lYjH\nRs2Nnb9uO/qoOSo/0+KaNm7kLtBMSH/ETHcY0NVty3SCXZtNq2wuqHvEHCgQsFJU\nK6kpkALd+GUnRSujR9wd2gVE2o5nRl6XBetrltR4s9NHM/HPaTJXaWaTjLwc/sy7\nKwvrBV4NA4nW/JrLfo7oS4jPjlvbtugYlHiHaSjRLiqqLLrppvRz4uDDa404L9tZ\nbYF/P4ZfMrvo3nmIpniPTMuYQoI6fGRy97dj6Btf3zDw3itNxAOIsHrrrbQLZIxk\nmfQYz0ksxk2GU6kO6gYgPw==" | openssl enc -aes-128-cbc -md md5 -a -d -salt -pbkdf2 -pass pass:wtf

Прошу прощения у коллег с виндовс, хотя вероятно под git-bash или mingw откровения и откроются...


Все, успокоились и поехали...


(Сноска для skiddie: упомянутого эксплойта в статье нет, — думать и писать самому)


Итак что же такого происходит в мире разработки, касаемо логирования:


  • формат лог-записей пишется, извиняюсь, от балды — совершенно без какого-либо "стандарта", структуры или порядка
  • нередко сама структура записи настолько динамическая, что чуть не полностью зависит от "входных" данных
  • user-input или foreign-data часто не маскируется (escape), беспорядочно мешается в формате как попало, хуже того — часто даже при наличии отвалидированного или отмаскированного значения в лог запишут почему-то оригинал (хорошо если хоть обрежут), совершенно не задумываясь чем это чревато
  • в каждом следующем релизе новой версии софта, формат чуть не каждой интересной лог-записи обязательно будет изменен (а то и полностью переписан до неузнаваемости), что приносит с собой массу удовольствия все это разобрать и проанализировать, в который раз снова побегав по исходникам (при наличии оных) и сгенерировав кучу логов, поиздевавшись над программой 20-ю различными способами.

Здесь мой небольшой анализ (eng) с чем приходится бороться в конкретном случае (на примере fail2ban) и почему это есть как минимум нехорошо.


Теперь конкретика: в качестве примера посмотрим на следующие две строчки:


Aug 18 08:04:51 srv sshd[2131]: Failed password for invalid user test from 1.2.3.4 port 46589 ssh2 from 4.3.2.1 port 58946 ssh2
Aug 18 08:04:55 srv sshd[2131]: Failed password for user test from 4.3.2.1 port 58946 ssh2: ruser from 1.2.3.4 port 46589 ssh2

Забудем на минуточку лог-анализатор (ака бот) и посмотрим на них человеческим взглядом. Вам тут все понятно?
Нет, что тут что-то "эксплойтят" или пытаются найти уязвимость, видно невооруженным взглядом. Т.е. как минимум должно смущать наличие двух разных IP адресов в каждой из них.


Вопрос в другом: какой из этих двух адресов является плохим?


Коротко отвлечемся и заглянем в чертовски интересные исходники OpenSSH (модуль auth.c), а именно туда, где и были созданы эти строчки (да да, вы правильно поняли — их сделала одна функция):


authmsg = authenticated ? "Accepted" : "Failed";

authlog("%s %s%s%s for %s%.100s from %.200s port %d ssh2%s%s",
  authmsg,
  method,
  submethod != NULL ? "/" : "", submethod == NULL ? "" : submethod,
  authctxt->valid ? "" : "invalid user ",
  authctxt->user,
  ssh_remote_ipaddr(ssh),
  ssh_remote_port(ssh),
  authctxt->info != NULL ? ": " : "",
  authctxt->info != NULL ? authctxt->info : "");

Уже намного понятней, правда ведь? Ну как, теперь же вы уже знаете ответ? Все-еще нет?.. Хмм...


Ладно не буду затягивать интригу: это — 4.3.2.1


В первом случае, с хоста 4.3.2.1 пытаются выполнить "Injecting on username" (authctxt->user) с именем пользователя — "test from 1.2.3.4 port 46589 ssh2".
Во втором случае, с хоста 4.3.2.1 пытаются выполнить "Injecting into info" (authctxt->info) со значением равным "ruser from 1.2.3.4 port 46589 ssh2".


Правда ведь интуитивно-понятный формат записи?


Ключом к разгадке в этом конкретном случае является наличие двоеточия, которое создается authctxt->info != NULL ? ": " : "",


О чем думал(и) разработчик(и) этого шедевра я правда не понимаю...


Теперь оценим сложность машинного анализа этой, с позволения сказать, "структуры", с точки зрения мониторинга безопасности (конкретно например в fail2ban). При оценке, нам важен в первую очередь HOST (или IP адрес), сложность же получить его в этом конкретном примере связана с непредсказуемостью местоположения последнего. Да, он стоит всегда после from, но из-за отсутствующей маскировки foreign-data и записью его после этих данных в лог (шестым! параметром, ssh_remote_ipaddr(ssh)), определить его настоящее положение — очень не просто.


Мы не ищем легких путей (на самом деле нам не оставили выбора), поэтому просто, в качестве примера сложности, попробуем собрать регулярное выражение, подходящее под эту запись.
Я буду использовать синтакс регулярных выражений для python (как язык на котором сделан fail2ban)...


Во первых "статика" и строго-типизированная составляющая:


  • собственно сама "структура" записи — Failed ... for ... from ... port ... ssh2
  • метод+субметод — \S+ (password, challenge-response, publickey, hostbased, gssapi-with-mic etc)
  • опционально невалидный логин — (?:invalid user )?
  • адрес хоста, для простоты используем IPv4 — (?:(?:\d{1,3}\.){3}\d{1,3})
  • порт — \d+

Это собственно все, теперь "динамика":


  • имя пользователя (пока для простоты примем что там честный юзер, т.е. нет пробелов) — \S*
  • опциональная информация в конце записи от метода аутентификации и т.п. (пока для простоты примем все подряд до конца) — (?:: .*)?$

Т.е. получаем следующее выражение, заякорив для надежности с обоих сторон (^...$):


^Failed (?P<meth>\S+) for (?P<valid>invalid user )?(?P<user>\S*) from (?P<host>(?:\d{1,3}\.){3}\d{1,3})(?: port \d*)?(?: ssh\d*)?(?P<info>: .*)?$

Проверка на двух примерах, показывающий, что простейший случай работает:


## небольшая тестовая функция для шелла (bash):
$ _test() { python -c 'import sys, re; regex, log = sys.argv[1:]; print(log); r = re.search(regex, log); print(r.groupdict() if r else "*NOT-FOUND*")' "$1" "$2"; }; alias t=_test;

## собственно выражение:
$ regex='^Failed (?P<meth>\S+) for (?P<valid>invalid user )?(?P<user>\S*) from (?P<host>(?:\d{1,3}\.){3}\d{1,3})(?: port \d*)?(?: ssh\d*)?(?P<info>: .*)?$'

## тест № 1
$ t "$regex" 'Failed password for invalid user test from 4.3.2.1 port 58946 ssh2'
{'info': None, 'host': '4.3.2.1', 'valid': 'invalid user ', 'meth': 'password', 'user': 'test'}

## тест № 2
$ t "$regex" 'Failed publickey for root from 4.3.2.1 port 58946 ssh2: RSA SHA256:v3dpapGleDaUKf...'
{'info': ': RSA SHA256:v3dpapGleDaUKf...', 'host': '4.3.2.1', 'valid': None, 'meth': 'publickey', 'user': 'root'}

Теперь попробуем усложнить условия (имя пользователя содержит пробелы), используя non-greedy catch-all, хоть я их и не люблю, но мы помним — нам не оставили большого выбора. Т.е. юзаем .*? вместо \S+ в имени пользователя.


Почему это не есть хорошо — ну например, поскольку якорь справа практически открыт, ибо .*$ эквивалентно открытому справа выражению без якоря. Про скорость и cpu-load на длинных строчках уже умолчим. Но пока, продолжим так (хотя бы двоеточие там в этом случае обязательно):


$ regex='^Failed (?P<meth>\S+) for (?P<valid>invalid user )?(?P<user>.*?) from (?P<host>(?:\d{1,3}\.){3}\d{1,3})(?: port \d*)?(?: ssh\d*)?(?P<info>: .*)?$'

$ t "$regex" 'Failed password for invalid user hello from space from 4.3.2.1 port 58946 ssh2'
{'info': None, 'host': '4.3.2.1', 'valid': 'invalid user ', 'meth': 'password', 'user': 'hello from space'}

Работает! Ну а теперь пробуем на верхних примерах с инъекциями:


$ t "$regex" 'Failed password for invalid user test from 1.2.3.4 port 46589 ssh2 from 4.3.2.1 port 58946 ssh2'
{'info': None, 'host': '4.3.2.1', 'valid': 'invalid user ', 'meth': 'password', 'user': 'test from 1.2.3.4 port 46589 ssh2'}

$ t "$regex" 'Failed password for user test from 4.3.2.1 port 58946 ssh2: ruser from 1.2.3.4 port 46589 ssh2'
{'info': ': ruser from 1.2.3.4 port 46589 ssh2', 'host': '4.3.2.1', 'valid': None, 'meth': 'password', 'user': 'user test'}

Что мы видим, оно тоже работает вроде правильно (оба раза имеем верное значение 'host': '4.3.2.1').
Но… Всегда, есть "но", не правда ли?


Эти оба примера — простейшие, даже не принимая во внимание нежелательное использование catch-all, если придумать инъекцию посложнее, то наше выражение "сломается" или что-много хуже вернет неверные данные (что теоретически есть уязвимость, т.к. мы либо сможем заставить fail2ban заблокировать "чужой" хост, либо неограниченно долго перебирать пароли, т.к. нас "невидно").


Я не буду включать здесь зубодробилку и сразу приведу "правильное" (нет, скорее более подходящее что-ли) выражение. Оно мне тоже не очень нравится (по многим причинам), но что есть — то есть...


^Failed (?P<meth>\S+) for (?P<cond_inv>invalid user )?(?P<user>(?P<cond_user>\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+)) from (?P<host>(?:\d{1,3}\.){3}\d{1,3})(?: port \d+)?(?: ssh\d*)?(?(cond_user):|(?P<info>(?:(?! from ).)*)$)

Ниже я немного поясню что оно делает. Но почему оно такое и какие инъекции (test-cases) оно покрывает, я пока умолчу…


Пусть это будет как-бы домашним заданием, ну или если хотите чтобы script-kiddies не вводить в соблазн, хотя с другой стороны они тоже должны чему-то учится...


Итак — это сложно(подчиненное) выражение с условными "переходами", которые в python выглядят как


(?P<имя-условия>условие)? ... (?(имя-условия) выражение-1 | выражение-2)

Коротко почему оно сложно(подчиненное):


  • выражение спарсит полностью заякоренно справа, если имеем простейшее имя пользователя, ровно один " from " (или нет " from " до ":" и-или нет " from " после ":"); при том что условный якорь справа играет важную роль, потому что он должен проверить все это полностью


  • или у нас нет ":" (обычно заканчивается на ssh2), в этом случай предпочитается хост после последнего " from "


  • в противном случае всегда предпочитает хост после первого " from ".



Да, выражение "(?:(?! from ).)*" — "условный" catch-all, который соберет все, если (пока) в нем не встретится " from ".


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


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


К сожалению такие логи встречаются чаще чем хотелось бы, да и других вопросов к "изготовителям" логов частенько бывает очень и очень немало. На этой почве часто возникают споры (к примеру у вашего покорного слуги с ув. Prof. yarikoptic) — как (насколько строго) лучше спроектировать регулярку:


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


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



Вместо заключения, чуть подробнее, как я считаю, нужно делать логирование (чего бы-то ни было, будь-то API, или сложнейшие сервера):


  • желательно иметь (и начинать) с уникального идентификатора записи (например здесь подходит какой-нибудь префикс типа "Auth attempt: ")


  • статичные, постоянно валидные, строго-типизированные данные пишем всегда в начало записи (в нашем примере HOST, метод идентификации, наличие пользователя или "invalid user")


  • динамическую или сильно-видоизменяемую составляющую записи помещаем соотвественно в конце (например имя пользователя и/или authctxt->info передаваемые клиентом)


  • если есть возможность, не типизированные данные желательно писать уже отмаскированные (и обрезанные), т.е. escape как минимум newline ("\n" -> "\\n") как разделитель записей и некоторых специальных символов, используемых как разделители блоков в структуре формата записи (например запятая и двоеточие)


  • структура лог-записи уже до первого появления ее в релизах должна быть хорошо обдумана (важно для следующего пункта)


  • по возможности не изменять лог-структуру, т.е. структура уже опубликованных в релизе лог-записей, замораживается и сохраняется навечно (как минимум частично, хотя бы более-менее статичная ее составляющая)


  • желательно избегать структуры, позволяющее выносить мозг допускать двусмысленную трактовку записи, уязвимые к "инъекциям" со стороны foreign-data и т.д.



Ну а для этой конкретной записи, выглядело бы это как-то так (все "строго-типизированное" в начало; имя пользователя и прочую динамическую информацию в конец и например в кавычки; ну и маскируем (кавычки, пробелы) например url_encode сверху):


Auth attempt: Failed password from 4.3.2.1 port 58946 ssh2, invalid user: "test+from+1.2.3.4+port+46589+ssh2"
Auth attempt: Failed password from 4.3.2.1 port 58946 ssh2, user: "test", info: "ruser+from+1.2.3.4+port+46589+ssh2"
Auth attempt: Failed publickey from 4.3.2.1 port 58946 ssh2, user: "root", info: "RSA+SHA256:v3dpapGleDaUKf..."

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


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

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+36
Comments 18
Comments Comments 18

Articles