target/riscv: matching sspopchk corrupts following PC-relative instructions in CF_PCREL mode

Host environment

  • Operating system:

    Ubuntu on WSL2 (Windows host)

  • OS/kernel version:

    Linux 6.6.114.1-microsoft-standard-WSL2

  • Architecture:

    x86_64

  • QEMU flavor:

    qemu-system-riscv64

  • QEMU version:

    11.0.92 (v11.1.0-rc2), also reproduces on git master

  • QEMU command line:

    qemu-system-riscv64 -machine virt
    -cpu rv64,zicfiss=true,zimop=true,zcmop=true,mmu=false,pmp=false
    -nographic -bios sspopchk-pcsync.bin ; echo "exit=$?"

    Description of problem

    trans_sspopchk() in target/riscv/tcg/insn_trans/trans_rvzicfiss.c.inc emits the gen_update_pc(ctx, 0) that synchronizes cpu_pc only on the shadow-stack mismatch (exception) path, after the tcg_gen_brcond_tl() that skips to the success label. In CF_PCREL translation mode gen_update_pc() also updates the translator's pc_save at translation time. Because the runtime cpu_pc write is placed on the mismatch path only, a matching sspopchk (the normal case) skips that write, leaving cpu_pc stale while the translator believes it is up to date. Every subsequent PC-relative instruction in the same translation block (auipc, lla, PC-relative branches, etc.) then computes a wrong address. The visible effect ranges from wrong auipc results to corrupted branch targets (hangs / wild jumps) in any CFI-compiled guest that uses shadow stacks. A fix has already been posted upstream by Max Chou (SiFive) but has not yet been reviewed or merged: https://lore.kernel.org/qemu-devel/20251105134331.2865581-1-max.chou@sifive.com/ It moves gen_update_pc(ctx, 0) before the shadow-stack load so the PC is synchronized on both paths. I have verified that this patch fixes the reproducer below.

    Steps to reproduce

    1. Save this self-contained bare-metal reproducer as sspopchk-pcsync.S. It runs in S-mode with mmu=false (so the shadow-stack access is identity mapped to normal RAM, no page tables needed), sets up a one-entry shadow stack, and, inside a single translation block, runs:

    run_test: auipc t2, 0     # t2 = &run_test (before sspopchk, always correct)
              sspopchk t0     # shadow stack matches -> takes the skip path
    here:     auipc t1, 0     # t1 must equal &here

    It then checks t1 == &here using register-absolute arithmetic (no PC-relative branch, which would itself be corrupted) and reports via the sifive_test device: exit code 0 on success, 42 on failure.

    .equ MENVCFG,     0x30a
    .equ MENVCFG_SSE, (1 << 3)
    .equ CSR_SSP,     0x011
    .equ MSTATUS_MPP,   (3 << 11)
    .equ MSTATUS_MPP_S, (1 << 11)
    .equ SIFIVE_TEST,   0x100000
    .equ FINISHER_PASS, 0x5555
    .equ FINISHER_FAIL, 0x2a3333
    .equ COOKIE,        0x123456789abcdef0
        .section .text
        .globl _start
    _start:
        li   t0, MENVCFG_SSE
        csrs MENVCFG, t0            # enable S-mode shadow stack
        li   t0, MSTATUS_MPP
        csrc mstatus, t0
        li   t0, MSTATUS_MPP_S
        csrs mstatus, t0           # mstatus.MPP = S
        la   t0, s_entry
        csrw mepc, t0
        mret                       # drop to S-mode
    s_entry:
        la   t1, ss_slot
        li   t2, COOKIE
        sd   t2, 0(t1)             # memory[ss_slot] = COOKIE
        csrw CSR_SSP, t1           # ssp = &ss_slot
        mv   t0, t2                # t0 (x5) = COOKIE == [ssp]
        j    run_test
        .align 2
    run_test:
        auipc t2, 0                # t2 = &run_test (correct)
        .word 0xCDC2C073           # sspopchk t0 -> match, skip path
    here:
        auipc t1, 0                # t1 = &here (buggy: 4 bytes low)
        addi  t3, t2, 8            # expected &here = &run_test + 8
        xor  t5, t1, t3            # 0 if correct
        snez t5, t5
        neg  t5, t5                # mismatch ? -1 : 0
        li   t4, FINISHER_PASS
        li   t6, (FINISHER_FAIL - FINISHER_PASS)
        and  t6, t6, t5
        add  t4, t4, t6            # 0x5555 if ok else 0x2a3333
        li   t3, SIFIVE_TEST
        sw   t4, 0(t3)             # exit QEMU
    1:  j 1b
        .section .bss
        .align 3
    ss_slot:
        .space 8

    2. Build (any binutils works; sspopchk t0 is emitted as .word 0xCDC2C073 since older assemblers lack Zicfiss):

    riscv64-unknown-elf-gcc -march=rv64i_zicsr -mabi=lp64 -nostdlib \
        -nostartfiles -Wl,-Ttext=0x80000000 -o sspopchk-pcsync.elf sspopchk-pcsync.S
    riscv64-unknown-elf-objcopy -O binary sspopchk-pcsync.elf sspopchk-pcsync.bin

    Additional information

    The root cause is the conditional PC synchronization in trans_sspopchk(); gen_update_pc(ctx, 0) must be emitted before the shadow-stack load so cpu_pc/pc_save stay consistent regardless of whether the check matches. This is exactly what the pending patch does.

    /label \\\~"kind::Bug"

Edited by A-Shehab