[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