diff --git a/cmd/micromize/root.go b/cmd/micromize/root.go index 5b89ca6..3224223 100644 --- a/cmd/micromize/root.go +++ b/cmd/micromize/root.go @@ -42,14 +42,33 @@ const ( ) var ( - enforce bool - verbose bool - filterNamespaces string - filterImageDigest string - disableGadgets string - exemptLabel string + enforce bool + verbose bool + filterNamespaces string + filterImageDigest string + disableGadgets string + exemptLabel string + socketDenyFamilies string + socketDenyNetlinkProtocols string ) +// defaultSocketDenyFamilies is the conservative set of socket address +// families denied out of the box by the configurable layer of socket-restrict. +// It targets families that are essentially never used by cloud-native +// application containers but periodically ship kernel LPEs. AF_ALG, AF_KEY and +// AF_NETLINK/XFRM are always blocked by the gadget's hardcoded layer and are +// intentionally omitted here. AF_PACKET (MetalLB, keepalived, tcpdump-in-pod, +// kube-proxy IPVS, Cilium) and AF_VSOCK (firecracker/kata) are also excluded — +// operators that want them blocked can opt-in via --socket-deny-families. +const defaultSocketDenyFamilies = "AF_TIPC,AF_RDS,AF_SMC,AF_CAN,AF_NFC,AF_BLUETOOTH,AF_AX25,AF_ATMPVC,AF_ATMSVC,AF_X25,AF_KCM,AF_CAIF" + +// defaultSocketDenyNetlinkProtocols is intentionally empty: blocking +// NETLINK_NETFILTER would break iptables-nft / nf_tables-based CNI plugins +// (Istio CNI, kube-proxy nft, etc.). NETLINK_XFRM is already blocked by the +// gadget's hardcoded layer. Operators concerned about nf_tables LPEs can +// opt-in via --socket-deny-netlink-protocols=NETLINK_NETFILTER. +const defaultSocketDenyNetlinkProtocols = "" + var rootCmd = &cobra.Command{ Use: "micromize", Short: "micromize is a security hardening tool for containerized applications", @@ -77,6 +96,8 @@ func init() { rootCmd.PersistentFlags().StringVar(&filterImageDigest, "filter-image-digest", "", "Filter out containers running this image digest from monitoring (e.g. sha256:abc123...)") rootCmd.PersistentFlags().StringVar(&disableGadgets, "disable-gadgets", "", "Comma-separated list of gadgets to disable (e.g. ptrace-restrict,cap-restrict)") rootCmd.PersistentFlags().StringVar(&exemptLabel, "exempt-label", "micromize.dev/exempt", "Kubernetes label key used to mark namespaces as exempt from monitoring (value must be 'true'). Set to empty string to disable. Changes take effect on restart.") + rootCmd.PersistentFlags().StringVar(&socketDenyFamilies, "socket-deny-families", defaultSocketDenyFamilies, "Comma-separated list of socket address families denied by socket-restrict's configurable layer. Names (e.g. AF_TIPC) or decimal numbers; case-insensitive. AF_ALG/AF_KEY/XFRM are always blocked regardless. Set to empty string to disable the configurable layer.") + rootCmd.PersistentFlags().StringVar(&socketDenyNetlinkProtocols, "socket-deny-netlink-protocols", defaultSocketDenyNetlinkProtocols, "Comma-separated list of additional AF_NETLINK protocols denied by socket-restrict. Defaults to empty (NETLINK_XFRM is always blocked). Use NETLINK_NETFILTER to block nf_tables LPE chains (incompatible with iptables-nft / nf_tables-based CNI).") } func run(ctx context.Context) error { @@ -122,7 +143,20 @@ func run(ctx context.Context) error { return fmt.Errorf("creating local manager operator: %w", err) } - contextManager := gadget.NewContextManager([]operators.DataOperator{ociHandlerOp, localManagerOp, eventTypeOp, outputOp}) + socketDenyFamilyList, err := operators.ParseSocketDenyFamilies(socketDenyFamilies) + if err != nil { + return fmt.Errorf("parsing --socket-deny-families: %w", err) + } + socketDenyNetlinkProtocolList, err := operators.ParseSocketDenyNetlinkProtocols(socketDenyNetlinkProtocols) + if err != nil { + return fmt.Errorf("parsing --socket-deny-netlink-protocols: %w", err) + } + slog.Info("Socket-restrict deny-list", + "families", socketDenyFamilyList, + "netlinkProtocols", socketDenyNetlinkProtocolList) + socketRestrictOp := operators.NewSocketRestrictOperator(socketDenyFamilyList, socketDenyNetlinkProtocolList) + + contextManager := gadget.NewContextManager([]operators.DataOperator{ociHandlerOp, localManagerOp, socketRestrictOp, eventTypeOp, outputOp}) // Create gadget registry registry := gadget.NewRegistry(contextManager, runtimeManager) diff --git a/gadgets/socket-restrict/README.md b/gadgets/socket-restrict/README.md index 723351d..fb2541f 100644 --- a/gadgets/socket-restrict/README.md +++ b/gadgets/socket-restrict/README.md @@ -1,24 +1,79 @@ # socket-restrict -Restrict dangerous socket primitives in containers. +Restrict dangerous socket address families and protocols in containers. -This gadget blocks all `AF_ALG` (kernel crypto userspace API) socket usage -inside containers. `AF_ALG` is rarely needed in containerized production -workloads — most TLS, SSH, and dm-crypt use cases never touch it — and -blocking it eliminates a class of kernel attack surface from the container -boundary. +The gadget enforces in two layers: -The initial motivation is CVE-2026-31431 (Copy Fail), a Linux kernel local -privilege escalation in `algif_aead` that can be triggered via `AF_ALG` -sockets. This gadget blocks the entire killchain at socket creation time, -before any vulnerable kernel path is reached. +1. **Hardcoded, always-on blocks** for the families with active kernel LPEs. + These are enforced regardless of configuration: + - `AF_ALG` — kernel crypto userspace API. Motivated by CVE-2026-31431 + (Copy Fail), a privilege escalation in `algif_aead` reachable via + `AF_ALG` sockets. + - `AF_KEY` — PF_KEY IPsec key management. + - `AF_NETLINK` with protocol `NETLINK_XFRM` — XFRM/IPsec state & policy + configuration. + + `AF_KEY` and `NETLINK_XFRM` are the only ways to configure XFRM/IPsec from + userspace and are the entry point for the DirtyClone killchain + (CVE-2026-43503), a kernel LPE on the XFRM/ESP packet path. Blocking them + at socket creation removes the attack surface before any vulnerable kernel + code is reached. + +2. **A configurable deny-list layer** for additional address families and + `AF_NETLINK` protocols that have no legitimate cloud-native use. This layer + is driven by two BPF maps populated at startup from CLI flags, so operators + can tune coverage without rebuilding the BPF program. + +## Configurable layer + +| Flag | Default | Notes | +|---|---|---| +| `--socket-deny-families` | `AF_TIPC,AF_RDS,AF_SMC,AF_CAN,AF_NFC,AF_BLUETOOTH,AF_AX25,AF_ATMPVC,AF_ATMSVC,AF_X25,AF_KCM,AF_CAIF` | Additional families to deny. Names or decimal numbers, case-insensitive. `AF_ALG`/`AF_KEY`/XFRM are always blocked and need not be listed. | +| `--socket-deny-netlink-protocols` | *(empty)* | Additional `AF_NETLINK` protocols to deny. `NETLINK_XFRM` is always blocked. | + +The defaults are **conservative**: only niche/legacy families with no +realistic cloud-native use are denied out of the box. + +### Opt-in (compatibility-sensitive) + +These are **not** denied by default because they have legitimate uses; enable +them explicitly after validating your workloads (ideally in audit mode, +`--enforce=false`, first): + +| Item | Enable with | Breaks if in use | +|---|---|---| +| `AF_PACKET` | `--socket-deny-families=...,AF_PACKET` | MetalLB, keepalived, tcpdump-in-pod, kube-proxy IPVS, Cilium | +| `AF_VSOCK` | `--socket-deny-families=...,AF_VSOCK` | firecracker / kata-containers agents | +| `NETLINK_NETFILTER` | `--socket-deny-netlink-protocols=NETLINK_NETFILTER` | iptables-nft, kube-proxy nft mode, Istio CNI, nft-based CNIs | + +## Recommended rollout (audit → enforce) + +1. Deploy with `--enforce=false` and the default deny-lists. Watch for + `socket_family_denied_create` / `_bind` events on a representative + workload sample. Defaults should produce ~zero events on a normal data + plane. +2. Opt-in additional families/protocols incrementally, still in audit mode, + validating against your CNI / kube-proxy mode / service mesh / IPsec. +3. Switch to `--enforce=true` once the audit log is clean. + +## Events + +| Event | When | +|---|---| +| `af_alg_socket_create` / `af_alg_socket_bind` | `AF_ALG` (hardcoded) | +| `af_key_socket_create` | `AF_KEY` (hardcoded) | +| `xfrm_netlink_socket_create` | `AF_NETLINK`/`NETLINK_XFRM` (hardcoded) | +| `socket_family_denied_create` / `socket_family_denied_bind` | any family/protocol from the configurable deny-list | ## Hooks | Hook | Purpose | |---|---| -| `lsm/socket_create` | Block `AF_ALG` socket creation (main choke point) | -| `lsm/socket_bind` | Defense-in-depth: block `AF_ALG` bind if a socket FD exists from before policy load. Preserves `alg_type`/`alg_name` for visibility. | +| `lsm/socket_create` | Block denied socket families and `AF_NETLINK` protocols at creation time (main choke point). | +| `lsm/socket_bind` | Defense-in-depth: block denied binds if a socket FD existed before policy load. Preserves `alg_type`/`alg_name` for `AF_ALG` visibility. | + +Both hooks preserve a prior LSM program's deny decision via the `ret` +chaining argument, so socket-restrict never overrides another LSM's block. ## Getting Started diff --git a/gadgets/socket-restrict/gadget.yaml b/gadgets/socket-restrict/gadget.yaml index d3a3dfa..e6a17d4 100644 --- a/gadgets/socket-restrict/gadget.yaml +++ b/gadgets/socket-restrict/gadget.yaml @@ -18,6 +18,9 @@ datasources: family: annotations: description: Socket address family + protocol: + annotations: + description: Socket protocol (e.g. NETLINK_XFRM for AF_NETLINK) process: annotations: description: The process attempting a restricted socket operation diff --git a/gadgets/socket-restrict/program.bpf.c b/gadgets/socket-restrict/program.bpf.c index d6991ea..a1fed4c 100644 --- a/gadgets/socket-restrict/program.bpf.c +++ b/gadgets/socket-restrict/program.bpf.c @@ -5,6 +5,7 @@ #include +#include #include #include #include @@ -16,19 +17,52 @@ GADGET_TRACER_MAP(events, 1024 * 256); GADGET_TRACER(socket_restrict, events, event); +// Runtime-populated deny-list of address families, populated from userspace +// at gadget init from the --socket-deny-families flag. Empty by default means +// the configurable layer is a no-op; the hardcoded AF_ALG/AF_KEY/XFRM blocks +// below always apply regardless of this map. +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, MAX_DENIED_FAMILIES); + __type(key, __u16); + __type(value, __u8); +} denied_families SEC(".maps"); + +// Runtime-populated deny-list of AF_NETLINK protocols, from +// --socket-deny-netlink-protocols. Empty by default. +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, MAX_DENIED_NETLINK_PROTOCOLS); + __type(key, __u32); + __type(value, __u8); +} denied_netlink_protocols SEC(".maps"); + +static __always_inline bool is_family_denied(__u16 family) { + return bpf_map_lookup_elem(&denied_families, &family) != NULL; +} + +static __always_inline bool is_netlink_protocol_denied(__u32 protocol) { + return bpf_map_lookup_elem(&denied_netlink_protocols, &protocol) != NULL; +} + // Block dangerous socket families at creation — the main choke point. // -// - AF_ALG kernel crypto userspace API (CVE-2026-31431, "Copy Fail") -// - AF_KEY PF_KEY IPsec key management -// - AF_NETLINK/XFRM XFRM/IPsec state & policy configuration -// -// AF_KEY and NETLINK_XFRM are the only ways to configure XFRM/IPsec from -// userspace and are the entry point for the DirtyClone killchain -// (CVE-2026-43503), a kernel LPE on the XFRM/ESP packet path. Blocking them -// here removes the attack surface before any vulnerable kernel code is reached. +// Two layers: +// 1. Hardcoded, always-on blocks for the families with active kernel LPEs: +// - AF_ALG kernel crypto userspace API (CVE-2026-31431) +// - AF_KEY PF_KEY IPsec key management +// - AF_NETLINK/XFRM XFRM/IPsec state & policy configuration +// AF_KEY and NETLINK_XFRM are the entry point for the DirtyClone +// killchain (CVE-2026-43503), a kernel LPE on the XFRM/ESP path. +// 2. A configurable deny-list layer (denied_families / +// denied_netlink_protocols maps) for additional families that have no +// legitimate cloud-native use. These emit the generic +// EVENT_TYPE_SOCKET_FAMILY_DENIED_* events. SEC("lsm/socket_create") int BPF_PROG(micromize_socket_create, int family, int type, int protocol, int kern, int ret) { + (void)type; + // Preserve a deny decision from a previously-run LSM program in the chain. if (ret) return ret; @@ -39,13 +73,20 @@ int BPF_PROG(micromize_socket_create, int family, int type, int protocol, if (gadget_should_discard_data_current()) return 0; + __u16 fam = (__u16)family; + __u32 socket_protocol = protocol >= 0 ? (__u32)protocol : 0; + __u32 event_type; - if (family == AF_ALG) { + if (fam == AF_ALG) { event_type = EVENT_TYPE_SOCKET_AF_ALG_CREATE; - } else if (family == AF_KEY) { + } else if (fam == AF_KEY) { event_type = EVENT_TYPE_SOCKET_AF_KEY_CREATE; - } else if (family == AF_NETLINK && protocol == NETLINK_XFRM) { + } else if (fam == AF_NETLINK && socket_protocol == NETLINK_XFRM) { event_type = EVENT_TYPE_SOCKET_XFRM_NETLINK_CREATE; + } else if (is_family_denied(fam)) { + event_type = EVENT_TYPE_SOCKET_FAMILY_DENIED_CREATE; + } else if (fam == AF_NETLINK && is_netlink_protocol_denied(socket_protocol)) { + event_type = EVENT_TYPE_SOCKET_FAMILY_DENIED_CREATE; } else { return 0; } @@ -61,7 +102,8 @@ int BPF_PROG(micromize_socket_create, int family, int type, int protocol, gadget_process_populate(&event->process); event->timestamp_raw = bpf_ktime_get_boot_ns(); event->event_type = event_type; - event->family = family; + event->family = (__u32)fam; + event->protocol = socket_protocol; event->alg_type[0] = '\0'; event->alg_name[0] = '\0'; @@ -73,13 +115,11 @@ int BPF_PROG(micromize_socket_create, int family, int type, int protocol, return 0; } -// Defense-in-depth: block AF_ALG bind if a socket FD exists from before -// policy load. Preserves alg_type/alg_name for visibility. +// Defense-in-depth: block denied socket binds if a socket FD exists from +// before policy load. Preserves AF_ALG alg_type/alg_name for visibility. SEC("lsm/socket_bind") int BPF_PROG(micromize_socket_bind, struct socket *sock, struct sockaddr *address, int addrlen, int ret) { - (void)sock; - // Preserve a deny decision from a previously-run LSM program in the chain. if (ret) return ret; @@ -87,13 +127,30 @@ int BPF_PROG(micromize_socket_bind, struct socket *sock, if (gadget_should_discard_data_current()) return 0; - if (!address || addrlen < SOCKADDR_ALG_TYPE_END) + if (!address || addrlen < sizeof(__u16)) return 0; __u16 family = 0; bpf_probe_read_kernel(&family, sizeof(family), address); - if (family != AF_ALG) + + __u32 protocol = 0; + __u32 event_type; + if (family == AF_ALG) { + event_type = EVENT_TYPE_SOCKET_AF_ALG_BIND; + } else if (is_family_denied(family)) { + event_type = EVENT_TYPE_SOCKET_FAMILY_DENIED_BIND; + } else if (family == AF_NETLINK) { + // Micro-optimization: only read sk_protocol for AF_NETLINK, and only + // when the family itself was not already denied above. + struct sock *sk = BPF_CORE_READ(sock, sk); + if (sk) + protocol = (__u32)BPF_CORE_READ_BITFIELD_PROBED(sk, sk_protocol); + if (!is_netlink_protocol_denied(protocol)) + return 0; + event_type = EVENT_TYPE_SOCKET_FAMILY_DENIED_BIND; + } else { return 0; + } struct event *event; event = gadget_reserve_buf(&events, sizeof(*event)); @@ -105,19 +162,22 @@ int BPF_PROG(micromize_socket_bind, struct socket *sock, gadget_process_populate(&event->process); event->timestamp_raw = bpf_ktime_get_boot_ns(); - event->event_type = EVENT_TYPE_SOCKET_AF_ALG_BIND; + event->event_type = event_type; event->family = family; + event->protocol = protocol; + event->alg_type[0] = '\0'; + event->alg_name[0] = '\0'; - bpf_probe_read_kernel(event->alg_type, SOCKADDR_ALG_TYPE_LEN, - (const char *)address + SOCKADDR_ALG_TYPE_OFFSET); - event->alg_type[SOCKADDR_ALG_TYPE_LEN] = '\0'; + if (family == AF_ALG && addrlen >= SOCKADDR_ALG_TYPE_END) { + bpf_probe_read_kernel(event->alg_type, SOCKADDR_ALG_TYPE_LEN, + (const char *)address + SOCKADDR_ALG_TYPE_OFFSET); + event->alg_type[SOCKADDR_ALG_TYPE_LEN] = '\0'; - if (addrlen >= SOCKADDR_ALG_MIN_LEN) { - bpf_probe_read_kernel(event->alg_name, SOCKADDR_ALG_NAME_LEN, - (const char *)address + SOCKADDR_ALG_NAME_OFFSET); - event->alg_name[SOCKADDR_ALG_NAME_LEN - 1] = '\0'; - } else { - event->alg_name[0] = '\0'; + if (addrlen >= SOCKADDR_ALG_MIN_LEN) { + bpf_probe_read_kernel(event->alg_name, SOCKADDR_ALG_NAME_LEN, + (const char *)address + SOCKADDR_ALG_NAME_OFFSET); + event->alg_name[SOCKADDR_ALG_NAME_LEN - 1] = '\0'; + } } gadget_submit_buf(ctx, &events, event, sizeof(*event)); diff --git a/gadgets/socket-restrict/program.bpf.h b/gadgets/socket-restrict/program.bpf.h index 7da3649..f164357 100644 --- a/gadgets/socket-restrict/program.bpf.h +++ b/gadgets/socket-restrict/program.bpf.h @@ -8,8 +8,16 @@ #define EPERM 1 #endif -#ifndef AF_ALG -#define AF_ALG 38 +#ifndef AF_AX25 +#define AF_AX25 3 +#endif + +#ifndef AF_ATMPVC +#define AF_ATMPVC 8 +#endif + +#ifndef AF_X25 +#define AF_X25 9 #endif #ifndef AF_KEY @@ -20,10 +28,70 @@ #define AF_NETLINK 16 #endif +#ifndef AF_PACKET +#define AF_PACKET 17 +#endif + +#ifndef AF_ATMSVC +#define AF_ATMSVC 20 +#endif + +#ifndef AF_RDS +#define AF_RDS 21 +#endif + +#ifndef AF_CAN +#define AF_CAN 29 +#endif + +#ifndef AF_TIPC +#define AF_TIPC 30 +#endif + +#ifndef AF_BLUETOOTH +#define AF_BLUETOOTH 31 +#endif + +#ifndef AF_CAIF +#define AF_CAIF 37 +#endif + +#ifndef AF_ALG +#define AF_ALG 38 +#endif + +#ifndef AF_NFC +#define AF_NFC 39 +#endif + +#ifndef AF_VSOCK +#define AF_VSOCK 40 +#endif + +#ifndef AF_KCM +#define AF_KCM 41 +#endif + +#ifndef AF_SMC +#define AF_SMC 43 +#endif + #ifndef NETLINK_XFRM #define NETLINK_XFRM 6 #endif +#ifndef NETLINK_AUDIT +#define NETLINK_AUDIT 9 +#endif + +#ifndef NETLINK_NETFILTER +#define NETLINK_NETFILTER 12 +#endif + +#ifndef NETLINK_KOBJECT_UEVENT +#define NETLINK_KOBJECT_UEVENT 15 +#endif + #define SOCKADDR_ALG_TYPE_OFFSET 2 #define SOCKADDR_ALG_TYPE_LEN 14 #define SOCKADDR_ALG_TYPE_END (SOCKADDR_ALG_TYPE_OFFSET + SOCKADDR_ALG_TYPE_LEN) @@ -34,11 +102,15 @@ #define EVENT_ALG_TYPE_LEN (SOCKADDR_ALG_TYPE_LEN + 1) +#define MAX_DENIED_FAMILIES 64 +#define MAX_DENIED_NETLINK_PROTOCOLS 32 + struct event { gadget_timestamp timestamp_raw; struct gadget_process process; __u32 event_type; __u32 family; + __u32 protocol; char alg_type[EVENT_ALG_TYPE_LEN]; char alg_name[SOCKADDR_ALG_NAME_LEN]; }; diff --git a/include/micromize/event_types.h b/include/micromize/event_types.h index 5d804f0..2635a3c 100644 --- a/include/micromize/event_types.h +++ b/include/micromize/event_types.h @@ -31,6 +31,8 @@ enum micromize_event_type { EVENT_TYPE_SOCKET_AF_ALG_BIND = 12, EVENT_TYPE_SOCKET_AF_KEY_CREATE = 14, EVENT_TYPE_SOCKET_XFRM_NETLINK_CREATE = 15, + EVENT_TYPE_SOCKET_FAMILY_DENIED_CREATE = 20, + EVENT_TYPE_SOCKET_FAMILY_DENIED_BIND = 21, }; #endif /* __MICROMIZE_EVENT_TYPES_H */ diff --git a/internal/operators/operators.go b/internal/operators/operators.go index 27e0222..6c70d59 100644 --- a/internal/operators/operators.go +++ b/internal/operators/operators.go @@ -76,6 +76,8 @@ const ( eventTypeCapModuleAutoload = 13 eventTypeSocketAFKeyCreate = 14 eventTypeSocketXfrmNetlinkCreate = 15 + eventTypeSocketFamilyDeniedCreate = 20 + eventTypeSocketFamilyDeniedBind = 21 ) var eventTypeNames = map[uint32]string{ @@ -95,6 +97,8 @@ var eventTypeNames = map[uint32]string{ eventTypeCapModuleAutoload: "module_autoload", eventTypeSocketAFKeyCreate: "af_key_socket_create", eventTypeSocketXfrmNetlinkCreate: "xfrm_netlink_socket_create", + eventTypeSocketFamilyDeniedCreate: "socket_family_denied_create", + eventTypeSocketFamilyDeniedBind: "socket_family_denied_bind", } // NewEventTypeOperator creates an operator that enriches events with a diff --git a/internal/operators/output.go b/internal/operators/output.go index 6361133..49c267a 100644 --- a/internal/operators/output.go +++ b/internal/operators/output.go @@ -44,6 +44,8 @@ var eventDescriptions = map[uint32]string{ eventTypeCapModuleAutoload: "Kernel module auto-load blocked", eventTypeSocketAFKeyCreate: "AF_KEY (PF_KEY IPsec) socket creation blocked", eventTypeSocketXfrmNetlinkCreate: "XFRM/IPsec netlink socket creation blocked", + eventTypeSocketFamilyDeniedCreate: "Socket family denied (create)", + eventTypeSocketFamilyDeniedBind: "Socket family denied (bind)", } var eventEmojis = map[uint32]string{} diff --git a/internal/operators/socket_restrict.go b/internal/operators/socket_restrict.go new file mode 100644 index 0000000..b8058be --- /dev/null +++ b/internal/operators/socket_restrict.go @@ -0,0 +1,249 @@ +// Copyright The micromize authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package operators + +import ( + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/cilium/ebpf" + igoperators "github.com/inspektor-gadget/inspektor-gadget/pkg/operators" + "github.com/inspektor-gadget/inspektor-gadget/pkg/operators/simple" +) + +// Linux socket address-family constants relevant to the socket-restrict +// gadget. Mirrors gadgets/socket-restrict/program.bpf.h. +var socketFamilyByName = map[string]uint16{ + "AF_UNIX": 1, + "AF_INET": 2, + "AF_AX25": 3, + "AF_IPX": 4, + "AF_APPLETALK": 5, + "AF_NETROM": 6, + "AF_BRIDGE": 7, + "AF_ATMPVC": 8, + "AF_X25": 9, + "AF_INET6": 10, + "AF_ROSE": 11, + "AF_DECNET": 12, + "AF_NETBEUI": 13, + "AF_SECURITY": 14, + "AF_KEY": 15, + "AF_NETLINK": 16, + "AF_PACKET": 17, + "AF_ASH": 18, + "AF_ECONET": 19, + "AF_ATMSVC": 20, + "AF_RDS": 21, + "AF_IRDA": 23, + "AF_PPPOX": 24, + "AF_WANPIPE": 25, + "AF_LLC": 26, + "AF_IB": 27, + "AF_MPLS": 28, + "AF_CAN": 29, + "AF_TIPC": 30, + "AF_BLUETOOTH": 31, + "AF_IUCV": 32, + "AF_RXRPC": 33, + "AF_ISDN": 34, + "AF_PHONET": 35, + "AF_IEEE802154": 36, + "AF_CAIF": 37, + "AF_ALG": 38, + "AF_NFC": 39, + "AF_VSOCK": 40, + "AF_KCM": 41, + "AF_QIPCRTR": 42, + "AF_SMC": 43, + "AF_XDP": 44, +} + +// Linux AF_NETLINK protocol numbers consulted by socket-restrict. +var netlinkProtocolByName = map[string]uint32{ + "NETLINK_ROUTE": 0, + "NETLINK_UNUSED": 1, + "NETLINK_USERSOCK": 2, + "NETLINK_FIREWALL": 3, + "NETLINK_SOCK_DIAG": 4, + "NETLINK_NFLOG": 5, + "NETLINK_XFRM": 6, + "NETLINK_SELINUX": 7, + "NETLINK_ISCSI": 8, + "NETLINK_AUDIT": 9, + "NETLINK_FIB_LOOKUP": 10, + "NETLINK_CONNECTOR": 11, + "NETLINK_NETFILTER": 12, + "NETLINK_IP6_FW": 13, + "NETLINK_DNRTMSG": 14, + "NETLINK_KOBJECT_UEVENT": 15, + "NETLINK_GENERIC": 16, + "NETLINK_SCSITRANSPORT": 18, + "NETLINK_ECRYPTFS": 19, + "NETLINK_RDMA": 20, + "NETLINK_CRYPTO": 21, +} + +// ParseSocketDenyFamilies parses a comma-separated string of address-family +// names (e.g. "AF_ALG,AF_TIPC") or decimal numbers (e.g. "38,30") into a +// deduplicated slice of family numbers. Empty entries are skipped. Whitespace +// is trimmed. Returns an error on unknown names or out-of-range numbers. +func ParseSocketDenyFamilies(input string) ([]uint16, error) { + if strings.TrimSpace(input) == "" { + return nil, nil + } + seen := make(map[uint16]struct{}) + var out []uint16 + for _, raw := range strings.Split(input, ",") { + token := strings.TrimSpace(raw) + if token == "" { + continue + } + var fam uint16 + if v, ok := socketFamilyByName[strings.ToUpper(token)]; ok { + fam = v + } else { + n, err := strconv.ParseUint(token, 10, 16) + if err != nil { + return nil, fmt.Errorf("unknown address family %q", token) + } + fam = uint16(n) + } + if _, dup := seen[fam]; dup { + continue + } + seen[fam] = struct{}{} + out = append(out, fam) + } + if len(out) > maxDeniedFamilies { + return nil, fmt.Errorf("too many denied socket families: %d exceeds the BPF map capacity of %d", len(out), maxDeniedFamilies) + } + return out, nil +} + +// ParseSocketDenyNetlinkProtocols parses a comma-separated string of +// AF_NETLINK protocol names (e.g. "NETLINK_NETFILTER,NETLINK_XFRM") or +// decimal numbers into a deduplicated slice of protocol numbers. +func ParseSocketDenyNetlinkProtocols(input string) ([]uint32, error) { + if strings.TrimSpace(input) == "" { + return nil, nil + } + seen := make(map[uint32]struct{}) + var out []uint32 + for _, raw := range strings.Split(input, ",") { + token := strings.TrimSpace(raw) + if token == "" { + continue + } + var proto uint32 + if v, ok := netlinkProtocolByName[strings.ToUpper(token)]; ok { + proto = v + } else { + n, err := strconv.ParseUint(token, 10, 32) + if err != nil { + return nil, fmt.Errorf("unknown netlink protocol %q", token) + } + proto = uint32(n) + } + if _, dup := seen[proto]; dup { + continue + } + seen[proto] = struct{}{} + out = append(out, proto) + } + if len(out) > maxDeniedNetlinkProtocols { + return nil, fmt.Errorf("too many denied netlink protocols: %d exceeds the BPF map capacity of %d", len(out), maxDeniedNetlinkProtocols) + } + return out, nil +} + +// Keep in sync with gadgets/socket-restrict/program.bpf.c. +const ( + socketDeniedFamiliesMapName = "map/denied_families" + socketDeniedNetlinkProtocolsMapName = "map/denied_netlink_protocols" +) + +// BPF deny-list map capacities. Keep in sync with MAX_DENIED_FAMILIES and +// MAX_DENIED_NETLINK_PROTOCOLS in gadgets/socket-restrict/program.bpf.h. +const ( + maxDeniedFamilies = 64 + maxDeniedNetlinkProtocols = 32 +) + +// NewSocketRestrictOperator returns a data operator that, on each gadget's +// init, populates the socket-restrict BPF deny-list maps from the supplied +// family / netlink-protocol slices. The operator is a no-op for any gadget +// that does not expose those maps. +func NewSocketRestrictOperator(families []uint16, netlinkProtocols []uint32) igoperators.DataOperator { + slog.Debug("Creating socket-restrict operator", + "families", families, "netlinkProtocols", netlinkProtocols) + return simple.New("socketRestrictOperator", + simple.OnInit(func(gadgetCtx igoperators.GadgetContext) error { + if err := populateUint16Map(gadgetCtx, socketDeniedFamiliesMapName, families); err != nil { + return fmt.Errorf("populating %s: %w", socketDeniedFamiliesMapName, err) + } + if err := populateUint32Map(gadgetCtx, socketDeniedNetlinkProtocolsMapName, netlinkProtocols); err != nil { + return fmt.Errorf("populating %s: %w", socketDeniedNetlinkProtocolsMapName, err) + } + return nil + }), + ) +} + +func populateUint16Map(gadgetCtx igoperators.GadgetContext, name string, keys []uint16) error { + m, ok := lookupMap(gadgetCtx, name) + if !ok { + return nil + } + value := uint8(1) + for _, k := range keys { + key := k + if err := m.Put(key, value); err != nil { + return fmt.Errorf("inserting key %d: %w", k, err) + } + } + slog.Debug("Populated socket-restrict map", "name", name, "entries", len(keys)) + return nil +} + +func populateUint32Map(gadgetCtx igoperators.GadgetContext, name string, keys []uint32) error { + m, ok := lookupMap(gadgetCtx, name) + if !ok { + return nil + } + value := uint8(1) + for _, k := range keys { + key := k + if err := m.Put(key, value); err != nil { + return fmt.Errorf("inserting key %d: %w", k, err) + } + } + slog.Debug("Populated socket-restrict map", "name", name, "entries", len(keys)) + return nil +} + +func lookupMap(gadgetCtx igoperators.GadgetContext, name string) (*ebpf.Map, bool) { + v, ok := gadgetCtx.GetVar(name) + if !ok { + return nil, false + } + m, ok := v.(*ebpf.Map) + if !ok || m == nil { + return nil, false + } + return m, true +} diff --git a/internal/operators/socket_restrict_test.go b/internal/operators/socket_restrict_test.go new file mode 100644 index 0000000..348e913 --- /dev/null +++ b/internal/operators/socket_restrict_test.go @@ -0,0 +1,154 @@ +// Copyright The micromize authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package operators + +import ( + "reflect" + "sort" + "strconv" + "strings" + "testing" +) + +// numericList builds a comma-separated string of the integers [start, start+n). +func numericList(start, n int) string { + parts := make([]string, 0, n) + for i := 0; i < n; i++ { + parts = append(parts, strconv.Itoa(start+i)) + } + return strings.Join(parts, ",") +} + +func sortedU16(in []uint16) []uint16 { + out := append([]uint16(nil), in...) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func sortedU32(in []uint32) []uint32 { + out := append([]uint32(nil), in...) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func TestParseSocketDenyFamilies(t *testing.T) { + tests := []struct { + name string + input string + want []uint16 + wantErr bool + }{ + {name: "empty", input: "", want: nil}, + {name: "whitespace only", input: " , ", want: nil}, + {name: "single name", input: "AF_ALG", want: []uint16{38}}, + {name: "case insensitive", input: "af_alg", want: []uint16{38}}, + {name: "decimal", input: "38", want: []uint16{38}}, + { + name: "mixed names and numbers", + input: " AF_ALG ,30, AF_VSOCK", + want: []uint16{30, 38, 40}, + }, + { + name: "duplicates collapsed", + input: "AF_ALG,38,AF_ALG", + want: []uint16{38}, + }, + {name: "unknown name", input: "AF_NOPE", wantErr: true}, + {name: "out of range", input: "1000000", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSocketDenyFamilies(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseSocketDenyFamilies(%q) err=%v, wantErr=%v", + tt.input, err, tt.wantErr) + } + if tt.wantErr { + return + } + if !reflect.DeepEqual(sortedU16(got), sortedU16(tt.want)) { + t.Errorf("ParseSocketDenyFamilies(%q) = %v, want %v", + tt.input, got, tt.want) + } + }) + } +} + +func TestParseSocketDenyNetlinkProtocols(t *testing.T) { + tests := []struct { + name string + input string + want []uint32 + wantErr bool + }{ + {name: "empty default", input: "", want: nil}, + { + name: "all four common opt-ins", + input: "NETLINK_NETFILTER,NETLINK_XFRM,NETLINK_AUDIT,NETLINK_KOBJECT_UEVENT", + want: []uint32{6, 9, 12, 15}, + }, + {name: "case insensitive", input: "netlink_netfilter", want: []uint32{12}}, + {name: "decimal", input: "12", want: []uint32{12}}, + {name: "unknown", input: "NETLINK_NOPE", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSocketDenyNetlinkProtocols(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseSocketDenyNetlinkProtocols(%q) err=%v, wantErr=%v", + tt.input, err, tt.wantErr) + } + if tt.wantErr { + return + } + if !reflect.DeepEqual(sortedU32(got), sortedU32(tt.want)) { + t.Errorf("ParseSocketDenyNetlinkProtocols(%q) = %v, want %v", + tt.input, got, tt.want) + } + }) + } +} + +func TestParseSocketDenyFamiliesCapacity(t *testing.T) { + // Exactly at capacity is allowed. + if _, err := ParseSocketDenyFamilies(numericList(1, maxDeniedFamilies)); err != nil { + t.Fatalf("ParseSocketDenyFamilies at capacity (%d) returned error: %v", maxDeniedFamilies, err) + } + // One over capacity must be rejected with a clear error. + got, err := ParseSocketDenyFamilies(numericList(1, maxDeniedFamilies+1)) + if err == nil { + t.Fatalf("ParseSocketDenyFamilies over capacity (%d) did not error", maxDeniedFamilies+1) + } + if got != nil { + t.Errorf("ParseSocketDenyFamilies over capacity returned non-nil slice: %v", got) + } +} + +func TestParseSocketDenyNetlinkProtocolsCapacity(t *testing.T) { + // Exactly at capacity is allowed. + if _, err := ParseSocketDenyNetlinkProtocols(numericList(0, maxDeniedNetlinkProtocols)); err != nil { + t.Fatalf("ParseSocketDenyNetlinkProtocols at capacity (%d) returned error: %v", maxDeniedNetlinkProtocols, err) + } + // One over capacity must be rejected with a clear error. + got, err := ParseSocketDenyNetlinkProtocols(numericList(0, maxDeniedNetlinkProtocols+1)) + if err == nil { + t.Fatalf("ParseSocketDenyNetlinkProtocols over capacity (%d) did not error", maxDeniedNetlinkProtocols+1) + } + if got != nil { + t.Errorf("ParseSocketDenyNetlinkProtocols over capacity returned non-nil slice: %v", got) + } +} diff --git a/tests/integration/cases/11_af_vsock_audit_mode.sh b/tests/integration/cases/11_af_vsock_audit_mode.sh new file mode 100755 index 0000000..f724e15 --- /dev/null +++ b/tests/integration/cases/11_af_vsock_audit_mode.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Test: AF_VSOCK socket-restrict in audit mode +# +# AF_VSOCK is intentionally NOT in the default socket-restrict deny-list — it +# can be used by firecracker / kata-containers / hypervisor agents. This test +# opts in AF_VSOCK via --socket-deny-families and runs micromize in audit +# mode (--enforce=false), so the socket call is *allowed* but the gadget +# emits an event. The probe asserts the socket() was not blocked; the harness +# is expected to additionally verify a "Socket family denied (create)" event +# was logged by micromize (this is harness-side and not part of the probe). +# +# Expected harness configuration: +# MICROMIZE_SOCKET_DENY_FAMILIES=AF_VSOCK +# MICROMIZE_ENFORCE=false +# These are read by the harness when launching micromize before this case +# executes, mirroring the convention used by the existing AF_ALG cases (which +# rely on the harness to have micromize already running in enforce mode). + +test_af_vsock_audit_mode() { + begin_test "AF_VSOCK socket allowed in audit mode while opted into deny-list" + + if ! command -v go &>/dev/null; then + fail_test "go is required to build the AF_VSOCK probe" + return + fi + + local probe_bin="${ROOTFS_DIR}/bin/af-vsock-probe" + if ! (cd "$REPO_ROOT" && CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build -o "$probe_bin" ./tests/integration/probes/af_vsock); then + fail_test "failed to build AF_VSOCK probe" + return + fi + + local bundle="${TEST_TMPDIR}/bundle-af-vsock" + local cid="micromize-test-af-vsock" + + create_bundle "$bundle" "$ROOTFS_DIR" /bin/af-vsock-probe + + local output + output=$(runc run "$cid" -b "$bundle" 2>&1) + local rc=$? + + if [[ $rc -ne 0 ]]; then + fail_test "AF_VSOCK probe exited with ${rc}: ${output}" + runc delete -f "$cid" 2>/dev/null || true + return + fi + + # In audit mode the socket call must succeed (audit only emits an event). + # Accept "skipped" for kernels without AF_VSOCK so the case is portable. + if echo "$output" | grep -qE "^(ok|skipped):"; then + pass_test + else + fail_test "Expected AF_VSOCK socket to be allowed in audit mode, got: ${output}" + fi + + runc delete -f "$cid" 2>/dev/null || true +} + +test_af_vsock_audit_mode diff --git a/tests/integration/probes/af_vsock/main.go b/tests/integration/probes/af_vsock/main.go new file mode 100644 index 0000000..7fd46e5 --- /dev/null +++ b/tests/integration/probes/af_vsock/main.go @@ -0,0 +1,77 @@ +// Copyright The micromize authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "syscall" +) + +const ( + afVSOCK = 40 + sockStream = 1 + sockSeqpacket = 5 +) + +// AF_VSOCK probe. Exits 0 in both audit and enforce modes so the harness can +// drive the audit-vs-enforce distinction externally (by toggling micromize's +// --enforce flag) while still asserting on the probe's printed status. +// +// "ok: AF_VSOCK socket created (audit-mode or opt-out)" socket() returned an fd +// "blocked: AF_VSOCK socket creation denied: " socket() returned EPERM/EACCES +// "skipped: AF_VSOCK not supported on this kernel" socket() returned EAFNOSUPPORT/EPROTONOSUPPORT +// +// Exit codes: +// +// 0 ok / blocked / skipped (any expected outcome) +// 2 unexpected error +func main() { + fd, err := syscall.Socket(afVSOCK, sockStream, 0) + if err == nil { + syscall.Close(fd) //nolint:errcheck,gosec + fmt.Println("ok: AF_VSOCK socket created (audit-mode or opt-out)") + return + } + + switch err { + case syscall.EPERM, syscall.EACCES: + fmt.Printf("blocked: AF_VSOCK socket creation denied: %v\n", err) + return + case syscall.EAFNOSUPPORT, syscall.EPROTONOSUPPORT: + fmt.Printf("skipped: AF_VSOCK not supported on this kernel: %v\n", err) + return + } + + // SOCK_STREAM is the newer transport; some older kernels only support + // SOCK_SEQPACKET. Retry once before declaring the result unexpected. + fd, err = syscall.Socket(afVSOCK, sockSeqpacket, 0) + if err == nil { + syscall.Close(fd) //nolint:errcheck,gosec + fmt.Println("ok: AF_VSOCK socket created (audit-mode or opt-out)") + return + } + switch err { + case syscall.EPERM, syscall.EACCES: + fmt.Printf("blocked: AF_VSOCK socket creation denied: %v\n", err) + return + case syscall.EAFNOSUPPORT, syscall.EPROTONOSUPPORT: + fmt.Printf("skipped: AF_VSOCK not supported on this kernel: %v\n", err) + return + } + + fmt.Printf("socket-error: %v\n", err) + os.Exit(2) +}