
shiro-cve-2020-17523: анализ двух методов обхода уязвимости и соответствующая среда для эксплуатации
Apache Shiro — это мощная и простая в использовании платформа безопасности Java, выполняющая аутентификацию, авторизацию, управление паролями и сессиями. Благодаря понятному API Shiro вы можете быстро и легко создавать любые приложения — от самых маленьких мобильных до крупнейших корпоративных.
При совместном использовании с Spring и определённых правилах сопоставления прав доступа злоумышленник может обойти аутентификацию, создав специальный HTTP-запрос.
Затронутые версии: Apache Shiro < 1.7.1
shiro 1.7.0
https://github.com/jweny/shiro-cve-2020-17523 Среда для обоих методов уже обновлена.
Метод 1:
http://127.0.0.1:8080/admin/%20 или http://127.0.0.1:8080/admin/%20/
Использование пробелов и других пустых символов позволяет обойти аутентификацию Shiro.

Метод 2:
После общения с коллегой p0desta был обнаружен ещё один метод использования в особых сценариях.
http://127.0.0.1:8080/admin/%2e или http://127.0.0.1:8080/admin/%2e/
Однако . (а также /) в правилах сопоставления путей Spring являются разделителями путей и не сопоставляются как обычные символы. Поэтому по умолчанию доступ к /admin/. возвращает 404.
Но если включён полный путь (setAlwaysUseFullPath(true)), сопоставление работает корректно.

В Shiro получение и сопоставление URL происходит в org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain.
Кратко рассмотрим метод getChain:


Этот метод сначала проверяет, заканчивается ли requestURI на /, и если да, удаляет последний /.
Затем в цикле сопоставления путей сначала проверяется, заканчивается ли правило пути pathPattern на /, и если да, он также удаляется. После этого вызывается метод pathMatches() для сопоставления пути.
Поэтому для обоих методов не имеет значения, заканчивается ли путь на /, так как после прохождения getChain он будет удалён.
Обратим внимание на метод pathMatches():
Вызовем Evaluate и вычислим pathMatches("/admin/*","/admin/1") и pathMatches("/admin/*","/admin/ "). Первый соответствует нормально, второй — нет.


Начинаем отладку. После долгого F7 доходим до doMatch("/admin/*","/admin/ "). Видно, что tokenizeToStringArray вернул pathDirs уже без второго уровня пути. Это приводит к несоответствию /admin/* и /admin.

Следуем за методом tokenizeToStringArray. Обнаруживаем, что при вызове tokenizeToStringArray параметр trimTokens равен true.

В методе tokenizeToStringArray при значении trimTokens true выполняется обработка trim(), что удаляет пробелы. После возврата в getChain последний / удаляется. Поэтому pathDirs, возвращённые tokenizeToStringArray, не содержат второго уровня пути.

Резюме: в уязвимой версии Shiro при вызове tokenizeToStringArray по умолчанию trimTokens равен true, и пробелы удаляются через trim(). После возврата в getChain последний / удаляется, поэтому /admin не соответствует /admin/*, что приводит к обходу аутентификации. В то же время Spring получает путь доступа /admin/%20 и обрабатывает его как обычно, что позволяет обойти авторизацию.
Глядя на второй метод с /. и /./, вспоминаете ли вы знакомый метод? Да, это normalize().

Кратко переведём:
| Условие | Пример |
|---|---|
| Обработка обратной косой черты в прямую | \ -> / |
| Двойной слеш обрабатывается как один |
Таким образом, /admin/. сначала обрабатывается в /admin/./, затем становится /admin/.

После обработки в org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain последний / удаляется, и получается /admin. /admin не соответствует /admin/*, поэтому аутентификация Shiro обходится.

В то же время Spring получает запрос /admin/.. Если полный путь не включён, в Spring . и / являются разделителями путей и не участвуют в сопоставлении. Поэтому маппинг не находится и возвращается 404.

Если включён полный путь, Spring сопоставляет весь URL и возвращает 200.
Здесь приведён код для включения полного пути:
@SpringBootApplication
public class SpringbootShiroApplication extends SpringBootServletInitializer implements BeanPostProcessor {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(SpringbootShiroApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(SpringbootShiroApplication.class, args);
}
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName)
throws BeansException {
if (bean instanceof RequestMappingHandlerMapping) {
((RequestMappingHandlerMapping) bean).setAlwaysUseFullPath(true);
}
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName)
throws BeansException {
return bean;
}
}
На основе анализа можно выделить две причины обхода авторизации в Shiro:
tokenizeToStringArray некорректно обрабатывает пробелы./ не должна выполняться до цикла сопоставления путей.Официальное исправление:
https://github.com/apache/shiro/commit/0842c27fa72d0da5de0c5723a66d402fe20903df
trimTokens в значение false в tokenizeToStringArray. 
/. Сначала сопоставлять исходный путь, и только если сопоставление не удалось, удалять последний /. 
Теоретически trim() удаляет все пробельные символы в начале и конце строки; пробел — лишь один из них. Однако в тестах другие пробельные символы, такие как %08, %09, %0a, при обработке Spring+Tomcat возвращают 400.
Таким образом, для первого метода, кроме пробела, других полезных payload пока не обнаружено.
https://github.com/apache/shiro/commit/0842c27fa72d0da5de0c5723a66d402fe20903df
| // -> / |
| Если заканчивается на /. или /.., добавляем / в конце | /. -> /./ /.. -> /../ |
| Нормализация /./ | /./ -> / |
| Прыжок по пути | /aaa/../bbb -> /bbb |