Xen Security Advisory 501 v4 (CVE-2026-62435,CVE-2026-62436) - grant-table: version change racing with other operations

Xen.org security team posted 1 patch 4 weeks, 1 day ago
Failed in applying to current master (apply log)
Xen Security Advisory 501 v4 (CVE-2026-62435,CVE-2026-62436) - grant-table: version change racing with other operations
Posted by Xen.org security team 4 weeks, 1 day ago
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

     Xen Security Advisory CVE-2026-62435,CVE-2026-62436 / XSA-501
                               version 4

       grant-table: version change racing with other operations

UPDATES IN VERSION 4
====================

Properly sync backports with staging patch (there was no functional issue,
just a code arrangement one).

Public release.

ISSUE DESCRIPTION
=================

With the introduction of Grant Table v2 came the requirement to be able to
switch between versions.  Switching from v1 to v2 reduces the number of
valid grant references, as a bigger shared entry structure is then needed
while the shared table doesn't change size.  Switching from v2 back to v1
the status frames, which are separate in v2, go away.

Code holding, but intermediately dropping and then re-acquiring the grant
table lock, sometimes wrongly assumes that said properties wouldn't change
across the window in time where the lock is not being held.

The v1 -> v2 issue is CVE-2026-62435.

The v2 -> v1 issue is CVE-2026-62436.

IMPACT
======

An unprivileged guest may be able to elevate its privileges to that of the
host.  Information leaks and Denial of Service (DoS) are possible as well.

VULNERABLE SYSTEMS
==================

All Xen versions from 4.0 onwards are vulnerable.  Xen versions 3.4 and
earlier are not vulnerable.

Only x86 guests permitted to use grant table version 2 interfaces can
leverage this vulnerability.  On Arm, grant table v2 use is explicitly
unsupported.

Only multi-vCPU guests can leverage this vulnerability.

Xen versions 4.13 and newer offer a way to build Xen without grant table
support.  Such hypervisors (CONFIG_GRANT_TABLE turned off) are not
vulnerable.

MITIGATION
==========

Using the "gnttab=max-ver:1" hypervisor command line option will avoid
the vulnerability.

Using the "max_grant_version=1" guest configuration option for guests will
also avoid the vulnerability.

CREDITS
=======

This issue was discovered by Mark Esler.

RESOLUTION
==========

Applying the appropriate attached patch resolves this issue.

Note that patches for released versions are generally prepared to
apply to the stable branches, and may not apply cleanly to the most
recent release tarball.  Downstreams are encouraged to update to the
tip of the stable branch before applying these patches.

xsa501.patch           xen-unstable - Xen 4.19.x
xsa501-4.18.patch      Xen 4.18.x
xsa501-4.17.patch      Xen 4.17.x

$ sha256sum xsa501*
e856d64f5b1a16dbb3d7ec19140f24eca508b62de0d25036fa5735cdd5b72be6  xsa501.patch
e006c4fe0a35698318eca59ce828202cac19327f5139d51645116124fc3126e8  xsa501-4.17.patch
10477adfd82fa09123f08497d6a5d44ae61f48d026590ce262cac65f482b2d97  xsa501-4.18.patch
$

DEPLOYMENT DURING EMBARGO
=========================

Deployment of the patches described above (or others which are
substantially similar) is permitted during the embargo, even on public-
facing systems with untrusted guest users and administrators.

HOWEVER, deployment of the mitigation is NOT permitted (except where
all the affected systems and VMs are administered and used only by
organisations which are members of the Xen Project Security Issues
Predisclosure List).  Specifically, deployment on public cloud systems
is NOT permitted.

This is because restricting the available grant table version is a guest
visible configuration change, which may lead to re-discovery of the issue.

Deployment of this mitigation is permitted only AFTER the embargo ends.

AND: Distribution of updated software is prohibited (except to other
members of the predisclosure list).

Predisclosure list members who wish to deploy significantly different
patches and/or mitigations, please contact the Xen Project Security
Team.

(Note: this during-embargo deployment notice is retained in
post-embargo publicly released Xen Project advisories, even though it
is then no longer applicable.  This is to enable the community to have
oversight of the Xen Project Security Team's decisionmaking.)

For more information about permissible uses of embargoed information,
consult the Xen Project community's agreed Security Policy:
  http://www.xenproject.org/security-policy.html
-----BEGIN PGP SIGNATURE-----

iQFABAEBCAAqFiEEI+MiLBRfRHX6gGCng/4UyVfoK9kFAmpomrIMHHBncEB4ZW4u
b3JnAAoJEIP+FMlX6CvZc98H/0wOjr4aa/IMxNH41B8p6vx1ayiBDvTV8qWgmjf9
AyjNGWG5sAjViMDjtFMUE/CeOeoby+gPRfzop9L7cjlHzy8+y/5s9oRUzC+0VIDq
1V5SBbD0XzhqujoLj5LBF0M1a/EyAJzYeVIV3t1DL6yFAz9YRpUGz6pNAwnF0dui
bfNZcT2K0+BV0YHUQMrEkAUd+PV/mObG8AiVDS8j9BU8sGN4m1Wx2hsYzqYBCXpK
T3lT8dG9qaYcnlH+m9jfWFIE2Srr9a2rLdCMs4dHY1CB/FmZLRrWe304yFuQntET
OGiyZWW86cjmANEKAjrYHSD/CRa81gBqdxiAH/N13zBxZ7I=
=VVHS
-----END PGP SIGNATURE-----
From: Jan Beulich <jbeulich@suse.com>
Subject: gnttab: cope with version changes racing other operations

Dropping and re-acquiring the grant table lock for a particular operation
requires special care, as in the meantime the grant table version can
change.

During a v2 -> v1 change, status frames going away means that pre-
calculated status pointers go stale, referencing freed (and possibly
already re-used) memory. Record in-flight v2 operations, permitting the
version change only when there are none of them. Recalculate "status" in
the one place (map_grant_ref()'s error path) where it could be stale, but
confine this to reserved entries.

This is CVE-2026-62436.

Reported-by: Mark Esler <mark@hexproof.dev>

During a v1 -> v2 change, the number of shared table entries reduces,
meaning that previously validated grant references may now be out of
bounds. Because of the checking of pin counts in gnttab_set_version()
(with the grant table lock held for writing), for now-out-of-bounds gref-s
neither active mappings can exist, nor can there be in-progress copy
operations. Nevertheless bounds checks are added there, just to be on the
safe side.

For gnttab_transfer(), to cover the gap between the lock being dropped by
gnttab_prepare_for_transfer() and it being re-acquired, have the helper
return the version it found, and fail the operation if the version turns
out to have changed after re-acquiring the lock.

Further avoid needless use of shared_entry_header(), as it involves
pointer arithmetic which, when using an out-of-bounds ref, is UB.

This is CVE-2026-62435.

Everything together is XSA-501.

Fixes: a98dc13703e0 ("Introduce a grant_entry_v2 structure")
Signed-off-by: Jan Beulich <jbeulich@suse.com>
Tested-by: Mark Esler <mark@hexproof.dev>
Reviewed-by: Andrew Cooper <andrew.cooper3@citrix.com>

diff --git a/xen/common/grant_table.c b/xen/common/grant_table.c
index b83dd7bc2af2..33ef5f651be8 100644
--- a/xen/common/grant_table.c
+++ b/xen/common/grant_table.c
@@ -71,6 +71,10 @@ struct grant_table {
     unsigned int          nr_grant_frames;
     /* Number of grant status frames shared with guest (for version 2) */
     unsigned int          nr_status_frames;
+
+    /* Number of version 2 operations in progress. */
+    atomic_t              nr_v2_ops;
+
     /*
      * Number of available maptrack entries.  For cleanup purposes it is
      * important to realize that this field and @maptrack further down will
@@ -933,6 +937,9 @@ static void reduce_status_for_pin(struct domain *rd,
 {
     unsigned int clear_flags = act->pin ? 0 : GTF_reading;
 
+    if ( unlikely(!status) )
+        return;
+
     if ( !readonly && !(act->pin & (GNTPIN_hstw_mask | GNTPIN_devw_mask)) )
         clear_flags |= GTF_writing;
 
@@ -1343,6 +1350,22 @@ map_grant_ref(
 
     grant_read_lock(rgt);
 
+    if ( unlikely(evaluate_nospec((rgt->gt_version == 1) !=
+                                  (status == &shah->flags))) )
+    {
+        /*
+         * After a v1 -> v2 change behind our backs "ref" may now be out of
+         * bounds.  Recalculate it, but only for reserved entries.  Others
+         * will have been cleared anyway by the version change.
+         */
+        if ( ref < GNTTAB_NR_RESERVED_ENTRIES )
+            status = evaluate_nospec(rgt->gt_version == 1)
+                     ? &shah->flags
+                     : &status_entry(rgt, ref);
+        else
+            status = NULL;
+    }
+
     act = active_entry_acquire(rgt, op->ref);
     act->pin -= pin_incr;
 
@@ -1586,9 +1609,8 @@ unmap_common_complete(struct gnttab_unmap_common *op)
     struct domain *ld, *rd = op->rd;
     struct grant_table *rgt;
     struct active_grant_entry *act;
-    grant_entry_header_t *sha;
     struct page_info *pg;
-    uint16_t *status;
+    uint16_t *status = NULL;
 
     if ( evaluate_nospec(!op->done) )
     {
@@ -1604,11 +1626,10 @@ unmap_common_complete(struct gnttab_unmap_common *op)
     grant_read_lock(rgt);
 
     act = active_entry_acquire(rgt, op->ref);
-    sha = shared_entry_header(rgt, op->ref);
 
     if ( evaluate_nospec(rgt->gt_version == 1) )
-        status = &sha->flags;
-    else
+        status = &shared_entry_v1(rgt, op->ref).flags;
+    else if ( evaluate_nospec(op->ref < nr_grant_entries(rgt)) )
         status = &status_entry(rgt, op->ref);
 
     pg = !is_iomem_page(act->mfn) ? mfn_to_page(op->mfn) : NULL;
@@ -2196,14 +2217,14 @@ gnttab_query_size(
  * Check that the given grant reference (rd,ref) allows 'ld' to transfer
  * ownership of a page frame. If so, lock down the grant entry.
  */
-static int
+static unsigned int
 gnttab_prepare_for_transfer(
     struct domain *rd, struct domain *ld, grant_ref_t ref)
 {
     struct grant_table *rgt = rd->grant_table;
     uint32_t *raw_shah;
     union grant_combo scombo;
-    int                 retries = 0;
+    unsigned int retries = 0, ver;
 
     grant_read_lock(rgt);
 
@@ -2248,8 +2269,11 @@ gnttab_prepare_for_transfer(
         scombo = prev;
     }
 
+    ver = rgt->gt_version;
+
     grant_read_unlock(rgt);
-    return 1;
+
+    return ver;
 
  fail:
     grant_read_unlock(rgt);
@@ -2274,7 +2298,7 @@ gnttab_transfer(
 
     for ( i = 0; i < count; i++ )
     {
-        bool okay;
+        unsigned int ver;
         int rc;
 
         if ( i && hypercall_preempt_check() )
@@ -2414,14 +2438,14 @@ gnttab_transfer(
          * pagelist.
          */
         nrspin_unlock(&e->page_alloc_lock);
-        okay = gnttab_prepare_for_transfer(e, d, gop.ref);
+        ver = gnttab_prepare_for_transfer(e, d, gop.ref);
 
         /*
          * Make sure the reference bound check in gnttab_prepare_for_transfer
          * is respected and speculative execution is blocked accordingly
          */
-        if ( unlikely(!evaluate_nospec(okay)) ||
-            unlikely(assign_pages(page, 1, e, MEMF_no_refcount)) )
+        if ( unlikely(!evaluate_nospec(ver)) ||
+             unlikely(assign_pages(page, 1, e, MEMF_no_refcount)) )
         {
             bool drop_dom_ref;
 
@@ -2433,7 +2457,7 @@ gnttab_transfer(
             drop_dom_ref = !domain_adjust_tot_pages(e, -1);
             nrspin_unlock(&e->page_alloc_lock);
 
-            if ( okay /* i.e. e->is_dying due to the surrounding if() */ )
+            if ( ver /* i.e. e->is_dying due to the surrounding if() */ )
                 gdprintk(XENLOG_INFO, "Transferee d%d is now dying\n",
                          e->domain_id);
 
@@ -2453,7 +2477,13 @@ gnttab_transfer(
         grant_read_lock(e->grant_table);
         act = active_entry_acquire(e->grant_table, gop.ref);
 
-        if ( evaluate_nospec(e->grant_table->gt_version == 1) )
+        if ( unlikely(evaluate_nospec(e->grant_table->gt_version != ver)) )
+        {
+            rc = -EILSEQ;
+            goto release;
+        }
+
+        if ( evaluate_nospec(ver == 1) )
         {
             grant_entry_v1_t *sha = &shared_entry_v1(e->grant_table, gop.ref);
 
@@ -2473,6 +2503,7 @@ gnttab_transfer(
         shared_entry_header(e->grant_table, gop.ref)->flags |=
             GTF_transfer_completed;
 
+    release:
         active_entry_release(act);
         grant_read_unlock(e->grant_table);
 
@@ -2501,28 +2532,27 @@ release_grant_for_copy(
     struct domain *rd, grant_ref_t gref, bool readonly)
 {
     struct grant_table *rgt = rd->grant_table;
-    grant_entry_header_t *sha;
     struct active_grant_entry *act;
     mfn_t mfn;
-    uint16_t *status;
+    uint16_t *status = NULL;
     grant_ref_t trans_gref;
     struct domain *td;
 
     grant_read_lock(rgt);
 
     act = active_entry_acquire(rgt, gref);
-    sha = shared_entry_header(rgt, gref);
     mfn = act->mfn;
 
     if ( evaluate_nospec(rgt->gt_version == 1) )
     {
-        status = &sha->flags;
+        status = &shared_entry_v1(rgt, gref).flags;
         td = rd;
         trans_gref = gref;
     }
     else
     {
-        status = &status_entry(rgt, gref);
+        if ( evaluate_nospec(gref < nr_grant_entries(rgt)) )
+            status = &status_entry(rgt, gref);
         td = (act->src_domid == rd->domain_id)
              ? rd : knownalive_domain_from_domid(act->src_domid);
         trans_gref = act->trans_gref;
@@ -2541,6 +2571,9 @@ release_grant_for_copy(
 
     reduce_status_for_pin(rd, act, status, readonly);
 
+    if ( !act->pin && act->is_sub_page )
+        atomic_dec(&rgt->nr_v2_ops);
+
     active_entry_release(act);
     grant_read_unlock(rgt);
 
@@ -2672,8 +2705,10 @@ acquire_grant_for_copy(
 
         /*
          * acquire_grant_for_copy() will take the lock on the remote table,
-         * so we have to drop the lock here and reacquire.
+         * so we have to drop the lock here and reacquire.  Before doing so,
+         * record that a v2 operation is in progress.
          */
+        atomic_inc(&rgt->nr_v2_ops);
         active_entry_release(act);
         grant_read_unlock(rgt);
 
@@ -2687,6 +2722,7 @@ acquire_grant_for_copy(
 
         if ( rc != GNTST_okay )
         {
+            atomic_dec(&rgt->nr_v2_ops);
             rcu_unlock_domain(td);
             reduce_status_for_pin(rd, act, status, readonly);
             active_entry_release(act);
@@ -2723,6 +2759,8 @@ acquire_grant_for_copy(
             rcu_unlock_domain(td);
 
             grant_read_lock(rgt);
+            atomic_dec(&rgt->nr_v2_ops);
+
             act = active_entry_acquire(rgt, gref);
             reduce_status_for_pin(rd, act, status, readonly);
             active_entry_release(act);
@@ -2749,6 +2787,8 @@ acquire_grant_for_copy(
              */
             act->is_sub_page = true;
         }
+        else
+            atomic_dec(&rgt->nr_v2_ops);
     }
     else if ( !old_pin ||
               (!readonly && !(old_pin & (GNTPIN_devw_mask|GNTPIN_hstw_mask))) )
@@ -2803,6 +2843,9 @@ acquire_grant_for_copy(
             act->src_domid = td->domain_id;
             act->trans_gref = trans_gref;
             act->mfn = grant_mfn;
+
+            if ( is_sub_page )
+                atomic_inc(&rgt->nr_v2_ops);
         }
         else if ( !mfn_eq(act->mfn, grant_mfn) ||
                   act->src_domid != td->domain_id ||
@@ -3232,7 +3275,17 @@ gnttab_set_version(XEN_GUEST_HANDLE_PARAM(gnttab_set_version_t) uop)
         if ( res < 0)
             goto out_unlock;
         break;
+
     case 2:
+        if ( atomic_read(&gt->nr_v2_ops) )
+        {
+            gdprintk(XENLOG_WARNING,
+                     "tried to change to grant table v1, but %d v2 operations still in progress\n",
+                     atomic_read(&gt->nr_v2_ops));
+            res = -EAGAIN;
+            goto out_unlock;
+        }
+
         for ( i = 0; i < GNTTAB_NR_RESERVED_ENTRIES; i++ )
         {
             switch ( shared_entry_v2(gt, i).hdr.flags & GTF_type_mask )
From: Jan Beulich <jbeulich@suse.com>
Subject: gnttab: cope with version changes racing other operations

Dropping and re-acquiring the grant table lock for a particular operation
requires special care, as in the meantime the grant table version can
change.

During a v2 -> v1 change, status frames going away means that pre-
calculated status pointers go stale, referencing freed (and possibly
already re-used) memory. Record in-flight v2 operations, permitting the
version change only when there are none of them. Recalculate "status" in
the one place (map_grant_ref()'s error path) where it could be stale, but
confine this to reserved entries.

This is CVE-2026-62436.

Reported-by: Mark Esler <mark@hexproof.dev>

During a v1 -> v2 change, the number of shared table entries reduces,
meaning that previously validated grant references may now be out of
bounds. Because of the checking of pin counts in gnttab_set_version()
(with the grant table lock held for writing), for now-out-of-bounds gref-s
neither active mappings can exist, nor can there be in-progress copy
operations. Nevertheless bounds checks are added there, just to be on the
safe side.

For gnttab_transfer(), to cover the gap between the lock being dropped by
gnttab_prepare_for_transfer() and it being re-acquired, have the helper
return the version it found, and fail the operation if the version turns
out to have changed after re-acquiring the lock.

Further avoid needless use of shared_entry_header(), as it involves
pointer arithmetic which, when using an out-of-bounds ref, is UB.

This is CVE-2026-62435.

Everything together is XSA-501.

Fixes: a98dc13703e0 ("Introduce a grant_entry_v2 structure")
Signed-off-by: Jan Beulich <jbeulich@suse.com>
Tested-by: Mark Esler <mark@hexproof.dev>
Reviewed-by: Andrew Cooper <andrew.cooper3@citrix.com>

--- a/xen/common/grant_table.c
+++ b/xen/common/grant_table.c
@@ -71,6 +71,10 @@ struct grant_table {
     unsigned int          nr_grant_frames;
     /* Number of grant status frames shared with guest (for version 2) */
     unsigned int          nr_status_frames;
+
+    /* Number of version 2 operations in progress. */
+    atomic_t              nr_v2_ops;
+
     /*
      * Number of available maptrack entries.  For cleanup purposes it is
      * important to realize that this field and @maptrack further down will
@@ -918,6 +922,9 @@ static void reduce_status_for_pin(struct
 {
     unsigned int clear_flags = act->pin ? 0 : GTF_reading;
 
+    if ( unlikely(!status) )
+        return;
+
     if ( !readonly && !(act->pin & (GNTPIN_hstw_mask | GNTPIN_devw_mask)) )
         clear_flags |= GTF_writing;
 
@@ -1318,6 +1325,22 @@ map_grant_ref(
 
     grant_read_lock(rgt);
 
+    if ( unlikely(evaluate_nospec((rgt->gt_version == 1) !=
+                                  (status == &shah->flags))) )
+    {
+        /*
+         * After a v1 -> v2 change behind our backs "ref" may now be out of
+         * bounds.  Recalculate it, but only for reserved entries.  Others
+         * will have been cleared anyway by the version change.
+         */
+        if ( ref < GNTTAB_NR_RESERVED_ENTRIES )
+            status = evaluate_nospec(rgt->gt_version == 1)
+                     ? &shah->flags
+                     : &status_entry(rgt, ref);
+        else
+            status = NULL;
+    }
+
     act = active_entry_acquire(rgt, op->ref);
     act->pin -= pin_incr;
 
@@ -1557,9 +1580,8 @@ unmap_common_complete(struct gnttab_unma
     struct domain *ld, *rd = op->rd;
     struct grant_table *rgt;
     struct active_grant_entry *act;
-    grant_entry_header_t *sha;
     struct page_info *pg;
-    uint16_t *status;
+    uint16_t *status = NULL;
 
     if ( evaluate_nospec(!op->done) )
     {
@@ -1575,11 +1597,10 @@ unmap_common_complete(struct gnttab_unma
     grant_read_lock(rgt);
 
     act = active_entry_acquire(rgt, op->ref);
-    sha = shared_entry_header(rgt, op->ref);
 
     if ( evaluate_nospec(rgt->gt_version == 1) )
-        status = &sha->flags;
-    else
+        status = &shared_entry_v1(rgt, op->ref).flags;
+    else if ( evaluate_nospec(op->ref < nr_grant_entries(rgt)) )
         status = &status_entry(rgt, op->ref);
 
     pg = !is_iomem_page(act->mfn) ? mfn_to_page(op->mfn) : NULL;
@@ -2167,14 +2188,14 @@ gnttab_query_size(
  * Check that the given grant reference (rd,ref) allows 'ld' to transfer
  * ownership of a page frame. If so, lock down the grant entry.
  */
-static int
+static unsigned int
 gnttab_prepare_for_transfer(
     struct domain *rd, struct domain *ld, grant_ref_t ref)
 {
     struct grant_table *rgt = rd->grant_table;
     uint32_t *raw_shah;
     union grant_combo scombo;
-    int                 retries = 0;
+    unsigned int retries = 0, ver;
 
     grant_read_lock(rgt);
 
@@ -2219,8 +2240,11 @@ gnttab_prepare_for_transfer(
         scombo = prev;
     }
 
+    ver = rgt->gt_version;
+
     grant_read_unlock(rgt);
-    return 1;
+
+    return ver;
 
  fail:
     grant_read_unlock(rgt);
@@ -2245,7 +2269,7 @@ gnttab_transfer(
 
     for ( i = 0; i < count; i++ )
     {
-        bool_t okay;
+        unsigned int ver;
         int rc;
 
         if ( i && hypercall_preempt_check() )
@@ -2385,14 +2409,14 @@ gnttab_transfer(
          * pagelist.
          */
         spin_unlock(&e->page_alloc_lock);
-        okay = gnttab_prepare_for_transfer(e, d, gop.ref);
+        ver = gnttab_prepare_for_transfer(e, d, gop.ref);
 
         /*
          * Make sure the reference bound check in gnttab_prepare_for_transfer
          * is respected and speculative execution is blocked accordingly
          */
-        if ( unlikely(!evaluate_nospec(okay)) ||
-            unlikely(assign_pages(page, 1, e, MEMF_no_refcount)) )
+        if ( unlikely(!evaluate_nospec(ver)) ||
+             unlikely(assign_pages(page, 1, e, MEMF_no_refcount)) )
         {
             bool drop_dom_ref;
 
@@ -2404,7 +2428,7 @@ gnttab_transfer(
             drop_dom_ref = !domain_adjust_tot_pages(e, -1);
             spin_unlock(&e->page_alloc_lock);
 
-            if ( okay /* i.e. e->is_dying due to the surrounding if() */ )
+            if ( ver /* i.e. e->is_dying due to the surrounding if() */ )
                 gdprintk(XENLOG_INFO, "Transferee d%d is now dying\n",
                          e->domain_id);
 
@@ -2424,7 +2448,13 @@ gnttab_transfer(
         grant_read_lock(e->grant_table);
         act = active_entry_acquire(e->grant_table, gop.ref);
 
-        if ( evaluate_nospec(e->grant_table->gt_version == 1) )
+        if ( unlikely(evaluate_nospec(e->grant_table->gt_version != ver)) )
+        {
+            rc = -EILSEQ;
+            goto release;
+        }
+
+        if ( evaluate_nospec(ver == 1) )
         {
             grant_entry_v1_t *sha = &shared_entry_v1(e->grant_table, gop.ref);
 
@@ -2444,6 +2474,7 @@ gnttab_transfer(
         shared_entry_header(e->grant_table, gop.ref)->flags |=
             GTF_transfer_completed;
 
+    release:
         active_entry_release(act);
         grant_read_unlock(e->grant_table);
 
@@ -2472,28 +2503,27 @@ release_grant_for_copy(
     struct domain *rd, grant_ref_t gref, bool readonly)
 {
     struct grant_table *rgt = rd->grant_table;
-    grant_entry_header_t *sha;
     struct active_grant_entry *act;
     mfn_t mfn;
-    uint16_t *status;
+    uint16_t *status = NULL;
     grant_ref_t trans_gref;
     struct domain *td;
 
     grant_read_lock(rgt);
 
     act = active_entry_acquire(rgt, gref);
-    sha = shared_entry_header(rgt, gref);
     mfn = act->mfn;
 
     if ( evaluate_nospec(rgt->gt_version == 1) )
     {
-        status = &sha->flags;
+        status = &shared_entry_v1(rgt, gref).flags;
         td = rd;
         trans_gref = gref;
     }
     else
     {
-        status = &status_entry(rgt, gref);
+        if ( evaluate_nospec(gref < nr_grant_entries(rgt)) )
+            status = &status_entry(rgt, gref);
         td = act->trans_domain;
         trans_gref = act->trans_gref;
     }
@@ -2511,6 +2541,9 @@ release_grant_for_copy(
 
     reduce_status_for_pin(rd, act, status, readonly);
 
+    if ( !act->pin && act->is_sub_page )
+        atomic_dec(&rgt->nr_v2_ops);
+
     active_entry_release(act);
     grant_read_unlock(rgt);
 
@@ -2623,8 +2656,10 @@ acquire_grant_for_copy(
 
         /*
          * acquire_grant_for_copy() will take the lock on the remote table,
-         * so we have to drop the lock here and reacquire.
+         * so we have to drop the lock here and reacquire.  Before doing so,
+         * record that a v2 operation is in progress.
          */
+        atomic_inc(&rgt->nr_v2_ops);
         active_entry_release(act);
         grant_read_unlock(rgt);
 
@@ -2638,6 +2673,7 @@ acquire_grant_for_copy(
 
         if ( rc != GNTST_okay )
         {
+            atomic_dec(&rgt->nr_v2_ops);
             rcu_unlock_domain(td);
             reduce_status_for_pin(rd, act, status, readonly);
             active_entry_release(act);
@@ -2674,6 +2710,8 @@ acquire_grant_for_copy(
             rcu_unlock_domain(td);
 
             grant_read_lock(rgt);
+            atomic_dec(&rgt->nr_v2_ops);
+
             act = active_entry_acquire(rgt, gref);
             reduce_status_for_pin(rd, act, status, readonly);
             active_entry_release(act);
@@ -2700,6 +2738,8 @@ acquire_grant_for_copy(
              */
             act->is_sub_page = true;
         }
+        else
+            atomic_dec(&rgt->nr_v2_ops);
     }
     else if ( !old_pin ||
               (!readonly && !(old_pin & (GNTPIN_devw_mask|GNTPIN_hstw_mask))) )
@@ -2754,6 +2794,9 @@ acquire_grant_for_copy(
             act->trans_domain = td;
             act->trans_gref = trans_gref;
             act->mfn = grant_mfn;
+
+            if ( is_sub_page )
+                atomic_inc(&rgt->nr_v2_ops);
         }
         else if ( !mfn_eq(act->mfn, grant_mfn) ||
                   act->trans_domain != td ||
@@ -3173,7 +3216,17 @@ gnttab_set_version(XEN_GUEST_HANDLE_PARA
         if ( res < 0)
             goto out_unlock;
         break;
+
     case 2:
+        if ( atomic_read(&gt->nr_v2_ops) )
+        {
+            gdprintk(XENLOG_WARNING,
+                     "tried to change to grant table v1, but %d v2 operations still in progress\n",
+                     atomic_read(&gt->nr_v2_ops));
+            res = -EAGAIN;
+            goto out_unlock;
+        }
+
         for ( i = 0; i < GNTTAB_NR_RESERVED_ENTRIES; i++ )
         {
             switch ( shared_entry_v2(gt, i).hdr.flags & GTF_type_mask )
From: Jan Beulich <jbeulich@suse.com>
Subject: gnttab: cope with version changes racing other operations

Dropping and re-acquiring the grant table lock for a particular operation
requires special care, as in the meantime the grant table version can
change.

During a v2 -> v1 change, status frames going away means that pre-
calculated status pointers go stale, referencing freed (and possibly
already re-used) memory. Record in-flight v2 operations, permitting the
version change only when there are none of them. Recalculate "status" in
the one place (map_grant_ref()'s error path) where it could be stale, but
confine this to reserved entries.

This is CVE-2026-62436.

Reported-by: Mark Esler <mark@hexproof.dev>

During a v1 -> v2 change, the number of shared table entries reduces,
meaning that previously validated grant references may now be out of
bounds. Because of the checking of pin counts in gnttab_set_version()
(with the grant table lock held for writing), for now-out-of-bounds gref-s
neither active mappings can exist, nor can there be in-progress copy
operations. Nevertheless bounds checks are added there, just to be on the
safe side.

For gnttab_transfer(), to cover the gap between the lock being dropped by
gnttab_prepare_for_transfer() and it being re-acquired, have the helper
return the version it found, and fail the operation if the version turns
out to have changed after re-acquiring the lock.

Further avoid needless use of shared_entry_header(), as it involves
pointer arithmetic which, when using an out-of-bounds ref, is UB.

This is CVE-2026-62435.

Everything together is XSA-501.

Fixes: a98dc13703e0 ("Introduce a grant_entry_v2 structure")
Signed-off-by: Jan Beulich <jbeulich@suse.com>
Tested-by: Mark Esler <mark@hexproof.dev>
Reviewed-by: Andrew Cooper <andrew.cooper3@citrix.com>

--- a/xen/common/grant_table.c
+++ b/xen/common/grant_table.c
@@ -71,6 +71,10 @@ struct grant_table {
     unsigned int          nr_grant_frames;
     /* Number of grant status frames shared with guest (for version 2) */
     unsigned int          nr_status_frames;
+
+    /* Number of version 2 operations in progress. */
+    atomic_t              nr_v2_ops;
+
     /*
      * Number of available maptrack entries.  For cleanup purposes it is
      * important to realize that this field and @maptrack further down will
@@ -933,6 +937,9 @@ static void reduce_status_for_pin(struct
 {
     unsigned int clear_flags = act->pin ? 0 : GTF_reading;
 
+    if ( unlikely(!status) )
+        return;
+
     if ( !readonly && !(act->pin & (GNTPIN_hstw_mask | GNTPIN_devw_mask)) )
         clear_flags |= GTF_writing;
 
@@ -1341,6 +1348,22 @@ map_grant_ref(
 
     grant_read_lock(rgt);
 
+    if ( unlikely(evaluate_nospec((rgt->gt_version == 1) !=
+                                  (status == &shah->flags))) )
+    {
+        /*
+         * After a v1 -> v2 change behind our backs "ref" may now be out of
+         * bounds.  Recalculate it, but only for reserved entries.  Others
+         * will have been cleared anyway by the version change.
+         */
+        if ( ref < GNTTAB_NR_RESERVED_ENTRIES )
+            status = evaluate_nospec(rgt->gt_version == 1)
+                     ? &shah->flags
+                     : &status_entry(rgt, ref);
+        else
+            status = NULL;
+    }
+
     act = active_entry_acquire(rgt, op->ref);
     act->pin -= pin_incr;
 
@@ -1584,9 +1607,8 @@ unmap_common_complete(struct gnttab_unma
     struct domain *ld, *rd = op->rd;
     struct grant_table *rgt;
     struct active_grant_entry *act;
-    grant_entry_header_t *sha;
     struct page_info *pg;
-    uint16_t *status;
+    uint16_t *status = NULL;
 
     if ( evaluate_nospec(!op->done) )
     {
@@ -1602,11 +1624,10 @@ unmap_common_complete(struct gnttab_unma
     grant_read_lock(rgt);
 
     act = active_entry_acquire(rgt, op->ref);
-    sha = shared_entry_header(rgt, op->ref);
 
     if ( evaluate_nospec(rgt->gt_version == 1) )
-        status = &sha->flags;
-    else
+        status = &shared_entry_v1(rgt, op->ref).flags;
+    else if ( evaluate_nospec(op->ref < nr_grant_entries(rgt)) )
         status = &status_entry(rgt, op->ref);
 
     pg = !is_iomem_page(act->mfn) ? mfn_to_page(op->mfn) : NULL;
@@ -2194,14 +2215,14 @@ gnttab_query_size(
  * Check that the given grant reference (rd,ref) allows 'ld' to transfer
  * ownership of a page frame. If so, lock down the grant entry.
  */
-static int
+static unsigned int
 gnttab_prepare_for_transfer(
     struct domain *rd, struct domain *ld, grant_ref_t ref)
 {
     struct grant_table *rgt = rd->grant_table;
     uint32_t *raw_shah;
     union grant_combo scombo;
-    int                 retries = 0;
+    unsigned int retries = 0, ver;
 
     grant_read_lock(rgt);
 
@@ -2246,8 +2267,11 @@ gnttab_prepare_for_transfer(
         scombo = prev;
     }
 
+    ver = rgt->gt_version;
+
     grant_read_unlock(rgt);
-    return 1;
+
+    return ver;
 
  fail:
     grant_read_unlock(rgt);
@@ -2272,7 +2296,7 @@ gnttab_transfer(
 
     for ( i = 0; i < count; i++ )
     {
-        bool_t okay;
+        unsigned int ver;
         int rc;
 
         if ( i && hypercall_preempt_check() )
@@ -2412,14 +2436,14 @@ gnttab_transfer(
          * pagelist.
          */
         spin_unlock(&e->page_alloc_lock);
-        okay = gnttab_prepare_for_transfer(e, d, gop.ref);
+        ver = gnttab_prepare_for_transfer(e, d, gop.ref);
 
         /*
          * Make sure the reference bound check in gnttab_prepare_for_transfer
          * is respected and speculative execution is blocked accordingly
          */
-        if ( unlikely(!evaluate_nospec(okay)) ||
-            unlikely(assign_pages(page, 1, e, MEMF_no_refcount)) )
+        if ( unlikely(!evaluate_nospec(ver)) ||
+             unlikely(assign_pages(page, 1, e, MEMF_no_refcount)) )
         {
             bool drop_dom_ref;
 
@@ -2431,7 +2455,7 @@ gnttab_transfer(
             drop_dom_ref = !domain_adjust_tot_pages(e, -1);
             spin_unlock(&e->page_alloc_lock);
 
-            if ( okay /* i.e. e->is_dying due to the surrounding if() */ )
+            if ( ver /* i.e. e->is_dying due to the surrounding if() */ )
                 gdprintk(XENLOG_INFO, "Transferee d%d is now dying\n",
                          e->domain_id);
 
@@ -2451,7 +2475,13 @@ gnttab_transfer(
         grant_read_lock(e->grant_table);
         act = active_entry_acquire(e->grant_table, gop.ref);
 
-        if ( evaluate_nospec(e->grant_table->gt_version == 1) )
+        if ( unlikely(evaluate_nospec(e->grant_table->gt_version != ver)) )
+        {
+            rc = -EILSEQ;
+            goto release;
+        }
+
+        if ( evaluate_nospec(ver == 1) )
         {
             grant_entry_v1_t *sha = &shared_entry_v1(e->grant_table, gop.ref);
 
@@ -2471,6 +2501,7 @@ gnttab_transfer(
         shared_entry_header(e->grant_table, gop.ref)->flags |=
             GTF_transfer_completed;
 
+    release:
         active_entry_release(act);
         grant_read_unlock(e->grant_table);
 
@@ -2499,28 +2530,27 @@ release_grant_for_copy(
     struct domain *rd, grant_ref_t gref, bool readonly)
 {
     struct grant_table *rgt = rd->grant_table;
-    grant_entry_header_t *sha;
     struct active_grant_entry *act;
     mfn_t mfn;
-    uint16_t *status;
+    uint16_t *status = NULL;
     grant_ref_t trans_gref;
     struct domain *td;
 
     grant_read_lock(rgt);
 
     act = active_entry_acquire(rgt, gref);
-    sha = shared_entry_header(rgt, gref);
     mfn = act->mfn;
 
     if ( evaluate_nospec(rgt->gt_version == 1) )
     {
-        status = &sha->flags;
+        status = &shared_entry_v1(rgt, gref).flags;
         td = rd;
         trans_gref = gref;
     }
     else
     {
-        status = &status_entry(rgt, gref);
+        if ( evaluate_nospec(gref < nr_grant_entries(rgt)) )
+            status = &status_entry(rgt, gref);
         td = (act->src_domid == rd->domain_id)
              ? rd : knownalive_domain_from_domid(act->src_domid);
         trans_gref = act->trans_gref;
@@ -2539,6 +2569,9 @@ release_grant_for_copy(
 
     reduce_status_for_pin(rd, act, status, readonly);
 
+    if ( !act->pin && act->is_sub_page )
+        atomic_dec(&rgt->nr_v2_ops);
+
     active_entry_release(act);
     grant_read_unlock(rgt);
 
@@ -2670,8 +2703,10 @@ acquire_grant_for_copy(
 
         /*
          * acquire_grant_for_copy() will take the lock on the remote table,
-         * so we have to drop the lock here and reacquire.
+         * so we have to drop the lock here and reacquire.  Before doing so,
+         * record that a v2 operation is in progress.
          */
+        atomic_inc(&rgt->nr_v2_ops);
         active_entry_release(act);
         grant_read_unlock(rgt);
 
@@ -2685,6 +2720,7 @@ acquire_grant_for_copy(
 
         if ( rc != GNTST_okay )
         {
+            atomic_dec(&rgt->nr_v2_ops);
             rcu_unlock_domain(td);
             reduce_status_for_pin(rd, act, status, readonly);
             active_entry_release(act);
@@ -2721,6 +2757,8 @@ acquire_grant_for_copy(
             rcu_unlock_domain(td);
 
             grant_read_lock(rgt);
+            atomic_dec(&rgt->nr_v2_ops);
+
             act = active_entry_acquire(rgt, gref);
             reduce_status_for_pin(rd, act, status, readonly);
             active_entry_release(act);
@@ -2747,6 +2785,8 @@ acquire_grant_for_copy(
              */
             act->is_sub_page = true;
         }
+        else
+            atomic_dec(&rgt->nr_v2_ops);
     }
     else if ( !old_pin ||
               (!readonly && !(old_pin & (GNTPIN_devw_mask|GNTPIN_hstw_mask))) )
@@ -2801,6 +2841,9 @@ acquire_grant_for_copy(
             act->src_domid = td->domain_id;
             act->trans_gref = trans_gref;
             act->mfn = grant_mfn;
+
+            if ( is_sub_page )
+                atomic_inc(&rgt->nr_v2_ops);
         }
         else if ( !mfn_eq(act->mfn, grant_mfn) ||
                   act->src_domid != td->domain_id ||
@@ -3226,7 +3269,17 @@ gnttab_set_version(XEN_GUEST_HANDLE_PARA
         if ( res < 0)
             goto out_unlock;
         break;
+
     case 2:
+        if ( atomic_read(&gt->nr_v2_ops) )
+        {
+            gdprintk(XENLOG_WARNING,
+                     "tried to change to grant table v1, but %d v2 operations still in progress\n",
+                     atomic_read(&gt->nr_v2_ops));
+            res = -EAGAIN;
+            goto out_unlock;
+        }
+
         for ( i = 0; i < GNTTAB_NR_RESERVED_ENTRIES; i++ )
         {
             switch ( shared_entry_v2(gt, i).hdr.flags & GTF_type_mask )