Reproducing Nimbuspwn: Linux Privilege Escalation via Path Traversal and TOCTOU in networkd-dispatcher

A technical walkthrough of reproducing Nimbuspwn (CVE-2022-29799 and CVE-2022-29800), privilege escalation vulnerabilities in networkd-dispatcher exploiting path traversal and TOCTOU race conditions via D-Bus signals to gain root access on Linux systems.

November 14, 2025 November 9, 2025 Research
Author Author Hung Nguyen Tuong

In the last post where I took some notes and reproduced CVE-2020-8831, a vulnerability in the lock file implementation of Apport that allows an attacker to create a world-writable root-owned file anywhere on the filesystem. I learned about what IPC is, what some of its methods are, and why it’s often considered a vector for privilege escalation exploits.

For further practice and deeper understanding of local attack vectors, taking the author’s recommendation, I attempted to reproduce the Nimbuspwn collection of vulnerabilities discovered by the Microsoft 365 Defender Research Team, including a path traversal and a time-of-check to time-of-use race condition issues, which can lead to privilege escalation to root in many desktop-oriented Linux distributions, such as Ubuntu, Linux Mint, etc.

So in this blog post, I’ll be demonstrating how I think these vulnerabilities can be discovered through code reviews (sink-to-source analysis) and dynamic analysis, and how I exploited them to gain root access on the target system. I’ll try my best to make this as detailed as possible, and of course, with some help of AI :)

Overview

Nimbuspwn is a collection of vulnerabilities that Microsoft discovered in a systemd unit called networkd-dispatcher by monitoring messages on the system bus while also performing code reviews and dynamic analysis on services running as root.

The two vulnerabilities are identified as CVE-2022-29799 and CVE-2022-29800. You can take a look at the original blog post from Microsoft here: Microsoft finds new elevation of privilege Linux vulnerability, Nimbuspwn | Microsoft Security Blog.

Background

Note

I made these below italic since I prompted Claude to explain the background knowledge, and I think it did great :)

D-Bus

What is D-Bus?

D-Bus stands for Desktop Bus. It’s a message-passing system that lets programs talk to each other on Linux and Unix systems.

Red Hat developers created it in the early 2000s for the freedesktop.org project, aiming to replace older communication systems and provide a standard way for desktop applications to communicate.

D-Bus is the default inter-process communication (IPC) system on most modern Linux distributions, especially those using GNOME or KDE desktop environments. It’s also used in systemd and many system services.

Communication methods
The system supports two main communication methods: method calls (request-response pattern, like asking a question and getting an answer) and signals (broadcast messages, like announcing something happened).
System bus & Session bus

There are two separate buses: the system bus and the session bus. The system bus handles system-wide services like network management, hardware detection, and system configuration - it runs with elevated privileges. The session bus is per-user and handles desktop applications like notification systems and media players.

The system bus is more attractive to attackers because it runs with root privileges and controls critical system functions. Exploiting it can give attackers system-wide control, while the session bus only affects individual user sessions with limited privileges.

D-Bus name ownership

D-Bus name ownership is how programs identify themselves on the bus. Every connection gets a unique connection name automatically, looking like :1.42 - this is temporary and changes each time the program connects.

Programs can also request well-known names (also called bus names), which are human-readable like org.freedesktop.NetworkManager or org.gnome.Shell. These are identifiable names that stay consistent, so other programs know exactly who to talk to.

To get well-known names, programs must request ownership from the bus daemon. By default, any program can try to own any name, but policy files in /usr/share/dbus-1/ control who actually succeeds. These XML configuration files specify which users or services can own specific names.

The rules are different between buses. On the system bus, strict policies limit name ownership - typically only root or specific system users can own important names like org.freedesktop.NetworkManager. On the session bus, the current user’s applications can generally own names freely since it’s less security-critical.

A well-known name can only be owned by one connection at a time. If another program requests an already-owned name, it either fails or queues to take over if the current owner disconnects. This single-owner rule prevents confusion about who’s actually providing a service.

D-Bus naming structure

