Stack-canary (ROP), format string leak plus how I learned that nullbyte is not a badchar to scanf("%s",buf) - while socat ignores read on STDIN - MBE LAB8A

This time we are having some fun with a standard null-armored stack canary, as well as  an additional custom one (we will extensively cover both scenarios, as there's plenty of subject matter here), plus some peculiarities regarding scanf() and read().

The relevant MBE lecture can be found here http://security.cs.rpi.edu/courses/binexp-spring2015/lectures/19/11_lecture.pdf (the last section covers stack canary implementations and possible bypasses, as well as resources on deeper research).

As usual, the target app can be found here - https://github.com/RPISEC/MBE/blob/master/src/lab08/lab8A.c.

Here are the compilation flags; static and no PIE - although the latter does not matter much in this case - we will leak the code segment base anyway:

Let's start with the main function:

We have two always functions called from the main function one after another, regardless to any user input; selectABook() and findSomeWords().

selectABook() looks like this:

Apart from its (and the entire app's, for that matter) general weirdness, we can see that:

  1. the function is recurrent (line 29) when user input does not match any of the hardcoded conditions
  2. it's vulnerable to a stack-based buffer overflow via scanf("%s",buf_secure) - line 16
  3. it's also vulnerable to format string (line 17)

readA(), readB() and readC() are just simple methods printing out static hardcoded strings (Aristote's Metaphysics quotes), nothing useful in the context of exploitation (unless we had printf() GOT overwritten, but that is not what's going to happen here):

So at this point it already looked like I had what was needed to pwn the app; two bugs to chain together:

  • an overflow to overwrite the saved RET on the stack
  • a format string to leak the value of stack canary (and stack and code base if neessary) - so we can overwrite the stack canary with its own original value and therefore avoid the stack guard noticing we smashed the stack and therefore avoid the stack guard preventing the program from returning to our arbitrary EIP

Leaking the standard canary with format string

Let's start with identifying how the actual built-in code for handling stack canaries looks like in gcc-produced assembly:

the beginning of the main() function
the bottom of the main() function

The same holds true for all other functions.

Now, let's see what the stack values look like between runs and how exactly stuff is aligned on the stack. As we want to leak from selectABook()'s stack - because this is where the format string resides - let's put our breakpoints there:

Let's stop at selectABook+15 - our current canary will be held in EAX.

Then at selectABook+42 - after the scanf() call - we'll fill the buf[512] with exactly 512 bytes so we don't overflow anything yet and see the original values on the stack.

So we run:

breakpoint 1 - canary value is held in EAX

OK, now let's continue. Now (we have already been prompted above - Enter Your Favorite Author's Last Name:), we just paste 512 characters:

OK, we're past the scanf() call. Let's see the stack now:

... snip ...

The format string we are exploiting is simple printf(buf_secure). buf_secure[512] is 512 bytes-long. If we apply abuser friendly format string %p (so the whole dword of choice is printed, as hex) - just like we did here https://hackingiscool.pl/heap-overflow-with-stack-pivoting-format-string-leaking-first-stage-rop-ing-to-shellcode-after-making-it-executable-on-the-heap-on-a-statically-linked-binary-mbe-lab7a/) - considering that 512/4 = 128, we would expect our canary at %129$p.

Nah, something's wrong. Maybe it's because string formats index the`$`-referred arguments starting at 1... Let's see what's under %1$p:

Nah, it's the buf_secure address itself.

How about 130?

Yeah more like it.

The value is consistent between function calls (selectABook() as well as selectABook()->selectABook() recurrent call - remember, the stack canary value is global to the entire process) and it changes between runs.

Also, in this case the saved EBP should be right next to it, at 131:

Consecutive values of saved EBP across recurrent selectABook() calls

Yup. The consecutive values are decreasing by a fixed offset, as recurrent calls of selectABook() continue.

We will need this value as well while developing the exploit for this.

As a matter of fact at this point I even wrote the first version of the exploit (https://github.com/ewilded/MBE-snippets/blob/master/LAB8/LAB8A/wannabe_initial_exploit.py).

As usual - the exploit failed at the first attempt...

And I was too lazy to actually debug it.

Instead, once I noticed that the saved RET was not overwritten in result of overflowing the buffer, I mistakenly assumed (self-limiting assumptions!) that the nullbyte-armoured stack canary (you probably already noticed that all the canaries so far had nullbyte as their least-significant byte) was the reason I could not - via scanf("%s",buf_secure) - write beyond the nullbyte. I just thought scanf() would stop reading after encountering 0x0 on its input, explicitly because of the %s format string. I was wrong, but this assumption was reinforced by the fact that oftentimes while figuring out solutions to MBE targets I felt like it was all fine and dandy... only to later realize some tiny little obstacle. A tiny little obstacle forcing me to double the overall effort to attain a working exploit. Thus I assumed selectABook() exploitability was too good (too easy) to be true.

To follow the selectABook() exploitation route, skip to Building the ROP chain and then to Successfully exploiting selectABook() locally and remotely sections.

Otherwise, read on to explore the remainder of the target app and my exploit dev process.

Analyzing the rest of the code

We have only read half of the source code yet (as mentioned, this is an extensive write up)!

So, to feed our curiosity, instead of getting ahead of ourselves, let's see what's going on in the second function - findSomeWords():

The stack-based buffer overflow of the 24-byte buf[24] buffer with read(STDIN, buf,2048) at line 75 is quite blatant.

The rest of the code is just super-weird. First, the unused char lolz[4], then the entire custom cookie mechanism.

Bypassing the custom canary check

So let's try to figure out what's the deal with it.

global_addr and global_addr_check are global pointers held in the data segment, declared at the top of the source code, right below the compilation flags comment:

Although their initialization expressions are quite simple, I found them far away from obvious:

So apparently global_addr is a pointer to the next value after the buf (I initially thought it's just the address of the buf buffer incremented by 1, but I was wrong).

Then global_addr_check is the global_addr (whatever it is) decremented by 2.

And then finally there's this check:

The implication is as follows: if we want to exploit the stack-based buffer overflow in findSomeWords(), we need the function to properly return, without the exit(EXIT_FAILURE) nor the standard stack guard interrupting.

So in order to make it return, we need to both:

  • overwrite the original stack canary stored on the stack with its own value that we leak earlier via format string in selectABook() (there is just one stack canary value for the entire program, initiated before main() is executed, used by the stack guard for all following function calls)
  • make the ((( globaladdr))^((globaladdrcheck))) != ((( globaladdr))^(0xdeadbeef)) condition return false so exit(EXITFAILURE) is not called

Let's simplify the custom-cookie condition.

We want this:

to evaluate false.

Which means we want this to be true:

Which means global_addr_check must equal 0xdeadbeef.

OK fair enough, does this mean that the custom cookie protection by default makes the program exit with EXIT_FAILURE error code and Whoah there! message?

Yes, it does - simply running the app and providing "A" and "HELLO" inputs, respectively, results in this:

Fair enough. Let's bypass this custom canary, forgetting about the format string and overflows for now.

Let's make this app print out Whew you made it! instead of doing exit(EXIT_FAILURE) in findSomeWords():

As my poor understanding of C kept me unsure about the mechanism, I got to the bottom of this by running gdb, disassebmling the findSomeWords()function, setting up a breakpoint after the read() call and stepping through it, instruction after instruction.

OK, breapoints:

Debugging step by step.

1)  findSomeWords+80:

At this point EAX is 0xbffff700 --> 0xc43c9300  - the address of the canary on the local function's stack.

2) findSomeWords+87:

At this point EAX is still 0xbffff700 --> 0xc43c9300, EDX is 0xc43c9300 (canary from the stack). So now we have proof that the global_addr = (&buf+0x1); instruction makes the global_addr pointer point at the canary on the stack.

And now we are about to find out what's under ds:0x80edf24 (the value just gets copied to EAX).

3) findSomeWords+92:

And now EAX is 0x080481a8... weird. Let's peek the stack and see what's what:

OK, so global_addr points at the canary on the stack, while global_addr_check points at the value two dwords (-0x8) earlier. But hang on, where did this 0x080481a8 value come from?

The reason is that we did not fill the entire buf[24] buffer (I only sent 11 Bs at that time). Here's how the buf[24] overlaps with global_addr_check:

This means that:

global_addr points at the stack-stored copy of the canary

global_addr_check points at the before-last byte of the buf[24]. So the (&buf+0x1); instruction considered the buf size, making it point at the next dword on the stack (the canary), while global_addr_check = global_addr-0x2; made global_addr_check points two dwords earlier, at the four bytes at buf[15-19].

In recap: the stack-stored canary XOR-ed with 0xdeadbeef must equal stack-stored canary XOR-ed with the before-last dword of the buff. Which simply means we just want the before-last dword of buff[24] (again, bytes 15-19) to be 0xdeadbeef.

So as long as the value we provide to the read(STDIN,buf,2048) call in findSomeWords() contains 0xdeadbeef at its fifth dword (bytes 15-19), we should bypass the custom stack protection:

https://github.com/ewilded/MBE-snippets/blob/master/LAB8/LAB8A/custom_canary_bypass.py

Yup, that's exactly it:

