This section of challenges introduces a new vulnerability stemming from the format string: the format write. Before solving this challenge, let's discuss how to use this vulnerability.
The Arbitrary Write is an additional modifier to the printf
format string list: %n
. %n
is a unique format specifier that, rather than writing data from a parameter to standard output, it writes data to the parameter. %n
writes the number of bytes written thus far to the parameter of choice. Consider the following code:
#include <stdio.h> int main() { int bytes; printf("Hello World!%n", &bytes); return 0; }
This still prints Hello World!
, but also moves the number of bytes written (12
) to bytes
.
We can use this format specifier to change the value of an address in memory, so long as the address of that location is present on the stack. This is the arbitrary write vulnerability, and a very powerful one!
The challenge indicates we must overwrite a variable currently valued at 0xdeadbeef
:
We notice there is a clear format string vulnerability:
0x000000000040168b <+226>: lea rax,[rbp-0x20] 0x000000000040168f <+230>: mov esi,0x48 0x0000000000401694 <+235>: mov rdi,rax 0x0000000000401697 <+238>: call 0x4010d0 <fgets@plt> 0x000000000040169c <+243>: lea rax,[rbp-0x20] 0x00000000004016a0 <+247>: mov rdi,rax 0x00000000004016a3 <+250>: mov eax,0x0 0x00000000004016a8 <+255>: call 0x4010c0 <printf@plt>
Our goal is to take advantage of the format write. In this case, we only have 0x48
bytes of input. This challenge only tasks us with changing the input; we can easily clear it to 0
only using a few bytes.
The order of arbitrary writes is the following:
The most difficult task with format writes is determining what parameter to use. We'll need to remember what number we might use as we leave a placeholder for that value. If you're confused, bear with me here.
First, we must write the number of bytes. In this case, we can simply write 0
bytes as an easy way to change the value. No padding needed.
Then, we need to write the format specifier. This will be in the form %x$[size]n
, where x
is the argument to use. This takes four bytes. We must pad this to the size of an address block, so we need four bytes of padding after this. This means the start of our payload is one address block long. This is essential for later.
lln
: Writing a long long
(useful for clearing out a block)ln
: Writing a long
hn
: Writing a short
hhn
: Writing a byte
(most used specifier, used to change one byte at a time)Finally, we need where to write. This address is leaked for us, so we'll need to parse for this address.
Now that we have the entire payload, we can retroactively compute the argument numbers. Looking at gdb
, we're writing to rbp-0x20
. When the stack frame is initialized, it was initialized to size 0x20
. Therefore, we start writing at rsp
. Using our knowledge of computing the argument offset to our buffer (or any desired value), we can easily recognize the buffer starts writing at argument 6
. Since the front half of the payload is one address block long, the address of where we're writing (the back half of the payload) must be at argument 7
. We'll use lln
to ensure the entire block gets cleared. Although n
will almost always do this naturally, it's good to enforce 8 bytes be written and not leave it to the Runtime.
Therefore, this makes the payload:
%7$llnAA\x88\xfa\x70\x6e\xff\x7f\x00\x00
To write an exploit, we need the leak first.
print(p.recvuntil(b'variable @ ').decode()) leak = int(p.recvline(), 16)
Then, we write the arbitrary-write payload. We make use of ljust
to make this payload a bit cleaner. Rather than including the padding, we can simply use this to pad to our desired size.
payload = b'%7$lln'.ljust(8, b'\x00') payload += p64(leak)
With this, it gets sent to the binary!
p64(leak)
pads to the size of an address using 0x00
, a null-byte for strings. This null byte would trigger printf
to stop printing, and thus the format specifier doesn't get resolved. This nullifies the exploit.