⊕ 2019-03-17
RISC-V is a relatively new architecture (2010) that has been getting some press and attention for [various](https://riscv.org/risc-v-foundation/) [reasons](https://www.westerndigital.com/company/newsroom/press-releases/2017/2017-11-28-western-digital-to-accelerate-the-future-of-next-generation-computing-architectures-for-big-data-and-fast-data-environments), and has garnered some enterprise adopters such as [NVIDIA](https://riscv.org/wp-content/uploads/2017/05/Tue1345pm-NVIDIA-Sijstermans.pdf) and [Western Digital](https://github.com/westerndigitalcorporation/swerv_eh1). Recently, other organizations have begun creating [physical silicon](https://www.sifive.com/risc-v-core-ip) and [virtualization support](https://wiki.qemu.org/Documentation/Platforms/RISCV). It is no longer vaporware. It's openness also allows for a unique ability to just go to the projects [git repositories](https://github.com/riscv) and find documentation.A new instruction set architecture is always interesting, but provides a unique opportunity for me to attempt to learn about a topic without all the research being completely done for me.
It's easy to feel like you are standing on the shoulders of giants in the security field and not gain the knowledge yourself by struggling. Which is why I decided that I would go down the path of writing RISC-V payloads and shellcode by hand without using any other resources than those provided by RISC-V and some prior light assembly knowledge with the goal of documenting my learning process step by step.
The code and documentation for this project is mirrored on my git repos:
The README in the root of the project gives a rough breakdown of how I
plan on structuring these walk-throughs and if you aren't a reading along
with a blog post type I have embedded pretty detailed comments in the
assembly files themselves. Part 1 will only cover the very basics of the
ISA and get to the example asm1.s
.
During this project the following supporting or helpful documents were used:
When I first tried to play with RISC-V it required using the git versions of glibc, gcc, qemu, the Linux kernel, etc. I had to build my own images to boot into qemu and cross my fingers that things would work as expected, flaky would be the polite version.
Luckily, distributions have even caught on and Debian, Fedora, and somewhat Alpine have varying degrees of support. For these I ended up using the pre-built images from fedora using the information here, this has a lot more realistic tooling and work pretty flawlessly.
More complete instructions are located in the git repositories listed above.
Once you have a basic image booted and accessible, ensure that a few
core tools are available: gcc
, objdump
, strace
, and gdb
.
The very first steps I took were to read the RISC-V ISA specification from front-to-back. This helped clarify a couple of questions I had and also lead me to a couple of questions that could only be answered by reading the actual implementation code.
Distilled down RISC-V contains 3 officially supported word-widths (32, 64, and 128), 32 registers, and 7 official instruction extensions.
The registers are all referred to as x0-x31
, but have a register
convention
defined in the following table:
Name | Mnemonic | Use | Preserved |
---|---|---|---|
x0 | zero | Zero | Immutable |
x1 | ra | Return address | No |
x2 | sp | Stack pointer | Yes |
x3 | gp | Global pointer | N/A |
x4 | tp | Thread pointer | N/A |
x5-x7 | t0-t2 | Temporary registers | No |
x8-x9 | s0-s1 | Callee saved registers | Yes |
x10-x17 | a0-a7 | Argument registers | No |
x18-x27 | s2-s11 | Callee saved registers | Yes |
x23-x31 | t3-t6 | Temporary registers | No |
In RISC-V each instruction is designated one of 6 "types" which are the
format in which the arguments of the instruction are structured when
they are read by the CPU. The core set of these is referred to as
RV{32,64,128}I
(for shortness sake this post will use the RV64
signature from now on) where the number is the word length, we will
refer to this as the core I
instructions. The best way to see this is
to visualize it:
Looking at the above table we can see the types and the bit formatting.
I found this a bit confusing at first so to break it down let's look at
ADD
which is type R
.
ADD
in assembly is structured like ADD rd,rs1,rs2
or in mathematical
format rd = rs1 + rs2
. Knowing this we know that ADD
puts the hex
representation of the two source registers and the destination, those
are relatively clear. But what are funct7
and funct3
? When the
opcode
is set funct3
and funct7
select additional operation
arguments, so in this case ADD
shares an opcode with 9 other
instructions. funct3
selects the opcodes 3-bit "sub-function" and
funct7
further modifies functionality. Using a terrible one liner that
we will talk about later we can view what ADD a0,a7,a6
looks like:
1# ./util/trashfmt.py $(./util/trashdis.sh "ADD a0,a7,a6")
200000001 00001000 10000101 00110011
I really like the way the ISA manual shows this Chapter 25, with the
individual opcode
s and functN
s filled out.
In addition to the RV32I
instructions there current 8 other extensions
officially supported by the RISC-V ISA that will depend on the hardware.
A listing of what some of those extensions are can be seen below:
NOTE: * denotes non-G
extensions
Luckily the RISC-V team has decided to organize a sub-section of
these into what they consider to be a "general-purpose" ISA, which will
contain RV64IMAFD + Zicsr/Zefencei
. As much as that has a nice ring to
it, that can be abbreviated to RV64G
. In my experience with emulators
and the few pieces of hardware out there, most are currently RV64GCU
to include the compressed extension. Because of that I'm going to
also have a few examples with "compressed" enabled as it can drastically
help show how to shrink our payloads and in some cases remove the need
for executable stack in some situations.
The VM I am using from Fedora has the following CPU configuration from
/proc/cpuinfo
:
1processor : 0
2hart : 3
3isa : rv64imafdcu
4mmu : sv48
5... snip ...
The u
is additionally not a real extension, but is an indicator that
the cpu is running in unprivileged mode.
Now that we understand the instructions and their extensions we can move on to understand how current operating systems are interacting with RISC-V. For the sake of purely adoption numbers and nothing else I will be focusing on the glibc and Linux implementations of RISC-V.
The first steps to understand the interaction of libc and Linux work with RISC-V is to dive straight to the system dependent source and documentation:
Most of these definitions are for setting up the logic for system calls and any other specific ISA requirements for Linux. The main part we are going to focus on for now is the system calls using the calling convention in the first table in the post.
When dealing with embedded payloads and shellcode generically it's always important to keep a in mind that you cannot always make guarantees about the system you are running on and one of the more consistent ways to ensure that your payloads are going to work on Linux is to rely on kernel builtin system calls.
The glibc code gleans a lot about how Linux, glibc, and RISC-V interact,
and the system specific documentation on the syscall(2)
man page
essentially confirms and clarifies the glibc code.
The important bits for now are that RISC-V expects system calls to use
argument a0-a6
and then set the syscall value in a7
.
Documentation for the syscall(2)
can be found in
source
or on the local VM in /usr/include/asm-generic/unistd.h
. These values
are the integers that we are loading into a7
to invoke the system
calls. Once the syscall is loaded we do something similar to syscall
/int 80
in x86_64
, use the environment call ECALL
instruction.
Once the system call is handled any return values are loaded into a0
,
which we will use to help shrink some of our payloads later on.
Many syscall's are wrapped by libc's and you should make sure to use the kernel versions of the calls and not what is expected from the libc.
In the instruction land it is important to note one additional thing, the assembler understands a set of pseudoinstructions. The pseudoinstructions are linker understood instructions that are aliases for common functions, the table of these can be seen in Table 26.2 with a translation of what the instructions are translated to.
This tripped me up for an embarrassingly long time as certain
instructions may generate their translated into different instructions
in different contexts. The best example is the li
pseudo-instruction
that may generate different code depending on data is being loaded. The
confusing-ness is even more confusing based on the ISA specification
specifically stating that "This chapter is a placeholder for an assembly
programmer’s manual" which is not particularly helpful.
We will go into more depth once we actually need to make the instructions predictable for our payloads.
Enough with the theory! Now that we have all the pieces that we need to really understand how to write assembly in RISC-V, lets put that into practice.
Our first example will simply exit the program with a value of 7.
To set this up we need to load our exit value (7) into the first
argument for the system call. As stated before many syscall's are
wrapped by libc and you should make sure to use the kernel versions of
the syscalls from unistd.h
. In my case this was the value of
__NR_exit
, which was defined as 93
.
The first part of this clears the a0
register using RV64G standard
instructions. The second uses the li
pseudoinstruction to load the
systemcall number. These effectively do the same thing, but demonstrate
the usage of pseudo-instructions when we decompile it:
1.section .text
2.globl _start
3_start:
4 xor a0,a0,a0 # Zero out the first argument (a0)
5 addi a0,a0,0x7 # Add 7 to a0. addi dest,src,immediate
6 li a7, 93 # 93 is the __NR_exit syscall
7 ecall # trigger system call
That's it. Generate this by running make asm
or compiling and linking
it yourself with gcc
. I compiled these with the explicit
-march=rv64g
since the VM is running RV64GCU
, as explained earlier.
We can verify that the compiler did what we wanted (or what it did with
the li
pseudo-instruction) by doing an objdump -D bin/asm1
.
1$ objdump -D ./bin/asm1
2
3bin/asm1: file format elf64-littleriscv
4
5
6Disassembly of section .text:
7
80000000000010078 <_start>:
9 10078: 00a54533 xor a0,a0,a0
10 1007c: 00750513 addi a0,a0,7
11 10080: 05d00893 li a7,93
12 10084: 00000073 ecall
A simple run and check for the return value confirms this, and an
strace
shows it even better:
1$ ./bin/asm1
2$ printf "%i\\n" "$?"
37
4$ strace ./bin/asm1
5execve("./bin.asm1", ["./bin/asm1"], 0x3fffddd4a0 /* 33 vars*/) = 0
6exit(7) = ?
7+++ exited with 7 +++
Now we have our very first example done! This is extremely rudimentary but helped me understand some of the terminology that I was struggling to infer.
Part 2 will contain more complex examples, exploring endianness, the
stack, a look at the li
pseudoinstruction, debugging, and some RISC-V
immediate usage.