[SECURITY] lib: sbi_domain: integer overflow in sbi_domain_check_addr_range() bypasses domain isolation — M-mode arbitrary read/write/execute from S-mode

刘通 liutong at iscas.ac.cn
Sat Apr 18 01:04:33 PDT 2026


Hi,

I am reporting a critical security vulnerability in OpenSBI.
An integer overflow in sbi_domain_check_addr_range() allows 
S-mode software to completely bypass domain permission checks,
directly enabling arbitrary read and arbitrary write of M-mode firmware
memory. These primitives can be further chained to achieve M-mode code
execution.

Under RISC-V H-extension virtualization, a VS-mode guest can exploit
the same flaw through the hypervisor SBI call forwarding path — a full
virtual machine escape.

All impacts have been verified on QEMU and Spike with working exploits.
This vulnerability is not mitigated by PMP, SmePMP, or the domain
system itself.

## Affected Code

File: lib/sbi/sbi_domain.c, lines 497-526

```c
bool sbi_domain_check_addr_range(const struct sbi_domain *dom,
                                 unsigned long addr, unsigned long size,
                                 unsigned long mode,
                                 unsigned long access_flags)
{
    unsigned long max = addr + size;    // [BUG] no overflow check
    ...
    while (addr < max) {               // skipped when max wraps
        ...                            // ALL permission checks here
    }
    return true;                       // fail-open: unchecked = allowed
}
```

## Root Cause

`max = addr + size` is an unprotected unsigned addition. On RV64, when
addr + size >= 2^64, the result wraps to a value less than addr. The
while loop condition `addr < max` is immediately false, so the loop
body — which contains all region permission checks — executes zero
times. The function then falls through to `return true`.

This is a fail-open design: no checks performed = access granted.

There are two trigger conditions:

  Condition A — Integer overflow (addr + size wraps to 0):
    addr = 0x80000000, size = 0xFFFFFFFF80000000
    max  = 0  →  while (0x80000000 < 0x0) = false  →  return true

  Condition B — Zero-size bypass (size = 0):
    addr = 0x80000000, size = 0
    max  = addr  →  while (addr < addr) = false  →  return true

Condition B is side-effect-free and suitable for silent probing.

## Affected SBI Extensions

The following ecall handlers pass S-mode-controlled register values
directly to the vulnerable function:

  Extension          File                    S-mode controlled params
  ─────────────────  ──────────────────────  ────────────────────────
  DBCN CONSOLE_WRITE sbi_ecall_dbcn.c:45     addr=a1, size=a0
  DBCN CONSOLE_READ  sbi_ecall_dbcn.c:45     addr=a1, size=a0
  MPXY set_shmem     sbi_mpxy.c:369          addr, size
  PMU set_shmem      sbi_pmu.c:1070          addr, size
  SSE read/write     sbi_sse.c:1001          addr, size
  SSE register       sbi_sse.c:1120          addr, size

DBCN is the most direct attack vector. After the domain check is
bypassed, M-mode operates on the attacker-supplied address with no
further validation:

  CONSOLE_WRITE → sbi_nputs(addr, size) → M-mode reads from addr
  CONSOLE_READ  → sbi_ngets(addr, size) → M-mode writes to addr

## Impact 1: Arbitrary Read of M-mode Memory

Using DBCN CONSOLE_WRITE with Condition A:

  ecall(ext=DBCN, fid=CONSOLE_WRITE, a0=-addr, a1=addr, a2=0)

The domain check sees addr + size = 0 (overflow), skips validation,
returns true. M-mode then calls sbi_nputs(addr, size), which reads
from the firmware memory address in a loop and outputs each byte to
the console. The attacker captures the output.

This directly leaks:
  - Firmware code (.text) and constants (.rodata)
  - All M-mode data: ecall handler tables, domain region configs,
    PMP state, platform structures, sbi_scratch per-hart data
  - Cryptographic keys or secrets stored in M-mode memory
  - The full firmware binary (verified: 64/64 byte-for-byte match)

No shellcode or write primitive is needed. The read works on the
first attempt with 100% reliability.

## Impact 2: Arbitrary Write to M-mode Memory

Using DBCN CONSOLE_READ with Condition A:

  ecall(ext=DBCN, fid=CONSOLE_READ, a0=-addr, a1=addr, a2=0)

The domain check is bypassed identically. M-mode then calls
sbi_ngets(addr, size), which reads bytes from the UART receive
buffer and writes them to the firmware memory address. The attacker
controls the UART input (via stdin pipe on emulators, or via
physical serial on real hardware).