D-Bus uses three separate naming systems that work together:

  1. Bus Names (with dots) - Identify which service you’re connecting to. Example: org.freedesktop.network1 is the systemd-networkd service. Think of this as the company name or service provider. Only one program can own each bus name at a time.

  2. Object Paths (with slashes) - Identify specific resources inside a service. Example: /org/freedesktop/network1/link/_3 points to a particular network interface. These are like addresses showing where things are located. Using the programming analogy: these are concrete instances - actual objects that exist and hold data. Each path represents a different instance (like different objects created from a class).

  3. Interface Names (with dots) - Define what type an object is and what capabilities it has. Example: org.freedesktop.network1.Link specifies that an object is a network link with specific methods and properties. This is like an abstract class or contract - it defines what features must be available but doesn’t represent an actual instance.

How they work together: The service org.freedesktop.network1 (bus name) contains multiple objects at paths like /org/freedesktop/network1/link/_3 (concrete instances). Each object implements the org.freedesktop.network1.Link interface (abstract specification), meaning it provides all the methods and properties that interface defines.

networkd-dispatcher

Before understanding networkd-dispatcher, we need to know these things first:

systemd
systemd is the init system and service manager used by most modern Linux distributions. It starts services, manages processes, and handles system initialization during boot.
systemd-networkd

systemd-network is a system user account (not a real person) that runs network-related services with limited privileges for security.

systemd-networkd is a system service (daemon) that manages network configuration - it handles network interfaces, IP addresses, routing, and basic network setup. It runs as the systemd-network user.

systemd-networkd’s D-Bus name is org.freedesktop.network1.

networkd-dispatcher

networkd-dispatcher is a separate service that acts as a dispatcher for network events. It monitors systemd-networkd for network state changes (like interface up/down, connection established) and runs scripts in response. Think of it as an event handler that lets administrators execute custom actions when network conditions change.

They communicate through the system bus because both are system-level services managing critical infrastructure. systemd-networkd sends D-Bus signals announcing network events, and networkd-dispatcher listens for these signals to trigger appropriate scripts.

networkd-dispatcher doesn’t typically own a well-known name - it acts as a client/listener rather than providing a service others call.

Sink-to-Source Analysis

Firstly, let’s search for the vulnerable version of networkd-dispatcher and download its source code for a sink-to-source analysis (that I learned in the first chapter of the book).

I searched for the vulnerabilities on the Ubuntu security update page and found the patched version (USN-5395-1: networkd-dispatcher vulnerabilities | Ubuntu security notices | Ubuntu). In order to get the vulnerable version, I’ll just have to look for the version right before the patched one.

Here I’m targeting the vulnerable version on Ubuntu 20.04 LTS. Therefore, the vulnerable version should be older than 2.1-2~ubuntu20.04.2. Next, I looked at the publishing history of the networkd-dispatcher package here: Publishing history : networkd-dispatcher package : Ubuntu

This must be the vulnerable version, let’s download it and examine the codebase:

ubuntu@hungnt-PC:~$ wget https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/networkd-dispatcher/2.1-2~ubuntu20.04.1/networkd-dispatcher_2.1.orig.tar.gz
ubuntu@hungnt-PC:~$ tar -xvzf networkd-dispatcher_2.1.orig.tar.gz
ubuntu@hungnt-PC:~$ cd networkd-dispatcher-2.1/
ubuntu@hungnt-PC:~/networkd-dispatcher-2.1$ ls -la
total 108
drwxr-xr-x  3 ubuntu ubuntu  4096 Apr 30  2020 .
drwxr-x--- 16 ubuntu ubuntu  4096 Nov  9 09:07 ..
-rw-r--r--  1 ubuntu ubuntu    56 Apr 30  2020 .gitignore
-rw-r--r--  1 ubuntu ubuntu  1402 Apr 30  2020 .gitlab-ci.yml
-rw-r--r--  1 ubuntu ubuntu 35141 Apr 30  2020 LICENSE
-rw-r--r--  1 ubuntu ubuntu   174 Apr 30  2020 Makefile
-rw-r--r--  1 ubuntu ubuntu  5877 Apr 30  2020 README.md
-rw-r--r--  1 ubuntu ubuntu    34 Apr 30  2020 __init__.py
-rwxr-xr-x  1 ubuntu ubuntu 18253 Apr 30  2020 networkd-dispatcher
-rw-r--r--  1 ubuntu ubuntu   130 Apr 30  2020 networkd-dispatcher.conf
-rw-r--r--  1 ubuntu ubuntu   266 Apr 30  2020 networkd-dispatcher.service
-rw-r--r--  1 ubuntu ubuntu  2655 Apr 30  2020 networkd-dispatcher.txt
lrwxrwxrwx  1 ubuntu ubuntu    19 Apr 30  2020 networkd_dispatcher.py -> networkd-dispatcher
drwxr-xr-x  3 ubuntu ubuntu  4096 Apr 30  2020 tests
-rw-r--r--  1 ubuntu ubuntu   518 Apr 30  2020 tox.ini

Based on the background knowledge, networkd-dispatcher acts as a dispatcher for network events, which receives signals from systemd-networkd and runs the corresponding scripts, and the scripts are stored on the filesystem. Besides that, networkd-dispatcher is written in Python. Therefore, I think we should look for sinks that have something to do with listing and reading files, or running system commands. Here, I asked Claude for a few common dangerous Python sinks that match my assumption:

Dangerous Python sinks

System commands:

  • os.system() - executes shell commands directly
  • subprocess.call(), subprocess.run(), subprocess.Popen() - especially with shell=True
  • eval(), exec() - execute arbitrary Python code
  • __import__() - dynamically imports modules
  • pickle.loads(), pickle.load() - deserialize objects (code execution)
  • yaml.load() - unsafe YAML parsing (use safe_load() instead)

Filesystem handling:

  • open() - read/write arbitrary files
  • os.remove(), os.unlink() - delete files
  • os.rmdir(), shutil.rmtree() - delete directories
  • os.rename(), os.replace() - move/overwrite files
  • os.chmod(), os.chown() - modify permissions/ownership
  • shutil.copy(), shutil.move() - copy/move files
  • tempfile.mktemp() - creates predictable temp files (race condition)

Now let’s grep for some of these sinks:

ubuntu@hungnt-PC:~/networkd-dispatcher-2.1$ grep -rn "os.system(" .
ubuntu@hungnt-PC:~/networkd-dispatcher-2.1$ grep -rn "subprocess.call(" .
ubuntu@hungnt-PC:~/networkd-dispatcher-2.1$ grep -rn "subprocess.run(" .
ubuntu@hungnt-PC:~/networkd-dispatcher-2.1$ grep -rn "subprocess.Popen(" .
./networkd-dispatcher:341:            ret = subprocess.Popen(script, env=script_env).wait()
ubuntu@hungnt-PC:~/networkd-dispatcher-2.1$ grep -rn "eval(" .
ubuntu@hungnt-PC:~/networkd-dispatcher-2.1$ grep -rn "exec(" .
ubuntu@hungnt-PC:~/networkd-dispatcher-2.1$ grep -rn "open(" .
./tests/test_networkd-dispatcher.py:28:    with open(in_file, 'rb') as fh:
./networkd-dispatcher:341:            ret = subprocess.Popen(script, env=script_env).wait()

There’s a call for subprocess.Popen() at line 341 in the networkd-dispatcher’s source code. Let’s check it out.

subprocess.Popen() is executing the scripts in script_list with some predefined environment variables. script_list is returned from self.get_scripts_list(state) where state is a parameter of the run_hooks_for_state() function.

Next, we examine how script_list is created inside get_scripts_list().

get_scripts_list() calls and returns the result of scripts_in_path() while passing in the arguments self.script_dir (which comes from the command-line argument or by default is set to the constant DEFAULT_SCRIPT_DIR = '/etc/networkd-dispatcher:/usr/lib/networkd-dispatcher' at line 436) and state + ".d". So, the script list is created by gathering scripts stored in these state + ".d" directories:

Inside scripts_in_path(), first it gathers all the scripts’ filenames into the base_filenames set, then, for each filename in the set, it checks if the script still exists, is owned by root and can be executed, finally appending the full path of the script to script_list.

With further review, we can tell that this function might have some security issues. If the passed-in subdir (state + ".d") contains path traversal patterns (such as ../../), scripts’ filenames can be gathered from unintended directories. Besides that, when appending to the script_list, os.path.isfile() and os.stat() don’t validate whether pathname is a symlink and happily follow it. We then take a look back at the run_hooks_for_state(), subprocess.Popen() also follows the symlink and executes the scripts directly. So there’s a time-of-check to time-of-use race condition happening here, meaning there’s a tiny time window between the scripts discovery and the scripts execution. If an attacker were able to quickly switch the symlink to their controlled directory within this time window, the program could mistakenly execute arbitrary scripts that aren’t owned by root with root privilege, since networkd-dispatcher is run as root.

Now, let’s continue tracing back to where run_hooks_for_state() is called to see how the state argument is processed so that we might be able to figure out how to control it. There’s only one call in the _handle_one_state() function. run_hooks_for_state() is called when state is not None, and force is True or state has changed (different from the prior_state).

_handle_one_state() is called twice inside handle_state, once for the administrative_state, the other for operational_state.

Continuing to trace back, we see that there are 2 calls to handle_state(). Let’s inspect the first call in trigger_all().

It iterates through all the interfaces in ifaces_by_name.items() and calls handle_state() on them with the corresponding arguments. We might continue to inspect ifaces_by_name.items() to see if we can control it, thus controlling the states passed to handle_state(). But let’s take a look at where trigger_all() is called before doing that.

From what I understand, trigger_all() serves in the initial state of the program, it’s called only once when networkd-dispatcher is first started with the flag -T or --run-startup-triggers specified. Therefore, I think even if we’re able to control ifaces_by_name.items(), we might not be able to call trigger_all() again to have handle_state() executed. Unless, of course, we have the required permission to run systemctl restart networkd-dispatcher.

For now, let’s assume that this might not be our attack vector, and we’ll move on to the second call of handle_state() in _receive_signal().

For better understanding of _receive_signal(), we take a look at register(), where _receive_signal() is registered as a callback handler for D-Bus PropertiesChanged signals coming from org.freedesktop.network1. And this registration happens at the start of the program.

So, _receive_signal() is called whenever there’s a PropertiesChanged signal from systemd-networkd. And in order for handle_state() to be executed, there are some conditions that need to be met:

  • First, the signal must come from a link object (path = /org/freedesktop/network1/link_) that implements a Link interface (typ = org.freedesktop.network1.link).
  • Second, the interface index idx after decoding must exist in iface_names_by_idx[].
  • Finally, operational_state or administrative_state must not be none.

Now we’ve found the complete path from the source (_receive_signal()) to the sink (subprocess.Popen()). I summarized it as follows:

  • _receive_signal() receives D-Bus PropertiesChanged signals from a valid Link object, then calls handle_state() if operational_state or administrative_state is not none.
  • handle_state() calls _handle_one_state() on both operational_state and administrative_state with force = False.
  • _handle_one_state() calls run_hooks_for_state() if the state has changed but is not None.
  • run_hooks_for_state() first builds a script list with get_scripts_list(state).
  • get_scripts_list() goes to scripts_in_path(), first discovering the scripts’ filenames, then keeping the scripts that are owned by root and can be executed.
  • run_hooks_for_state() finally calls subprocess.Popen() to execute scripts in the script list.

We can notice that there aren’t any sanitizations applied to the state anywhere on this path. Therefore, if an attacker somehow has owned the D-Bus name org.freedesktop.network1 from systemd-networkd, they could craft a malicious signal whose OperationalState contains a path traversal pattern to their controlled directory and broadcast it on the system bus. Upon receiving the signal, networkd-dispatcher builds the script list from un unintended directory. The attacker could immediately race the symlink right after that to trick subprocess.Popen() into executing arbitrary scripts.

Exploitation

So here’s our plan assuming we’ve owned the D-Bus name of systemd-networkd (I copied it from the original post and prompted Claude to make it more readable):

  1. Create a directory /tmp/nimbuspwn and plant a symlink /tmp/nimbuspwn/poc.d pointing to /sbin. The /sbin directory was chosen specifically because it contains many executables owned by root that don’t block when run without additional arguments. This will abuse the symlink race issue we mentioned earlier.
  2. For every executable filename under /sbin that’s owned by root, plant the same filename under /tmp/nimbuspwn. For example, if /sbin/vgs is executable and owned by root, plant an executable file /tmp/nimbuspwn/vgs with the desired payload. This helps the attacker win the race condition imposed by the TOCTOU vulnerability.
  3. Send a signal with OperationalState set to ../../../tmp/nimbuspwn/poc. This abuses the directory traversal vulnerability and escapes the script directory.
  4. The networkd-dispatcher signal handler kicks in and builds the script list from the directory /etc/networkd-dispatcher/../../../tmp/nimbuspwn/poc.d, which resolves to the symlink (/tmp/nimbuspwn/poc.d), which points to /sbin. Therefore, it creates a list composed of many executables owned by root.
  5. Quickly change the symlink /tmp/nimbuspwn/poc.d to point to /tmp/nimbuspwn. This abuses the TOCTOU race condition vulnerability—the script path changes without networkd-dispatcher being aware.
  6. The dispatcher starts running files that were initially under /sbin but are actually under the /tmp/nimbuspwn directory. Since the dispatcher “believes” those files are owned by root, it executes them blindly with subprocess.Popen() as root. Therefore, the attacker has successfully exploited the vulnerability.

