看我如何用ARM汇编语言编写TCP Bind Shell

一、前言

在本教程中,我会向大家介绍如何编写不包含null字节、可以用于实际漏洞利用场景的TCP bind shellcode。我所提到的漏洞利用过程,指的是经过许可、合法的漏洞研究过程。如果大家对软件漏洞利用技术不是特别熟练,希望我能够引导大家将这种技术用在正当场合中。如果我们找到了某个软件漏洞(比如栈溢出漏洞),希望能够测试漏洞的可利用性,此时我们就需要切实可用的shellcode。不仅如此,我们还需要通过恰当的技术来使用shellcode,使其能够在部署了安全机制的环境中正常执行。只有这样,我们才能够演示漏洞的可利用性,也能演示恶意攻击者利用这种安全缺陷的具体方法。

读完本教程后,你可以了解如何编写将shell绑定(bind)到本地端口的shellcode,也可以了解编写此类shellcode的常用手法。bind型shellcode与反弹型(reverse)shellcode差别不大,只有1~2个函数或者某些参数有所差异,其余大部分代码基本相同。编写bind或reverse shell远比创建简单的execve() shell复杂得多。如果你想从简单的开始学起,你可以先学一下如何使用汇编语言编写简单的execve() shell,然后再深入阅读本篇教程。如果你需要重温Arm汇编知识,你可以参考我之前写的ARM汇编基础系列教程,或者参考如下这张图:

在正式开始前,我想提醒大家,我们正在编写ARM平台的shellcode,因此如果手头没有ARM环境,我们首先需要搭建相应的实验环境。你可以自己搭建一个(使用QEMU模拟Raspberry Pi),也可以直接下载我搭建的现成虚拟机(ARM LAB VM),一切准备就绪,可以开始工作了。

二、背景知识

首先介绍下什么是bind shell及其工作原理。使用bind shell时,我们可以在目标主机上打开某个通信端口,或者创建某个监听端(listener)。监听端接受我们发起的连接,返回能够访问目标系统的shell。

使用reverse shell时,目标主机会反连至我们的主机。这种情况下,我们的主机上需要运行一个监听端,接受目标系统的反向连接。

这两种shell各有其优点及缺点,需要根据目标环境来权衡使用。比如,通常情况下目标防火墙会阻拦入站连接,放行出站连接,此时如果你使用的是bind shell,虽然可以bind目标系统的某个端口,但由于防火墙阻拦了入站连接,结果就是你无法成功与之建连。因此,在某些场景中,我们可以优先选择使用reverse shell,如果防火墙配置不当,允许出站连接,那么我们的shell就能正常工作。如果你知道如何编写bind shell,你应该也知道如何编写reverse shell。一旦我们理解具体工作原理,只需要做几处改动,我们就可以将已有的汇编代码改成reverse shell代码。

为了将bind shell改写成汇编语言,我们首先需要熟悉bind shell的工作流程:

1、创建新的TCP socket。

2、将该socket绑定到某个本地端口上。

3、监听连接。

4、接受连接。

5、将STDIN、STDOUT以及STDERR重定向至新创建的客户端socket。

6、启动shell。

这个过程对应的C代码如下所示,后面我们会将该代码转化为相应的汇编代码:

 


三、系统函数及其参数

第一步是确定所需的系统函数、函数参数以及相应的系统调用号(system call number)。观察上述C代码,我们可知需要使用这几个函数:socket、bind、listen、accept、dup2以及execve。我们可以使用如下命令找到这些函数的系统调用号:

 


需要注意的是,_NR_SYSCALL_BASE的值为0:

 


我们所需的所有系统调用号如下所示:

 


我们可以查找Linux的man页面),了解每个函数所需的参数,也可以访问w3challs.com查找相关内容。

接下来我们需要找到这些参数的具体取值。一种方法是使用strace来查看已成功建连的bind shell。strace命令可以用来跟踪系统调用、监视进程与Linux内核之间的交互。我们用strace来测试一下C版本的bind shell。为了减少冗余信息,我限制了输出结果,只关心我们感兴趣的那几个函数。

 

 


strace的输出结果如下所示:

 


现在,我们可以记录下汇编语言的bind shell中函数所需的参数值,如下图所示:

