缘由
攻防对抗中,JavaScript 和 Powershell 脚本的混淆见得很多,但经过混淆的 Shell 脚本见得相对少一些。其实,Shell 作为一种简易而灵活的编程语言,其脚本也完全可以做到像 JS/Powershell 那样的混淆。混淆一般是为了逃过自动化检测,也可以给人工分析增加难度。比如,针对 cat /etc/passwd
这一条命令就可以用下面 3 种轻度混淆的方式来编写:
|
|
更复杂的 Shell 脚本混淆在真实攻击中也时有发生,比如 systemdMiner 借鸡下蛋,通过 DDG 传播自身 这个案例里的 cron.sh,就经过了高度混淆,混淆到了肉眼几乎什么也看不出来的地步。
对于这些混淆过的 Shell 脚本,最便捷的方式莫过于用 Bash 的 -x
选项,可以对混淆过的脚本逐层解混淆,直到最终还原,以下是上面的 cron.sh 解混淆过程最后部分的截图:
但是,这个做法有个问题:bash -x <SH_SCRIPT_FILE>
最终会真的执行 Shell 脚本。
如此一来,针对恶意的、混淆过的 Shell 脚本,用这种方法解混淆,就只能扔到 Linux 虚拟机中去执行了。只想看它的动态行为,这样没问题;如果只想对恶意 Shell 脚本进行静态分析,并不想真正执行它,更懒得单独开一个虚拟机去还原它……怎么办?就不能直接通过一个什么工具还原它嘛?或者根据简单的混淆规则、自己写一个小工具对 Shell 脚本自动化解混淆?
我没找到这种工具,研究了一下,Shell 代码混淆的姿势还是有点繁杂的。想自己写一个工具,走语法解析并还原最终代码的路子,会比较艰难。
于是就想,Bash 是自由软件,网上可以直接下载到源码,那能不能试试给 Bash 的源码打个 Patch,让 -x 选项仅仅打印解混淆后的 Shell 代码,而不执行最终的 Shell 代码,达到静态解混淆的目的?网上搜 Shell 反混淆 相关的资料,搜到这么一篇文章: linux命令反混淆-忙里偷闲 ,里面也用到了这个思路,但没有把 Patch Bash 源码的技术细节公开。
只好自动动手试一下,最终给 Bash 5.0 的源码打了一个不完美的 Patch,但基本满足静态解混淆的需求。本文记录给 Bash 源码打 Patch 的思路。
Bash5.0 源码下载: https://ftp.gnu.org/gnu/bash/bash-5.0.tar.gz
思路
比较直接的思路,就是找到 Bash 源码中最终执行 Shell 代码的位置,根据 -x 选项给它 Patch 掉,不让它执行 Shell 代码。这样大概需要做以下 3 件事:
- 确认 -x 在代码中的标识。Bash 启动的时候,肯定解析参数,对于 -x 选项,肯定要在代码中做一下标记,以便在后续的执行过程中,对于特定的代码要打印出来;
- 厘清 Bash 从启动到解析 Shell 脚本、解混淆脚本中混淆过的代码到最终执行的大概流程,尤其要梳理清楚这个过程中的关键函数调用路径;
- 找到最后执行解混淆后 Shell 代码的位置,用 -x 选项的标记给它 Patch 一下:如果启用了 -x 选项,就不执行代码,否则就执行最终的 Shell 代码。
这种工作,注定是“头重脚轻”的工作:开头要做大量的工作,反复研究、设计、校对方案,最后只不过需要在关键位置做几个改动……类似于修一架构造庞杂的机器,事先要做大量的检测工作,最后只需要拧两颗螺丝就搞定。
而 Bash 5.0 有近 20W 行代码,不算很小的项目。好在做这件事并不需要读懂所有代码。
-x: echo_command_at_execute
Bash 5.0 的 main() 函数在 shell.c 文件中。顺着 main() 函数的执行流程,找到参数解析部分,就能找到 Bash 5.0 如何处理 -x 选项的。按照顺序逐个介绍 main() 函数里的关键步骤(主要是关键函数调用路径)。
setjmp_nosigs():
设置 sigjmp,捕获早期异常。
xtrace_init():
初始化 xtrace 模块。正是该模块设定了 -x 选项的底层输出。-x 选项,在 Bash 源码中,被称作 xtrace ,意思应该就是追踪 Shell 代码的执行了。在 print_cmd.c 文件中,有以下定义:
|
|
可以看到最后的设定, xtrace 把内容输出到 stderr 中。
check_dev_tty()
顾名思义,检查设备 tty。
set_default_locale() :
设定当前 locale。
uidget():
获取当前用户的 uid/gid/euid/egid。
set_shell_name(argv[0]):
用 argv[0](即当前 Bash 的执行体文件名) 为当前 Shell 命名。
parse_long_options():
解析 Bash 的长参数。Bash 里面的参数分长参数和普通参数,在 bash --help
的输出里有说明:
|
|
可以看到我的目标, -x 并不属于 长参数。
parse_shell_options() :
解析 Bash 的普通参数,普通参数的详细说明,可以用 bash -c "help set"
来打印出来,其中就包括我想要找的 -x 参数:
|
|
parse_shell_options() 的实现,同样在 shell.c 中。不过翻遍函数中的 switch-case 结构,发现并没有在该函数中直接处理 -x 参数。只好去 swtich-default 语句块中找,这里调用了一个 change_flag(‘x’, ‘-‘) 函数。
change_flag(flag, on_or_off) 函数在 flags.c 文件中定义。函数开头,调用了 flags.c::find_flag() 函数,find_flag() 函数会从一个结构体数组 shell_flags 中检索目标 flag。shell_flags 的定义如下:
|
|
可以看到,对于 flag -x ,Bash 源码内部的标识是 echo_command_at_execute 这个变量,这个变量在 flags.c 中的定义如下:
|
|
一旦在启动 Bash 的时候,开启了 -x 选项,那么 echo_command_at_execute 的值就会被 flags.c::change_flag() 函数设定为 1:
|
|
至此,我就完成了第一个工作:找到了 -x 选项在源码中对应的标识变量。
解析 Shell 脚本,执行 Shell 命令
接下来,继续顺着 shell.c::main() 函数中的代码,找到解析 Shell 脚本、执行 Shell 命令的关键步骤。
init_interactive():
如果要用交互式的方式启动 Bash,这个函数就初始化交互式 Shell。
init_noninteractive() :
如果要用非交互式的方式启动 Bash,这个函数就初始化非交互式 Shell。常用的 -c 和 -x 选项就是以非交互的方式启动 Bash。
shell_initialize() :
初始化整个 Shell,从这里开始,初始完的 Shell 应该是一个可用的 Shell。
set_default_lang() :
设定默认环境语言。
set_default_locale_vars():
设置默认的 locale 变量。
如果是以交互式方式运行 Bash,接下来还要做一些设置。后续还要读取 Bash 的一些配置( .rc/.profile)、初始化 Bash history 等等。
如果要执行一个 Bash 脚本文件,还要把可能的脚本参数与脚本文件绑定(bind_args() 函数),在这一步,读取 Shell 脚本文件名,存入变量 shell_script_filename 中。
如果启用 -c 选项执行一条 Bash 命令,那么调用 run_one_command() 函数来完成这个任务。
最后,才是处理执行一个 Shell 脚本文件的情况。
open_shell_script(shell_script_filename) :
打开 Shell 脚本文件。
set_bash_input() :
设置 Bash 的 input 环境。
reader_loop() :
读取、解析、执行 Shell 脚本中的代码。reader_loop() 函数在 eval.c 文件中定义。该函数读取每一个 Shell 命令后,会调用 dispose_cmd.c::dispose_command() 函数把 Shell 命令初步解析成通用结构,Bash 中的 Command 结构在 command.h 文件中定义如下:
|
|
基于这个基础结构体,Bash 中定义了更多类型的 Command,后续将会为这些不同的 Command 执行不同的操作,具体可以阅读 command.h 中的代码。
最后,eval.c::reader_loop() 会把初步解析好的每一条 Shell 命令传给 execute_cmd.c::execute_command() 函数来执行。execute_cmd.c::execute_command() 函数,就是对每一条 Shell 命令解析、执行的入口。
execute_command() 函数实现非常简短:
|
|
它的核心功能就是初始化 Shell 代码执行的环境之后,把 Command 交给 execute_cmd.c::execute_command_internal() 函数来执行。
execute_command_internal() 函数实现相当复杂,里面会根据 Command 的类型、动作涉及很多具体的函数调用,而且多个具体的函数调用会在一、二级调用之后再次调用 execute_command_internal() 函数。这样就形成了特别繁杂的递归调用。
在IDA Pro 里反编译 Bash 二进制文件,对 execute_command_internal() 的引用状况能看个大概:
但是 IDA 中的 xrefs 对于该函数的层级递归调用关系展示的并不完整,所以我手动整理了一个树形图,看完一目了然:
|
|
这样就给我最终找到真正执行 Shell 代码的位置带来了更多麻烦。
顺便说一下,在 execute_cmd.c 文件中,对于根据 -x 选项需要打印的 Shell 命令,都会判断一下是否设置 -x 选项,并打印 Shell 代码,形如:
|
|
这样一来,要找到最终执行 Shell 代码的位置并打上 Patch,有两条路可走:
- 在 execute_cmd.c 中,以 echo_command_at_execute 标识变量为线索,凡是检查这个变量并做相应 xtrace_print_XXX 的函数,均可能是最终输出不同类型 Command 的“终极函数”,在这些“终极函数”中,最终执行 Shell 命令之前给它打个 Patch,让它们只打印、不执行;
- 梳理清楚上面围绕 execute_command_internal() 函数繁杂的递归调用,找到递归逻辑的边界,即是最终执行 Shell 命令的代码。递归的逻辑特性就是这样,一直递归是有问题的,所以一定会有至少一个边界条件来跳出递归逻辑,执行递归逻辑之后最终的任务。我的目标就是找出这一堆多层递归逻辑的边界条件。这么些 execute_XXX() 函数,在结束对 execute_command_internal() 函数的多层递归调用之后,肯定要执行最终解混淆之后的 Shell 代码,我在它们执行代码之前,根据 echo_command_at_execute 标识变量打个 Patch 即可。
经过一番分析,发现第 1 条路走不通。那些调用 xtrace_XXX() 函数来输出不同类型 Shell 命令的函数,并不是最终执行 Shell 命令的“终极函数”,它们还会把 Shell 命令交给其他的函数去执行。
所以只有第 2 条路。经过一番艰苦的梳理,确认最终执行 Shell 命令的函数为:execute_cmd.c::shell_execve() ,这个函数会对最后解混淆的 Shell 命令调用 C 语言的 execve() 函数。
打 Patch
找到了目标之后,就简单了。我只需要在 execute_cmd.c::shell_execve() 函数中,调用 execve() 之前打个 Patch 即可:
是的,你没看错,上面费尽心血梳理源代码,最后只需改动这一处。
打完了 Patch,编译、运行,测试几段混淆过的 Shell 代码,效果如下:
|
|
当然,这个 Patch 还不完美,因为没有处理重定向和管道,所以对个别情况解析不完美。有更好思路的朋友欢迎留言指教。