OK cool, now we should be able to easily exploit the overflow in findSomeWords().

Building the ROP chain

Since we don't have libc dynamically linked in here, we can't do system().

Fine, we just want to call execve syscall the usual way:

eax = 0xb

ebx = pointer to "/bin/sh" - or, for that matter, "/bin/python" or anything other than "/bin/bash" (because bash is evil and drops the euid if called from a suid binary - fucking safety features)

ecx = edx = 0

int 0x80

Let's start ROPeme ropshell.py, generate the gadgets from the target binary and search through them.

Spoiler alert: at the late stage of the exploit development process I realized that - when targeting the scanf("%s") overflow - characters 0xa (newline) and 0xd (carriage-return) have to be avoided - as opposed to 0x0 (yes, really).

Thus, some of the gadgets I initially used had to be replaced due to the fact their addresses contained either 0xa or 0xd.

Running ROPeme, generating the gadgets:

Loading the gadgets:

Searching the gadgets (let's start with xor anything anything):

OK, all the last three look good for starters, we can initiate EAX with 0.

By the way, please keep in mind I started building this one with the assumption I could not use nullbytes in the payload, so instead of just putting a pop eax address followed by a nullbyte, I kept assembling these workarounds - but it was fun and finally worked.

So - as there was no xor edx edx (effectively EDX=0) gadget, I followed one of the tips found here (https://trustfoundry.net/basic-rop-techniques-and-tricks/) to use xchg instead (as we have already put 0 to EAX):

Just keep in mind now EAX hold whatever garbage was in EDX, so we'll have to zero it again, with one of the xor eax eax gadgets.

Oh fuck, we can't use them. They all contain 0xa.

Fair enough.

Instead, we use the gadget putting 0xffffffff to EDX followed by inc edx to overflow it to 0:

Now, we want EAX to become 0xb. It's 0 at the moment.

So why not to call inc eax twelve times.

My meticulous effort to keep the chain clean from nullbytes finally collapsed when I had to nullify ecx. Instead of pop ecx followed by a nullbyte I did this:

Which looks nicer but still does not change the fact that p32(0x1) = 0x00000001 - contains three nullbytes.

Then, EBX = address of "/bin/sh" (we will smuggle /bin/sh string to the stack in user input, then just calculate its address based on the leaked EBP value):

OK, one last thing, the int 0x80 call.

But wait, it has a nullbyte (I did not want nullbytes!).

OK, so what's the instruction right above it?

It's a NOP. Wonderful. So we can as well use 0x806f8ff.

Successfully exploiting findSomeWords() locally - read(STDIN,buf,2048) not catching up via socat

Having all the bits and pieces I assembled an exploit targeting the findSomeWords() overflow, with the following algorithm:

1) leak the canary and the saved RET via format string

2) make the selectABook() function return by providing one of the expected values ("A") to its input