sbi_ngets() uses non-blocking sbi_getc(): it writes exactly as many
bytes as are available in the UART buffer, then returns. This gives
the attacker precise control over write length and content.

Verified: ecall_impid at 0x80040cf0 overwritten from 0x1 to
0x4242424241414141 (8 bytes, confirmed via SBI BASE GET_IMPL_ID).

The firmware .data section (0x80040000-0x8005ffff) contains 18 ecall
extension structures (each with a .handle function pointer), the
domain_list security policy, sbi_hart_expected_trap, platform ops,
and 31 other function pointer targets. All are writable.

## Impact 3: M-mode Code Execution

The write primitive enables a standard three-step code execution
chain:

  Step 1: Write 16-byte shellcode to 0x80060000 (RAM beyond the
          firmware region, accessible to M-mode)
  Step 2: Overwrite ecall_rfence.handle (at 0x80040e18) with
          0x80060000
  Step 3: ecall(ext=SBI_EXT_RFENCE) → OpenSBI dispatches to the
          hijacked .handle → shellcode executes in M-mode

The shellcode reads misa CSR (M-mode only; S-mode access causes an
illegal instruction trap). Verified return: misa = 0x800000000014112d.

## Impact 4: VM Escape via H-extension

Under H-extension virtualization, VS-mode ecalls (cause 10) are
delegated to HS-mode via MEDELEG. The hypervisor (e.g., Linux KVM)
forwards guest SBI calls to M-mode by re-issuing ecall from HS-mode
with the guest's original register values.

OpenSBI cannot distinguish this forwarded ecall from a direct HS-mode
call: it reads MSTATUS.MPP (= PRV_S for both) but never checks
MSTATUS.MPV. No GPA-to-HPA translation is performed on the address
parameters. The domain check uses the HS-mode hart's domain, not the
guest's permissions.

Verified on Spike (--isa=rv64imafdc_h) with a minimal HS-mode
hypervisor: VS-mode guest achieves M-mode arbitrary read, write, and
code execution. misa = 0x80000000001411ad (RV64 ACDFHIMSU, H-bit
confirms the hypervisor extension context).

## Verified Test Results

All tests on OpenSBI v1.8.1 (commit 2257e995), generic platform.

  Test                    Environment      Result
  ──────────────────────  ───────────────  ─────────────────────────
  Domain bypass (×6)      QEMU + Spike     3/3 overflow bypass,
                                           3/3 baseline blocked
  PMP direct access       Spike H-ext      ACCESS FAULT (PMP works)
  Firmware read (leak)    QEMU + Spike     64/64 bytes match fw_jump.bin
  Firmware write          QEMU + Spike     ecall_impid: 0x1 → 0x4242424241414141
  M-mode code execution   QEMU + Spike     misa = 0x800000000014112d
  PMP disable shellcode   QEMU             csrw pmpcfg0/2, zero — succeeded
  VM escape (H-ext RCE)   Spike rv64gc_h   misa = 0x80000000001411ad

## Additional Overflow Sites

The same overflow pattern exists in two other functions in the same
file. These are only called during boot from trusted parameters
(FDT/hardware), so they are not currently exploitable from S-mode,
but should be fixed for defense in depth:

  - sbi_domain_root_add_memrange() line 766:
      end = addr + size;
      while (pos < end) { ... }

  - sbi_domain_memregion_init() line 107:
      (addr + size - 1UL) underflows when size = 0

## Suggested Fix

```c
bool sbi_domain_check_addr_range(const struct sbi_domain *dom,
                                 unsigned long addr, unsigned long size,
                                 unsigned long mode,
                                 unsigned long access_flags)
{
    unsigned long max;
    const struct sbi_domain_memregion *reg, *sreg;

    if (!dom)
        return false;

    /* Reject zero-size ranges */
    if (!size)
        return false;

    /* Detect unsigned overflow */
    if (addr + size < addr)
        return false;

    max = addr + size;

    while (addr < max) {
        /* ... existing logic unchanged ... */
    }

    return true;
}
```

The two added checks are O(1) with no impact on normal operation:

  1. size == 0 → return false
     Zero-size address ranges have no legitimate security use.

  2. addr + size < addr → return false
     Standard unsigned overflow detection: a wrapped sum is always
     less than either operand.

## Disclosure Note

OpenSBI does not appear to have a documented security disclosure
policy (no SECURITY.md or security advisory process). I am sending
this to the public mailing list accordingly. If a private channel
exists, I am happy to coordinate disclosure there instead.

Regards

Signed-off-by: liutong at iscas.ac.cn


More information about the opensbi mailing list