kqueue: UDP read reports a truncated source address for IPv6 senders

Open Beginner friendly
#241 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
84/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
zig
Domain
networking

Research direction

Start in src/backend/kqueue.zig at the recvfrom operation around lines 1652-1657, then follow perform and UDPSendto.read. Compare its address handling with UDPSendMsg in the io_uring and epoll backends. Done means IPv6 and IPv4-mapped IPv6 source addresses are preserved and the provided reproduction reports the expected address on macOS kqueue.

Written by the indexing model from the issue text.

Description

Summary

On the kqueue backend, xev.UDP.read reports a wrong source address when the datagram comes from an IPv6 peer. That includes IPv4 peers reaching a dual-stack (IPV6_V6ONLY=0) socket, whose addresses arrive as IPv4-mapped IPv6. The address bytes come back as all zeros, so replying to the reported address sends the reply nowhere.

io_uring and epoll are not affected: UDPSendMsg receives into a sockaddr.storage.

Cause

The kqueue recvfrom operation receives the source address into a 16-byte posix.sockaddr:

https://github.com/mitchellh/libxev/blob/9ce8e8e6ff89e583258a7f8e7adeeeaeae8611bf/src/backend/kqueue.zig#L1652-L1657

recvfrom: struct {
    fd: posix.fd_t,
    buffer: ReadBuffer,
    addr: posix.sockaddr = undefined,
    addr_size: posix.socklen_t = @sizeOf(posix.sockaddr),
},

A sockaddr_in6 is 28 bytes, so the kernel truncates it to the family, port, flow info and the first 8 bytes of the address. UDPSendto.read then passes &op.recvfrom.addr to net.Address.initPosix(...).toIpAddress(), which reads it as a sockaddr_in6, past the end of the 16-byte field.

Reproduction

macOS 15 (Apple Silicon), Zig 0.16.0, libxev 9ce8e8e6ff89e583258a7f8e7adeeeaeae8611bf.

The program binds a UDP socket, sends it one datagram and prints the source address xev.UDP.read reports.

  • v6: bound to [::1], sent from ::1
  • mapped: bound to [::] with IPV6_V6ONLY=0, sent from 127.0.0.1
repro.zig
const std = @import("std");
const builtin = @import("builtin");
const xev = @import("xev");

var got: ?std.Io.net.IpAddress = null;

fn onRead(_: ?*void, _: *xev.Loop, _: *xev.Completion, _: *xev.UDP.State, addr: std.Io.net.IpAddress, _: xev.UDP, _: xev.ReadBuffer, r: xev.ReadError!usize) xev.CallbackAction {
    _ = r catch |err| std.debug.panic("read: {}", .{err});
    got = addr;
    return .disarm;
}

fn sendFrom(v4: bool, port: u16) void {
    if (v4) {
        const fd = std.c.socket(std.c.AF.INET, std.c.SOCK.DGRAM, 0);
        var sa = std.mem.zeroes(std.c.sockaddr.in);
        if (@hasField(std.c.sockaddr.in, "len")) sa.len = @sizeOf(std.c.sockaddr.in);
        sa.family = std.c.AF.INET;
        sa.port = std.mem.nativeToBig(u16, port);
        sa.addr = std.mem.nativeToBig(u32, 0x7f000001);
        _ = std.c.sendto(fd, "hello", 5, 0, @ptrCast(&sa), @sizeOf(std.c.sockaddr.in));
    } else {
        const fd = std.c.socket(std.c.AF.INET6, std.c.SOCK.DGRAM, 0);
        var sa = std.mem.zeroes(std.c.sockaddr.in6);
        if (@hasField(std.c.sockaddr.in6, "len")) sa.len = @sizeOf(std.c.sockaddr.in6);
        sa.family = std.c.AF.INET6;
        sa.port = std.mem.nativeToBig(u16, port);
        sa.addr[15] = 1;
        _ = std.c.sendto(fd, "hello", 5, 0, @ptrCast(&sa), @sizeOf(std.c.sockaddr.in6));
    }
}

pub fn main(init: std.process.Init) !void {
    var it = std.process.Args.Iterator.init(init.minimal.args);
    _ = it.skip();
    const mode = it.next() orelse "v6";
    const v6 = std.mem.eql(u8, mode, "v6");

    var loop = try xev.Loop.init(.{});
    defer loop.deinit();

    const port: u16 = 38471;
    const addr = try std.Io.net.IpAddress.parse(if (v6) "::1" else "::", port);
    const udp = try xev.UDP.init(addr);
    if (!v6) {
        const off: c_int = 0;
        const v6only: u32 = if (builtin.os.tag == .linux) 26 else 27; // IPV6_V6ONLY
        _ = std.c.setsockopt(udp.fd, std.c.IPPROTO.IPV6, v6only, std.mem.asBytes(&off).ptr, @sizeOf(c_int));
    }
    try udp.bind(addr);

    var c: xev.Completion = .{};
    var state: xev.UDP.State = undefined;
    var buf: [64]u8 = undefined;
    udp.read(&loop, &c, &state, .{ .slice = &buf }, void, null, onRead);
    sendFrom(!v6, port);
    try loop.run(.until_done);

    const a = got.?.ip6;
    std.debug.print("backend {s}: source {x} port {d} (expected {s})\n", .{ @tagName(xev.backend), &a.bytes, a.port, if (v6) "::1" else "::ffff:127.0.0.1" });
}

Built with zig build-exe -lc --dep xev -Mroot=repro.zig -Mxev=<libxev>/src/main.zig.

macOS (kqueue)
$ ./repro v6
backend kqueue: source 00000000000000000000000000000000 port 57589 (expected ::1)
$ ./repro mapped
backend kqueue: source 00000000000000000000000000000000 port 55307 (expected ::ffff:127.0.0.1)
Linux (io_uring), same program
$ ./repro v6
backend io_uring: source 00000000000000000000000000000001 port 48034 (expected ::1)
$ ./repro mapped
backend io_uring: source 00000000000000000000ffff7f000001 port 59158 (expected ::ffff:127.0.0.1)

Suggested fix

Receive into a posix.sockaddr.storage in the kqueue recvfrom operation, as UDPSendMsg does for io_uring and epoll:

recvfrom: struct {
    fd: posix.fd_t,
    buffer: ReadBuffer,
    addr: posix.sockaddr.storage = undefined,
    addr_size: posix.socklen_t = @sizeOf(posix.sockaddr.storage),
},

Then pass @ptrCast(&op.addr) to recvfrom in perform, and convert from the storage in UDPSendto.read. I'm happy to send a PR if that approach looks right.

How we found it

We were writing a STUN responder on libxev. It binds one UDP socket to [::] with IPV6_V6ONLY=0, so the same socket answers IPv4 and IPv6 clients, reads requests with xev.UDP.read, and sends each reply to the source address the read callback reports.

The tests passed on Linux (io_uring). On macOS no client got an answer, over IPv4 or IPv6, and neither the read nor the write returned an error. The same responder had worked on macOS while it was bound to 0.0.0.0, because a sockaddr_in fits in 16 bytes. Reading the kqueue backend led to the 16-byte sockaddr in the recvfrom operation, and the program above confirmed the zeroed addresses.

Dominant language
Zig
Stars
3.6k
Forks
191
PR merge metrics
No merged PRs in 30d

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from mitchellh/libxev

All issues in mitchellh/libxev

Similar issues

More Networking issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.