The Unsafe Unlink is considered the original GLIBC heap exploitation technique, developed in the
year 2000 by Alexander Peslyak, a.k.a. Solar Designer,
it was originally used to pwn the Netscape browser via a heap overflow.
It was also used to exploit the sudo binary
the subsequent year in a Phrack article entitled "Vudo Malloc Tricks".
If you haven't heard of Solar Designer, he's not only credited with the first generic heap exploitation
technique, but is also the father of return-oriented programming or ROP.
Back in 1997, in the wake of AlephOne's "Smashing the Stack for Fun & Profit", he developed the
first ret2libc attack to bypass his own non-executable stack patch.
So not only did he implement NX before it was commonplace, he also developed ROP to bypass it.
He then went on to develop a patch for his bypass for his patch, which involved mapping libraries into
addresses with null bytes to mitigate the use of ROP in conjunction with unsafe string functions.
He's an interesting guy,
if you want to read more about him Phrack published a great interview with him in 2016,
so let's learn about his Unsafe Unlink technique.
We'll begin by examining the vulnerable 'unsafe_unlink' binary. As we do every time, use checksec
to see which security features it has enabled.
The main difference this binary has from our previous ones is that it wasn't compiled with NX.
As I mentioned in the previous video, we're taking a trip back to the year 2000 when the unsafe unlink
technique was originally conceived.
The advantage we have when NX is disabled is that we can write shellcode into memory that wouldn't
otherwise be executable, then execute it.
You'll also notice that this binary is linked against a special version of GLIBC, which is patched
to behave similarly to the version
this technique was designed against.
So let's go ahead and load this binary into GDB, then let it run.
We're provided with both a libc and heap leak, followed by the familiar numbered menu.
Option 1 allows us to request only two chunks which fall into the so-called "small" chunk range.
Chunks are considered "small" when their size is less than 0x400
and that includes the fast sizes of 0x20 through 0x80.
In this case, we're limited to small chunks, excluding the fast range,
so 0x90 through 0x3f0.
Some of these binaries may seem needlessly restrictive in what they allow us to do with the heap,
but they're designed like that to focus our attention on the technique at hand.
I'll request the smallest chunk we can to keep things compact.
Notice how we're not prompted for data, this time allocation and writing are done via separate menu options.
In a production environment,
sometimes a program might populate fields of an object
immediately after requesting space for that object,
other times it might defer that write until later.
Option 2 allows us to write data to chunks by index.
Let's write some garbage data to our 0x90 chunk at index zero.
Breaking in GDB and inspecting the heap shows us that, as expected, we've been allocated a 0x90-
sized chunk to service our request for one hundred and thirty six bytes of memory
and the program has written our garbage data into that chunk's
user data.
Option 3 lets us free chunks by index.
First, let's request another 0x90-sized chunk to stop our first chunk getting consolidated with
the top chunk, then free our first chunk and take a look at the heap.
We can see the same changes we saw in the demo binary.
The succeeding chunk's prev_inuse flag has been cleared, indicating that the previous chunk is no longer
in use.
The last quadword of our first chunk's user data now belongs to the succeeding chunk and has been
repurposed as a prev_size field which matches its size field.
Lastly, the free chunk has been linked into the unsortedbin, annotated here,
the first two quadwords of its user data repurposed as forward and backward pointers, confirmed by the
'unsortedbin' command.
The command prints some meaningless information because it's confused by the read+write+execute nature
of the memory it's printing
so don't worry about that.
So we can request a couple of small chunks, write data into them whenever we want
and free those chunks.
Now's your opportunity to find the bug in this program
so pause the video and come back when you've found it.
Did you find the bug?
If not, don't worry, I'll restart my copy of the program and show you what it is.
I'll request a 0x90-sized chunk and attempt to write more than 136 bytes into its user data.
Breaking and inspecting the heap reveals that this program has an overflow bug similar to the
house_of_force binary.
However, this time we're not limited to overwriting just the top chunk size field,
we can also target size fields belonging to other chunks.
So let's request another 0x90-sized chunk. Inspecting the heap
now shows us what we'd expect, two 0x90-sized chunks followed by a now corrupt top chunk.
Let's continue and use the overflow bug again to overwrite the size field of the second 0x90 chunk.
Break in GDB
and this time we need to use the 'dq' command to inspect the heap because the corrupt size field
will confuse the 'vis' command.
The 'mp_' struct is used by malloc to hold a small amount of its parameter data and we'll be
seeing more of it later.
For now, we'll use its 'sbrk_base' member as a quick way to find the start of the default heap where
we'll print 38 quadwords of memory.
We see our garbage data taking up the first chunk's user data and that one quadword of it has overflowed
into the succeeding chunk's size field.
We can also see the top chunk's corrupt size field at the end of what was the second chunk's user data.
Now, even though we can corrupt the top chunk size field with this overflow, the House of Force technique
isn't viable here,
we just can't request enough memory or enough chunks.
So let's consider how corrupting a "normal" chunk's size field might benefit us.
We could change its size, then free it,
but to capitalize on that, we'd have to request the chunk back again, which we can't do due to the
two request limit imposed by this program.
But what if we left the size alone and instead just changed the flags?
Consider what would happen
were we to clear the second chunk's prev_inuse flag.
We know that the prev_inuse flag is used by malloc to determine whether a previous chunk is a candidate
for consolidation. In this case,
clearing the second chunk's prev_inuse flag would indicate to malloc that were we to free the second
chunk, the first chunk would be a consolidation candidate.
We also know that the consolidation process will involve unlinking the first chunk, but the first
chunk isn't really free
so it isn't linked into a free list and therefore has no forward or backward pointers.
However, this is our opportunity to provide them, and remember - an unsafe unlink operation on attacker-controlled
pointers
yields a reflected write primitive.
So let's close GDB,
make a copy of the pwntools_template Python script and open it in your editor of choice.
Scroll to the bottom where we can see some example uses of the helper functions.
The malloc() function just takes a request size argument
this time. There's an edit() function that takes the index of the chunk you want to edit as the first argument
and the data you want to write as the second.
Lastly, there's a free() function that takes an index argument.
There's also a 'shellcode' variable which holds some execve("/bin/sh") shellcode compiled by the
'shellcraft' module that comes with pwntools,
we'll make use of this later.
We're going to start by doing what was suggested earlier, corrupt a chunk's size field by clearing its
prev_inuse flag.
Begin by clearing everything except the shellcode and requesting two 0x90-sized chunks,
although you could use any size the program accepts. I'll label mine chunks A and B.
Remember that an easy way to request a chunk of specific size is to subtract eight from the size
you want,
hence my request sizes of 0x88.
Run your script with the GDB and NOASLR
options to check that everything is as expected. After breaking,
'vis' shows us that we have two 0x90-sized chunks on the default heap, both of which have their
prev_inuse flags set.
Return to your editor. Now
we're going to leverage the overflow bug to overwrite chunk B's size field.
We want to keep the size the same but clear
its prev_inuse flag. Because chunk A has one hundred and thirty six or 0x88 bytes of user data
I'll need to write that many bytes of garbage into it first.
You'll need to write however many bytes of user data your first chunk holds.
Then we'll replace the next chunk's size field, which in my case currently holds the value
0x91,
so I need to pack a 0x90 value here, keeping the size the same whilst clearing the prev_inuse
flag.
You'll need to apply the same change to your second chunk's
size field.
Running our script with the debugging options and inspecting the heap
shows us that it's had the intended effect. Chunk B now has a size field of 0x90
rather than 0x91
and the last quadword of chunk A's user data is now considered part of chunk B,
specifically, its prev_size field.
So what would happen if we tried to free chunk B now?
Well, malloc would check its prev_inuse flag,
see that it was clear and attempt to consolidate chunk B with chunk A. To find the start of chunk A
malloc needs to use chunk B's prev_size field,
but right now that field is populated with garbage, so malloc would undoubtedly cause a segfault
whilst trying to access memory at that address minus 0x595959... etc.
So let's return to our editor and make some more changes.
Let's prepare some variables ahead of time to make our lives easier.
Soon we'll have to forge a pair of forward and backward pointers for our reflected write,
so let's create placeholders for those now.
We'll need to set a prev_size field that guides malloc to the start of chunk A, in my case
this value is 0x90, which is what would legitimately be written there if we had freed chunk A.
Use the size of your first chunk here. To help clarify things,
let's also turn our fake size field into a variable.
We need to write the fd and bk into the first two quadwords of chunk A, followed by enough garbage
to take us up to the prev_size field.
We can use the p8() or "pack eight" function here to write
null bytes rather than printing characters
which can make things a little less cluttered when viewing memory dumps.
Then we'll append our prev_size field and fake size fields.
Once you've made those changes, run your script with the usual options and inspect the heap.
We can see our forged fd and bk at the start of chunk A, chunk B's cleared prev_inuse flag
and this time we have a sane prev_size field for malloc to use.
Now if we were to free chunk B, malloc would check its prev_inuse flag,
see that it was clear, attempt to consolidate chunk
B with chunk A, read our forged prev_size field and subtract it from chunk B's address to successfully
find the start of chunk A.
Next, it will attempt to unlink chunk A from whichever free list it's linked into.
It's at this point that malloc will perform the unlink procedure on our forged fd and bk, giving us
that reflected write primitive. malloc will follow our fd to what it believes is another chunk and
overwrite that chunk's
bk with our bk. Then malloc will follow our bk to what it also believes is another chunk and
overwrite that chunk's fd with our fd.
For this to work, both addresses we supply must point to writable memory, which means if we try to
overwrite the free hook with the address of system(), for example, the second half of our reflected
write will attempt to write the address of the free hook into the system() function, causing a segfault
as it tries writing to non-writable memory.
Fortunately, it's the year 2000 and our writable memory is also executable.
Full RELRO is still enforced however,
so we need to target the malloc hooks for our attack to succeed.
We have a heap leak and we can write shellcode onto an executable heap,
so what's stopping us from using our reflected write to overwrite the free hook with the address of
our shellcode?
Let's try it.
Return to your editor
where the first thing we'll do is write our shellcode into chunk A.
Don't forget to amend the amount of garbage we write into chunk A to account for the shellcode.
Next, we'll focus on the first half of the reflected write, in which our bk will be copied to where
our fd points plus 0x18.
So let's set our bk to the address of our shellcode on the heap, which starts in the first chunk
just after our forged fd and bk,
so heap plus 0x20.
We want to copy this bk into the free hook,
that way, when we attempt to free another chunk after the unlinking has taken place, our shellcode
will be executed instead.
So let's point out fd at the free hook minus 0x18 to account for the fact that malloc
will treat whatever our fd points to as a chunk and overwrite its
bk, which is 24 or 0x18 bytes after the start of that chunk.
Now we'll run our script with the usual debugging options,
break and inspect the heap where we can see our forged fd and bk
at the start of chunk A. Printing
the address of the free hook confirms that our fd points twenty four bytes before it and our bk
holds the address of our shellcode.
To trigger the unsafe unlink attack
all we need to do is free chunk B. malloc
will check its prev_inuse flag, which we've cleared via the overflow bug, and attempt to consolidate
it with chunk A.
The prev_size that we supplied will be used to find the start of chunk A, which will then
be subject to the unlinking process.
Our forged fd will be followed and our bk will be copied over the bk of the chunk at the destination,
which overlaps the free hook.
So let's continue, switch windows to the program,
free chunk B at index one, triggering the unsafe unlink, then return to GDB,
break and inspect the heap again.
It looks like all that's left of our two chunks is the top chunk.
Remember that so-called normal chunks can not only be consolidated with each other, but with the top
chunk too. Since chunk B bordered the top chunk
it was first consolidated with chunk A, then consolidated into the top chunk afterwards.
But that doesn't mean our unsafe unlink didn't work.
Dumping the memory that the free hook points to reveals that it's pointing to our shellcode on the
heap.
Great!
Next time we free a chunk our shellcode will be executed.
There's just one thing we haven't accounted for, and that's the second half of the unlinking procedure
in which our bk was followed and our fd copied over the fd at its destination.
And we can see the outcome of that process here where our fd has been written into the middle of our
shellcode.
This leaves us with just 16 bytes of shellcode to deal with this corruption.
Fortunately, by disassembling the first part of our shellcode with the 'u' command,
we see that this shellcode already accounts for this by using its first two bytes to jump over the corruption,
after which the actual shellcode starts.
All that's left to add to our exploit is one line to free chunk B, triggering the unsafe unlink, and
another line to free chunk A afterwards, executing our shellcode via the free hook.
So let's return to our editor and make those final changes. While you're doing that,
notice the 'jmp' instruction prepend to the shellcode that dealt with the side effect of the unsafe
unlink.
Once you're ready, run your exploit script under production conditions and you should get a shell.
We just leveraged the original Unsafe Unlink technique.
In summary, we requested two chunks, A and B, and took advantage of a heap overflow to clear the
prev_inuse flag on chunk B, that should otherwise have it set.
In this case, the overflow was a whole quadword, but it could just as easily have been a single byte
or even a single null byte if the size field already had a least-significant byte of zero.
When we freed the corrupt chunk B, malloc determined that because it's prev_inuse flag was clear
its previous chunk was a candidate for consolidation.
Consolidation involves unlinking the candidate, or victim, chunk from its free list and adding its size
to the chunk being freed.
To find the candidate chunk malloc uses the prev_size field of the freed chunk.
prev_size fields are formed from the last quadword of user data belonging to the previous chunk.
We were able to correctly populate chunk B's prev_size field because chunk A wasn't really free
so we still controlled its user data.
Next, malloc attempted to unlink chunk A
and because we still controlled its user data, we were able to provide forged fd and bk pointers for
the unlink process to operate on.
We pointed our fd at the free hook minus 24 and our bk at some shellcode
we had written onto the heap. The unlink process followed our fd and copied our bk over the free hook.
It then followed our bk and copied our fd 16 bytes into our shellcode, which we had to deal with by
providing a 'jmp' instruction to jump over it.
Finally, we triggered a call to free(), which was redirected via the free hook to our shellcode on the
heap, giving us a shell.
Don't forget that the only reason we were able to use shellcode this way is because it's the year 2000.
Nevertheless, if you're dealing with IoT or embedded devices, you may find that NX is not enabled
even in the present day.
As usual, you can download a working exploit script, complete with comments from this section's resources.
If you want to refresh your memory on the unlinking process, you can refer to the 'Unlinking' section
of the PDF.
It's worth noting that we used so-called "backward consolidation" against this binary, where the forged
fd and bk are held in the chunk behind the chunk being freed.
There does exist a "forward consolidation" variant of this technique in which the forged fd and bk
reside in the succeeding chunk.
This variant was used in 2001 to exploit the sudo binary.
But if you're tired of life in the early 2000s, then join me in the next video where we return to the
present day to show how this technique can still be leveraged against modern versions of GLIBC.