Netdev List
 help / color / mirror / Atom feed
From: Jakub Kicinski <kuba@kernel.org>
To: omniameasunt@proton.me
Cc: Jakub Kicinski <kuba@kernel.org>,
	netdev@vger.kernel.org, paul@paul-moore.com
Subject: Re: [PATCH net v2] cipso_v4: fail the walk on malformed options
Date: Fri, 30 Oct 2026 19:03:17 -0700	[thread overview]
Message-ID: <20261031020317.3415448-1-kuba@kernel.org> (raw)
In-Reply-To: <uOcTaW7-g5e97ng6BG_DcH5mlMnpw7KzU25SZjL69pkAVINTMiL26FryLAuYPSNowDo8qwfb9hm_I4dQ1BwpiRe62E-I2TyUkJZ_eBeuLoo=@proton.me>

This is an AI-generated review of your patch. The human sending this
email has considered the AI review valid, or at least plausible.
---
cipso_v4: fail the walk on malformed options

This patch makes cipso_v4_get_actual_opt_len() return -EINVAL when an
option is truncated or malformed. It also makes cipso_v4_delopt() and
cipso_v4_skbuff_delattr() bail out on malformed streams. In addition,
cipso_v4_delopt() now builds the compacted options in a new
ip_options_rcu, publishes it with rcu_assign_pointer() and retires the
old object with kfree_rcu().

>   * a stack buffer overflow when optlen exceeds the 60-byte scratch
>     buffer (return-address overwrite; exploitable for privilege
>     escalation on kernels built without stack protector).

[Severity: Low]

Is the buffer size right here? In do_ip_getsockopt() the buffer is:

	unsigned char optbuf[sizeof(struct ip_options)+40];

struct ip_options is 16 bytes: two __be32 fields plus eight one-byte
fields, and the flexible __data[] adds nothing. That makes optbuf 56
bytes.

The copy into it is:

	memcpy(optbuf, &inet_opt->opt,
	       sizeof(struct ip_options) +
	       inet_opt->opt.optlen);

This goes through the fortified memcpy(), and the destination size is
known at compile time. Wouldn't CONFIG_FORTIFY_SOURCE also trap the
oversized write? The stated exploitability condition seems incomplete
without that.

> cipso_v4_delopt() then drops the
> socket's entire option set (reusing the existing branch that frees the
> ip_options_rcu when CIPSO was the only option)

The patch doesn't reuse that branch. It adds a new drop_all label that
copies the same logic. Could the wording be adjusted?

> Well-formed option streams are unaffected: a socket whose options
> survive the compaction intact keeps them; only malformed streams now
> lose all options instead of carrying a partially parsed set.

[Severity: Medium]

Is this accurate? cipso_v4_delopt() now walks only the compacted region
and fills the stale tail with IPOPT_END:

	optlen = new->opt.optlen - cipso_len;
	memset(new->opt.__data + optlen, IPOPT_END, cipso_len);
	optlen_new = cipso_v4_get_actual_opt_len(new->opt.__data, optlen);

Before the patch the walk covered the old optlen. If no IPOPT_END was
left in the compacted region, the walk ran into the stale tail and
counted those bytes a second time.

Well-formed options can hit this. ip_options_echo() writes rr/ts/srr
first and CIPSO last, and pads only up to a 4-byte boundary. Suppose
CIPSO ends exactly on that boundary:

  - memmove() copies 0 bytes
  - the CIPSO bytes get walked again
  - optlen_new equals the old optlen and hdr_delta is 0
  - opt.cipso is cleared, but the CIPSO bytes stay in the options and
    go out on the wire

Socket options such as CIPSO(8)+RR(8) with no padding behave the same
way: RR ends up duplicated and optlen does not shrink.

With this patch CIPSO really is removed in those cases. That looks like
a second bug being fixed. Could the commit message mention it?

The message also leaves out the request socket path. There,
cipso_v4_delopt() runs on options echoed from a remote SYN when the peer
maps to an unlabeled domain:

netlbl_req_setattr()
  netlbl_req_delattr()
    cipso_v4_req_delattr()
      cipso_v4_delopt()

