Re: [PATCH] SUNRPC: fix out-of-bounds write in xdr_inline_pages()
From: Anna Schumaker
Date: Fri Aug 21 2026 - 08:57:57 EST
Hi Paula,
On Thu, Aug 20, 2026, at 10:30 AM, Paula Moutafian wrote:
> xdr_inline_pages() re-carves an existing receive buffer into a head, a
> page list and a tail at a caller-supplied offset, computing the tail
> length as buflen - offset. It does not check that the offset lies
> within the buffer being carved, and iov_len is unsigned, so an offset
> past the end wraps the tail length instead of failing.
>
> The offset reaches it from wire data. rpc_prepare_reply_pages() derives
> it from the authentication flavour's au_ralign, which is read on every
> encode, while the receive buffer's size is fixed once in call_allocate()
> from au_rslack. unx_validate() raises au_verfsize, au_rslack and
> au_ralign from the reply verifier's length, and runs before
> rpc_decode_header() reads the accept_stat, so a GARBAGE_ARGS reply
> poisons the alignment and only then selects call_encode().
> call_allocate() short-circuits on the existing buffer, so rq_rcvsize is
> not recomputed and the retry carves a new offset against the old size.
> auth_unix keeps these fields in a file-scope static rpc_auth shared by
> every AUTH_SYS client.
>
> For an NFSv3 READ the design slack is exactly one XDR word, so the
> underflow needs only au_ralign at encode to exceed au_rslack at
> allocation by two -- a five-byte verifier body. Observed with eight:
> rq_rcvsize is 132 with a 128-byte head and a 4-byte tail, the verifier
> raises au_ralign from 2 to 4, and the retry carves at (26 + 4 + 4) << 2
> = 136 against an unchanged 132-byte buffer. The in-tree
> sunrpc:rpc_xdr_reply_pages tracepoint shows the transition on one task:
>
> rpc_xdr_reply_pages: head=[..a18,128] tail=[..a98,4]
> rpc_xdr_reply_pages: head=[..a18,136] tail=[..aa0,4294967292]
>
> (one task, before and after the re-encode; addresses abbreviated)
>
> xs_read_xdr_buf() then fills the tail against that length, and the TCP
> record length is not capped against rq_rcvsize, so the reply to the
> retried call writes peer-controlled bytes past the object:
>
> BUG: KASAN: slab-out-of-bounds in _copy_to_iter+0x7c8/0x1538
> Write of size 1664 at addr ffff0000c7640aa0 by task kworker/u8:0/12
> Workqueue: xprtiod xs_stream_data_receive_workfn
> _copy_to_iter+0x7c8/0x1538
> __skb_datagram_iter+0x33c/0x560
> skb_copy_datagram_iter+0x3c/0x454
> tcp_recvmsg_locked+0x110c/0x2308
> xs_sock_recvmsg.constprop.0+0x34/0xe4
> xs_read_stream_request.constprop.0+0x410/0x1140
> xs_read_stream.constprop.0+0x680/0xe9c
> xs_stream_data_receive_workfn+0xcc/0x420
> Allocated by task 12:
> rpc_malloc+0x174/0x2d0
> call_allocate+0x25c/0x944
> The buggy address belongs to the object at ffff0000c7640880
> which belongs to the cache rpc_buffers of size 2048
> The buggy address is located 544 bytes inside of
> allocated 2048-byte region [ffff0000c7640880, ffff0000c7641080)
>
> That is 160 bytes past the object, with both the length and every byte
> chosen by the peer. Under KASAN the interposed __asan_memcpy() returns
> without calling __memcpy once the range check fails, so the report
> establishes that the write is reachable and out of bounds; it does not
> establish that the copy executed. The enlarged head stays inside the
> same slab object and is not itself a violation.
>
> Reproduced twice on an unmodified v7.2-rc7 arm64 KASAN kernel with
> CONFIG_NFS_V3=y and no other configuration change; the geometry above
> came from tracepoints that already ship in the kernel, not from added
> instrumentation. The trigger is an ordinary read(2) on an already
> mounted NFSv3 AUTH_SYS share over TCP and needs no client privilege;
> every byte the attacker supplies is a legal server reply. Under
> RPCSEC_GSS the verifier is authenticated and cannot be forged. UDP is
> bounded by the datagram length in xs_udp_data_read_skb() and is not
> affected. NFSv4 does not take the retry path, since
> RPC_TASK_NO_RETRANS_TIMEOUT suppresses the re-encode.
>
> Commit 53bc19f17f21 ("SUNRPC: receive buffer size estimation values
> almost never change") placed the equivalent slack update behind
> RPCAUTH_AUTH_UPDATE_SLACK in gss_update_rslack(), but auth_unix was
> never converted and still assigns unconditionally. Gating it the same
> way narrows the window without closing it, because the first update can
> still land between the allocation and the re-encode of an in-flight
> request, and it would leave the other caller of xdr_inline_pages()
> unguarded.
>
> Clamp the offset to the length of the buffer being carved so the tail
> length cannot underflow.
>
> Assisted-by: Bynario AI
> Signed-off-by: Paula Moutafian <paula@xxxxxxxx>
> ---
> Note: the defect is reproduced 2/2 on an unmodified v7.2-rc7 arm64 KASAN
> kernel; the patched kernel has not been built or booted. A reproducer
> exists and can be shared privately on request.
Question: Am I understanding you correctly that you have a reproducer, but
haven't tested or even compiled the code to verify that it works?
Thanks,
Anna
>
> No Fixes: tag: the unbounded carve is original to xdr_inline_pages()
> (unchanged since the initial git import) and unx_validate() has written
> the shared rpc_auth's slack from the reply verifier since at least
> v2.6.32, so there is no commit that introduced this.
>
> net/sunrpc/xdr.c | 3 +++
> 1 file changed, 3 insertions(+)
>
> diff --git a/net/sunrpc/xdr.c b/net/sunrpc/xdr.c
> index fa6a30b..f77dbdb 100644
> --- a/net/sunrpc/xdr.c
> +++ b/net/sunrpc/xdr.c
> @@ -409,6 +409,9 @@ xdr_inline_pages(struct xdr_buf *xdr, unsigned int offset,
> char *buf = (char *)head->iov_base;
> unsigned int buflen = head->iov_len;
>
> + if (offset > buflen)
> + offset = buflen;
> +
> head->iov_len = offset;
>
> xdr->pages = pages;
>
> base-commit: a4ff2be345d0abc943da8dd8da98151843b750dc
> --
> 2.50.1 (Apple Git-155)