redpwnCTF 2021 - javaisez3

If you participated in redpwnCTF 2021, you might know that I authored the javaisez3 reverse-engineering challenge. So… here is my writeup. I attempted to write this writeup in a way that is friendly to those who do not have a lot of experience with the Java Virtual Machine (JVM), so hopefully you will find this educational and helpful should you ever run into future Java bytecode reverse-engineering scenarios.

It will also help if you do a quick reading on how the JVM works. The JVM is a stack machine with a stack-per-method model (different methods have separate stacks). Each operand on the stack consists of 4 byte words and values can be stored and loaded from local variables unique to each method. Something important to keep in mind is that the JVM does not pop arguments off the stack in the reverse order for method invocations (which you expect in something like x64 assembly), so the following set of instructions is equivalent to xyz.itzsomebody.MyClass.myMethod(0, "hello world").

iconst_0 // push 0
ldc "hello world" // push "hello world" to stack
invokestatic xyz/itzsomebody/MyClass myMethod (ILjava/lang/String;)V // invoke method

Note: I did not proofread much. I am too tired now. :lemonthink:

A quick introduction to Java bytecode: https://en.wikipedia.org/wiki/Java_bytecode
And, of course, nothing beats the JVM specification: https://docs.oracle.com/javase/specs/jvms/se11/html/jvms-6.html

