Skip to content

lua-lsm: add skb payload accessors and hand out only full socks - #31

Open
chenzongyao200127 wants to merge 6 commits into
openanolis:lua-lsmfrom
chenzongyao200127:lua-lsm-skb-accessors
Open

lua-lsm: add skb payload accessors and hand out only full socks#31
chenzongyao200127 wants to merge 6 commits into
openanolis:lua-lsmfrom
chenzongyao200127:lua-lsm-skb-accessors

Conversation

@chenzongyao200127

@chenzongyao200127 chenzongyao200127 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Netlink and packet hooks receive an sk_buff, but the skb object only exposed
metadata (sock, protocol, iif, secmark). A policy that has to decide on
message contents — which netlink command is being issued, for instance — could
not be written at all.

This series adds the payload accessors, fixes a fail-open in skb:sock(), and
documents the one thing a content-aware policy cannot guess: where offset 0 is.

  • skb:len() / skb:read(off, len)read() returns the bytes as a string,
    or nil when the range falls outside the window, so the policy keeps the
    verdict instead of taking a Lua error that would fall back to the hook default
    and allow the operation. Reads go through skb_copy_bits(), so a non-linear
    skb is stitched together transparently, and one read is bounded by a fixed
    256-byte on-stack buffer.
  • sock:proto() — the raw sk_protocol. suites() renders that number through
    the IPPROTO_* namespace, which cannot name protocols of other families such
    as netlink's NETLINK_ROUTE.
  • skb:sock() now resolves skb->sk through skb_to_full_sk(). On handshake
    and time-wait paths skb->sk is a request or time-wait sock, which stops short
    of the fields sock:proto() and sock:suites() read, so a policy asking for
    the protocol number got neighbouring slab bytes — and since those pass for a
    valid number, the rule they feed failed open. A half-open connection now
    resolves to its listener and a time-wait sock yields nil. skb:full_sk()
    became a duplicate of skb:sock() and is dropped.
  • docs/API.md defines the data window (skb->len bytes from skb->data) once
    for both accessors and records where it starts in each hook that passes an skb.
    Each layer advances skb->data past its own header, so the base is a property
    of the hook, not of the packet — and getting it wrong is quiet: an out-of-range
    read yields nil, an in-range one yields whatever field happens to sit there.

Multi-byte fields are decoded in Lua, since the in-kernel Lua has no bit
library; the examples below use string.byte and modulo arithmetic.

Example policies

Deny route deletions over rtnetlink. Under netlink_send the window spans
everything one sendmsg() wrote, so a batch arrives as consecutive
length-delimited messages and the policy has to walk them.

local errno = require("errno")

local NETLINK_ROUTE = 0
local RTM_DELROUTE = 25
local NLMSG_HDRLEN = 16

-- struct nlmsghdr: u32 nlmsg_len, u16 nlmsg_type, u16 nlmsg_flags, ...
-- Netlink fields are in native byte order (little-endian here).
local function u16(skb, off)
    local s = skb:read(off, 2)
    if not s then
        return nil
    end
    local lo, hi = s:byte(1, 2)
    return lo + hi * 256
end

local function u32(skb, off)
    local s = skb:read(off, 4)
    if not s then
        return nil
    end
    local b1, b2, b3, b4 = s:byte(1, 4)
    return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
end

return {
    name = "netlink_route_guard",
    author = "example",
    description = "Deny RTM_DELROUTE over rtnetlink",
    license = "GPL-2.0",
    version = 1,

    netlink_send = function(sk, skb)
        if sk:proto() ~= NETLINK_ROUTE then
            return
        end

        local off, total = 0, skb:len()
        while off + NLMSG_HDRLEN <= total do
            local len = u32(skb, off)
            local nlmsg_type = u16(skb, off + 4)

            -- A truncated or nonsensical header is not something to guess at.
            if not len or not nlmsg_type or len < NLMSG_HDRLEN then
                return false, errno.EINVAL
            end
            if nlmsg_type == RTM_DELROUTE then
                return false, errno.EPERM
            end

            -- NLMSG_ALIGN(len), without a bit library.
            local aligned = len + 3
            off = off + aligned - aligned % 4
        end
    end,
}

Log inbound TCP SYNs to a port. Under socket_sock_rcv_skb offset 0 is the TCP
header on the IPv4/IPv6 TCP path — but the same hook fires for netlink, SCTP,
unix and raw sockets, where the window starts somewhere else, so the family
check is not optional.

local audit = require("audit")

return {
    name = "tcp_syn_watch",
    author = "example",
    description = "Audit inbound SYNs to the SSH port",
    license = "GPL-2.0",
    version = 1,

    socket_sock_rcv_skb = function(sk, skb)
        if not sk:is_tcp() then
            return
        end

        -- struct tcphdr: u16 source, u16 dest, ..., u8 flags at offset 13.
        -- Wire fields are big-endian.
        local hdr = skb:read(0, 14)
        if not hdr then
            return
        end
        local dport = hdr:byte(3) * 256 + hdr:byte(4)
        local flags = hdr:byte(14)
        local syn = flags % 4 >= 2
        local ack = flags % 32 >= 16

        if dport == 22 and syn and not ack then
            audit.log{ op = "tcp_syn", dport = dport, len = skb:len() }
        end
    end,
}

skb:sock() can return nil — no owning socket, or a time-wait sock that is
not a full sock — so a policy that reads through it needs the nil check;
indexing nil raises, and a Lua error inside a hook falls back to that hook's
default, which is "allow" for most hooks. Wrap parsing in pcall() where the
policy must fail closed.

Test plan

  • Load both example policies and confirm the verdicts (ip route del denied,
    SYNs to port 22 audited).
  • skb:read() boundaries: off == skb:len() with len == 0, len == 256,
    len > 256, negative off/len, and a read spanning a non-linear skb.
  • skb:sock() on a SYN (inet_conn_request) returns the listener, and on a
    time-wait skb returns nil rather than a mini-sock.
  • Offsets in docs/API.md re-checked against the hook call sites for each
    row of the table.

A netlink or packet policy has to look at message contents to make a
decision, but the skb object only exposed metadata, so such policies
could not be written at all. Expose the payload, and the raw protocol
number that suites() cannot name, so content-aware policies become
possible.

Signed-off-by: Zongyao Chen <ZongYao.Chen@linux.alibaba.com>
So policy authors can find the accessors and know a bad read yields nil
rather than an error, keeping the decision in the policy's hands.

Signed-off-by: Zongyao Chen <ZongYao.Chen@linux.alibaba.com>
On handshake and time-wait paths skb->sk is a request or time-wait sock,
which stops short of the fields sock:proto() and sock:suites() read. A
policy asking for the protocol number therefore got neighbouring slab
bytes, and since those pass for a valid number the rule they feed fails
open rather than erroring.

Resolve skb->sk through skb_to_full_sk() so a sock handed to a policy is
always a full sock, making the guarantee a property of the object rather
than of each accessor. skb:full_sk() then duplicates skb:sock() and has
no users, so drop it.

Signed-off-by: Zongyao Chen <ZongYao.Chen@linux.alibaba.com>
A policy author cannot tell from the method name that a half-open or
time-wait connection resolves to the listener or to nil, and would read
the nil as "no owning socket" instead of "not a full sock".

Signed-off-by: Zongyao Chen <ZongYao.Chen@linux.alibaba.com>
The helper is local to lua_net.c, but skb_ is netcore's prefix and the
function sits two lines from skb_copy_bits() and skb_to_full_sk(), so
nothing tells a reader it is not a core networking helper. Every other
static function in the file carries the module prefix.

Signed-off-by: Zongyao Chen <ZongYao.Chen@linux.alibaba.com>
skb:len() was documented as "payload length", but it measures the window
starting at skb->data, and each layer advances that pointer past its own
header, so at most hooks the window still covers a header. A policy
author trusting the word "payload" computes every skb:read() offset from
the wrong base, and an out-of-range read yields nil rather than an error,
so the mistake never surfaces.

Define the window once for both accessors and record where it starts in
each hook that passes an skb, since that is the only thing an offset can
be computed from.

Signed-off-by: Zongyao Chen <ZongYao.Chen@linux.alibaba.com>
@chenzongyao200127 chenzongyao200127 changed the title lua-lsm: add skb payload accessors lua-lsm: add skb payload accessors and hand out only full socks Aug 6, 2026
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.

1 participant