四、逐个变换

在上一个步骤中,我们已经解答了如下几个问题,获得了汇编程序所需的所有信息:

1、我们需要哪些函数?

2、这些函数的系统调用号是多少?

3、这些函数的参数是什么?

4、这些参数的具体取什么值?

接下来我们需要综合利用这些信息,将C代码转化为汇编代码。我们可以逐个分析每个函数,重复如下过程:

1、确定每个参数所需使用的具体寄存器。

2、了解如何将所需值传递给这些寄存器。

(1)如何将某个立即数(immediate value)传递给某个寄存器。

(2)如何在不直接使用0的情况下清零某个寄存器(我们需要避免在代码中使用null字节,因此必须找到其他方法来清零寄存器或者内存中的某个值)。

(3)如何让寄存器指向保存常量及字符串的内存区域。

3、使用正确的系统调用号来调用函数,同时保持跟踪寄存器的内容变化。

(1)需要注意的是,系统调用的结果会落在r0寄存器中,也就是说,如果我们需要在另一个函数中利用之前那个函数的返回结果,那么我们需要在调用另一个函数前,将该结果保存到另一个寄存器中。

(2)举个例子:host_sockid = socket(2, 1, 0),socket调用的返回结果(host_sockid)会落在r0寄存器中。如listen(host_sockid, 2)之类的其他函数会复用这个结果,因此我们需要将结果保存到另一个寄存器中。

4.1 切换到Thumb模式

为了减少碰到null字节的可能性,我们要做的第一件事情就是使用Thumb模式。在Arm模式中,使用的是32位指令,在Thumb模式中,指令为16位。这意味着在减少指令大小的前提下,我们已经可以减少碰到null字节的概率。回顾一下如何切换到Thumb模式:ARM指令必须为4字节对齐指令。为了从ARM模式切换到Thumb模式,我们可以将PC寄存器的值加1,将(PC寄存器中)下一条指令地址的LSB(Least Significant Bit,最低有效位)设置为1,然后将其保存到另一个寄存器中。接下来,使用BX(分支(Branch)及交换(eXchange))指令跳转到另一个寄存器,这样处理器就会切换到Thumb模式。上面这段话对应如下两条指令:

从此时起,你写的就是Thumb代码,因此需要在代码中使用.THUMB指示性语句(directive)。

4.2 创建新的Socket

我们所需的socket调用参数的值如下:

 


设置完参数后,我们可以使用svc指令调用socket系统调用,所得结果为host_sockid,最终存放在r0寄存器中。由于我们后面还需要用到host_sockid,因此我们可以将这个值存放到r4寄存器中。

在ARM中,我们不能简单地将任何立即数移动到寄存器中。如果你对这一细节比较感兴趣,你可以阅读这篇参考文章(在比较靠后的章节)。

为了检查我们是否可以使用某个立即数,我写了一个简单的脚本:rotator.py

最终的代码片段为:

4.3 绑定Socket到本地端口

通过第一条命令,我们将一个包含地址族、主机端口以及主机地址的结构体对象存放在文字池(literal pool)中,通过pc相对地址来引用这个对象。文字池是同一个section中的一段内存区域(因为文字池本身就是代码中的一部分),可以存放常量、字符串或者偏移量。我们无需手动计算pc相对地址,相反,我们可以使用带有便签(label)的ADR指令完成这一任务。ADR可以接受PC相对表达式,也就是带有可选偏移量的标签,其中标签的地址为与PC标签有关的相对地址。如下所示:

