无源码的情况下如何破解JVM上的第三方库包API?


JVM生态系统已经成熟,并提供了大量库,因此您无需重新发明轮子。通过引入依赖可以使用这些库包。但是,有时,依赖关系引用的库包和当前用例会略有不一致。
在本文中,我们将研究一些替代方法,使它能现在就能在当前案例中工作:
 
反射
反射使您可以访问不希望被访问的状态,或调用不希望被调用的方法。

public class Private {

  private String attribute = "My private attribute";

  private String getAttribute() {
    return attribute;
  }
}

public class ReflectionTest {

  private Private priv;

  @BeforeEach
  protected void setUp() {
    priv = new Private();
  }

  @Test
  public void should_access_private_members() throws Exception {
    var clazz = priv.getClass();
    var field = clazz.getDeclaredField(
"attribute");                             
    var method = clazz.getDeclaredMethod(
"getAttribute");                       
    AccessibleObject.setAccessible(new AccessibleObject[]{field, method}, true); 
    field.set(priv,
"A private attribute whose value has been updated");         
    var value = method.invoke(priv);                                            
    assertThat(value).isEqualTo(
"A private attribute whose value has been updated");
  }
}

但是,反思有一些局限性:
  • “魔术”发生在AccessibleObject.setAccessible。可以在运行时使用适当配置的安全管理器来禁止这样做。我承认,在我的职业生涯中,我从未见过使用Security Manager。
  • 模块系统限制了Reflection API的使用。例如,调用者和目标类都必须在同一模块中,目标成员必须是public,等等。请注意,许多库都不使用模块系统。
  • 如果您直接使用具有私有成员的类,则反射是很好的。但是,如果您需要更改依赖类的行为,那就没有用:如果您的类使用A本身需要一个类的第三方类,B并且您需要更改B。

  
类路径阴影
类路径classpath是JVM将用来加载先前卸载的类的文件夹和JAR的有序列表。
启动应用程序的最简单命令如下:
java -cp=.:thirdparty.jar Main
不管出于什么原因,想象一下我们需要改变第三方包中类的行为。它的设计不允许这样做。
无论采用哪种设计,我们都可以通过以下方式对其进行破解:
  1. 获取类的源代码 
  2. 根据我们的要求进行更改
  3. 编译它
  4. 将已编译的类放在包含原始类的JAR之前的classpath上

这种方法也有一些局限性:
  • 您需要第三方的源代码,或者至少需要一种从编译后的代码中获取源代码的方法:反编译。
  • 您需要能够从源代码进行编译。这意味着您需要重新创建的所有必需依赖项B。

 
面向方面的编程
在Java中,AspectJ是首选的AOP库。它依赖于以下核心概念
  •  join point连接点定义了某些定义明确的点在该程序,执行例如,方法的执行
  • 一个pointcut切入点可以在程序流中挑选出特定的连接点,例如,执行任何带有注释的方法@Loggable
  • 一条advice汇集了一个切入点(以选择连接点)和一段代码(在每个连接点上运行)

这里有两个类:一个代表公共API,并将其实现委托给另一个。
public class Public {

  private final Private priv;

  public Public() {
    this.priv = new Private();
  }

  public String entryPoint() {
    return priv.implementation();
  }
}

final class Private {

  final String implementation() {
    return "Private internal implementation";
  }
}

假设我们需要更改私有实现:

public aspect Hack {

  pointcut privateImplementation(): execution(String Private.implementation()); 

  String around(): privateImplementation() {                                    
    return "Hacked private implementation!";
  }
}

AspectJ提供了不同的实现:
  1. 编译时:在构建过程中更新字节码
  2. 编译后时间:在构建后立即更新字节码。它不仅允许更新项目类,还可以更新依赖的JAR。
  3. 加载时间:在加载类时在运行时更新字节码

您可以像这样在Maven中设置第一个选项:

<build>
  <plugins>
    <plugin>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>2.22.2</version>
    </plugin>
    <plugin>
      <groupId>com.nickwongdev</groupId>
      <artifactId>aspectj-maven-plugin</artifactId>
      <version>1.12.6</version>
      <configuration>
        <complianceLevel>${java.version}</complianceLevel>
        <source>${java.version}</source>
        <target>${java.version}</target>
        <encoding>${project.encoding}</encoding>
      </configuration>
      <executions>
        <execution>
          <goals>
            <goal>compile</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
<dependencies>
  <dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjrt</artifactId>
    <version>1.9.5</version>
  </dependency>
</dependencies>

但是,Codehaus的官方AspectJ Maven插件只能处理版本8(包括在内)的JDK,因为自2018年以来没有人进行过更新。有人在GitHub上分叉了可处理更高版本的代码。该fork可以处理JDK到版本13,以及AspectJ库到1.9.5。
 
Java代理
当您想破解时,AOP提供了一个高级抽象。但是,如果您想以一种细粒度的方式更改代码,则除了更改字节码本身之外别无其他方法。有趣的是,JVM为我们提供了一种在加载类时更改字节码的标准机制。
在您的职业生涯中,您可能已经遇到过该功能:它们被称为Java代理。当您启动JVM时,可以在命令行上静态设置Java代理,或者在以后动态地将Java代理动态附加到已经运行的JVM。
public class Agent {

    public static void premain(                      
            String args,                             
            Instrumentation instrumentation){        
        var transformer = new HackTransformer();
        //设置一个转换器,该转换器可以在JVM加载字节码之前对其进行更改
        instrumentation.addTransformer(transformer); 
    }
}

Java代理在字节码级别工作。代理为您提供了字节数组,该字节数组根据JVM规范存储类的定义。必须更改字节数组中的字节并不有趣。好消息是其他人以前有此要求。因此,该生态系统提供了可使用的库,这些库提供了更高级别的抽象。
在以下代码段中,转换器使用Javassist

public class HackTransformer implements ClassFileTransformer {

  @Override
  public byte[] transform(ClassLoader loader,
              String name,
              Class<?> clazz,
              ProtectionDomain domain,
              byte[] bytes) {                                            
    if ("ch/frankel/blog/agent/Private".equals(name)) {
      var pool = ClassPool.getDefault();                                 
      try {
        var cc = pool.get(
"ch.frankel.blog.agent.Private");              
        var method = cc.getDeclaredMethod(
"implementation");             
        method.setBody(
"{ return \"Agent-hacked implementation!\" }");  
        bytes = cc.toBytecode();                                         
      } catch (NotFoundException | CannotCompileException | IOException e) {
        e.printStackTrace();
      }
    }
    return bytes;                                                        
  }
}
 


结论
在这篇文章中,我们列出了四种用于破解第三方库行为的方法:反射,类路径阴影,面向方面的编程和Java代理。

有了这些,您应该能够解决遇到的任何问题。只要记住,库和JVM都是以这种方式设计的,这是有充分理由的:防止您犯错误。