Our first heap exploitation technique of HeapLAB Part 2 is known as the House of Spirit.
It's part of the Malloc Maleficarum, alongside the House Force from Part 1.
It's not the most versatile technique, but I think it's interesting because it can be used to leverage
non-heap-related bugs. We'll also take this opportunity to cover some of the malloc internals, pwndbg
commands and pwntools functions
we learned in Part 1, before moving on to something more complicated.
From within your exploit-dev environment, open a terminal and navigate to the HeapLAB_Part2
folder you extracted earlier, then to the house_of_spirit directory.
The binary named house_of_spirit is our target, and just like in HeapLAB Part 1, it's a small CTF-
style program written in C that has a memory corruption bug.
Hopefully you'll recall that the first thing we want to learn about our target binary is its security
stance, which we can discover with our friend checksec.
checksec reveals that the house_of_spirit binary was built with the full RELRO, stack canary and
NX mitigations. Full RELRO means that we'll be unable to target this program's procedure linkage table
or fini array if we develop an arbitrary write primitive. Stack
canaries mean that stack memory corruption bugs aren't as powerful and NX means that shellcode is
out of the question
unless we developed an mprotect ROP chain, which is out of scope for this course.
Our vulnerable binary is not a position independent, executable, meaning we'll know its address
at runtime and from its runpath, we can see that it's linked against GLIBC version 2.30, compiled
without tcache support.
I mentioned the reason for this set up in HeapLAB Part 1, but I'll recap now so that we're all on
the same page.
The binary isn't position independent because that gives us a known location to test any arbitrary write
primitive
we craft. Lack of position
independence is not required for heap exploitation.
GLIBC 2.30 is at a time of recording a recent version of GLIBC, it's compiled without tcache support
here simply because we haven't covered the tcache yet.
Typically, the presence of the tcache makes the GLIBC heap implementation easier to exploit.
So now that we're familiar with the exploit mitigations this program does and doesn't have, let's move
on and see how it behaves at runtime.
Load the house_of_spirit binary into GDB and start it using the 'run' command, which you'll recall can
be shortened to just 'r'.
Debugging like this will disable ASLR, but for now we're only concerned with learning what control
we have over this program.
Since we're learning a new technique, a libc address and the default heap start address are leaked
for us.
Now, the program is rather rudely prompting us for our age.
Normally, I'm not keen on pretending these programs are anything other than a direct interface to malloc,
but asking students for an arbitrary number felt, well, arbitrary.
I'll enter 32 here, which is easily identifiable in hexadecimal.
We're also prompted for a username, I'll just enter some garbage data.
Finally, we're presented with the numbered menu familiar to us from HeapLAB Part 1. Option
1 lets us request up to 8 chunks from malloc.
When we use it, we're prompted for a request size which is limited to so-called small chunks.
You'll recall from our brief foray into the smallbins in Part 1, that small chunks cover the size
range 0x20 through 0x3f0.
The maximum user data a 0x3f0 chunk can hold is 0x3e8 or 1000 bytes.
Selecting option 1 again, I'll request a minimum-sized chunk. Now we're prompted for data to write
into our chunk, again
I'll just send some garbage data.
Lastly, we're asked to name our chunk, I'll call mine AAAA.
The menu shows us that 1 of our 8 chunks has been allocated, so let's see what the house_of_spirit
program did with our data.
You know the drill, break in GDB with ctrl+c and inspect the heap with the 'vis_heap_chunks'
command, which we like to shorten to 'vis'.
The output shows us that we were indeed allocated a minimum-sized chunk with a size field of 0x20
and that our data, those capital Z's, was copied into that chunks user data.
But what about our chunk name, those capital A's, well that's stored on the stack. In HeapLAB Part
1 we learned that malloc doesn't keep track of allocated chunks, instead the onus for doing so
is on the program that requested them.
These course binaries keep track of their allocated chunks using an array I've named "m_array"
and the house_of_spirit program keeps its m_array on the stack.
In Part 1 we used the 'ptype' command to see how structures were laid out in memory.
Right now, the 'ptype' command won't work with m_array because it's local to the main() function
and we're currently in the read() function's frame.
We can see from the backtrace panel that main()'s frame is number 2, so use the 'frame' command or just
'f' to switch to frame 2.
Now that we're in the main() function' frame, GDB will recognize its local variables and we can see how
this program's m_array is laid out.
It's simply an array of 8 "chunk" structs, each holding an 8 byte "name" field, followed
by a pointer to an allocated chunk's user data.
We can also confirm that this array resides on the stack using the 'xinfo' command.
Inspecting the contents of this m_array with the 'print' or 'p' command shows that we've populated the first
entry, here's our chunk name, followed by a pointer to the chunk's user data, which contains our data.
Let's move on with our runtime analysis by issuing the 'continue' command, which you'll remember can
be shortened to just 'c'.
Hit return to print the house_of_spirit menu and note that option 2 allows us to free chunks we have
allocated.
As was the case in HeapLAB Part 1, chunks are freed by index, starting at zero.
I'm sure you remember how the free function works, but let's quickly recap by freeing chunk 0, breaking
in GDB with ctrl+c and inspecting the heap with 'vis'.
As you no doubt predicted, the 0x20 chunk we requested earlier was freed into the 0x20 fastbin,
confirmed by the 'fastbins' command.
Continue and hit return again, and this time it's use option 3 from the menu. Just like in Part
1,
our objective once we've learned the House of Spirit technique is to leverage it to craft an
arbitrary write primitive.
Our target is, as usual, this short string of X's that reside in the data section of this
binary.
The binary is, you'll recall, non-position-independent
so we know the target's address at runtime.
Let's confirm the location of our target data and complete our runtime analysis of this binary. The
target resides within a struct named "user",
so break in GDB and use the 'ptype' command to show its layout.
The "user" structure contains the "age" field we were asked to populate when we first ran the program,
followed by the target after a small gap and lastly, the "username" field we also populated earlier after
a second gap.
To help us visualize the "user" struct, let's use the 'dq' command to dump 22 quadwords starting at
the beginning of the struct.
He is our "age" field that we set to 32 or 0x20, followed by the target with those X's
represented by these 0x58 bytes and our username, our Y's, represented by these
0x59's.
I imagine you're already getting suspicious about that "age "field and yes, we will be using it as a
fake size field shortly, but we're getting ahead of ourselves. First,
I'll give you a moment to find the bug in this program.
When the prompt appears, pause the video and see what you can break.
If you found the bug in the house_of_spirit program, then well done.
If not, don't worry, this one can be difficult to track down.
As I mentioned at the start of the video, the House of Spirit technique can be used to take advantage
of non-heap-related bugs,
and in line with that theme, the bug in this program is a stack overflow.
In case you missed it, let me show you how to trigger it, I'll load the the house_of_spirit binary into
GDB,
run it, then send some garbage age and username values. I'll use option 1 to request a minimum-sized
chunk, fill it with garbage data, at which point I'm prompted for a chunk name.
This is where the overflow occurs.
Recall that this program keeps its chunk pointers in an array on the stack with each pointer preceded
by an 8 byte name the user gave it. If I send a chunk name longer than 8 bytes, it will overflow
into a chunk pointer.
So let's see the damage, if I break in GDB and switch to the main() function's frame so I can inspect the
contents of the first m_array entry, we see that the overflow bug has allowed me to overwrite a pointer
to a chunk.
Now, one could argue that because malloc doesn't keep track of allocated chunks, we haven't corrupted
malloc metadata, just program data.
And if we were now able to use this pointer in a read or write operation, that would constitute a powerful
primitive without really having anything to do with malloc itself.
However, in this scenario, we're only able to write to a chunk immediately after its allocation,
meaning there's just one thing we can do with this pointer after corrupting it, which is free it.
This is the basis for the House of Spirit, corrupting a pointer that is subsequently passed to the free()
function. How you corrupt the pointer isn't relevant here.
We've leveraged a stack overflow, but the memory corruption could just as easily have happened anywhere
else.
Leveraging the House of Spirit is theoretically simple, all we need to do is free a chunk that overlaps
sensitive data, then allocate that same fake chunk so we can read from or
or write
to that data.
Think of it like a fastbin dup.
We're linking a fake chunk into a bin, but instead of corrupting bin metadata, we're legitimately
freeing it into that bin.
As we'll see, this has both advantages and disadvantages.
So now that we know what this program's bug is and roughly what we're going to do with it, let's start
crafting a House of Spirit exploit with the pwntools library.
Make a copy of the pwntools_template.py script, remembering not to call the copy pwn.py and open
it in your editor of Choice, as I did in part one, I'll use Vim, but you can use any editor or IDE
you like.
The format of these exploit templates hasn't changed.
They still make use of Python3 and the pwntools library and they still contain helper functions
I've written to speed things up.
This script has malloc() and free() helper functions.
The malloc() function wraps option 1 from the house_of_spirit program menu and takes 3 arguments;
the size of the chunk
you want to request, the data you wish to write into it and the name of the chunk. It returns the index
of the chunk you requested.
Remember that this program lets us request up to 8 chunks.
The free() function wraps option 2 from the program menu and takes just 1 argument, the index of
the chunk you want to free.
Scrolling to the bottom of the script reveals a familiar layout, the libc leak is used to populate
the "libc" object, giving us quick access to its symbols and the heap leak is recorded in the "heap" variable.
We'll write our exploit between these dashed lines.
Currently, the script sets the "age" and "username" fields to arbitrary values, followed by an example
use of each helper function.
Here, the malloc() function is used to request a minimum-sized chunk, fill it with garbage data, some
Y's, and populate its name field with A's.
After that, the same chunk is freed via the free() helper function.
Begin by removing the "EXAMPLE" header and the call to the free() helper function.
As you know, running our script will start the house_of_spirit program, send it the inputs we've
told it to, then return control to us via io.interactive().
Remember that we can pass our pwntools script, the GDB and NOASLR options to attach GDB to the
target process and disable ASLR to make debugging easier.
So let's do that now.
We're presented with a GDB window, and we can alt+` between it and the program window.
Let's see what our script has done, from the GDB window hit ctrl+c to break, followed by the 'vis'
command to inspect this program's default heap.
As expected, our request for a 0x20-sized chunk was successful and we've filled that chunk's user
data with Y's.
What we're really interested in however, is the stack, switch to the main() function's frame and
print the first m_array entry.
We see our A's in the "name" field, followed by a pointer to that 0x20-sized chunk.
You'll recall that it's the population of this "name" field where the overflow bug resides.
Issue the same 'dq' command we used earlier to remind yourself of the memory layout surrounding
our target data, and don't forget that we want to free a fake chunk that overlaps this target data.
Now, that age field is just dying to be used as a counterfeit size field, and we're going to need
it because, as you know, a chunk size determines its fate during the free procedure.
We'll make some changes to our script so close GDB with 'quit' or ctrl+c and return to your editor.
The contents of the "name" variable are what get sent when the program asks us to name chunk A, anything
we append to these A's will overflow into the chunk pointer stored in the first entry of m_array.
So let's send the address of the "user" struct, which holds our target data.
We can use the pwntools pack() function to pack the address into a string, the width of which is determined
by the architecture of the target binary.
So in this case, it's equivalent to p64(),
you can use either one in this case.
We'll overwrite the pointer to chunk A with the address of the "user" struct plus 16, remember that we're
overwriting a pointer to chunk user data here
so when free() is invoked on it, malloc will subtract 16 to find the chunk header.
Let's also reinstate the line that frees chunk A, since freeing a fake chunk is what gives us the House
of Spirit primitive.
We'll leave the size field alone for now, it's set to a specific value that I want to use to make
a point before we change it.
Instead, let's run our script in its current state with the GDB and NOASLR options.
GDB stops and you'll notice a familiar backtrace indicative of triggering one of malloc's exploit mitigations.
First check that our overwrite was successful by switching to the main() function's frame, which is now
frame 5 and printing the contents of the first m_array entry.
GDB's annotation indicates that the pointer, which originally held the address of chunk A's user
data now points to the "user" struct plus 16, as we planned. Use 'dq' to once again dump memory at
the "user" struct and note that the address we provided points here, making the "age" field our fake chunk's
size field, again as was our intention.
So why has GLIBC called abort? We can use pwndbg's 'malloc_chunk' command to show us what malloc
will think of our fake chunk.
Its size field is 0x6f, we've used 0x7f in fake chunks before. In HeapLAB Part 1 we
successfully allocated a fake 0x7f chunk that overlapped the malloc hook while learning the
fastbin dup technique.
So why isn't 0x6f working here?
Well, notice how the 'malloc_chunk' command has also printed out fake chunk's flags.
And that because the least-significant nybble of our size field is 0x0f. all its flags are set, including
the IS_MMAPPED flag.
When malloc receives a request larger than a variable named "mmap_threshold", the request will be
serviced via the GLIBC mmap() function rather than from an arena. For the curious, you can read more
about mmap from its manpage and you can find the "mmap_threshold" variable amongst the malloc parameters,
which can be dumped with pwndbg's
'mp' command. We'll learn more about the mmap threshold later when we tackle the House of Rabbit technique.
For now, all we need to know is that when a chunk is allocated via mmap, its IS_MMAPPED flag is set. When
the free() function operates on a chunk with a set IS_MMAPPED flag,
instead of linking it back into an arena, it will unmap the chunk with GLIBC's munmap() function,
and if we issue the 'context backtrace' command,
we can see that frame 4 corresponds to malloc's munmap_chunk() function, which was trying to unmap
our fake chunk. The chunk actually failed an alignment check, which we can see if we switch to frame
4 and issue the 'context code' command.
But even if it had passed the check, this code path is useless to us because we want to link the fake
chunk into an arena so we can allocate it back later.
This is the first constraint the House of Spirit imposes when compared to the fastbin dup;
our fake chunk's size field must have a clear IS_MMAPPED flag.
So let's head back to our editor and make a quick fix to that size value.
The "age" variable is acting as our fake size field, and the IS_MMAPPED flag corresponds to the second-
least significant bit of that size field.
So replacing 0x6f with 0x6d will clear the IS_MMAPPED flag whilst keeping all the others.
If you want to refresh your memory on chunk flags, they're detailed under the "Chunks" header of the
PDF's "malloc internals" section.
Now that we've updated our fake size field, let's try this again, run your script with the GDB and
NOASLR options.
GDB stops. This time the backtrace panel shows that the problem is in the free() function itself, specifically
the arena_for_chunk() macro.
You may recall from HeapLAB Part 1 that there are two types of arenas, the main arena, which
resides in GLIBC's data section and non-main arenas, which can be created by malloc in multithreaded
applications.
Because non-main arenas are created dynamically at runtime, malloc can't resolve them using symbols.
Instead, it gives new arenas and their corresponding heaps something called a "heap_info" struct and
ensures they're mapped at a specific alignment.
malloc can then locate non-main arenas by applying an and-mask to a chunk's address to find its corresponding
heap_info struct.
To determine which arena a chunk should be linked into, the free() function checks its NON_MAIN_ARENA
flag, which the output of the 'malloc_chunk' command shows is set in our fake chunk.
When free() comes across a set NON_MAIN_ARENA flag, rather than linking the chunk into the main arena,
it will instead apply that and-mask to the chunk's address, in effect rounding it down to a much lower
value in an attempt to locate that chunk's arena.
Of course, our fake chunk doesn't belong to an arena, and therefore, when we try to free it, malloc
rounds its address down to an unmapped address and subsequently segfaults.
Under some circumstances, you could use the NON_MAIN_ARENA flag to orchestrate mayhem by providing
a fake arena, but in our case, it's easier just to clear it.
Return to your editor and replace the 0x6d value in the "age" variable with 0x69.
Now, both the IS_MMAPPED and NON_MAIN_ARENA flags are clear, leaving just the prev_inuse flag and the unused
fourth-least significant bit set.
Once again, run your script with the usual options.
And once again, GDB stops. This time we got as far as the int_free () function, so let's switch to its
frame, which is frame 4 and use 'context code' to see what went wrong.
malloc has detected an invalid size and we can see an error message printed if we alt+` to
the program window.
If you see one of malloc's sometimes cryptic error messages, you can find out which exploit mitigation
it's associated with by checking the "Mitigation error messages" header under the "Mitigations" section
in the HeapLAB PDF.
Returning to the debugger window, it's clear that our fake chunk has failed one of these checks and
it isn't the first one because we know that its size is above the minimum value.
The second check, that aligned_OK() macro is there specifically to catch the presence of a set forth-least
significant bit in a chunk's size field.
So it's back to the drawing board, once more return to your editor and set the age variable to
0x61, clearing the whole least-significant nybble with the exception of the prev_inuse flag.
Run your script with the usual options
and again, witness GDB stop.
The backtrace panel shows that we've once more found ourselves on the wrong side of an exploited mitigation
in the free() function. You know the drill, switch to the free() frame and type 'context code'.
This time, the error message states "invalid next size (fast)".
Type 'list 4245' to see the whole preceding check.
It's one of malloc's size sanity checks, but instead of operating on the chunk being freed, it's testing
the succeeding chunk.
If that chunk is smaller than a fencepost chunk or larger than av->system_mem, which we dealt with in Part
1, the mitigation is triggered.
Rerunning the 'dq' command from earlier shows that presently the size field after our fake chunk
is null.
Fortunately, if we were to change our fake chunk size to, say, 0x80, we could provide a sane
next size field in the "username" variable, which spans these quadwords after the target data.
Return to your editor and we'll do just that.
Start by increasing the size field of our fake chunk from 0x61 to 0x81, so it reaches into the "username"
field.
You can keep the prev_inuse flag set at this point, but you don't have to, since this chunk will
be treated as a fast chunk
the prev_inuse flag is ignored.
Next, modify the "username" variable, it needs to hold 3 padding quadwords followed by a sane
chunk size, you can use any value between a fencepost chunk, 0x11, and av->system_mem - 1.
The low-order nybble doesn't need to be valid.
Having made those changes, run your script with the usual options and thankfully GDB doesn't stop
this time.
Breaking with ctrl+c and inspecting the fastbins reveals that we were finally able to link our
fake chunk into the 0x80 fastbin.
Dumping memory at the "user" struct shows our setup, with our counterfeit size field here and succeeding
size field here.
All that's left to do is request our fake chunk back via malloc and use it to overwrite the target data.
So let's add that last part to our script.
Return to your editor and after the call to free() append a call to the malloc() helper function that requests
our fake 0x80-sized chunk.
Remember that a quick way to request a chunk of specific size is to subtract 8 from the desired
chunk size.
We'll send 64 bytes of garbage data to fill in the gap between the start of our fake chunk's user
data and the target, then overwrite the target data with an easily recognizable string.
Lastly, we need to provide a chunk name, any value will do here.
Once you're done, run your exploit script under production conditions without the GDB and NOASLR
options.
You can hit return to see the program menu, then select option 3 to print the target data, which
confirms we were successful in leveraging the House of Spirit technique to craft an arbitrary write
primitive.
In summary, this binary has an overflow bug which allowed us to overwrite a pointer that was subsequently
passed to the free() function.
The overflow bug manifests on the stack, but it could have occurred anywhere.
We used this bug to replace a pointer with the address of a fake chunk we'd prepared overlapping our
target data.
The constraints of this fake chunk are quite strict; its IS_MMAPPED flag must be clear,
if it isn't, malloc attempts to unmap our fake chunk rather than link it into the main arena.
Its NON_MAIN_ARENA flag must also be clear,
otherwise malloc is likely to segfault when it goes looking for a nonexistent arena.
The fourth-least significant bit of the size field must be clear, if not we'll trigger a mitigation.
Lastly, a second fake size field that passes malloc's size sanity checks must be present after the fake
chunk.
Once we've found or in this case crafted our fake chunk, we are able to free it into the main arena,
then request it back and use it to overwrite the target data.
So the House of Spirit isn't the most versatile technique, we need to control at minimum 2 quadwords
one either side of our target data.
But what's important is that it can be used to take advantage of non-heap-related bugs.
If you want to review this technique, you can download a commented exploit script from this lecture's
resources.
Before we move on
note that our fake chunk moved through the 0x80 fastbin, with a different binary if you can get the
two size fields to line up, you could use any fast-sized chunk.
But what if we needed a larger chunk?
That would change how free() behaves when processing it and in turn introduces more constraints.
Before I give you an example of this, you can make a copy of your exploit so far or continue to edit
it in place.
Once you're ready, let's try the House of Spirit with a small chunk.
Change the size of our fake chunk from 0x81 to 0x91 by modifying the "age" variable.
This puts it in the "normal" chunk size range, making it eligible for unsortedbin insertion on free.
If you didn't have the prev_inuse bit set before, set it now.
You probably remember from the Unsafe Unlink technique in Part 1 that a clear prev_inuse flag on a non-
fast chunk will cause malloc to consolidate it with the previous chunk.
Not only are we unable to provide a prev_size field in this case, even if we could we'd have to satisfy
the safe unlinking checks and both size vs prev_size checks, introducing a prohibitive amount
of complexity to our exploit.
So keep that in prev_inuse bit set.
The free() function applies its next size sanity check to both fast and normal chunks alike.
So now that we've changed our fake chunk size, we need to edit the "username" variable to line up our
second size field to pass that check, all we have to do is add 2 more padding quadwords at the beginning.
I'm also going to clear the prev_inuse bit of this size field to make a point that will become clear in a
moment.
Note that the version of GLIBC this binary is linked against, 2.30, also performs the
same next size sanity check during unsortedbin scanning.
We'll also need to change the size of our final request to match that of our fake chunk, so replace
0x78 with 0x88 in this call to the malloc() helper function.
Once you've done that, run your script with the GDB and no ASLR options.
As we've come to expect, GDB stops.
You know what to do by now, and it looks like we've triggered a double-free mitigation.
This one's different to the fastbins double-free check we came across in Part 1.
All it's doing is checking the prev_inuse flag of the succeeding chunk.
If that flag is clear, that chunk we're trying to free must already be free.
Return to your editor where we'll make the final changes to our exploit.
We're nearly there, but before we change that second size field, consider the following; we've set
our fake chunk's prev_inuse flag to avoid backward consolidation.
But what about forward consolidation?
The Unsafe Unlink technique taught us that malloc will also check the prev_inuse flag of the chunk
after the succeeding chunk.
If that's clear, then it will attempt forward consolidation.
The easiest way to avoid this is to provide a third fake size field using fencepost chunks.
So let's replace the second size field with a fencepost chunk, not forgetting to set its prev_inuse
flag to avoid triggering the double-free mitigation, then add a padding quadword followed by another
fencepost chunk, again with a set prev_inuse flag, this time to avoid forward consolidation.
This third size field doesn't actually have to be sane so long as it has a set prev_inuse flag, making
it more likely that we could just find one in memory rather than crafting it ourselves.
Go ahead and run your exploit script, this time with just the GDB option.
It looks like we haven't triggered any mitigations this time, so alt+` to the program window
and a select option 3, which confirms that this time we've successfully used a fake small chunk
to craft an arbitrary write primitive using the House of Spirit.
If you want to see what our setup looks like in memory, you can switch to the debugger window, break
with ctrl+c, then use the 'dq' command to dump memory at the "user" struct.
We see our fake chunk's size field here, our fencepost chunk here and our forward consolidation guard
here.
So using a fake normal chunk in the House of Spirit has the same constraints as using a fast chunk,
plus we need to control or find a third quadword with a set least-significant bit.
The advantages of using a normal chunk are that it can take on a wider range of sizes and we can remainder
it, meaning that we don't have to request the exact size of our fake chunk to allocate from it.
There is one disadvantage to using normal chunks in the House of Spirit that we haven't covered; the
following command prints a snippet of the malloc source code that the comments suggest is checking
whether the next chunk is beyond the boundaries of the arena.
Now, this check is only applied to normal chunks during the free process, fast chunks are exempt,
but shouldn't we have failed this check?
Our fake normal chunk, along with its succeeding chunk, are nowhere near the boundaries of the heap.
But read the code carefully, it's only checking whether the next chunk is at an address larger than
or equal to that of the end of the top chunk.
This means that any address below that is fair game, including the writable segment of the program.
So if you're using fake fast chunks in the House of Spirit, they can be located anywhere you like,
but when using fake normal chunks you're limited to addresses below the end of the main arena's heap.
The exception to this rule is if the main arena has become non-contiguous under memory pressure, which
we won't see during this course.
If you want another commented solution script, there's one that uses a fake normal chunk available
from this lecture's resources.
So how are we going to leverage a technique with such tight restrictions to get a shell?
Join me in the next video and we'll find out together.