Introduction to Tcache Poisoning
This going to be our first tutorial on heap exploitation. We have studied the heap internals in depth now. We need to move into the actual exploitation stuff.
First of all lets setup the environment. In this tutorial we are going to see the TCache posinoning based attacks. For this we need to find a LibC version where Tcache is enabled. I'm using libc 2.27 to demonstrate this. This is the first version which TCache is introduced. In this version there are less number of protections. Which makes it easier to study.
apt update
apt install -y gcc gdb python3 python3-pip git vim
pip3 install pwntools
git clone https://github.com/pwndbg/pwndbg ~/pwndbg
cd ~/pwndbg && ./setup.sh
The pwandbg is an extension to GDB. Of course we can do tis without the plain GDB. Just like we did in stack overflow tutorials. But here I'm going to introduce pawndbg.
Confirm you got the right libc:
thilan@ubuntu:~$ ldd --version | head -1
ldd (Ubuntu GLIBC 2.27-3ubuntu1.5) 2.27
In over stack buffer overflow tutorials, we turned off ASLR in the first most tutorial. You already know that Address Layout Randomization is a protection layer for exploitations. Since we are going to learn, we first disable it. In a later part we will enable it and study bypasses.
thilan@ubuntu:~$ echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
0
thilan@ubuntu:~$ cat /proc/sys/kernel/randomize_va_space
0
Tcache in action
Lets write a simple C Program to see the actual behavior of the Tcache.
#include<stdio.h>
#include<stdlib.h>
int main(){
char* a = malloc(10);
printf("malloc returned addr for a: %p\n", a);
free(a);
char* b = malloc(10);
printf("malloc returned addr for b: %p\n", b);
free(b);
return 0;
}
What does it do? First it allocates a ten bytes memory. Then that memory is freed and another ten byte memory is allocated.
If we forget bins and Tcache, we might get some random memory addresses. However here the TCache layer is active.
Theoretically the Chunk a goes to the Tcache once it is free. Then we should get same chunk when we request memory for chunk b.
I use gcc to compile it.
thilan@ubuntu:~$ gcc simple_tcache.c -o simple_tcache -m32 -no-pie -fno-stack-protector -g
You can see that I'm using a several flags with gcc.
-
-genable debugging symbols. This allow us to see the source code in GDB. -
-no-piefixes the binary at0x400000, which puts the heap at a readable0x602000instead of a PIE address, and makes global symbols constant. -
-fno-stack-protectoris not strictly needed here as we never touch the stack. But it keeps the disassembly clean. -
-m32 is used to compile the binary in 32 bit mode. I prefer short memory addresses for the tutorial. That's the whole reason.
Notice what is not in that list. No -z execstack. We are never executing heap data, so
NX is irrelevant and stays on.
Lets run it and see what happens.
thilan@ubuntu:~$ ./simple_tcache
malloc returned addr for a: 0x804b160
malloc returned addr for b: 0x804b160
Hmm.. Same pointer. free() pushed the chunk onto a tcache bin, and the next malloc() of the
same size popped it straight back off. No randomness, no delay, no quarantine.
Now we are going to analyze it in low level. Lets load it in to GDB.
thilan@ubuntu:~$ gdb -q ./simple_tcache
pwndbg> set disassembly-flavor intel
pwndbg> disass main
Dump of assembler code for function main:
0x08048486 <+0>: lea ecx,[esp+0x4]
0x0804848a <+4>: and esp,0xfffffff0
0x0804848d <+7>: push DWORD PTR [ecx-0x4]
0x08048490 <+10>: push ebp
0x08048491 <+11>: mov ebp,esp
0x08048493 <+13>: push ebx
0x08048494 <+14>: push ecx
0x08048495 <+15>: sub esp,0x10
0x08048498 <+18>: call 0x80483c0 <__x86.get_pc_thunk.bx>
0x0804849d <+23>: add ebx,0x1b63
0x080484a3 <+29>: sub esp,0xc
0x080484a6 <+32>: push 0xa
0x080484a8 <+34>: call 0x8048340 <malloc@plt>
0x080484ad <+39>: add esp,0x10
0x080484b0 <+42>: mov DWORD PTR [ebp-0xc],eax
0x080484b3 <+45>: sub esp,0x8
0x080484b6 <+48>: push DWORD PTR [ebp-0xc]
0x080484b9 <+51>: lea eax,[ebx-0x1a60]
0x080484bf <+57>: push eax
0x080484c0 <+58>: call 0x8048320 <printf@plt>
0x080484c5 <+63>: add esp,0x10
0x080484c8 <+66>: sub esp,0xc
0x080484cb <+69>: push DWORD PTR [ebp-0xc]
0x080484ce <+72>: call 0x8048330 <free@plt>
0x080484d3 <+77>: add esp,0x10
0x080484d6 <+80>: sub esp,0xc
0x080484d9 <+83>: push 0xa
0x080484db <+85>: call 0x8048340 <malloc@plt>
0x080484e0 <+90>: add esp,0x10
0x080484e3 <+93>: mov DWORD PTR [ebp-0x10],eax
0x080484e6 <+96>: sub esp,0x8
0x080484e9 <+99>: push DWORD PTR [ebp-0x10]
0x080484ec <+102>: lea eax,[ebx-0x1a40]
0x080484f2 <+108>: push eax
0x080484f3 <+109>: call 0x8048320 <printf@plt>
0x080484f8 <+114>: add esp,0x10
0x080484fb <+117>: sub esp,0xc
0x080484fe <+120>: push DWORD PTR [ebp-0x10]
0x08048501 <+123>: call 0x8048330 <free@plt>
0x08048506 <+128>: add esp,0x10
0x08048509 <+131>: mov eax,0x0
0x0804850e <+136>: lea esp,[ebp-0x8]
0x08048511 <+139>: pop ecx
0x08048512 <+140>: pop ebx
0x08048513 <+141>: pop ebp
0x08048514 <+142>: lea esp,[ecx-0x4]
0x08048517 <+145>: ret
End of assembler dump.
I know. It is bit lengthy when I put the whole disassemble here. But I want to keep the whole context in this article. I don't care about SEO. :p
Lets set breakpoints.
pwndbg> b *main+72
Breakpoint 1 at 0x80484ce: file simple_tcache.c, line 9.
pwndbg> b *main+77
Breakpoint 2 at 0x80484d3: file simple_tcache.c, line 9.
pwndbg> b *main+90
Breakpoint 3 at 0x80484e0: file simple_tcache.c, line 11.
The first BP is after the malloc(a). The second one is just after free(a). The third BP is after malloc(b).
We run the program and it will hit at first BP.
pwndbg> r
Starting program: /home/thilan/simple_tcache
malloc returned addr for a: 0x804b160
Breakpoint 1, 0x080484ce in main () at simple_tcache.c:7
Since we are using pawndbg, we have some nice command set to examine memory.
pwndbg> tcache
tcache is pointing to: 0x804b010 for thread 1
{
counts = '\000' <repeats 63 times>,
entries = {0x0 <repeats 64 times>},
}
We used the tcache command to examine the Tcache area. As we expected it is empty. Because there is nothing freed yet.
There is another useful command called vis which help us to visualize the heap layout.
pwndbg> vis
0x804b008 0x00000000 0x00000151 ....Q...
0x804b010 0x00000000 0x00000000 ........
0x804b018 0x00000000 0x00000000 ........
0x804b020 0x00000000 0x00000000 ........
0x804b028 0x00000000 0x00000000 ........
0x804b030 0x00000000 0x00000000 ........
0x804b038 0x00000000 0x00000000 ........
0x804b040 0x00000000 0x00000000 ........
0x804b048 0x00000000 0x00000000 ........
0x804b050 0x00000000 0x00000000 ........
0x804b058 0x00000000 0x00000000 ........
.
.
.
0x804b150 0x00000000 0x00000000 ........
0x804b158 0x00000000 0x00000011 ........
0x804b160 0x00000000 0x00000000 ........
0x804b168 0x00000000 0x00000411 ........
0x804b170 0x6c6c616d 0x7220636f malloc r
0x804b178 0x72757465 0x2064656e eturned
0x804b180 0x72646461 0x726f6620 addr for
0x804b188 0x203a6120 0x30387830 a: 0x80
0x804b190 0x36316234 0x00000a30 4b160...
0x804b198 0x00000000 0x00000000 ........
0x804b1a0 0x00000000 0x00000000 ........
.
.
.
0x804b570 0x00000000 0x00000000 ........
0x804b578 0x00000000 0x00021a89 ........ <-- Top chunk
As we can see the heap is starts from 0x804b008 and goes until 0x804b578.
To get the allocated chunk information, we have another command called heap. Lets see the available chunks at the moment.
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x804b008
Size: 0x151
Allocated chunk | PREV_INUSE
Addr: 0x804b158
Size: 0x11
Allocated chunk | PREV_INUSE
Addr: 0x804b168
Size: 0x411
Top chunk | PREV_INUSE
Addr: 0x804b578
Size: 0x21a89
Hmm.. Interesting.
There are four chunks. But we only allocated the chunk a at the moment. Lets deep in to the basics and figure out how this happens.
As we studied in the last tutorial, the Tcache is itself a chunk. It s the chunk we can see at the starting of the heap. Starts from 0x804b008 and goes till 0x804b158.
We can see its size flag is 0x00000151. We already know how to get the actual size from this. So I'm not going to to the math again here. The chunk size is 150 Bytes.
This space is used to store the Tcache related information. That means the counts and entries.
- counts[64] occupies
0x804b010to0x804b04f - entries[64] occupies
0x804b050to0x804b14f
The next chunk is at 0x804b158. It's size flag is 0x00000011. It is the allocated memory for the chunk a.
There is another chunk with size flag 0x00000411 at 0x804b168. This is created by printf() function. That space is used to store the data belongs to that.
At last, the top chunk is there starting from 0x804b578.
Lets summarize these details.
| Chunk addr | Size field | User data | What it is |
|---|---|---|---|
| 0x804b008 | 0x151 | 0x804b010 | tcache_perthread_struct |
| 0x804b158 | 0x11 | 0x804b160 | Chunk a |
| 0x804b168 | 0x411 | 0x804b170 | stdout buffer from printf |
| 0x804b578 | 0x21a89 | top chunk |
Each chunk address plus its size gives the next one: 0x804b008 + 0x150 = 0x804b158, 0x804b158 + 0x10 = 0x804b168, 0x804b168 + 0x410 = 0x804b578. The top chunk then runs to 0x806d000, the end of the initial 132 KB heap.
An additional point to be noted.
The heap starts at 0x804b000, but the first chunk is at 0x804b008. On i386 the chunk header is 8 bytes, and user data must be 16-byte aligned, so malloc shifts the first chunk by 8. That’s why every chunk address ends in 8 and every user pointer ends in 0.
Lets continue the execution till it hits the next BP which is just after calling free(a).
pwndbg> c
Continuing.
Breakpoint 2, 0x080484d3 in main () at simple_tcache.c:9
9 free(a);
pwndbg> tcache
tcache is pointing to: 0x804b010 for thread 1
{
counts = "\001", '\000' <repeats 62 times>,
entries = {0x804b160, 0x0 <repeats 63 times>},
}
As you can see the Tcache counts and entries got changed. Our chunk is 0x10 in size. Lets see quickly the breakdown.
We requested 10 bytes of user data. Malloc gave a chunk sized 0x10 which is 16 Bytes. However 8 bytes are allocated to meta data. As we already know we can use the remaining 8 bytes + some of next chunks meta data space to get the required 10 Bytes. If you forgot this just refer the heap internals article. There we discussed this concept deeply.
Anyhow we have the chunk with size 0x10. We know that chunks in the size range 1–12 bytes are fitting into the Tcache bin 0. In above we can see the counts and entries got filled.
- counts[0] = 1
- entries[0] = 0x804b160
If we say it more clearly, the counts array's first item hold the count of chunks in the Tcache bin 0. Here it is one. Also the entries Array's first element is a memory address. It is actually a pointer to the chunk a which is freed. In above case that is 0x804b160.
I think now you got the idea how it works actually. For a moment think what happen if we free two chunks which the size is 0x10? We will see this in a later part of this article.
Lets use the vis command to visually see the heap.
pwndbg> vis
0x804b008 0x00000000 0x00000151 ....Q...
0x804b010 0x00000001 0x00000000 ........
0x804b018 0x00000000 0x00000000 ........
0x804b020 0x00000000 0x00000000 ........
0x804b028 0x00000000 0x00000000 ........
0x804b030 0x00000000 0x00000000 ........
0x804b038 0x00000000 0x00000000 ........
0x804b040 0x00000000 0x00000000 ........
0x804b048 0x00000000 0x00000000 ........
0x804b050 0x0804b160 0x00000000 `.......
0x804b058 0x00000000 0x00000000 ........
.
.
.
0x804b150 0x00000000 0x00000000 ........
0x804b158 0x00000000 0x00000011 ........
0x804b160 0x00000000 0x0804b010 ........ <-- tcachebins[0x10][0/1]
0x804b168 0x00000000 0x00000411 ........
0x804b170 0x6c6c616d 0x7220636f malloc r
0x804b178 0x72757465 0x2064656e eturned
0x804b180 0x72646461 0x726f6620 addr for
0x804b188 0x203a6120 0x30387830 a: 0x80
0x804b190 0x36316234 0x00000a30 4b160...
0x804b198 0x00000000 0x00000000 ........
.
.
.
0x804b570 0x00000000 0x00000000 ........
0x804b578 0x00000000 0x00021a89 ........ <-- Top chunk
Nice! This visualization gives us a good opportunity to understand how tcache is represented in the actual heap.
The tcache structure starts at 0x804b010.
The address 0x804b010 contains the value 0x00000001. That is counts[0] meaning bin 0 contains one chunk.
Note that The address 0x804b050 contains the value 0x0804b160 This is the pointer to the first chunk in bin 0.
The freed chunk is at 0x0804b160. Let's break it down. The general chunk layout is something like following:
<layout scale="linear"> <label pos="top-left">Low memory</label> <label pos="top-right">High memory</label> <block size=4>...</block> <block size=4>Prev size</block> <block size=4>Size + Flags</block> <block size=16>User Data</block> <block size=4>...</block> <ptr index=1>ptr - 8</ptr> <ptr index=2>ptr - 4</ptr> <ptr index=3>malloc() pointer</ptr>
<layout scale="linear"> <block size=4>...</block> <block size=4>0x0</block> <block size=4>0x11</block> <block size=4>0x0</block> <block size=4>0x0804b010</block> <block size=8></block> <block size=4>...</block> <ptr index=1>0x804b158</ptr> <ptr index=2>0x804b15c</ptr> <ptr index=3>0x804b160</ptr> <ptr index=4>0x804b164</ptr>
The former user-data area, now holding tcache metadata.
A few important observations:
0x11means chunk size0x10plus the PREV_INUSE flag (1).0x804b160is the user pointer.- The first word of the freed chunk stores the next pointer. (In this case it is NULL)
- The second word stores the tcache key. In glibc 2.27, the key is a pointer to the thread's tcache structure.
Just to confirm above lets use the heap command.
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x804b008
Size: 0x151
Free chunk (tcachebins) | PREV_INUSE
Addr: 0x804b158
Size: 0x11
fd: 0x00
Allocated chunk | PREV_INUSE
Addr: 0x804b168
Size: 0x411
Top chunk | PREV_INUSE
Addr: 0x804b578
Size: 0x21a89
Now we have observed the Tcache behavior. We can continue until the next BP and see what happens when we call malloc(10) again.
pwndbg> tcache
tcache is pointing to: 0x804b010 for thread 1
{
counts = '\000' <repeats 63 times>,
entries = {0x0 <repeats 64 times>},
}
We can see now tcache is empty. You may use vis and heap commands too to see what happened in the memory level. Here I'm not going to list out that.
Tcache Exploitation
Now we are moving to the next part. Actual exploitation.
#include <stdio.h>
#include <stdlib.h>
int target[4];
int main(){
char *a = malloc(10);
char *b = malloc(10);
free(a);
free(b);
*(void **)b = target;
char *c = malloc(10);
char *d = malloc(10);
printf("target: %p\n", (void *)target);
printf("d: %p\n", (void *)d);
return 0;
}
Its clear what this code does except *(void **)a = target;. Let me explain that part later. First of all lets compile and run the program.
thilan@ubuntu:~$ gcc pois_tcache.c -o pois_tcache -m32 -no-pie -fno-stack-protector -g
thilan@ubuntu:~$ ./pois_tcache
target: 0x804a028
y: 0x804a028
We can see target's memory address is given to the chunk d.
The following is the virtual memory layout.
<label pos="top-left">High memory</label> <label pos="top-right">Low memory</label> <block size=4>...</block> <block size=4>Kernel Space</block> <block size=4>Stack -></block> <block size=4>Memory Mapping</block> <block size=4><- Heap</block> <block size=4>Bss</block> <block size=4>Data</block> <block size=4>Txt</block> <block size=4>...</block>
We selected a memory block in .bss segment as the target. We could assign the memory address of this .bss code block into another malloc() returned pointer. Now have an arbitrary-address allocation primitive, and anything we write through d lands in target. How this is possible?
To understand the exploit, lets go though gdb and see the program at low level.
thilan@ubuntu:~$ gdb -q ./pois_tcache
pwndbg> disass main
Dump of assembler code for function main:
0x08048486 <+0>: lea ecx,[esp+0x4]
0x0804848a <+4>: and esp,0xfffffff0
0x0804848d <+7>: push DWORD PTR [ecx-0x4]
0x08048490 <+10>: push ebp
0x08048491 <+11>: mov ebp,esp
0x08048493 <+13>: push ebx
0x08048494 <+14>: push ecx
0x08048495 <+15>: sub esp,0x10
0x08048498 <+18>: call 0x80483c0 <__x86.get_pc_thunk.bx>
0x0804849d <+23>: add ebx,0x1b63
0x080484a3 <+29>: sub esp,0xc
0x080484a6 <+32>: push 0xa
0x080484a8 <+34>: call 0x8048340 <malloc@plt>
0x080484ad <+39>: add esp,0x10
0x080484b0 <+42>: mov DWORD PTR [ebp-0xc],eax
0x080484b3 <+45>: sub esp,0xc
0x080484b6 <+48>: push 0xa
0x080484b8 <+50>: call 0x8048340 <malloc@plt>
0x080484bd <+55>: add esp,0x10
0x080484c0 <+58>: mov DWORD PTR [ebp-0x10],eax
0x080484c3 <+61>: sub esp,0xc
0x080484c6 <+64>: push DWORD PTR [ebp-0xc]
0x080484c9 <+67>: call 0x8048330 <free@plt>
0x080484ce <+72>: add esp,0x10
0x080484d1 <+75>: sub esp,0xc
0x080484d4 <+78>: push DWORD PTR [ebp-0x10]
0x080484d7 <+81>: call 0x8048330 <free@plt>
0x080484dc <+86>: add esp,0x10
0x080484df <+89>: mov eax,DWORD PTR [ebp-0x10]
0x080484e2 <+92>: mov edx,0x804a028
0x080484e8 <+98>: mov DWORD PTR [eax],edx
0x080484ea <+100>: sub esp,0xc
0x080484ed <+103>: push 0xa
0x080484ef <+105>: call 0x8048340 <malloc@plt>
0x080484f4 <+110>: add esp,0x10
0x080484f7 <+113>: mov DWORD PTR [ebp-0x14],eax
0x080484fa <+116>: sub esp,0xc
0x080484fd <+119>: push 0xa
0x080484ff <+121>: call 0x8048340 <malloc@plt>
0x08048504 <+126>: add esp,0x10
0x08048507 <+129>: mov DWORD PTR [ebp-0x18],eax
0x0804850a <+132>: sub esp,0x8
0x0804850d <+135>: mov eax,0x804a028
0x08048513 <+141>: push eax
0x08048514 <+142>: lea eax,[ebx-0x1a30]
0x0804851a <+148>: push eax
0x0804851b <+149>: call 0x8048320 <printf@plt>
0x08048520 <+154>: add esp,0x10
0x08048523 <+157>: sub esp,0x8
0x08048526 <+160>: push DWORD PTR [ebp-0x18]
0x08048529 <+163>: lea eax,[ebx-0x1a24]
0x0804852f <+169>: push eax
0x08048530 <+170>: call 0x8048320 <printf@plt>
0x08048535 <+175>: add esp,0x10
0x08048538 <+178>: mov eax,0x0
0x0804853d <+183>: lea esp,[ebp-0x8]
0x08048540 <+186>: pop ecx
0x08048541 <+187>: pop ebx
0x08048542 <+188>: pop ebp
0x08048543 <+189>: lea esp,[ecx-0x4]
0x08048546 <+192>: ret
End of assembler dump.
As usual, I did not remove any Assembly instruction from the output. Incase you are following the tutorial itself, you can clearly see each instruction.
Lets set the break points.
pwndbg> b *main+64 <- After two malloc()s
Breakpoint 1 at 0x80484c6: ...
pwndbg> b *main+72 <- After free(a)
Breakpoint 2 at 0x80484ce: ...
pwndbg> b *main+86 <- After free(b)
Breakpoint 3 at 0x80484dc: ...
pwndbg> b *main+100 <- After *(void **)a = target;
Breakpoint 4 at 0x80484ea: ...
pwndbg> b *main+110 <- After malloc(c)
Breakpoint 5 at 0x80484f4: ...
pwndbg> b *main+126 <- After malloc(d)
Breakpoint 6 at 0x8048504: ...
Lets see the heap layout just after allocating a and b. I'm only showing the necessary part of the vis command output.
pwndbg> vis
...
0x804b158 0x00000000 0x00000011 ........
0x804b160 0x00000000 0x00000000 ........
0x804b168 0x00000000 0x00000011 ........
0x804b170 0x00000000 0x00000000 ........
0x804b178 0x00000000 0x00021e89 ........ <-- Top chunk
Now we know the starting addresses of chunk a and chunk b. The following is the memory layout of those two.
<label pos="top-left">Low memory</label> <label pos="top-right">High memory</label> <block size=1>...</block> <block size=4>Prev size</block> <block size=4>Size + Flags</block> <block size=24>User Data</block> <block size=4>Prev size</block> <block size=4>Size + Flags</block> <block size=24>User Data</block> <block size=12>Top Chunk</block> <block size=1>...</block> <ptr index=1>Chunk a starts here (0x804b158)</ptr> <ptr index=4>Chunk a ends & Chunk b starts here (0x804b168)</ptr> <ptr index=7>Chunk b ends here (0x804b178)</ptr>
Now continue the execution till it hits the second breakpoint. At this point we have called free(a).
pwndbg> tcache
tcache is pointing to: 0x804b010 for thread 1
{
counts = "\001", '\000' <repeats 62 times>,
entries = {0x804b160, 0x0 <repeats 63 times>},
}
This is something we know. In the first simple_tcache example, we saw this behavior.
We need to get the vis output and compare with pervious one.
pwndbg> vis
...
0x804b158 0x00000000 0x00000011 ........
0x804b160 0x00000000 0x0804b010 ........ <-- tcachebins[0x10][0/1]
0x804b168 0x00000000 0x00000011 ........
0x804b170 0x00000000 0x00000000 ........
0x804b178 0x00000000 0x00021e89 ........ <-- Top chunk
As we expected the chunk a is moved to tcache. We can see the tcache next pointer and key is in the chunk a's user data section.
Also note that chunk b is the adjacent chunk of b. But we have not change anything in there. The prev_size of chunk b is not still changed.
<label pos="top-left">Low memory</label> <label pos="top-right">High memory</label> <block size=1>...</block> <block size=4>Prev size</block> <block size=4>Size + Flags</block> <block size=24>User Data</block> <block size=4>Prev size</block> <block size=4>Size + Flags</block> <block size=24>User Data</block> <block size=12>Top Chunk</block> <block size=1>...</block> <ptr index=1>Chunk a starts here (0x804b158)</ptr> <ptr index=4>Chunk a ends & Chunk b starts here (0x804b168)</ptr> <ptr index=7>Chunk b ends here (0x804b178)</ptr>
Lets hit the third breakpoint. At this point we have called free(b).
pwndbg> tcache
tcache is pointing to: 0x804b010 for thread 1
{
counts = "\002", '\000' <repeats 62 times>,
entries = {0x804b170, 0x0 <repeats 63 times>},
}
pwndbg> vis
...
0x804b158 0x00000000 0x00000011 ........
0x804b160 0x00000000 0x0804b010 ........ <-- tcachebins[0x10][1/2]
0x804b168 0x00000000 0x00000011 ........
0x804b170 0x0804b160 0x0804b010 `....... <-- tcachebins[0x10][0/2]
0x804b178 0x00000000 0x00021e89 ........ <-- Top chunk
<label pos="top-left">Low memory</label> <label pos="top-right">High memory</label> <block size=1>...</block> <block size=4>Prev size</block> <block size=4>Size + Flags</block> <block size=24>User Data</block> <block size=4>Prev size</block> <block size=4>Size + Flags</block> <block size=24>User Data</block> <block size=12>Top Chunk</block> <block size=1>...</block> <ptr index=1>Chunk a starts here (0x804b158)</ptr> <ptr index=4>Chunk a ends & Chunk b starts here (0x804b168)</ptr> <ptr index=7>Chunk b ends here (0x804b178)</ptr>
next BP. After *(void **)a = target;
pwndbg> tcache
tcache is pointing to: 0x804b010 for thread 1
{
counts = "\002", '\000' <repeats 62 times>,
entries = {0x804b170, 0x0 <repeats 63 times>},
}
0x804b158 0x00000000 0x00000011 ........
0x804b160 0x00000000 0x0804b010 ........
0x804b168 0x00000000 0x00000011 ........
0x804b170 0x0804a028 0x0804b010 (....... <-- tcachebins[0x10][0/2]
0x804b178 0x00000000 0x00021e89 ........ <-- Top chunk
next BP. After malloc(c);
pwndbg> tcache
tcache is pointing to: 0x804b010 for thread 1
{
counts = "\001", '\000' <repeats 62 times>,
entries = {0x804a028 <target>, 0x0 <repeats 63 times>},
}
0x804b158 0x00000000 0x00000011 ........
0x804b160 0x00000000 0x0804b010 ........
0x804b168 0x00000000 0x00000011 ........
0x804b170 0x0804a028 0x00000000 (.......
0x804b178 0x00000000 0x00021e89 ........ <-- Top chunk
next BP. After malloc(d);
pwndbg> tcache
tcache is pointing to: 0x804b010 for thread 1
{
counts = '\000' <repeats 63 times>,
entries = {0x0 <repeats 64 times>},
}
0x804b158 0x00000000 0x00000011 ........
0x804b160 0x00000000 0x0804b010 ........
0x804b168 0x00000000 0x00000011 ........
0x804b170 0x0804a028 0x00000000 (.......
0x804b178 0x00000000 0x00021e89 ........ <-- Top chunk
Two details in this result are worth noting:
tcache_get never validated the target. There’s no chunk at 0x804a028, no size field, nothing heap-like. Tcache simply followed the pointer. Fastbins, by contrast, check that the fake chunk’s size field matches the bin, so fastbin attacks need you to find or forge a plausible size first. That’s why tcache poisoning became the go-to technique after 2.26.
The returned address isn’t 16-byte aligned. 0x804a028 ends in 8, but every legitimate chunk on your system returns addresses ending in 0. malloc handed it over anyway, which confirms there’s no alignment check (that arrived in 2.32 alongside safe-linking). On newer glibc this exact program would abort with malloc(): unaligned tcache chunk detected.