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.script.GhidraScript
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 文件和可疑的应用程序时,可以尝试寻找关于位图操作的调用。
参考资料
GDB 调试 : https://packmad.github.io/gdb-android/
图片来源 : https://www.deviantart.com/velinov/art/Hydra-monster-144496963