All of lore.kernel.org
 help / color / mirror / Atom feed
* [RESEND][SECURITY] block: double unpin in bounced direct reads
@ 2026-07-10 12:26 Neptune
  2026-07-13  9:29 ` Christoph Hellwig
  0 siblings, 1 reply; 4+ messages in thread
From: Neptune @ 2026-07-10 12:26 UTC (permalink / raw)
  To: Jens Axboe, Christoph Hellwig; +Cc: linux-block, security

[-- Attachment #1: Type: text/plain, Size: 5247 bytes --]

Resending in plain text because my first message was rejected by the
mailing list due to its MIME format. The technical content is
unchanged.

Hi Jens, Christoph,

I found a double-unpin in the block-layer read-bounce path that can be
reached by an unprivileged process through XFS direct I/O. I
reproduced it as a uid-1000 to uid-0 privilege escalation on mainline
commit 0e35b9b6ec0f. The bug was introduced by e7b8b3c5b2a6 ("block:
align down bounces bios"), which first appears in v7.1-rc4.

AI-assisted tooling was used during the source review and validation.
I independently verified the bug against the source, reproduced the
complete privilege-escalation chain, and tested the proposed fix. I am
treating the finding as public for coordination purposes, but I have
not included the reproducer or exploit in this message.

bio_iov_iter_bounce_read() stores the bounce folio in bi_io_vec[0],
while the extracted user bvecs start at bi_io_vec[1]. bi_vcnt counts
only those user bvecs. bio_iov_iter_align_down(), however, treats
bi_vcnt as the length of an array starting at slot zero and selects
the last vector with bio->bi_io_vec[bio->bi_vcnt - 1].

I create the partial extraction with three iovecs: a one-byte mapped
segment, a 512-byte mapped segment, and a 511-byte PROT_NONE segment.
The complete 1024-byte read is aligned, but pinning the third segment
fails after the first two segments have already produced 513 bytes.
This leaves the following state immediately before trimming:

    bi_size        = 513
    bi_vcnt        = 2
    len_align_mask = 511

    vec[0] = bounce folio,     len 513
    vec[1] = first user page,  len   1
    vec[2] = second user page, len 512

The one-byte remainder should be removed from vec[2], but the helper
selects vec[1]. Because that bvec is exactly one byte long, it is
removed and its page is unpinned. bi_vcnt becomes one, and read-bounce
completion subsequently walks the user array from vec[1] and unpins
the same page again. Once the partial-extraction state has been
created, the two unpins are deterministic and do not depend on a race.

The in-tree path I confirmed is xfs_file_dio_read() with
IOMAP_DIO_BOUNCE set when mapping_stable_writes() is true. The trigger
needs a non-DAX XFS file backed by an allocated extent on a device
that either provides an integrity checksum or advertises
BLK_FEAT_STABLE_WRITES. It only requires read access to that file and
an aligned O_DIRECT vectored read; raw block-device access and
capabilities are not required. In my test setup, queue/stable_writes
was zero, but the NVMe namespace exposed T10-DIF-TYPE1-CRC, which also
makes bdev_stable_writes() true.

For the privilege-escalation test, I registered the page backing the
one-byte first iovec as an io_uring fixed buffer before triggering the
bug. The DIO pin and the fixed-buffer pin account for two
GUP_PIN_COUNTING_BIAS references. GDB showed a refcount of 2050 at the
first erroneous unpin, followed by 2050 -> 1026 in the trim path and
1026 -> 2 in read-bounce completion. After munmap() and memfd
teardown, the page is freed while io_uring still retains its bvec and
old PFN.

I then reclaimed that PFN as the page-cache page containing the entry
point of a root-owned setuid ELF. IORING_OP_WRITE_FIXED was used as a
read oracle to confirm the reuse, and IORING_OP_READ_FIXED replaced
the entry point with a position-independent
setuid(0)/execve("/bin/sh") payload. This does not require a kernel
address leak or kernel control-flow hijacking.

The vulnerable kernel produced:

    $ id
    uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
    $ /bio_bounce_lpe
    [*] uid=1000
    [+] stale page hit
    [+] exec
    # id
    uid=0(root) gid=0(root) groups=1000(ctf)

With the proposed fix, using the same config, initramfs, NVMe PI
namespace and XFS setup, the exploit no longer obtains a stale page:

    $ /bio_bounce_lpe
    [*] uid=1000
    [-] stale page miss
    $ id
    uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)

I repeated the fixed-kernel test on fresh images and obtained the same
result. The setuid target in the disposable VM was an unmodified
static BusyBox binary copied onto XFS and installed as root-owned mode
4755. It was only a convenient page-cache target: the exploit parses
the ELF entry point at runtime, does not depend on BusyBox behavior or
a hard-coded offset, and I also validated the same ending with a
dynamically linked PIE executable.

The attached patch changes bio_iov_iter_align_down() to accept the
index of the first vector it is allowed to trim. Existing callers pass
zero and read-bounce passes one. It also updates the bounce bvec
length after alignment and drops the bounce folio if alignment removes
all user data. I tested the resulting kernel in the same VM described
above.

I can provide the minimal trigger, full exploit, disposable initramfs,
exact QEMU command line, and the GDB transcript showing both unpins if
they would be useful for review.

Could you take a look at the analysis and patch? In particular, I
would appreciate your view on whether passing the first vector index
into the alignment helper is the right fix, or whether you would
prefer the read-bounce layout itself to be changed.

Regards,

0wnerD1ed
l7z@0b1t.tech

[-- Attachment #2: 0001-block-fix-bvec-offset-when-aligning-bounced-reads.patch --]
[-- Type: application/octet-stream, Size: 3538 bytes --]

From dae9025b3403bcecb2f1224306cb4b8b22a6c27c Mon Sep 17 00:00:00 2001
From: 0wnerD1ed <l7z@0b1t.tech>
Date: Fri, 10 Jul 2026 19:05:58 +0800
Subject: [PATCH] block: fix bvec offset when aligning bounced reads

bio_iov_iter_bounce_read() stores the bounce folio in bi_io_vec[0] and
extracts user bvecs starting at bi_io_vec[1].  bi_vcnt, however, counts
only the extracted user bvecs.

bio_iov_iter_align_down() assumes that the vectors it trims start at
index zero.  Thus, with two extracted user bvecs it selects index one as
the last vector instead of index two.  If the alignment remainder
consumes that vector, a pinned user page is released even though it is
still present in the completion-side vector array.  Completion releases
the same page again.

Pass the first vector index to the alignment helper and use an offset of
one for bounced reads.  After trimming, also synchronize the bounce
folio length with the aligned bio size.  Release the bounce folio if
alignment removes all user data.

Fixes: e7b8b3c5b2a6 ("block: align down bounces bios")
Cc: stable@vger.kernel.org
Signed-off-by: 0wnerD1ed <l7z@0b1t.tech>
---
 block/bio.c | 18 +++++++++++++-----
 1 file changed, 13 insertions(+), 5 deletions(-)

diff --git a/block/bio.c b/block/bio.c
index f2a5f4d0a967..6b7d893df169 100644
--- a/block/bio.c
+++ b/block/bio.c
@@ -1191,7 +1191,7 @@ void bio_iov_bvec_set(struct bio *bio, const struct iov_iter *iter)
  * for the next iteration.
  */
 static int bio_iov_iter_align_down(struct bio *bio, struct iov_iter *iter,
-			    unsigned len_align_mask)
+			    unsigned int len_align_mask, unsigned int first_vec)
 {
 	size_t nbytes = bio->bi_iter.bi_size & len_align_mask;
 
@@ -1201,7 +1201,8 @@ static int bio_iov_iter_align_down(struct bio *bio, struct iov_iter *iter,
 	iov_iter_revert(iter, nbytes);
 	bio->bi_iter.bi_size -= nbytes;
 	do {
-		struct bio_vec *bv = &bio->bi_io_vec[bio->bi_vcnt - 1];
+		struct bio_vec *bv =
+			&bio->bi_io_vec[first_vec + bio->bi_vcnt - 1];
 
 		if (nbytes < bv->bv_len) {
 			bv->bv_len -= nbytes;
@@ -1276,7 +1277,7 @@ int bio_iov_iter_get_pages(struct bio *bio, struct iov_iter *iter,
 
 	if (is_pci_p2pdma_page(bio->bi_io_vec->bv_page))
 		bio->bi_opf |= REQ_NOMERGE;
-	return bio_iov_iter_align_down(bio, iter, len_align_mask);
+	return bio_iov_iter_align_down(bio, iter, len_align_mask, 0);
 }
 
 static struct folio *folio_alloc_greedy(gfp_t gfp, size_t *size,
@@ -1360,7 +1361,7 @@ static int bio_iov_iter_bounce_write(struct bio *bio, struct iov_iter *iter,
 
 	if (!bio->bi_iter.bi_size)
 		return -ENOMEM;
-	return bio_iov_iter_align_down(bio, iter, minsize - 1);
+	return bio_iov_iter_align_down(bio, iter, minsize - 1, 0);
 }
 
 static int bio_iov_iter_bounce_read(struct bio *bio, struct iov_iter *iter,
@@ -1368,6 +1369,7 @@ static int bio_iov_iter_bounce_read(struct bio *bio, struct iov_iter *iter,
 {
 	size_t len = min3(iov_iter_count(iter), maxlen, SZ_1M);
 	struct folio *folio;
+	int ret;
 
 	folio = folio_alloc_greedy(GFP_KERNEL, &len, minsize);
 	if (!folio)
@@ -1398,7 +1400,13 @@ static int bio_iov_iter_bounce_read(struct bio *bio, struct iov_iter *iter,
 	bvec_set_folio(&bio->bi_io_vec[0], folio, bio->bi_iter.bi_size, 0);
 	if (iov_iter_extract_will_pin(iter))
 		bio_set_flag(bio, BIO_PAGE_PINNED);
-	return bio_iov_iter_align_down(bio, iter, minsize - 1);
+	ret = bio_iov_iter_align_down(bio, iter, minsize - 1, 1);
+	if (ret) {
+		folio_put(folio);
+		return ret;
+	}
+	bio->bi_io_vec[0].bv_len = bio->bi_iter.bi_size;
+	return 0;
 }
 
 /**
-- 
2.34.1


^ permalink raw reply related	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2026-07-13 12:47 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-07-10 12:26 [RESEND][SECURITY] block: double unpin in bounced direct reads Neptune
2026-07-13  9:29 ` Christoph Hellwig
2026-07-13 10:20   ` Neptune
2026-07-13 12:47     ` Christoph Hellwig

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.