Skip to content

Instantly share code, notes, and snippets.

@moyix
Last active April 18, 2026 19:47
Show Gist options
  • Select an option

  • Save moyix/7271ab4536b6d8889e5474578f8ce3e0 to your computer and use it in GitHub Desktop.

Select an option

Save moyix/7271ab4536b6d8889e5474578f8ce3e0 to your computer and use it in GitHub Desktop.
IE5 Solaris SPARC vulns found by GPT-5.4 and a best-effort attempt to match to publicly known vulns

Bug Catalogue

This file is a self-contained snapshot of the bug families currently tracked in LEADS.md. It is meant to preserve what is actually known today: bug class, reachability story, supporting decompilation, and current status.

The snippets below are trimmed from the current decompiler corpus for readability. Variable names are Ghidra's unless otherwise noted.

1. libwininet.so: Gopher+ parser family

This is still the most important area. There are at least two real bug families here:

  1. the intended FUN_0004e1c8 Gopher+ attribute parser overwrite candidate
  2. a separate malformed-line bug in FUN_0004dacc that was accidentally confounding older +VIEWS repros

1.1 FUN_0004e1c8: shifted second-line caller-frame overwrite candidate

Current model:

  • FUN_0004e1c8 reads the first Gopher+ body line into a local stack buffer
  • it then keeps reading more lines through a movable write pointer local_408
  • if the first line is shaped so that local_408 ends up near the end of the local buffer, the next line read can spill out of the callee frame and into the caller frame
  • the caller's local_120 is the most important downstream target

The callee owns a 0x400-byte line buffer and reuses a movable pointer into it:

char local_48c [36];
char local_468 [92];
undefined4 local_40c;
char *local_408;
char local_404 [5];

local_4 = 0x400;
local_408 = local_404;
FUN_0004eba0(param_1,local_404,&local_4,param_1 + 0x20);
...
local_4 = 0x400;
FUN_0004eba0(param_1,local_408,&local_4,&local_40c);

The line reader itself copies attacker bytes until CR/LF and then forcibly appends \r\n\0:

for (...; ((cVar2 != '\r' && (cVar2 != '\n')) && (uVar7 != 0)) && (iVar5 != 0); ...) {
  *pcVar6 = *pcVar4;
  cVar2 = pcVar4[1];
  pcVar4 = pcVar4 + 1;
  pcVar6 = pcVar6 + 1;
}
...
if (cVar2 == '\n') {
  pcVar6[2] = '\0';
  *pcVar6 = '\r';
  pcVar4 = pcVar4 + 1;
  iVar5 = iVar5 + -1;
  pcVar6[1] = '\n';
  uVar7 = uVar7 - 3;
  pcVar6 = pcVar6 + 3;
}

That \r\n\0 matters. The current overwrite math is based on landing the second line so that bytes 0x3f8..0x3fb from that line hit caller local_120, while the forced terminator lands after it.

The caller really does use local_120 immediately after returning from FUN_0004e1c8:

FUN_0004c2b4(puVar6,0xff010102,iVar4,(local_c[0] & 0x80000000) != 0,2,&local_120);
...
if ((param_3 == 0) ||
   (puVar7 = local_120, FUN_0004e1c8(local_120,param_3), local_11c = puVar7,
   puVar7 == (undefined1 *)0x0)) {
  param_1 = *(size_t *)(local_120 + 0xc);
}
else {
  FUN_00050a20(local_120);
}

Why this is still the best lead:

  • HTTP 302 to gopher://... is confirmed live on the current workstation image
  • the browser reaches the attacker gopher server
  • live breakpoints confirmed that +INFO gets the current test shape past FUN_0004c2b4 and into live FUN_0004e1c8
  • the decompiler corpus is consistent with the caller/callee stack geometry that makes caller local_120 reachable

Current status:

  • still the top lead
  • no longer just "a save-area overwrite that later survives into abort"
  • the strongest current path is the cleaned +\r\n+INFO: route into a FUN_0004bdc8 / FUN_0004a5b8 saved-window overwrite, followed by a state flip that turns the copied %i* set into a live interrupted frame

1.1a Saved-window state flip into live FUN_0004a5b8 frame

The old window-map marker payload showed that the preserved %i0-%i7 half of the overwritten FUN_0004a5b8 save area is cleanly controllable. A later variant showed something stronger: one of those copied words changes how later exception handling consumes that saved window.

Starting from the marker block:

AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH
IIII JJJJ KKKK LLLL MMMM NNNN OOOO PPPP

patching only JJJJ = 1 changed the observed interrupted frame to:

ec03a690 ???????? (49494949, 1, 4b4b4b4b, 4c4c4c4c, 4d4d4d4d, 4e4e4e4e)

That means the copied saved %i0-%i5 values became the live arguments of the resumed FUN_0004a5b8 frame at live 0xec03a690, old address 0x4a690.

The first pass on this was mislocalized to old 0x3a634; the fresh JJJJ = 1 core fixes that. Its captured faulting PC is 0xec03a6b8, old 0x4a6b8, at:

lduw [i0+0x130], g2

The same core also captures the fault address 0x49494a79, exactly 0x49494949 + 0x130, which proves copied %i0 / IIII really became live i0.

This gives a much more concrete exploit plan than the original "later signal consumer" theory:

  1. set the copied state word JJJJ = 1
  2. make copied %i0 / IIII point 0x130 bytes before attacker-controlled stack memory
  3. keep copied %l2 / CCCC nonzero so the resumed frame skips the deeper helper path
  4. use the following ret; restore with attacker-controlled copied %i6/%i7 as the first real fake-frame pivot

The first direct probe for that corrected return path is tmp/gopher-j1-i0sig-ret0.bin, which points IIII to 0xebfc0170 so that i0 + 0x130 lands on the second controlled saved-window copy at 0xebfc02a0, keeps JJJJ = 1, and supplies a plausible next fake-frame pointer in OOOO.

Current status:

  • confirmed state flip into a live interrupted frame
  • corrected mapping: the resumed frame is FUN_0004a5b8, not the earlier temporary 0x3a634 guess
  • not yet proven all the way through a clean return from the resumed FUN_0004a5b8 frame

1.2 FUN_0004dacc: malformed gopher-line parser bug

This is a separate bug from the intended FUN_0004e1c8 overwrite. It is important because it explained a large amount of misleading crash behavior in earlier +VIEWS probes.

FUN_0004dacc assumes the line contains tab separators and does pointer arithmetic on the result of memchr(..., '\t', ...) without a null check:

sVar1 = strlen(param_1);
...
param_1 = param_1 + 1;
pvVar2 = memchr(param_1,9,sVar1 - 1);
sVar6 = (int)pvVar2 - (int)param_1;
if (param_3 != (void *)0x0) {
  if (*param_4 <= sVar6) {
    return ZEXT48(param_2) << 0x20;
  }
  memcpy(param_3,param_1,sVar6);
  *(undefined1 *)((int)param_3 + sVar6) = 0;
  *param_4 = sVar6;
}

FUN_0004c904 feeds raw gopher directory lines into that parser when synthesizing a URL:

memcpy(param_2,s_gopher____000e58e4,9);
...
FUN_0004dacc(param_1,auStack_830,0,0,auStack_934,&local_828,auStack_a38,&local_82c,&local_a3c,
             &local_a40);
...
memcpy((void *)((int)param_2 + 9),auStack_a38,local_82c);

Why this mattered dynamically:

  • a live trace on FUN_0004dacc showed it being called on:
    • normal menu lines such as 7\tfoo\t10.0.2.2\t70\t+\r\n
    • +INFO: 1test\tfoo\t10.0.2.2\t70\r\n
    • bare +VIEWS:\r\n
  • bare +VIEWS:\r\n has no tabs, so it goes straight into this malformed-line path
  • replacing the hardcoded bare header with a tab-compliant line such as +VIEWS: \tfoo\t10.0.2.2\t70\r\n stopped the immediate crash in a fresh broker-style VM

Current status:

  • confirmed real
  • currently a confounder to avoid, not the intended exploitation path

1.3 Post-corruption sink: FUN_00050a20

This is not a separate input bug, but it is an important downstream sink if lead 1.1 really gives control of caller local_120.

FUN_00050a20 contains a classic unlink-style write sequence on attacker-controlled fields:

if (param_1[6] == 0) {
  iVar3 = *param_1;
  piVar1 = (int *)param_1[1];
  *piVar1 = iVar3;
  *(int **)(iVar3 + 4) = piVar1;
  FUN_00049a84(param_1[9]);
  ...
  LocalFree(param_1);
}

If local_120 can be replaced with a fake object, this becomes a plausible write-what-where pivot or a fake-vtable setup step. Prior notes specifically called out DAT_000ea800 as a candidate downstream target because it is a globally used pointer that later dispatches through a vtable.

Current status:

  • exploitation-relevant
  • depends entirely on proving 1.1 at runtime

2. libjscript.so: borrowed / sparse Array.sort corruption path

This lead is real enough to keep near the top of the list, but it is less closed-out than the gopher path.

The current static anchor is FUN_0005e7a0, which allocates two temp arrays for sort bookkeeping, one of them as param_4 * 0x18 bytes:

__ptr = malloc(param_4 * 4);
if (__ptr == (undefined4 *)0x0) { ... }
__s = malloc(param_4 * 0x18);
if (__s == (uint *)0x0) { ... }
memset(__s,0,param_4 * 0x18);
FUN_00079df4(&local_20,__s,param_4,0x18,8);

The comparator-driven caller path does select this helper when a user-controlled compare function is present:

if (*param_5 == 0x4a) {
  piVar5 = *(int **)(param_5 + 4);
  ...
  piVar1[0x15] = piVar1[0x15] & 0xfffffffdU | 2;
  FUN_0005e7a0(param_1,piVar1,piVar5,local_30,local_3c);
  piVar1[0x15] = piVar1[0x15] & 0xfffffffd;
}

What the prior dynamic work established:

  • jssort.html?mode=borrow&count=23 consistently reached after-sort
  • jssort.html?mode=borrow&count=24 could crash or abort iexplorer
  • the no-comparator path also mattered; one prior run saved tmp/live-nocmp24-abrt.core
  • saved artifacts from the earlier session include:
    • tmp/ie-borrow24.core
    • tmp/ie-borrowobj24.core
    • tmp/ie-borrowstr24.core
    • tmp/jsgcprobe-postgc.core
    • tmp/live-nocmp24-abrt.core

The working model is not just "sort sometimes crashes". It is:

  1. trigger a sparse / borrowed sort-specific corruption condition
  2. preserve liveness at the 23-ish boundary long enough to groom adjacent heap state
  3. force GC and refill the damaged region with target objects

The unresolved part is the post-corruption target. The old session oscillated between:

  • allocator metadata poisoning
  • reuse of a freed 0x60 Array-like header or related JScript object

Current status:

  • strong dormant lead
  • still missing a fresh, end-to-end explanation of which object or allocator structure is actually getting corrupted

3. libshdocvw.so: SaveAs and parser-family bugs

This is the strongest parked non-gopher lead because the sinks themselves look real and easy to believe. The missing piece is final browser-visible reachability.

3.1 FUN_0025bfd8: SaveAs underwrite

This function temporarily zero-terminates the path four bytes before the file name component:

iVar2 = *(int *)(param_1 + 4);
PathFindFileNameW();
uVar6 = *(undefined4 *)(iVar2 + -4);
*(undefined4 *)(iVar2 + -4) = 0;
...
GetTempFileNameW(uVar1,iVar2,0,auStack_1000);
...
*(undefined4 *)(iVar2 + -4) = uVar6;

If PathFindFileNameW() returns a pointer to the first character of a bare filename, then *(iVar2 - 4) = 0 is an underwrite into the preceding heap cell, not an in-bounds temporary terminator inside the same string object.

Current status:

  • the sink looks real
  • what remains open is whether the browser-facing SaveAs path can hand this helper a leaf-only filename, or whether it always normalizes to a full path first

3.2 FUN_002af304: unbounded token copy into caller buffers

This helper skips whitespace and then copies either a quoted token or an unquoted token into the caller's buffer. The quoted path has a late bound check; the unquoted path does not.

Quoted-token copy:

if (param_2 + 0xffe <= piVar6) {
  return CONCAT44(param_2,0x80020009);
}
...
*piVar6 = *piVar5;
piVar6 = piVar6 + 1;

Unquoted-token copy:

do {
  if (iVar1 < 0xd) {
    if (iVar1 - 9U < 2) break;
  }
  else if ((iVar1 == 0xd) || (iVar1 == 0x20)) break;
  piVar2 = piVar5;
  CharNextW();
  *param_1 = piVar2;
  if (piVar5 < piVar2) {
    iVar1 = *piVar5;
    while( true ) {
      *piVar6 = iVar1;
      piVar5 = piVar5 + 1;
      piVar6 = piVar6 + 1;
      ...
    }
  }
  ...
} while (iVar1 != 0);

The important point is that the unquoted branch keeps advancing piVar6 without any length clamp of its own. The surrounding parser calls this helper repeatedly from large dispatcher functions such as FUN_002ae470, often with fixed stack buffers.

Current status:

  • good sink
  • exact JS-visible window.external binding still needs to be rediscovered cleanly

3.3 FUN_002af554: write-after-CoTaskMemRealloc stale pointer bug

This helper grows a token/output buffer, but when it calls CoTaskMemRealloc it does not reliably replace the local working pointer with the returned pointer before continuing to write:

if (local_c == local_8) {
  local_8 = local_8 << 1;
  CoTaskMemRealloc(local_4,iVar4);
}
...
*(int *)(local_4 + local_c * 4) = *piVar3;

and later again:

if (local_c == local_8) {
  local_8 = local_8 << 1;
  CoTaskMemRealloc(iVar4,iVar1);
  local_4 = iVar4;
}
*(int *)(local_4 + local_c * 4) = *piVar3;

If CoTaskMemRealloc moves the allocation, subsequent writes still go through the stale pointer held in local_4 / iVar4.

Current status:

  • good sink
  • same reachability problem as 3.2

4. libmshtml.so: print-path dispatch-object corruption

This path looks stronger than it first seemed because the vulnerable helper chain is definitely real and definitely reaches IDispatchEx-backed attacker-controlled properties.

The top-level print entry does call the internal print helper:

if (-1 < (int)piVar3) {
  piVar4 = piVar2;
  FUN_0024a694(piVar2,param_3,0,0x47,0);
}

Inside FUN_0024a694, the print settings helper FUN_00258708 is called on the objects it is building:

if (local_1038 == 0) {
  pppiVar16 = &local_1058;
  FUN_00258708(pppiVar16,0,param_1,1);
  if (pppiVar16 != (int ***)0x0) goto LAB_0024b130;
}
...
ppppiVar14 = (int ****)&local_1058;
FUN_00258708(ppppiVar14,local_50b4,param_1,0);

The property lookup wrapper FUN_00257ec0 explicitly queries IID_IDispatchEx and invokes a named property:

(**(code **)(*param_4 + 8))(param_4,&IID_IDispatchEx,&local_14);
if (((param_4 == (int *)0x0) &&
    (param_4 = local_14, (**(code **)(*local_14 + 0x24))(local_14,param_1,1,&local_18),
    param_4 == (int *)0x0)) &&
   ((param_4 = local_14,
    (**(code **)(*local_14 + 0x28))(local_14,local_18,0x400,2,&local_10,param_2,0,0),
    param_4 == (int *)0x0 && (*param_2 != param_3)))) {
  param_4 = (int *)&DAT_80070057;
}

FUN_00258708 then uses that wrapper to fetch printToFileOk and printToFileName, and copies the returned file name with an unbounded wcscpy into a fixed-offset field inside the print/settings object:

pwVar8 = u_printToFileOk_00533104;
FUN_00257ec0(u_printToFileOk_00533104,auStack_1118,0xb,local_4);
...
pwVar8 = u_printToFileName_0053313c;
FUN_00257ec0(u_printToFileName_0053313c,auStack_1118,8,local_4);
if ((pwVar8 == (wchar_t *)0x0) && (local_1110 != (wchar_t *)0x0)) {
  wcscpy(param_1 + 0xf,local_1110);
  ...
}

That is the core of the bug: attacker-controlled BSTR, retrieved through a dynamic dispatch object, copied into a fixed field with no length check.

Current status:

  • strong sink
  • still missing the cleanest proof of how script injects the malicious dispatch object into exactly this print pipeline on this Solaris build

5. libshdocvw.so: long-navigation bugs

5.1 HlinkFrameNavigateNHL: long URL + target name stack overwrite

The navigation dispatcher does pass attacker-controlled URL and target strings into HlinkFrameNavigateNHL:

if (param_6 == 0) {
  HlinkFrameNavigateNHL(piVar5,0,0,0,param_8,param_3);
}

Inside HlinkFrameNavigateNHL, the URL is copied into a fixed stack buffer and the target string is then copied starting at an offset derived from the URL length:

undefined1 auStack_3914 [2084];
undefined1 auStack_30f0 [8336];
...
StrCpyNW(auStack_30f0,param_5,0x824);
if (((param_6 != (int *)0x0) && (*param_6 != 0)) && (2 < 0x824U - iVar4)) {
  StrCpyNW(auStack_30f0 + iVar4 * 4,param_6,0x823 - iVar4);
}

The key problem is the unsigned arithmetic in 0x824U - iVar4. If the URL length iVar4 is already larger than 0x824, that subtraction underflows and the guard can still pass. The subsequent destination auStack_30f0 + iVar4 * 4 is then beyond the fixed stack buffer.

Current status:

  • strong static signal
  • current dynamic repros on the recovered image have been negative or flaky

5.2 FUN_0027abcc: hostname-only stack overflow

This helper copies the URL prefix up to UrlGetLocationW() into a fixed 2084-wchar stack buffer, but it uses the byte distance between pointers as the copy count:

int aiStack_2090 [2084];
...
StrCpyNW(aiStack_2090,param_2,
         ((int)(((int)piVar2 - (int)param_2) + ((int)piVar2 - (int)param_2 >> 0x1f & 3U)) >> 2)
         + 1);

If the hostname or pre-# portion is longer than the stack buffer, StrCpyNW will happily walk off the end.

Current status:

  • good static candidate
  • live repros have not yet yielded a clean crash on the current image

6. liburlmon.so: MIME / class-activation path

The earlier top-level Content-Type repro likely exercised the wrong entry point, but the underlying sinks are still very real.

6.1 FUN_00030594: Content-Type into 156-byte stack buffer

This helper converts a wide Content-Type string to multibyte and appends it to "MIME\\Database\\Content Type\\" inside a fixed 156-byte stack buffer:

undefined1 auStack_4ac [1024];
char acStack_a8 [156];
...
WideCharToMultiByte(0,0,param_1,0xffffffff,pcVar2,pcVar1,0,0);
...
strcpy(acStack_a8,s_MIME_Database_Content_Type__000e8c79);
strcat(acStack_a8,local_c);
RegOpenKeyExA(0x80000000,acStack_a8,0,1,&local_4);

That is a plain stack overflow if the converted MIME string is long enough.

Current status:

  • real sink
  • likely not the right path for ordinary top-level HTML navigation on this build, because the bind-layer cached type seems to get capped before this helper sees it

6.2 FUN_000901a8: same bug in the CoGetClassObjectFromURL path

There is a second copy of the same strcpy + strcat bug in the MIME-to-CLSID lookup path:

undefined1 auStack_1a8 [256];
char acStack_a4 [156];
...
if (*param_1 != '\0') {
  strcpy(acStack_a4,s_MIME_Database_Content_Type__000f5964);
  strcat(acStack_a4,param_1);
  RegOpenKeyExA(0x80000000,acStack_a4,0,1,&local_4);
  ...
}

The interesting reachability edge is that urlmon does feed multibyte MIME strings into this helper when resolving class activation:

WideCharToMultiByte(0,0,param_4,0xffffffff,auStack_400,0x400,0,0);
puVar7 = auStack_400;
if (iVar4 != 0) {
  FUN_000901a8(puVar7,param_2,0);
}

That makes the narrower current hypothesis:

  • the simple top-level /ct repro was probably wrong
  • the more interesting route is OBJECT / plugin / ActiveX class activation through CoGetClassObjectFromURL(szType) and its helpers

Current status:

  • still worth revisiting
  • likely the right way to reopen this family if gopher and JScript stall

7. Historical crash family: AutoScan / object-target

This lead is no longer just a vague remembered cluster. The supporting corpus was moved out of the main repo to avoid confusion and now lives at:

  • /Users/moyix/ie5-solaris-autoscan

7.1 What the probes were actually exercising

The central surface is window.external.AutoScan(search, failureUrl, targetName).

The moved lab pages show that the earlier work was systematically varying the named target object that AutoScan resolves:

  • a named iframe
  • a popup window
  • a popup that is immediately closed
  • an <object> with the browser control CLSID
  • reentrant helper actors that remove, replace, or navigate the target during the scan

The simplest target-lab page makes that explicit:

function go() {
  var mode = qp("mode", "iframe");
  var term = qp("term", "foo");
  var target = qp("target", "A");
  ...
  setup(mode);
  window.setTimeout(function () {
    try {
      b("autoscan-start-" + mode);
      var r = window.external.AutoScan(term, fail, target);
      b("autoscan-ret-" + mode + "-" + escape(String(r)));
    } catch (e) {
      b("autoscan-err-" + mode + "-" + escape(String(e && e.number ? e.number : e)));
      return;
    }
    b("autoscan-done-" + mode);
  }, 1000);
}

The object-spray harness shows the same call wrapped in heap grooming and target fabrication:

b("start-" + spray);
if (spray == "string") sprayStrings(tag, count, size);
else if (spray == "html") sprayHtml(tag, count, size);
else if (spray == "names") sprayNames(tag, count, size);
else if (spray == "objects") sprayObjects(tag, count, size);
...
setupTargets(kind, target, navurl, madeStage, madeRaw, copies, sameName, actionUrl, inputValue, textValue, imgSrc);
...
window.setTimeout(function () {
  ...
  var r = window.external.AutoScan(term, fail, target);
  ...
}, delay);

The reentrant harnesses confirm that one major hypothesis was time-of-check/time-of-use instability around the named target:

function fire() {
  beacon("start-0-" + target);
  try {
    var ret = window.external.AutoScan(search, failure, target);
    beacon("ret-0-" + escape(String(ret)));
  } catch (e) {
    beacon("err-0-" + escape(String(e && e.number ? e.number : e)));
    return;
  }
  beacon("done-0");
}

and the helper actor can remove or navigate the target while the scan is in flight.

7.2 What artifacts exist

The moved corpus contains:

  • browser/broker runs under /Users/moyix/ie5-solaris-autoscan/broker-work
  • HTML probes under /Users/moyix/ie5-solaris-autoscan/host-web
  • many recovered Solaris/SPARC cores under /Users/moyix/ie5-solaris-autoscan/tmp

