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).
A look at the target app, its vulns and its custom stack cookie protection
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:
- the function is recurrent (line
29
) when user input does not match any of the hardcoded conditions - it's vulnerable to a stack-based buffer overflow via
scanf("%s",buf_secure)
- line 16 - 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 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
:
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
:
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 returnfalse
soexit(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 B
s 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:
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:
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
:
The final code can be found here:
https://github.com/ewilded/MBE-snippets/blob/master/LAB8/LAB8A/exploit_working.py