Would it be worth describing that caller too?

> diff --git a/net/ipv4/cipso_ipv4.c b/net/ipv4/cipso_ipv4.c
> index 1aacbbeffbc64..e618cbbd56c6a 100644
> --- a/net/ipv4/cipso_ipv4.c
> +++ b/net/ipv4/cipso_ipv4.c

[ ... ]

> @@ -1992,53 +1996,79 @@ int cipso_v4_req_setattr(struct request_sock *req,
>   *
>   * Description:
>   * Deletes the CIPSO IP option from a set of IP options and makes the necessary
> - * adjustments to the IP option structure.  Returns zero on success, negative
> - * values on failure.
> + * adjustments to the IP option structure.  Returns zero or a positive header
> + * length delta on success, negative values on failure.
>   *
>   */
>  static int cipso_v4_delopt(struct ip_options_rcu __rcu **opt_ptr)
>  {
>  	struct ip_options_rcu *opt = rcu_dereference_protected(*opt_ptr, 1);
> +	struct ip_options_rcu *new;

[ ... ]

> +	new = kzalloc(sizeof(*new) + opt->opt.optlen, GFP_ATOMIC);
> +	if (!new)
> +		return -ENOMEM;

[Severity: Medium]

Before this patch cipso_v4_delopt() could not fail. Can the callers
handle the new -ENOMEM return? On this path *opt_ptr is not touched, so
the ip_options_rcu that still carries CIPSO stays published.

None of the callers check for a negative value:

net/ipv4/cipso_ipv4.c:cipso_v4_sock_delattr() {
    ...
	hdr_delta = cipso_v4_delopt(&sk_inet->inet_opt);
	if (inet_test_bit(IS_ICSK, sk) && hdr_delta > 0) {
    ...
}

net/ipv4/cipso_ipv4.c:cipso_v4_req_delattr() {
	cipso_v4_delopt(&inet_rsk(req)->ireq_opt);
}

The layers above don't see the failure either:

  - netlbl_sock_delattr() and netlbl_req_delattr() return void.
  - netlbl_conn_setattr() and netlbl_req_setattr() set ret_val = 0 in
    the NETLBL_NLTYPE_UNLABELED case.
  - selinux_netlbl_socket_connect_helper() and smack_netlbl_delete()
    (which sets SMK_NETLBL_UNLABELED) then update their state as if
    the label had been removed.

After a GFP_ATOMIC failure, wouldn't packets to an unlabeled destination
still carry the CIPSO option? The same question applies to the SYN-ACK
and the child socket on the request path.

The updated kernel-doc says negative values mean failure, but no caller
acts on that. The drop_all fallback below needs no allocation. Would
using it here keep the CIPSO option always removed, as before?

> +	memcpy(&new->opt, &opt->opt, sizeof(new->opt) + opt->opt.optlen);

[ ... ]

> +	hdr_delta = opt->opt.optlen;
> +	new->opt.optlen = (optlen_new + 3) & ~3;
> +	hdr_delta -= new->opt.optlen;
> +	rcu_assign_pointer(*opt_ptr, new);
> +	kfree_rcu(opt, rcu);

[Severity: High]

In the multi-option branch, the old ip_options_rcu used to be modified
in place and was never freed. It is now replaced and passed to
kfree_rcu(). Is every writer of inet->inet_opt serialized against this?

The Smack caller doesn't seem to be. smk_ipv4_check() is called from
smack_socket_connect() and smack_socket_sendmsg() without lock_sock(),
and it gets here through:

smack_netlbl_delete()
  netlbl_sock_delattr()
    cipso_v4_sock_delattr()
      cipso_v4_delopt()

Only the socket spinlock is held:

security/smack/smack_lsm.c:smack_netlbl_delete() {
    ...
	local_bh_disable();
	bh_lock_sock_nested(sk);
	netlbl_sock_delattr(sk);
	bh_unlock_sock(sk);
	local_bh_enable();
	ssp->smk_state = SMK_NETLBL_UNLABELED;
}

bh_lock_sock_nested() takes only sk_lock.slock.
do_ip_setsockopt(IP_OPTIONS) holds socket ownership through
sockopt_lock_sock(), which has already released slock. So the two paths
don't exclude each other:

CPU1: smack_netlbl_delete()->...->cipso_v4_delopt()
    opt = rcu_dereference_protected(*opt_ptr, 1);

CPU2: do_ip_setsockopt(IP_OPTIONS)
    old = rcu_dereference_protected(inet->inet_opt, ...);

CPU1:
    rcu_assign_pointer(*opt_ptr, new);
    kfree_rcu(opt, rcu);

CPU2:
    rcu_assign_pointer(inet->inet_opt, opt);
    if (old)
        kfree_rcu(old, rcu);

Here opt and old point to the same object. Could this queue call_rcu()
twice on the same rcu_head and free that object twice? It would also
leak one of the two replacement objects.

Because cipso_v4_delopt() uses rcu_dereference_protected(*opt_ptr, 1),
lockdep can't flag the missing exclusion either.

The Smack locking predates this patch, and so does the same free pattern
in the only-CIPSO branch. This patch extends the free to every option
set that includes CIPSO. One example is CIPSO+RR set through
setsockopt(IP_OPTIONS) with CAP_NET_RAW in a user namespace.

> +	return hdr_delta;
> +
> +drop_all:
> +	kfree(new);
> +	rcu_assign_pointer(*opt_ptr, NULL);
> +	hdr_delta = opt->opt.optlen;
> +	kfree_rcu(opt, rcu);
>  	return hdr_delta;
>  }

[Severity: Low]

The kernel-doc for cipso_v4_delopt() still says it "Deletes the CIPSO IP
option from a set of IP options". The kernel-doc for
cipso_v4_sock_delattr() says it "Removes the CIPSO option from a socket".

The drop_all path frees the whole option set, srr/rr/ts/router_alert
included, and returns a positive hdr_delta. The updated Returns sentence
reserves negative values for failure. Yet a malformed stream is reported
as success, and only an allocation failure returns a negative value.

Should the kernel-doc describe this behaviour? drop_all looks
unreachable for options that went through ip_options_compile() or
ip_options_echo(), so this only affects the documentation.

[ ... ]

> @@ -2263,12 +2297,19 @@ int cipso_v4_skbuff_delattr(struct sk_buff *skb)
>  		return ret_val;
>  
>  	iph = ip_hdr(skb);
> +	if (opt->optlen > (iph->ihl << 2) - sizeof(struct iphdr))
> +		return -EINVAL;

[Severity: Low]

sizeof(struct iphdr) is a size_t, so this comparison is unsigned. When
iph->ihl < 5, (iph->ihl << 2) - sizeof(struct iphdr) wraps to a huge
value and the check always passes. Is that intended?

The code further down relies on this guard to keep hdr_len_delta
non-negative:

	hdr_len_delta = (iph->ihl << 2) - new_hdr_len;
	...
	memmove((unsigned char *)iph + hdr_len_delta, iph, new_hdr_len_actual);
	...
	skb_pull(skb, hdr_len_delta);

With a negative delta, memmove() would write below the network header
and skb_pull() would be passed a huge length.

ip_rcv_core() drops packets with ihl < 5, and the local output paths
always build ihl >= 5. Reaching this would need something like
privileged netfilter payload mangling. Would a signed comparison, or an
explicit iph->ihl < 5 check, make the new guard cover the case it was
added for?

[ ... ]

  parent reply	other threads:[~2026-10-31  2:03 UTC|newest]

Thread overview: 4+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-27 11:16 [PATCH net v2] cipso_v4: fail the walk on malformed options omniameasunt
2026-10-29  1:11 ` Paul Moore
2026-10-31  2:03 ` Jakub Kicinski [this message]
  -- strict thread matches above, loose matches on Subject: below --
2026-10-27 11:21 netdev-bot+sinfo

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261031020317.3415448-1-kuba@kernel.org \
    --to=kuba@kernel.org \
    --cc=netdev@vger.kernel.org \
    --cc=omniameasunt@proton.me \
    --cc=paul@paul-moore.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox