[BUG] Spectre-type Vulnerability and Its Different Variants were Found in CVA6

Placeholder Xingzhi Zhang
2026-01-23T16:07:43Z

## Summary

We creatively used `ecall` to delay the resolution of the branch, creating a branch misprediction transient window. In this window, we can access kernel-space data and encode it in different microarchitecture components at U-mode without exception.

## Bug Description
Hi! Our processor side-channel vulnerability detection work has found Spectre vulnerability and its different variants on CVA6, which allows an attacker to leak kernel-space data from user space without exception.

CVA6 employs thie Scoreboard for out-of-order issuing. The Scoreboard algorithm won't issue instructions that have RAW conflicts. Therefore, when a B-type instruction is issued, its operands are already ready. So, B-type instructions can be resolved very quickly. As a result, the traditional Spectre vulnerabilities cannot perform the secret access and encoding operation within the CVA6's 1-2 cycles branch midprediction window.

However, we creatively introduce `ecall` to delay the resolution of the branch. `ecall` is a instruction that will implicitly write register `a0`. But in the implementation of CVA6 Scoreboard, `ecall` won't be in conflict with the subsequent instructions that take `a0` as an operand. This give us a chance to issue B-type instruction with operand `a0`. CVA6 will read the old value of `a0` and make a wrong descision on the B-type instrucion. Then, we accessed and encoded the secret on the wrong branch. This takes advantage of the feature of the CVA6 Scoreboard: in-flight intructions in the scoreboard won’t trigger exceptions until they commit, which we have illustrated in detail in previous issue: [An exploitable Meltdown vulnerability was found in CVA6](https://gitlab.eclipse.org/openhw-group/backup-20260329/cva6/-/issues/3163).

We provide an end-to-end exploit code and vulnerability explanation below.

```
2f40: fdiv.d fa5, fa5, fa4
2f44: ecall # overwrite a0 to a non-zero value
2f48: bnez a0, 2f84 # the old value of a0 is zero
2f4c: lb t0, 0(a1) # a1 contains secret address 0xffffffffffe04000
2f50: slli t0, t0, 0x5 # use secret as index
2f54: add t0, t1, t0 # t1 contains probe array address 0x10000
2f58: ld t1, 0(t0) # encode secret to probe array
...
2f84 : # the true control flow will jump to here
...
```

The code runs under U-mode. `a0` is initialized to 0. The `lb` at `0x2f4c` tries to access `0xffffffffffe04000`, which is a kernel-space address. After the secret data is loaded, it will be encoded into the cache through the user-space array with the base address of `0x10000`. Due to the long latency of fdiv and the misprediction of `bnez`, subsequent instructions will be issued during the transition execution window, as the waveform shows.