The core names are already informative:

  • object-core-input1.elf, object-core-input2.elf, ...
  • object-core-form1.elf, object-core-form2.elf, ...
  • object-core-longurl1.elf, ...
  • object-core-target1.elf
  • popup-core.elf
  • warm-groom-object1.elf, warm-groom-input1.elf, ...
  • submission-input-*.elf

So the historical work was not just a single crash page. It was a deliberate matrix over target kind, naming collisions, warm-grooming, long URLs, and reentrant target mutation.

7.3 What the old core-analysis script says

The moved helper /Users/moyix/ie5-solaris-autoscan/tmp/analyze_object_core.py explicitly describes itself as:

"""Inspect the object-target AutoScan crash shape in a Solaris/SPARC core."""

It hard-codes a specific caller-frame depth and then inspects a caller local called local_4 plus the first dword reachable through it:

FRAME_INDEX = 8
...
auto_fp = chain[FRAME_INDEX][0]
...
local_4 = read_u32(segs, auto_fp - 4) or 0
first = read_u32(segs, local_4) or 0
print("auto_fp=%08x local_4=%08x first_dword=%08x" % (auto_fp, local_4, first))

That tells us the earlier work had already identified a stable crash shape in which some AutoScan caller frame local was the interesting object pointer to inspect.

7.4 What one recovered rollout had already shown

One recovered rollout analyzed object-core-input1.elf and found:

  • auto_fp=efff6ca0
  • local_4=001de948
  • first_dword=001ff240

and the memory at first_dword contained the beacon URL fragment associated with the target that had just been fabricated:

001ff240: 00 00 01 20 41 63 63 65 ...
001ff270: ... 00 00 00 62 00 00 00 6f 00 00 00 64
001ff280: 00 00 00 79 00 00 00 3d 00 00 00 6f 00 00 00 6b
001ff290: 00 00 00 26 00 00 00 73 00 00 00 74 00 00 00 61
001ff2a0: 00 00 00 67 00 00 00 65 00 00 00 3d 00 00 00 74
001ff2b0: 00 00 00 61 00 00 00 72 00 00 00 67 00 00 00 65
001ff2c0: 00 00 00 74 00 00 00 2d 00 00 00 6d 00 00 00 61
001ff2d0: 00 00 00 64 00 00 00 65 00 00 00 2d 00 00 00 69
001ff2e0: 00 00 00 6e 00 00 00 70 00 00 00 75 00 00 00 74
001ff2f0: 00 00 00 2d 00 00 00 49 00 00 00 4e 00 00 00 50
001ff300: 00 00 00 56 00 00 00 41 00 00 00 4c ...

That is not yet a root cause, but it does make the lead more concrete:

  • the crash family really was centered on AutoScan's resolution or use of named targets
  • at least some crashes were happening while dereferencing object state that still contained attacker-controlled target/beacon strings

7.5 What is still missing

I still do not have:

  • a localized decompilation sink inside the AutoScan implementation
  • a clean symbolic map from the analyzed FRAME_INDEX = 8 caller frame to a named function in the browser modules
  • a current exploitability ranking against the gopher, JScript, and SaveAs families

Current status:

  • definitely real enough to keep in the bug catalogue
  • materially better grounded than before now that the moved corpus is known
  • still behind gopher and JScript until the crashing module/function is re-identified

8. Other concrete static candidate: libmshtml.so IFont / BSTR stack overflow

This one is worth writing down because it is a straightforward stack overflow if the right COM object is instantiable in this build.

The helper queries IID_IFont, retrieves the font name BSTR, computes the string length, and then copies it into a 32-wchar stack buffer with that full length:

(**(code **)(*local_4 + 0x14))(local_4,&local_18);
...
memset(local_c0,0,0x9c);
__n = local_18;
FUN_0011804c();
wcsncpy(awStack_a4,local_18,(size_t)__n);

awStack_a4 is only 32 wide chars. If an attacker can supply an IFont object whose Name BSTR is longer than that, this becomes a direct stack overwrite.

Current status:

  • concrete sink
  • parked only because the right script-visible / ActiveX-visible route to an IFont provider on this Solaris build has not been re-validated yet

9. libwininet.so: FTP listing synthesis / directory HTML formatter stack overflows

This lead was originally found by the first libwininet URL survey and later reinforced by a more FTP-focused subagent. The important part was not "FTP parsing seems messy" in the abstract. The memorable bug family was that libwininet appears to synthesize HTML directory listings for ftp:// content with fixed stack buffers and wsprintfA, using attacker-controlled path and listing text.

There are two concrete sinks.

9.1 FUN_000c2b54: top-level FTP directory page synthesis

This helper parses the current URL/path, loads a localized format string, and then builds the page heading and outer HTML in stack buffers.

Relevant decompilation:

local_18 = strlen(local_8);
...
FUN_000bd7bc(local_135c,0,0,0,0,0,&local_1c,&local_20,0,0,0,0,0,&local_8,&local_18,0,0,0);
...
LoadStringA(DAT_000ea5e0,0x1d,local_1ec,200);
wsprintfA(local_ab8,local_1ec,local_8,auStack_124);
...
puVar3 = auStack_eb8;
wsprintfA(puVar3,s_<_DOCTYPE_HTML_PUBLIC_____IETF___000eb484,local_ab8,local_ab8);

The structure matters:

  • local_8 is the parsed URL path
  • auStack_124 holds another URL-derived string built earlier in the same helper
  • local_ab8 is a first-stage formatted string
  • auStack_eb8 is only a 1024-byte stack buffer, but it is populated from local_ab8 twice by a second wsprintfA

So the danger is not just one unchecked format into a fixed buffer. It is a two-stage expansion:

  1. attacker-controlled URL/path text is formatted into local_ab8
  2. that already-expanded string is then injected into the final HTML template in auStack_eb8

If the path or related URL components are long enough, the second wsprintfA is the cleaner-looking smash candidate.

9.2 FUN_000c3838: per-entry FTP listing row synthesis

This helper appears to synthesize one HTML row per listing entry. It first constructs a URL-ish path in a large local buffer and then formats the final anchor row into a fixed 2084-byte stack buffer.

Relevant decompilation:

sVar3 = strlen(param_2);
uVar1 = -(uint)(sVar3 < 0x823) & sVar3 - 0x823;
__n = uVar1 + 0x823;
memcpy(&uStack_82c,param_2,__n);
...
sVar3 = strlen((char *)puVar4);
uVar2 = 0x823 - __n;
sVar3 = (-(uint)(sVar3 < uVar2) & sVar3 - uVar2) + uVar2;
memcpy(auStack_82b + uVar1 + 0x822,puVar4,sVar3);
...
puVar6 = auStack_10c4;
wsprintfA(puVar6,pcVar9,auStack_890,pcVar7,&uStack_82c,param_3);
if ((undefined1 *)*param_5 < puVar6) {
  ...
  return CONCAT44(param_2,0x7a);
}
memcpy(param_4,auStack_10c4,(size_t)puVar6);

The late check is the key problem. Whatever size accounting *param_5 is supposed to provide happens after wsprintfA has already written into auStack_10c4.

The attacker-controlled ingredients are good enough for a real lead:

  • param_2: current directory path
  • puVar4 / appended text: listing-derived path component
  • param_3: listing-entry text used in the final row formatter

That gives an attacker two knobs:

  1. a long navigated ftp://attacker/<path>
  2. long server-controlled listing names inside the generated directory page

9.3 Trigger model and current status

The original trigger model was an attacker-controlled page that causes IE to navigate or embed ftp://attacker/<long-path>, for example with:

  • <iframe src="ftp://attacker/...">
  • direct navigation
  • an HTTP redirect into ftp://

Then the attacker FTP server supplies long path components and/or long entry names so the generated HTML listing overflows during synthesis.

There were also separate lower-level FTP findings in the old survey, including LIST parser oddities and a one-byte line-reader issue, but the two functions above are the remembered core of the lead because they are straightforward fixed-stack wsprintfA sinks.

Current status:

  • statically strong
  • not yet freshly revalidated on the recovered workstation image
  • still gated on whether ftp:// navigation from attacker-controlled HTML is practical enough in this build to make the sink worth promoting

10. libpngfilt.so + libimgutil.so: PNG width-overflow and dither-pipeline bugs

This lead came from the dedicated image subagent. It is actually two connected bugs on a very cheap trigger surface: normal image decode.

The recovered understanding is:

  1. libpngfilt trusts oversized PNG dimensions and computes row sizes with 32-bit arithmetic
  2. the decoded image then flows into libimgutil, where a dither/error-row allocation can also wrap on width

10.1 libpngfilt.so FUN_00024cc8: IHDR width-times-bpp wrap before row-buffer allocation

This helper copies image metadata into the live decode state, computes rowbytes, and allocates two row buffers from that size.

Relevant decompilation:

memcpy((void *)(param_1 + 0x84),(void *)(param_1 + 0x138),0x10);
...
iVar3 = *(int *)(param_1 + 0x84);
iVar2 = iVar3;
_umul(iVar3,*(undefined4 *)(param_1 + 0xc4));
uVar4 = iVar2 + 7U >> 3;
*(uint *)(param_1 + 0xbc) = uVar4;
...
iVar2 = uVar4 + 1;
__1c2N6FI_pv_();
*(int *)(param_1 + 0xb0) = iVar2;
...
iVar2 = *(int *)(param_1 + 0xbc) + 1;
__1c2N6FI_pv_();
*(int *)(param_1 + 0xac) = iVar2;

The exact decompiler output is a little rough, but the structure is clear:

  • width comes from the parsed PNG IHDR state copied to param_1 + 0x84
  • that width is multiplied by a per-pixel/bit-depth field at param_1 + 0xc4 using _umul
  • the result is converted into rowbytes
  • two row-sized heap buffers are then allocated from that truncated value

So an oversized PNG width can produce a tiny allocation followed by row processing that still assumes the true logical width.

10.2 libimgutil.so FUN_00022aec: (width + 2) * 0x18 dither-row allocation wrap

The downstream image path contains a second width-derived allocation bug in the RGB8/RGB24 conversion or dither setup helper.

Relevant decompilation:

if (iVar1 == 0) {
  *(undefined4 *)(param_1 + 0x40) = 0;
  *(undefined4 *)(param_1 + 0x38) = 0x18;
  *(uint *)(param_1 + 0x2c) = param_2;
}
...
__s = (void *)((param_2 + 2) * 0x18);
__1c2N6FI_pv_();
*(void **)(param_1 + 0x4c) = __s;
...
*(int *)(param_1 + 0x50) = (int)__s + 0xc;
*(void **)(param_1 + 0x54) = (void *)((int)__s + (*(int *)(param_1 + 0x2c) + 3) * 0xc);
memset(__s,0,(*(int *)(param_1 + 0x2c) + 2) * 0x18);
...
FUN_00020284(piVar2,-*(int *)(param_1 + 0x30),0,*(undefined4 *)(param_1 + 0x38),0,0,
             param_1 + 0x24,param_1 + 0x34);

Here param_2 is the width. The helper:

  • stores that width in param_1 + 0x2c
  • allocates ((width + 2) * 0x18) bytes
  • derives additional row pointers from the same wrapped base
  • clears the region with a matching wrapped memset
  • then proceeds into downstream image setup with the original logical width still live

That is a second clean 32-bit wrap candidate. Even if the PNG-side row allocation did not immediately yield a useful overwrite, the dither/setup path has its own width-dependent memory corruption story.

10.3 Normal image-decode reach

The reason this family is attractive is that it does not depend on odd browser features like ftp:// or Gopher+.

FUN_00024cc8 hands the decode state into another helper before returning:

FUN_000256b8(param_1,0);
iVar2 = param_1;
FUN_000239b8();

And FUN_000239b8 in turn dispatches into the downstream image sink:

piVar1 = (int *)param_1[0x1e];
(**(code **)(*piVar1 + 0x14))
          (piVar1,param_1[0x21],param_1[0x22],auStack_14,param_1[0x2e],5,&local_4);

So the intended trigger model is ordinary image loading, for example:

  • <img src="http://attacker/x.png">
  • CSS background images
  • any other browser path that hands a PNG to the normal decoder

Current status:

  • statically strong
  • not yet re-run with the upgraded live-debug workflow
  • attractive because trigger reach should be much cheaper than the FTP family and much less brittle than the Gopher+/navigation lines

IE5 Solaris Static-Analysis Leads vs. Public Historical Vulnerabilities

This file captures a best-effort mapping between the currently tracked IE5 Solaris bug leads and historically public Internet Explorer vulnerability disclosures.

It is not a claim that each Solaris bug is identical to the public Windows bug. In several cases, the mapping is to a bug family / attack surface analogue rather than a proven same-root-cause match.

Primary internal source notes:

  • LEADS.md
  • BUGS.md

Legend

  • High: strong same-family match in surface and bug shape
  • Medium: good family-level analogue, but likely a different concrete bug
  • Low: no convincing public match found yet, or only a very weak analogue

Revised matrix

Lead Bug shape from current notes Best public historical analogue Confidence Current read
libwininet Gopher+ shifted second-line overwrite Gopher+ parser uses a movable write pointer into a 0x400-byte stack buffer; a shaped first line can push the second line out of the callee frame and into caller local_120. MS02-047 / CAN-2002-0646 – Gopher protocol handler buffer overrun in IE. High Very likely the same broad public family. Strong family-level rediscovery, though not proven to be the identical sink Microsoft patched on Windows.
Gopher fake-unlink / fake-vtable follow-on (FUN_00050a20) Post-corruption sink: attacker-controlled fake object reaches an unlink-style write primitive and possible singleton/vtable pivot. No separate public bug; this is the exploitation continuation of the gopher parser family. High as follow-on Best understood as the exploit path after the public-family input bug, not a separately disclosed vulnerability.
libjscript borrowed / sparse Array.sort corruption Sort-specific temp-allocation / bookkeeping corruption around param_4 * 0x18 buffers, with a dynamic count=23 vs 24 boundary and signs that even no-comparator paths matter. Closest later analogue: CVE-2017-11907 (jscript.dll / Array.sort heap overflow). Related but different later family member: CVE-2020-0674 (Array.sort callback GC/UAF). Medium Looks like a real long-lived Array.sort bug family, but probably not the same issue as the later comparator/GC UAFs. Likely not publicly disclosed in this exact form.
libshdocvw SaveAs underwrite PathFindFileNameW() result is used such that *(iVar2 - 4) = 0 can underwrite before the filename component for a leaf-only filename. No good public match found. Low Strong sink, but still looks like a plausible silent fix / never-publicly-named bug.
window.external parser family (FUN_002af304, FUN_002af554) Unbounded unquoted-token copy into caller buffers, plus stale-pointer writes after CoTaskMemRealloc if the buffer moves. MS98-011 / CVE-1999-1093window.external long-string vulnerability. Medium Strong family match. Not proof the Solaris IE5 bug was publicly tracked under the same advisory, especially since Microsoft said IE 4.0 for UNIX (Solaris) was not affected by that 1998 bulletin.
libmshtml print-path dispatch-object corruption Print helper chain reaches IDispatchEx properties like printToFileOk / printToFileName, then copies attacker-controlled filename data with unbounded wcscpy. MS12-023 / CVE-2012-0168 – print feature RCE in IE. Medium-High Much stronger as a family match now. Clearly the print pipeline remained exploitable; probably not the same concrete bug, but the same broad surface.
libshdocvw long-navigation bug (HlinkFrameNavigateNHL) URL copied into fixed stack buffer, then target string copied at an offset derived from URL length; unsigned underflow in 0x824U - iVar4 can defeat the guard. MS06-042 / CVE-2006-3873 – long URL buffer overflow in IE. Medium-Low Good thematic match, but likely a different concrete bug in the same broad long-URL/navigation surface.
FUN_0027abcc hostname-only overflow Prefix up to UrlGetLocationW() copied into a fixed 2084-wchar stack buffer with a count derived from pointer byte distance. No clean public match beyond generic long-URL families. Low-Medium Could have been fixed as part of broader long-URL hardening, but no confident public analogue yet.
liburlmon MIME / class-activation path (FUN_00030594, FUN_000901a8) Wide Content-Type converted to multibyte and appended with strcat into a 156-byte stack buffer; second copy sits in MIME-to-CLSID / CoGetClassObjectFromURL path. MS03-020 / CAN-2003-0344 and MS03-040 / CAN-2003-0838, CAN-2003-0809 – object type / popup / XML data-binding crafted-response bugs. High Strong same-family match: crafted server-controlled type/object-resolution handling reaching stack corruption.
AutoScan / object-target crash family window.external.AutoScan(search, failureUrl, targetName) appears to crash while resolving or dereferencing named targets under reentrant or target-mutation conditions. Possible analogue: MS03-020 / MS03-040 object/popup/XML-databinding cluster. Low-Medium Definitely real enough to track, but still not localized enough to say whether it matches a known public object-tag-style bug or is a separate AutoScan bug.
libmshtml IFont / BSTR stack overflow IID_IFont is queried, Name BSTR length is computed, then copied into a 32-wchar stack buffer with full length. No good public match found. Low Concrete sink, weak public analogue. Good silent-fix / generic-cumulative candidate.
libwininet FTP listing synthesis / directory HTML formatter overflows Attacker-controlled FTP path and listing text flow through fixed-stack wsprintfA HTML-synthesis helpers for directory pages and per-entry rows. No convincing public match found. Low Statistically strong and very believable, but no clear public advisory match yet. Another good silent-fix / never-publicly-named candidate.
libpngfilt / libimgutil PNG width-overflow + dither-pipeline bugs Oversized PNG dimensions can wrap row-size math and allocations in libpngfilt; downstream dither/setup path can also wrap ((width + 2) * 0x18) allocation math in libimgutil. MS02-066 / CAN-2002-1185 – malformed PNG image file buffer overrun. High for PNG front end, Medium-Low for dither follow-on The PNG front-end almost certainly lives in the same public family; the dither-stage width wrap may be a sibling bug that was never independently called out.

Practical split

Probably public family / likely rediscovery

  • Gopher parser family
  • window.external parser family
  • URLMON object / MIME / class-activation family
  • PNG front-end width/parsing family
  • Print pipeline, but probably as a later-family analogue rather than a same-era disclosed bug

Probably silent / never publicly named

  • SaveAs underwrite
  • IFont / BSTR overflow
  • FTP listing synthesis / directory HTML formatter overflows
  • Possibly the exact AutoScan bug
  • Possibly the downstream libimgutil dither-width wrap even if the PNG front-end family was public

Interesting long-lived family, but not obviously the same bug

  • Array.sort
  • Long-navigation / long-URL helpers

Notes

  • These matches are strongest at the bug-family level, not necessarily the exact sink or code path.
  • The public record is heavily Windows-centric. The IE5 Solaris build may share family-level bugs with public Windows advisories without ever having had a Solaris-specific disclosure.
  • The strongest current “likely public rediscovery” cases are Gopher, object/MIME/class-activation, and PNG.
  • The strongest current “possibly silent / never named” cases are SaveAs underwrite, FTP listing synthesis, and IFont/BSTR.

Public references

  • MS02-047 – Cumulative Patch for Internet Explorer (includes Gopher handler issue)
  • MS98-011 – malformed window.external parameter handling
  • MS03-020 – IE Object Type Vulnerability
  • MS03-040 – cumulative IE update with popup / XML data-binding object-tag issues
  • MS06-042 – long URL buffer overflow
  • MS02-066 – malformed PNG image overrun
  • MS12-023 – IE print feature vulnerability
  • CVE-2017-11907 – Array.sort heap overflow family reference
  • CVE-2020-0674 – Array.sort callback/GC UAF family reference

Current Leads

This is the current state of the exploit hunt for the IE5 Solaris challenge. It is meant to be a working handoff document, not a polished report. Recovered rollout-only leads stay here even when the last reachability step is still missing, so this file acts as persistent memory instead of only a snapshot of the current favorite.

Current ranking

  1. Gopher+ FUN_0004e1c8 caller-frame overwrite in libwininet.so Status: best lead, parser entry is confirmed live, the cleaned long second-line overwrite is proven at runtime, and the FUN_0004bdc8-side save-area overwrite is now shown to provide deliberate eight-byte control; the open question is how that control turns into clean PC execution
  2. Gopher+ follow-on fake-unlink / fake-vtable pivot via poisoned local_120 Status: still viable if the same overwrite can be steered into FUN_0004ba80; currently secondary to the now-stronger FUN_0004bdc8 save-area pivot
  3. JScript borrowed / sparse Array.sort temp-buffer wrap Status: real dormant lead with dynamic crash evidence; still behind gopher because the corruption target and post-corruption primitive are not closed out
  4. libshdocvw SaveAs underwrite / window.external parser family Status: strong parked lead; the primitives look real, but exact browser-visible reachability is still incomplete
  5. mshtml print-path dispatch-object corruption Status: vulnerable helper path is live, but the attacker-controlled object injection route is still incomplete
  6. libshdocvw long-URL / long-target navigation bug Status: browser-visible long navigations are definitely reachable in both open and anchor mode, but the vulnerable StrCpyNW path now looks gated behind the wrong internal branch
  7. liburlmon MIME / class-activation path Status: the simple top-level Content-Type repro was likely the wrong path; OBJECT / ActiveX class activation is still open
  8. AutoScan / object-target crash family Status: real historical crash cluster, current root cause and exploitability not re-ranked yet
  9. Other static candidates Status: still interesting, but not yet the best place to spend time

SPARC-specific guidance

  • durable architecture notes now live in:
    • SPARC_EXPLOIT_NOTES.md
  • most important current implication:
    • the cleaned gopher overwrite now looks like real caller save-area corruption, and one copied state word (JJJJ) is strong enough to flip that saved %i* set into the live interrupted frame of a later libwininet function
    • that means the best gopher follow-ons are no longer just passive spill/fill consumers; the current top path is to make that resumed function return cleanly and then treat the poisoned %i6/%i7 pair as a standard SPARC fake-frame pivot
    • the FTP formatter family briefly looked more attractive under that model, but live validation found libgdiuser32!wvsprintfA hard-caps output at 0x400, so the old "multi-kilobyte current-frame smash" story is no longer credible

Gopher+ FUN_0004e1c8 caller-frame overwrite

Why it is still the best lead

  • The gopher path is the only lead with a concrete exploit model that already lines up with actual crash artifacts in tmp/.
  • The main idea is not "smash the callee return address directly". The more plausible path is:
    1. Use a first +VIEWS body line to move local_408 near the end of local_404.
    2. Let the next FUN_0004eba0 line read start at that shifted pointer.
    3. The second line then writes out of the callee frame and into the caller frame.
    4. Overwrite something meaningful in the caller frame or save area.
    5. On return, use that corruption either as the old local_120 / FUN_00050a20 fake-unlink path or as a smaller-caller save-area pivot.

Confirmed stack geometry

FUN_0004e1c8:

  • save sp, -0x558, sp
  • local_404 at [fp-0x404]
  • local_408 at [fp-0x408]
  • local_468 at [fp-0x468]
  • local_48c at [fp-0x48c]

FUN_0004ba80:

  • save sp, -0x190, sp
  • local_120 at [fp-0x120]

From the callee frame, the caller locals appear at positive offsets:

  • caller local_120 at current_fp + 0x70
  • caller local_11c at +0x74
  • caller local_118 at +0x78
  • caller local_114 at +0x7c

Important correction from the line reader

FUN_0004ed5c appends \r\n\0 when it finishes a line. That matters for the overwrite math.

The currently derived "shifted second-line" shape is:

  • first body line:
    • b" A B:" + b"X"*0x75 + b"<0>\r\n"
  • this puts the next-line write cursor at delta 0x7c from local_404
  • second-line content length:
    • 0x3fd
  • bytes 0x3f8..0x3fb of the second-line content land on caller local_120
  • the forced \r\n\0 lands on caller local_11c, not local_120

Confirmed caller behavior after FUN_0004e1c8

Disassembly around FUN_0004ba80 confirms:

  • call FUN_0004c2b4(..., &local_120)
  • call FUN_0004e1c8(local_120, param_3)
  • if return value is zero:
    • lduw [fp+-0x120], g3
    • lduw [g2+0xc], l2
    • effectively dereference local_120 + 0xc
  • if return value is nonzero:
    • call FUN_00050a20(local_120)

So a poisoned local_120 is definitely a real target.

Dynamic status

  • Existing gopher-related cores are present:
    • tmp/gopher-correct-recovered.core
    • tmp/gopher-secondline.core
    • tmp/gopher-gap320.core
    • tmp/gopher-local120test.core
    • tmp/gopher-gdbharness.core
  • Host gdb-multiarch cannot read these Solaris/SPARC cores directly.
  • The best inspection path is still guest-side pstack / pflags collected via tmp/inspect_submission_crash.py.
  • New workstation-image findings from the upgraded debug workflow:
    • plain HTTP 302 to gopher://10.0.2.2/... is confirmed live
    • the browser really does reach the attacker gopher server; a benign probe produced req=b'foo\\tbar\\t+\\r\\n'
    • the libwininet addresses in the decompiler corpus appear to be biased by +0x10000 relative to the live ELF / runtime
      • example:
        • gq: GopherFindFirstFileA @ 0x49e80
        • live ELF / nm / runtime: 0x39e80
      • practical correction:
        • old FUN_0004ba80 decompiler address 0x4ba80 corresponds to live 0x3ba80
        • old FUN_0004e1c8 decompiler address 0x4e1c8 corresponds to live 0x3e1c8
    • live adb disassembly confirmed:
      • FUN_0004ba80 at 0xec03ba80 with save sp, -0x190, sp
      • FUN_0004e1c8 at 0xec03e1c8 with save sp, -0x558, sp
  • +INFO is required for the current redirect/test shape to get past FUN_0004c2b4 and actually call the parser:
    • without +INFO, a breakpoint at live 0xec03bd54 showed FUN_0004c2b4 returning 0x2ee3, so the FUN_0004e1c8 call was skipped
    • with +INFO, the same breakpoint showed o0 = 0, and a direct breakpoint at live 0xec03e1c8 hit cleanly
  • The earlier "corrected +INFO payload" crash has now been narrowed:
    • a live trace on FUN_0004dacc showed the parser being called on:
      • 7\tfoo\t10.0.2.2\t70\t+\r\n
      • +INFO: 1test\tfoo\t10.0.2.2\t70\r\n
      • +VIEWS:\r\n
    • that means the bare +VIEWS:\r\n header itself is being reparsed by the older malformed-line path FUN_0004c904 -> FUN_0004dacc
    • this explains why many earlier +VIEWS-bearing probes crashed even when the body lines were tiny
  • tmp/gopher_server.py now has --views-line so the +VIEWS header can be replaced with a tab-compliant variant such as:
    • +VIEWS: \tfoo\t10.0.2.2\t70\r\n
  • A second important correction came from the cleaned repro work:
    • it is not enough for the +VIEWS header itself to contain tabs
    • the following +VIEWS body lines are still fed through the older menu parser machinery
    • so a body line without trailing tab-separated gopher fields can still die first in FUN_0004dacc
    • guest-side postmortem on the fresh cleaned crash recovered a saved fault PC at live 0xec03dc8c
      • this corresponds to old decompiler address 0x4dc8c
      • that instruction is inside FUN_0004dacc, at the second memchr(..., '\t', ...) on malformed input
  • With that tab-compliant +VIEWS header in a fresh broker-style VM:
    • a minimal body-line probe no longer crashed
    • the old long second-line shape (gap-len 117, extra line C*0x3fd) also no longer reproduced the browser death / core
  • The cleaned path is nevertheless definitely reachable:
    • a custom payload using
      • +INFO: 1test\tsel\t10.0.2.2\t70\r\n
      • +VIEWS: \tfoo\t10.0.2.2\t70\r\n
      • body line AAAAAAAA <1>\tfoo\t10.0.2.2\t70\r\n
    • hit the inner line-processing path under live tracing without crashing
    • tracing FUN_0004eba0 showed the body line arriving intact, including the trailing \tfoo\t10.0.2.2\t70
  • A later attach-first trace on the repaired workstation image changed the current understanding of the top exploit model:
    • live FUN_0004e1c8 at 0xec03e1c8 definitely hits on the broker-style redirect path
    • but inspecting the first argument at entry showed *(uint *)(param_1 + 0x1c) == 0
    • a breakpoint at live 0xec03e2d4 (the post-FUN_0004e7cc branch point) again showed i0 + 0x1c == 0
    • explicit breakpoints at live 0xec03e2f0 and 0xec03e304 never hit
    • so the post-+INFO loop previously assumed to drive the shifted-second-line overwrite is not running on the current live path
  • The reason is now understood more precisely:
    • inside live FUN_0004ba80, the callsite at 0xec03bd48 still shows the upstream sign-bit path active
      • o3 = 1
      • caller local flags at fp-0xc = 0x80000080
    • but the object returned through caller local_120 after FUN_0004c2b4 is a different heap object
    • tracing live 0xec03bd50 showed:
      • local_120 = 0x001b07e8
      • *(uint *)(local_120 + 0x1c) = 0
    • this resolves the apparent contradiction: the caller-side sign-bit path is active, but the returned object actually handed to FUN_0004e1c8 has +0x1c == 0
    • therefore the earlier shifted-second-line overwrite model is currently blocked on finding a URL / request shape that causes FUN_0004c2b4 to return an output object with that state bit set
  • That blocker is now only partially true: a more exact raw Gopher+ reply changed the result again.
    • using a raw reply that begins with the transport prelude +\r\n, then +INFO: ..., then a tab-compliant +VIEWS: ..., live 0xec03bd50 showed:
      • local_120 = 0x001e79d8
      • *(uint *)(local_120 + 0x1c) = 1
    • with the same +\r\n+INFO: ... prefix, a direct breakpoint at live 0xec03e2d4 showed i0 + 0x1c == 1
    • so the previously "dead" loop in FUN_0004e1c8 is in fact live when the response includes the explicit Gopher+ prelude, not just bare +INFO
    • practical lesson:
      • the response needs to start with +\r\n+INFO: ...
      • bare +INFO: ... is not sufficient to reproduce the good object state reliably
  • The better live trace point for the overwrite candidate is now FUN_0004ed5c, not FUN_0004eba0:
    • FUN_0004eba0 is influenced by recv/refill chunking
    • FUN_0004ed5c is the per-line helper and gives cleaner visibility into the logical first and second +VIEWS body lines
  • The current best overwrite-shape probe is therefore:
    • line 1: b" A B:" + b"X"*0x75 + b"<0>\tfoo\t10.0.2.2\t70\r\n"
    • line 2: b" D\tfoo\t10.0.2.2\t70\r\n"
    • this keeps the outer parser alive while still exercising the inner +VIEWS handling
  • Under the corrected +\r\n+INFO: ... prefix, live FUN_0004ed5c tracing recovered the real cleaned-path write geometry:
    • line 1 above shifts the second-line destination to local_404 + 0x83
    • that is, the second logical +VIEWS body line starts writing at *(char **)(fp-0x408) = local_404 + 0x83
    • if the caller-local target is still local_120, the naive content-length needed to land four controlled bytes there is 0x3f5 before the forced \r\n\0
  • The overwrite math itself is now confirmed, but on the wrong caller frame:
    • with tmp/gopher-overwrite-body.bin, a live breakpoint at 0xec03e30c showed the second-line hit with:
      • o1 = 0xebfc015f
      • bytes at fp+0x60 equal to foo\t10.0.2.2\t70\tAAAA\r\n\0
      • in particular, 0x41414141 landed at fp+0x70 and the forced \r\n\0 landed at fp+0x74
    • this proves the old 0x3f5 second-line content-length model was basically right for the frame that was actually executing
    • however, printing i7 on the same hits showed that every observed long-line execution so far is returning to 0xec03bde8, i.e. the secondary caller FUN_0004bdc8, not the FUN_0004ba80 callsite at 0xec03bd70
    • so the currently confirmed overwrite is across FUN_0004bdc8's tiny save sp, -96, sp caller frame / save area, not the previously assumed FUN_0004ba80 local_120 slot
    • consistent with that, a direct caller-side breakpoint at live 0xec03bd9c in FUN_0004ba80 still reloaded a valid local_120 from [fp-0x120] on the same shaped submission
    • the new task is therefore not "prove the second-line overwrite exists" but:
      • map what fp+0x60..+0x74 means for the FUN_0004bdc8 caller chain
      • determine whether the exploit should pivot through that smaller caller / save area instead of FUN_0004ba80
      • or find the request shape that makes the same long-line parser run under FUN_0004ba80
  • That caller-chain mapping is now much sharper.
    • Static frame sizes from the live runtime:
      • FUN_0004e1c8: save sp, -0x558, sp
      • FUN_0004bdc8: save sp, -0x60, sp
      • FUN_0004a5b8: save sp, -0x90, sp
      • InternetFindNextFileA: save sp, -0x78, sp
    • From the current FUN_0004e1c8 frame, this means:
      • current_fp + 0x60 is the sp of FUN_0004a5b8
      • current_fp + 0x98 and current_fp + 0x9c line up with the next caller save-area words for that chain, i.e. the slots that should become sp+0x38 and sp+0x3c in FUN_0004a5b8
  • A deliberate overwrite at those higher words is now proven.
    • With tmp/gopher-i6i7-control.bin, a live breakpoint at 0xec03e30c showed:
      • o1 = 0xebfc018b
      • bytes at fp+0x90:
        • foo\tbar\tBBBBCCCC
        • words at fp+0x98/+0x9c:
          • 0x42424242
          • 0x43434343
      • This is stronger than the earlier AAAA proof at fp+0x70: the cleaned gopher path now gives repeatable control of the final eight bytes in that smaller caller save area.
  • The save-area picture is now better than just "two trailing words".
    • tmp/gopher-window-map.bin laid down a full 16-word marker block:
      • AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH IIII JJJJ KKKK LLLL MMMM NNNN OOOO PPPP
    • the resulting postmortem in tmp/live-gopher-windowmap.core shows:
      • a preserved copy at 0xebfc0540, which is exactly the fp+0x60 region from the live overwrite stop
      • a second copy at 0xebfc02a0
    • the important dump is the 0xebfc0540 one:
      • sp+0x00..+0x1f (saved %l0-%l7) is only partially stable
        • AAAA BBBB CCCC 00000000 FFFG FFFF GGGG HHHH
      • sp+0x20..+0x3c (saved %i0-%i7) is clean:
        • IIII JJJJ KKKK LLLL MMMM NNNN OOOO PPPP
    • practical meaning:
      • the crashing thread's preserved FUN_0004a5b8 saved %i0-%i7 set is cleanly controllable
      • the %l* half is not clean enough to treat as a full fake frame yet
  • That saved-window control survives into later machinery in a more structured way than the earlier BBBBCCCC test suggested.
    • the same window-map postmortem showed:
      • _thrp_kill(..., 0x50505050) from the PPPP marker
      • an extra copy of JJJJ at 0xebfc0358
      • an extra copy of PPPP at 0xebfbffac
    • so the saved %i1 and %i7 values are being copied forward into later signal / abort-side structures, not merely sitting in dead stack memory
  • The no-debug postmortem path also preserves that control far enough to matter.
    • Running the same BBBBCCCC payload without the debugger produced a fresh guest core.
    • Guest-side dbx on /export/home/sol/core still ended in the abort chain, but one controlled word survived into the live thread teardown:
      • _thrp_kill(..., 0x43434343)
    • That does not yet prove direct %pc control, but it shows the save-area overwrite is not being fully scrubbed before later control-sensitive code consumes it.
  • A return-into-filler hypothesis is now better motivated than before.
    • tmp/gopher-ret2stack-nops.bin kept the same overwrite geometry, but filled the large body-line padding with aligned SPARC nop words and set the final eight bytes to:
      • guessed saved frame pointer 0xebfc0648
      • return slot 0xebfc0188, so a normal ret would nominally land at 0xebfc0190
    • The resulting crash was materially different from the plain BBBBCCCC case:
      • previous signature:
        • SIGBUS
        • Signal_Handler::raise(..., 0xebfc01d0, 0xebfc04c0)
        • _thrp_kill(..., 0x43434343)
      • NOP-filler signature:
        • SIGSEGV
        • Signal_Handler::raise(..., 0xebfc0150, 0xebfc04c0)
        • _thrp_kill(..., 0x859d8)
    • The important point is not that this already worked, but that changing only the filler bytes changed the original fault mode and fault address. The filler region is therefore likely participating in live control flow or another execution-adjacent path, not just passive overflow junk.
  • The recovered artifact set now preserves three concrete exploit hypotheses that should stay in rotation instead of being rediscovered later:
    • tmp/gopher-ret2stack-goodfp.bin
      • plausible saved %fp, stack landing at 0xebfc0190
    • tmp/gopher-ret2stack-ta8.bin
      • same geometry, but filler replaced with repeated ta 8 trap words as an execution discriminator
    • tmp/gopher-ret2stack-ta8-land0198.bin
      • same trap filler, but saved %i7 changed so a normal ret would nominally land at 0xebfc0198
    • companion oddball probes also exist:
      • tmp/gopher-ret2stack-ill-land0198.bin
      • tmp/gopher-i6valid-i7cccc.bin
      • tmp/gopher-ret2stack-goodfp-crash3.bin
      • tmp/gopher-ret2stack-ta8-crash3.bin
  • A later window-map refinement changed the exploit model again in a useful way.
    • Starting from the clean marker payload
      • AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH IIII JJJJ KKKK LLLL MMMM NNNN OOOO PPPP
      • patching only JJJJ = 1 produced a materially different crash than the ordinary marker case
    • Guest pstack on that run showed the interrupted frame itself as:
      • ec03a690 ???????? (49494949, 1, 4b4b4b4b, 4c4c4c4c, 4d4d4d4d, 4e4e4e4e)
    • This is the key observation:
      • the poisoned saved %i0-%i5 words no longer only survive into later abort-side copies
      • with JJJJ = 1, they become the live argument set of the interrupted FUN_0004a5b8 frame at live 0xec03a690
    • The first pass on this was mislocalized because of the live/decompiler +0x10000 bias.
      • the correct mapping is:
        • live 0xec03a690
        • old address 0x4a690
        • function FUN_0004a5b8
    • A fresh core from the same JJJJ = 1 family sharpens the actual fault site further:
      • ucontext->pc = 0xec03a6b8
      • old address 0x4a6b8
      • instruction:
        • lduw [i0+0x130], g2
      • captured fault address:
        • 0x49494a79
        • exactly 0x49494949 + 0x130
    • Practical consequence:
      • the crash with raw markers is expected, because copied %i0 / IIII = 0x49494949
      • the immediate exploit idea is now:
        • keep JJJJ = 1
        • set IIII so live i0 + 0x130 points into attacker-controlled stack memory
        • preserve CCCC != 0 so the resumed frame takes the l2 != 0 branch and skips the deeper helper at 0x52b30
        • then use copied %i6/%i7 as the first real fake-frame pivot when FUN_0004a5b8 returns
    • The first concrete payload in this direction is:
      • tmp/gopher-j1-i0sig-ret0.bin
      • patches:
        • IIII = 0xebfc0170
        • JJJJ = 1
        • OOOO = 0xebfc05d0
        • PPPP = 0
      • intent:
        • make i0 + 0x130 == 0xebfc02a0, the second controlled saved-window copy
        • keep CCCC nonzero so the resumed frame should avoid the deeper helper path
        • use PPPP = 0 as a discriminator: if the frame really returns, the next crash should move away from the old SIGBUS @ 0x49494a79 pattern and toward a near-0x8 post-return fault
    • The resulting postmortem still landed in the abort thread, so this payload is not yet proven.
      • What is proven is the more important architectural step:
        • JJJJ = 1 acts like a state / disposition bit that turns the saved-window overwrite into a live interrupted-frame register set
        • the gopher lead is therefore now a concrete "drive resumed FUN_0004a5b8 far enough to return, then pivot" problem, not only a vague signal-copy problem
  • A companion ucontext sanity check also clarified one earlier ambiguity.
    • A guest-side sigprobe confirmed the real Solaris ucontext_t layout on this image:
      • uc_mcontext.gregs starts at +0x28
      • uc_mcontext.gwins is the word at +0x74
    • Parsing tmp/live-gopher-windowmap.core on the host then showed:
      • the real signal-frame ucontext_t at 0xebfc0300
      • uc->uc_mcontext.gwins == 0
    • So the gwindows_t-looking blob at 0xebfc04c0 in prior notes is not the kernel ucontext's gwins pointer on this path.
    • This matters because it pushes the current exploit model away from "kernel will directly refill from that exact ucontext slot" and toward "MainWin / userland exception machinery is copying or reinterpreting the saved-window data later".
  • A second sub-lead in the same function is now re-opened too:
    • with the clean +\r\n+INFO: ... prefix, a direct first-token +VIEWS body line overflow (' ' + 'A'*200 + ' B:<0>...') is again reachable enough to be worth testing
    • this is the older agent04 observation about the unbounded copy into local_468[92]
    • current status:
      • a live breakpoint at 0xec03e390 with a 200-byte first token showed:
        • local_468 really starts at fp-0x468
        • the copied A bytes extend upward through local_40c / local_408 / local_404
        • but they do not reach the current frame's saved return state because the line reader caps a line at 0x400 bytes and fp - (fp-0x468) = 0x468
      • so the direct local_468 / local_48c bugs are real, but the old "direct %i7 smash in the current frame" story was too optimistic on SPARC
      • if they matter, it will be by corrupting parser locals or by crossing into a smaller caller frame, not by directly reaching FUN_0004e1c8's own saved return state
  • The gopher lead therefore remains real, but the previously recovered core was probably driven by the malformed bare-+VIEWS header, possibly mixed with the FUN_0004e1c8 path.
  • The next concrete step is no longer just "re-derive the geometry"; that part is done.
    • Immediate runtime tasks:
      • recover the original untouched values at fp+0x98/+0x9c using the same shifted first line but a short second line
      • identify exactly which saved-register pair those two words correspond to in the FUN_0004bdc8 -> FUN_0004a5b8 -> InternetFindNextFileA chain
      • run a more discriminating filler-execution probe, ideally with a deliberate trap pattern rather than plain nops
      • if filler execution is confirmed, replace the filler with a real SPARC payload that runs /usr/local/bin/dispense_flag
  • A big part of the pain has been repro plumbing rather than the static model:
    • full fresh-guest runs are slow
    • host logging was sometimes buffered
    • one-shot port-70 listeners were not always hit during the runs that were attempted

