Thank you for your contribution! Sashiko AI review found 2 potential
issue(s) to consider.

Critical: 0 · High: 1 (1) · Medium: 0 · Low: 1

If you believe none of the issues are legitimate, please reply to each
issue of Medium severity or higher and explain why the AI is wrong.
If any of them are legitimate and you plan to respin, please let patchwork
know by including "pw-bot: cr" as a separate line at the end of your reply
(one such reply per series is enough).

- [High] ipvs: use-after-free in ip_vs_conn_expire() due to timer re-
  arming race
- [Low] ip_vs_conn_del_put() (net/netfilter/ipvs/ip_vs_conn.c:1333-1346)
  keeps its old comment "Try to delete connection while holding…

Pre-existing issues:
- [High] ip_vs_conn_del_put() (net/netfilter/ipvs/ip_vs_conn.c:1336-1341)
  cancels the controller's timer, drops the caller's only reference on…

--

ipvs: avoid stack overflow from recursive connection expiration

This changes ip_vs_conn_del_put() to return whether it managed to delete
the controller's timer, and makes ip_vs_conn_expire() loop back with the
controller instead of recursing into itself.  The stated goal is to keep
chain cleanup synchronous while using a single stack frame.

v1 Link: https://lore.kernel.org/all/[email protected]/

> diff --git a/net/netfilter/ipvs/ip_vs_conn.c b/net/netfilter/ipvs/ip_vs_conn.c
> index 6fa3e1dc534c3..c7b88ce1765dc 100644
> --- a/net/netfilter/ipvs/ip_vs_conn.c
> +++ b/net/netfilter/ipvs/ip_vs_conn.c
> @@ -1331,17 +1331,18 @@ static void ip_vs_conn_del(struct ip_vs_conn *cp)
>  }
>  
>  /* Try to delete connection while holding reference */
> -static void ip_vs_conn_del_put(struct ip_vs_conn *cp)
> +static bool ip_vs_conn_del_put(struct ip_vs_conn *cp)
>  {
>       if (timer_delete(&cp->timer)) {
>               /* Drop cp->control chain too */
>               if (cp->control)
>                       cp->timeout = 0;
>               __ip_vs_conn_put(cp);
> -             ip_vs_conn_expire(&cp->timer);
> -     } else {
> -             __ip_vs_conn_put(cp);
> +             return true;
>       }
> +
> +     __ip_vs_conn_put(cp);
> +     return false;
>  }

[Severity: Low]
This isn't a bug, but should the comment above ip_vs_conn_del_put() be
updated?  On the success path the helper no longer deletes or expires the
connection, it just cancels the timer, drops the caller's reference and
hands the expiration back to the caller.

The sibling helper still keeps a nearly identical comment while doing the
expiration itself:

        /* Try to delete connection while not holding reference */
        static void ip_vs_conn_del(struct ip_vs_conn *cp)
        {
                if (timer_delete(&cp->timer)) {
                        ...
                        ip_vs_conn_expire(&cp->timer);
                }
        }

Could the new return value be documented, e.g. "returns true if the timer
was cancelled and the caller must continue expiring @cp"?  Otherwise a
future caller of ip_vs_conn_del_put() written against the current comment,
like the ip_vs_conn_del() users in ip_vs_random_dropentry() and
expire_nodest_conn_handler(), would cancel the timer and drop the reference
while leaving the conn hashed and never expired.

>  
>  static void ip_vs_conn_expire(struct timer_list *t)
> @@ -1349,6 +1350,7 @@ static void ip_vs_conn_expire(struct timer_list *t)
>       struct ip_vs_conn *cp = timer_container_of(cp, t, timer);
>       struct netns_ipvs *ipvs = cp->ipvs;
>  
> +repeat:
>       /*
>        *      do I control anybody?
>        */
> @@ -1358,6 +1360,7 @@ static void ip_vs_conn_expire(struct timer_list *t)
>       /* Unlink conn if not referenced anymore */
>       if (likely(ip_vs_conn_unlink(cp))) {
>               struct ip_vs_conn *ct = cp->control;
> +             bool next = false;
>  
>               /* delete the timer if it is activated by other users */
>               timer_delete(&cp->timer);
> @@ -1372,7 +1375,7 @@ static void ip_vs_conn_expire(struct timer_list *t)
>                           (!(ct->flags & IP_VS_CONN_F_TEMPLATE) ||
>                            !(ct->state & IP_VS_CTPL_S_ASSURED))) {
>                               IP_VS_DBG(4, "drop controlling connection\n");
> -                             ip_vs_conn_del_put(ct);
> +                             next = ip_vs_conn_del_put(ct);
>                       } else if (has_ref) {
>                               __ip_vs_conn_put(ct);
>                       }
> @@ -1402,6 +1405,10 @@ static void ip_vs_conn_expire(struct timer_list *t)
>               else
>                       call_rcu(&cp->rcu_head, ip_vs_conn_rcu_free);
>               atomic_dec(&ipvs->conn_count);
> +             if (next) {
> +                     cp = ct;
> +                     goto repeat;
> +             }
>               return;
>       }

[Severity: High]
One more angle on the deferral window, from the packet path side.

After ip_vs_conn_del_put(ct) returns true we own ct only by
convention: its timer has been cancelled, our reference has already
been dropped by the __ip_vs_conn_put(ct) inside the helper, and ct is
still hashed and still findable by ip_vs_conn_in_get() /
ip_vs_conn_out_get().  Nothing in ct's state records that this context
intends to come back and expire it.

