前几天我在代码审计知识星球里发表了一个介绍nmap利用interactive
模式提权的帖子:
# 进入nmap的交互模式
nmap --interactive
# 执行sh,提权成功
!sh
但具体实施的时候会遇到很多有趣的问题,我们来详细研究一下。
说到这个话题,我们不得不先介绍一下两个东西:
通常来说,Linux运行一个程序,是使用当前运行这个程序的用户权限,这当然是合理的。但是有一些程序比较特殊,比如我们常用的ping命令。
ping需要发送ICMP报文,而这个操作需要发送Raw Socket。在Linux 2.2引入CAPABILITIES前,使用Raw Socket是需要root权限的(当然不是说引入CAPABILITIES就不需要权限了,而是可以通过其他方法解决,这个后说),所以你如果在一些老的系统里ls -al $(which ping)
,可以发现其权限是-rwsr-xr-x
,其中有个s位,这就是suid:
root@linux:~# ls -al /bin/ping
-rwsr-xr-x 1 root root 44168 May 7 2014 /bin/ping
suid全称是Set owner User ID up on execution。这是Linux给可执行文件的一个属性,上述情况下,普通用户之所以也可以使用ping命令,原因就在我们给ping这个可执行文件设置了suid权限。
设置了s位的程序在运行时,其Effective UID将会设置为这个程序的所有者。比如,/bin/ping
这个程序的所有者是0(root),它设置了s位,那么普通用户在运行ping时其Effective UID就是0,等同于拥有了root权限。
这里引入了一个新的概念Effective UID。Linux进程在运行时有三个UID:
通常情况下Effective UID和Real UID相等,所以普通用户不能写入只有UID=0号才可写的/etc/passwd
;有suid的程序启动时,Effective UID就等于二进制文件的所有者,此时Real UID就可能和Effective UID不相等了。
有的同学说某某程序只要有suid权限,就可以提权,这个说法其实是不准确的。只有这个程序的所有者是0号或其他super user,同时拥有suid权限,才可以提权。
常用nmap的同学就知道,如果你要进行UDP或TCP SYN扫描,需要有root权限:
$ nmap -sU target
You requested a scan type which requires root privileges.
QUITTING!
$ nmap -sS 127.0.0.1
You requested a scan type which requires root privileges.
QUITTING!
原因就是这些操作会用到Raw Socket。
有时候你不得不使用sudo来执行nmap,但在脚本调用nmap时sudo又需要tty,有可能还要输入密码,这个限制在很多情况下会造成一些不必要的麻烦。
所以,有一些管理员会给nmap加上suid权限,这样普通用户就可以随便运行nmap了。
当然,增加了s位的nmap是不安全的,我们可以利用nmap提权。在nmap 5.20以前存在interactive交互模式,我们可以通过这个模式来提权:
星球里@A11risefor*师傅提到,nmap 5.20以后可以通过加载自定义script的方式来执行命令:
补充一个,--interactive应该是比较老版本的nmap提供的选项,最近的nmap上都没有这个选项了,不过可以写一个nse脚本,内容为
os.execute('/bin/sh')
,然后nmap --script=shell.nse
来提权
的确是一个非常及时的补充,因为现在大部分的nmap都是没有interactive交互模式了。
但经过测试我们发现,这个方法启动的shell似乎仍然是当前用户的,并没有我们想象中的提权。
我曾使用interactive模式提取成功,但是因为那个nmap版本过老,没有script支持,所以没法测试script的提权方法;同样,新的nmap支持script但又没有interactive模式,无法做直观对比,我只能先猜想提权失败的原因:
这些猜想中变量太多,所以我需要控制一下。首先我阅读了老版本nmap的源码,发现其实!sh
执行的就是很简单的system('sh')
,而且前面并没用丢弃Effective UID权限的操作:
} else if (*myargv[0] == '!') { cptr = strchr(command, '!'); system(cptr + 1); }
那么我们将这个过程抽象成这么一个C程序suid.c
:
int main(int argc, char* argv[]) { return system(argv[1]); }
编译,并赋予其suid权限:
root@linux:/tmp# gcc suid.c -o suid
root@linux:/tmp# chmod +s suid
接着我尝试在不同系统中,用www-data用户运行./suid id
:
Linux发行版 | 输出结果 |
---|---|
Ubuntu 14.04 | uid=33(www-data) gid=33(www-data) euid=0(root) egid=0(root) groups=0(root),33(www-data) |
Ubuntu 16.04 | uid=33(www-data) gid=33(www-data) groups=33(www-data) |
Ubuntu 18.04 | uid=33(www-data) gid=33(www-data) groups=33(www-data) |
CentOS 6 | uid=33(www-data) gid=33(www-data) groups=33(www-data) |
CentOS 8 | uid=33(www-data) gid=33(www-data) groups=33(www-data) |
Debian 6 | uid=33(www-data) gid=33(www-data) euid=0(root) egid=0(root) groups=0(root),33(www-data) |
Debian 8 | uid=33(www-data) gid=33(www-data) euid=0(root) egid=0(root) groups=0(root),33(www-data) |
Kali 2019 | uid=33(www-data) gid=33(www-data) groups=33(www-data) |
可见,有些系统是root权限,有些系统仍然是原本用户权限。那么上面nmap提权失败的原因,就可以排除nmap的原因了。
同样,CentOS 6和Debian 6都是较老的发行版,但CentOS 6的表现却和新版Ubuntu类似,经过网上的询问和翻文档,得到了bash中的这么一段说明:
If the shell is started with the effective user (group) id not equal to the real user (group) id, and the -p option is not supplied, no startup files are read, shell functions are not inherited from the environment, the SHELLOPTS, BASHOPTS, CDPATH, and GLOBIGNORE variables, if they appear in the environment, are ignored, and the effective user id is set to the real user id. If the -p option is supplied at invocation, the startup behavior is the same, but the effective user id is not reset.
如果启动bash时的Effective UID与Real UID不相同,而且没有使用-p参数,则bash会将Effective UID还原成Real UID。
我们知道,Linux的system()
函数实际上是执行的/bin/sh -c
,而CentOS的/bin/sh
是指向了/bin/bash
:
[root@localhost tmp]# ls -al /bin/sh
lrwxrwxrwx. 1 root root 4 Apr 10 2017 /bin/sh -> bash
这就解释了为什么CentOS中suid程序执行id获得的结果仍然是www-data。假设我们此时将sh修改成dash,看看结果是什么:
[root@localhost tmp]# su -s /bin/bash nobody
bash-4.1$ ls -al /bin/sh
lrwxrwxrwx. 1 root root 9 Feb 19 00:21 /bin/sh -> /bin/dash
bash-4.1$ ./suid id
uid=99(nobody) gid=99(nobody) euid=0(root) egid=0(root) groups=0(root),99(nobody) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
dash并没有限制Effective UID,这里可以看到成功获取了root权限。
但是,我们来看看Ubuntu 16.04,其/bin/sh指向的同样是dash:
$ ls -al /bin/sh
lrwxrwxrwx 1 root root 4 9月 18 2016 /bin/sh -> dash
$ ls -al /bin/dash
-rwxr-xr-x 1 root root 154072 2月 17 2016 /bin/dash
为什么仍然会出现无法提权的情况?
此时我们又需要了解另一个情况了。通常来说,类似Ubuntu这样的发行版都会对一些程序进行修改,比如我们平时在查看PHP版本的时候,经常会看到这样的banner:PHP 7.0.33-0ubuntu0.16.04.11
,在官方的版本号后会带上Ubuntu的一些版本号,这是因为Ubuntu发行版在打包这些软件时会增加一些自己的代码。
那么我们可以来看看Ubuntu 16.04源中dash目录:
下载其中的dash_0.5.8.orig.tar.gz
和dash_0.5.8-2.1ubuntu2.diff.gz
并分别加压,我们可以看到dash 0.5.8的原始代码,和Ubuntu对其做的patch。
我们对原始代码进行patch后,会发现多了一个setprivileged
函数:
void setprivileged(int on) { static int is_privileged = 1; if (is_privileged == on) return; is_privileged = on; /* * To limit bogus system(3) or popen(3) calls in setuid binaries, require * -p flag to work in this situation. */ if (!on && (uid != geteuid() || gid != getegid())) { setuid(uid); setgid(gid); /* PS1 might need to be changed accordingly. */ choose_ps1(); } }
on
的取值取决于用户是否传入了-p
参数, 而uid和gid就是当前进程的Real UID(GID)。可见,在on为false,且Real UID 不等于Effective UID的情况下,这里重新设置了进程的UID:
setuid函数用于设置当前进程的Effective UID,如果当前进程是root权限或拥有CAP_SETUID
权限,则Real UID和Saved UID将被一起设置。
所以,可以看出,Ubuntu发行版官方对dash进行了修改:当dash以suid权限运行、且没有指定-p
选项时,将会丢弃suid权限,恢复当前用户权限。
这样以来,dash在suid的表现上就和bash相同了,这也就解释了为什么在Ubuntu 16.04以后,我们无法直接使用SUID+system()
的方式来提权。
同样的,你下载Debian 10最新的dash,也可以看到类似代码。那么,为什么各大发行版分分在sh中增加了这个限制呢?
我们可以将其理解为是Linux针对suid提权方式的一种遏制。因为通常来说,很多命令注入漏洞都是发生在system()
和popen()
函数中的,而这些函数依赖于系统的/bin/sh。相比CentOS来说,Ubuntu和Debian中的sh一直都是dash,也就一直受到suid提权漏洞的影响。
一旦拥有suid的程序存在命令注入漏洞或其本身存在执行命令的功能,那么就有本地提权的风险,如果在sh中增加这个限制,提权的隐患就能被极大地遏制。
那么,如果我们就是要留一个具有suid的shell作为后门,我们应该怎么做?
将之前的suid.c
做如下修改:
int main(int argc, char* argv[]) { setuid(0); system(argv[1]); }
编译和执行,我们就可以发现,id命令输出的uid就是0了:
原因是我们将当前进程的Real UID也修改成了0,Real UID和Effective UID相等,在进入dash后就不会被降权了。
另一种方法,我们可以给dash或bash增加-p选项,让其不对shell降权。但这里要注意,我们不能再使用system函数了,因为system()
内部执行的是/bin/sh -c
,我们只能控制-c的参数值,无法给sh中增加-p选项。
这里我们可以使用execl
或其他exec系列函数:
int main(int argc, char* argv[]) {
return execl("/bin/sh", "sh", "-p", "-c", argv[1], (char *)0);
}
此时输出结果类似于Ubuntu 14.04里的结果,因为我给sh加了-p参数:
再回到我们最初的问题:那么具有suid权限的nmap在Ubuntu 18.04或类似系统中我们如何进行提权呢?
因为nmap script中使用的是lua语言,而lua库中似乎没有直接启动进程的方式,都会依赖系统shell,所以我们可能并不能直接通过执行shell的方式来提权。但是因为此时nmap已经是root权限,我们可以通过修改/etc/passwd
的方式来添加一个新的super user:
local file = io.open("/etc/passwd", "a")
file:write("root2::0:0::/root:/bin/bash\n")
file:close()
成功提权:
作为一个系统的运维人员,我们如何来防御类似的suid提权攻击呢?
当然我们需要先感谢Linux内核和Ubuntu和Debian等发行版的开发人员,他们也在慢慢帮我们不断提高系统的安全性和稳定性,但类似于nmap这样功能强大的软件,我们无法奢求一律Secure By Default,所以必须学习一些更有趣的知识。
Linux 2.2以后增加了capabilities的概念,可以理解为水平权限的分离。以往如果需要某个程序的某个功能需要特权,我们就只能使用root来执行或者给其增加SUID权限,一旦这样,我们等于赋予了这个程序所有的特权,这是不满足权限最小化的要求的;在引入capabilities后,root的权限被分隔成很多子权限,这就避免了滥用特权的问题,我们可以在capabilities(7) - Linux manual page中看到这些特权的说明。
类似于ping和nmap这样的程序,他们其实只需要网络相关的特权即可。所以,如果你在Kali下查看ping命令的capabilities,你会看到一个cap_net_raw
:
$ ls -al /bin/ping
-rwxr-xr-x 1 root root 73496 Oct 5 22:34 /bin/ping
$ getcap /bin/ping
/bin/ping = cap_net_raw+ep
这就是为什么kali的ping命令无需设置setuid权限,却仍然可以以普通用户身份运行的原因。
同样,我们也可以给nmap增加类似的capabilities:
sudo setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip /usr/bin/nmap
nmap --privileged -sS 192.168.1.1
再次使用TCP SYN扫描时就不会出现权限错误的情况了: