Improving control-flow integrity for Linux on RISC-V
Redirecting execution flow is a common malware technique that can be used to compromise operating systems. To protect from such attacks, the chip makers of leading architectures like x86 and arm64 have implemented control-flow-integrity (CFI) extensions, though they need system software support to function. At the Linux Security Summit North America, RISC-V kernel developer Deepak Gupta described the CFI protections for that architecture and invited community input on the kernel support for them.
RISC-V is an instruction set architecture (ISA) that follows the philosophy of open-source hardware. Its attractiveness lies in its lack of ISA licensing fees and that it is extensible, allowing designers to integrate custom instructions and features tailored to their specific needs. Notably, startups have chosen to adopt RISC-V and the European Union has made substantial investment toward developing its own chips based on the architecture.
Gupta's talk was entitled "Control Flow Integrity on RISC-V Linux"; it is available as a YouTube video for those interested. He began by explaining that CFI techniques address vulnerabilities originating from memory-safety issues in C and C++ code bases; these vulnerabilities can be exploited using the usual suspects (memory attacks, such as stack buffer overflow) to divert the proper execution flow and redirect it somewhere else within the address space.
An important thing to remember regarding control flow is the distinction between forward and backward directions of the function-call process. Forward control-flow integrity protects the flow into functions (the green arrow in the diagram). Indirect function calls, such as by calling a function via a pointer, present opportunities for attackers to alter the pointer value to jump to an address of their choosing, thus they need CFI protection. Backward control-flow integrity is concerned with returns from functions (the red arrow in the diagram); the return addresses stored on the stack or in a register can also be altered by attackers. Gupta discussed the protection options for both directions for the RISC-V architecture.
Forward control flow
Previous efforts to protect forward control flow mainly involved keeping track of indirect calls and jumps using the CPU and/or system-software extensions. A few years ago, a popular approach involved validating indirect function pointers in some way to ensure that the flow remained as expected. This was done by checking the function prototype, including the type of the return value and all of the argument types. If the forward flow was hijacked, the only place an attacker could jump to was the start of a function in the kernel that matched the required prototype.
However, this approach had limited success and coarse granularity, since it left many kernel functions that met the requirements as valid options. A more direct approach is modifying the functions directly. In the case of the x86 architecture, this is done through indirect branch tracking (IBT), which introduced new instructions, endbr64 and endbr32, where indirect branches must land. With IBT, the CPU implements a state machine to track indirect jump and call instructions, marking valid target addresses. If a violation is detected, it triggers a Control Protection exception (#CP).
Gupta introduced the Zicfilp CPU extensions, designed to protect RISC-V systems against forward-control-flow threats. He had sent patches to support this feature earlier in the year. Zicfilp makes sure that all indirect branches (shown in the diagram above) must land on the new instruction lpad, which essentially acts as a landing pad. lpad includes a label that must match a corresponding value stored into register t2 by the caller. Enforcing this matching label ensures control-flow integrity: if the CPU fails to find a match, it raises a new software-check exception, indicating a detected error. For example, consider the following snippet from Gupta's talk:
lui t2, 0x1 # Set up label value in t2 jalr a5 (...) foo_lpad_loc: lpad <label> # Landing pad with label 0x1
In this code, the lui instruction loads a specific value (in this case, 0x1) into register t2, setting up the label value required to validate the integrity of the flow. The subsequent jalr instruction jumps to the target location indirectly, meaning that it obtains the target address at run time from a register (a5). The execution flow then reaches the designated landing pad at function foo_lpad_loc, where the lpad instruction with the embedded label (0x1) resides and serves as a reference point to ensure proper flow. In this example, the combination of the jalr instruction's indirect jump and the lpad instruction's label validation mechanism ensures the integrity of the forward flow.
Backward control flow
In the case of backward control flow, the most popular attacks to CFI typically involve return-oriented programming (ROP). With ROP attacks, vulnerabilities in backward indirect control transfers are exploited. These transfers are vulnerable because the function return addresses are determined dynamically at run time, allowing execution to be redirected to sequences of existing code fragments.
In RISC-V, as in arm64, the function return address is stored in a register (in x1 based on the RISC-V calling convention), which provides protection to that address from stack-based attacks. However, subsequent calls require pushing the return addresses onto the stack. Such addresses need to be protected with something better than regular store operations, either via software with the compiler adding additional checks (for example, LLVM's CFI protection and SafeStack insert extra security checks into programs) or hardware-based solutions. For this, x86 Control-flow Enforcement Technology (CET) relies on a shadow stack and arm64, similarly, uses its guarded control stack (GCS).
As with the case of forward control flow, a #CP exception may be issued in scenarios where shadow stacks are enabled. The return addresses get pushed onto both the regular and the shadow stack on a function call. Then, when returning from the function call, both stacks are popped. If the addresses retrieved from the two stacks don't match, indicating potential corruption, a #CP exception is raised.
This is the same approach followed by Zicfiss, which is used on RISC-V to protect backward control flow, Gupta said. With Zicfiss, the ISA is extended to include a shadow stack and instructions to safely manipulate the stack (sspush for pushing and sspopchk for popping and checking). The sspush instruction executes a store operation similar to existing store instructions, with the distinction that the base is implicitly the new shadow-stack pointer (ssp). There is also a new instruction, ssrdp, to read the ssp; its width is determined by the word length of the architecture. The virtual address stored in ssp needs a shadow-stack attribute (for more information on this, refer to RISC-V's Shadow Stack Memory Protection documentation). The shadow stack can be located anywhere in the address space and can even be discontiguous (this reduces the memory footprint when multiple threads share an address space). To ensure security, the CPU is informed that a region is a shadow stack through a new page-table bit added by the ISA extension, which ensures that the memory is writable only with the new specialized instructions.
Additional extensions
RISC-V flow control extensions also provide the new instruction ssamoswap for atomically swapping a value on the stack, designed for asynchronous operations within the kernel — similar to the RISC-V amoswap atomic-swap instruction but tailored to the shadow stack. Gupta mentioned that it is needed for signal handling in Linux, where the execution flow can be hijacked, and the stack needs to be switched.
Concurrent operation of multiple threads on the same shadow stack can be problematic and a source of security concerns; for example, an adversarial thread can reuse return addresses placed by the primary thread or inject return addresses into the shadow stack. To switch securely when the shadow stack is enabled, certain tokens are used to verify stack switching at run time: ssamoswap makes this possible by atomically storing these tokens, thereby mitigating any concurrency issues during validation (and raising software check exceptions if needed). The ssamoswap instruction is the RISC-V counterpart to x86's rstorssp and saveprevssp, as well as Arm v9's gcsss1 and gcsss2, providing analogous functionality for asynchronous shadow-stack operations.
Gupta recently submitted patches to test Zicfiss with straightforward kselftests. In these tests, a child process attempts to write to the memory location of a shadow stack (which should result in the process being killed with a SIGSEGV). The parent process then checks if the memory contents have been changed.
The patches for improving RISC-V kernel defenses against
control-flow attacks are still in the works. It's exciting to see the
community getting behind the effort to make open-source hardware more
secure. Moreover, it's worth noting that the kernel work led by Gupta and
others shares similarities and builds upon previous methods for control-flow
integrity — a characteristic generally desirable in open-source
development.
Index entries for this article | |
---|---|
Security | Control-flow integrity |
Security | Linux kernel |
Security | RISC-V |
GuestArticles | Bilbao, Carlos |
Conference | Linux Security Summit North America/2024 |