Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Running your own code
In real exploits, it's not particularly likely that you will have a win()
function lying around - shellcode is a way to run your own instructions, giving you the ability to run arbitrary commands on the system.
Shellcode is essentially assembly instructions, except we input them into the binary; once we input it, we overwrite the return pointer to hijack code execution and point at our own instructions!
I promise you can trust me but you should never ever run shellcode without knowing what it does. Pwntools is safe and has almost all the shellcode you will ever need.
The reason shellcode is successful is that Von Neumann architecture (the architecture used in most computers today) does not differentiate between data and instructions - it doesn't matter where or what you tell it to run, it will attempt to run it. Therefore, even though our input is data, the computer doesn't know that - and we can use that to our advantage.
ASLR is a security technique, and while it is not specifically designed to combat shellcode, it involves randomising certain aspects of memory (we will talk about it in much more detail later). This randomisation can make shellcode exploits like the one we're about to do more less reliable, so we'll be disabling it for now using this.
Again, you should never run commands if you don't know what they do
Let's debug vuln()
using radare2
and work out where in memory the buffer starts; this is where we want to point the return pointer to.
This value that gets printed out is a local variable - due to its size, it's fairly likely to be the buffer. Let's set a breakpoint just after gets()
and find the exact address.
It appears to be at 0xffffcfd4
; if we run the binary multiple times, it should remain where it is (if it doesn't, make sure ASLR is disabled!).
Now we need to calculate the padding until the return pointer. We'll use the De Bruijn sequence as explained in the previous blog post.
The padding is 312 bytes.
In order for the shellcode to be correct, we're going to set context.binary
to our binary; this grabs stuff like the arch, OS and bits and enables pwntools to provide us with working shellcode.
We can use just process()
because once context.binary
is set it is assumed to use that process
Now we can use pwntools' awesome shellcode functionality to make it incredibly simple.
Yup, that's it. Now let's send it off and use p.interactive()
, which enables us to communicate to the shell.
If you're getting an EOFError
, print out the shellcode and try to find it in memory - the stack address may be wrong
And it works! Awesome.
We injected shellcode, a series of assembly instructions, when prompted for input
We then hijacked code execution by overwriting the saved return pointer on the stack and modified it to point to our shellcode
Once the return pointer got popped into EIP, it pointed at our shellcode
This caused the program to execute our instructions, giving us (in this case) a shell for arbitrary command execution
The most basic binexp challenge
A ret2win is simply a binary where there is a win()
function (or equivalent); once you successfully redirect execution there, you complete the challenge.
To carry this out, we have to leverage what we learnt in the introduction, but in a predictable manner - we have to overwrite EIP, but to a specific value of our choice.
To do this, what do we need to know? Well, a couple things:
The padding until we begin to overwrite the return pointer (EIP)
What value we want to overwrite EIP to
When I say "overwrite EIP", I mean overwrite the saved return pointer that gets popped into EIP. The EIP register is not located on the stack, so it is not overwritten directly.
This can be found using simple trial and error; if we send a variable numbers of characters, we can use the Segmentation Fault
message, in combination with radare2, to tell when we overwrote EIP. There is a better way to do it than simple brute force (we'll cover this in the next post), but it'll do for now.
You may get a segmentation fault for reasons other than overwriting EIP; use a debugger to make sure the padding is correct.
We get an offset of 52 bytes.
Now we need to find the address of the flag()
function in the binary. This is simple.
afl
stands for Analyse Functions List
The flag()
function is at 0x080491c3
.
The final piece of the puzzle is to work out how we can send the address we want. If you think back to the introduction, the A
s that we sent became 0x41
- which is the ASCII code of A
. So the solution is simple - let's just find the characters with ascii codes 0x08
, 0x04
, 0x91
and 0xc3
.
This is a lot simpler than you might think, because we can specify them in python as hex:
And that makes it much easier.
Now we know the padding and the value, let's exploit the binary! We can use pwntools
to interface with the binary (check out the pwntools posts for a more in-depth look).
If you run this, there is one small problem: it won't work. Why? Let's check with a debugger. We'll put in a pause()
to give us time to attach radare2
onto the process.
Now let's run the script with python3 exploit.py
and then open up a new terminal window.
By providing the PID of the process, radare2 hooks onto it. Let's break at the return of unsafe()
and read the value of the return pointer.
0xc3910408
- look familiar? It's the address we were trying to send over, except the bytes have been reversed, and the reason for this reversal is endianness. Big-endian systems store the most significant byte (the byte with the largest value) at the smallest memory address, and this is how we sent them. Little-endian does the opposite (for a reason), and most binaries you will come across are little-endian. As far as we're concerned, the byte are stored in reverse order in little-endian executables.
radare2
comes with a nice tool called rabin2
for binary analysis:
So our binary is little-endian.
The fix is simple - reverse the address (you can also remove the pause()
)
If you run this now, it will work:
And wham, you've called the flag()
function! Congrats!
Unsurprisingly, you're not the first person to have thought "could they possibly make endianness simpler" - luckily, pwntools has a built-in p32()
function ready for use!
becomes
Much simpler, right?
The only caveat is that it returns bytes
rather than a string, so you have to make the padding a byte string:
Otherwise you will get a
The better way to calculate offsets
De Bruijn sequences of order n
is simply a sequence where no string of n
characters is repeated. This makes finding the offset until EIP much simpler - we can just pass in a De Bruijn sequence, get the value within EIP and find the one possible match within the sequence to calculate the offset. Let's do this on the ret2win binary.
Again, radare2
comes with a nice command-line tool (called ragg2
) that can generate it for us. Let's create a sequence of length 100
.
The -P
specifies the length while -r
tells it to show ascii bytes rather than hex pairs.
Now we have the pattern, let's just input it in radare2
when prompted for input, make it crash and then calculate how far along the sequence the EIP is. Simples.
The address it crashes on is 0x41534141
; we can use radare2
's in-built wopO
command to work out the offset.
Awesome - we get the correct value!
We can also be lazy and not copy the value.
The backticks means the dr eip
is calculated first, before the wopO
is run on the result of it.
The defence against shellcode
As you can expect, programmers were hardly pleased that people could inject their own instructions into the program. The NX bit, which stands for No eXecute, defines areas of memory as either instructions or data. This means that your input will be stored as data, and any attempt to run it as instructions will crash the program, effectively neutralising shellcode.
To get around NX, exploit developers have to leverage a technique called ROP, Return-Oriented Programming.
The Windows version of NX is DEP, which stands for Data Execution Prevention
You can either use pwntools' checksec
or rabin2
.
Welcome to my blog! There's a lot here and it's a bit spread out, so here's a guide:
If you're looking for the binary exploitation notes, you're in the right place! Here I make notes on most of the things I learn, and also provide vulnerable binaries to allow you to have a go yourself. Most "common" stack techniques are mentioned along with some super introductory heap; more will come soonâ„¢.
If you're looking for my maths notes, they are split up (with some overlap):
Cryptography-specific maths can be found on GitBook , or by clicking the hyperlink in the header
All my other maths notes can be found on Notion . I realise having it in multiple locations is annoying, but maths support in Notion is just wayyy better. Like so much better. Sorry.
Hopefully these two get moulded into one soon
If you'd like to find me elsewhere, I'm usually down as ir0nstone. The accounts you'd actually be interested in seeing are likely or my (or X, if you really prefer).
If this resource has been helpful to you, please consider :)
And, of course, thanks to GitBook for all of their support :)
~ Andrej Ljubic
More reliable shellcode exploits
NOP (no operation) instructions do exactly what they sound like: nothing. Which makes then very useful for shellcode exploits, because all they will do is run the next instruction. If we pad our exploits on the left with NOPs and point EIP at the middle of them, it'll simply keep doing no instructions until it reaches our actual shellcode. This allows us a greater margin of error as a shift of a few bytes forward or backwards won't really affect it, it'll just run a different number of NOP instructions - which have the same end result of running the shellcode. This padding with NOPs is often called a NOP slide or NOP sled, since the EIP is essentially sliding down them.
In intel x86 assembly, NOP instructions are \x90
.
The NOP instruction actually used to stand for XCHG EAX, EAX
, which does effectively nothing. You can read a bit more about it .
We can make slight changes to our exploit to do two things:
Add a large number of NOPs on the left
Adjust our return pointer to point at the middle of the NOPs rather than the buffer start
Make sure ASLR is still disabled. If you have to disable it again, you may have to readjust your previous exploit as the buffer location my be different.
It's probably worth mentioning that shellcode with NOPs is not failsafe; if you receive unexpected errors padding with NOPs but the shellcode worked before, try reducing the length of the nopsled as it may be tampering with other things on the stack
Note that NOPs are only \x90
in certain architectures, and if you need others you can use pwntools:
An introduction to binary exploitation
Binary Exploitation is about finding vulnerabilities in programs and utilising them to do what you wish. Sometimes this can result in an authentication bypass or the leaking of classified information, but occasionally (if you're lucky) it can also result in Remote Code Execution (RCE). The most basic forms of binary exploitation occur on the stack, a region of memory that stores temporary variables created by functions in code.
When a new function is called, a memory address in the calling function is pushed to the stack - this way, the program knows where to return to once the called function finishes execution. Let's look at a basic binary to show this.
The binary has two files - source.c
and vuln
; the latter is an ELF
file, which is the executable format for Linux (it is recommended to follow along with this with a Virtual Machine of your own, preferably Linux).
We're gonna use a tool called radare2
to analyse the behaviour of the binary when functions are called.
The -d
runs it while the -A
performs analysis. We can disassemble main
with
s main
seeks (moves) to main, while pdf
stands for Print Disassembly Function (literally just disassembles it).
The call to unsafe
is at 0x080491bb
, so let's break there.
db
stands for debug breakpoint, and just sets a breakpoint. A breakpoint is simply somewhere which, when reached, pauses the program for you to run other commands. Now we run dc
for debug continue; this just carries on running the file.
It should break before unsafe
is called; let's analyse the top of the stack now:
pxw
tells r2 to analyse the hex as words, that is, 32-bit values. I only show the first value here, which is 0xf7efe000
. This value is stored at the top of the stack, as ESP points to the top of the stack - in this case, that is 0xff984af0
.
Note that the value 0xf7efe000
is random - it's an artefact of previous processes that have used that part of the stack. The stack is never wiped, it's just marked as usable, so before data actually gets put there the value is completely dependent on your system.
Let's move one more instruction with ds
, debug step, and check the stack again. This will execute the call sym.unsafe
instruction.
Huh, something's been pushed onto the top of the stack - the value 0x080491c0
. This looks like it's in the binary - but where? Let's look back at the disassembly from before:
We can see that 0x080491c0
is the memory address of the instruction after the call to unsafe
. Why? This is how the program knows where to return to after unsafe()
has finished.
But as we're interested in binary exploitation, let's see how we can possibly break this. First, let's disassemble unsafe
and break on the ret
instruction; ret
is the equivalent of pop eip
, which will get the saved return pointer we just analysed on the stack into the eip
register. Then let's continue and spam a bunch of characters into the input and see how that could affect it.
Now let's read the value at the location the return pointer was at previously, which as we saw was 0xff984aec
.
Huh?
It's quite simple - we inputted more data than the program expected, which resulted in us overwriting more of the stack than the developer expected. The saved return pointer is also on the stack, meaning we managed to overwrite it. As a result, on the ret
, the value popped into eip
won't be in the previous function but rather 0x41414141
. Let's check with ds
.
And look at the new prompt - 0x41414141
. Let's run dr eip
to make sure that's the value in eip
:
Yup, it is! We've successfully hijacked the program execution! Let's see if it crashes when we let it run with dc
.
radare2
is very useful and prints out the address that causes it to crash. If you cause the program to crash outside of a debugger, it will usually say Segmentation Fault
, which could mean a variety of things, but usually that you have overwritten EIP.
Of course, you can prevent people from writing more characters than expected when making your program, usually using other C functions such as fgets()
; gets()
is intrinsically unsafe because it doesn't check the length of the input, meaning that the presence of gets()
is always something you should check out in a program. It is also possible to give fgets()
the wrong parameters, meaning it still takes in too many characters.
When a function calls another function, it
pushes a return pointer to the stack so the called function knows where to return
when the called function finishes execution, it pops it off the stack again
Because this value is saved on the stack, just like our local variables, if we write more characters than the program expects, we can overwrite the value and redirect code execution to wherever we wish. Functions such as fgets()
can prevent such easy overflow, but you should check how much is actually being read.
The differences between the sizes
Everything we have done so far is applicable to 64-bit as well as 32-bit; the only thing you would need to change is switch out the p32()
for p64()
as the memory addresses are longer.
The real difference between the two, however, is the way you pass parameters to functions (which we'll be looking at much closer soon); in 32-bit, all parameters are pushed to the stack before the function is called. In 64-bit, however, the first 6 are stored in the registers RDI, RSI, RDX, RCX, R8 and R9 respectively as per the . Note that different Operating Systems also have different calling conventions.
Utilising Calling Conventions
The program expects the stack to be laid out like this before executing the function:
So why don't we provide it like that? As well as the function, we also pass the return address and the parameters.
Everything after the address of flag()
will be part of the stack frame for the next function as it is expected to be there - just instead of using push
instructions we just overwrote them manually.
Same logic, except we have to utilise the gadgets we talked about previously to fill the required registers (in this case rdi
and rsi
as we have two parameters).
We have to fill the registers before the function is called
A minor issue
A small issue you may get when pwning on 64-bit systems is that your exploit works perfectly locally but fails remotely - or even fails when you try to use the provided LIBC version rather than your local one. This arises due to something called stack alignment.
Essentially the x86-64 ABI (application binary interface) guarantees 16-byte alignment on a call
instruction. LIBC takes advantage of this and uses SSE data transfer instructions to optimise execution; system
in particular utilises instructions such as movaps
.
That means that if the stack is not 16-byte aligned - that is, RSP is not a multiple of 16 - the ROP chain will fail on system
.
The fix is simple - in your ROP chain, before the call to system
, place a singular ret
gadget:
This works because it will cause RSP to be popped an additional time, pushing it forward by 8 bytes and aligning it.
Bypassing NX
The basis of ROP is chaining together small chunks of code already present within the binary itself in such a way to do what you wish. This often involves passing parameters to functions already present within libc
, such as system
- if you can find the location of a command, such as cat flag.txt
, and then pass it as a parameter to system
, it will execute that command and return the output. A more dangerous command is /bin/sh
, which when run by system
gives the attacker a shell much like the shellcode we used did.
Doing this, however, is not as simple as it may seem at first. To be able to properly call functions, we first have to understand how to pass parameters to them.
Position Independent Code
PIE stands for Position Independent Executable, which means that every time you run the file it gets loaded into a different memory address. This means you cannot hardcode values such as function addresses and gadget locations without finding out where they are.
Luckily, this does not mean it's impossible to exploit. PIE executables are based around relative rather than absolute addresses, meaning that while the locations in memory are fairly random the offsets between different parts of the binary remain constant. For example, if you know that the function main
is located 0x128
bytes in memory after the base address of the binary, and you somehow find the location of main
, you can simply subtract 0x128
from this to get the base address and from the addresses of everything else.
So, all we need to do is find a single address and PIE is bypassed. Where could we leak this address from?
The stack of course!
We know that the return pointer is located on the stack - and much like a canary, we can use format string (or other ways) to read the value off the stack. The value will always be a static offset away from the binary base, enabling us to completely bypass PIE!
Due to the way PIE randomisation works, the base address of a PIE executable will always end in the hexadecimal characters 000
. This is because pages are the things being randomised in memory, which have a standard size of 0x1000
. Operating Systems keep track of page tables which point to each section of memory and define the permissions for each section, similar to segmentation.
Checking the base address ends in 000
should probably be the first thing you do if your exploit is not working as you expected.
The Buffer Overflow defence
Stack Canaries are very simple - at the beginning of the function, a random value is placed on the stack. Before the program executes ret
, the current value of that variable is compared to the initial: if they are the same, no buffer overflow has occurred.
If they are not, the attacker attempted to overflow to control the return pointer and the program crashes, often with a ***stack smashing detected***
error message.
On Linux, stack canaries end in 00
. This is so that they null-terminate any strings in case you make a mistake when using print functions, but it also makes them much easier to spot.
There are two ways to bypass a canary.
This is quite broad and will differ from binary to binary, but the main aim is to read the value. The simplest option is using format string if it is present - the canary, like other local variables, is on the stack, so if we can leak values off the stack it's easy.
The source is very simple - it gives you a format string vulnerability, then a buffer overflow vulnerability. The format string we can use to leak the canary value, then we can use that value to overwrite the canary with itself. This way, we can overflow past the canary but not trigger the check as its value remains constant. And of course, we just have to run win()
.
First let's check there is a canary:
Yup, there is. Now we need to calculate at what offset the canary is at, and to do this we'll use radare2.
The last value there is the canary. We can tell because it's roughly 64 bytes after the "buffer start", which should be close to the end of the buffer. Additionally, it ends in 00
and looks very random, unlike the libc and stack addresses that start with f7
and ff
. If we count the number of address it's around 24 until that value, so we go one before and one after as well to make sure.
It appears to be at %23$p
. Remember, stack canaries are randomised for each new process, so it won't be the same.
Now let's just automate grabbing the canary with pwntools:
Now all that's left is work out what the offset is until the canary, and then the offset from after the canary to the return pointer.
We see the canary is at 0xffea8afc
. A little later on the return pointer (we assume) is at 0xffea8b0c
. Let's break just after the next gets()
and check what value we overwrite it with (we'll use a De Bruijn pattern).
Now we can check the canary and EIP offsets:
Return pointer is 16 bytes after the canary start, so 12 bytes after the canary.
Same source, same approach, just 64-bit. Try it yourself before checking the solution.
Remember, in 64-bit format string goes to the relevant registers first and the addresses can fit 8 bytes each so the offset may be different.
This is possible on 32-bit, and sometimes unavoidable. It's not, however, feasible on 64-bit.
As you can expect, the general idea is to run the process loads and load of times with random canary values until you get a hit, which you can differentiate by the presence of a known plaintext, e.g. flag{
and this can take ages to run and is frankly not a particularly interesting challenge.
Reading memory off the stack
Format String is a dangerous bug that is easily exploitable. If manipulated correctly, you can leverage it to perform powerful actions such as reading from and writing to arbitrary memory locations.
In C, certain functions can take "format specifier" within strings. Let's look at an example:
This prints out:
So, it replaced %d
with the value, %f
with the float value and %x
with the hex representation.
This is a nice way in C of formatting strings (string concatenation is quite complicated in C). Let's try print out the same value in hex 3 times:
As expected, we get
What happens, however, if we don't have enough arguments for all the format specifiers?
Erm... what happened here?
The key here is that printf
expects as many parameters as format string specifiers, and in 32-bit it grabs these parameters from the stack. If there aren't enough parameters on the stack, it'll just grab the next values - essentially leaking values off the stack. And that's what makes it so dangerous.
Surely if it's a bug in the code, the attacker can't do much, right? Well the real issue is when C code takes user-provided input and prints it out using printf
.
If we run this normally, it works at expected:
But what happens if we input format string specifieres, such as %x
?
It reads values off the stack and returns them as the developer wasn't expecting so many format string specifiers.
To print the same value 3 times, using
Gets tedious - so, there is a better way in C.
The 1$
between tells printf to use the first parameter. However, this also means that attackers can read values an arbitrary offset from the top of the stack - say we know there is a canary at the 6th %p
- instead of sending %p %p %p %p %p %p
we can just do %6$p
. This allows us to be much more efficient.
In C, when you want to use a string you use a pointer to the start of the string - this is essentially a value that represents a memory address. So when you use the %s
format specifier, it's the pointer that gets passed to it. That means instead of reading a value of the stack, you read the value in the memory address it points at.
Now this is all very interesting - if you can find a value on the stack that happens to correspond to where you want to read, that is. But what if we could specify where we want to read? Well... we can.
Let's look back at the previous program and its output:
You may notice that the last two values contain the hex values of %x
. That's because we're reading the buffer. Here it's at the 4th offset - if we can write an address then point %s
at it, we can get an arbitrary write!
%p
is a pointer; generally, it returns the same as %x
just precedes it with a 0x
which makes it stand out more
As we can see, we're reading the value we inputted. Let's write a quick pwntools script that write the location of the ELF file and reads it with %s
- if all goes well, it should read the first bytes of the file, which is always \x7fELF
. Start with the basics:
Nice it works. The base address of the binary is 0x8048000
, so let's replace the 0x41424344
with that and read it with %s
:
It doesn't work.
The reason it doesn't work is that printf
stops at null bytes, and the very first character is a null byte. We have to put the format specifier first.
Let's break down the payload:
We add 4 |
because we want the address we write to fill one memory address, not half of one and half another, because that will result in reading the wrong address
The offset is %8$p
because the start of the buffer is generally at %6$p
. However, memory addresses are 4 bytes long each and we already have 8 bytes, so it's two memory addresses further along at %8$p
.
It still stops at the null byte, but that's not important because we get the output; the address is still written to memory, just not printed back.
Now let's replace the p
with an s
.
Of course, %s
will also stop at a null byte as strings in C are terminated with them. We have worked out, however, that the first bytes of an ELF file up to a null byte are \x7fELF\x01\x01\x01
.
Luckily C contains a rarely-used format specifier %n
. This specifier takes in a pointer (memory address) and writes there the number of characters written so far. If we can control the input, we can control how many characters are written an also where we write them.
Obviously, there is a small flaw - to write, say, 0x8048000
to a memory address, we would have to write that many characters - and generally buffers aren't quite that big. Luckily there are other format string specifiers for that. I fully recommend you watch this video to completely understand it, but let's jump into a basic binary.
Simple - we need to overwrite the variable auth
with the value 10. Format string vulnerability is obvious, but there's also no buffer overflow due to a secure fgets
.
As it's a global variable, it's within the binary itself. We can check the location using readelf
to check for symbols.
Location of auth
is 0x0804c028
.
We're lucky there's no null bytes, so there's no need to change the order.
Buffer is the 7th %p
.
And easy peasy:
As you can expect, pwntools has a handy feature for automating %n
format string exploits:
The offset
in this case is 7
because the 7th %p
read the buffer; the location is where you want to write it and the value is what. Note that you can add as many location-value pairs into the dictionary as you want.
You can also grab the location of the auth
symbol with pwntools:
Check out the pwntools tutorials for more cool features
The standard ROP exploit
A ret2libc is based off the system
function found within the C library. This function executes anything passed to it making it the best target. Another thing found within libc is the string /bin/sh
; if you pass this string to system
, it will pop a shell.
And that is the entire basis of it - passing /bin/sh
as a parameter to system
. Doesn't sound too bad, right?
To start with, we are going to disable ASLR. ASLR randomises the location of libc in memory, meaning we cannot (without other steps) work out the location of system
and /bin/sh
. To understand the general theory, we will start with it disabled.
Fortunately Linux has a command called ldd
for dynamic linking. If we run it on our compiled ELF file, it'll tell us the libraries it uses and their base addresses.
We need libc.so.6
, so the base address of libc is 0xf7dc2000
.
Libc base and the system and /bin/sh offsets may be different for you. This isn't a problem - it just means you have a different libc version. Make sure you use your values.
To call system, we obviously need its location in memory. We can use the readelf
command for this.
The -s
flag tells readelf
to search for symbols, for example functions. Here we can find the offset of system from libc base is 0x44f00
.
Since /bin/sh
is just a string, we can use strings
on the dynamic library we just found with ldd
. Note that when passing strings as parameters you need to pass a pointer to the string, not the hex representation of the string, because that's how C expects it.
-a
tells it to scan the entire file; -t x
tells it to output the offset in hex.
Repeat the process with the libc
linked to the 64-bit exploit (should be called something like /lib/x86_64-linux-gnu/libc.so.6
).
Note that instead of passing the parameter in after the return pointer, you will have to use a pop rdi; ret
gadget to put it into the RDI register.
Unsurprisingly, pwntools has a bunch of features that make this much simpler.
The 64-bit looks essentially the same.
Pwntools can simplify it even more with its ROP capabilities, but I won't showcase them here.
A more in-depth look into parameters for 32-bit and 64-bit programs
Let's have a quick look at the source:
Pretty simple.
If we run the 32-bit and 64-bit versions, we get the same output:
Just what we expected.
Let's open the binary up in radare2 and disassemble it.
If we look closely at the calls to sym.vuln
, we see a pattern:
We literally push
the parameter to the stack before calling the function. Let's break on sym.vuln
.
The first value there is the return pointer that we talked about before - the second, however, is the parameter. This makes sense because the return pointer gets pushed during the call
, so it should be at the top of the stack. Now let's disassemble sym.vuln
.
Here I'm showing the full output of the command because a lot of it is relevant. radare2
does a great job of detecting local variables - as you can see at the top, there is one called arg_8h
. Later this same one is compared to 0xdeadbeef
:
Clearly that's our parameter.
So now we know, when there's one parameter, it gets pushed to the stack so that the stack looks like:
Let's disassemble main
again here.
Hohoho, it's different. As we mentioned before, the parameter gets moved to rdi
(in the disassembly here it's edi
, but edi
is just the lower 32 bits of rdi
, and the parameter is only 32 bits long, so it says EDI
instead). If we break on sym.vuln
again we can check rdi
with the command
Just dr
will display all registers
Awesome.
Registers are used for parameters, but the return address is still pushed onto the stack and in ROP is placed right after the function address
We've seen the full disassembly of an almost identical binary, so I'll only isolate the important parts.
It's just as simple - push
them in reverse order of how they're passed in. The reverse order becomes helpful when you db sym.vuln
and print out the stack.
So it becomes quite clear how more parameters are placed on the stack:
So as well as rdi
, we also push to rdx
and rsi
(or, in this case, their lower 32 bits).
Just to show that it is in fact ultimately rdi
and not edi
that is used, I will alter the original one-parameter code to utilise a bigger number:
If you disassemble main
, you can see it disassembles to
movabs
can be used to encode the mov
instruction for 64-bit instructions - treat it as if it's a mov
.
Controlling execution with snippets of code
Gadgets are small snippets of code followed by a ret
instruction, e.g. pop rdi; ret
. We can manipulate the ret
of these gadgets in such a way as to string together a large chain of them to do what we want.
Let's for a minute pretend the stack looks like this during the execution of a pop rdi; ret
gadget.
What happens is fairly obvious - 0x10
gets popped into rdi
as it is at the top of the stack during the pop rdi
. Once the pop
occurs, rsp
moves:
And since ret
is equivalent to pop rip
, 0x5655576724
gets moved into rip
. Note how the stack is laid out for this.
When we overwrite the return pointer, we overwrite the value pointed at by rsp
. Once that value is popped, it points at the next value at the stack - but wait. We can overwrite the next value in the stack.
Let's say that we want to exploit a binary to jump to a pop rdi; ret
gadget, pop 0x100
into rdi
then jump to flag()
. Let's step-by-step the execution.
On the original ret
, which we overwrite the return pointer for, we pop the gadget address in. Now rip
moves to point to the gadget, and rsp
moves to the next memory address.
rsp
moves to the 0x100
; rip
to the pop rdi
. Now when we pop, 0x100
gets moved into rdi
.
RSP moves onto the next items on the stack, the address of flag()
. The ret
is executed and flag()
is called.
Essentially, if the gadget pops values from the stack, simply place those values afterwards (including the pop rip
in ret
). If we want to pop 0x10
into rdi
and then jump to 0x16
, our payload would look like this:
Note if you have multiple pop
instructions, you can just add more values.
We use rdi
as an example because, if you remember, that's the register for the first parameter in 64-bit. This means control of this register using this gadget is important.
We can use the tool ROPgadget
to find possible gadgets.
Combine it with grep
to look for specific registers.
Address Space Layout Randomisation
ASLR stands for Address Space Layout Randomisation and can, in most cases, be thought of as libc
's equivalent of PIE - every time you run a binary, libc
(and other libraries) get loaded into a different memory address.
While it's tempting to think of ASLR as libc
PIE, there is a key difference.
ASLR is a kernel protection while PIE is a binary protection. The main difference is that PIE can be compiled into the binary while the presence of ASLR is completely dependant on the environment running the binary. If I sent you a binary compiled with ASLR disabled while I did it, it wouldn't make any different at all if you had ASLR enabled.
Of course, as with PIE, this means you cannot hardcode values such as function address (e.g. system
for a ret2libc).
It's tempting to think that, as with PIE, we can simply format string for a libc address and subtract a static offset from it. Sadly, we can't quite do that.
When functions finish execution, they do not get removed from memory; instead, they just get ignored and overwritten. Chances are very high that you will grab one of these remnants with the format string. Different libc versions can act very differently during execution, so a value you just grabbed may not even exist remotely, and if it does the offset will most likely be different (different libcs have different sizes and therefore different offsets between functions). It's possible to get lucky, but you shouldn't really hope that the offsets remain the same.
Instead, a more reliable way is reading the .
For the same reason as PIE, libc base addresses always end in the hexadecimal characters 000
.
As shown in the , pwntools has a host of functionality that allows you to really make your exploit dynamic. Simply setting elf.address
will automatically update all the function and symbols addresses for you, meaning you don't have to worry about using readelf
or other command line tools, but instead can receive it all dynamically.
Not to mention that the are incredibly powerful as well.
Exploiting PIE with a given leak
Pretty simple - we print the address of main
, which we can read and calculate the base address from. Then, using this, we can calculate the address of win()
itself.
Let's just run the script to make sure it's the right one :D
Yup, and as we expected, it prints the location of main
.
First, let's set up the script. We create an ELF
object, which becomes very useful later on, and start the process.
Now we want to take in the main
function location. To do this we can simply receive up until it (and do nothing with that) and then read it.
Since we received the entire line except for the address, only the address will come up with p.recvline()
.
Now we'll use the ELF
object we created earlier and set its base address. The sym
dictionary returns the offsets of the functions from binary base until the base address is set, after which it returns the absolute address in memory.
In this case, elf.sym['main']
will return 0x11b9
; if we ran it again, it would return 0x11b9
+ the base address. So, essentially, we're subtracting the offset of main
from the address we leaked to get the base of the binary.
Now we know the base we can just call win()
.
By this point, I assume you know how to find the padding length and other stuff we've been mentioning for a while, so I won't be showing you every step of that.
And does it work?
Awesome!
From the leak address of main
, we were able to calculate the base address of the binary. From this we could then calculate the address of win
and call it.
And one thing I would like to point out is how simple this exploit is. Look - it's 10 lines of code, at least half of which is scaffolding and setup.
Try this for yourself first, then feel free to check the solution. Same source, same challenge.
Bypassing ASLR
The PLT and GOT are sections within an ELF file that deal with a large portion of the dynamic linking. Dynamically linked binaries are more common than statically linked binary in CTFs. The purpose of dynamic linking is that binaries do not have to carry all the code necessary to run within them - this reduces their size substantially. Instead, they rely on system libraries (especially libc
, the C standard library) to provide the bulk of the fucntionality.
For example, each ELF file will not carry their own version of puts
compiled within it - it will instead dynamically link to the puts
of the system it is on. As well as smaller binary sizes, this also means the user can continually upgrade their libraries, instead of having to redownload all the binaries every time a new version comes out.
Not quite.
The problem with this approach is it requires libc
to have a constant base address, i.e. be loaded in the same area of memory every time it's run, but remember that exists. Hence the need for dynamic linking. Due to the way ASLR works, these addresses need to be resolved every time the binary is run. Enter the PLT and GOT.
The PLT (Procedure Linkage Table) and GOT (Global Offset Table) work together to perform the linking.
When you call puts()
in C and compile it as an ELF executable, it is not actually puts()
- instead, it gets compiled as puts@plt
. Check it out in GDB:
Why does it do that?
Well, as we said, it doesn't know where puts
actually is - so it jumps to the PLT entry of puts
instead. From here, puts@plt
does some very specific things:
If there is a GOT entry for puts
, it jumps to the address stored there.
If there isn't a GOT entry, it will resolve it and jump there.
The GOT is a massive table of addresses; these addresses are the actual locations in memory of the libc
functions. puts@got
, for example, will contain the address of puts
in memory. When the PLT gets called, it reads the GOT address and redirects execution there. If the address is empty, it coordinates with the ld.so
(also called the dynamic linker/loader) to get the function address and stores it in the GOT.
Well, there are two key takeaways from the above explanation:
Calling the PLT address of a function is equivalent to calling the function itself
The GOT address contains addresses of functions in libc
, and the GOT is within the binary.
The use of the first point is clear - if we have a PLT entry for a desirable libc
function, for example system
, we can just redirect execution to its PLT entry and it will be the equivalent of calling system
directly; no need to jump into libc
.
The second point is less obvious, but debatably even more important. As the GOT is part of the binary, it will always be a constant offset away from the base. Therefore, if PIE is disabled or you somehow leak the binary base, you know the exact address that contains a libc
function's address. If you perhaps have an arbitrary read, it's trivial to leak the real address of the libc
function and therefore bypass ASLR.
There are two main ways that I (personally) exploit an arbitrary read. Note that these approaches will cause not only the GOT entry to be return but everything else until a null byte is reached as well, due to strings in C being null-terminated; make sure you only take the required number of bytes.
A ret2plt is a common technique that involves calling puts@plt
and passing the GOT entry of puts as a parameter. This causes puts
to print out its own address in libc
. You then set the return address to the function you are exploiting in order to call it again and enable you to
flat()
packs all the values you give it with p32()
and p64()
(depending on context) and concatenates them, meaning you don't have to write the packing functions out all the time
This has the same general theory but is useful when you have limited stack space or a ROP chain would alter the stack in such a way to complicate future payloads, for example when stack pivoting.
The PLT and GOT do the bulk of static linking
The PLT resolves actual locations in libc
of functions you use and stores them in the GOT
Next time that function is called, it jumps to the GOT and resumes execution there
Calling function@plt
is equivalent to calling the function itself
An arbitrary read enables you to read the GOT and thus bypass ASLR by calculating libc
base
Just as we did for PIE, except this time we print the address of system.
Yup, does what we expected.
Your address of system might end in different characters - you just have a different libc version
Much of this is as we did with PIE.
Note that we include the libc here - this is just another ELF
object that makes our lives easier.
Parse the address of system and calculate libc base from that (as we did with PIE):
Now we can finally ret2libc, using the libc
ELF
object to really simplify it for us:
Try it yourself :)
If you prefer, you could have changed the following payload to be more pwntoolsy:
Instead, you could do:
The benefit of this is it's (arguably) more readable, but also makes it much easier to reuse in 64-bit exploits as all the parameters are automatically resolved for you.