**G1** represents the [commit signals](https://github.com/openhwgroup/cva6/blob/master/core/scoreboard.sv#L100).**G2** represents [Scoreboard entries](https://github.com/openhwgroup/cva6/blob/master/core/scoreboard.sv#L100). **G3** represents the LSU, Dcache and memory bus behaviors, including [`lsu_valid_i`](https://github.com/openhwgroup/cva6/blob/master/core/load_store_unit.sv#L52), [`vaddr_o`](https://github.com/openhwgroup/cva6/blob/master/core/load_store_unit.sv#L575), [`paddr_i`](https://github.com/openhwgroup/cva6/blob/master/core/load_store_unit.sv#L579), [`miss_o`](https://github.com/openhwgroup/cva6/blob/master/core/cache_subsystem/wt_dcache.sv#L37), [`mem_rtrn_vld_i`](https://github.com/openhwgroup/cva6/blob/master/core/cache_subsystem/wt_dcache.sv#L51), `io_axi4_0_ar_valid`, `io_axi4_0_ar_bits_addr` and `io_axi4_0_r_valid`. The first `lsu_valid_i` is triggered by `lb`, and the access address is successfully translated. The secret data is read from the Dcache and forwarded to the Scoreboard. Thus, the `slli` and `add` can be issued in sequence. Finally, the secret is encoded in `t0`, which is the load address of `ld` at `0x2f58`. This is a cache-miss address. Thus, Dcache will fill the cacheline by initiating a memory access, as observed in `io_axi4_0_ar_valid`. All of the above processes take place within the `fdiv` transient execution window.

After `ecall` trap process finished, `a0` will be modified to a non-zero value. `bnez` at `0x2f48` will taken and control flow will skip these instructions between `0x2f4c` and `0x2f58`. But these instructions have been microarchitecturally executed.

Then we try to probe whether we successfully encoded the secret into the cache. The code sequentially accessed the memory addresses `0x10000`, `0x10010`, `0x10020`, and `0x10030`. These addresses belong to different cachelines.

```
5000: lui t0, 0x10
5004: ld t1, 0(t0) # t0 = 0x10000
5008: ld t1, 16(t0)
500c: ld t1, 32(t0)
5010: ld t1, 48(t0)
```

As shown in the waveform, compared with the `ld` that accesses `0x10020`, all the other `ld` have significantly longer execution times and all exhibit bus memory access behavior. Therefore, through the time side-channel, we can recover the aforementioned secret data.

This vulnerability can be reproduced at the master commit [aa4f5a5](https://github.com/openhwgroup/cva6/commit/aa4f5a548b33f7edff63483347fc8510aa9ad5fd) and the default cv64a6_imafdc_sv39 config. The full elf file are provided below.

## Variants

In CVA6, not only the DCache can be used as a component for encoding and decoding secrets. We have discovered that the frontend states, such as BTB, BHT, RAS, etc., can be changed within the transient window. And after the speculation execution was rolled back, these states will not be rolled back. Thus, secrets can be encoded in these front-end components.

### BTB

[BTB](https://github.com/openhwgroup/cva6/blob/master/core/frontend/btb.sv#L28) is a frontend branch prediction unit. It provides branch prediction for `jalr` instructions except for `call` and `ret`. BTB will record the target addresses of these `jalr` instructions, and when it encounters the corresponding `jalr` again in the future, it will directly predict the jump address to be the recorded target address. As shown in the following code, we have constructed a jump address that depends on the secret. Depending on the secret, the target address for the `jalr` instruction located at `0x2f58` will change. During the transient window, after the `jalr` target jump address is calculated, the BTB entries will be updated. After the transient window rollback, the BTB entries will not be invalidated, so we will keep the secret in the BTB.

```
2f40: fdiv.d fa5, fa5, fa4
2f44: ecall
2f48: bnez a0, 80002f9c
2f4c: lb t0, 0(a1) # a1 contains secret address
2f50: slli t0, t0, 0x2
2f54: add t1, t1, t0 # t1 contains probe address
2f58 :
2f58: jalr t0, t1 # train the btb, different secret will reseult in different target address
...
2f8c :
2f8c: nop
2f90: nop
2f94: nop
2f98: nop
...
```

As shown in the following two waveform figures. We changed the value of secret and ran the testcase. When the secret is 0, the predicted jump address of the corresponding entry in the BTB will be updated to `2f8c`. When the secret is 1, the predicted jump address of the corresponding entry in the BTB will be updated to `2f90`. **G5** represents [BTB update signals](https://github.com/openhwgroup/cva6/blob/master/core/frontend/btb.sv#L45).

**secret = 0**

**secret != 0**

Next, we will attempt to decode the data. Based on the analysis of the [BTB entry matching mechanism](https://github.com/openhwgroup/cva6/blob/master/core/frontend/btb.sv#L69), we found that BTB makes predictions only based on the lower bits of the PC, and does not match the entire PC. Therefore, we use the following instruction for decoding.
```
5000: la t1, probe # t1 = 0x2f8c
...
5058: jalr t0, t1
```

During the decoding stage, we placed a `jalr` instruction at the address `0x5058`. This address shares the same BTB entry with the `jalr` instruction that previously encoded the secret at `0x2f58`. So it will trigger branch prediction. **G4** represents [BTB prediction signals](https://github.com/openhwgroup/cva6/blob/master/core/frontend/btb.sv#L47). If the secret is 0, then the branch prediction is correct and the execution time is shorter. If the secret is 1, then the branch prediction is incorrect and the execution time will be slower. So this creates a timing side-channel.

**secret = 0**

**secret != 0**

### BHT

[BHT](https://github.com/openhwgroup/cva6/blob/master/core/frontend/bht.sv) is also a frontend branch predictor. It records the jump situations of the B-type instructions. When it encounters the corresponding B-type instructions again, it makes branch prediction based on the recorded historical information. This BHT uses a 2-bit [saturation counter](https://github.com/openhwgroup/cva6/blob/master/core/frontend/bht.sv#L56) to record the historical taken/untaken situations. If a B-type instruction is taken, the saturation counter of the corresponding entry is incremented by one. When the value of the saturation counter is greater than or equal to 2, it predicts that the B-type instruction will be taken in the future.

We conduct the following testcase. The `beqz` instruction at `0x2f50` is taken/untaken based on whether the secret is zero. We use this instruction to encode the secret.

```
2f40: fdiv.d fa5, fa5, fa4
2f44: ecall
2f48: bnez a0, 2f80
2f4c: lb t0, 0(a1) # a1 contains secret address
2f50:
2f50: beqz t0, 2f7c # train BHT
...
2f7c :
```

The two waveform diagrams below illustrate the encoding stages under different secrets. **G5** represents the [BHT entry](https://github.com/openhwgroup/cva6/blob/master/core/frontend/bht.sv#L59) corresponding to the `beqz` instruction. When the secret is 0, the `beqz` instruction is taken and updates the corresponding saturation counter to 1. When the secret is 1, the `beqz` instruction is untaken, and the saturation counter for the corresponding entry remains unchanged and stays at 0.

**secret = 0**

**secret != 0**

We use the following instructions to decode the secret. Similar to BTB, BHT also only matches the lower bits of the PC to determine a BHT entry. We placed a `beqz` instruction at the address `0x5050`. This instruction will use the same BHT entry as the `beqz` instruction that encodes secret at address `0x2f50`. We set `t0` to 0 so that the `beqz` instruction will be taken. Then we observe the behavior of the `beqz` instruction at address 0x5050 at its second time execution.

```
5000: li t0, 0
...
5050: beqz t0, 5000
```

**G4** represents the [BHT prediction result](https://github.com/openhwgroup/cva6/blob/master/core/frontend/bht.sv#L42) for the `beqz` instruction at address `0x5050`. When the secret is 0, this BHT entry records two branch takens. Therefore, when the instruction at `0x5050` is executed for the second time, the BHT will predicte it to be taken. The branch prediction is correct and the execution time is faster. When the secret is 1, this BHT entry records only one branch taken. The BHT will predicte it to be untaken. The branch prediction is incorrect and the execution time slows down. This forms a timing side-channel.

**secret = 0**

**secret != 0**

### RAS

[RAS](https://github.com/openhwgroup/cva6/blob/master/core/frontend/ras.sv) is used to push the return address onto the stack when making a call. We attempted to utilize RAS to create a temporal side-channel. By performing secret-dependent RAS operations within the transient window, the secret is encoded within the RAS. After the transient window rollback, the status of RAS will not be revoked. This enables the decoding of the secret.

In the following testcase, `ra` is influenced by the secret. If the secret is 0, then the `jalr` instruction located at `0x2f58` will be executed. Executing this instruction will push the address of the next instruction into RAS, thereby changing the state of RAS. If the secret is not zero, then `0x2f58` will be skipped and the state of RAS will not be changed.

```
2f40: fdiv.d fa5, fa5,fa4
2f44: ecall
2f48: bnez a0, 2fb0
2f4c: lb t0, 0(a1) # a1 contains secret address
2f50: add ra, t2, t0 # t2 is initialized to 0x2f58. RAS top = previous call address + 4
2f54: ret # pop RAS. if t0 == 0, continue to execute 0x2f58
2f58: jalr ra, t3 # push 0x2f5c into RAS. RAS top = 0x2f5c
```

**G4** represents the RAS-related signal. Including the push signal [`push_i`](https://github.com/openhwgroup/cva6/blob/master/core/frontend/ras.sv#L29), the data pushed onto the stack [`data_i`](https://github.com/openhwgroup/cva6/blob/master/core/frontend/ras.sv#L33), the pop signal [`pop_i`](https://github.com/openhwgroup/cva6/blob/master/core/frontend/ras.sv#L31), the valid signal indicating the availability of the top stack data [`data_o.valid`](https://github.com/openhwgroup/cva6/blob/master/core/frontend/ras.sv#L35), and the data at the top of the stack [`data_o.ra`](https://github.com/openhwgroup/cva6/blob/master/core/frontend/ras.sv#L35). When the secret is 0, the `jalr` instruction at `0x2f58` within the transient window is executed, pushing `0x2f5c` into the stack. When the secret is not 0, the state of RAS has not changed.

**secret = 0**

**secret != 0**

We use the following code to decode the secret. We execute an instruction that returns to `0x2f5c`. At this point, RAS is used for target address prediction. This address is precisely the value that was pushed into RAS when the secret was 0.

```
5000: li ra, 0x2f5c
5008: ret
```

Observe the ret instruction located at 0x5008. If the value of "secret" is 0, the prediction is correct and the execution time is fast. If the value of "secret" is not 0, the prediction will be incorrect and the execution time will be slow. This forms a timing side-channel.

**secret = 0**

**secret != 0**

### ICache

All the instructions executed by CVA6 come from the [ICache](https://github.com/openhwgroup/cva6/blob/master/core/cache_subsystem/cva6_icache.sv). The reading of instructions is strongly related to the control flow. So we attempted to influence the control flow through "secret", thereby affecting the state of the ICache, in order to encode the secret into the ICache.

In the following testcase, a secret-dependent `beqz` instruction was placed at the address `0x2f50`. If the secret is 0, it will jump to the `probe` instruction block. It is necessary to ensure beforehand that the `probe` instruction block is not in the ICache.

```
2f40: fdiv.d fa5, fa5, fa4
2f44: ecall
2f48: bnez a0, 2f90
2f4c: lb t0, 0(a1)
2f50: beqz t0, 2f80
...
2f80 :
```

**G4** represents the signals related to the ICache, including the ICache miss signal [`miss_o`](https://github.com/openhwgroup/cva6/blob/master/core/cache_subsystem/cva6_icache.sv#L50) and the instructions request signal [`areq_i`](https://github.com/openhwgroup/cva6/blob/master/core/cache_subsystem/cva6_icache.sv#L52). When the secret is 0, the control flow needs to jump to the `probe` instruction block at address `0x2f80`. Since the `probe` instruction is not located in the ICache, an ICache miss occurred. At this point, the ICache initiates a refill request to the memory. Ultimately, the content of the `probe` instruction block is filled back into the ICache. However, when the secret is not zero, the `probe` instruction block will not be filled into the ICache.

**secret = 0**

**secret != 0**

We directly execute the probe instruction block and observe the execution time.

```
5000: j 2f80
```

It can be observed that when the secret is 0, the `probe` instruction block has already been loaded into the ICache during the transient window, so when jumping to the `probe` instruction block, it can be executed directly. However, when the secret is not zero, the `probe` instruction block will not be loaded in the transient window. Thus, an ICache miss occurs and the instruction needs to be fetched from the memory, resulting in a significant time difference.

**secret = 0**

**secret != 0**

## Conclusion

Overall, although CVA6 uses the scoreboard for instruction scheduling and inherently has a relatively small transient execution window, it can still be bypassed in various ways. Moreover, the microarchitecture of CVA6 has numerous timing vulnerabilities.

[CVA6-spectre-ecall-dcache.zip](https://github.com/user-attachments/files/24755549/CVA6-spectre-ecall-dcache.zip)

[CVA6-spectre-ecall-btb.zip](https://github.com/user-attachments/files/24755552/CVA6-spectre-ecall-btb.zip)

[CVA6-spectre-ecall-bht.zip](https://github.com/user-attachments/files/24755554/CVA6-spectre-ecall-bht.zip)

[CVA6-spectre-ecall-icache.zip](https://github.com/user-attachments/files/24755556/CVA6-spectre-ecall-icache.zip)

[CVA6-spectre-ecall-ras.zip](https://github.com/user-attachments/files/24755557/CVA6-spectre-ecall-ras.zip)