0. 背景

准备对最近频发的 ApacheShiro 做个简单的分析和总结,从 1.2.4 开始统计,之前的古老版本就不看了。

1. Deserialization

1.1 shiro-550 AKA CVE-2016-4437

已经写过了:https://lightless.me/archives/java-unserialization-apache-shiro.html

1.2 shiro-721 AKA CVE-2019-12422

已经写过了:https://lightless.me/archives/padding-oracle-attacks.html


2. Authentication Bypass

shiro-??? AKA CVE-2016-6802

影响范围:< 1.3.2

由于这个版本还没有 shiro-spring-boot-starter,只能从GitHub上获取项目进行测试;

git clone https://github.com/apache/shiro.git shiro-root
cd shiro-root && git fetch
git checkout shiro-root-1.3.1

接下来还需要在 samples/web/pom.xml 文件中,将 jstl 的版本指定为 1.2。这样就可以正常部署 sample-web 项目了,测试用的 Tomcat 版本为 8.5.34

关于这个漏洞,网上公开的资料非常少,唯二能找到的有用的就是 GitHub 上的 commit 记录,以及 CVE 的描述信息。

  • GitHub commit 信息:https://github.com/apache/shiro/commit/b15ab927709ca18ea4a02538be01919a19ab65af
  • CVE 描述:https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-6802

根据 commit 信息,可以定位到缺陷是在 WebUtils.java 中的 getContextPath 方法。

CVE 描述如下:

Apache Shiro before 1.3.2 allows attackers to bypass intended servlet filters and gain access by leveraging use of a non-root servlet context path.

根据这个 CVE 描述以及 commit 信息,我们可以知道,漏洞是出现在 Non-ROOT ContextPath 的情况下,获取 ContextPath 时造成的,不过详细信息还无从得知。

既然是与 ContextPath 相关,所以部署样例项目的时候,需要部署到某个 ContextPath 下面,不能使用 ROOT。我们先在 getContextPath 函数的入口处 247 行打个断点,并且发送一个正常的需要登录才能访问的请求看一下这附近代码的处理逻辑;

curl -v "http://localhost:8080/sample_web_war/account/index.jsp"

断下来之后可以看到,这里就是取了 ContextPath,并无特殊操作,我们跟着 return 继续向下跟。如果不了解 ContextPath,建议先自行Google一下。

跟到 org.apache.shiro.web.util.WebUtils#getPathWithinApplication 这个函数,可以发现,我们刚刚获取的 ContextPath 其实是为了计算出 RequestURI ,参考画线的两行代码;

