[PATCH] riscv: lib: Fix ZBB strnlen reading past count boundary

Michael Neuling mikey at neuling.org
Sun Apr 12 18:07:38 PDT 2026


The ZBB-optimized strnlen loop loads one word ahead before checking the
aligned boundary:

    REG_L   t1, SZREG(t0)       // load next word
    addi    t0, t0, SZREG       // advance
    orc.b   t1, t1
    bgeu    t0, t4, 4f          // boundary check AFTER load

where t4 = (s + count) & -SZREG.  When s is aligned and count is a
multiple of SZREG, t4 equals s + count and the loop loads a full word
starting at exactly s + count.  If s + count falls on a page boundary
with the next page unmapped, this faults.

Fix by computing the aligned boundary from the last valid byte
(s + count - 1) instead of s + count.  This makes the loop stop at the
word containing the last valid byte rather than potentially loading the
word after it.  The count == 0 case is already handled by the beqz
early exit.

Also add a pre-loop guard (bgeu t0, t4) for the case where all valid
bytes fit within the first word.  With the adjusted boundary, t4 can
equal t0, and entering the loop with stale register state from the
first-word processing would produce incorrect results.

The final minu clamp ensures the result is still correct when the last
loaded word extends past s + count - 1 within the same aligned word.

Fixes: 5ba15d419fab ("riscv: lib: add strnlen() implementation")
Signed-off-by: Michael Neuling <mikey at neuling.org>
Assisted-by: Claude Opus4.6 High Thinking
---

Here is a test case that demonstrates the bug. The kernel code is pulled out 
into a standalone file for testing.

 % cat test-strnlen-single.c
// SPDX-License-Identifier: GPL-2.0
/*
 * Minimal test: one strnlen call that triggers the ZBB over-read.
 * Run under GDB to single-step the fault.
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

extern size_t kernel_strnlen(const char *s, size_t count);

int main(void)
{
	size_t page_size = sysconf(_SC_PAGESIZE);
	char *region, *start;
	size_t count = 16;  /* multiple of SZREG=8, triggers the bug */
	size_t ret;

	/* Map one page, guard page after it is unmapped */
	region = mmap(NULL, 2 * page_size, PROT_READ | PROT_WRITE,
		      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
	munmap(region + page_size, page_size);

	/* Fill with non-NUL, no terminator within count */
	memset(region, 'A', page_size);

	/* Aligned start, count reaches exactly to page end */
	start = region + page_size - count;

	printf("page=%p start=%p count=%zu end=%p (page_end=%p)\n",
	       region, start, count, start + count, region + page_size);
	printf("Calling kernel_strnlen...\n");

	/* This will fault on the buggy ZBB path */
	ret = kernel_strnlen(start, count);

	printf("Result: %zu (expected %zu)\n", ret, count);

	munmap(region, page_size);
	return 0;
}
% cat test-strnlen-zbb.S
/* SPDX-License-Identifier: GPL-2.0-only */
/*
 * Standalone copy of the kernel's ZBB strnlen for userspace testing.
 * Extracted from arch/riscv/lib/strnlen.S
 */

#define SZREG	8
#define REG_L	ld

.text
.global kernel_strnlen
.type kernel_strnlen, @function

kernel_strnlen:
	/* Jump straight to ZBB path (we know we have it) */
	j	strnlen_zbb

	/*
	 * Non-ZBB fallback (byte-at-a-time)
	 */
	addi	t1, a0, -1
	add	t2, a0, a1
1:
	addi	t1, t1, 1
	beq	t1, t2, 2f
	lbu	t0, 0(t1)
	bnez	t0, 1b
2:
	sub	a0, t1, a0
	ret


strnlen_zbb:

# define CZ	ctz
# define SHIFT	srl