Note that to win the TOCTOU race condition with high probability, we plant more files that can potentially run. Also, since we don’t wish to run the exploit every time we want to gain root privileges, the payload that we ended up implementing leaves a root backdoor as follows:

  1. Copies /bin/sh to /tmp/sh.
  2. Turns the new /tmp/sh into a Set-UID (SUID) binary.
  3. Runs /tmp/sh -p. The -p flag is necessary since modern shells drop privileges by design.

Here’s the flow-chart of the attack described in 3 stages from the original post:

Testing Environment

The exploitation assumes that the attacker can somehow own the D-Bus name of systemd-networkd.

Doing some research with Perplexity, I found out that most desktop-oriented Linux distros don’t run systemd-networkd by default. Instead, they often use NetworkManager as the default network management daemon. Although systemd-networkd is usually installed with systemd, it remains disabled on desktops. In this case, the D-Bus name org.freedesktop.network1 is not owned by any running process. On the other hand, many modern server-oriented Linux distros do make systemd-networkd the default network management solution out of the box. Therefore, to set up a testing environment, I’m going to use an Ubuntu Desktop 20.04 VMware instance running inside a Windows host.

We also need to have the systemd-network user’s permission to actually own the D-Bus name. In the original post, they mentioned that in some Linux environments, they were able to spot several processes running as the systemd-network user executing code from world-writable locations. But since we’re trying to reproduce the vulnerabilities, I think it’s okay to just use sudo -u systemd-network to own the D-Bus name.

Alright, I’ve set up the instance and it’s running here and we need to ensure something. First, the dbus service is running:

ubuntu@ubuntu:~$ sudo systemctl status dbus
● dbus.service - D-Bus System Message Bus
     Loaded: loaded (/lib/systemd/system/dbus.service; static; vendor preset: enabled)
     Active: active (running) since Sun 2025-11-09 16:19:32 PST; 5min ago
TriggeredBy: ● dbus.socket
       Docs: man:dbus-daemon(1)
   Main PID: 768 (dbus-daemon)
      Tasks: 1 (limit: 4534)
     Memory: 3.8M
     CGroup: /system.slice/dbus.service
             └─768 /usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only

Second, systemd-networkd is disabled (it is, by default), and the D-Bus name is not owned by any processes.

ubuntu@ubuntu:~$ sudo systemctl status systemd-networkd
● systemd-networkd.service - Network Service
     Loaded: loaded (/lib/systemd/system/systemd-networkd.service; disabled; vendor preset: enabled)
     Active: inactive (dead)
       Docs: man:systemd-networkd.service(8)
ubuntu@ubuntu:~$ busctl status org.freedesktop.network1
Failed to get credentials: No such device or address

Finally, the system is running a vulnerable version of networkd-dispatcher. We should be able to downgrade the current version to a vulnerable one without any issues.

ubuntu@ubuntu:~$ sudo systemctl status networkd-dispatcher
● networkd-dispatcher.service - Dispatcher daemon for systemd-networkd
     Loaded: loaded (/lib/systemd/system/networkd-dispatcher.service; enabled; vendor preset: enabled)
     Active: active (running) since Sun 2025-11-09 16:19:33 PST; 6min ago
   Main PID: 778 (networkd-dispat)
      Tasks: 1 (limit: 4534)
     Memory: 18.9M
     CGroup: /system.slice/networkd-dispatcher.service
             └─778 /usr/bin/python3 /usr/bin/networkd-dispatcher --run-startup-triggers
ubuntu@ubuntu:~$ dpkg -l | grep networkd-dispatcher
ii  networkd-dispatcher                        2.1-2~ubuntu20.04.3                  all          Dispatcher service for systemd-networkd connection status changes

Stop the service before downgrading:

ubuntu@ubuntu:~$ sudo systemctl stop networkd-dispatcher
ubuntu@ubuntu:~$ sudo systemctl status networkd-dispatcher
● networkd-dispatcher.service - Dispatcher daemon for systemd-networkd
     Loaded: loaded (/lib/systemd/system/networkd-dispatcher.service; enabled; vendor preset: enabled)
     Active: inactive (dead) since Sun 2025-11-09 16:32:40 PST; 7s ago
    Process: 778 ExecStart=/usr/bin/networkd-dispatcher $networkd_dispatcher_args (code=killed, signal=TERM)
   Main PID: 778 (code=killed, signal=TERM)
ubuntu@ubuntu:/tmp$ wget https://launchpad.net/ubuntu/+source/networkd-dispatcher/2.1-2~ubuntu20.04.1/+build/21782892/+files/networkd-dispatcher_2.1-2~ubuntu20.04.1_all.deb
ubuntu@ubuntu:/tmp$ sudo dpkg -i networkd-dispatcher_2.1-2~ubuntu20.04.1_all.deb
ubuntu@ubuntu:/tmp$ dpkg -l | grep networkd-dispatcher
ii  networkd-dispatcher                   2.1-2~ubuntu20.04.1               all          Dispatcher service for systemd-networkd connection status changes

Restart the service:

ubuntu@ubuntu:/tmp$ sudo systemctl start networkd-dispatcher
ubuntu@ubuntu:/tmp$ sudo systemctl status networkd-dispatcher
● networkd-dispatcher.service - Dispatcher daemon for systemd-networkd
     Loaded: loaded (/lib/systemd/system/networkd-dispatcher.service; enabled; vendor preset: enabled)
     Active: active (running) since Sun 2025-11-09 16:33:50 PST; 24s ago
   Main PID: 2260 (networkd-dispat)
      Tasks: 1 (limit: 4534)
     Memory: 8.2M
     CGroup: /system.slice/networkd-dispatcher.service
             └─2260 /usr/bin/python3 /usr/bin/networkd-dispatcher --run-startup-triggers

Building the PoC

Now that we’ve finished setting up our testing environment, let’s build a proof-of-concept. Since we need to craft a malicious signal, we first need to know its format to be able to craft a valid one. So let’s enable systemd-networkd, mess with the network, and then monitor messages on the system bus to capture a valid signal.

ubuntu@ubuntu:/tmp$ sudo systemctl start systemd-networkd
ubuntu@ubuntu:/tmp$ sudo systemctl status systemd-networkd
● systemd-networkd.service - Network Service
     Loaded: loaded (/lib/systemd/system/systemd-networkd.service; disabled; vendor preset: enabled)
     Active: active (running) since Sun 2025-11-09 18:23:50 PST; 6s ago
       Docs: man:systemd-networkd.service(8)
   Main PID: 3944 (systemd-network)
     Status: "Processing requests..."
      Tasks: 1 (limit: 4534)
     Memory: 1.3M
     CGroup: /system.slice/systemd-networkd.service
             └─3944 /lib/systemd/systemd-networkd

Start capturing the signal:

ubuntu@ubuntu:/tmp$ sudo dbus-monitor --system "sender='org.freedesktop.network1'"

Turn off the network:

Great, we now know the signal’s format!

ubuntu@ubuntu:/tmp$ sudo dbus-monitor --system "sender='org.freedesktop.network1'"
signal time=1762741837.168134 sender=org.freedesktop.DBus -> destination=:1.147 serial=2 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameAcquired
   string ":1.147"
signal time=1762741837.168152 sender=org.freedesktop.DBus -> destination=:1.147 serial=4 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameLost
   string ":1.147"
signal time=1762741841.445399 sender=:1.141 -> destination=(null destination) serial=19 path=/org/freedesktop/network1/link/_32; interface=org.freedesktop.DBus.Properties; member=PropertiesChanged
   string "org.freedesktop.network1.Link"
   array [
      dict entry(
         string "AddressState"
         variant             string "off"
      )
      dict entry(
         string "OperationalState"
         variant             string "carrier"
      )
   ]
   array [
   ]

In the signal we’ve just seen, the OperationalState is carrier. So, let’s place an example script in /etc/networkd-dispatcher/carrier.d to see if networkd-dispatcher actually executes it. Remember to make it executable and owned by root.

