安卓恶意软件分析: 剖析 Hydra Dropper
Hydra 是另一个针对银行的安卓木马变种。 它使用“覆盖”手段来窃取信息,这种手法与阿努比斯(Anubis)很像。 它的名字来源于命令和控制面板。 从2018年7月到2019年3月,谷歌官方应用商店上至少有8到10个这种样本。 恶意软件的分布类似于阿努比斯,Dropper 恶意应用程序也会上传到谷歌应用商店。 但是与 阿努比斯 不同的是,Dropper 的应用程序通过 kinda 速记从 png 文件中提取 dex 文件,并通过这些 dex 文件从命令和控制服务器中下载恶意应用程序。 你可以在这个链接里找到我将要介绍的例子: Dropper
本次分析的目标是:
· 在 Java 端绕过检查
· GDB 调试
· Ghidra 的诡计
· 理解 dex 文件的创建过程
· 额外的奖励
首先,如果Dropper应用程序运行在合适的环境中,那么它会加载 dex 文件并连接到命令和控制服务器。 它在 java 端和 native 端做了多种检查。 我们将使用 gdb 调试 native 端,并使用 ghidra 来帮助我们查找恶意程序的检查点和一些重要的函数。
时间检查
当我们用 jadx 打开第一个应用程序时,我们可以在类 com.taxationtex.giristexation.qes.Hdvhepuwy 中看到时间检查的代码
public static boolean j() {
return new Date().getTime() >= 1553655180000L && new Date().getTime() <= 1554519180000L;
}
这个函数在另一个类中进行调用: com.taxationtex.giristexation.qes.Sctdsqres
class Sctdsqres {
private static boolean L = false;
private static native void fyndmmn(Object obj);
Sctdsqres() {
}
static void j() {
if (Hdvhepuwy.j()) {
H();
}
}
static void H() {
if (!L) {
System.loadLibrary("hoter");
L = true;
}
fyndmmn(Hdvhepuwy.j());
}
}
首先,它会对当前时间进行检查,如果条件成立,应用程序将加载本地库并调用本次函数fyndmmn(Hdvhepuwy.j());。 我们需要绕过这个检查,这样应用程序就可以在每次启动时都能加载本地库。
我使用 apktool 将 apk 反汇编为 smali,并将 j() 改为总是返回 true。
· apktool d com.taxationtex.giristexation.apk
· cd com.taxationtex.giristexation/smali/com/taxationtext/giristexation/qes
· edit j()Z in Hdvhepeuwy.smali
.method public static j()Z
.locals 1
const/4 v0, 0x1
return v0
.end method
执行下面的命令重新构建 apk 文件, 然后进行签名。
apktool b com.taxationtex.giristexation -o hydra_time.apk
现在时间检查的控制条件总是返回 true,之后会加载本地库并调用 fyndmmn 本地函数。即使我们这样做了,应用程序仍然不会加载 dex 文件。
GDB 调试
这有一篇很棒的文章,解释了如何设置 gdb 来调试本地库。 步骤如下:
· Download android sdk with ndk
· adb push ~android-ndk-r20/prebuilt/android-TARGET-ARCH/gdbserver/gdbserver /data/local/tmp
· adb shell “chmod 777 /data/local/tmp/gdbserver”
· adb shell “ls -l /data/local/tmp/gdbserver”
· get process id, ps -A | grep com.tax
· /data/local/tmp/gdbserver :1337 –attach $pid
· adb forward tcp:1337 tcp:1337
· gdb
· target remote :1337
· b Java_com_tax\TAB
这里有个小问题。 应用程序会加载本地库,调用本地函数之后会退出。但是应用程序需要等待 gdb 的连接。 我的第一个想法是添加 sleep,然后连接到 gdb。
· apktool d hydra_time.apk
· vim hydra_time/com.taxationtex.giristexation/smali/com/taxationtex/giristexation/qes/Sctdsqres.smali
在下面的代码块后面:
.line 43
:cond_0
添加
const-wide/32 v0, 0xea60
invoke-static {v0, v1}, Landroid/os/SystemClock;->sleep(J)V
因为 locals 变量的值是1,因此我们需要使用一个额外的 v1变量,把它增加到2
.method static H()V
.locals 2
再次对应用程序进行签名并安装。 如果一切顺利,应用程序将停留在白色屏幕上并等待60秒。 现在我们可以连接gdb 了。
ps | grep com.tax
/data/local/tmp/gdbserver :1337 --attach $pid
我使用 pwndbg 是为了获得更好的 gdb 调试体验,你可以尝试使用 peda 或任何你想要使用的方法。
· adb forward tcp:1337 tcp:1337
· gdb
· target remote :1337
debug session 调试会话
加载所有的库需要一些时间。 将断点设置在本地函数 fymdmmn 上。
设置断点
如果希望同步 gdb 和 ghidra 地址,请在 gdb 中输入 vmmap 并查找 libhoter.so 的第一个条目。
0xe73be000 0xe73fc000 r-xp 3e000 0 /data/app/com.taxationtex.giristexation-1/lib/x86/libhoter.so
所以 0xe73be000 是我的基址。
转到窗口->内存映射并点击右上角的主页图标。 把你的基地址输进去然后查询构建二进制陈谷。
看看 ghdira 中显示本地函数:
fyndmmn 函数
为什么要调用 time 函数? 难道又是时间检查? 重命名 time 函数的返回值(curr_time) ,然后按 ctrl + shift + f 从汇编视图转到上下文为 READ 的位置。
return (uint)(curr_time + 0xa3651a74U < 0xd2f00)
所以我们的猜想是对的,这里还是在做时间检查。将当前函数重命名为check_time。 计算时间:
>>> 0xffffffff-0xa3651a74+0xd2f00
>>> 1554519179
>>> (1554519179+ 0xa3651a74) & 0xffffffff < 0xd2f00
>>> True
转换为时间后是: Saturday, April 6, 2019 2:52:59 AM,这是应用程序上传到应用商店的时间。 检查如何使用这个布尔值。 查找函数 check_time 的 xrefs。
正如我们之前所想的那样,如果时间不够,程序就会退出。 第一个断点或二进制补丁点就在这里。 或者我们可以将模拟器或手机的时间更改为2019年4月5日。
b *(base + 0x8ba8)
但是只绕过时间检查是不够的。
Ghidra 的诡计
现在进入二进制文件分析阶段,你会发现类似于下面这样的多个函数:
解密过程的代码块
仔细分析 while 循环:
异或操作循环
有两个数据块被执行了异或(XOR)操作。 (长度是 0x18)我们可以把断点放在 do while 语句之后,但这不是有效的解决方案。 让我们考虑一种编程方式来查找已解密的字符串。 这些被异或的数据块彼此相邻。 如果我们可以得到数据块的长度,我们可以很容易地得到解密字符串。 然后找到使用这些异或数据块的函数并将函数重命名。 然后,我们可以跳到 2*length,得到下一个被执行异或操作的数据块。 重复这个过程。 开始执行异或操作的数据块是0x34035。 获取该数据块的 xrefs:
异或操作数据块的过程
进入函数里面
获取 cmp 的值
从 CMP 指令中获取大小,因为我们知道第一个 异或数据块的地址,所以将大小添加到第一个地址并获得第二个异或数据块的地址。 对数据块执行异或操作并重命名调用函数。
Ghidra: 转到窗口->脚本管理器->创建新的脚本->Python。 为脚本设置名称,现在让我们编写 ghidra 脚本。
import ghidra.app..Ghidra
import exceptions
from ghidra.program.model.address import AddressOutOfBoundsException
from ghidra.program.model.symbol import SourceType
def xor_block(addr,size):
## get byte list
first_block = getBytes(toAddr(addr),size).tolist()
second_block = getBytes(toAddr(addr+size),size).tolist()
a = ""
## decrypt the block
for i in range(len(first_block)):
a += chr(first_block[i]^second_block[i])
## each string have trash value at the end, delete it
trash = len("someval")
return a[:-trash]
def block(addr):
## block that related to creation of dex file. pass itt
if addr == 0x34755:
return 0x0003494f
## get xrefs
xrefs = getReferencesTo(toAddr(addr))
if len(xrefs) ==0:
## no xrefs go to next byte
return addr+1
for xref in xrefs:
ref_addr = xref.getFromAddress()
try:
inst = getInstructionAt(ref_addr.add(32))
except AddressOutOfBoundsException as e:
print("Found last xor block exiting..")
exit()
## Get size of block with inst.getByte(2)
block_size = inst.getByte(2)
## decrypt blocks
dec_str = xor_block(addr,block_size)
## get function
func = getFunctionBefore(ref_addr)
new_name = "dec_"+dec_str[:-1]
## rename the function
func.setName(new_name,SourceType.USER_DEFINED)
## log
print("Block : {} , func : {}, dec string : {}".format(hex(addr),func.getEntryPoint(),dec_str))
return addr+2*block_size
def extract_encrypted_str():
## starting block
curr_block_location = 0x34035
for i in range(200):
curr_block_location = block(curr_block_location)
def run():
extract_encrypted_str()
run()
要运行我们编写的脚本,请在脚本管理器中选择已创建的脚本,然后点击“运行”。 现在让我们看看脚本的输出。
ghidra 脚本的输出
你可以看到这些函数: getSimCountryISO,getNetworkCountryIso,getCountry 和一个可疑的字符串: tr。 如果不运行脚本,我们可以假设代码将检查这些函数的返回值是否等于 tr。因为我已经知道这个应用程序的攻击目标是土耳其人,所以这个结果是合理的,目的是用来避免沙盒,甚至是手动分析。如果你跟随这些函数的 xrefs 跳到函数 FUN_00018A90()(在时间检查函数之后) ,你可以看到如下代码:
对国家进行检查
因此,下一个补丁或断点是这样的检查:
b *(base + 0x8c80)
在这些检查之后,代码将删除 dex 并加载它。 如果不使用补丁或断点运行,则只显示 edevlet 页面,不会发生任何事情。 获取你的基址并尝试绕过检查:
b *(base + 0x8ba8)
b *(base + 0x8c80)
copy eip : .... a8 -> set $eip = .... aa
c
copy eip : .... 80 -> set $eip = .... 82
c
在这些断点之后,应用程序将创建 dex 文件并加载这些文件。 如果你操作正确的话,你会看到弹出了无障碍助手页面。
绕过检查
或者我们可以将 je 指令补丁到本地库中的 jne,然后再次构建 apk。
理解 dex 文件的创建过程
如果在文件系统中查找该恶意程序创建的文件,你不会看到任何内容。 因为文件已经被删除。我们可以很容易地通过 frida 调试分析并得到创建的文件。但是请暂时忘记这件事器,现在我们需要找出这个恶意程序是如何使用 png 文件创建了 dex 文件。
查看 ghidra 脚本输出内容的最后那一部分。
ghidra 脚本的输出结果
使用 AndroidBitmap 处理 prcnbzqn.png,然后创建了名为 xwchfc.dex 的 dex 文件。 然后使用 ClassLoader API 加载 dex 文件,之后调用了类 moonlight.loader.sdk.SdkBuilder。
检查函数: 0xee0
从 asset(资产) 文件夹中获取 png 文件
资产文件夹并查找 png 文件。 将此函数重命名为asset_caller。 访问这个函数的 xref,找到0xe2c0。 我重命名了一些函数的名称。 dex_header 在内存中创建 dex 文件。 dex_dropper 把 dex 文件放到系统中,然后加载。
函数调用层次
dex_header是如何创建 dex 文件的呢? 我们转到函数定义看看。
dex 创建函数
bitmap_related函数从 png 文件创建位图。 位图对象传递给到 dex_related函数。这里为什么是位图呢?让我们继续往下看。
如果你读取了 png 文件字节,你不能直接得到像素的颜色代码。 你需要将其转换为位图。 所以应用程序首先传输 png 文件到位图,读取像素的十六进制值。 启动 gimp或者paint程序,查看图像第一个像素的十六进制代码,并与下面的图片进行比较:
像素的 rgb 值
现在到了有趣的部分。 如何使用这些值。 在 0xfbf0 处你可以找到dex_related函数。
位图对象被传递给这个函数,现在这里有两个重要的函数:
两个重要的函数
byte_chooser将返回一个字节, dex_extractor将使用该字节获得最后的 dex 字节。 4_cmp 变量在开始时设置为0,在 else 代码块结束时设置为0。 所以程序执行流将命中 byte_chooser 2次之前进入 dex_extractor函数。下面是byte_chooser函数的代码:
字节选择函数
param_3是像素的十六进制代码。 param_2就像一个种子变量。 如果它第一次调用byte_chooser时被设置为0,在字节选择器的第二次调用中, param_2 会返回第一次调用的值并左移4位。 然后在 else 代码块的末尾将其设置为0。
通过两次调用字节选择器计算字节后,返回值传递给 dex_extractor 函数。
dex字节计算器函数
param_2 用于计算字节, param_1 是索引。
现在我们知道 dex 文件是如何创建的了。让我们用 python 来实现这个过程:
from PIL import Image
import struct
image_file = "prcnbzqn.png"
so_file = "libhoter.so"
offset = 0x34755
size = 0x1fa
output_file = "drop.dex"
im = Image.open(image_file)
rgb_im = im.convert('RGB')
im_y = im.size[1]
im_x = im.size[0]
dex_size = im_y*im_x/2-255
f = open(so_file)
d = f.read()
d = d[offset:offset+size]
def create_magic(p1,p2,p3):
return (p1<<2 &4 | p2 & 2 | p2 & 1 | p1 << 2 & 8 | p3)
def dex_extractor(p1,p2):
return (p1/size)*size&0xffffff00| ord(d[p1%size]) ^ p2
count = 0
dex_file = open(output_file,"wb")
second = False
magic_byte = 0
for y in range(0,im.size[1]):
for x in range(0,im.size[0]):
r, g, b = rgb_im.getpixel((x, y))
magic_byte = create_magic(r,b,magic_byte)
if second:
magic_byte = magic_byte & 0xff
dex_byte = dex_extractor(count,magic_byte)
dex_byte = dex_byte &0xff
if count > 7 and count-8 < dex_size:
dex_file.write(struct.pack("B",dex_byte))
magic_byte = 0
second = False
count+=1
else:
magic_byte = magic_byte << 4
second = True
dex_file.close()
让我们看一下 jadx 的输出文件:
删除 dex 文件的代码
还记得 ghidra 脚本输出中的内容吗? 通过对比后可以发现输出是正确的。
Frida
好吧,我写这篇文章就不能不提到 frida。
· 在 Java 端和本地端都有时间检查
· 国家检查
· 文件在本地端被删除
var unlinkPtr = Module.findExportByName(null, 'unlink');
// remove bypass
Interceptor.replace(unlinkPtr, new NativeCallback( function (a){
console.log("[+] Unlink : " + Memory.readUtf8String(ptr(a)))
}, 'int', ['pointer']));
var timePtr = Module.findExportByName(null, 'time');
// time bypass
Interceptor.replace(timePtr, new NativeCallback( function (){
console.log("[+] native time bypass : ")
return 1554519179
},'long', ['long']));
Java.perform(function() {
var f = Java.use("android.telephony.TelephonyManager")
var t = Java.use('java.util.Date')
//country bypass
f.getSimCountryIso.overload().implementation = function(){
console.log("Changing country from " + this.getSimCountryIso() + " to tr ")
return "tr"
}
t.getTime.implementation = function(){
console.log("[+] Java date bypass ")
return 1554519179000
}
})
Frida 会话的输出内容
使用下面的命令将 dex 文件拖到本地:
adb pull path/xwcnhfc.dex
家庭作业
这部分是我为读者布置的家庭作业,这个恶意软件的下一个版本只使用本地 ARM 版的二进制文件。 因此,如果没有基于 ARM 的设备,我们很难进行调试。 但是我们可以使用我们的 dex dropper python 脚本。 恶意软件样本可以在这里找到。 把 ARM 二进制文件加载到 ghidra。 查找 dex 数据块的正确偏移量和块的大小。 dex_extractor 函数可能看起来不太一样,但它的作用是一样的。 因此,你只需要更改 python 脚本中的文件名、偏移量和大小变量即可。 7ff02fb46009fc96c139c48c28fb61904cc3de60482663631272396c6c6c32ec
总结
我们附加 gdb 来调试本地端代码并发现某些检查。 然后我们编写了一个 ghidra 脚本来自动解密字符串并使用 frida 脚本来绕过检查。 通过分析,我们还发现,png 文件需要与 Bitmap 一起转换,以获得像素值。 因此,下次你看到 png 文件和可疑的应用程序时,可以尝试寻找关于位图操作的调用。