back

lunar client's (now outdated) arbitrary local code execution vulnerability

not that interesting or severe 2026-03-20

this exploit is ancient, it's been sitting in my IdeaProjects for so long that Moonsworth seems to have nuked the vulnerable system in the meantime, and replaced it with another (also vulnerable?) one.

00 - "arbitrary local code execution? isn't that useless?"

yep, it pretty much is, other than 2 minor details:

while malware development is somewhat interesting to me, the cheat development part was what caught my attention. cheat injection like this can persist across game and computer restarts, and the ichor cache isn't something that local cheat detection tools (a.k.a. "ss tools") scan.

01 - how it worked

lunar client used to have a system of caching all classes that pass through one of their lower-level classloaders, the IchorClassLoader, into a bake.cache file (uncompressed zip archive of class bytes) for the hash combination of the minecraft version and lunar client version that the cache is for. once the cache is built, the client loads it no-questions-asked, even if it has externally been modified.

10 - old proof-of-concept

incomplete snippets, the full code is messy and unneeded.

if (!backupPath.toFile().exists()) {
    Files.copy(originalPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
    logger.info("Created backup: {}", backupPath);
}

Files.delete(originalPath);
logger.info("Deleted original: {}", originalPath);

try (final @NotNull ZipFile backupZip = new ZipFile(backupPath.toFile());
     final @NotNull ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(originalPath))) {
    backupZip.stream().forEach(entry -> {
        try {
            if (!entry.isDirectory()) {
                try (final @NotNull InputStream is = backupZip.getInputStream(entry)) {
                    byte @NotNull [] content = is.readAllBytes();

                    if (wantedClassSet.contains(entry.getName())) {
                        content = applyPatches(content, entry.getName());
                    }

                    final @NotNull ZipEntry newEntry = new ZipEntry(entry.getName());
                    newEntry.setTime(entry.getTime());
                    newEntry.setComment(entry.getComment());

                    zos.putNextEntry(newEntry);
                    zos.write(content);
                    zos.closeEntry();
                }
            } else {
                zos.putNextEntry(new ZipEntry(entry.getName()));
                zos.closeEntry();
            }
        } catch (final @NotNull IOException caught) {
            throw new UncheckedIOException(caught);
        }
    });
}

logger.info("Created patched zip: {}", originalPath);

and then a patch can just be something like this:

final @NotNull MethodNode constructor = ASMUtil.findMethod(classNode, "<init>");
final @NotNull AbstractInsnNode sessionLog = ASMUtil.findLDC(constructor.instructions,  "Setting user: ");

final @NotNull InsnList instructions = new InsnList();

instructions.add(new FieldInsnNode(
        Opcodes.GETSTATIC,
        "net/minecraft/client/Minecraft",
        "logger",
        "Lorg/apache/logging/log4j/Logger;"
));
instructions.add(new LdcInsnNode("Boop!"));
instructions.add(new MethodInsnNode(
        Opcodes.INVOKEINTERFACE,
        "org/apache/logging/log4j/Logger",
        "info",
        "(Ljava/lang/String;)V",
        true
));

constructor.instructions.insertBefore(sessionLog, instructions);

11

this was never meant to be a proper blog post on a vulnerability, i neither write those nor know how to write those (which i feel is noticeable), but it's a good enough test for my broken blog system.