เกริ่นก่อนว่าไม่ใช่ช่องโหว่ใหม่อะไรนะครับ ถถถ LoL ... หลังจากที่ได้อ่านข่าว Russian malware targets WordPress users, over 100,000 sites infected สรุปคร่าวๆได้ว่า มีเว็บไซต์ที่ได้รับผลกระทบจากมัลแวร์ชื่อ SoakSoak เป็นจำนวนมากถึงกวา่ 100k เว็บซึ่งมัลแวร์ดังกล่าวได้ใช้ประโยชน์จาก Slider Revolution ที่เป็น Third party ที่ติดมากับ Plugin/ Theme ของ Wordpress โดยแพคเก็จดังกล่าวมีช่องโหว่ Local File Inclusion อยู่ ก็เลยเอามาวิเคราะห์ช่องโหว่นี้เพ่ืออธิบายครับ
ขั้นตอนแรกจะเห็นว่าการโจมตีจะเกิดจากการ GET Request ไปที่ /wp-admin/admin-ajax.php?action=revslider_show_image&img=../wp-config.php จะเห็นว่าพารามิเตอร์ img ถูกเรียกไปที่ wp-config.php ซึ่งเป็นไฟล์ที่ใช้เก็บค่า Config ต่างๆ ใน Database บน Wordpress ครับ
โดยที่พารามิเตอร์ img นั้นอยู่ในคลาส UniteImageViewRev ฟังก์ชั่น showImageFromGet ที่ไฟล์ /revslider/inc_php/framework/image_view.class.php ครับ
public function showImageFromGet(){ $imageFilename = UniteFunctionsRev::getGetVar("img"); $maxWidth = UniteFunctionsRev::getGetVar("w",-1); $maxHeight = UniteFunctionsRev::getGetVar("h",-1); $type = UniteFunctionsRev::getGetVar("t",""); //set effect $effect = UniteFunctionsRev::getGetVar("e"); $effectArgument1 = UniteFunctionsRev::getGetVar("ea1"); if(!empty($effect)) $this->setEffect($effect,$effectArgument1); $this->showImage($imageFilename,$maxWidth,$maxHeight,$type); }
มีการเก็บค่าที่รับมาจากพารามิเตอร์ img ใส่ไว้ในตัวแปร $imageFilename จากนั้นก็ถูกส่งเข้าไปในฟังก์ชั่น showImage ครับ
private function showImage($filename,$maxWidth=-1,$maxHeight=-1,$type=""){ if(empty($filename)) $this->throwError("image filename not found"); //validate input if($type == self::TYPE_EXACT || $type == self::TYPE_EXACT_TOP){ if($maxHeight == -1) $this->throwError("image with exact type must have height!"); if($maxWidth == -1) $this->throwError("image with exact type must have width!"); } $filepath = $this->pathImages.$filename; if(!is_file($filepath)) $this->outputEmptyImageCode(); //if gd library doesn't exists - output normal image without resizing. if(function_exists("gd_info") == false) $this->throwError("php must support GD Library"); //check conditions for output original image if(empty($this->effect)){ if((is_numeric($maxWidth) == false || is_numeric($maxHeight) == false)) outputImage($filepath); if($maxWidth == -1 && $maxHeight == -1) $this->outputImage($filepath); } if($maxWidth == -1) $maxWidth = 1000000; if($maxHeight == -1) $maxHeight = 100000; //init variables $this->filename = $filename; $this->maxWidth = $maxWidth; $this->maxHeight = $maxHeight; $this->type = $type; $filepathNew = $this->getThumbFilepath(); if(is_file($filepathNew)){ $this->outputImage($filepathNew); exit(); } try{ if($type == self::TYPE_EXACT || $type == self::TYPE_EXACT_TOP){ $isSaved = $this->cropImageSaveNew($filepath,$filepathNew); } else $isSaved = $this->resizeImageSaveNew($filepath,$filepathNew); if($isSaved == false){ $this->outputImage($filepath); exit(); } }catch(Exception $e){ $this->outputImage($filepath); } if(is_file($filepathNew)) $this->outputImage($filepathNew); else $this->outputImage($filepath); exit(); }
ค่าในตัวแปร $filename ถูกต่อ String กับค่าในตัวแปร $pathImages โดยค่าที่ถูกรวมกันแล้วนั้นจะไปอยู่ในตัวแปร $filepath และตัวแปร $filepath ก็ถูกส่งต่อไปให้กับฟังก์ชั่น outputImage ครับ
private function outputImage($filepath){ $info = UniteFunctionsRev::getPathInfo($filepath); $ext = $info["extension"]; $filetime = filemtime($filepath); $ext = strtolower($ext); if($ext == "jpg") $ext = "jpeg"; $numExpires = 31536000; //one year $strExpires = @date('D, d M Y H:i:s',time()+$numExpires); $strModified = @date('D, d M Y H:i:s',$filetime); $contents = file_get_contents($filepath); $filesize = strlen($contents); header("Last-Modified: $strModified GMT"); header("Expires: $strExpires GMT"); header("Cache-Control: public"); header("Content-Type: image/$ext"); header("Content-Length: $filesize"); echo $contents; exit(); }
จะเห็นว่าตัวแปร $filepath ที่ถูกส่งเข้าฟังก์ชั่น outputImage มานั้นถูกส่งเข้าไปในฟังก์ชั่น file_get_contests (อ่านไฟล์ออกมาเป็น string) ต่อและถูกเก็บค่าที่อ่านได้นั้น มาใส่ไว้ในตัวแปร $content จากนั้นก็ echo เพื่อแสดงข้อความที่ get content มาโดยไม่มีการกรอง Input ใดๆเลยครับ
ตอนนี้แพทช์ที่แก้ไขช่องโหว่ดังกล่าวได้ออกมาแล้ว โดยเพิ่ม Whitelist extension หรือนามสกุลไฟล์ที่อนุญาตเข้าไปนั้นเองครับ
private function outputImage($filepath){ $info = UniteFunctionsRev::getPathInfo($filepath); $ext = $info["extension"]; $filetime = filemtime($filepath); $ext = strtolower($ext); $good_extensions = array('jpg', 'png', 'gif', 'jpeg', 'tiff', 'bmp'); if(empty($ext) || !in_array($ext, $good_extensions)){ header("HTTP/1.1 403 Unauthorized" ); die('Unauthorized'); } if($ext == "jpg") $ext = "jpeg"; $numExpires = 31536000; //one year $strExpires = @date('D, d M Y H:i:s',time()+$numExpires); $strModified = @date('D, d M Y H:i:s',$filetime); $contents = file_get_contents($filepath); $filesize = strlen($contents); header("Last-Modified: $strModified GMT"); header("Expires: $strExpires GMT"); header("Cache-Control: public"); header("Content-Type: image/$ext"); header("Content-Length: $filesize"); echo $contents; exit(); }
โดยในเงื่อนไข if หมายความว่า ถ้านามสกุลไฟล์ที่ใส่มาในพารามิเตอร์ img ไม่ได้อยู่ใน Array คือไม่ได้เป็นรูปภาพ ให้ตอบกลับไปว่า Unauthorized ครับ สำหรับใครที่ใช้ปลั๊กอินหรือธีมที่มี Slider Revolution ควรอัพเดทให้เป็นเวอร์ชั่นล่าสุดเพื่อป้องกันการถูกโจมตีด้วยนะครับ
Ref: Russian malware targets WordPress users, over 100,000 sites infected
Ref: RevSlider Vulnerability Leads To Massive WordPress SoakSoak Compromise
Ref: 2600 Thailand