io_uring_register_bpf_filter(3)

๐Ÿ“ฆ version 2.15, in arch rolling, ubuntu stonking

๐Ÿ’– NAME ๐Ÿ’–

io_uring_register_bpf_filter, io_uring_register_bpf_filter_task โ€” okay so these are the besties that register classic BPF filters for io_uring operations. This is from the liburing Manual (you know, the io_uring_register_bpf_filter(3) corner of the universe) ๐Ÿฆ‹


โœจ SYNOPSIS โœจ

No cap, first you gotta pull in the headers:

#include <liburing.h>
#include <liburing/io_uring/bpf_filter.h>
#include <linux/filter.h>

And then the two functions themselves, serving looks:

int io_uring_register_bpf_filter(struct io_uring *ring,
                                 struct io_uring_bpf *bpf);

int io_uring_register_bpf_filter_task(struct io_uring_bpf *bpf);

๐ŸŒธ DESCRIPTION ๐ŸŒธ

So literally what these functions do is register classic BPF (cBPF) filters to restrict what io_uring operations are allowed to go down. Think of it as a bouncer at the club door ๐Ÿšชโœจ โ€” you can build security policies by allowing or denying specific operations based on their parameters. Security policy era, honestly.

io_uring_register_bpf_filter(3) registers a filter on one specific ring. That filter only applies to operations submitted through that ring. It's like a filter that only cares about its own little circle.

io_uring_register_bpf_filter_task(3) registers a filter on the calling task. And bestie, the scope here is bigger:

  • It applies to all io_uring rings created by the task after the filter is registered.
  • It is inherited by child processes created via fork(2).
  • Rings that were created before the filter was registered are not affected. (Sorry, no retroactive glow-up ๐Ÿ’…)
  • Task-level filters cannot be removed โ€” periodt. Once they're on, they're on.
  • Child processes cannot loosen restrictions set by their parent. No negotiating with the ancestors ๐Ÿ™…โ€โ™€๏ธ

The bpf argument is a pointer to a struct io_uring_bpf with cmd_type set to IO_URING_BPF_CMD_FILTER. The embedded struct io_uring_bpf_filter describes the filter you're registering:

struct io_uring_bpf_filter {
    __u32   opcode;      /* io_uring opcode to filter */
    __u32   flags;       /* IO_URING_BPF_FILTER_* */
    __u32   filter_len;  /* number of BPF instructions */
    __u8    pdu_size;    /* expected pdu size for opcode */
    __u8    resv[3];
    __u64   filter_ptr;  /* pointer to BPF filter */
    __u64   resv2[5];
};

Let's break down the vibe check on each field, bestie:

opcode says which io_uring operation the filter is for (like IORING_OP_SOCKET, IORING_OP_NOP, IORING_OP_READ โ€” you get it โœจ).

filter_ptr points to an array of filter_len BPF instructions (struct sock_filter). The filter runs for each matching operation, and here's the rule: it must return non-zero to allow the operation, or zero to deny it โ€” and denying means the app gets -EACCES back. Binary. Iconic. ๐ŸŽ€

pdu_size is the expected size in bytes of the operation-specific payload data for that opcode (like the socket or open structs inside struct io_uring_bpf_ctx). For opcodes with no extra payload, this should be zero. For IORING_OP_SOCKET this would be 12 (three 4-byte members), and for IORING_OP_OPENAT and IORING_OP_OPENAT2 this would be 24 (three 8-byte members).

Now, the size-matching drama, because it matters:

If the application's pdu_size matches the kernel's expected size for the opcode โ†’ registration succeeds. Slay. ๐Ÿซถ

If the sizes differ, it depends on whether IO_URING_BPF_FILTER_SZ_STRICT is set in flags:

  • If IO_URING_BPF_FILTER_SZ_STRICT is set, registration fails with -EMSGSIZE if the sizes differ.
  • If IO_URING_BPF_FILTER_SZ_STRICT is not set, registration is allowed if the application's pdu_size is smaller than the kernel's. This lets older applications that were compiled against a smaller payload still load filters, since the kernel can safely evaluate the filter on the subset of data the application expects. (Nostalgic apps deserve love too ๐Ÿ’œ)
  • Regardless of IO_URING_BPF_FILTER_SZ_STRICT, registration always fails with -EMSGSIZE if the application's pdu_size is larger than the kernel's โ€” because the kernel cannot provide data that it does not support. It's not being shady, it literally doesn't have it. ๐Ÿ’€

On an -EMSGSIZE failure, the kernel writes back the kernel's expected pdu_size into the struct io_uring_bpf_filter that the application passed in. So your app can discover the kernel's expected payload size and adjust or retry accordingly. Very "here's the answer, bestie, take notes" of the kernel ๐Ÿ“โœจ

flags can be zero, or a bitwise OR of these:

  • IO_URING_BPF_FILTER_DENY_REST โ€” when set, any opcode that does not have a filter registered will be denied. This lets you create an allowlist of permitted operations. Allowlist only, nobody else gets in ๐Ÿ”ฅ
  • IO_URING_BPF_FILTER_SZ_STRICT โ€” when set, registration of a filter will fail with -EMSGSIZE if the application's pdu_size does not exactly match the kernel's expected payload size for the opcode. Without this flag, the kernel permits filters where the application's pdu_size is smaller than or equal to the kernel's.

๐Ÿงš Filter Context ๐Ÿงš

The BPF filter gets handed a context structure it can inspect using BPF_LD instructions with absolute addressing. The layout is literally this:

struct io_uring_bpf_ctx {
    __u64   user_data;     /* offset 0: user_data from SQE */
    __u8    opcode;        /* offset 8: io_uring opcode */
    __u8    sqe_flags;     /* offset 9: SQE flags */
    __u8    pdu_size;      /* offset 10: aux data size for filter */
    __u8    pad[5];        /* offset 11-15: padding */
    union {
        struct {
            __u32   family;    /* offset 16: socket family */
            __u32   type;      /* offset 20: socket type */
            __u32   protocol;  /* offset 24: socket protocol */
        } socket;
        struct {
            __u64   flags;     /* offset 16: open flags */
            __u64   mode;      /* offset 24: file mode */
            __u64   resolve;   /* offset 32: resolve flags */
        } open;
    };
};

The pdu_size field tells you the size in bytes of the operation-specific data passed in the union. A filter can check this value to verify it's receiving the expected payload. And bestie, this is genuinely useful for forward compatibility: if a future kernel adds new members to an operation's context, the filter can inspect pdu_size to figure out whether those fields are even present. Future-proof girlie behavior ๐Ÿ”ฎ

For IORING_OP_SOCKET operations, the socket family, type, and protocol fields are populated and can be used to filter based on socket parameters. pdu_size is set to 12 (three 4-byte members).

For IORING_OP_OPENAT and IORING_OP_OPENAT2 operations, the open flags, mode, and resolve fields are populated:

  • The flags field contains the open flags (e.g., O_RDONLY, O_CREAT).
  • The resolve field is only meaningful for IORING_OP_OPENAT2 and contains resolve flags (e.g., RESOLVE_IN_ROOT).

pdu_size is set to 24 (three 8-byte members).


๐Ÿ‘‘ Filter Stacking ๐Ÿ‘‘

Multiple filters can be registered for the same opcode. When multiple filters exist, they are evaluated in order, and all of them must return non-zero for the operation to be allowed. Every single one. It's a group project and everyone has to pass ๐Ÿ˜ค

For task-level filters, the child's filters are evaluated before the parent's filters.


โญ RETURN VALUE โญ

On success, these functions return 0. On failure, they return a negative error code. Short and sweet.


๐Ÿšจ ERRORS ๐Ÿšจ

  • -EINVAL โ€” Invalid filter, opcode, or flags specified. Something in there was giving "wrong" energy.
  • -EMSGSIZE โ€” The application's pdu_size does not match the kernel's expected payload size for the opcode. This happens when IO_URING_BPF_FILTER_SZ_STRICT is set and the sizes differ, or when the application's pdu_size is larger than the kernel's regardless of flags. Size mismatch moment ๐Ÿ˜ญ
  • -ENOMEM โ€” Insufficient memory to register the filter. No room, bestie.
  • -EFAULT โ€” The filter pointer is invalid. Bad pointer, babe.
  • -EACCES โ€” The caller does not have the CAP_SYS_ADMIN capability and the no_new_privs attribute is not set on the calling task. See prctl(2) with PR_SET_NO_NEW_PRIVS. Basically: permissions said no ๐Ÿ™ˆ

๐Ÿงช EXAMPLES (yes bestie, real code) โœจ

๐Ÿ’€ Deny all NOP operations

Okay so this is the "no NOPs allowed, ever, not on my watch" setup:

#include <sys/prctl.h>
#include <linux/filter.h>
#include <liburing.h>
#include <liburing/io_uring/bpf_filter.h>

struct sock_filter deny_filter[] = {
    BPF_STMT(BPF_RET | BPF_K, 0),  /* return 0 (deny) */
};

struct io_uring_bpf bpf = {
    .cmd_type = IO_URING_BPF_CMD_FILTER,
    .filter = {
        .opcode = IORING_OP_NOP,
        .filter_len = 1,
        .filter_ptr = (unsigned long) deny_filter,
    },
};

/* Must set no_new_privs before registering task filters */
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);

/* Register on a specific ring */
io_uring_register_bpf_filter(&ring, &bpf);

/* Or register on the task */
io_uring_register_bpf_filter_task(&bpf);

Literally one instruction and the whole opcode is cancelled. The filter just returns 0 (deny) and that's the entire personality. ๐Ÿ’…


๐ŸŒ Allow only AF_INET sockets

This one is giving main character security energy โ€” it peeks at the socket family inside the context and says "IPv4 or gtfo":