继续一直跟到了 org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver#getChain 这个函数中,可以看到这里最终其实是获取了 RequestURI 的,这里稍微单步跟一下,不难发现 103 行附近的 for 循环,是用我们获取到 RequestURI 进行一个匹配处理,对匹配到情况执行相应的操作。
我们这里请求的是 account/index.jsp,刚好匹配到了 /account/** 这个模式。在配置文件(shiro.ini)中,/account/** 这个目录是需要登录后的用户才能访问的,猜测这里后续的操作则是校验当前用户是否已经登录,或者说是否有权限;

这里直接 F9 放行,可以发现确实 302 跳转到了登录页面。这样一来,思路就清晰了,出问题的地方是 ContextPath,而 ContextPath 是用来生成 RequestURI 的,而 RequestURI 又是决定这个请求是否需要鉴权的。所以我们只需要构造出畸形的 ContextPath,使其生成出畸形的 RequestURI,就可以绕过 ApacheShiro 的鉴权机制了。

那么应当如何构造呢?根据 commit 的补丁代码,可以看到是是进行了解码和 normalize 操作,那肯定是因为有非 normal 的路径导致的,猜测只需要构造 /x/../ 这样的路径,让其取到错误的 ContextPath 即可。

curl --path-as-is -v "http://localhost:8080/x/../sample_web_war/account/index.jsp"

重新断下来之后,可以看到 ContextPath 的值已经变成 /x/../sample_web_war 了,再向下跟一下,看看计算出的 RequestURI 是什么?经过 normalize 和解码之后,RequestURI 的值为 /sample_web_war/account/index.jsp

继续向下跟到刚刚看到过的匹配部分,不难看出现在的 RequestURI 已经无法正常匹配到 /account/** 这个模式了,也就不会进入校验是否登录的阶段,我们把这次请求放行之后看看是否正常。

成功的返回了需要登录之后才能看到的页面内容。主要还是因为对 ContextPathRequestURI 处理不一致导致的。升级到 1.3.2 后,漏洞已被修复。

shiro-682 AKA CVE-2020-1957

影响版本:< 1.5.2

时间来到 2020 年,Apache Shiro 框架又被爆出存在权限绕过的问题,我们先尝试不去读其他人的分析文章,独立从相关信息里分析出漏洞。可以找到的信息有三个,分别是 CVE 信息,GitHub commit,以及 ApacheShiroJIRA 上的单子:

  • CVE信息:https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1957
  • GitHub commit:https://github.com/apache/shiro/commit/e61a29cd6ad4724cf9d85c463103c01f6bafdc44
  • SHIRO JIRA:https://issues.apache.org/jira/browse/SHIRO-682

根据这些信息来看,可以很明显的知道,这个漏洞是与 SpringWeb 结合使用时产生的。

in spring web, the requestURI : /resource/menus and resource/menus/ both can access the resource,
but the pathPattern match /resource/menus can not match resource/menus/
user can use requestURI + "/" to simply bypassed chain filter, to bypassed shiro protect

令人迷惑的一点是,这个修复的 commit 是在 1.5.0 版本修复的,而 CVE 的公告里则是说 < 1.5.2 均存在问题,猜测中间的这几个版本中可能存在绕过的情况或者进行了其他的修复,但是这中间的 commit 过多,一时没有找到,决定先分析已经发现的这个问题。

研究这个漏洞时,我们用 SpringBoot 搭建一个环境,具体如何搭建这里不再赘述,使用 Spring Boot 2.3.4 + Shiro 1.4.2 版本进行测试,测试用的代码见:https://github.com/lightless233/ApacheShiroVulnExample
这个项目里的接口如下:

  • /index 无需登录,可以直接访问;
  • /account/index 登录后才可以访问,未登录状态下访问会自动跳转到 /login 页面;
  • /login 登录接口,访问 /login?username=lightless&password=123456 即可登录;

根据漏洞的描述,和我们在分析上个漏洞时得到的经验(URL处理不一致的问题),可以知道是在处理 /account/index/account/index/ 时的问题,末尾多了一个 / 导致的。对于 SpringBoot 来说,末尾是否有 / 在大多数情况下,会指向同一个 controller,可以进入相同的业务逻辑里。但是对于 ApacheShiro 来说,情况却不一样了。

  • ?:匹配单个字符
  • *:匹配多个字符
  • **:匹配多个路径

如果 ApacheShiro 有类似这样的配置:

Map<String, String> map = new HashMap<>();
map.put("/account/*", "authc");
bean.setFilterChainDefinitionMap(map);

那么 ApacheShiro 在处理鉴权的时候,只能匹配到 /account/index 这种请求,而对于 /account/index/ 这种认为是路径形式,从而不被单个星号匹配,而到了 SpringBoot 的层面,会认为这两个 URL 是等效的,从而绕过了鉴权控制;

把我们的项目跑起来,测试一下:

$ curl -v http://localhost:8080/account/index
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /account/index HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 302
< Set-Cookie: JSESSIONID=3F5892664C5AB4D734491B4DE20F94E3; Path=/; HttpOnly
< Location: http://localhost:8080/login;jsessionid=3F5892664C5AB4D734491B4DE20F94E3
< Content-Length: 0
< Date: Wed, 30 Sep 2020 02:21:03 GMT
<
* Connection #0 to host localhost left intact
* Closing connection 0

$ curl -v http://localhost:8080/account/index/
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /account/index/ HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 14
< Date: Wed, 30 Sep 2020 02:21:00 GMT
<
* Connection #0 to host localhost left intact
Login Success.* Closing connection 0

可以看到已经成功的绕过了登录限制,这部分原理还是比较简单的,甚至不需要调试代码就能明白。在 1.5.0 中的修复代码也非常简单,就是先去掉末尾的 / 再进行判断,从而修复了该问题。

但是问题来了,版本小于 1.5.2 的绕过又是怎么回事?经过不懈努力,终于在 1.5.2 发布的前一次提交中,找到了端倪:https://github.com/apache/shiro/commit/3708d7907016bf2fa12691dff6ff0def1249b8ce

在这次提交中,对我们的老朋友 WebUtils 类中获取 RequestURI 的方式进行了变更,不再直接通过 Servlet 提供的 API 获取了,而是取了 URL 中的各种信息进行的拼接。除此之外还添加了看起来很像绕过鉴权控制的测试样例。我们前面知道了这个 RequestURI 是用来匹配哪些请求需要鉴权用的,也就是说,直接取的情况下,可能会出现和其他地方表现不一致的情况,从而导致绕过。看到这里,想到了之前在工作中遇到的一种情况,就是利用类似于添加分号的形式进行绕过,也是与 RequestURI 相关。我们直接按照猜想对现在的这个版本测试一下。

curl --path-as-is "http://localhost:8080/account;/index"
Login Success.

这里其实就是涉及到了 getRequestURI 函数的问题,我们调试一下看看情况,首先还是在 org.apache.shiro.web.util.WebUtils#getPathWithinApplication 这里下断点:

发送请求 /account;/index,断下来后,跟到 112 行的 getRequestUri 函数中看一下,会发现取到的 RequestURI 就是原始的 URI,经过处理之后,返回了 /account

接下来进行路径匹配的时候,就会发现,/account/* 和当前的 RequestURI 是无法匹配的,也就绕过了鉴权控制;

如果我们传入一个正常的 URI 进行调试,例如 /account/index,会发现这里获取到的 requestURI 应该为 /account/index,那么是什么导致了我们取了错误的 URI 呢?很明显元凶就在 decodeAndCleanUriString 这个函数中,这里将分号后面的字符全部都丢弃了,因为 ApacheShiro 似乎认为分号后面的东西都是多余的,但是为什么呢?
经过了一段时间的研究,发现这段移除分号的代码是在 2009 年完成的,而且那次 commit 还是从 SVN 迁移的记录,具体原因已经无法考证了。但是经过一些推测,猜测是为了移除 URL 后面拼接的 JSESSIONID 。参考:https://stackoverflow.com/questions/1045668/jsessionid-is-occurred-in-all-urls-which-are-generated-by-jstl-curl-tag 和 http://shiro-user.582556.n2.nabble.com/Removing-JSESSIONID-xxx-from-the-url-after-login-td7579370.html

但是问题还是没有完全解开,为什么 /account;/index 这样的代码可以被 Spring 识别并且调用到正确的 controller 上呢?
经过一番调试,在 org.apache.catalina.connector.CoyoteAdapter#parsePathParameters 这里找到了 Tomcat 对 URI 中分号的处理,将分号到最近一个 / 之间的内容删除掉,目的是为了解析出 path parameters。这样一来,我们传入的 /account;/index 就变成了 /account/index 正常的 URI,并且接下来的代码中将这个值设置到 request.servletPath 中。

不过分析完成后,发现和网上流传的 PoC 并不一致,并且网上流传的 PoC 本地无法复现成功,虽然绕过了 ApacheShiro 的限制,但是会返回 404 页面,并没有匹配到正确的 controller 。猜测是 Spring 的版本问题。当把 SpringBoot 的版本降低到 1.5.22.RELEASE 后,发现网上流传的 /xxx/..;/account/index 可以正常使用了。简单分析了一下,主要是因为在某个 SpringBoot 版本之后,对路由查找方式发生了变化。将 ApacheShiro 升级到 1.5.2 版本后,两种利用方式均已失效;

SpringBoot 2.3.4 版本中,在 RequestMappingHandlerMapping 类中,是通过 /x/../account/index 去查找路由的,那么一定是找不到的。

而在 SpringBoot 1.5.22 中,是通过 /account/index 来进行路由查找的;

在测试的时候,还发现了一点,当 ApacheShiro 配置的鉴权控制为 /account/** 时,/account;/index 是无法绕过的,而 /x/..;/account/index 是可以正常使用的。总结如下(为什么是 2.3.x 后面将会说明):

  • 当 SpringBoot 版本小于 2.3.x 时
    • 当 Shiro 鉴权配置为 /account/**
      • /x/..;/account/index 可用
      • /account;/index 凉凉
    • 当 Shiro 鉴权配置为 /account/*
      • /x/..;/account/index 可用
      • /account;/index 可用
  • 当 SpringBoot 版本为 2.3.x 时
    • 当 Shiro 鉴权配置为 /account/**
      • /x/..;/account/index 凉凉
      • /account;/index 凉凉
    • 当 Shiro 鉴权配置为 /account/*
      • /x/..;/account/index 凉凉
      • /account;/index 可用

shiro-782 AKA CVE-2020-11989

影响版本:< 1.5.3

在测试上一个漏洞的过程中,无意中发现 /;/account/index 形式的请求也可以绕过鉴权,但是将 ApacheShiro 升级到 1.5.2 后就失效了。后来查了一下其他人的分析才知道,这就是这个 CVE-2020-11989PoC

老样子,我们先上 GitHub 查一查 commit 记录,看看是否有其他的修复。

  • CVE 信息:https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-11989
  • SHIRO JIRA:https://issues.apache.org/jira/projects/SHIRO/issues/SHIRO-782?filter=allissues

很幸运,从 1.5.2 到 1.5.3 之间的 commit 并不多,很容易就找到了相关的修复 commit:https://github.com/apache/shiro/commit/b90f91875e5e18c4805013c2fa0567b1700f5a96
这次 commit 信息量很大,我们仔细来看看。
首先是官方也意识到了行为不一致而带来的问题,直接大改了 getPathWithinApplication 方法的逻辑,不再取 requestURI 然后自行计算了,而是直接使用了 servletPathPathInfo 进行处理。紧接着在 commit 的信息中,发现了 SHIRO-753 又提到了一个双重编码的问题,难道这个也能导致绕过吗?

SHIRO-753 中,问题描述也非常清晰了:

In Shiro 1.5.2, WebUtils.getRequestURI() no longer support paths with '%' character in it
In Shiro 1.5.1, when the path is "A%B" then the String URI retrieved from request.getRequestURI() returns "A%25B" which is properly decoded afterward by the decodeAndCleanUriString method.
In Shiro 1.5.2, when the path is "A%B" then the String URI reconstructed from context+path+pathInfo returns "A%B" (it's already decoded) which crashes when calling decodeAndCleanUriString

看起来确实是双重编码导致的绕过。

先来分析这个编码的问题吧,但是一开始是懵逼的,并不知道需要对哪部分进行 URL 编码。先随便编一个,然后下断点到 getPathWithinApplication 看看情况。
请求 /account/%252findex ,正常拦截,跳转了登录页。
请求 /account/%25%32%66index,正常拦截,跳转了登录页。
虽然获取到的 requestURI 看起来不怎么正常,但是经过格式化之后,就正常了。

一开始完全就是瞎尝试,后来仔细思考了一下这个流程,是这样的:

  1. 拼接 URI -> decodeAndCleanUriString -> normalize -> 得到最终的 RequestURI
  2. decodeAndCleanUriString 这个函数中,会主动对拼接出的 URI 再进行一次解码。

也就是说,我们可以利用这个特性,向最终的 RequestURI 中插入一些想保留的字符。比如我们依然想使用 /account;/index 这个方法绕过鉴权控制,那么对分号进行二次编码后试一下,会发现最终格式化好的 RequestURI/account,也就是说,如果此时的权控配置的是 /account/* 的时候,就可以绕过权限控制了。

调整完配置后断下来看看,确实绕过了鉴权,但是无法正常进入到业务逻辑中,毕竟 Spring 不会进行二次解码,无法正常识别路由。

这样一来,就有了一个限制条件,用于匹配路由部分的 URI 必须是完好的不能经过二次编码的,同时还要让鉴权失效,听起来感觉不太可能。
但是如果鉴权策略采用的是 /account/* 形式,那么后面如果是个路径的话,鉴权就会失效,例如 /account/aaa/index,那是不是构造出 /account/aaa%252findex就可以了呢?

$ curl -v --path-as-is "http://localhost:8080/account/x%252findex"
{"timestamp":"2020-09-30T09:06:07.018+00:00","status":404,"error":"Not Found","message":"","path":"/account/x/index"}* Closing connection 0

确实已经绕过了鉴权,进入了 Spring 处理的阶段,不过因为找不到路由而返回了404。如果业务上真的存在 /account/xxxyyy/index 这种路由,那么鉴权配置肯定会采用 /account/** 的形式,除非配置错误,但是这种错误很容易在开发阶段就被发现。那么就只剩下一种情况了,考虑 xxxyyy/index 是一个参数并且鉴权配置采用了 /account/* 的情况,就可以完美绕过了,即通过 SpringBoot@PathVariable 的方式获取参数。

接下来分析另外一个修复,用我们一开始可以使用的 PoC 调试一下,即 /;/account/index
1.4.2 版本中,ApacheShiro 使用的是 request.getRequestURI() 方法,所以分号得以保留了下来,并在后续的 decodeAndCleanUriString 方法中被处理了,从而绕过了鉴权。

而在 1.5.2 版本中,不再使用 request.getRequestURI() 方法了,所以分号也不会被保留下来了,在格式化之后,就变成了正常的 URI,PoC 也就失效了。

先来仔细分析下,为什么现在这个 PoC 不能用了。当发送 /;/account/index 的时候,拼接出的 URI 是这个样子的:

  • request.getContextPath(): ""
  • request.getServletPath(): "/account/index"
  • request.getPathInfo(): ""

最终拼接结果为://account/index,请求中的分号不见了,根据前面分析出来的结果可以知道,是因为 Tomcat 在处理的请求的时候,将分号相关的内容删掉了,所以我们这里是拿不到分号的,所以也就没办法欺骗 decodeAndCleanUriString 根据分号来错误的截断 URI 了。

聪明的同学已经想到了,既然 ServletPath 没办法留住分号,可以通过 ContextPath 呀。确实是可以,在 SpringBoot 的环境下需要手动配置一下 ContextPath。在 application.properties 中添加如下内容:

server.context-path=/context               # SpringBoot 1.x 添加此行
server.servlet.context-path=/context    # SpringBoot 2.x 添加此行

context 前面添加一个分号试试,理论上 Tomcat 应该是不会处理这部分的数据的。

curl.exe --path-as-is -v "http://localhost:8080/;/context/account/index"

拼接出的 URI 为 /;/context//account/index ,那么经过处理之后,肯定就剩下 / 了。

绕过了鉴权控制,接下来的事情就是看看 SpringBoot 的路由选择问题了,看看这个畸形的 URI 能否正确的匹配到路由。
把断点打到 org.springframework.web.servlet.DispatcherServlet#doDispatch 方法上,这其中有一处关键的调用 mappedHandler = getHandler(processedRequest); 需要跟进。

跟入之后会发现,lookupPath 的值为 /account/path,很明显是可以匹配到路由的,自然可以走到正常的业务逻辑中去。

到这里这个漏洞其实已经分析完了,但是还有一个遗留问题没有解决,那就是这个利用是否和 SpringBoot 版本相关?经过测试得到如下结果:

  • 在 SpringBoot 1.x 中(1.5.22.RELEASE),可以正常利用;
  • 在 SpringBoot 2.0.x 中(2.0.9RELEASE),可以正常利用;
  • 在 SpringBoot 2.1.x 中(2.1.17.RELEASE),可以正常使用;
  • 在 SpringBoot 2.2.x 中(2.2.10.RELEASE),可以正常使用;
  • 在 SpringBoot 2.3.x 中(2.3.4.RELEASE),无法正常使用;

至于出现这种情况的原因,应该也很容易猜到,那就是 SpringBoot 2.3.x 中,修改了匹配路由时的匹配方法,取到了不同的 URI
先来看一下 SpringBoot 2.3.x 之前版本的逻辑,这里面经过了一系列复杂的处理之后,最终返回了 ServletPath,过程比较简单,就不再赘述了。

而在 SpringBoot 2.3.x 的时候,this.alwaysUseFullPathtrue,会导致函数提前返回。根据 GitHub 上的一个 ISSUE:https://github.com/spring-projects/spring-boot/issues/21499 可以知道是为了更好的适配 Servlet4.0 而做的变更,在 Release Notes 中也能找到相关的说明,这也解释了为什么我们的 PoC 均无法在 SpringBoot 2.3.x 环境下正常运行。

shiro-??? AKA CVE-2020-13933

  • CVE 信息:https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-13933

影响版本:< 1.6.0

在 commit 信息中并没有找到什么有用的信息,唯一值得留意的是升级了 Spring 相关的版本,修改、新增了几个 Filter
先将 ApacheShiro 升级到 1.5.3 看一下修复上一个漏洞之后的代码是什么样的。

只剩下 ServletPathPathInfo 了,看起来似乎无懈可击的样子,我们之前利用的 ContextPath 也被干掉了,但是实际上呢?很明显移除分号的逻辑还在,那能否再利用这个逻辑进行一下绕过呢?

根据前面的经验,当我们的鉴权控制为 /account/* 的时候,只需要控制这个函数返回 /account/ 即可绕过鉴权,同时只要有接收 @PathVariable 形式的接口,那么就可以正常访问。既然他这里要删除分号之后的内容,只要构造出 /account/;xxxx 这样的请求就可以了。但是需要注意,这里的分号需要编码一下,否则会被(Tomcat?)直接移除掉;

如果上面的几个漏洞都跟下来了,那么这个漏洞还是很容易理解的,就不再赘述了。1.6.0 中新增的 Filter,就是用于修复这个漏洞的,在 Filter 中将一些特殊字符移除了。

ApacheShiro 绕过总结

说到底,所有的鉴权控制绕过都是因为多方标准不一致而导致的,这个问题 Orange 在很久以前就已经讲过了,当时并没有理解的那么深刻,也没有引起重视,随着近些时间以来不断增多的绕过问题,重新分析后才恍然大悟。

这些漏洞当中,有些利用条件都非常的苛刻,并非可以无脑的随便打。还需要结合具体情况进行分析使用;

3. 参考文献

  • https://shiro.apache.org/security-reports.html