ubuntu@ubuntu:/etc/networkd-dispatcher/carrier.d$ echo -e '#!/bin/sh\nlogger "TEST SCRIPT EXECUTED!"' | sudo tee ./test
#!/bin/sh
logger "TEST SCRIPT EXECUTED!"
ubuntu@ubuntu:/etc/networkd-dispatcher/carrier.d$ sudo chown root:root test
ubuntu@ubuntu:/etc/networkd-dispatcher/carrier.d$ sudo chmod 755 test
ubuntu@ubuntu:/etc/networkd-dispatcher/carrier.d$ ls -la test
-rwxr-xr-x 1 root root 41 Nov  9 19:22 test
ubuntu@ubuntu:/etc/networkd-dispatcher/carrier.d$

Now let’s turn on the network, run journalctl to monitor system logs, then we’ll turn off the network again:

ubuntu@ubuntu:/etc/networkd-dispatcher/carrier.d$ sudo journalctl -f
-- Logs begin at Sun 2025-11-09 07:06:25 PST. --
Nov 09 19:23:43 ubuntu NetworkManager[770]: <info>  [1762745023.5751] dhcp4 (ens33): state changed bound -> done
Nov 09 19:23:43 ubuntu avahi-daemon[764]: Withdrawing address record for 192.168.198.132 on ens33.
Nov 09 19:23:43 ubuntu avahi-daemon[764]: Leaving mDNS multicast group on interface ens33.IPv4 with address 192.168.198.132.
Nov 09 19:23:43 ubuntu NetworkManager[770]: <info>  [1762745023.5779] manager: NetworkManager state is now DISCONNECTED
Nov 09 19:23:43 ubuntu avahi-daemon[764]: Interface ens33.IPv4 no longer relevant for mDNS.
Nov 09 19:23:43 ubuntu whoopsie[924]: [19:23:43] Cannot reach: https://daisy.ubuntu.com
Nov 09 19:23:43 ubuntu nm-dispatcher[5305]: run-parts: failed to stat component /etc/network/if-post-down.d/avahi-daemon: No such file or directory
Nov 09 19:23:43 ubuntu root[5313]: TEST SCRIPT EXECUTED! # <----- HERE!!!
Nov 09 19:23:45 ubuntu sudo[5316]:   ubuntu : TTY=pts/0 ; PWD=/etc/networkd-dispatcher/carrier.d ; USER=root ; COMMAND=/usr/bin/journalctl -f
Nov 09 19:23:45 ubuntu sudo[5316]: pam_unix(sudo:session): session opened for user root by (uid=0)

Alright, we’ve made sure that everything works, let’s disable systemd-networkd and check for the D-Bus name again:

ubuntu@ubuntu:/tmp$ sudo systemctl stop systemd-networkd
ubuntu@ubuntu:/tmp$ sudo systemctl status systemd-networkd
● systemd-networkd.service - Network Service
     Loaded: loaded (/lib/systemd/system/systemd-networkd.service; disabled; vendor preset: enabled)
     Active: inactive (dead) since Sun 2025-11-09 19:29:33 PST; 3s ago
       Docs: man:systemd-networkd.service(8)
    Process: 3944 ExecStart=/lib/systemd/systemd-networkd (code=exited, status=0/SUCCESS)
   Main PID: 3944 (code=exited, status=0/SUCCESS)
     Status: "Shutting down..."
ubuntu@ubuntu:/tmp$ busctl status org.freedesktop.network1
Failed to get credentials: No such device or address

Since I’ve explained the vulnerabilities and the exploit in such detail, there shouldn’t be any problem implementing it. Here’s an example PoC that I made with some help from Claude:

nimbuspwn-CVE-2022-29800-CVE-2022-29799/exploit.py at main · ngtuonghung/nimbuspwn-CVE-2022-29800-CVE-2022-29799

Demonstrating the PoC

I also made a youtube video demonstrating the exploit, you can watch it here:

Conclusion

Wow, I’m finally done writing this post, it does take some amount of effort :). In the process of reproducing the vulnerabilities and detailing them in this post, I learned a lot! If you actually spent time reading the entire post, thank you so much. I do hope that you’ve learned something too, and found it useful, or at least somewhat interesting. If you have any questions or feedback, please let me know in the comment section down here. Thank you for your time!

See you in the future post.