Current judgment

This remains the primary lead, but the current blocker is sharper than before.

The static case is still strong, scheme reach is confirmed, parser entry is confirmed live, and the new workstation workflow makes it practical to instrument the real path instead of relying on fresh-guest churn.

What is still missing is not just "clean overwrite math after removing the malformed-line bug". The sharper current picture is:

  • the bare +VIEWS crash is now understood well enough to avoid
  • the same malformed-line bug also applies to tabless +VIEWS body lines, and that is now understood too
  • the bigger remaining question is architectural, not parser-level:
    • does this save-area overwrite become live only on a later spill/fill/signal path, or can it still be steered into a more immediate caller-local object target?
  • a cleaned body line with appended gopher fields does reach the inner parser
  • the explicit Gopher+ transport prelude +\r\n restores the good object state:
    • local_120 + 0x1c == 1
    • live 0xec03e2d4 sees the loop gate set
  • the same cleaned path now gives deliberate two-word control at fp+0x98/+0x9c, not just a one-word marker at fp+0x70
  • a no-debug core with BBBBCCCC preserves 0x43434343 into _thrp_kill, so the corruption survives past the immediate parser frame
  • a full saved-window marker probe now shows that the preserved %i0-%i7 half of the FUN_0004a5b8 save area is cleanly controllable, and that copied %i1 / %i7 values survive into later signal / teardown structures
  • setting copied %i1 / JJJJ to 1 is now known to flip the poisoned %i0-%i5 set into the live interrupted arguments of FUN_0004a5b8 at 0xec03a690
  • the fresh JJJJ = 1 core shows the faulting resumed instruction as:
    • live 0xec03a6b8
    • old 0x4a6b8
    • lduw [i0+0x130], g2
  • the captured fault address 0x49494a79 proves copied %i0 / IIII really became live i0
  • the immediate exploit subproblem is therefore:
    • choose IIII / live i0 so i0 + 0x130 points into attacker-controlled stack memory
    • preserve CCCC / live l2 as nonzero so the resumed frame avoids the deeper helper path
    • then use attacker-controlled %i6/%i7 from the same preserved window as the first real fake-frame pivot
  • a NOP-filled ret-to-stack attempt changes the original fault from SIGBUS to SIGSEGV, which is strong evidence that the filler region is execution-relevant
  • so the top remaining uncertainty is not whether the cleaned overwrite exists, but exactly which saved-register pair is being replaced and whether the best exploit path is direct return-to-filler or a nearby register/state pivot

So the next gopher tasks are:

  • keep using the transport prelude +\r\n+INFO: ... in all serious Gopher+ probes
  • keep the JJJJ = 1 state flip in the mainline tests
  • use the new saved-window result to test whole-context pivots, not just saved %fp / saved %i7
  • drive resumed FUN_0004a5b8 past lduw [i0+0x130], g2 by controlling IIII
  • preserve CCCC != 0 so the resumed frame keeps the l2 != 0 shortcut and avoids the deeper helper at 0x52b30
  • once that return path is proven, replace the temporary stack landing with a short ret2libc target such as system("/usr/local/bin/dispense_flag")
  • only after that, re-test the direct local_468 / local_48c token-copy overflows as a side path