.option push
.option arch,+zbb

	/* If maxlen is 0, return 0. */
	beqz	a1, 3f

	/* Number of irrelevant bytes in the first word. */
	andi	t2, a0, SZREG-1

	/* Align pointer. */
	andi	t0, a0, -SZREG

	li	t3, SZREG
	sub	t3, t3, t2
	slli	t2, t2, 3

	/* Aligned boundary. */
	add	t4, a0, a1
	andi	t4, t4, -SZREG

	/* Get the first word.  */
	ld	t1, 0(t0)

	/*
	 * Shift away the partial data we loaded to remove the irrelevant bytes
	 * preceding the string with the effect of adding NUL bytes at the
	 * end of the string's first word.
	 */
	srl	t1, t1, t2

	/* Convert non-NUL into 0xff and NUL into 0x00. */
	orc.b	t1, t1

	/* Convert non-NUL into 0x00 and NUL into 0xff. */
	not	t1, t1

	/*
	 * Search for the first set bit (corresponding to a NUL byte in the
	 * original chunk).
	 */
	ctz	t1, t1

	/*
	 * The first chunk is special: compare against the number
	 * of valid bytes in this chunk.
	 */
	srli	a0, t1, 3

	/* Limit the result by maxlen. */
	minu	a0, a0, a1

	bgtu	t3, a0, 2f

	/* Prepare for the word comparison loop. */
	addi	t2, t0, SZREG
	li	t3, -1

	/*
	 * Our critical loop is 4 instructions and processes data in
	 * 4 byte or 8 byte chunks.
	 */
	.p2align 3
1:
	ld	t1, SZREG(t0)
	addi	t0, t0, SZREG
	orc.b	t1, t1
	bgeu	t0, t4, 4f
	beq	t1, t3, 1b
4:
	not	t1, t1
	ctz	t1, t1
	srli	t1, t1, 3

	/* Get number of processed bytes. */
	sub	t2, t0, t2

	/* Add number of characters in the first word.  */
	add	a0, a0, t2

	/* Add number of characters in the last word.  */
	add	a0, a0, t1

	/* Ensure the final result does not exceed maxlen. */
	minu	a0, a0, a1
2:
	ret
3:
	mv	a0, a1
	ret

.option pop

.size kernel_strnlen, .-kernel_strnlen
% riscv64-linux-gnu-gcc -march=rv64gc_zbb -O0 -g -static -o test-strnlen-single-rv64 test-strnlen-single.c test-strnlen-zbb.S
% qemu-riscv64 -cpu rv64,zbb=true ./test-strnlen-single-rv64
page=0x7ff6d698c000 start=0x7ff6d698cff0 count=16 end=0x7ff6d698d000 (page_end=0x7ff6d698d000)
Calling kernel_strnlen...
Segmentation fault (core dumped)
%

 arch/riscv/lib/strnlen.S | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/arch/riscv/lib/strnlen.S b/arch/riscv/lib/strnlen.S
index 53afa7b5b3..a8911605c2 100644
--- a/arch/riscv/lib/strnlen.S
+++ b/arch/riscv/lib/strnlen.S
@@ -83,8 +83,13 @@ strnlen_zbb:
 	sub	t3, t3, t2
 	slli	t2, t2, 3
 
-	/* Aligned boundary. */
+	/*
+	 * Aligned boundary.  Use the address of the last valid byte
+	 * (s + count - 1) to avoid loading a word past the count
+	 * boundary in the loop below.  count == 0 is handled above.
+	 */
 	add	t4, a0, a1
+	addi	t4, t4, -1
 	andi	t4, t4, -SZREG
 
 	/* Get the first word.  */
@@ -120,6 +125,9 @@ strnlen_zbb:
 
 	bgtu	t3, a0, 2f
 
+	/* All remaining bytes are in the first word, no loop needed. */
+	bgeu	t0, t4, 2f
+
 	/* Prepare for the word comparison loop. */
 	addi	t2, t0, SZREG
 	li	t3, -1
-- 
2.43.0




More information about the linux-riscv mailing list