Skip to content

Layer 2 sockets over a PACKET_MMAP ring buffer - #5083

Draft
polybassa wants to merge 1 commit into
secdev:masterfrom
polybassa:ringbuf-supersocket
Draft

Layer 2 sockets over a PACKET_MMAP ring buffer#5083
polybassa wants to merge 1 commit into
secdev:masterfrom
polybassa:ringbuf-supersocket

Conversation

@polybassa

@polybassa polybassa commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What this adds

scapy/arch/linux/ringbuf.py, a Linux packet socket that carries its packets
through a ring of frames shared with the kernel (TPACKET_V2) instead of
recvfrom() and send(). The ring is mapped once, so receiving a packet is a
read out of memory, and sending one is a write followed by a single send()
that hands the filled frames over.

  • L2RingSocket — receive and transmit rings, an L2Socket subclass, so
    sniff(opened_socket=...), sr1(), srp() and conf.L2socket = L2RingSocket
    work unchanged. select() on a packet socket already reports the ring, so no
    change was needed in the sniffing loop.
  • L2ListenRingSocket — the listen-only variant, which keeps the outgoing
    packets and refuses to send.
  • RingSpec — the geometry (frame size, block size, block count), validated
    here rather than letting the kernel answer a bad one with a bare EINVAL.
  • sock.stats() — the packets the kernel put in the ring and the ones it had to
    drop, which is the counter you want when a capture comes up short.
  • python -m scapy.arch.linux.ringbuf eth0 /run/scapy.sock --owner scapyuser
    the privileged side of the handover below, from the command line.
    Nothing else in the tree changes, apart from one line adding the module to
    .config/mypy/mypy_enabled.txt.

Running Scapy without privileges

The kernel checks CAP_NET_RAW when a packet socket is created. Mapping its
rings, binding it, receiving and sending on it do not. A privileged program can
therefore open the socket and pass the descriptor to an unprivileged Scapy,
which then sniffs and sends on it as if it had opened it itself:

# in the privileged program
sock = L2RingSocket(iface="eth0")
sock.share(unix_socket)

in Scapy, without privileges

sock = L2RingSocket.from_unix(unix_socket)
sniff(opened_socket=sock, count=10)
srp1(Ether() / IP(dst="1.1.1.1") / ICMP(), opened_socket=sock)

share() sends the descriptor over SCM_RIGHTS together with the geometry of
the rings, which has to travel with it because the kernel offers no way to ask a
socket about its rings. from_fd(fd, rx, tx) is the lower-level entry for a
descriptor passed some other way, for instance inherited across an exec.

Two details the handover has to get right:

  • The interface stays in promiscuous mode for as long as the process that put it
    there holds the socket, so the receiving side never drops that membership on
    close. The command line server hands each client a socket of its own and lets
    go of it without taking the interface back out of promiscuous mode.
  • The read position of a ring only follows the position the kernel writes at as
    long as it started with it. A ring taken over while it was already running
    gives neither, so the timestamps of its frames tell where to pick it up. The
    scan only happens when the socket is woken with no frame at its own position,
    never on the normal path.

Testing

test/linux_ringbuf.uts, 17 tests, marked ~ linux needs_root veth and picked
up by the existing test/*.uts glob:

  • a packet in through the receive ring and one out through the transmit ring
  • a burst of 300 packets arriving whole and in order
  • wrap-around on a ring of four frames
  • sniff() and sr1() over a ring socket
  • padding of a frame too short for the wire, and refusal of one larger than a
    frame
  • the fallback to a plain send() when there is no transmit ring
  • the listen socket keeping what goes out and sending nothing
  • the handover over a UNIX socket, including a ring taken over mid-stream, and a
    message that carries no socket
    Beyond the suite, the rootless claim was checked with a child process in a
    nested user namespace, holding no capability over the network namespace: it
    could not open a packet socket of its own (PermissionError), yet sniffed and
    sent through the one it was handed, both directly and through the command line
    server.
    mypy and flake8 are clean on the new module.

Notes

  • The frame size caps the packet size: jumbo frames need RingSpec(frame_size=…)
    raised from the 2048-byte default, and what does not fit is dropped by the
    kernel and shows up in stats().
  • The default geometry is 2 MiB per ring (1024 frames of 2 KiB).
  • Anyone allowed to connect to the UNIX socket gets raw access to the interface,
    which is why the server defaults to mode 0600 and takes an explicit --owner.

A packet socket can carry its packets through a ring of frames it shares
with the kernel, mapped once, so that receiving one is a read out of
memory and sending one a write followed by a send() that hands the
filled frames over.

The kernel only asks for CAP_NET_RAW when a packet socket is created:
mapping its rings, binding, receiving and sending do not. A privileged
program can therefore set a socket up and pass it over a UNIX socket to
an unprivileged Scapy, which sniffs and sends on it as if it had opened
it itself. The geometry of the rings travels with the descriptor, as the
kernel offers no way to ask a socket about it.

The read position of a ring only follows the position the kernel writes
at as long as it started with it, which a ring taken over while it was
already running does not, so the timestamps of its frames tell where to
pick it up.

AI-Assisted: yes (Cursor)
Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.78641% with 81 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.55%. Comparing base (b1a9799) to head (3a43a60).

Files with missing lines Patch % Lines
scapy/arch/linux/ringbuf.py 73.78% 81 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5083      +/-   ##
==========================================
+ Coverage   80.05%   80.55%   +0.49%     
==========================================
  Files         390      391       +1     
  Lines       96810    97119     +309     
==========================================
+ Hits        77499    78231     +732     
+ Misses      19311    18888     -423     
Files with missing lines Coverage Δ
scapy/arch/linux/ringbuf.py 73.78% <73.78%> (ø)

... and 22 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@polybassa
polybassa requested review from gpotter2 and a lite review from Copilot August 12, 2026 08:45
@polybassa

Copy link
Copy Markdown
Contributor Author

@gpotter2 What do you think about this PoC

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Linux PF_PACKET Layer-2 sockets backed by a PACKET_MMAP (TPACKET_V2) ring buffer, enabling zero-copy-ish receive/transmit and allowing privileged socket creation to be handed off to an unprivileged Scapy process via UNIX fd passing.

Changes:

  • Introduces scapy/arch/linux/ringbuf.py with RingSpec, L2RingSocket, L2ListenRingSocket, fd handover helpers, and a CLI server mode.
  • Adds UTscapy coverage for ring geometry validation, rx/tx behavior over veth, wrap-around, sniff/sr1 integration, and UNIX-socket handover (test/linux_ringbuf.uts).
  • Enables mypy checking for the new module by listing it in .config/mypy/mypy_enabled.txt.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
scapy/arch/linux/ringbuf.py Implements PACKET_MMAP ring-backed L2 sockets, fd sharing/restore, and a CLI socket-sharing server.
test/linux_ringbuf.uts Adds regression tests for ring geometry, rx/tx behavior, and fd handover scenarios.
.config/mypy/mypy_enabled.txt Adds the new ringbuf module to the mypy-enabled list.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +128 to +133
if frame_size <= _TPACKET2_HDRLEN or frame_size % TPACKET_ALIGNMENT:
raise ValueError(
"frame_size must be over %d and a multiple of %d" % (
_TPACKET2_HDRLEN, TPACKET_ALIGNMENT
)
)
Comment on lines +368 to +373
poller = select.poll()
poller.register(self.ins.fileno(), events)
timeout = None # type: Optional[float]
if deadline is not None:
timeout = max(0.0, deadline - time.monotonic()) * 1000
return bool(poller.poll(timeout))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants