之前遇到了一个JEECMS大概看了一下, 测试版本JEECMSV9.3
SSRF
/src/main/java/com/jeecms/cms/action/member/UeditorAct.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
"/ueditor/getRemoteImage.jspx")
(value =
public void getRemoteImage(HttpServletRequest request,
HttpServletResponse response)
throws Exception {
String url = request.getParameter(
"upfile");
CmsSite site=CmsUtils.getSite(request);
JSONObject json =
new JSONObject();
String[] arr = url.split(UE_SEPARATE_UE);
String[] outSrc =
new String[arr.length];
for (
int i =
0; i < arr.length; i++) {
outSrc[i]=saveRemoteImage(arr[i], site.getContextPath(), site.getUploadPath());
}
String outstr =
"";
for (
int i =
0; i < outSrc.length; i++) {
outstr += outSrc[i] + UE_SEPARATE_UE;
}
outstr = outstr.substring(
0, outstr.lastIndexOf(UE_SEPARATE_UE));
json.put(URL, outstr);
json.put(SRC_URL, url);
json.put(TIP, LocalizedMessages.getRemoteImageSuccessSpecified(request));
ResponseUtils.renderJson(response, json.toString());
}
|
在接受了用户传递过来的url之后, 带入saveRemoteImage方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
private String saveRemoteImage(String imgUrl,String contextPath,String uploadPath) {
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
CloseableHttpClient client = httpClientBuilder.build();
String outFileName=
"";
try{
if(endWithImg(imgUrl)){
HttpGet httpget =
new HttpGet(
new URI(imgUrl));
HttpResponse response = client.execute(httpget);
InputStream is =
null;
OutputStream os =
null;
HttpEntity entity =
null;
entity = response.getEntity();
is = entity.getContent();
outFileName=UploadUtils.generateFilename(uploadPath, FileNameUtils.getFileSufix(imgUrl));
os =
new FileOutputStream(realPathResolver.get(outFileName));
IOUtils.copy(is, os);
}
|
在saveRemoteImage方法当中, 如果通过了endWithImg方法的检测,就直接发起请求, 并且把请求到的结果输出到文件当中。
1
2
3
4
5
6
7
8
9
|
private boolean endWithImg(String imgUrl){
if(StringUtils.isNotBlank(imgUrl)&&(imgUrl.endsWith(
".bmp")||imgUrl.endsWith(
".gif")
||imgUrl.endsWith(
".jpeg")||imgUrl.endsWith(
".jpg")
||imgUrl.endsWith(
".png"))){
return
true;
}
else{
return
false;
}
}
|
endWithImg的检测比较简单, 绕过也比较简单加个?.jpg就可以绕过了。
不过本地测试时, 访问这个jpg文件的结果却是404.
首先来看看保存访问结果的文件的文件名生成方法, 是包含一个月份目录的。
1
2
3
4
|
public static String generateFilename(String path, String ext) {
return path + MONTH_FORMAT.format(
new Date())
+ RandomStringUtils.random(
4, Num62.N36_CHARS) +
"." + ext;
}
|
结果类似为 /u/cms/www/201902/15002619t400.jpg
而在jeecms的默认源码当中, 是不存在201902这个目录的。
并且在saveRemoteImage方法当中, 并没有”判断这个目录存不存在,如果不存在的话就创建该目录”这种逻辑。
在FileOutputStream时, 如果目录是不存在的话, 会出异常, 所以这里的文件并没有保存上。
要想保存上这个文件, 首先还是得创建这个目录。
在
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
"/ueditor/upload.jspx",method = RequestMethod.POST)
(value =
public void upload(
@RequestParam(value =
"Type", required =
false) String typeStr,
Boolean mark,
HttpServletRequest request, HttpServletResponse response)
throws Exception {
responseInit(response);
if (Utils.isEmpty(typeStr)) {
typeStr =
"File";
}
if(mark==
null){
mark=
false;
}
JSONObject json =
new JSONObject();
JSONObject ob = validateUpload(request, typeStr);
if (ob ==
null) {
json = doUpload(request, typeStr, mark);
}
else {
json = ob;
}
ResponseUtils.renderJson(response, json.toString());
}
|
直接查看调用的doUpload方法,
1
2
3
4
5
6
|
private JSONObject doUpload(HttpServletRequest request, String typeStr,Boolean mark) throws Exception {
.......
else {
fileUrl = fileRepository.storeByExt(site.getUploadPath(),
ext, uplFile);
}
|
继续查看storeByExt方法
1
2
3
4
5
6
7
8
9
10
11
|
public String storeByExt(String path, String ext, MultipartFile file)
throws IOException {
//String filename = UploadUtils.generateFilename(path, ext);
//File dest = new File(getRealPath(filename));
String fileName=UploadUtils.generateRamdonFilename(ext);
String fileUrl =path+fileName;
File dest =
new File(getRealPath(path),fileName);
dest = UploadUtils.getUniqueFile(dest);
store(file, dest);
return fileUrl;
}
|
文件名和目录的生成方法和saveRemoteImage时使用的方法相同,然后调用了store方法。
1
2
3
4
5
6
7
8
9
|
private void store(MultipartFile file, File dest) throws IOException {
try {
UploadUtils.checkDirAndCreate(dest.getParentFile());
file.transferTo(dest);
}
catch (IOException e) {
log.error(
"Transfer file error when upload file", e);
throw e;
}
}
|
1
2
3
4
|
public static void checkDirAndCreate(File dir) {
if (!dir.exists())
dir.mkdirs();
}
|
可以看到虽然在下载远程图片的功能中, 没有”如果不存在这个日期目录就创建该目录”这个逻辑, 但是在上传的时候存在这个逻辑。 所以可以先通过上传, 创建了该目录之后, 再继续给SSRF利用。
上传这个功能, 需要登录之后才能正常使用。
因为在doupload方法之前,
1
2
3
4
5
6
|
JSONObject ob = validateUpload(request, typeStr);
if (ob ==
null) {
json = doUpload(request, typeStr, mark);
}
else {
json = ob;
}
|
经过了validateUpload方法, 在该方法当中
1
2
3
4
5
6
7
|
CmsUser user = CmsUtils.getUser(request);
// 非允许的后缀
if (!user.isAllowSuffix(ext)) {
result.put(STATE, LocalizedMessages
.getInvalidFileSuffixSpecified(request));
return result;
}
|
如果是未登录状态, user为null 接下来就会出现空指针异常。
不过发起请求的httpClientBuilder, 仅支持HTTP/HTTPS协议。
SSTI
JEECMS中存在一些可以上传任意文件的点, 只举例一个
/src/main/java/com/jeecms/cms/action/member/SwfUploadAct.java
1
2
3
4
5
6
7
8
9
|
"/member/o_swfAttachsUpload.jspx", method = RequestMethod.POST)
(value =
public void swfAttachsUpload(
String root,
Integer uploadNum,
@RequestParam(value =
"Filedata", required =
false) MultipartFile file,
HttpServletRequest request, HttpServletResponse response,
ModelMap model)
throws Exception{
super.swfAttachsUpload(root, uploadNum, file, request, response, model);
}
|
调用了父类的swfAttachsUpload方法,
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
|
protected void swfAttachsUpload(
String root,
Integer uploadNum,
@RequestParam(value =
"Filedata", required =
false) MultipartFile file,
HttpServletRequest request, HttpServletResponse response,
ModelMap model)
throws Exception {
JSONObject data=
new JSONObject();
WebCoreErrors errors = validateUpload( file, request);
if (errors.hasErrors()) {
data.put(
"error", errors.getErrors().get(
0));
ResponseUtils.renderJson(response, data.toString());
}
else{
CmsSite site = CmsUtils.getSite(request);
String ctx = request.getContextPath();
String origName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(origName).toLowerCase(
Locale.ENGLISH);
// TODO 检查允许上传的后缀
String fileUrl=
"";
try {
if (site.getConfig().getUploadToDb()) {
String dbFilePath = site.getConfig().getDbFileUri();
fileUrl = dbFileMng.storeByExt(site.getUploadPath(), ext, file
.getInputStream());
// 加上访问地址
fileUrl = request.getContextPath() + dbFilePath + fileUrl;
}
else
if (site.getUploadFtp() !=
null) {
Ftp ftp = site.getUploadFtp();
String ftpUrl = ftp.getUrl();
fileUrl = ftp.storeByExt(site.getUploadPath(), ext, file
.getInputStream());
// 加上url前缀
fileUrl = ftpUrl + fileUrl;
}
else
if (site.getUploadOss() !=
null) {
CmsOss oss = site.getUploadOss();
fileUrl = oss.storeByExt(site.getUploadPath(), ext, file.getInputStream());
}
else {
fileUrl = fileRepository.storeByExt(site.getUploadPath(), ext,
file);
// 加上部署路径
fileUrl = ctx + fileUrl;
}
cmsUserMng.updateUploadSize(CmsUtils.getUserId(request), Integer.parseInt(String.valueOf(file.getSize()/
1024)));
fileMng.saveFileByPath(fileUrl, origName,
false);
model.addAttribute(
"attachmentPath", fileUrl);
}
catch (IllegalStateException e) {
model.addAttribute(
"error", e.getMessage());
}
catch (IOException e) {
model.addAttribute(
"error", e.getMessage());
}
data.put(
"attachUrl", fileUrl);
data.put(
"attachName", origName);
ResponseUtils.renderJson(response, data.toString());
}
}
|
从TODO注释中也能看出来, 检查允许上传的后缀这个功能还未实现就直接上线了。
不过在jeecms中上传的jsp,jspx文件并不能被访问到。
1
2
3
4
5
6
7
8
|
<servlet-mapping>
<servlet-name>JeeCmsFront
servlet-name>
<url-pattern>*.jspx
url-pattern>
servlet-mapping>
<servlet-mapping>
<servlet-name>JeeCmsFront
servlet-name>
<url-pattern>*.jsp
url-pattern>
servlet-mapping>
|
jsp和jspx文件都经过了JeeCmsFront,
1
2
3
4
5
6
7
8
9
10
11
12
|
<servlet>
<servlet-name>JeeCmsFront
servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet
servlet-class>
<init-param>
<param-name>contextConfigLocation
param-name>
<param-value>
/WEB-INF/config/jeecms-servlet-front.xml
/WEB-INF/config/plug/**/*-servlet-front-action.xml
param-value>
init-param>
<load-on-startup>2
load-on-startup>
servlet>
|
jsp和jspx文件都会经过org.springframework.web.servlet.DispatcherServlet, 上传上去的jsp文件肯定是没有对应的映射的 就直接404了。
这里得结合一些其他的点进行利用,
/src/main/java/com/jeecms/cms/action/front/CsiCustomAct.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
"/csi_custom*.jspx")
(value =
public String custom(String tpl, HttpServletRequest request,
HttpServletResponse response, ModelMap model) {
log.debug(
"visit csi custom template: {}", tpl);
CmsSite site = CmsUtils.getSite(request);
if(StringUtils.isNotBlank(tpl)){
// 将request中所有参数保存至model中。
model.putAll(RequestUtils.getQueryParams(request));
FrontUtils.frontData(request, model, site);
FrontUtils.frontPageData(request, model);
return FrontUtils.getTplPath(site.getSolutionPath(), TPLDIR_CSI_CUSTOM,
tpl);
}
else{
return FrontUtils.pageNotFound(request, response, model);
}
}
|
可以看到将用户传递过来的tpl变量直接带入了getTplPath方法,
1
2
3
|
public static String getTplPath(String solution, String dir, String name) {
return solution +
"/" + dir +
"/" + name + TPL_SUFFIX;
}
|
可控的tpl变量直接拼接进了模板路径当中,
1
|
public
static
final String TPL_SUFFIX =
".html";
|
默认的模板后缀为.html, 高版本jdk当中已经不再能够截断, 所以这里先通过刚才的任意文件上传一个.html文件, 然后控制模板文件路径为自己上传的模板文件进行SSTI.
因为jeecms的模板引擎使用的是freemarker, 一开始以为直接用freemarker的SSTI就能rce了, 但是测试的时候失败了。
1
|
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }
|
在新版本freemarker中, 多了一个TemplateClassResolver.SAFER_RESOLVER配置。
TemplateClassResolver.SAFER_RESOLVER now disallows creating freemarker.template.utility.JythonRuntime and freemarker.template.utility.Execute. This change affects the behavior of the new built-in if FreeMarker was configured to use SAFER_RESOLVER, which is not the default until 2.4 and is hence improbable.
1
2
3
4
5
6
7
8
9
10
11
12
13
|
TemplateClassResolver SAFER_RESOLVER =
new TemplateClassResolver() {
public Class resolve(String className, Environment env, Template template) throws TemplateException {
if (!className.equals(ObjectConstructor.class.getName()) && !className.equals(Execute.class.getName()) && !className.equals(
"freemarker.template.utility.JythonRuntime")) {
try {
return ClassUtil.forName(className);
}
catch (ClassNotFoundException var5) {
throw
new _MiscTemplateException(var5, env);
}
}
else {
throw MessageUtil.newInstantiatingClassNotAllowedException(className, env);
}
}
}
|
如果使用了TemplateClassResolver.SAFER_RESOLVER, 就不允许再调用freemarker.template.utility.Execute, freemarker.template.utility.ObjectConstructor以及freemarker.template.utility.JythonRuntime。
1
2
3
4
5
6
|
public ConstructorFunction(String classname, Environment env, Template template) throws TemplateException {
this.env = env;
this.cl = env.getNewBuiltinClassResolver().resolve(classname, env, template);
if (!TemplateModel.class.isAssignableFrom(
this.cl)) {
throw
new _MiscTemplateException(NewBI.
this, env,
new Object[]{
"Class ",
this.cl.getName(),
" does not implement freemarker.template.TemplateModel"});
}
|
并且允许调用的类只允许为实现了freemarker.template.TemplateModel接口的类, 大概看了下实现了该接口的类, 除了不允许使用的三个类,没有找到其他能利用的类, 就只有放弃RCE了。
从文档中可以看出, freemarker从2.4版本以后才默认打开TemplateClassResolver.SAFER_RESOLVER, jeecms使用的版本为
1
|
<freemarker.version>2.3.25-incubating
freemarker.version>
|
虽然没有默认打开该配置, 但是JEECMS中的freemarker手动打开了TemplateClassResolver.SAFER_RESOLVER,所以SSTI没办法RCE了。
1
2
3
4
5
6
7
8
9
10
11
|
protected void initApplicationContext() throws BeansException {
super.initApplicationContext();
if (getConfiguration() ==
null) {
FreeMarkerConfig config = autodetectConfiguration();
Configuration configuration=config.getConfiguration();
configuration.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
setConfiguration(configuration);
}
checkTemplate();
}
|
在TemplateClassResolver.SAFER_RESOLVER的限制下, SSTI也就只能读读文件了, 并且只能读取WEB目录下的文件。
反序列
JEECMS中使用了shiro, 版本为
1
|
<shiro.version>1.4.0
shiro.version>
|
老版本shiro(1.2.4)曾爆过一个反序列,
看了一下maven下载的1.4.0的shiro包, 依然存在反序列的点
1
2
3
4
5
6
7
|
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (
this.getCipherService() !=
null) {
bytes =
this.decrypt(bytes);
}
return
this.deserialize(bytes);
}
|
经过decrypt, aes解密之后就开始反序列了。
1
2
3
|
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
return (PrincipalCollection)
this.getSerializer().deserialize(serializedIdentity);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
|
public T deserialize(byte[] serialized) throws SerializationException {
if (serialized ==
null) {
String msg =
"argument cannot be null.";
throw
new IllegalArgumentException(msg);
}
else {
ByteArrayInputStream bais =
new ByteArrayInputStream(serialized);
BufferedInputStream bis =
new BufferedInputStream(bais);
try {
ObjectInputStream ois =
new ClassResolvingObjectInputStream(bis);
T deserialized = ois.readObject();
ois.close();
|
高版本shiro只是没有在AbstractRememberMeManager中硬编码了AES的key, 但是在JEECMS当中, 又再次硬编码了AES的key
/src/main/webapp/WEB-INF/config/shiro-context.xml
1
2
3
4
5
|
<bean id="rememberMeManager" class="org.apache.shiro.web.mgt.CookieRememberMeManager">
<property name="cipherKey" value="#{T(org.apache.shiro.codec.Base64).decode('4AvVhmFLUs0KTA3Kprsdag==')}"/>
<property name="cookie" ref="rememberMeCookie"/>
bean>
|
直接使用这个AES key就能打反序列了。
看了下JEECMS的jar包, 打反序列版本比较合适的为C3P0的jar包。
JEECMS的C3P0包版本和ysoserial自带的C3P0包版本相同。
1
|
<c3p0.version>0.9.5.2
c3p0.version>
|
一开始不知道C3P0这gadget到底是咋用, 看了下代码。
/com/mchange/c3p0/0.9.5.2/c3p0-0.9.5.2.jar!/com/mchange/v2/c3p0/impl/PoolBackedDataSourceBase.class
1
2
3
4
5
6
7
8
|
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
short version = ois.readShort();
switch(version) {
case
1:
Object o = ois.readObject();
if (o
instanceof IndirectlySerialized) {
o = ((IndirectlySerialized)o).getObject();
}
|
继续调用getObject方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public Object getObject() throws ClassNotFoundException, IOException {
try {
InitialContext var1;
if (
this.env ==
null) {
var1 =
new InitialContext();
}
else {
var1 =
new InitialContext(
this.env);
}
Context var2 =
null;
if (
this.contextName !=
null) {
var2 = (Context)var1.lookup(
this.contextName);
}
return ReferenceableUtils.referenceToObject(
this.reference,
this.name, var2,
this.env);
|
调用referenceToObject方法,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
public static Object referenceToObject(Reference var0, Name var1, Context var2, Hashtable var3) throws NamingException {
try {
String var4 = var0.getFactoryClassName();
String var11 = var0.getFactoryClassLocation();
ClassLoader var6 = Thread.currentThread().getContextClassLoader();
if (var6 ==
null) {
var6 = ReferenceableUtils.class.getClassLoader();
}
Object var7;
if (var11 ==
null) {
var7 = var6;
}
else {
URL var8 =
new URL(var11);
var7 =
new URLClassLoader(
new URL[]{var8}, var6);
}
Class var12 = Class.forName(var4,
true, (ClassLoader)var7);
ObjectFactory var9 = (ObjectFactory)var12.newInstance();
return var9.getObjectInstance(var0, var1, var2, var3);
|
通过URLClassLoader获取远程jar包中的类, 然后classforname后, newInstance实例化该类, 调用构造方法。
明明yso的C3P0版本和jeecms的一样, 但是还是提示suid错误。
因为jeecms中依赖了quartz-scheduler包, 这个包又依赖了0.9.1.1的c3p0. 反序列的时候调用的是老版本的C3P0的包。(这里我也不太懂我本地为什么调用的是老版本的包, 按理maven解决依赖冲突时 优先最短路径优先, 应该调用的是0.9.5.2包。并且高版本的C3P0依赖在前,有大哥懂为啥调用老版本的jar包的麻烦教我一手。)
这时候ysoserial的C3P0版本和jeecms的版本就不相同了 suid就不同了, 这里直接修改一下ysoserial的C3P0版本,
text变量的字符串为ysoserial生成的C3P0 payload base64编码,
References
1.https://freemarker.apache.org/docs/versions_2_3_19.html
2.https://portswigger.net/blog/server-side-template-injection