前陣子我的一位朋友發來一份程式碼讓我幫忙看看。具體就是所有的jsp檔案內容和大小都一樣,漏洞挖掘無從下手。經過分析發現所有的Class都使用了自定義的加密工具加密,經過逆向分析,順利解密,因而有了此文。
檔案內容如下所示:
其他檔案亦如是:
接著在tomcat work目錄找到了編譯後的class檔案:
但是沒辦法直接反編譯,檢視頭資訊發現都一樣:
因此猜測一種可能性是Java層面實現的類載入器,類載入的時候進行動態解密操作。於是自己寫了一個jsp檔案上傳到目標環境,首先存取一下這個jsp檔案,讓JVM載入至記憶體中。然後我們呼叫Class.forName再去載入該類,獲取到該類的java.lang.Class物件範例,然後呼叫getClassLoader獲取該類的載入器:
<%
try {
out.println(Class.forName("org.apache.jsp.test_html").getClassLoader());
} catch(Throwable th) {
th.printStackTrace(out);
}
%>
結果獲取到的內容為tomcat實現的WebAppClassLoader,回溯父類別載入器也沒有發現自定義的實現。於是計劃取巧,使用Arthas之類的工具attach到目標的JVM,去記憶體dump載入過的Class。然後發現無法attach,估計是目標JVM版本過低,為JDK 1.5。因而繼續取巧,用動態偵錯偵錯,在ClassLoader#defineClass方法下斷點爭取將byte[]直接dump出來,可是也沒有成功。
過了幾天,後來回頭重新去做分析。在tomcat啟動指令碼中發現瞭如下的引數:
嗯,果然是自定義載入器,通過實現JVMTI介面,並未在應用層使用Java程式碼去實現,而是直接用C++實現介面。具體的實現就在這個dll中:
經過對java agent的簡單學習,瞭解相關引數和實現後,在ida中將相關的結構體還原始碼如上圖所示。這裡有個ida使用技巧,分析C/C++程式碼最重要就是要了解關鍵的結構體功能,這相當於瞭解Java中類的定義和相關方法的含義。而JVMTI的SDK在JDK的安裝目錄中是開源的,我們可以用ida匯入本地結構體的功能批次匯入。匯入的時候根據header檔案的載入順序依次複製到一個檔案中,否則會有很多依賴缺失導致的報錯。最終的標頭檔案結構如下:
jni_md.h -> jni.h -> jvmti.h
然後需要將前邊的include指令匯入操作刪除掉,匯入ida:
順利的話,將會提示如上圖所示:
相關的錯誤資訊也會在message視窗輸出。可以用來定位錯誤。
接著開始我們的逆向之旅,經過一些分析之後還原出來的虛擬碼如圖所示:
在第22行,這裡一定要把資料的顯示格式修改為hex,如上圖,我們可以看到這裡判斷了Class檔案的魔術頭,因而猜測這裡就是解密的操作了,我們跟進解密函數的具體實現(第29行 decryptClassBytes函數):
程式碼的實現很簡單,根據亂數種子設定srand函數。然後迴圈呼叫rand()函數去和讀取到的byte進行互斥或操作。最終返回互斥或結果。這裡一度讓我十分困惑,因為根據我對亂數的認識,至少這裡應該會把亂數也存放在某個地方,這樣將來解密才能正確執行。因此我自己寫了一些程式碼來觀察亂數的生成:
#include <stdio.h>
#include <stdlib.h>
int main()
{
srand(0x96F07);
int i = rand();
printf("rand number = %x\n", i);
int c = rand();
printf("rand number = %d\n", c);
int n = rand();
printf("rand number = %d\n", n);
int k = rand();
printf("rand number = %d\n", k);
return 0;
}
找了好幾個線上執行程式碼的站點,發現最終生成的結果居然是完全一樣的!查詢該函數之後發現了這樣的一句話:
初始化隨機種子,會提供一個種子,這個種子會對應一個亂數,如果使用相同的種子後面的 rand() 函數會出現一樣的亂數。
結合前陣子JumpServer出現過的亂數問題,讓我再次認識到這個問題的居然是這種方式!
因此我們的解密操作就很簡單了:
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
int main() {
const int BUFFER_SIZE = 1;
srand(0x96F07);
char *src_file = "D:\\cms\\tomcat\\work\\Catalina\\localhost\\oa\\org\\apache\\jsp\\test_html.class";
char *dst_file = "C:\\Users\\Administrator\\CLionProjects\\decrypt\\decrypt.class";
FILE *p_src = fopen(src_file, "rb");
if (p_src == NULL) {
printf("src_file open failed");
return 0;
}
FILE *p_dst = fopen(dst_file, "wb");
if (p_dst == NULL) {
printf("dst_file open failed");
return 0;
}
// 判斷檔案大小 , 該結構體接收檔案大小結果
struct stat st = {0};
stat(src_file, &st);
// 計算緩衝區檔案大小
int buffer_size = st.st_size;
if ( buffer_size > BUFFER_SIZE ) {
buffer_size = BUFFER_SIZE;
}
char *buffer = malloc(buffer_size);
char output[1];
while ( !feof(p_src) ) {
int res = fread(buffer, 1, buffer_size, p_src);
*output = *buffer ^ rand();
fwrite(output, 1, res, p_dst);
}
// 釋放緩衝區記憶體
free(buffer);
fclose(p_src);
fclose(p_dst);
printf("Copy Success");
return 0;
}
以上程式碼並沒有成功,後來我用這個程式碼加密一個未加密過的class檔案,發現和目標檔案差了1個位元組。也就是說解密操作是越過第一個位元組開始的,在如上程式碼基礎上,越過第一個位元組即可:
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
int main() {
const int BUFFER_SIZE = 1;
srand(0x96F07);
char *src_file = "D:\\cms\\tomcat\\work\\Catalina\\localhost\\oa\\org\\apache\\jsp\\test_html.class";
char *dst_file = "C:\\Users\\Administrator\\CLionProjects\\decrypt\\decrypt.class";
FILE *p_src = fopen(src_file, "rb");
if (p_src == NULL) {
printf("src_file open failed");
return 0;
}
FILE *p_dst = fopen(dst_file, "wb");
if (p_dst == NULL) {
printf("dst_file open failed");
return 0;
}
// 判斷檔案大小 , 該結構體接收檔案大小結果
struct stat st = {0};
stat(src_file, &st);
// 計算緩衝區檔案大小
int buffer_size = st.st_size;
if ( buffer_size > BUFFER_SIZE ) {
buffer_size = BUFFER_SIZE;
}
char *buffer = malloc(buffer_size);
char output[1];
// 跳過最初的1位元組
if (fseek(p_src, 1, SEEK_SET) != 0) {
printf("fseek error!\n");
fclose(p_src);
return 1;
}
while ( !feof(p_src) ) {
int res = fread(buffer, 1, buffer_size, p_src);
*output = *buffer ^ rand();
fwrite(output, 1, res, p_dst);
}
// 釋放緩衝區記憶體
free(buffer);
fclose(p_src);
fclose(p_dst);
printf("Copy Success");
return 0;
}
解密: