kqueue: UDP read reports a truncated source address for IPv6 senders
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:
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::1mapped: bound to[::]withIPV6_V6ONLY=0, sent from127.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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from mitchellh/libxev
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
-
Difficulty 5/5 Over a week Newbie friendliness 32/100
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
-
Difficulty 5/5 Over a week Newbie friendliness 42/100
-
Difficulty 4/5 3-5 days Newbie friendliness 38/100
All issues in mitchellh/libxev
Similar issues
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
TheManticoreProject/Manticore#1380 ·
-
P3 sonic-vpp
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
sonic-net/sonic-buildimage#29662 ·
-
support
Difficulty 1/5 Under an hour Newbie friendliness 88/100
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100