#include <sys/prctl.h>
#include <linux/filter.h>
#include <sys/socket.h>
#include <liburing.h>
#include <liburing/io_uring/bpf_filter.h>

#define CTX_OFF_SOCKET_FAMILY  16

struct sock_filter inet_only_filter[] = {
    /* Load socket family from context */
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, CTX_OFF_SOCKET_FAMILY),
    /* If family == AF_INET, jump to allow */
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AF_INET, 0, 1),
    /* Allow: return 1 */
    BPF_STMT(BPF_RET | BPF_K, 1),
    /* Deny: return 0 */
    BPF_STMT(BPF_RET | BPF_K, 0),
};

struct io_uring_bpf bpf = {
    .cmd_type = IO_URING_BPF_CMD_FILTER,
    .filter = {
        .opcode = IORING_OP_SOCKET,
        .filter_len = 4,
        .filter_ptr = (unsigned long) inet_only_filter,
        .pdu_size = 12,  /* 3x __u32: family, type, protocol */
    },
};

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
io_uring_register_bpf_filter_task(&bpf);

Notice the pdu_size = 12 there? That's 3x __u32: family, type, protocol. The filter is 4 instructions long (filter_len = 4), and it's wired to IORING_OP_SOCKET. Slay. ๐Ÿงš


๐Ÿ‘‘ Allow only NOP, deny everything else

The inverse vibe โ€” one tiny allow, and then IO_URING_BPF_FILTER_DENY_REST handles all the haters for you:

struct sock_filter allow_filter[] = {
    BPF_STMT(BPF_RET | BPF_K, 1),  /* return 1 (allow) */
};

struct io_uring_bpf bpf = {
    .cmd_type = IO_URING_BPF_CMD_FILTER,
    .filter = {
        .opcode = IORING_OP_NOP,
        .flags = IO_URING_BPF_FILTER_DENY_REST,
        .filter_len = 1,
        .filter_ptr = (unsigned long) allow_filter,
    },
};

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
io_uring_register_bpf_filter_task(&bpf);

Allowlist supremacy, honestly. ๐Ÿ’œ


๐Ÿ” Discover kernel pdu_size for an opcode

This next one's a lil detective arc. It shows off the -EMSGSIZE write-back so you can find out what payload size the kernel actually expects:

This example demonstrates how to use the -EMSGSIZE write-back to discover the kernel's expected payload size.
struct sock_filter allow[] = {
    BPF_STMT(BPF_RET | BPF_K, 1),
};

struct io_uring_bpf bpf = {
    .cmd_type = IO_URING_BPF_CMD_FILTER,
    .filter = {
        .opcode = IORING_OP_SOCKET,
        .flags = IO_URING_BPF_FILTER_SZ_STRICT,
        .filter_len = 1,
        .filter_ptr = (unsigned long) allow,
        .pdu_size = 0,  /* intentionally wrong */
    },
};
int ret;

ret = io_uring_register_bpf_filter(&ring, &bpf);
if (ret == -EMSGSIZE) {
    /* kernel wrote back expected size */
    printf("kernel pdu_size for SOCKET: %u\n",
           bpf.filter.pdu_size);
    /* retry with correct size */
    ret = io_uring_register_bpf_filter(&ring, &bpf);
}

Yes the pdu_size = 0 is on purpose. Intentionally wrong, bestie โ€” that's how you get the answer back. ๐Ÿ˜ญ


๐Ÿ“ NOTES (the serious bits, but make it cute) ๐ŸŒธ

๐Ÿ” Privilege Requirements

Okay so this is important. Similar to seccomp(2), registering BPF filters requires either the CAP_SYS_ADMIN capability or the no_new_privs attribute to be set on the calling task.

Why tho? Because otherwise an unprivileged process could install a filter and then execute a setuid binary โ€” which would run with elevated privileges but under the attacker-controlled filter. Nope. Not today. Not on this kernel. ๐Ÿ’€

To set the no_new_privs attribute, call:

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
Once it's set, no_new_privs cannot be unset, and it's inherited by child processes across fork(2) and preserved across execve(2). One-way door, bestie. ๐Ÿšช๐Ÿ”’

๐Ÿซถ Inheritance

Task-level filters registered with io_uring_register_bpf_filter_task(3) are inherited by child processes. So a parent process can establish security restrictions that apply to all of its descendants. Family rules, literally.

Children can add additional restrictions, but they cannot remove or weaken filters set by their ancestors. Nice try though. ๐Ÿ™ˆ

Ring-level filters registered with io_uring_register_bpf_filter(3) only apply to that specific ring, and they are not inherited. Ring-exclusive. Main character behaviour. ๐Ÿ‘‘


๐ŸŒˆ SEE ALSO


And that's the whole page, bestie โ€” you ate it. ๐ŸŽ€

This one comes from the liburing-2.14 era, dated January 18, 2026. Go forth and filter responsibly. ๐Ÿ’–โœจ๐Ÿฆ‹

๐Ÿ“ฆ where this page comes from