3) overflow the buf[24] buffer via read(STDIN, buf, 2048), using the leaked canary as well as the 0xdeadbeef constant properly aligned in the payload, followed by four bytes of garbage to fill the saved EBP and the ROP chain beginning where saved RET was:

https://github.com/ewilded/MBE-snippets/blob/master/LAB8/LAB8A/exploit_works_only_locally.py

And it worked just fine on the target binary /levels/lab08/lab8A, getting me a shell... The problem was that my privileges were still lab8A instead of expected lab8end... So I listed the /levels/lab08 directory only to find out that this one is NOT a suid binary.

Instead I found this:

This means the target is being run from root like this:

socat TCP-LISTEN:8841,reuseaddr,fork,su=lab8end EXEC:timeout 60 /levels/lab08/lab8A

"Well that's just as well" - I thought. And just changed the p = process(binary.path,stdin=PTY) line to p = remote("127.0.0.1", 8841) and ran the thing.

It did not work.

Debugging (this time attaching to the target PID from root, as there was no other way) revealed that the exactly same exploit code did not deliver a single byte to the buf[24] buffer.

So I thought "how come, ffs... Does it mean it completely ignores the user input?".

So I ran it manually to see that was the case:

Interacting with the socat-run target app via nc

So yes, I could only interact with the selectABook() function. Simply typed "A" and pressed enter, having no further opportunity to interact with the application.

At the moment I still do not know why - please let me know if you have a clue, I am curious.

Successfully exploiting selectABook() locally and remotely

At this point, as usual when I felt despair - I peeked into Corb3nik's solutions (https://github.com/Corb3nik/MBE-Solutions/blob/master/lab8a/solution.py) - not only to see that his exploit did not deal with findSomeWords() and its custom stack canary at all - but mostly to realize he exploited selectABook() (which meant scanf("%s") ... with nullbytes in the payload!

So I fell back on the first exploit I wrote, started debugging it again. I found out the reason it was failing was due 0xa and 0xd characters in the initial ROP chain. These turned out to be the real bad characters when it comes to scanf()! Again, as opposed to nullbyte.

Then I found out that the string I was trying to make EBX point to (/bin/python) - as I found that string on the stack in the early stage of the exploit development and thought it would be nice to use it instead of delivering /bin/sh via user input) - was not there when targeting the actual app running under socat... It must have been a side effect of spawning the process from the python script with pwntools while developing the exploit.

Then it turned out my lengthy ROP chain (overflowing the local buf_secure of the  selectABook()->selectABook() call ) overwrote the /bin/sh value I delivered to the stack right after the initial format-string payload (the first call of selectABook()).

So I ended up adding additional 200 characters (H) between the format string and /bin/sh and increasing the value subtracted from the leaked EBP in the binsh_addr =EBP_value-338 expression accordingly.

1) Attacking the first selectABook() call to leak the canary and the saved EBP via format string while also stuffing /bin/sh on the stack - with 200 H-s between as this buffer will get overwritten by the ROP chain when we overflow the buffer in the second (recurrent) call selectABook()->selectABook():

2) Attacking the second call selectABook()->selectABook() by overflowing the buf_secure[512] with 512 B-s followed by the original leaked canary value, the original saved EBP value (although this value does not matter here as long as it is not a bad char) and the 0xa-free and 0xd-free ROP chain replacing the saved RET:

3) Making the third selectABook()->selectABook()->selectABook() call return (instead of continuing the recurrence) by providing one of the expected values - A:

Getting the flag

The final code can be found here:

https://github.com/ewilded/MBE-snippets/blob/master/LAB8/LAB8A/exploit_working.py

No one really cares about cookies and neither do I