[BUG] PMP Bypass: Unauthorized store instructions are committed without exceptions

Placeholder Xingzhi Zhang
2026-03-11T16:58:59Z

# Summary
Hi! We found that the CVA6 physical memory protection mechanism (PMP) is not able to throw exception when encounter a PMP-violation store. In addition, the store will be processed and committed normally. We disclose the details of this bug and explain the reason below.

# Bug Description

With PMP, a physical address space can be protected by hardware. PMP is a set of rules that was set in the M-mode. If these rules are violated in S-mode or U-mode, an access fault exception should be thrown. We found that in CVA6, the PMP check fails to prevent a store that violates PMP configuration and does not throw an exception.

We setup the PMP configuration first. In this configuration, the physical address 0x80005000 was restricted to only readable and executable.
```c
uintptr_t pmp_cfg_global = PMP_NAPOT | PMP_R | PMP_W | PMP_X;
uintptr_t pmp_addr_global = ((uintptr_t)1 << (__riscv_xlen == 32 ? 31 : 53)) - 1;
uintptr_t pmp0_addr_80005000 = 0x200015FF;
uintptr_t pmp0_cfg_80005000 = PMP_NAPOT | PMP_R | PMP_X;
uintptr_t pmp_cfg0_combined = (pmp_cfg_global << 8) | pmp0_cfg_80005000;
asm volatile ("la t0, 1f\n\t"
"csrrw t0, mtvec, t0\n\t"
"csrw pmpaddr0, %0\n\t"
"csrw pmpaddr1, %1\n\t"
"csrw pmpcfg0, %2\n\t"
".align 2\n\t"
"1: csrw mtvec, t0"
:
: "r" (pmp0_addr_80005000),
"r" (pmp_addr_global),
"r" (pmp_cfg0_combined)
: "t0");
```

Then, we do some page table mapping and turn into U-mode. In the user space, we try to write a value at the virtual address 0x5000. The virtual address 0x5000 was mapping to physical address 0x80005000, which was protected by PMP.

```asm
li a0, 0x5000
...
sd zero, 0(a0)
```

After execution, we found that there is no exception and the value was successfully written. This strongly violates the security attribute provided by PMP.

The core code section that do PMP check is in [pmp_data_if.sv](https://github.com/openhwgroup/cva6/blob/master/core/pmp/src/pmp_data_if.sv#L118-L148), which are listed as below.
```verilog
always_comb begin : data_interface
// save request and DTLB response
lsu_valid_o = lsu_valid_i;
lsu_paddr_o = lsu_paddr_i;
lsu_exception_o = lsu_exception_i;
pmp_access_type = lsu_is_store_i ? riscv::ACCESS_WRITE : riscv::ACCESS_READ;

// If translation is not enabled, check the paddr immediately against PMPs
if (lsu_valid_i && !data_allow_o) begin
lsu_exception_o.valid = 1'b1;

if (CVA6Cfg.TvalEn) begin
lsu_exception_o.tval = lsu_vaddr_xlen;
end

if (lsu_is_store_i) begin
lsu_exception_o.cause = riscv::ST_ACCESS_FAULT;
end else begin
lsu_exception_o.cause = riscv::LD_ACCESS_FAULT;
end
if (CVA6Cfg.RVH) begin
lsu_exception_o.tval2 = '0;
lsu_exception_o.tinst = '0;
lsu_exception_o.gva = ld_st_v_i;
end
end
end
```
If a store trigger `riscv::ST_ACCESS_FAULT`, the [`lsu_valid_i`](https://github.com/openhwgroup/cva6/blob/master/core/pmp/src/pmp_data_if.sv#L25) and [`lsu_is_store_i`](https://github.com/openhwgroup/cva6/blob/master/core/pmp/src/pmp_data_if.sv#L29) should be 1 at the same time. However, In waveform **G4**, it can be observed that when a store instruction enters the pmp_data_if, `lsu_valid_i` and `lsu_is_store_i` are not synchronized. That is to say, PMP can never trigger the `STORE_ACCESS_FAULT`, nor can it block this memory write request.

The root cause of this out-sync is that the [`lsu_valid_i`](https://github.com/openhwgroup/cva6/blob/master/core/pmp/src/pmp_data_if.sv#L25) signal that comes from [load_store_unit.sv](https://github.com/openhwgroup/cva6/blob/master/core/load_store_unit.sv#L286) was driven by sequential logic and was delayed one cycle in the [cva6_mmu.sv](https://github.com/openhwgroup/cva6/blob/master/core/load_store_unit.sv#L286). The code is listed as below. But the [`lsu_is_store_i`](https://github.com/openhwgroup/cva6/blob/master/core/load_store_unit.sv#L281) is driven by combinational logic. Thus, these two signal have different sources and are destined to be asynchronous.

```verilog
always_ff @(posedge clk_i or negedge rst_ni) begin
if (~rst_ni) begin
lsu_vaddr_q <= '0;
lsu_gpaddr_q <= '0;
lsu_req_q <= '0;
dtlb_pte_q <= '0;
dtlb_gpte_q <= '0;
dtlb_hit_q <= '0;
lsu_is_store_q <= '0;
dtlb_is_page_q <= '0;
lsu_tinst_q <= '0;
hs_ld_st_inst_q <= '0;
misaligned_ex_q <= '0;
end else begin
lsu_vaddr_q <= lsu_vaddr_n;
lsu_req_q <= lsu_req_n;
dtlb_pte_q <= dtlb_pte_n;
dtlb_hit_q <= dtlb_hit_n;
lsu_is_store_q <= lsu_is_store_n;
dtlb_is_page_q <= dtlb_is_page_n;
misaligned_ex_q <= misaligned_ex_n;

if (CVA6Cfg.RVH) begin
lsu_tinst_q <= lsu_tinst_n;
hs_ld_st_inst_q <= hs_ld_st_inst_n;
dtlb_gpte_q <= dtlb_gpte_n;
lsu_gpaddr_q <= lsu_gpaddr_n;
end
end
end
```

This bug can be reproduced in both DTLB hit and DTLB miss at the default cv64a6_imafdc_sv39 config. The full elf file and waveform file are provided below.

[rv64_test-av-CVA6-pmp-store-bypass.zip](https://github.com/user-attachments/files/25913875/rv64_test-av-CVA6-pmp-store-bypass.zip)