接下来的5条指令为STRB(store byte)指令。STRB指令可以将寄存器中的一个字节存储到某个内存区域中。[r1, #1]语句的意思是将R1作为基地址,使用立即数(#1)作为偏移量。

在第一条指令中,我们将R1指向了存放AF_INET、本地端口以及IP地址的那个内存区域。我们可以使用静态IP地址,也可以直接使用0.0.0.0这个地址,这样我们的bind shell就会监听在目标主机所有的IP地址上,shellcode也更加灵活。但这样代码中会包含许多null字节。

我们需要处理掉所有的null字节,以便让我们的shellcode能够适配许多漏洞利用场景,因为某些漏洞利用技术针对的是内存损坏漏洞,而这种漏洞可能对null字节比较敏感。如果开发者没有正确使用诸如strcpy之类的函数,那么就会造成缓冲区溢出。strcpy的任务是拷贝数据,遇到null字节时才停止工作。我们利用缓冲区溢出来接管程序的执行流程,如果strcpy碰到null字节,那么它会停止复制我们的shellcode,因此我们的漏洞利用过程就无法顺利完成。使用STRB指令后,我们可以从寄存器中取出null字节,在执行过程中修改我们的代码。这样一来,虽然实际上我们的shellcode中没有包含null字节,但可以通过动态方式将null值添加到合适的位置中。为了实现这个功能,我们需要可写的代码段,只需在程序链接过程中使用-N标志即可。

这样处理后,我们的代码中已经不包含null字节,需要的时候再将null字节动态存放到合适的位置。如下图所示,我们最开始使用的是1.1.1.1这个IP地址,在执行过程中,这个地址会被替换为0.0.0.0

第一条STRB指令会将x02xffxff这个占位符替换为x00,以便将AF_INET设置为x02x00。那么我们怎么知道我们使用的是一个null字节呢?原因是r2寄存器存放的恰恰就是0。还记得前面我们用过的sub r2, r2, r2指令吗?这条指令会将r2寄存器清零。接下来的4条指令会将1.1.1.1替换为0.0.0.0。如果你不想在strb r2, [r1, #1]指令后面使用4条strb指令,那么你可以使用1条str r2, [r1, #4]指令,也会起到同样效果。

mov指令会将sockaddr_in结构的长度值(16个字节,其中AF_INET占了2字节,PORT占了2字节,IP地址占了4字节,还有8字节的填充数据)存放到r2中。接下来,我们将r7的值加1,变成282,因为上一条系统调用后r7的值为281。

4.4 监听连接

这个步骤中,我们将之前保存的host_sockid存放到r0中。将R1设置为2,让r7的值加2(因为上一个系统调用后r7为282)。

4.5 接受连接

这里我们同样需要将前面保存的host_sockid存放到r0中。由于我们要避开null字节,因此我们不会直接将#0移动到r1和r2中,相反,我们通过减法让这些寄存器清零。然后将R7值加1。调用完成后我们可以得到client_sockid,将这个值存放到r4中即可,此时我们已经不再需要将host_sockid保存到这个位置(我们会跳过C代码中的close函数调用语句)。

4.6 STDIN/STDOUT/STDERR

dup2函数的系统调用号为63。此时,我们需要将前面保存的client_sockid再次移动到r0中,然后通过sub指令将r1设置为0。对于剩下的两个dup2调用语句,我们只需要在每次系统调用完成后,改变r1的值,将r0重置为client_sockid即可。

4.7 启动shell

我在如何编写ARM Shellcode教程中给出了一个例子,这里execve()函数的转换过程与之前的例子相同,因此我不会再去详细解释具体步骤。

最后,我们需要在汇编代码的末尾存放一些值,如AF_INET(包含0xff数值,后面会被null字节替换)、端口号、IP地址以及/bin/sh字符串。

五、完整汇编代码

我们最终生成的bind shellcode如下所示:

六、测试shellcode

将以上汇编代码保存为bind_shell.s文件。在使用ld命令时,记得加上-N标志。之所以这么做,是因为我们使用了多个strb操作来修改我们的代码段(.text)。这就要求代码段处于可写状态,我们可以在链接过程中添加-N标志完成这个任务。

接下来,连接到我们设定的那个端口。

成功了!现在,我们可以将程序转换为十六进制字符串,命令如下:

 


我们得到了bind shellcode!这段shellcode长度为112个字节。本文是一个初学者教程,为了简单起见,我们并没有尽量去精简这段shellcode。最初的shellcode成功后,大家可以使用各种方法来缩减指令数,这样就能缩短shellcode篇幅。

希望大家读完本文后能有所收获,可以利用所学知识来编写自己的shellcode。如果有任何意见或建议,请随时与我联系。

 

转载自:https://www.anquanke.com/post/id/94527    原文地址:https://azeria-labs.com/tcp-bind-shell-in-assembly-arm-32-bit/