作者:merjerson@360CERT
0x00 背景
做过脚本的同学都知道提权的苦楚。
平时在做定向渗透,溯源反制的时候,经常遇到进得去,系统却拿不下来的情况。
14号看到 Snap < 2.37.1 提权漏洞,测了下,异常好用。在 ubuntu 18.04之后版本默认安装,只需要有文件写入权限和python环境,即可完美提权。几个vps一打一个准。
想了想,最末分析过的 linux 平台漏洞还是 “脏牛”。近半年多一直在搞其他方面,许久没做漏洞分析了,正好有个提权漏洞换换脑子。
0x01 linux提权姿势梳理
首先梳理一下 linux 提权的种类。我所知道的所有提权思路有这么几种:
- 内核漏洞利用
- 服务、程序漏洞利用
- 权限配置不当
内核漏洞利用
内核漏洞利用是最常见的提权方式,渗透提权的时候首先想到的就是查看系统版本、内核版本。根据环境找提权 exp。
内核漏洞利用的常规利用方式,有这么几步:
- 通过漏洞将 payload 打入到内核模式下
- 操纵内存数据,比如将用户空间映射到内核空间
- 启动新权限的shell,获得root权限
这种提权方法,需要找到对应内核版本的漏洞利用工具,并且具有运行利用工具的能力。
即使能够运行工具,也不一定完全提权成功。许多公开的漏洞利用工具都不稳定。运行提权工具有可能造成目标主机宕机重启。
服务、程序漏洞利用
权限具有继承性,高权限运行的服务、程序,他的执行能力也是高权限。一些web服务,数据库应用,系统服务组件往往都在高权限下运行。
例如,运维人员通常使用root权限运行mysql。这时可以尝试使用mysql提权漏洞,将低权限的mysql用户提权至mysql root 权限。
mysql 本身具有 shell 执行环境。系统root身份运行的mysql,其 mysql root 权限接近系统 root。
权限配置不当
这种要具体情况具体分析,常见的比如:
- 弱口令
- suid配置错误
- sudo权限滥用
- 路径配置不当
- 配置不当的Cron jobs 等
这部分,可以参照这篇 blog。
本次分析的snap提权漏洞,属于root权限运行的服务漏洞。
snapd 在使用api的时候,身份鉴权存在问题,允许地权限用户调用高权限api,从而造成提权。
一篇完整的漏洞分析必须要包括:
- 漏洞背景
- 漏洞成因分析
- 漏洞上下文分析
- 利用方式分析
- 补丁分析
- 漏洞验证
- 安全建议
下面是正文
0x02 漏洞背景
snap是一个Linux系统上的包管理软件。在Ubuntu18.04后默认预安装到了系统中。
snapd 是负责管理本地安装服务与在线应用商店通信的程序,随着snap一起安装,并且在root权限下运行,这是提权的基本条件。
根据官方描述,服务进程snapd中提供的REST API服务对请求客户端身份鉴别存在问题,从而导致了提权。Chris Moberly 已经公开了细节。
0x03 漏洞成因分析
漏洞位置在:
func ucrednetGet(remoteAddr string) (pid uint32, uid uint32, socket string, err error) {
pid = ucrednetNoProcess
uid = ucrednetNobody
for _, token := range strings.Split(remoteAddr, ";") {
var v uint64
if strings.HasPrefix(token, "pid=") {
if v, err = strconv.ParseUint(token[4:], 10, 32); err == nil {
pid = uint32(v)
} else {
break
}
} else if strings.HasPrefix(token, "uid=") {
if v, err = strconv.ParseUint(token[4:], 10, 32); err == nil {
uid = uint32(v)
} else {
break
}
}
if strings.HasPrefix(token, "socket=") {
socket = token[7:]
}
}
if pid == ucrednetNoProcess || uid == ucrednetNobody {
err = errNoID
}
return pid, uid, socket, err
}
该函数,对 remoteAddr进行分割,标志符为 “;” ,将分割后得到的数组,for 循环。通过 HasPrefix 判别内容,对pid、uid、socket进行赋值。
这里存在一个问题: for循环中,有可能会对变量重复赋值。Split分割后的数组,如果存在多个 uid= 开头的值,则 uid 的值将被后者覆盖。
例如,”uid=1000;pid=1100;uid=0″,通过 ; 进行分割,得到[‘uid=1000′,’pid=1100′,’uid=0’],该数组在迭代的时候:
} else if strings.HasPrefix(token, "uid=") {
if v, err = strconv.ParseUint(token[4:], 10, 32); err == nil {
uid = uint32(v)
} else {
break
}
}
第一次执行到这里的时候,uid被赋值为1000,因为后面还有一个以uid为开头的值(uid=0),所以程序还会进入这个代码段,将uid 重置为0。这是,漏洞形成的基本逻辑。
如果是发漏洞预警,分析到这里已经可以了。但如果是做漏洞研究,还远远不够,还要进行漏洞上下文和利用技术分析。
0x04 漏洞上下文分析
除了找到漏洞成因,还要知道”漏洞从哪来,到哪去”。
从哪来:
漏洞逻辑函数:
func ucrednetGet(remoteAddr string) (pid int32, uid uint32, socket string, err error) {
pid = ucrednetNoProcess
uid = ucrednetNobody
for _, token := range strings.Split(remoteAddr, ";") {
var v uint64
......
漏洞处理函数,ucrednetGet() ,传入变量为 remoteAddr,该变量即是Split处理对象。则查找该函数调用关系。
可以看到有n多调用,在api.go 文件中,有丰富逻辑代码。随进入分析。
ucrednetGet() 被重命名为 postCreateUserUcrednetGet() 和 runSnapctlUcrednetGet(), 查看调用逻辑:
func getUsers(c *Command, r *http.Request, user *auth.UserState) Response {
_, uid, _, err := postCreateUserUcrednetGet(r.RemoteAddr)
if err != nil {
return BadRequest("cannot get ucrednet uid: %v", err)
}
if uid != 0 {
return BadRequest("cannot get users as non-root")
}
......
postCreateUserUcrednetGet() 传入的参数为 r.RemoteAddr 。r 为 http.Request对象。由此可得,漏洞逻辑代码,处理的对象来自,http.Request.RemoteAddr ,即:
传入漏洞逻辑函数 ucrednetGet() 的参数 remoteAddr 为 http.Request.RemoteAddr。
查了下,http.Request.RemoteAddr 为 go 内建结构,之后查看 go 代码。
这里,分析了go中整个 SockaddrUnix 调用过程。这里只简单写下要点:
- coon.go:123 声明 RemoteAddr(),调用Conn.conn.RemoteAddr()
- coon.go:27 声明结构体 Conn,其中 conn 为 net.Conn
- net.go:221 声明net.conn.RemoteAddr(),返回c.fd.raddr,c 为 conn指针
- net.go:164 声明conn结构体,fd 为 netFD 指针
- fd_unix.go:19 声明 netFD 结构体。
- fd_unix.go:45 声明 setAddr 函数,对 netFD.raddr进行赋值, 此处即为漏洞传入参数 RemoteAddr,首次声明位置。 找到这里还不够,我们需要知道这个传入的值,究竟从哪来的。
- file_unix.go:66 调用 setAddr() :fd.setAddr(laddr, raddr),第二个参数,是我们需找的。
- file_unix.go:60 设置raddr:addr := fd.addrFunc()(rsa)
- sock_posi.go:92 声明 addrFunc(),可以看到根据套接字族设定进行不同的操作,返回sockaddrToXXX
func (fd *netFD) addrFunc() func(syscall.Sockaddr) Addr {
switch fd.family {
case syscall.AF_INET, syscall.AF_INET6:
switch fd.sotype {
case syscall.SOCK_STREAM:
return sockaddrToTCP
case syscall.SOCK_DGRAM:
return sockaddrToUDP
case syscall.SOCK_RAW:
return sockaddrToIP
}
case syscall.AF_UNIX:
switch fd.sotype {
case syscall.SOCK_STREAM:
return sockaddrToUnix
case syscall.SOCK_DGRAM:
return sockaddrToUnixgram
case syscall.SOCK_SEQPACKET:
return sockaddrToUnixpacket
}
}
return func(syscall.Sockaddr) Addr { return nil }
}
- 查阅资料,原来 AF_UNIX 用于进程间通信,绑定的文件,可以通过 sockaddrToUnix 取得。下面是说明:
…….
Address format
A UNIX domain socket address is represented in the following
structure:struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* pathname */
};The sun_family field always contains AF_UNIX. On Linux sun_path is
108 bytes in size; see also NOTES, below.Various systems calls (for example, bind(2), connect(2), and
sendto(2)) take a sockaddr_un argument as input. Some other system
calls (for example, getsockname(2), getpeername(2), recvfrom(2), and
accept(2)) return an argument of this type.Three types of address are distinguished in the sockaddr_un struc‐
ture:* pathname: a UNIX domain socket can be bound to a null-terminated
filesystem pathname using bind(2). When the address of a pathname
socket is returned (by one of the system calls noted above), its
length isoffsetof(struct sockaddr_un, sun_path) + strlen(sun_path) + 1
and sun_path contains the null-terminated pathname. (On Linux,
the above offsetof() expression equates to the same value as
sizeof(sa_family_t), but some other implementations include other
fields before sun_path, so the offsetof() expression more portably
describes the size of the address structure.)For further details of pathname sockets, see below.
……
- unixsock_posix.go:52 定义了 sockaddrToUnix(),可以看到,是通过 syscall.SockaddrUnix获得的绑定文件名。
分析到这里,RemoteAddr 怎么来的我们算整明白了:根据不同的套接字族,返回不同的地址。如果是通过 AF_UNIX 创建的套接字,将返回绑定的文件名。
到哪去
那么,哪里调用了存在漏洞的函数?该漏洞有多大影响呢?
之前看到,漏洞函数在api.go 中进行调用:
ucrednetGet 重命名为 postCreateUserUcrednetGet, 在postCreateUser有调用:
......
func postCreateUser(c *Command, r *http.Request, user *auth.UserState) Response {
_, uid, _, err := postCreateUserUcrednetGet(r.RemoteAddr)
if err != nil {
return BadRequest("cannot get ucrednet uid: %v", err)
}
if uid != 0 {
return BadRequest("cannot use create-user as non-root")
}
......
而该函数,对应的是创建本地用户的API:
......
createUserCmd = &Command{
Path: "/v2/create-user",
POST: postCreateUser,
}
......
了解下 snap API:
功能是创建本地用户,使用权限是root。结合漏洞会将uid 覆盖为 0(root)的可能,则该漏洞可以通过调用api,创建用户,如果 sudoer 设置为true,则创建的为特权用户。
0x05 漏洞利用分析
之上,将漏洞分析的明明白白。此时其实可以自己写出exp:
- 创建 AF_UNIX 族套接字
- 绑定一个文件,文件名为;uid=0,“;”用于截取字符串,获取覆盖uid的能力。
- 调用API,且sudoer 设为true
- snapd在鉴权的时候会获取远程地址,如果是 AF_UNIX 类型套接字。将返回绑定的文件,触发漏洞。
- 鉴权的到uid=0,认为是root权限调用,执行生成本地用户操作,且调用API,且sudoer=true,则生成的用户具有特权。
漏洞作者给的 exp,确实是这么写的。
0x06 补丁分析
漏洞修补的很粗暴,之前:
return fmt.Sprintf("pid=%s;uid=%s;socket=%s;%s", wa.pid, wa.uid, wa.socket, wa.Addr)
现在定义了一个结构体 ucrednet ,并且现在
return fmt.Sprintf("pid=%d;uid=%d;socket=%s;", un.pid, un.uid, un.socket)
不再返回 wa.Addr ,即不再处理远程连接地址。通过 AF_UNIX 套接字向RemoteAddr 注入文本已经行不通。从而修补了漏洞。
0x07 漏洞验证
漏洞十分的好用,snap < 2.37.1 以下版本均受影响。
因为在ubuntu 18.04 以后版本,默认安装 snap ,并且测试时发现,一些vps 供应商 Ubuntu 16.04 同样默认安装snap。
以下vps服务商的 ubuntu 安装镜像均存在问题:
- 腾讯云
- 谷歌云
- 亚马逊云
- vultr
- 搬瓦工
- ……
除了阿里云外,一打一个准。阿里云ubuntu 镜像中,不带有snap,是我测的主机中,唯一不受漏洞影响的云服务商。
0x08 安全建议
修补很简单,将 snap 升级到最新版就好了。
有 ubuntu vps 的同学,建议查看一下自己主机上snap的版本。
0x09 后记
很多漏洞作者,都会公布漏洞详情。建议做漏洞分析的同学,不要先去看漏洞详情。成长的过程在于对漏洞的摸索。
拿着分析文章,看一步调一步。没有太大意义,沉淀不了自己的经验。
本次分析的snap漏洞,唯一卡住的地方,是套接字那里。去看漏洞作者详细分析,才知道原来还有 AF_UNIX 用于本地进程通信。这个是我知识盲区,卡在这确实没办法。
ps:其实整篇下来,最难得部分是逆 go 的net标准库。(笑