So while we finish tearing down cp -- ip_vs_conn_drop_conntrack(),
ip_vs_unbind_app(), ip_vs_unbind_dest(), call_rcu() and the
atomic_dec(&ipvs->conn_count) -- a packet arriving on another CPU can
look ct up, take a reference, and then release it through
ip_vs_conn_put(), which re-arms the timer unconditionally via
mod_timer(&cp->timer, jiffies + cp->timeout).  With ct->timeout forced
to 0 by ip_vs_conn_del_put() when ct has a controller of its own, that
timer is due immediately, so ip_vs_conn_expire() can run for ct on that
CPU, win the refcount_dec_if_one() in ip_vs_conn_unlink(), and hand ct
to call_rcu(&ct->rcu_head, ip_vs_conn_rcu_free).

When this context then does "cp = ct; goto repeat;", the first thing it
touches is atomic_read(&cp->n_control) on a connection that has already
been unlinked and queued for freeing -- and since the entries via
ip_vs_random_dropentry() and expire_nodest_conn_handler() reach
ip_vs_conn_expire() from process context, the deferral is preemptible
and the grace period can complete before we get there.  Before the
patch the recursive ip_vs_conn_expire(&ct->timer) call ran immediately
after the timer_delete(), so this window was a few instructions wide;
now it spans the entire teardown of cp.

The timer_delete() under the "delete the timer if it is activated by
other users" comment does not help here either: it only catches a
re-armed timer that has not fired yet, not a callback already running
or completed on another CPU.

How do you want to close this?  Keeping a reference on ct across the
deferral (i.e. not dropping it in ip_vs_conn_del_put() when the caller
asked to continue) or re-establishing ownership at the repeat label by
re-checking and re-deleting ct's timer both look plausible, but the
refcount bookkeeping differs quite a bit -- which one matches the
intended invariant?

[Severity: High]
This isn't a race introduced by this patch, the old code also dropped the
reference on ct before recursing, but does the deferral here widen the
window considerably?  Between ip_vs_conn_del_put(ct) returning true and
"cp = ct; goto repeat;", ct is still hashed, has no pending timer, and is
referenced by nobody, while the whole teardown of the previous conn runs in
between (ip_vs_conn_drop_conntrack(), ip_vs_unbind_app(),
ip_vs_unbind_dest(), call_rcu(), atomic_dec()).

Note that ip_vs_conn_del_put() forces ct->timeout = 0 when ct itself has a
controller:

                if (cp->control)
                        cp->timeout = 0;

So a concurrent reference holder finishing with ct re-arms the timer to fire
on the next tick:

        static void __ip_vs_conn_put_timer(struct ip_vs_conn *cp)
        {
                unsigned long t = (cp->flags & IP_VS_CONN_F_ONE_PACKET) ?
                        0 : cp->timeout;
                mod_timer(&cp->timer, jiffies+t);

                __ip_vs_conn_put(cp);
        }

Can that leave two contexts in ip_vs_conn_expire() for the same conn, with
only one winning refcount_dec_if_one() inside ip_vs_conn_unlink()?

CPU0 (deferring context)                CPU1 (packet path / timer)
  next = ip_vs_conn_del_put(ct)
    timer_delete(&ct->timer) ok
    ct->timeout = 0
    __ip_vs_conn_put(ct)   /* refcnt 1 */
  ... teardown of cp ...
                                        ip_vs_conn_put(ct)
                                          mod_timer(&ct->timer, jiffies+0)
                                        ip_vs_conn_expire(&ct->timer)
                                          ip_vs_conn_unlink(ct) -> true
                                          call_rcu(&ct->rcu_head, ...)
  cp = ct; goto repeat;
  ip_vs_conn_unlink(ct) -> false  /* HASHED already cleared */
  expire_later:
    refcount_inc(&ct->refcnt);   /* was zero */
    ct->timeout = 60*HZ;
    __ip_vs_conn_put_timer(ct);  /* mod_timer on freed-pending conn */

Does the refcount_inc() on a refcount that already reached zero trigger
refcount_warn_saturate, and does the subsequent mod_timer() touch a conn
already queued for kmem_cache_free() by ip_vs_conn_rcu_free()?

The "delete the timer if it is activated by other users" timer_delete()
after a successful unlink only covers a re-armed timer that has not fired
yet, not a callback already running on another CPU.  The comment in
ip_vs_conn_expire_now() ("Using mod_timer_pending will ensure the timer is
not modified after the final timer_delete in ip_vs_conn_expire") suggests
the code relies on a "timer not pending means I own this conn" invariant
that the plain mod_timer() in __ip_vs_conn_put_timer() does not honour.

For the process-context entries into this path, ip_vs_random_dropentry()
and expire_nodest_conn_handler() both reach ip_vs_conn_del() ->
ip_vs_conn_expire() with cp->timeout forced to 0 while traffic is flowing,
so the deferral is also preemptible.  Can the RCU grace period complete and
free ct before the repeat iteration reads ct->n_control?

Would it work to keep a reference on ct across the deferral, or to
re-establish ownership at the repeat entry by re-checking and re-deleting
the timer?

-- 
Sashiko AI review · 
https://netdev-ai.bots.linux.dev/sashiko/#/patchset/cover.1789435989.git.zihanx%40nebusec.ai

Reply via email to