今天在網上看到平底鍋blog上Josh Grunzweig發表了一系列關于利用IDAPython分析malware的教程。感覺內容非常不錯,于是翻譯成中文與大家一起分享。原文地址:
Part1: http://researchcenter.paloaltonetworks.com/2015/12/using-idapython-to-make-your-life-easier-part-1/
Part2: http://researchcenter.paloaltonetworks.com/2015/12/using-idapython-to-make-your-life-easier-part-2/
作為一名malware逆向工程師,我的日常活動就是使用IDA Pro。這并不奇怪,因為IDA Pro可以說是行業標配(盡管它的替代品,如radare2和hopper也越來越受歡迎)。IDA其中一個非常強大的功能就是可以使用Python腳本(又被稱為IDAPython)。用戶可以通過IDAPython調用大量的IDA API。當然,用戶還可以通過使用IDAPython獲取到腳本語言提供的各種功能。
不幸的是,只有少量的關于IDAPython的資料,僅有的一些資料如下:
為了能提供更多的教程給分析師,我準備寫一篇帶例子的分析文章供大家學習。在本系列的第一部分,我將教大家編寫一個腳本用來解決一個malware樣本的多處字符串混淆問題。
在逆向分析一個病毒樣本的時候,我遇到了這樣一個函數:
圖片1 字符串解密函數
根據以往的經驗,我懷疑這個函數是用來進行解密的。關于這個函數大量的引用證實了我的猜想。
圖2 大量的對可疑函數的引用
在圖2中,我們可以看到有116處對這個函數的引用。每當這個函數被調用時,都有一段數據作為參數通過ESI寄存器提供給這個函數。
圖3可疑的函數 (405BF0) 被調用的實例
在這個時候,我已經非常肯定這個函數是malware用來在運行時進行字符串解密的函數了。當我們面臨這種情況時,我們一般有如下幾種選擇:
如果malware只解密了很少的幾個字符串的話,我會選擇第一種或者第二種方法。但是,根據之前確認的,這個函數被調用了116次,所以采用IDAPython腳本來解決問題會更靠譜一些。
解決字符串混淆問題的第一步是確認和重寫解密函數。幸運的是,這個解密函數非常的簡單。這個函數只是把數據的第一個字符當做XOR算法的key用來解密剩余的數據。
E4 91 96 88 89 8B 8A CA 80 88 88
在上面這個例子中,我們把E4作為key來異或剩余的數據。最后的結果是”urlmon.dll”。在Python中,我們可以把這個解密函數重寫為:
#!python
def decrypt(data):
length = len(data)
c = 1
o = ""
while c < length:
o += chr(ord(data[0]) ^ ord(data[c][/c]))
c += 1
return o
可以看到,我們的測試腳本可以得到我們所期望的結果:
#!bash
>>> from binascii import *
>>> d = unhexlify("E4 91 96 88 89 8B 8A CA 80 88 88".replace(" ",''))
>>> decrypt(d)
'urlmon.dll'
我們要做的下一步工作就是確認哪些代碼引用了這個解密函數,并且提取作為參數的數據。獲取到函數的引用非常的簡單,只需要使用XrefsTo()這個API函數就能達到我們的目的。在這個腳本中,我將會在腳本中硬編碼這個地址。作為測試,我先將這些地址用16進制打印出來:
#!python
for addr in XrefsTo(0x00405BF0, flags=0):
print hex(addr.frm)
Result:
0x401009L
0x40101eL
0x401037L
0x401046L
0x401059L
0x40106cL
0x40107fL
<truncated>
獲取到這些交叉引用的參數并且提取這些原始數據需要一些技巧,但絕非很難。第一件我們想要做的事是獲取”mov esi, offset unk_??”指令中的偏移地址,因為這個指令會把參數傳遞給解密函數。為了做到這點,我們需要找到調用解密函數指令的前一個指令。找到這個指令后,我們可以使用GetOperandValue()?這個指令得到這個偏移地址的值。如下面的代碼所示:
#!python
def find_function_arg(addr):
while True:
addr = idc.PrevHead(addr)
if GetMnem(addr) == "mov" and "esi" in GetOpnd(addr, 0):
print “We found it at 0x%x” % GetOperandValue(addr, 1)
break
Example Results:
Python>find_function_arg(0x00401009)
We found it at 0x418be0
現在我們只需要將字符串從那個偏移地址中提取出來即可。正常來說我們會使用GetString()這個API函數,但是在這個問題中這些字符串都是原始的二進制數據,因此使用這個API可能不太合適。解決方案是我們自己編寫一個函數,然后一個字符一個字符的讀取數據直到碰到空的終止符為止。代碼如下:
#!python
def get_string(addr):
out = ""
while True:
if Byte(addr) != 0:
out += chr(Byte(addr))
else:
break
addr += 1
return out
最后,我們將所有的代碼放在一起:
#!python
def find_function_arg(addr):
while True:
addr = idc.PrevHead(addr)
if GetMnem(addr) == "mov" and "esi" in GetOpnd(addr, 0):
return GetOperandValue(addr, 1)
return ""
def get_string(addr):
out = ""
while True:
if Byte(addr) != 0:
out += chr(Byte(addr))
else:
break
addr += 1
return out
def decrypt(data):
length = len(data)
c = 1
o = ""
while c < length:
o += chr(ord(data[0]) ^ ord(data[c][/c]))
c += 1
return o
print "[*] Attempting to decrypt strings in malware"
for x in XrefsTo(0x00405BF0, flags=0):
ref = find_function_arg(x.frm)
string = get_string(ref)
dec = decrypt(string)
print "Ref Addr: 0x%x | Decrypted: %s" % (x.frm, dec)
Results:
[*] Attempting to decrypt strings in malware
Ref Addr: 0x401009 | Decrypted: urlmon.dll
Ref Addr: 0x40101e | Decrypted: URLDownloadToFileA
Ref Addr: 0x401037 | Decrypted: wininet.dll
Ref Addr: 0x401046 | Decrypted: InternetOpenA
Ref Addr: 0x401059 | Decrypted: InternetOpenUrlA
Ref Addr: 0x40106c | Decrypted: InternetReadFile
<truncated>
我們可以看到所有解密后的字符串。如果我們可以進一步給字符串的引用地址和加密數據提供解密后的字符串作為注釋就更完美了。想要做到這一點,我們需要MakeComm()這個API函數。增加這樣兩行代碼就會給程序加入必要的注釋:
#!python
MakeComm(x.frm, dec)
MakeComm(ref, dec)
增加了這一步后,我們能夠非常清晰的看到交叉引用的數據。如下圖所示,我們可以很輕松的分辨出哪些字符串被引用了:
圖4 運行完腳本后的字符串交叉引用界面
除此之外,我們在反匯編代碼中也能看到這些解密后的字符串作為注釋:
圖5 運行完腳本后的反匯編代碼
在反編譯中我們經常會見到shellcode和malware使用哈希算法來混淆加載的函數或者庫。比如逆向工程師們經常會在shellcode中看到混淆后的函數名。總的來說,整個過程是很簡單的。代碼在運行時會先加載knerel32.dll。然后,它會用這個加載的鏡像去識別并存儲LoadLibraryA函數,這函數是用來加載更多的庫和函數的。這種特定的技術一般采用某種哈希算法來識別函數的。最常用的哈希算法一般是CRC32,當然,其他的一些變種算法,如ROR13,也是非常常見的。
比如說,當我逆向一個malware的某一部分內容的時候,我看到了如下的代碼:
圖片6 malware使用CRC32哈希算法來動態的加載函數
因為0xEDB88320這個常數是CRC32算法的常用參數。所以我們可以判斷出這個例子使用了CRC32哈希算法。
圖片7 確認CRC32算法
通過圖7我們可以確定這個算法是CRC32算法。現在,算法和函數已經確定了。我們可以通過交叉引用(ida中按x)的數量來確定這個函數被被調用了多少次。可以看到這個函數一共被調用了190次。顯然,手動的解密并重命名這些哈希值并不是我們想要的。因此,我們可以使用IDAPython來幫我們解決。
第一步實際上并不需要IDAPython,但是它用到了Python。為了驗證哪個哈希值對應哪個函數,我們需要生成一個windows通用函數哈希列表。想要做到這點,我們只需要獲取一個windows通用庫的列表,然后遍歷這些庫的函數列表。代碼如下:
#!python
def get_functions(dll_path):
pe = pefile.PE(dll_path)
if ((not hasattr(pe, 'DIRECTORY_ENTRY_EXPORT')) or (pe.DIRECTORY_ENTRY_EXPORT is None)):
print "[*] No exports for %s" % dll_path
return []
else:
expname = []
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
if exp.name:
expname.append(exp.name)
return expname
我們隨后可以得到函數名字的列表,然后計算他們的CRC32的哈希值。代碼如下:
#!python
def calc_crc32(string):
return int(binascii.crc32(string) & 0xFFFFFFFF)
最后我們將結果寫入一個JSON格式的文件中,并且命名為"output.json"。這個JSON文件包含了一個非常大的字典,采用如下的格式:
#!bash
HASH => NAME
完整版的代碼如下:
https://github.com/pan-unit42/public_tools/blob/master/ida_scripts/gen_function_json.py
當這個文件生成之后,我們可以返回IDA,并且繼續編寫我們的IDAPython腳本。我們腳本第一步要做的事情是讀取我們之前創建的'output.json'這個JOSON數據文件。不幸的是,JSON對象并不支持整數作為key,因此當數據被加載后,我們需要手動把key從字符串轉換為整數。代碼如下:
#!python
for k,v in json_data.iteritems():
json_data[int(k)] = json_data.pop(k)
當這些數據被加載后,我們將會創建一個枚舉對象保存了哈希值與函數名的對應關系。(想要了解更多的關于枚舉對象的信息,我推薦你閱讀這篇教程:
http://www.cprogramming.com/tutorial/enum.html
使用枚舉對象,我們可以找到一個整數對應的字符串,比如說CRC32哈希值對應的函數名。為了在IDA中創建新的枚舉對象,我們可以使用AddEnum()這個函數。為了讓這個腳本更加健壯,我們先使用GetEnum()函數來檢測用來枚舉的值是否已經存在。
#!python
enumeration = GetEnum("crc32_functions")
if enumeration == 0xFFFFFFFF:
enumeration = AddEnum(0, "crc32_functions", idaapi.hexflag())
這個枚舉的值隨后將會被修改。下一步要干的事情是根據函數的哈希值來確定真實的函數地址。這一部分看起來很像第一部分的內容。我們通過觀察這個函數的結構可以發現CRC32哈希值是這個加載函數的第二個參數。
圖片8 傳遞給load_function()的參數
同樣的,我們還是枚舉之前的指令來尋找函數的第二個參數。當我們找到后,我們通過output.json中的JSON數據來進行檢測,并且確保有一個函數名對應了這個哈希值。代碼如下:
#!python
for x in XrefsTo(load_function_address, flags=0):
current_address = x.frm
addr_minus_20 = current_address-20
push_count = 0
while current_address >= addr_minus_20:
current_address = PrevHead(current_address)
if GetMnem(current_address) == "push":
push_count += 1
data = GetOperandValue(current_address, 0)
if push_count == 2:
if data in json_data:
name = json_data[data]
這個時候,我們使用AddConstEx()這個函數將CRC32哈希和函數名加入我們之前創建的枚舉對象中。
#!python
AddConstEx(enumeration, str(name), int(data), -1)
當這個數據加入到枚舉對象中后,我們可以將CRC32的哈希值轉換為對應的枚舉名字了。下面的兩個函數一個是用來將一個整數轉換成對應的枚舉數據,另一個是用來將某個地址的數據轉換成對應的枚舉數據。
#!python
def get_enum(constant):
all_enums = GetEnumQty()
for i in range(0, all_enums):
enum_id = GetnEnum(i)
enum_constant = GetFirstConst(enum_id, -1)
name = GetConstName(GetConstEx(enum_id, enum_constant, 0, -1))
if int(enum_constant) == constant: return [name, enum_id]
while True:
enum_constant = GetNextConst(enum_id, enum_constant, -1)
name = GetConstName(GetConstEx(enum_id, enum_constant, 0, -1))
if enum_constant == 0xFFFFFFFF:
break
if int(enum_constant) == constant: return [name, enum_id]
return None
def convert_offset_to_enum(addr):
constant = GetOperandValue(addr, 0)
enum_data = get_enum(constant)
if enum_data:
name, enum_id = enum_data
OpEnumEx(addr, 0, enum_id, 0)
return True
else:
return False
當我們把這個枚舉轉換完成后,我們來研究一下如何修改DWORD處的值,因為DWORD處的值保存了加載后的函數地址。
圖片9 當加載完函數后,程序將函數地址存儲到了DWORD地址
為了做到這一點,我們不光需要遍歷之前的指令,還要查找之后的指令,也就是將eax存儲到一個DWORD地址的指令。當我們發現這條指令之后,我們可以給這個DWORD地址重新命名成正確的函數名。為了防止沖突,我們在函數名前加上一個”d_
”字符串。
#!python
address = current_address
while address <= address_plus_30:
??address = NextHead(address)
??if GetMnem(address) == "mov":
????if 'dword' in GetOpnd(address, 0) and 'eax' in GetOpnd(address, 1):
??????operand_value = GetOperandValue(address, 0)
??????MakeName(operand_value, str("d_"+name))
等這一切都做完后,我們會發現原來很難讀懂的匯編代碼變得很好理解了。如圖所示:
圖片10 運行完腳本后的變化
現在,當我們看到DOWRDS列表的時候,就已經能得到真實的函數名字了。并且這些數據能夠很好的幫助我們進行靜態分析。
完整的代碼如下:
#!python
import json
def get_enum(constant):
all_enums = GetEnumQty()
for i in range(0, all_enums):
enum_id = GetnEnum(i)
enum_constant = GetFirstConst(enum_id, -1)
name = GetConstName(GetConstEx(enum_id, enum_constant, 0, -1))
if int(enum_constant) == constant: return [name, enum_id]
while True:
enum_constant = GetNextConst(enum_id, enum_constant, -1)
name = GetConstName(GetConstEx(enum_id, enum_constant, 0, -1))
if enum_constant == 0xFFFFFFFF:
break
if int(enum_constant) == constant: return [name, enum_id]
return None
def convert_offset_to_enum(addr):
constant = GetOperandValue(addr, 0)
enum_data = get_enum(constant)
if enum_data:
name, enum_id = enum_data
OpEnumEx(addr, 0, enum_id, 0)
return True
else:
return False
def enum_for_xrefs(load_function_address, json_data, enumeration):
for x in XrefsTo(load_function_address, flags=0):
current_address = x.frm
addr_minus_20 = current_address-20
push_count = 0
while current_address >= addr_minus_20:
current_address = PrevHead(current_address)
if GetMnem(current_address) == "push":
push_count += 1
data = GetOperandValue(current_address, 0)
if push_count == 2:
if data in json_data:
name = json_data[data]
AddConstEx(enumeration, str(name), int(data), -1)
if convert_offset_to_enum(current_address):
print "[+] Converted 0x%x to %s enumeration" % (current_address, name)
address_plus_30 = current_address+30
address = current_address
while address <= address_plus_30:
address = NextHead(address)
if GetMnem(address) == "mov":
if 'dword' in GetOpnd(address, 0) and 'eax' in GetOpnd(address, 1):
operand_value = GetOperandValue(address, 0)
MakeName(operand_value, str("d_"+name))
fh = open("output.json", 'rb')
d = fh.read()
json_data = json.loads(d)
fh.close()
# JSON objects don't allow using integers as dict keys. Little workaround for
# this issue.
for k,v in json_data.iteritems():
json_data[int(k)] = json_data.pop(k)
conversion_function = 0x00405680
enumeration = GetEnum("crc32_functions")
if enumeration == 0xFFFFFFFF:
enumeration = AddEnum(0, "crc32_functions", idaapi.hexflag())
enum_for_xrefs(conversion_function, json_data, enumeration)
在上一節中,我們利用IDAPython成功的解決了一個哈希混淆的問題,在這個問題中我們用到了枚舉對象。枚舉對象對我們分析這類問題會很有幫助,能夠節省我們大量的時間。并且這個對象可以很容易的在IDA工程中提取或者加載,這對我們進行批量的逆向分析會很有幫助。