Edit: The challenge can now be downloaded from the redpwnCTF 2021 challenges repository! (https://github.com/redpwn/redpwnctf-2021-challenges/)

1. Initial interactions with challenge

Here is the description of the challenge:

3rd round of your local Java rev! Note: This requires Java 11 and above to run.

…Not particularly helpful. We are provided a .jar file (the JVM binary format) so let’s begin taking a look at that. When we run the .jar file via java -jar javaisez3.jar, we get the following:

This is a direct quoting of Hu Tao from the game Genshin Impact, but we’re not here to discuss Chinese Teyvat anime girls… we are here to solve the challenge for points:tm:. Since there is nothing here of revelance, let’s attempt to analyze the program. First, though:

2. Attempting to extract compiled code

Something that should be realized here is that .jar files are actually PKZIP archives. This means that under ordinary conditions, we can normally extract the compiled form of Java known as “classfiles” from the .jar via something like 7zip or Linux’s unzip utility. Indeed, when running zipinfo on javaisez3.jar, we get

$ zipinfo javaisez3.jar 
Archive:  javaisez3.jar
Zip file size: 15485 bytes, number of entries: 13
-rw----     2.0 fat     4831 bl defN 21-Jul-05 22:11 net/redpwn/ctf/tetsujou.class/
-rw----     2.0 fat        4 bl defN 21-Jul-05 22:11 net/redpwn/ctf/tetsujou.class/index
-rw----     2.0 fat       29 bl defN 21-Jul-05 22:11 net/redpwn/ctf/tetsujou.class/name
-rw----     2.0 fat       30 bl defN 21-Jul-05 22:11 net/redpwn/ctf/tetsujou.class/data
-rw----     2.0 fat     5322 bl defN 21-Jul-05 22:11 net/redpwn/ctf/suo.class/
-rw----     2.0 fat        4 bl defN 21-Jul-05 22:11 net/redpwn/ctf/suo.class/index
-rw----     2.0 fat       24 bl defN 21-Jul-05 22:11 net/redpwn/ctf/suo.class/name
-rw----     2.0 fat       30 bl defN 21-Jul-05 22:11 net/redpwn/ctf/suo.class/data
-rw----     2.0 fat    12284 bl defN 21-Jul-05 22:11 net/redpwn/ctf/JavaIsEZ3.class/
-rw----     2.0 fat        4 bl defN 21-Jul-05 22:11 net/redpwn/ctf/JavaIsEZ3.class/index
-rw----     2.0 fat       30 bl defN 21-Jul-05 22:11 net/redpwn/ctf/JavaIsEZ3.class/name
-rw----     2.0 fat       30 bl defN 21-Jul-05 22:11 net/redpwn/ctf/JavaIsEZ3.class/data
-rw----     2.0 fat       63 bl defN 21-Jul-05 22:11 META-INF/MANIFEST.MF
13 files, 22685 bytes uncompressed, 13449 bytes compressed:  40.7%

Something that should be striking you as extremely odd here is that the .class/ “directories” are unusually large. This observation is important and I will bring it up later. For now, let’s try extracting it:

$ unzip javaisez3.jar 
Archive:  javaisez3.jar
   creating: net/redpwn/ctf/tetsujou.class/
  inflating: net/redpwn/ctf/tetsujou.class/index   bad CRC 2144df1c  (should be ea80ebd2)
  inflating: net/redpwn/ctf/tetsujou.class/name   bad CRC 921fbb53  (should be 53c069a2)
  inflating: net/redpwn/ctf/tetsujou.class/data   bad CRC 632c8f2f  (should be f504c297)
   creating: net/redpwn/ctf/suo.class/
  inflating: net/redpwn/ctf/suo.class/index   bad CRC 99f8b879  (should be 93333e0f)
  inflating: net/redpwn/ctf/suo.class/name   bad CRC f3b3d38f  (should be 6ede3719)
  inflating: net/redpwn/ctf/suo.class/data   bad CRC 632c8f2f  (should be 1fef1206)
   creating: net/redpwn/ctf/JavaIsEZ3.class/
  inflating: net/redpwn/ctf/JavaIsEZ3.class/index   bad CRC 8b4d1797  (should be c6b38137)
  inflating: net/redpwn/ctf/JavaIsEZ3.class/name   bad CRC 45ecf7f9  (should be 2468ef7d)
  inflating: net/redpwn/ctf/JavaIsEZ3.class/data   bad CRC 632c8f2f  (should be 4fe7b231)
  inflating: META-INF/MANIFEST.MF     bad CRC 8fee4f7c  (should be e49c6f24)

So… despite every entry in the ZIP file having an incorrect CRC value, the program was still able to run. What you should be getting from this is that the JVM does not verify entry CRCs when loading a Java program. This is a simple yet sometimes effective way of preventing newbies from extracting classfiles out of a .jar file. At this point, there should be at least two attack methods that should come to your mind:

  1. Realize that since the program runs, it must be stored in memory somewhere; therefore, dumping the contents from memory will get around all this nonsense or
  2. Attempt to figure out how the JVM loads .jar files via java -jar or
  3. Use something like Recaf which is able to bypass all of the anti-extraction measures.

While the first option is definitely viable (and faster) and the third option is by far the most efficient, we will take the second option so I look cooler and because not everyone knew about Recaf during the duration of the CTF.

3. ClassLoaders and stuff

Almost everything in Java relies around classes. Just like at the source level, those classes also remain at the compiled level in the form of .class files (and are consequently known as classfiles), so it follow that there is some part of the JVM that must load said classfiles at runtime. In our particular case, we want to know specifically which part of the JVM loads a .jar’s classes. We can quickly do so by making a quick Java applet:

class Test {
    public static void main(String[] args) {
        System.out.println(Test.class.getClassLoader());
    }
}

After compiling Test.java via javac and running our applet with java Test, we get the following:

It seems like that jdk.internal.loader.ClassLoaders$AppClassLoader is what we should be looking at. So let’s go open up some OpenJDK code! For the record, subclasses are typically compiled as their own class identified by parent_class_name_and_path + $ + sub_class_name, so we are looking specifically for the class called ClassLoaders.java and then we will search for AppClassLoader within.

After playing around with GitHub’s file search, we land over here: https://github.com/openjdk/jdk11u-dev/blob/1960a05717ed6235033f450f822f7348405e10d0/src/java.base/share/classes/jdk/internal/loader/ClassLoaders.java and find AppClassLoader as desired. Here are the first few lines:

/**
  * The application class loader that is a {@code BuiltinClassLoader} with
  * customizations to be compatible with long standing behavior.
  */
private static class AppClassLoader extends BuiltinClassLoader {
    static {
        if (!ClassLoader.registerAsParallelCapable())
            throw new InternalError();
    }

    final URLClassPath ucp;

    AppClassLoader(PlatformClassLoader parent, URLClassPath ucp) {
        super("app", parent, ucp);
        this.ucp = ucp;
    }

    ...

There are lots of interesting things here. In particular, we could take a look at BuildinClassLoader, URLClassPath, PlatformClassLoader, and at various other parts of Java in the parts of AppClassLoader that I cut out of the snippet; however, I do not wish to drone on so I will immediately turn our attention to URLClassPath which is relevant to what we are trying to do. If you do find the time; however, you may wish to take a look at these various classes as they will enhance your understanding of the huge spaghetti design monster JVM.

I am not going to post specific snippets here (because I am lazy), but if you peek at URLClassPath.java, you will notice that .jar files are loaded via java.util.jar.JarFile. This is extremely convenient because now we have a way to extract said files. Here is a (very bad) code snippet I whipped up just now to aid us in extracting the classfiles.

public class Javaisez3Extractor {
    public static void main(String[] args) {
        File input = new File("input.jar");
        File output = new File("output.jar");

        try {
            JarFile jar = new JarFile(input);
            JarOutputStream jos = new JarOutputStream(new FileOutputStream(output));
            Enumeration<? extends JarEntry> entries = jar.entries();
            while (entries.hasMoreElements()) {
                JarEntry entry = entries.nextElement();
                try {
                    JarEntry newEntry = new JarEntry(entry.getName());
                    jos.putNextEntry(newEntry);
                    jos.write(toByteArray(jar.getInputStream(entry)));
                } catch (Throwable t) {
                  System.out.println("Error while writing " + entry.getName());
                  t.printStackTrace();
                }
            }
            jos.close();
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }

    public static byte[] toByteArray(final InputStream stream) throws IOException {
        try (stream; var out = new ByteArrayOutputStream()) {
            var buf = new byte[0x1000];
            int bytesRead;
            while ((bytesRead = stream.read(buf, 0, buf.length)) != -1) {
                out.write(buf, 0, bytesRead);
            }
            out.flush();
            return out.toByteArray();
        }
    }
}

As you can clearly see, this obviously does not actually extract the entries to disk (it creates a “repaired” .jar and copies everything over instead). This is because I am a pepega and happened to find this on my laptop (in other words, I lied about whipping this up on the spot) and I did not want to spend effort on making an actual extractor. Anyways, now we can finally unzip the .jar without errors:

4. Fake directories

Unzip time! (keep PG-13 pls)

$ unzip output.jar 
Archive:  output.jar
   creating: net/redpwn/ctf/tetsujou.class/
  inflating: net/redpwn/ctf/tetsujou.class/index  
  inflating: net/redpwn/ctf/tetsujou.class/name  
  inflating: net/redpwn/ctf/tetsujou.class/data  
   creating: net/redpwn/ctf/suo.class/
  inflating: net/redpwn/ctf/suo.class/index  
  inflating: net/redpwn/ctf/suo.class/name  
  inflating: net/redpwn/ctf/suo.class/data  
   creating: net/redpwn/ctf/JavaIsEZ3.class/
  inflating: net/redpwn/ctf/JavaIsEZ3.class/index  
  inflating: net/redpwn/ctf/JavaIsEZ3.class/name  
  inflating: net/redpwn/ctf/JavaIsEZ3.class/data  
  inflating: META-INF/MANIFEST.MF 

Okay! The first thing that looks of interest is net/redpwn/ctf/JavaIsEZ3.class/data. Let’s decompile it and see what happens.

Wtmoo. Fernflower got mad at me for invalid class.

Let’s open it in xxd, then.

$ xxd net/redpwn/ctf/JavaIsEZ3.class/data
00000000: 2a66 616e 6661 7265 2a20 596f 7527 7665  *fanfare* You've
00000010: 2062 6565 6e20 7072 616e 6b65 6421        been pranked!

This is, of course, another Hu Tao quote, but our aim is for flag, not anime girl. Recall in section (1), I pointed out that the .class/ “directories” are unusually large. Well, that is because those “directories” are not directories at all. The JVM allows you to suffix classfiles with a forward slash when writing them as ZIP file entries and the .jar program will still run as if nothing happened. The result prevents most extraction attempts out of the box because most ZIP archivers will treat the entry as a directory rather than file. If you want to know why this works, you will need to dig around in java.util.zip.ZipFile or java.util.jar.JarFile as well as some of the other ClassLoader-related classes we touched on earlier. But for now, just assume it is dark magic (and stupid, obviously) that such a feature of the JVM exists.

So… uh… yeah…

Updated “"”extractor”””:

public class Javaisez3Extractor {
    public static void main(String[] args) {
        File input = new File("input.jar");
        File output = new File("output.jar");

        try {
            JarFile jar = new JarFile(input);
            JarOutputStream jos = new JarOutputStream(new FileOutputStream(output));
            Enumeration<? extends JarEntry> entries = jar.entries();
            while (entries.hasMoreElements()) {
                JarEntry entry = entries.nextElement();
                try {
                    if (entry.getName().contains(".class/") && !entry.getName().endsWith(".class/")) {
                        // Skip fake entries
                        continue;
                    }
                    JarEntry newEntry = new JarEntry(entry.getName().replace(".class/", ".class"));
                    jos.putNextEntry(newEntry);
                    jos.write(toByteArray(jar.getInputStream(entry)));
                } catch (Throwable t) {
                  System.out.println("Error while writing " + entry.getName());
                  t.printStackTrace();
                }
            }
            jos.close();
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }

    public static byte[] toByteArray(final InputStream stream) throws IOException {
        try (stream; var out = new ByteArrayOutputStream()) {
            var buf = new byte[0x1000];
            int bytesRead;
            while ((bytesRead = stream.read(buf, 0, buf.length)) != -1) {
                out.write(buf, 0, bytesRead);
            }
            out.flush();
            return out.toByteArray();
        }
    }
}

And now unzip:

$ unzip output.jar 
Archive:  output.jar
  inflating: net/redpwn/ctf/tetsujou.class  
  inflating: net/redpwn/ctf/suo.class  
  inflating: net/redpwn/ctf/JavaIsEZ3.class  
  inflating: META-INF/MANIFEST.MF

Much better. Now we can finally start some actual reverse-engineering instead of dealing with ZIP stupidity.

5. Defeating string encryption

From META-INF/MANIFEST.MF, we can see that net.redpwn.ctf.JavaIsEZ3 is the entry point. Let’s decompile the main method in Fernflower and see what we get:

public static void main(String[] var0) {
    redpwnCTF2021 var10000 = (redpwnCTF2021)null;
    if (var0.a<invokedynamic>(var0, tetsujou.saisaki("⧈㚟⧒ᣚ⧔㚟⧂ᢄ⧑㚔⦈ᢗ⧒㚜⦈ᢾ⧇㚌⧇ᢽ⧕㚿⧼ᣇ", 322826692), -4280091229029863812L) == 0) {
        try {
            tetsujou.saisaki("ᘾ३ᘢ❧ᘬदᘧ❱ᘽ०ᘳ✨ᘁुᘙ❧ᘺ३ᘳ❣ᘦ", 649776694).a<invokedynamic>(tetsujou.saisaki("ᘾ३ᘢ❧ᘬदᘧ❱ᘽ०ᘳ✨ᘁुᘙ❧ᘺ३ᘳ❣ᘦ", 649776694), 8560971300846057061L).a<invokedynamic>(tetsujou.saisaki("ᘾ३ᘢ❧ᘬदᘧ❱ᘽ०ᘳ✨ᘁुᘙ❧ᘺ३ᘳ❣ᘦ", 649776694).a<invokedynamic>(tetsujou.saisaki("ᘾ३ᘢ❧ᘬदᘧ❱ᘽ०ᘳ✨ᘁुᘙ❧ᘺ३ᘳ❣ᘦ", 649776694), 8560971300846057061L), tetsujou.saisaki("鯈蒟鯔ꪑ鯚蓐鯑ꪇ鯋蒐鯅꫞鯷蒷鯯ꪑ鯌蒟鯅ꪕ鯐", -784448576), 1235598990591485937L);
            null.a<invokedynamic>((Object)null, tetsujou.saisaki("듿ꮙ듀薒듕ꯝ듏薖듙ꮂ듀藒뒌ꮒ듅薒듀ꮉ뒁薝듄ꮅ듞薒뒀ꯐ듟薗듀ꮜ듕藓듎ꮙ듀薒듕ꯐ듄薗듀ꮙ듏薖듙ꮂ듀藐뒂ꯞ뒌薩듃ꮟ듃薖뒍꯺듒薿뒌ꮓ듉薌듘ꮑ듅薐뒌ꮧ듍薐듋ꮃ듄薛듂ꮗ뒌薸듙ꮞ듉薌듍ꮜ뒌薮듍ꮂ듀薑듞ꯐ듈薗듞ꮕ듏薊듃ꮂ뒦藴뒄ꮤ듄薗듟ꯐ듅薍뒌ꮞ듃薊뒌ꮄ듄薛뒌ꮖ듀薟듋ꯜ뒌薜듘ꮇ뒅", -1851298610), tetsujou.saisaki("䃤徳䃸熽䃶忼䃽熫䃧徼䃩燲䃄徝䃾熨䃧徽䃠熌䃯徼䃫", -558917396), -8331272066798825690L);
        } catch (Throwable var5) {
        }
    } else {
        if (var0[0].b<invokedynamic>(var0[0], tetsujou.saisaki("Ꙛ뤍Ꙇ霃ꘞ뤀ꙑ霌ꙗ륂ꙣ霖Ꙃ뤅Ꙟ霅", -1637188014), -4751795797312301073L) != 48) {
            tetsujou.saisaki("????", -2016726913).d<invokedynamic>(tetsujou.saisaki("????", -2016726913), 474225325441265L).b<invokedynamic>(tetsujou.saisaki("????", -2016726913).d<invokedynamic>(tetsujou.saisaki("????", -2016726913), 474225325441265L), tetsujou.saisaki("ᮈҘᯃ⪞ᯄҟᯐ⪕ᮈӞ᯻⪟ᯗәᯔ⪕ᮂҜᯇ⪕ᯌӞᯒ⪂ᯃҐᯉ⪕ᯆӟ", -846806080), tetsujou.saisaki("龜퓀陼풏﫝퓏﫼퓓龜", 801127825), -1351703383126743055L);
            return;
        }

        String var6 = tetsujou.saisaki("徺䃐征滑徘䃅循滖徟䃝徯滚從䃅循滖徟䃝徲溏忚䂞応溊", 1677756303);
        char[] var1 = var6.b<invokedynamic>(var6, tetsujou.saisaki("ꕝ먊ꕁ鐄ꔙ먇ꕖ鐋ꕐ멅ꕤ鐑ꕅ먂ꕙ鐂", -1768915627), 3921157978488572744L);
        char[][] var2 = new char[][]{var1, null, null};
        int var3 = var0[0].b<invokedynamic>(var0[0], tetsujou.saisaki("ᓽபᓡ▤ᒹ஧ᓶ▫ᓰ௥ᓄ▱ᓥ஢ᓹ▢", -789459723), -4751795797312301073L) / 2;

        for(int var4 = 0; var4 < 2; ++var4) {
            int var10001 = var4 + 1;
            String var10002 = var0[0].b<invokedynamic>(var0[0], var4 * var3, (var4 + 1) * var3, tetsujou.saisaki("쒲쒽쒧쒴", -236704285), 2278661231839426149L);
            var2[var10001] = var10002.b<invokedynamic>(var10002, tetsujou.saisaki("犽淪犡䏤狹淧状䏫犰涥犄䏱犥淢犹䏢", 1785375413), 3921157978488572744L);
        }

        var2.a<invokedynamic>(var2, tetsujou.saisaki("⏙㲎⏃ዋ⏅㲎⏓ን⏀㲅⎙ኆ⏃㲍⎙ኯ⏖㲝⏖ኬ⏄㲮⏭ዖ", 411433941), -4036077825718603401L);
        if (var2.a<invokedynamic>(var2, tetsujou.saisaki("붢ꋵ붸貰붾ꋵ붨賮붻ꋾ뷢賽붸ꋶ뷢賔붭ꋦ붭賗붿ꋕ붖貭", 1254712750), -4328141322681971509L) & var2.a<invokedynamic>(var2, tetsujou.saisaki("앬?앶앰?앦앵?씬앶?씬앣?앣앱?았", -723903136), 8504114058794503371L) & var0[0].b<invokedynamic>(var0[0], tetsujou.saisaki("뷹ꊮ뷥負붽ꊣ뷲貯뷴ꋡ뷀貵뷡ꊦ뷽貦", 778462705), 634352354493306863L) == 1101317042) {
            tetsujou.saisaki("㨴╣㨨୭㩰╮㨿ୢ㨹┬㨍୵㨭╶㨻ୡ", -1278287300).d<invokedynamic>(tetsujou.saisaki("㨴╣㨨୭㩰╮㨿ୢ㨹┬㨍୵㨭╶㨻ୡ", -1278287300), 474225325441265L).b<invokedynamic>(tetsujou.saisaki("㨴╣㨨୭㩰╮㨿ୢ㨹┬㨍୵㨭╶㨻ୡ", -1278287300).d<invokedynamic>(tetsujou.saisaki("㨴╣㨨୭㩰╮㨿ୢ㨹┬㨍୵㨭╶㨻ୡ", -1278287300), 474225325441265L), tetsujou.saisaki("㎙ⳮ㎯˼㎿Ⲩ㏺ʨ㎔⳩㎭ʨ㎣⳩㎯ʨ㎱⳨㎵˿㏺Ⳬ㎣ʨ㎩ⳣ㎹˺㎿Ⳳ", -921572424), tetsujou.saisaki("ꊒ뷅ꊎ鏋ꋖ뷍ꊗ鎄ꊨ뷖ꊑ鏄ꊌ뷷ꊌ鏘ꊝ뷅ꊕ", -266634598), -1351703383126743055L);
        } else {
            tetsujou.saisaki("쎴?쎨쏰?쎿쎹?쎍쎭?쎻", 1400642492).d<invokedynamic>(tetsujou.saisaki("쎴?쎨쏰?쎿쎹?쎍쎭?쎻", 1400642492), 474225325441265L).b<invokedynamic>(tetsujou.saisaki("쎴?쎨쏰?쎿쎹?쎍쎭?쎻", 1400642492).d<invokedynamic>(tetsujou.saisaki("쎴?쎨쏰?쎿쎹?쎍쎭?쎻", 1400642492), 474225325441265L), tetsujou.saisaki("ͨᱸ̣㉾̤᱿̰㉵ͨ᰾̛㉿̷᰹̴㉵͢ᱼ̧㉵̬᰾̲㉢̣ᱰ̩㉵̦᰿", -662578400), tetsujou.saisaki("숄쉋숋숗", -501863595), -1351703383126743055L);
        }
    }

}

Dunno about you, but this is pretty illegible to me. Plus, more than half of this isn’t legal Java. However, something that we can make out very consistently in this method (other than the invokedynamic stuff which I’ll get to later) is the static method invocation tetsujou.saisaki(). And due to the way it appears in the decompiled result, we can assume it must be some sort of string encryption. Let’s take a look at net.redpwn.ctf.tetsujou to check if this is the case.

After cleaning up net.redpwn.ctf.tetsujou a bit, we are left with

public class tetsujou {
    private static final Map hanekawa = new HashMap();

    private static int tetsujou(String var0) throws Exception {
        File var1 = new File(tetsujou.class.getProtectionDomain().getCodeSource().getLocation().getPath());
        ZipFile var2 = new ZipFile(var1);
        Method var3 = ZipFile.class.getMethod("getEntry", String.class);
        var3.setAccessible(true);
        Object var4 = var3.invoke(var2, var0.replace('.', '/') + ".class");
        Method var5 = var4.getClass().getMethod("getComment");
        var5.setAccessible(true);
        return var5.invoke(var4).hashCode();
    }

    public static String saisaki(String encryptedString, int randomizedKey) throws Exception {
        String cachedStr;
        if ((cachedStr = (String) hanekawa.get(encryptedString)) != null) {
            return cachedStr;
        } else {
            char[] encryptedChars = encryptedString.toCharArray();
            char[] decryptedChars = new char[encryptedChars.length];

            Thread thread = Thread.currentThread();
            StackTraceElement[] callstack = thread.getStackTrace();
            int xorKey1 = callstack[2].getClassName().hashCode() + callstack[1].getClassName().hashCode() + callstack[2].getMethodName().hashCode() + tetsujou(callstack[2].getClassName()) ^ randomizedKey;
            int xorkey2 = callstack[2].getMethodName().hashCode() + callstack[1].getMethodName().hashCode() + callstack[2].getClassName().hashCode() + tetsujou(callstack[2].getClassName()) ^ randomizedKey;
            int xorKey3 = callstack[1].getClassName().hashCode() + callstack[2].getClassName().hashCode() + callstack[2].getMethodName().hashCode() + tetsujou(callstack[2].getClassName()) ^ randomizedKey;
            int xorKey4 = callstack[1].getMethodName().hashCode() + callstack[2].getClassName().hashCode() + callstack[1].getClassName().hashCode() + tetsujou(callstack[2].getClassName()) ^ randomizedKey;
            for (int var37 = 0; var37 < encryptedChars.length; ++var37) {
                switch (var37 % 4) {
                    case 0:
                        decryptedChars[var37] = (char) ((encryptedChars[var37] ^ xorKey1) & 0xffff);
                        break;
                    case 1:
                        decryptedChars[var37] = (char) ((encryptedChars[var37] ^ xorkey2) & 0xffff);
                        break;
                    case 2:
                        decryptedChars[var37] = (char) ((encryptedChars[var37] ^ xorKey3) & 0xffff);
                        break;
                    case 3:
                        decryptedChars[var37] = (char) ((encryptedChars[var37] ^ xorKey4) & 0xffff);
                }
            }

            String decryptedStr = new String(decryptedChars);
            hanekawa.put(encryptedString, decryptedStr);
            return decryptedStr;
        }
    }
}

First, let’s deal with the tetsujou() method. The tetsujou() makes use of something called the Java Reflection API which allows Java to programs to introspect stuff at runtime. This is probably one of the more powerful parts Java that any reverse-engineer should know of as it is one of the few tools that allows for limited dynamic debugging. To spare you the time and effort, I will translate the reflection API stuff into the more readable and semantically equivalent direct invocation form.

private static int tetsujou(String className) throws Exception {
    // Gets .jar containing this the net.redpwn.ctf.tetsujou class (in this case, javaisez3.jar)
    File file = new File(tetsujou.class.getProtectionDomain().getCodeSource().getLocation().getPath());
    ZipFile zip = new ZipFile(var1); // Opens as ZIP
    ZipEntry entry = zip.getEntry(className.replace('.', '/') + ".class"); // Gets className from javaisez3.jar
    String comment = entry.getComment(); // Gets entry comment
    return comment.hashCode(); // Returns hashcode
}

To see why this is useful, let’s turn our attention back to saisaki(). We note that saisaki() is context-sensitive in the way that it computes four different XOR keys based on what appears on the callstack (or stacktrace, in Java-world). At any given moment, we can expect the callstack to look like the following:

callstack[0] = thread.currentStackTrace() // Last method invoked before capturing the callstack state
callstack[1] = net.redpwn.ctf.tetsujou.saisaki() // Invokes Thread.currentStackTrace()
callstack[2] = anothercaller.anothermethod() // Invokes net.redpwn.ctf.tetsujou.saisaki()
... // everything else is irrelevant since saisaki() only looks at indexs 0 to 2

Now we have a problem. In addition to computing keys dynamically from the callstack, saisaki() also uses tetsujou() to grab the hashcode of either the caller’s or saisaki()’s ZIP entry comment. This means that we need to extract those comments from javaisez3.jar to continue further. Fortunately, because this program is pretty small, we can just use zipinfo -v javaisez3.jar and get those by hand. If this program was larger, we would have had to have written a deobfuscator to automate the process. Here are the relevant entry comments:

"net/redpwn/ctf/tetsujou.class" -> "03/02"
"net/redpwn/ctf/suo.class" -> "77"
"net/redpwn/ctf/JavaIsEZ3.class" -> "07/15"

This allows us to create a decryptor to decrypt all of the strings in the program.

public class tetsujou {
    private static final Map hanekawa = new HashMap();

    private static int getCommentHashcodeFor(String className) {
        Map<String, String> lookup = new HashMap<>();
        lookup.put("net.redpwn.ctf.tetsujou", "03/02");
        lookup.put("net.redpwn.ctf.suo", "77");
        lookup.put("net.redpwn.ctf.JavaIsEZ3", "07/15");
        return lookup.get(className).hashCode(); // Imagine doing null checks
    }

    public static String saisaki(String encryptedString, int randomizedKey, String callerClassName, String callerMethodName) {
        String cachedStr;
        if ((cachedStr = (String) hanekawa.get(encryptedString)) != null) {
            return cachedStr;
        } else {
            char[] encryptedChars = encryptedString.toCharArray();
            char[] decryptedChars = new char[encryptedChars.length];
            String decryptorClassName = "net.redpwn.ctf.tetsujou";
            String decryptorMethodName = "saisaki";
            int xorKey1 = callerClassName.hashCode() + decryptorClassName.hashCode() + callerMethodName.hashCode() + getCommentHashcodeFor(callerClassName) ^ randomizedKey;
            int xorkey2 = callerMethodName.hashCode() + decryptorMethodName.hashCode() + callerClassName.hashCode() + getCommentHashcodeFor(callerClassName) ^ randomizedKey;
            int xorKey3 = decryptorClassName.hashCode() + callerClassName.hashCode() + callerMethodName.hashCode() + getCommentHashcodeFor(callerClassName) ^ randomizedKey;
            int xorKey4 = decryptorMethodName.hashCode() + callerClassName.hashCode() + decryptorClassName.hashCode() + getCommentHashcodeFor(callerClassName) ^ randomizedKey;
            for (int var37 = 0; var37 < encryptedChars.length; ++var37) {
                switch (var37 % 4) {
                    case 0:
                        decryptedChars[var37] = (char) ((encryptedChars[var37] ^ xorKey1) & 0xffff);
                        break;
                    case 1:
                        decryptedChars[var37] = (char) ((encryptedChars[var37] ^ xorkey2) & 0xffff);
                        break;
                    case 2:
                        decryptedChars[var37] = (char) ((encryptedChars[var37] ^ xorKey3) & 0xffff);
                        break;
                    case 3:
                        decryptedChars[var37] = (char) ((encryptedChars[var37] ^ xorKey4) & 0xffff);
                }
            }

            String decryptedStr = new String(decryptedChars);
            hanekawa.put(encryptedString, decryptedStr);
            return decryptedStr;
        }
    }
}

Finally, we can decrypt all of the strings in the program.

public class JavaIsEZ3 {
    private static byte[] araragi;
    private static int[] hitagi;

    private static boolean oshino(char[][] var0) {
        char[] var1 = var0[1];
        String var12 = new String(var1);
        if (var12.b<invokedynamic>(var12, "java.lang.String", 634352354493306863L) != 998474623) {
            return false;
        } else {
            int[] var2 = new int[6];
            int var3 = 0;

            int var4;
            for(var4 = 0; var4 < var1.a<invokedynamic>(var1, "net.redpwn.ctf.JavaIsEZ3", -4280091229029863812L); var4 += 4) {
                var2[var3++] = (var1[var4] << 24 | var1[var4 + 1] << 16 | var1[var4 + 2] << 8 | var1[var4 + 3]) ^ 118818581;
            }

            var4 = 0;
            int[] var5 = new int[15];
            int var6 = 0;
            boolean var7 = true;

            while(true) {
                byte var8 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4];
                byte var9;
                int var11;
                switch(var8) {
                case 0:
                    var9 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 1];
                    --var6;
                    var2[var9] = var5[var6];
                    var4 += 2;
                    break;
                case 1:
                    var9 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 1];
                    var5[var6++] = var2[var9];
                    var4 += 2;
                    break;
                case 2:
                    return var7;
                case 3:
                    var11 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 1] << 24 | "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 2] << 16 | "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 3] << 8 | "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 4];
                    var5[var6++] = var11;
                    var4 += 5;
                    break;
                case 4:
                    --var6;
                    var11 = var5[var6];
                    --var6;
                    int var10 = var5[var6];
                    var7 &= var10 == var11;
                    ++var4;
                    break;
                case 5:
                    var11 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 1] << 8 | "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 2];
                    var5[var6++] = var11;
                    var4 += 3;
                    break;
                case 6:
                    var9 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L)[var4 + 1];
                    var5[var6++] = var9;
                    var4 += 2;
                }
            }
        }
    }

    private static boolean sengoku(char[][] var0) {
        char[] var1 = var0[2];
        long[] var2 = new long[15];
        int var3 = 0;

        int var4;
        for(var4 = 0; var4 < var1.a<invokedynamic>(var1, "net.redpwn.ctf.JavaIsEZ3", -4280091229029863812L); var4 += 8) {
            var2[var3++] = ((long)var1[var4] << 56 | (long)var1[var4 + 1] << 48 | (long)var1[var4 + 2] << 40 | (long)var1[var4 + 3] << 32 | (long)var1[var4 + 4] << 24 | (long)var1[var4 + 5] << 16 | (long)var1[var4 + 6] << 8 | (long)var1[var4 + 7]) ^ 216743518893377301L;
        }

        String var10002 = new String(var1);
        var2[var3] = (long)var10002.b<invokedynamic>(var10002, "java.lang.String", 634352354493306863L);
        var4 = 0;
        long[] var5 = new long[15];
        int var6 = 0;

        while(true) {
            int var7 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4];
            int var8;
            int var9;
            long var10;
            switch(var7) {
            case 0:
                var10 = (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1] << 56 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 2] << 48 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 3] << 40 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 4] << 32 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 5] << 24 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 6] << 16 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 7] << 8 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 8];
                var5[var6++] = var10;
                var4 += 9;
                break;
            case 1:
                var10 = (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1] << 24 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 2] << 16 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 3] << 8 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 4];
                var5[var6++] = var10;
                var4 += 5;
                break;
            case 2:
                var10 = (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1] << 8 | (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 2];
                var5[var6++] = var10;
                var4 += 3;
                break;
            case 3:
                var10 = (long)"net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                var5[var6++] = var10;
                var4 += 2;
                break;
            case 4:
                String var11 = "net.redpwn.ctf.JavaIsEZ3";
                var8 = var11.d<invokedynamic>(var11, -5227033679506699442L)[var4 + 1];
                var11 = "net.redpwn.ctf.JavaIsEZ3";
                var9 = var11.d<invokedynamic>(var11, -5227033679506699442L)[var4 + 2];
                var2[0] = var2[var8] == var2[var9] ? 0L : 1L;
                var4 += 3;
                break;
            case 5:
                var4 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                break;
            case 6:
                if (var2[0] == 0L) {
                    var4 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                } else {
                    var4 += 2;
                }
                break;
            case 7:
                if (var2[0] != 0L) {
                    var4 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                } else {
                    var4 += 2;
                }
                break;
            case 8:
                var8 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                var9 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 2];
                var2[var8] ^= var2[var9];
                var4 += 3;
                break;
            case 9:
                var8 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                var9 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 2];
                var2[var8] |= var2[var9];
                var4 += 3;
            case 10:
            case 11:
            case 12:
            case 13:
            case 14:
            case 15:
            default:
                break;
            case 16:
                var8 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                var9 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 2];
                var2[var8] &= var2[var9];
                var4 += 3;
                break;
            case 17:
                var8 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                --var6;
                var2[var8] = var5[var6];
                var4 += 2;
                break;
            case 18:
                var8 = "net.redpwn.ctf.JavaIsEZ3".d<invokedynamic>("net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L)[var4 + 1];
                var5[var6++] = var2[var8];
                var4 += 2;
                break;
            case 19:
                return var2[0] == 0L;
            }
        }
    }

    private static void kanbaru(char[][] var0) {
        for(int var1 = 0; var1 < var0.a<invokedynamic>(var0, "net.redpwn.ctf.JavaIsEZ3", -4280091229029863812L) - 1; ++var1) {
            char[] var2 = var0[var1];
            char[] var3 = var0[var1 + 1];

            for(int var4 = 0; var4 < var2.a<invokedynamic>(var2, "net.redpwn.ctf.JavaIsEZ3", -4280091229029863812L); ++var4) {
                var3[var4] ^= var2[var4];
            }
        }

    }

    public static int hachikuji(Object var0) {
        try {
            String var4 = "java.lang.reflect.Array";
            Class var1 = var4.a<invokedynamic>(var4, "java.lang.Class", -2913566224361156927L);
            Method var2 = var1.b<invokedynamic>(var1, "getLength", new Class[]{Object.class}, "java.lang.Class", -3289775410440245109L);
            var2.b<invokedynamic>(var2, true, "java.lang.reflect.Method", -2856230251947868740L);
            Integer var5 = (Integer)var2.b<invokedynamic>(var2, (Object)null, new Object[]{var0}, "java.lang.reflect.Method", -5083925746546271785L);
            return var5.b<invokedynamic>(var5, "java.lang.Integer", 2388217054567176175L);
        } catch (Throwable var3) {
            throw new RuntimeException("Ayaya");
        }
    }

    public static void main(String[] var0) {
        if (var0.a<invokedynamic>(var0, "net.redpwn.ctf.JavaIsEZ3", -4280091229029863812L) == 0) {
            try {
                "javax.swing.UIManager".a<invokedynamic>("javax.swing.UIManager", 8560971300846057061L).a<invokedynamic>("javax.swing.UIManager".a<invokedynamic>("javax.swing.UIManager", 8560971300846057061L), "javax.swing.UIManager", 1235598990591485937L);
                null.a<invokedynamic>((Object)null, "Silly-churl, billy-churl, silly-billy hilichurl... Woooh!\n~A certain Wangsheng Funeral Parlor director\n\n(This is not the flag, btw)", "javax.swing.JOptionPane", -8331272066798825690L);
            } catch (Throwable var5) {
            }
        } else {
            if (var0[0].b<invokedynamic>(var0[0], "java.lang.String", -4751795797312301073L) != 48) {
                "java.lang.System".d<invokedynamic>("java.lang.System", 474225325441265L).b<invokedynamic>("java.lang.System".d<invokedynamic>("java.lang.System", 474225325441265L), "*fanfare* You've been pranked!", "java.io.PrintStream", -1351703383126743055L);
                return;
            }

            String var6 = "WalnutGirlBestGirl_07/15";
            char[] var1 = var6.b<invokedynamic>(var6, "java.lang.String", 3921157978488572744L);
            char[][] var2 = new char[][]{var1, null, null};
            int var3 = var0[0].b<invokedynamic>(var0[0], "java.lang.String", -4751795797312301073L) / 2;

            for(int var4 = 0; var4 < 2; ++var4) {
                int var10001 = var4 + 1;
                String var10002 = var0[0].b<invokedynamic>(var0[0], var4 * var3, (var4 + 1) * var3, "java.lang.String", 2278661231839426149L);
                var2[var10001] = var10002.b<invokedynamic>(var10002, "java.lang.String", 3921157978488572744L);
            }

            var2.a<invokedynamic>(var2, "net.redpwn.ctf.JavaIsEZ3", -4036077825718603401L);
            if (var2.a<invokedynamic>(var2, "net.redpwn.ctf.JavaIsEZ3", -4328141322681971509L) & var2.a<invokedynamic>(var2, "net.redpwn.ctf.JavaIsEZ3", 8504114058794503371L) & var0[0].b<invokedynamic>(var0[0], "java.lang.String", 634352354493306863L) == 1101317042) {
                "java.lang.System".d<invokedynamic>("java.lang.System", 474225325441265L).b<invokedynamic>("java.lang.System".d<invokedynamic>("java.lang.System", 474225325441265L), "Chute.  Now you know my secret", "java.io.PrintStream", -1351703383126743055L);
            } else {
                "java.lang.System".d<invokedynamic>("java.lang.System", 474225325441265L).b<invokedynamic>("java.lang.System".d<invokedynamic>("java.lang.System", 474225325441265L), "*fanfare* You've been pranked!", "java.io.PrintStream", -1351703383126743055L);
            }
        }

    }

    static {
        (new byte[]{3, 88, 72, 7, 83, 3, 2, 70, 7, 70, 3, 43, 10, 46, 76, 3, 42, 0, 117, 5, 3, 9, 5, 113, 24, 3, 54, 24, 10, 28, 1, 0, 4, 1, 1, 4, 1, 2, 4, 1, 3, 4, 1, 4, 4, 1, 5, 4, 2}).f<invokedynamic>(new byte[]{3, 88, 72, 7, 83, 3, 2, 70, 7, 70, 3, 43, 10, 46, 76, 3, 42, 0, 117, 5, 3, 9, 5, 113, 24, 3, 54, 24, 10, 28, 1, 0, 4, 1, 1, 4, 1, 2, 4, 1, 3, 4, 1, 4, 4, 1, 5, 4, 2}, "net.redpwn.ctf.JavaIsEZ3", -3219759681266250937L);
        (new int[]{1, 102, 214, 57, 24, 0, 118, 112, 88, 118, 107, 110, 50, 46, 0, 113, 67, 20, 106, 112, 110, 31, 33, 0, 109, 102, 121, 67, 57, 77, 57, 109, 17, 4, 17, 5, 17, 6, 17, 7, 5, 47, 3, 1, 17, 0, 19, 4, 0, 4, 7, 42, 4, 1, 5, 7, 42, 4, 2, 6, 7, 42, 4, 3, 7, 7, 42, 19}).f<invokedynamic>(new int[]{1, 102, 214, 57, 24, 0, 118, 112, 88, 118, 107, 110, 50, 46, 0, 113, 67, 20, 106, 112, 110, 31, 33, 0, 109, 102, 121, 67, 57, 77, 57, 109, 17, 4, 17, 5, 17, 6, 17, 7, 5, 47, 3, 1, 17, 0, 19, 4, 0, 4, 7, 42, 4, 1, 5, 7, 42, 4, 2, 6, 7, 42, 4, 3, 7, 7, 42, 19}, "net.redpwn.ctf.JavaIsEZ3", -5227033679506699442L);
    }
}

And yes, you did just see “Ayaya”.

6. Defeating invokedynamic

If you’ve been following along up to now, you have most definitely been wondering what <invokedynamic> is and for that I give you this: https://stackoverflow.com/a/6638764.

Now, here’s the rub about invokedynamic instructions: only a few patterns of them decompile nicely. Notable examples of nicely-decompilable ones involve lambdas and the others are for Java 11 string concatenation. Almost everything else does not decompile into valid Java code. Because of this simple fact, many Java obfuscators abuse invokedynamic instructions to make decompiled Java very messy. What you are seeing in my decompiled sample is Fernflower’s attempt to show something somewhat reasonable and for our purposes, it will be sufficient for the most part.

If you read the SO answer I linked as well as the the blog post linked in the SO answer, then you will have a general idea of what I am doing throughout the rest of the attack on the invokedynamic setup. The various types of CallSites can also found here: https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/invoke/CallSite.html

First, we will need to locate the bootstrap method because that tells us how the CallSite for each invokedynamic instruction is resolved. Because Fernflower isn’t nice enough to show us that, we will need to peek at the bytecode which means we need a bytecode editor of some sort. Personally, I recommend you use either Recaf or Krakatau disassembler for working with obfuscated samples. You can also use javap, but you will need to resolve the illegal method/field generics signatures before you can do by whatever means. From here on out, though, I will be using Recaf’s disassembler to aid me in attacking the invokedynamics.

Let’s take the first occurence of an invokedynamic instruction in the main method of the program:

ALOAD 0
LDC "net.redpwn.ctf.JavaIsEZ3"
LDC -4280091229029863812L
INVOKEDYNAMIC a (Ljava/lang/Object;Ljava/lang/Object;J)I handle[H_INVOKESTATIC net/redpwn/ctf/suo.oshino(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;] args[]

So as we can see, this invokedynamic instruction pops 3 objects (technically 4 due to how longs/doubles work in the JVM) off the stack. It is then bootstrapped by net.redpwn.ctf.suo.oshino(). Let’s take a look at that method.

public static Object oshino(Object var0, Object var1, Object var2) {
    MutableCallSite var3 = new MutableCallSite((MethodType)var2);

    ConstantCallSite var10000;
    ConstantCallSite var10001;
    MethodHandle var10002;
    try {
        var3.setTarget(MethodHandles.explicitCastArguments(MethodHandles.insertArguments("teori".asCollector(Object[].class, ((MethodType)var2).parameterCount()), 0, new Object[]{var0, var1, var2, var3}), (MethodType)var2));
        var10000 = new ConstantCallSite;
        var10001 = var10000;
        var10002 = var3.getTarget();
    } catch (Exception var5) {
        var5.printStackTrace();
        return null;
    }

    var10001.<init>(var10002);
    return var10000;
}

As you might notice, Fernflower kinda died on decompilation. Let’s take a look at the bytecode to see why. Here is the first part of the method:

NEW java/lang/invoke/MutableCallSite
DUP
ALOAD 2
CHECKCAST java/lang/invoke/MethodType
INVOKESPECIAL java/lang/invoke/MutableCallSite.<init>(Ljava/lang/invoke/MethodType;)V
ASTORE 3

This is the bytecode equivalent of MutableCallSite var3 = new MutableCallSite((MethodType)var2). As we can see, it initializes a new MutableCallSite and passes in the second method argument casted to MethodType and assigns the value to var3. What’s more interesting is the next couple of lines:

ALOAD 3
LDC handle[H_INVOKESTATIC net/redpwn/ctf/suo.teori(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MutableCallSite;[Ljava/lang/Object;)Ljava/lang/Object;]
LDC [Ljava/lang/Object;
ALOAD 2
CHECKCAST java/lang/invoke/MethodType
INVOKEVIRTUAL java/lang/invoke/MethodType.parameterCount()I
INVOKEVIRTUAL java/lang/invoke/MethodHandle.asCollector(Ljava/lang/Class;I)Ljava/lang/invoke/MethodHandle;
ICONST_0
ICONST_4
ANEWARRAY java/lang/Object
...
AASTORE
INVOKESTATIC java/lang/invoke/MethodHandles.insertArguments(Ljava/lang/invoke/MethodHandle;I[Ljava/lang/Object;)Ljava/lang/invoke/MethodHandle;
ALOAD 2
CHECKCAST java/lang/invoke/MethodType
INVOKESTATIC java/lang/invoke/MethodHandles.explicitCastArguments(Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/MethodHandle;
INVOKEVIRTUAL java/lang/invoke/MutableCallSite.setTarget(Ljava/lang/invoke/MethodHandle;)V

The reason Fernflower kinda died is because of something called a MethodHandle constant (in this case, it is the LDC handle... instruction). The JVM allows you to define ldc instructions such that you can load MethodHandles as constants. There is absolutely no semantic equivalent in Java as the closest you can come in Java significantly changes the callstack. Fortunately, unlike the string decryptor from earlier, the invokedynamic bootstrap resolver isn’t context-sensitive with regards to the callstack state so we can ignore this hindsight. The closest thing you will get is something like

MethodHandles.lookup().findStatic(suo.class, "teori", MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MutableCallSite;[Ljava/lang/Object;)Ljava/lang/Object;", null)

Anyways, with this in mind, we can sorta fix Fernflower’s output now (and rewrite it in a way that is actually legible):

public static Object oshino(Object var0, Object var1, Object var2) {
    MutableCallSite var3 = new MutableCallSite((MethodType)var2);
    try {
        MethodHandle ldcHandle = MethodHandles.lookup().findStatic(suo.class, "teori", MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MutableCallSite;[Ljava/lang/Object;)Ljava/lang/Object;", null);
        var3.setTarget(
            MethodHandles.explicitCastArguments(
                MethodHandles.insertArguments(
                    ldcHandle.asCollector(
                        Object[].class,
                        ((MethodType)var2).parameterCount()
                    ),
                    0,
                    new Object[]{var0, var1, var2, var3}
                ),
                (MethodType)var2
            )
        );
        return new ConstantCallSite(var3.getTarget());
    } catch (Exception var5) {
        var5.printStackTrace();
        return null;
    }
}

With some referene to the Java documentation, we can see that ldcHandle is subjected to MethodHandle#asCollector which creates an array-collecting MethodHandle that accepts a given number of trailing positional arguments and collects them into an array argument. It seems like ldcHandle, a MethodHandle to the teori() method is pretty important here. So let’s now turn our attention to that.

private static Object teori(Lookup var0, String var1, MethodType var2, MutableCallSite var3, Object[] var4) throws Throwable {
    char var5 = var1.charAt(0);
    long var6 = (Long)var4[var4.length - 1];
    int var8 = (int)(var6 >> 32);
    int var9 = (int)var6;
    String var10 = (String)var4[var4.length - 2];
    Class var11 = Class.forName(var10);
    MethodHandle var12;
    ...

Just from the first few lines, we can sort of already understand the structure of this method with respect to the invokedynamic instructions we have been seeing without having to fully understand the MethodHandle documentation. Here’s the one I pointed out earlier for reference:

ALOAD 0
LDC "net.redpwn.ctf.JavaIsEZ3"
LDC -4280091229029863812L
INVOKEDYNAMIC a (Ljava/lang/Object;Ljava/lang/Object;J)I handle[H_INVOKESTATIC net/redpwn/ctf/suo.oshino(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;] args[]

Notice that var10 is assigned the the second-to-last value in the array var4 which happens to be a string. In this particular invokedynamic instruction, we note that this matches up with LDC "net.redpwn.ctf.JavaIsEZ3" which positionally second-to-last in the dynamic invocation. Furthermore, we see that var6 is assigned the last value in the array var4 which happens to be a long integer. This matches up with LDC -4280091229029863812L which is positionally last in the dynamic invocation. Thus, we can now make the reasonable assumption that this invokedynamic instruction must resolve to either invoke some method or set/get some field in net.redpwn.ctf.JavaIsEZ3. Let’s continue on.

char var5 = var1.charAt(0);
...
MethodHandle var12;
if (var5 >= 'a' && var5 <= 'c') {
    Method var15 = mizudori(var11, var8, var9);
    if (var15 == null) {
        throw new NoSuchMethodException(var10 + ' ' + var1 + ' ' + var9);
    }

    MethodType var14 = MethodType.methodType(var15.getReturnType(), var15.getParameterTypes());
    if (var5 == 'a') {
        var12 = var0.unreflect(var15).asType(var14);
    } else if (var5 == 'b') {
        var12 = var0.unreflect(var15).asType(var14.insertParameterTypes(0, new Class[]{Object.class}));
    } else {
        var12 = var0.unreflectSpecial(var15, suo.class).asType(var14);
    }
} else {
    Field var13 = kagenui(var11, var8, var9);
    if (var13 == null) {
        throw new NoSuchFieldException(var10 + ' ' + var1 + ' ' + var9);
    }

    if (var5 == 'd') {
        var12 = var0.unreflectGetter(var13).asType(MethodType.methodType(var13.getType()));
    } else if (var5 == 'e') {
        var12 = var0.unreflectGetter(var13).asType(MethodType.methodType(var13.getType()).insertParameterTypes(0, new Class[]{Object.class}));
    } else if (var5 == 'f') {
        var12 = var0.unreflectSetter(var13).asType(MethodType.methodType(Void.TYPE, var13.getType()));
    } else {
        var12 = var0.unreflectSetter(var13).asType(MethodType.methodType(Void.TYPE, var13.getType()).insertParameterTypes(0, new Class[]{Object.class}));
    }
}

Ah, this looks like some pretty important meat. The program first figures out whether the invokedynamic instruction should resolve to a method invocation or a field getter/setter via the value of var5. If you’ve done you’re homework, then you will know that var5 is just the first character of the name of the invokedynamic instruction. So if we use our same example, we know that we’re looking at a method invocation because INVOKEDYNAMIC a (Ljava/lang/Object... indicates that a is the name of the invokedynamic instruction. Here’s the same example in case you got lost:

ALOAD 0
LDC "net.redpwn.ctf.JavaIsEZ3"
LDC -4280091229029863812L
INVOKEDYNAMIC a (Ljava/lang/Object;Ljava/lang/Object;J)I handle[H_INVOKESTATIC net/redpwn/ctf/suo.oshino(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;] args[]

So following along the execution path, we see that the mizudori() method returns something with a type of java.lang.reflect.Method. This seems of interest so let’s also jump into that.

private static Method mizudori(Class var0, int var1, int var2) {
    redpwnCTF2021 var10000 = (redpwnCTF2021)null;
    Method[] var3 = var0.getDeclaredMethods();

    Method var4;
    for(int var5 = 0; var5 < var3.length; ++var5) {
        if (var3[var5].getName().hashCode() == var1 && oikura(var3[var5]) == var2) {
            var4 = var3[var5];
            var4.setAccessible(true);
            return var4;
        }
    }

    if (var0.getInterfaces() != null) {
        Class[] var9 = var0.getInterfaces();
        int var6 = var9.length;

        for(int var7 = 0; var7 < var6; ++var7) {
            Class var8 = var9[var7];
            var4 = mizudori(var8, var1, var2);
            if (var4 != null) {
                var4.setAccessible(true);
                return var4;
            }
        }
    }

    do {
        if (var0.getSuperclass() == null) {
            return null;
        }

        var0 = var0.getSuperclass();
        var4 = mizudori(var0, var1, var2);
    } while(var4 == null);

    var4.setAccessible(true);
    return var4;
}

From this method, we notice that the program searches for a Method in var0 whose string name hashcode is equal to var1. If one cannot be found, then it searches implemented interfaces or the super class for the Method in question via the same matter. This is sort of pepega because if you know anything about how Java computes the hashcode for a string, you will know that it is lossy. We also note that this method calls oikura. Peeking at that, we can see that oikura computes a key based on the hashcode of each of the parameters of the Method in question as well as its return type.

private static int oikura(Method var0) {
    int var1 = 0;

    for(int var2 = 0; var2 < var0.getParameterCount(); ++var2) {
        var1 ^= var0.getParameterTypes()[var2].getName().hashCode();
    }

    var1 ^= var0.getReturnType().getName().hashCode();
    return var1;
}

However, this doesn’t get around the fact that Java string hashcodes are lossy. In other words, we cannot just go from hashcode to the original string – we need to do some trial-and-error matching; however, we can be smart about the way we do it. Let’s look at our invokedynamic instruction again:

ALOAD 0
LDC "net.redpwn.ctf.JavaIsEZ3"
LDC -4280091229029863812L
INVOKEDYNAMIC a (Ljava/lang/Object;Ljava/lang/Object;J)I handle[H_INVOKESTATIC net/redpwn/ctf/suo.oshino(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;] args[]

Based on what we have seen, we know that this instruction resolves to a method in net.redpwn.ctf.JavaIsEZ3 or one of its parent classes. Seeing as that net.redpwn.ctf.JavaIsEZ3 doesn’t extend or implement any classes, we know that we at most only have to check two classes for the desired method: net.redpwn.ctf.JavaIsEZ3 and java.lang.Object (all classes eventually inherit java.lang.Object at some point in the hierarchy with the except of java.lang.Object itself). Furthermore, we know that the signed long -4280091229029863812L is used to hold two hashcodes. After pulling up jshell, we can see that the two hashcodes are -996536396 (method name) and 1063841404 (parameter + return type name hashcode).

jshell> (int) (-4280091229029863812L >> 32)
$1 ==> -996536396

jshell> (int) (-4280091229029863812L >> 0)
$2 ==> 1063841404

Now we look at the method names in net.redpwn.ctf.JavaIsEZ3: oshino, sengoku, kanbaru, hachikuji, and main. Notice that none of these methods have the same name, so we may be able to get away with just checking method names instead checking both method names and parameter/return type names. Quick Java program to do figure out which one is the correct one gives hachikuji.

public static void main(String[] args) {
    String[] methodNames = {"oshino", "sengoku", "kanbaru", "hachikuji", "main"};
    for (String name : methodNames) {
        if (name.hashCode() == -996536396) {
            System.out.println(name);
        }
    }
}

Now that we know that the desired Method will be hachikuji, let’s back-track a bit back to over here:

private static Object teori(Lookup var0, String var1, MethodType var2, MutableCallSite var3, Object[] var4) throws Throwable {
    char var5 = var1.charAt(0);
    long var6 = (Long)var4[var4.length - 1];
    int var8 = (int)(var6 >> 32);
    int var9 = (int)var6;
    String var10 = (String)var4[var4.length - 2];
    Class var11 = Class.forName(var10);
    MethodHandle var12;
    if (var5 >= 'a' && var5 <= 'c') {
        Method var15 = mizudori(var11, var8, var9);
        if (var15 == null) {
            throw new NoSuchMethodException(var10 + ' ' + var1 + ' ' + var9);
        }

        MethodType var14 = MethodType.methodType(var15.getReturnType(), var15.getParameterTypes());
        if (var5 == 'a') {
            var12 = var0.unreflect(var15).asType(var14);
        } else if (var5 == 'b') {
            var12 = var0.unreflect(var15).asType(var14.insertParameterTypes(0, new Class[]{Object.class}));
        } else {
            var12 = var0.unreflectSpecial(var15, suo.class).asType(var14);
        }
    } else {
        ...
    }

    return MethodHandles.dropArguments(var12, var2.parameterCount() - 2, new Class[]{String.class, Long.TYPE}).asSpreader(Object[].class, var4.length).invoke(var4);
}

Now we see that var12 is set to the result of var0.unreflect(var15).asType(var14). Then, the method drops the two arguments which provide the class to search and the long containing the two hashcodes and invokes the result. This means that net.redpwn.ctf.JavaIsEZ3 and -4280091229029863812L do not actually appear in the final (unobfusated) program but are only used to resolve the MethodHandle to the correct method. Thus, we can look at our current decompiled result in the program and replace

public static void main(String[] var0) {
        if (var0.a<invokedynamic>(var0, "net.redpwn.ctf.JavaIsEZ3", -4280091229029863812L) == 0) {

with

public static void main(String[] var0) {
        if (hachikuji(var0) == 0) {

Furthermore, we also notice that there are many occurences of someVar.a<invokedynamic>(someVar, "net.redpwn.ctf.JavaIsEZ3", -4280091229029863812L). Because we know that this particular invokedynamic will always resolve to hachikuji, we can replace each of those with hachikuji(someVar).

Before we call it a day on invokedynamics, I need to address a couple more things. First, let’s look at this more interesting example that is, again, from the main method:

if (var0[0].b<invokedynamic>(var0[0], "java.lang.String", -4751795797312301073L) != 48) {
    // Ignored all the invokedynamics below, we're looking only at `b<invokedynamic>(var0[0], "java.lang.String", -4751795797312301073L)`
    "java.lang.System".d<invokedynamic>("java.lang.System", 474225325441265L).b<invokedynamic>("java.lang.System".d<invokedynamic>("java.lang.System", 474225325441265L), "*fanfare* You've been pranked!", "java.io.PrintStream", -1351703383126743055L);
    return;
}

It should be pretty obvious that something is being done to var0[0], but it’s not completely clear what. Also, unlike our previous example, this invokedynamic instruction has a name of b. So let us revisit our evilgood friend again:

private static Object teori(Lookup var0, String var1, MethodType var2, MutableCallSite var3, Object[] var4) throws Throwable {
    char var5 = var1.charAt(0);
    long var6 = (Long)var4[var4.length - 1];
    int var8 = (int)(var6 >> 32);
    int var9 = (int)var6;
    String var10 = (String)var4[var4.length - 2];
    Class var11 = Class.forName(var10);
    MethodHandle var12;
    if (var5 >= 'a' && var5 <= 'c') {
        Method var15 = mizudori(var11, var8, var9);
        if (var15 == null) {
            throw new NoSuchMethodException(var10 + ' ' + var1 + ' ' + var9);
        }

        MethodType var14 = MethodType.methodType(var15.getReturnType(), var15.getParameterTypes());
        if (var5 == 'a') {
            var12 = var0.unreflect(var15).asType(var14);
        } else if (var5 == 'b') { // Looking specifically here
            var12 = var0.unreflect(var15).asType(var14.insertParameterTypes(0, new Class[]{Object.class}));
        } else {
            var12 = var0.unreflectSpecial(var15, suo.class).asType(var14);
        }
    } else {
        ...
    }

    return MethodHandles.dropArguments(var12, var2.parameterCount() - 2, new Class[]{String.class, Long.TYPE}).asSpreader(Object[].class, var4.length).invoke(var4);
}

This looks pretty similar to what we were looking at earlier; however, this time the program specifically inserts a parameter type at the beginning of the parameter list. This is confusing, so maybe we should attempt to figure out what exactly is going on. Now, unlike the previous example, java.lang.String has a fair number of overloaded methods (and a lot of methods in general), so it’s actually quite a bit faster to cheese by copy-pasting some of the program’s code (specifically the mizudori and oikura methods) into a new script and having Java locate those methods for you. Here’s how I would do it:

public static void main(String[] args) throws Throwable {
    Method method = mizudori(String.class, (int) (-4751795797312301073L >> 32), (int) (-4751795797312301073L >> 0));
    System.out.println(method);
    // Output:  public int java.lang.String.length()
}

Very clearly, we can then replace var0[0].b<invokedynamic>(var0[0], "java.lang.String", -4751795797312301073L) != 48 with var0[].length() != 48, but this still doesn’t answer our question earlier of why an Object parameter is inserted at index 0. The reason for that was because String#length() is a virtual (non-static) method. To understand why that makes such a difference, let me show you what that looks like in bytecode-land:

ALOAD 0
ICONST_0
AALOAD // var0[0]
INVOKEVIRTUAL java/lang/String.length()I

This is what var0[].length() looks like in bytecode and what you should be noticing here is that there is an additional object pushed on the stack despite String#length() taking no arguments. This is because invokevirtual instructions implicitly pop an additional word off the stack as the instance upon which the virtual method is invoked. This is also what allows the this keyword in Java to work: the zeroth slot in any virtual method is always the instance upon which you are invoking (var0[0] in this case). If you are familiar with Python, you will notice that Java is internally very similar to Python in this aspect in that Python forces virtual methods to have the self keyword as the first parameter.

Finally, the last thing I would like to address is fields: You can get and set fields via MethodHandle’s. Reversing invokedynamic’d fields is very similar to reversing invokedynamics to methods so I’ll leave that as an exercise to the reader.

Once you get through all of the invokedynamic instructions, you should be left with something like the following:

public class JavaIsEZ3 {
    private static byte[] araragi;
    private static int[] hitagi;

    private static boolean oshino(char[][] var0) {
        char[] var1 = var0[1];
        String var12 = new String(var1);
        if (var12.hashCode() != 998474623) {
            return false;
        } else {
            int[] var2 = new int[6];
            int var3 = 0;

            int var4;
            for(var4 = 0; var4 < hachikuji(var1); var4 += 4) {
                var2[var3++] = (var1[var4] << 24 | var1[var4 + 1] << 16 | var1[var4 + 2] << 8 | var1[var4 + 3]) ^ 118818581;
            }

            var4 = 0;
            int[] var5 = new int[15];
            int var6 = 0;
            boolean var7 = true;

            while(true) {
                byte var8 = araragi[var4];
                byte var9;
                int var11;
                switch(var8) {
                case 0:
                    var9 = araragi[var4 + 1];
                    --var6;
                    var2[var9] = var5[var6];
                    var4 += 2;
                    break;
                case 1:
                    var9 = araragi[var4 + 1];
                    var5[var6++] = var2[var9];
                    var4 += 2;
                    break;
                case 2:
                    return var7;
                case 3:
                    var11 = araragi[var4 + 1] << 24 | araragi[var4 + 2] << 16 | araragi[var4 + 3] << 8 | araragi[var4 + 4];
                    var5[var6++] = var11;
                    var4 += 5;
                    break;
                case 4:
                    --var6;
                    var11 = var5[var6];
                    --var6;
                    int var10 = var5[var6];
                    var7 &= var10 == var11;
                    ++var4;
                    break;
                case 5:
                    var11 = araragi[var4 + 1] << 8 | araragi[var4 + 2];
                    var5[var6++] = var11;
                    var4 += 3;
                    break;
                case 6:
                    var9 = araragi[var4 + 1];
                    var5[var6++] = var9;
                    var4 += 2;
                }
            }
        }
    }

    private static boolean sengoku(char[][] var0) {
        char[] var1 = var0[2];
        long[] var2 = new long[15];
        int var3 = 0;

        int var4;
        for(var4 = 0; var4 < hachikuji(var1); var4 += 8) {
            var2[var3++] = ((long)var1[var4] << 56 | (long)var1[var4 + 1] << 48 | (long)var1[var4 + 2] << 40 | (long)var1[var4 + 3] << 32 | (long)var1[var4 + 4] << 24 | (long)var1[var4 + 5] << 16 | (long)var1[var4 + 6] << 8 | (long)var1[var4 + 7]) ^ 216743518893377301L;
        }

        String var10002 = new String(var1);
        var2[var3] = (long)var10002.hashCode();
        var4 = 0;
        long[] var5 = new long[15];
        int var6 = 0;

        while(true) {
            int var7 = hitagi[var4];
            int var8;
            int var9;
            long var10;
            switch(var7) {
            case 0:
                var10 = (long)hitagi[var4 + 1] << 56 | (long)hitagi[var4 + 2] << 48 | (long)hitagi[var4 + 3] << 40 | (long)hitagi[var4 + 4] << 32 | (long)hitagi[var4 + 5] << 24 | (long)hitagi[var4 + 6] << 16 | (long)hitagi[var4 + 7] << 8 | (long)hitagi[var4 + 8];
                var5[var6++] = var10;
                var4 += 9;
                break;
            case 1:
                var10 = (long)hitagi[var4 + 1] << 24 | (long)hitagi[var4 + 2] << 16 | (long)hitagi[var4 + 3] << 8 | (long)hitagi[var4 + 4];
                var5[var6++] = var10;
                var4 += 5;
                break;
            case 2:
                var10 = (long)hitagi[var4 + 1] << 8 | (long)hitagi[var4 + 2];
                var5[var6++] = var10;
                var4 += 3;
                break;
            case 3:
                var10 = (long)hitagi[var4 + 1];
                var5[var6++] = var10;
                var4 += 2;
                break;
            case 4:
                var8 = hitagi[var4 + 1];
                var9 = hitagi[var4 + 2];
                var2[0] = var2[var8] == var2[var9] ? 0L : 1L;
                var4 += 3;
                break;
            case 5:
                var4 = hitagi[var4 + 1];
                break;
            case 6:
                if (var2[0] == 0L) {
                    var4 = hitagi[var4 + 1];
                } else {
                    var4 += 2;
                }
                break;
            case 7:
                if (var2[0] != 0L) {
                    var4 = hitagi[var4 + 1];
                } else {
                    var4 += 2;
                }
                break;
            case 8:
                var8 = hitagi[var4 + 1];
                var9 = hitagi[var4 + 2];
                var2[var8] ^= var2[var9];
                var4 += 3;
                break;
            case 9:
                var8 = hitagi[var4 + 1];
                var9 = hitagi[var4 + 2];
                var2[var8] |= var2[var9];
                var4 += 3;
            case 10:
            case 11:
            case 12:
            case 13:
            case 14:
            case 15:
            default:
                break;
            case 16:
                var8 = hitagi[var4 + 1];
                var9 = hitagi[var4 + 2];
                var2[var8] &= var2[var9];
                var4 += 3;
                break;
            case 17:
                var8 = hitagi[var4 + 1];
                --var6;
                var2[var8] = var5[var6];
                var4 += 2;
                break;
            case 18:
                var8 = hitagi[var4 + 1];
                var5[var6++] = var2[var8];
                var4 += 2;
                break;
            case 19:
                return var2[0] == 0L;
            }
        }
    }

    private static void kanbaru(char[][] var0) {
        for(int var1 = 0; var1 < hachikuji(var0) - 1; ++var1) {
            char[] var2 = var0[var1];
            char[] var3 = var0[var1 + 1];

            for(int var4 = 0; var4 < hachikuji(var2); ++var4) {
                var3[var4] ^= var2[var4];
            }
        }

    }

    public static int hachikuji(Object var0) {
        try {
            String var4 = "java.lang.reflect.Array";
            Class var1 = Class.forName(var4);
            Method var2 = var1.getDeclaredMethod("getLength", new Class[]{Object.class});
            var2.setAccessible(true);
            Integer var5 = (Integer)var2.invoke((Object)null, new Object[]{var0});
            return var5.intValue();
        } catch (Throwable var3) {
            throw new RuntimeException("Ayaya");
        }
    }

    public static void main(String[] var0) {
        if (hachikuji(args) == 0) {
            try {
                UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                JOptionPane.showMessageDialog("Silly-churl, billy-churl, silly-billy hilichurl... Woooh!\n~A certain Wangsheng Funeral Parlor director\n\n(This is not the flag, btw)");
            } catch (Throwable var5) {
            }
        } else {
            if (var0[0].length() != 48) {
                System.out.println("*fanfare* You've been pranked!");
                return;
            }

            String var6 = "WalnutGirlBestGirl_07/15";
            char[] var1 = var6.toCharArray();
            char[][] var2 = new char[][]{var1, null, null};
            int var3 = var0[0].length() / 2;

            for(int var4 = 0; var4 < 2; ++var4) {
                int var10001 = var4 + 1;
                String var10002 = var0[0].substring(var4 * var3, (var4 + 1) * var3);
                var2[var10001] = var10002.toCharArray();
            }

            kanbaru(var2);
            if (oshino(var2) & sengoku(var2) & (var0[0].hashCode() == 1101317042)) {
                System.out.println("Chute.  Now you know my secret");
            } else {
                System.out.println("*fanfare* You've been pranked!");
            }
        }

    }

    static {
        araragi = new byte[]{3, 88, 72, 7, 83, 3, 2, 70, 7, 70, 3, 43, 10, 46, 76, 3, 42, 0, 117, 5, 3, 9, 5, 113, 24, 3, 54, 24, 10, 28, 1, 0, 4, 1, 1, 4, 1, 2, 4, 1, 3, 4, 1, 4, 4, 1, 5, 4, 2};
        hitagi = new int[]{1, 102, 214, 57, 24, 0, 118, 112, 88, 118, 107, 110, 50, 46, 0, 113, 67, 20, 106, 112, 110, 31, 33, 0, 109, 102, 121, 67, 57, 77, 57, 109, 17, 4, 17, 5, 17, 6, 17, 7, 5, 47, 3, 1, 17, 0, 19, 4, 0, 4, 7, 42, 4, 1, 5, 7, 42, 4, 2, 6, 7, 42, 4, 3, 7, 7, 42, 19};
    }
}

7. Quick analysis of deobfuscated program (main method)

Now that we pretty much have fully deobfuscated everything, we can get to some actual reversing. Following along the main method, we can see that the program checks if the input string (assuming you passed one in) is 48 characters long. Later in the code, we can see that the input’s hashcode is compared against the value 1101317042. Again, because hashcodes are lossy, we cannot reconstruct the flag as is.

The program proceeds to split the user input in half and shove it into an array of 3 character arrays var2, the first element is "WalnutGirlBestGirl_07/15".toCharArray() and the second and third elements are the two halves of the flag, respectively. We then see that the method kanbaru is called on var2. Looking at the code of kanbaru, we see that it is a chaining block cipher (with no cipher or key, lol) with WalnutGirlBestGirl_07/15 as the IV. Afterwords, oshino and sengoku both perform some kind of checking on var2.

Before moving on, I would like to bring our attention to hachikuji. Note that it is just a reflection equivalent of

public static int hachikuji(Object arrObject) {
    try {
        return Array.getLength(arrObject);
    } catch (Throwable t) {
        throw new RuntimeException("Ayaya!");
    }
}

In other words, it just returns the length of an arbitrary array. Alright, onto oshino!

8. Reversing oshino’s VM

We notice that the first thing oshino does is convert the second element (var1) of the array of character arrays (i.e. the first half of the user input) back into a string and compares its hashcode to 998474623. Still lossy so no hope there. Then, we run into the following

int[] var2 = new int[6];
int var3 = 0;

int var4;
for(var4 = 0; var4 < hachikuji(var1); var4 += 4) {
    var2[var3++] = (var1[var4] << 24 | var1[var4 + 1] << 16 | var1[var4 + 2] << 8 | var1[var4 + 3]) ^ 118818581; // 0x07150715
}

Seeing as that var1 is the first half of the user’s input (which is of length 48 / 2 = 24), this loop creates six 4-byte integers by OR-ing the individual character bytes together then XORs the result with 118818581 or 0x07150715 in hex. Looking at the rest of the method, we can see that the main meat of the check is a simple stack VM. This allows us to decode the instructions stored in araragi as so:

0x3, 0x58, 0x48, 0x07, 0x53, // push 0x58480753
0x3, 0x02, 0x46, 0x07, 0x46, // push 0x02460746
0x3, 0x2B, 0x0A, 0x2E, 0x4C, // push 0x2B0A2E4C
0x3, 0x2A, 0x00, 0x75, 0x05, // push 0x2A007505
0x3, 0x09, 0x05, 0x71, 0x18, // push 0x09057118
0x3, 0x36, 0x18, 0x0A, 0x1C, // push 0x36180A1C
0x1, 0x0, // load register[0]
0x4, // compare
0x1, 0x1, // load register[1]
0x4, // compare
0x1, 0x2, // load register[2]
0x4, // compare
0x1, 0x3, // load register[3]
0x4, // compare
0x1, 0x4, // load register[4]
0x4, // compare
0x1, 0x5, // load register[5]
0x4, // compare
0x2  // kill vm

If any of the comparison checks fail, then var7 is set to false. So, in other words, our CBC’d version of the first half of the user’s input’s bytes must match up against the six 4-byte integers pushed on the VMs stack. Here is a Java equivalent:

boolean correct = true;
correct = r[0] == 0x36180A1C;
correct = r[1] == 0x09057118;
correct = r[2] == 0x2A007505;
correct = r[3] == 0x2B0A2E4C;
correct = r[4] == 0x02460746;
correct = r[5] == 0x58480753;
return correct;

Let’s move onto sengoku now.

9. Reversing sengoku’s VM

sengoku entire method operates in a very similar way to oshino’s does, though, sengoku’s VM has a different architecture. sengokus VM operates instead on 8-byte integers (or longs in Java-land) and is considerably less stack-based than oshino’s VM. sengoku’s VM also allows for flow control opcodes, which leads to a slightly more difficult reversing task. However, with some patience, we get the following:

/* 0 */ 0x1, 0x66, 0xD6, 0x39, 0x18, // push 0x66D63918
/* 5 */ 0x0, 0x76, 0x70, 0x58, 0x76, 0x6B, 0x6E, 0x32, 0x2E, // push 0x767058766B6E322E
/* 14 */ 0x0, 0x71, 0x43, 0x14, 0x6A, 0x70, 0x6E, 0x1F, 0x21, // push 0x7143146A706E1F21
/* 23 */ 0x0, 0x6D, 0x66, 0x79, 0x43, 0x39, 0x4D, 0x39, 0x6D, // push 0x6D667943394D396D
/* 32 */ 0x11, 0x4, // assign register[4]
/* 34 */ 0x11, 0x5, // assign register[5]
/* 36 */ 0x11, 0x6, // assign register[6]
/* 38 */ 0x11, 0x7, // assign register[7]
/* 40 */ 0x5, 47, // jmp 47
/* 42 */ 0x3, 0x1, // push 0x1
/* 44 */ 0x11, 0x0, // assign register[0]
/* 46 */ 0x13, // kill vm
/* 47 */ 0x4, 0x0, 0x4, // cmp r0, r4
/* 50 */ 0x7, 42, // jnz 42
/* 52 */ 0x4, 0x1, 0x5, // cmp r1, r5
/* 55 */ 0x7, 42, // jnz 42
/* 57 */ 0x4, 0x2, 0x6, // cmp r2, r6
/* 60 */ 0x7, 42, // jnz 42
/* 62 */ 0x4, 0x3, 0x7, // cmp r3, r7
/* 65 */ 0x7, 42, // jnz 42
/* 67 */ 0x13 // kill vm

Earlier in the method, we see that

char[] var1 = var0[2];
long[] var2 = new long[15]; // registers
int var3 = 0;

int var4;
for(var4 = 0; var4 < hachikuji(var1); var4 += 8) {
    var2[var3++] = ((long)var1[var4] << 56 | (long)var1[var4 + 1] << 48 | (long)var1[var4 + 2] << 40 | (long)var1[var4 + 3] << 32 | (long)var1[var4 + 4] << 24 | (long)var1[var4 + 5] << 16 | (long)var1[var4 + 6] << 8 | (long)var1[var4 + 7]) ^ 216743518893377301L;
}
String var10002 = new String(var1);
var2[var3] = (long)var10002.hashCode();

So var1 is assigned the second half of the user’s input. Furthermore, the user’s ciphered input is shoved into the first 3 registers as 8-byte integers (24 / 8 = 3) via the for loop and var2[3] is assigned the value of the hashcode of the second half of the user’s input. A Java equivalent of the VM code would be

r[4] = 0x6D667943394D396D;
r[5] = 0x7143146A706E1F21;
r[6] = 0x767058766B6E322E;
r[7] = 0x66D63918;
if (r[0] != r[4]) {
    return false;
}
if (r[1] != r[5]) {
    return false
}
if (r[2] != r[6]) {
    return false
}
if (r[3] != r[7]) {
    return false
}
return true;

10. FLAG!!!!!

Since user’s input is ciphered via CBC and oshino and sengoku do what are essentially fancy byte-by-byte checking, we can extract the flag from the program by going through the process in reverse. First, we will take the integers from oshino and sengoku that are compared against ciphered user input, and reconstruct the two ciphered halves of the flag. Then, we will undo the chaining block cipher, and we should get our flag.

public class Solve {
    private static char[] list2Array(List<Character> list) { // cuz Java sucks
        var arr = new char[list.size()];
        for (var i = 0; i < arr.length; i++) {
            arr[i] = list.get(i);
        }
        return arr;
    }

    private static void uncipher(char[][] substrings) {
        for (int i = substrings.length - 1; i > 0; i--) {
            char[] first = substrings[i - 1];
            char[] second = substrings[i];
            for (int j = 0; j < first.length; j++) {
                second[j] ^= first[j];
            }
        }
    }

    private static char[] reconstructFirstCipheredArray() {
        int[] ints = new int[]{
                0x36180A1C,
                0x09057118,
                0x2A007505,
                0x2B0A2E4C,
                0x02460746,
                0x58480753
        };

        var cipheredArray = new ArrayList<Character>();
        for (var i = 0; i < ints.length; i++) {
            int unXord = ints[i] ^ 0x07150715;

            cipheredArray.add((char) ((unXord >> 24) & 0xff));
            cipheredArray.add((char) ((unXord >> 16) & 0xff));
            cipheredArray.add((char) ((unXord >> 8) & 0xff));
            cipheredArray.add((char) ((unXord >> 0) & 0xff));
        }
        return list2Array(cipheredArray);
    }

    private static char[] reconstructSecondCipheredArray() {
        long[] longs = new long[]{
                0x6D667943394D396DL,
                0x7143146A706E1F21L,
                0x767058766B6E322EL
        };

        var cipheredArray = new ArrayList<Character>();
        for (var i = 0; i < longs.length; i++) {
            long unXord = longs[i] ^ 0x0302071503020715L;

            cipheredArray.add((char) ((unXord >> 56) & 0xff));
            cipheredArray.add((char) ((unXord >> 48) & 0xff));
            cipheredArray.add((char) ((unXord >> 40) & 0xff));
            cipheredArray.add((char) ((unXord >> 32) & 0xff));
            cipheredArray.add((char) ((unXord >> 24) & 0xff));
            cipheredArray.add((char) ((unXord >> 16) & 0xff));
            cipheredArray.add((char) ((unXord >> 8) & 0xff));
            cipheredArray.add((char) ((unXord >> 0) & 0xff));
        }
        return list2Array(cipheredArray);
    }

    private static String decipherFlag() {
        var parts = new char[][]{
                "WalnutGirlBestGirl_07/15".toCharArray(),
                reconstructFirstCipheredArray(),
                reconstructSecondCipheredArray()
        };
        uncipher(parts);
        return String.valueOf(parts[1]) +
                String.valueOf(parts[2]);
    }

    public static void main(String[] args) {
        System.out.println(decipherFlag());
    }
}

And indeed, we get our flag as desired: flag{d1d_y0u_kn0w?_chr1s_is_4_Hu_Tao_s1mp!_0715}

Of course, we should always check to make sure our flag actually works, so let’s do that now:

$ java -jar backup.jar "flag{d1d_y0u_kn0w?_chr1s_is_4_Hu_Tao_s1mp\!_0715}"
Chute.  Now you know my secret

11. Afterword

There were very, very, few solves on JavaIsEZ3 this year. This tells me that I may have set the technicality level on this challenge much higher than I should have. To any frustrated CTFers, if you felt that was the case, I am sorry that that was the case and am currently rethinking how to present JavaIsEZ4 (if I finish it by next year) in a way that is significantly less technical while being solvable by most CTFers.

There was a lot of manual work in this writeup which would lead any person to asking if there is a way to automate the process. The answer is yes, but not many tools exist to do so. For inspiration, I suggest you look at the java-deobfuscator tools which can be found on GitHub at: https://github.com/java-deobfuscator/. Something that may also help is https://github.com/GraxCode/threadtear. Both utilize the OW2 ASM libraries to parse, edit, and write Java bytecode.

Some competitors asked me what tools should be used for Java reversing (specifically decompilation and disassembling). I generally recommend people to use https://github.com/Col-E/Recaf because

  1. It is actively maintained.
  2. It is designed specifically to be able to handle obfuscated JARs.
  3. I have interacted with the developers so I have a bias.

Some other good tools are Krakatau (made by the legendary Robert Grosse) and java-disassembler (made by Bibl and cts). As for debuggers, none really exist unfortunately.

You might have noticed the concentration of Monogatari character names. If you have no idea what Monogatari is, read this tweet first: https://twitter.com/whitequark/status/868714238692478976.

And, above all else, I am indeed a Hu Tao simp.

Comments