That ret2libc milestone is now reached.

  • Guest pmap on a benign live browser showed:
    • libwininet.so exec base 0xec000000
    • libc.so.1 exec base 0xeef80000
  • Host-side ELF inspection on binaries/ie5-solaris/rootfs/usr/lib/libc.so.1 showed:
    • system offset 0x68b3c
    • so absolute live system is 0xeefe8b3c
    • and the saved-return slot for a normal SPARC ret landing at system entry is system - 8 == 0xeefe8b34
  • The working ret2libc family is:
    • tmp/gopher-j1-ret2system.bin
    • tmp/gopher-j1-ret2system-o0.bin
    • tmp/gopher-j1-ret2system-o0str.bin
    • tmp/gopher-j1-ret2system-o0x59.bin
  • The strongest proof came from a live breakpoint on 0xeefe8b3c during the exploit run with tmp/gopher-j1-ret2system-o0x59.bin:
    • o0 = 0xebfc0101
    • sp = 0xebfc05d0
    • o7 = 0xeefe8b34
    • memory at 0xebfc0101 contained:
      • "/usr/local/bin/dispense_flag"
  • Practical meaning:
    • the gopher overwrite plus the JJJJ = 1 resumed-frame path is no longer just a "pivot candidate"
    • it already reaches system("/usr/local/bin/dispense_flag") with a valid command pointer and the expected SPARC return convention
  • The immediate remaining uncertainty is operational, not exploitability:
    • local debug runs did not create /tmp/ancients_solaris_broker/flag.txt
    • but the checked-out broker code in broker/broker.py provisions /tmp/ancients_solaris_broker as root-owned 0755 and launches the browser as user sol
    • that means the local helper path can fail to leave a marker even when the exploit has already reached system("/usr/local/bin/dispense_flag")
    • the real broker instance the user restarted may or may not have the same permission layout, so the decisive next step is a real submission against http://127.0.0.1:8080
  • That decisive broker submission has now been run twice.
    • First run:
      • job_id = 20260418-104247-69ce386b
      • result: success: false, "browser ran, but dispense_flag was not executed in time"
      • postmortem: the host raw gopher server had died before the guest reached port 70, so this run is not meaningful for exploit reach
    • Second run:
      • job_id = 20260418-104519-2d742526
      • result: success: false, "browser ran, but dispense_flag was not executed in time"
      • this run did hit the host raw gopher server with:
        • req=b'foo\tbar\t+\r\n'
        • sent 1264 bytes
      • so the real broker guest definitely reached the intended gopher delivery path and consumed tmp/gopher-j1-ret2system-o0x59.bin
    • Current best interpretation:
      • the exploit path itself is likely solved or very close, because the same payload already broke at live system("/usr/local/bin/dispense_flag") in the debug guest
      • the remaining blocker is now most likely the broker provisioning bug above, not lack of reach to the attacker server
      • unless the broker is restarted with /tmp/ancients_solaris_broker writable by sol, the current service cannot reliably award the flag even if /usr/local/bin/dispense_flag is executed
    • That broker bug is now confirmed and locally fixed:
      • patch:
        • in _provision_guest, change /tmp/ancients_solaris_broker ownership to browser_user before the chmod 0755
        • file: broker/broker.py
      • rationale:
        • dispense_flag.sh runs as the browser user sol
        • it must create /tmp/ancients_solaris_broker/flag.txt
        • with a root-owned 0755 directory, success is impossible
      • validation:
        • after the fix, a full fresh local run with a synthetic flag succeeded using the exact same exploit URL and payload:
          • command:
            • BROKER_FLAG='flag{test-fixed}' python3 broker/broker.py run 'http://10.0.2.2/static/gopher-delay.html?d=3000'
          • result:
            • job_id = 20260418-105455-012a3fef
            • success: true
            • message: "exploit succeeded"
            • flag: "flag{test-fixed}"
        • host gopher delivery on that successful run still matched the expected request:
          • req=b'foo\tbar\t+\r\n'
          • sent 1264 bytes
      • practical meaning:
        • the exploit chain is now effectively solved end-to-end in the fixed local broker
        • once the real :8080 service is restarted with this provisioning fix and the real flag, the same submission should return the actual flag
    • That final live-service run is now complete too:
      • broker service:
        • restarted on 127.0.0.1:8080 with the real flag after the provisioning fix
      • submission:
        • POST /submit
        • url=http://10.0.2.2/static/gopher-delay.html?d=3000
      • host gopher delivery:
        • request:
          • req=b'foo\tbar\t+\r\n'
        • payload:
          • tmp/gopher-j1-ret2system-o0x59.bin
          • sent 1264 bytes
      • broker result:
        • job_id = 20260418-110005-f6868221
        • success: true
        • message: "exploit succeeded"
        • returned flag:
          • flag{5un_1nt3rn37_3xpl0d3r}
    • Current state:
      • challenge solved
      • exploit path confirmed both in the fixed standalone runner and in the live HTTP broker service

The gopher family nevertheless remains the best lead because:

  • scheme reach is confirmed
  • parser entry is confirmed live
  • there are at least two real bug families in this area
  • the new debugging stack can now distinguish them at runtime
  • the most important "state-seeding" blocker was actually the missing Gopher+ prelude, which is now understood

Gopher follow-on primitive via FUN_00050a20

Why it matters

If lead 1 gives control of caller local_120 and the caller takes the nonzero return path, FUN_00050a20(local_120) gives a fake-object unlink-style primitive.

Relevant behavior in FUN_00050a20:

  • no null guard
  • uses fields inside param_1
  • if param_1[6] == 0:
    • iVar3 = *param_1
    • piVar1 = (int *)param_1[1]
    • *piVar1 = iVar3
    • *(int **)(iVar3 + 4) = piVar1

That is a real write-what-where-style unlink pattern if local_120 points to attacker-controlled fake data.

Candidate overwrite target already identified

  • DAT_000ea800 in libwininet
  • runtime address used in prior notes:
    • 0xEC0EA800

Rationale:

  • It is a global singleton-like pointer that is later used through a vtable.
  • Prior analysis identified calls that eventually dispatch via vtable + 0x20.
  • If we can redirect DAT_000ea800 to a fake object in attacker-controlled memory, that should be a reasonable code-exec pivot.

Status

Dependent on lead 1.

This is a good exploitation direction, not yet a standalone trigger.

libshdocvw long-URL / long-target navigation

Static findings from the survey:

  • HlinkFrameNavigateNHL at 0x17d684
  • signed/unsigned wrap in a wide-char stack write path
  • related hostname-only stack bug in FUN_0027abcc

Live probes already tried

Files used:

  • host-web/navprobe-long.html
  • host-web/navprobe-userinfo.html
  • host-web/navprobe-host.html

Observed results on the recovered image:

  • navprobe-long.html with long URL and target:
    • long request was emitted
    • browser reported open-ok
    • no crash
  • navprobe-long.html in anchor mode with ulen=3116, tlen=128, and base=http://10.0.2.2/:
    • the page reached before-click
    • the browser emitted the long GET /AAAA...
    • the page reached after-click
    • no crash
  • calibrated ulen=3123 tests:
    • still no crash in either open or anchor mode
  • navprobe-userinfo.html:
    • stalled after before-open
    • no crash
  • navprobe-host.html:
    • stalled after before-open
    • no crash

Sharper explanation from live tracing

  • A live breakpoint on HlinkFrameNavigateNHL confirms the browser-visible path reaches the function.
  • The dangerous stack-copy sequence is in the param_1 == 0 arm:
    • StrCpyNW(fp-0x30f0 + i0*4, i5, 0x823 - i0)
  • The only internal caller recovered so far appears to pass a nonzero first argument.
  • That fits the current runtime picture:
    • window.open(url, target) and anchor-target navigation both reach HlinkFrameNavigateNHL
    • both emit the long navigation
    • neither has produced the stack overwrite
  • Working interpretation:
    • the browser-visible paths we have so far are taking the nonzero-param_1 COM path rather than the vulnerable param_1 == 0 branch

Status

Still a real static candidate, but it has lost priority because the recovered-image runtime picture now looks like "reachable function, wrong branch" rather than "mysterious flaky repro."

If the gopher path stalls out completely, this is still worth revisiting.

liburlmon MIME / class-activation path

Static finding from the survey:

  • FUN_00030594 / FUN_000901a8
  • attacker-controlled Content-Type eventually reaches a strcat into a 156-byte stack buffer

Live probe already tried

  • URL used:
    • http://10.0.2.2/ct?n=300
  • host helper:
    • scripts/ctf_http_server.py
  • result on the recovered image:
    • request reached the server
    • no crash
    • IE stayed alive until timeout

Status

Static case still looks good, but the earlier top-level /ct repro was probably testing the wrong entry path.

The recovered rollout notes suggest normal top-level navigation likely caps the bind object's cached wide Content-Type around 127 wide chars before FUN_00030594 sees it. The more interesting remaining path is OBJECT / plugin / ActiveX class activation through CoGetClassObjectFromURL(szType), not ordinary page navigation.

JScript borrowed / sparse Array.sort temp-buffer wrap

This lead came from the prior Codex session's own subagents and dynamic work, not just the external tmp/corruption-hunt survey.

Why it is real

  • The old session treated libjscript FUN_0005e7a0 as the key temp-allocation site in the borrowed-sort path.
  • Multiple saved cores exist from that work:
    • tmp/ie-borrow24.core
    • tmp/ie-borrowobj24.core
    • tmp/ie-borrowstr24.core
    • tmp/jsgcprobe-postgc.core
    • tmp/live-nocmp24-abrt.core
  • Dynamic sweeps in the prior session found a meaningful boundary:
    • jssort.html?mode=borrow&count=23 reached after-sort
    • jssort.html?mode=borrow&count=24 could crash or abort iexplorer
  • The prior session explicitly described this as a borrowed / sparse Array.sort bug around wrapped temp-allocation math for the 0x18-byte sort records.

What the old dynamic work established

  • There was a consistent "alive but probably corrupted" boundary at count=23 versus count=24.
  • The follow-on pages were not just one-shot crash probes. The session also built post-sort harnesses around:
    • host-web/jsgcmini.html
    • host-web/jsgcprobe.html
    • host-web/jsarraygroom.html
    • host-web/jsarraytarget.html
  • The no-comparator path was also pursued, with jsgcmini.html?mode=borrow&count=24&kind=arr&n=128&esize=1&gc=2&postdelay=50&usecmp=0 producing the saved abort core tmp/live-nocmp24-abrt.core.
  • A first grooming sweep in the prior session found a real discriminator:
    • XMLHTTP victims with mode=borrow or mode=borrowstr died fast at count=23
    • XMLDOM and borrowobj variants stayed alive

Current model

  • The working idea was not "sort crashes sometimes". It was:
    1. trigger the borrowed / sparse Array.sort temp-buffer wrap in libjscript
    2. use the count=23 boundary to keep execution alive long enough to groom or observe adjacent heap state
    3. drop references, run CollectGarbage(), and then allocate target objects to consume the corrupted region
  • The prior session then split into two sub-questions:
    • whether the immediate post-buffer corruption was mostly allocator metadata poisoning
    • or whether the more useful live target was a higher-level JScript object, especially an Array-related header

What is still unresolved

  • The exact ownership of the chunks immediately after the wrapped temp buffer was never fully closed out.
  • The later working hypothesis shifted toward reuse of a freed 0x60 Array-like header rather than only small-bin allocator metadata, but that was not validated into a full primitive.
  • Solaris allocator behavior complicates the CollectGarbage() plan because recent frees are not always immediately recycled into the next allocation.
  • Broker-style runs for the jssort / jsgcmini family did not turn into a clean end-to-end solve during the earlier round.

Current judgment

This is stronger than a speculative static candidate and should stay in the lead list.

It is still behind the gopher path because the gopher path has a tighter caller/callee corruption story, while the JScript path still needs the exact adjacency and post-corruption consumption path pinned down.

Recovered parked leads from rollout logs

These were solid enough to preserve in the lead document even though they were not fully closed out during the prior round.

libshdocvw SaveAs underwrite / window.external parser family

  • FUN_0025bfd8 was treated in the rollout logs as a real underwrite primitive:
    • it temporarily zero-terminates four bytes before the filename component
    • a bare filename would therefore clobber memory immediately before the heap string
  • The same analysis also flagged two sibling parser bugs:
    • FUN_002af304: unbounded unquoted-token copy into fixed stack buffers
    • FUN_002af554: write-after-CoTaskMemRealloc stale-pointer writes if the buffer moves
  • Why this was parked:
    • the last reachability step was not closed
    • for SaveAs, it was not proven whether the browser-facing path can hand FUN_0025bfd8 a leaf-only filename instead of a normalized full path
    • for the parser-family bugs, the exact JS-visible window.external binding name was not recovered from the analyzed slice
  • Reopen order:
    • first parked lead to revisit if gopher or JScript stall

mshtml print-path dispatch-object corruption

  • Prior rollout notes say the vulnerable helpers definitely invoke IDispatchEx::InvokeEx on attacker-controlled property names:
    • printToFileOk
    • printToFileName
  • The same notes traced the live path as:
    • PrintHTML -> FUN_0024a694 -> FUN_00258708
  • Why this was parked:
    • the helper path looked live, but the route for script to supply the malicious dispatch object into the print pipeline was not finished
  • Reopen order:
    • second parked lead after SaveAs/parser

AutoScan / object-target crash family

  • The supporting corpus was moved out of the main repo and currently lives at:
    • /Users/moyix/ie5-solaris-autoscan
  • That corpus contains:
    • broker runs under /Users/moyix/ie5-solaris-autoscan/broker-work
    • probes under /Users/moyix/ie5-solaris-autoscan/host-web
    • cores and helpers under /Users/moyix/ie5-solaris-autoscan/tmp
  • The actual surface being exercised is window.external.AutoScan(search, failureUrl, targetName).
  • The moved harnesses show the old work was systematically varying the named target resolved by AutoScan:
    • iframe targets
    • popup targets
    • popup-close races
    • <object> browser-control targets
    • reentrant helper actors that remove, replace, or navigate the target during the scan
  • The moved autoscan-object-spray.html also confirms this family was paired with heap grooming / target fabrication rather than being a one-off smoke test.
  • The moved analyze_object_core.py explicitly describes itself as inspecting the "object-target AutoScan crash shape" in a Solaris/SPARC core and hard-codes a specific caller-frame depth to inspect local_4 plus the first dword reachable through it.
  • One recovered rollout analyzing object-core-input1.elf found:
    • auto_fp=efff6ca0
    • local_4=001de948
    • first_dword=001ff240
    • memory at first_dword still contained the beacon URL/string for the fabricated target (stage=target-made-input-...INPVAL...)
  • Why this was parked:
    • it was eclipsed by better-understood gopher and JScript leads
    • Solaris/SPARC core inspection was cumbersome enough that the crash family was never cleanly re-ranked into a current primitive
  • Status:
    • not disproven
    • now better grounded than before, but still needs root-cause reconstruction and a fresh exploitability ranking

Other static candidates worth remembering

These have not been the main focus of dynamic testing yet:

  • FTP listing synthesis in libwininet
    • recovered from the original libwininet URL survey and the later FTP/chunked subagent
    • FUN_000c2b54 builds the directory page heading with wsprintfA(local_ab8, format, local_8, auStack_124) and then expands that again into a smaller stack HTML buffer with wsprintfA(auStack_eb8, html_template, local_ab8, local_ab8)
    • FUN_000c3838 builds per-entry rows with wsprintfA(auStack_10c4, pcVar9, auStack_890, pcVar7, &uStack_82c, param_3), and the size check happens only after the format call
    • attacker model: ftp://attacker/<long-path> via iframe / redirect / direct nav, plus long server-controlled entry names in the listing
    • recovered-image runtime check:
      • real IE ftp:// navigation is confirmed live
      • the guest performs USER / PASS / CWD / TYPE A / PASV / LIST against the host FTP server
      • live tracing also hit FUN_000c2b54
    • important correction:
      • the old exploit model assumed a huge uncontrolled wsprintfA stack overflow
      • live decomp of libgdiuser32!wvsprintfA shows an explicit 0x400 output cap
      • that makes the old "multi-kilobyte current-frame smash" story effectively dead
    • current status:
      • still reachable and still worth remembering for truncation / termination edge cases
      • no longer one of the top exploitation bets
  • PNG / image pipeline
    • recovered from the dedicated image subagent
    • libpngfilt FUN_00024cc8: IHDR width times pixel-depth arithmetic can wrap before row-buffer allocation
    • libimgutil FUN_00022aec: (width + 2) * 0x18 dither-row allocation and memset can wrap on the same logical width
    • libpngfilt FUN_000239b8 is the normal bridge from the PNG decoder into the downstream image sink, so this is not a weird side path
    • attacker model: ordinary <img src> / CSS image / decoder-driven fetches
    • status: strong static fallback with a cheap trigger surface; worth promoting if gopher and JScript stall
  • IFont / BSTR path in libmshtml
    • promising if the right COM object is instantiable in this build

Infrastructure and repro notes

Canonical image and session model

Use the workstation image and the validated persistent debug harness:

  • image:
    • assets/sparc.qcow2.workstation-20260417
  • primary workflow:
    • python3 scripts/ie_debug_session.py start
    • python3 scripts/ie_debug_session.py ensure-x
    • python3 scripts/ie_debug_session.py launch http://10.0.2.2/static/debug-loop.html
    • python3 scripts/ie_debug_session.py inspect

Important implications:

  • the broker now uses the same workstation base image and the same queue-driven sol OpenWindows session model
  • olwm is required for reliable network behavior, but ensure-x now handles that on a persistent guest
  • the control plane is telnet/serial-style guest commands, not GUI typing
  • persistent_ie_probe.py now uses the same queued-launch session model, so it is again safe for one-shot checks

Host services

  • port 80 helper:
    • scripts/ctf_http_server.py
  • port 70:
    • attacker-controlled gopher server

If host logs matter, run the HTTP helper unbuffered or with explicit flushing. Buffered file output made some earlier "no request seen" conclusions weaker than they should have been.

Guest-side inspection

  • baseline live workflow:
    • python3 scripts/ie_debug_session.py inspect
    • python3 scripts/ie_debug_session.py guest "/usr/proc/bin/pstack $(python3 scripts/ie_debug_session.py pid)"
    • python3 scripts/ie_debug_session.py guest "/usr/proc/bin/pflags $(python3 scripts/ie_debug_session.py pid)"
    • python3 scripts/ie_debug_session.py guest "/usr/proc/bin/pmap $(python3 scripts/ie_debug_session.py pid)"
    • python3 scripts/ie_debug_session.py guest "/usr/proc/bin/pldd $(python3 scripts/ie_debug_session.py pid)"
  • postmortem snapshot:
    • python3 scripts/ie_debug_session.py gcore
    • python3 scripts/ie_debug_session.py adb-core /export/home/sol/tmp/ie.core.<pid> --cmd '$r'
  • syscall/network tracing:
    • python3 scripts/ie_debug_session.py launch ... --under-truss --truss-args='-fael' --truss-output /export/home/sol/tmp/ie.truss
  • older broker helpers still exist:
    • tmp/inspect_submission_crash.py
    • tmp/collect_submission_core.py

GDB / QEMU notes

  • host gdb-multiarch is now validated against the QEMU gdbstub on 127.0.0.1:1235
  • prefer the supported wrappers over older ad hoc harnesses:
    • python3 scripts/ie_debug_session.py gdb-break --module libgdiuser32.so --symbol WaitMessage ...
    • python3 scripts/ie_debug_session.py gdb-trace --module libjscript.so --symbol 0x...
  • adb-live is lighter-weight for process-local breakpoints and stepping; use QEMU gdbstub when whole-machine control is worth the cost
  • gdb-multiarch still does not directly understand the pulled Solaris/SPARC core files, so use gcore plus adb-core for postmortem work
  • if a run looks dead, verify all three before trusting the result:
    • olwm is alive
    • iexplorer is alive
    • the host HTTP log shows the expected request
  • for libwininet, remember the current live-address correction:
    • decompiler addresses appear to be +0x10000 relative to the live ELF / runtime addresses on this workstation image

Current tooling blocker

  • The persistent debug workflow currently has a stop / start regression:
    • after restart, /export/home/sol/.profile in the guest can be empty
    • then the respawned console su - sol login never launches openwin
    • python3 scripts/ie_debug_session.py ensure-x hangs waiting for an X/OpenWindows session that never appears
  • A naive manual .profile workaround that launches openwin whenever DISPLAY is empty is not acceptable:
    • it brings X back on the console
    • but it also causes telnet sol logins to exec openwin, so guest / inspect stop getting a shell prompt
  • See DEBUGGING-ISSUE.md for the exact repro, guest-state evidence, and the likely fix:
    • restore a console-only openwin launcher block even when .profile is empty or missing
    • keep the guard tied to /dev/console, not only to DISPLAY

Process / output caveat

Guest ps output truncates long command lines, so do not over-interpret browser argv displays that appear to stop at & or mid-querystring.

Recommended next steps

  1. Stay on the gopher path.
  2. Use the persistent workstation workflow instead of fresh-guest churn for small checks:
    • python3 scripts/ie_debug_session.py start
    • python3 scripts/ie_debug_session.py ensure-x
    • keep the guest alive across launches
    • treat the host HTTP log as the primary signal for whether IE is really making requests
  3. Re-run the gopher path with:
    • a tab-compliant +VIEWS header
    • tab-compliant +VIEWS body lines
    • direct tracing on live FUN_0004ed5c
  4. Decode the FUN_0004ed5c hits to map the first long body line, the shifted destination pointer, and the start of the next logical line.
  5. Reproduce the cleaned FUN_0004e1c8 candidate under guest-side pstack / pflags or direct breakpoints.
  6. Determine which caller path is actually taken at runtime after the overwrite:
    • success path dereference of local_120 + 0xc
    • failure path FUN_00050a20(local_120)
  7. If the failure path is reachable, pivot to the DAT_000ea800 fake-object strategy.
  8. Use the improved debugging stack aggressively on active leads:
    • adb-live or gdb-break for deterministic breakpoints such as WaitMessage
    • gdb-trace for hot internal paths in libjscript.so, libmshtml.so, or libwininet.so
    • gcore plus adb-core when a crash or near-crash state is worth freezing for offline work
  9. If the gopher path completely fails to reproduce, fall back to:
    • JScript borrowed / sparse Array.sort
    • libshdocvw SaveAs / parser family
    • mshtml print path
    • AutoScan / object-target crash family
    • libshdocvw navigation path
    • liburlmon class-activation path
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment