溯源imap_open是如何绕过disable_function

在最近遇见的CTF题中有很多都禁了所有有关命令执行的函数一类的题,如何绕过disable_function执行命令确实是一个问题,对于想要绕过disable_function的方法,有下面几种:

  • 利用环境变量LD_PRELOAD
  • ImageMagick
  • mail函数
  • imagecreatefromgd2
  • 破壳bash漏洞
  • imap_open

最新的方法肯定是imap_open,于是找来几篇imap_open关于绕过disable_function的文章来研究一下,最近也有类似用到imap的CTF题。看了2篇外文,2篇中文。在知道基本的原理的基础上,发现都讲的差不错,然而有些细节方面都没有我想要的,甚至某些地方有一些出入。我想要知道为什么imap会走到ssh上,我注意到在php 函数imap_open的第一个$mailbox参数中多了一个},没有它似乎也没有外部的execve调用,我也想知道内部是如何分隔$mailbox参数的,带着这样的疑问,便有了此文,把php 内核调用imap-2007f的mail_open过程看了一边,终于理顺了。纸上得来终觉浅!

0x01 imap_open 整个调用链的分析

0x00 PHP调用mail_open的过程

php 内部并没有自己实现imap过程,用的是c下imap-2007f的库。

1
0x1 PHP_FUNCTION(imap_open) -> php_imap_do_open() -> mail_open();

这是PHP 内的调用过程,全程没有对其第一个参数$mailbox进行处理,传给了mail_open()

1
imap_stream = mail_open(NIL, ZSTR_VAL(mailbox), flags);

0x01 imap-2007f下mail_open执行过程

进入imap-2007f的库函数下mail_open() imap-2007f/src/c-client/mail.c:1186name$mailbox

1
2
3
4
5
6
switch (name[0]) {		
case '#': ...
default:
d = mail_valid (NIL,name,(options & OP_SILENT) ?
(char *) NIL : "open mailbox");
}

进入 switch 如果mailbox 开头是#,会进行特别的处理,类似于hook预处理。

或者进入default这是我们要进的位置。

0x02 驱动选择过程

->mail_valid() imap-2007f/src/c-client/mail.c:1257

选择要使用的邮件驱动 包括 imapdriverpop3drivernntpdriverdummydriver

首先会有一个基础的判断mailbox不允许带 \n\r,前面会通过mail_link() 将这些驱动注册到全局变量factory

1
2
3
4
5
for (factory = maildrivers; factory && 
((factory->flags & DR_DISABLE) ||
((factory->flags & DR_LOCAL) && (*mailbox == '{')) ||
!(*factory->valid) (mailbox));
factory = factory->next);

通过遍历factory,由各个驱动的valid 函数来进行决定用哪个驱动。最后返回选择好的驱动。比如imap_vaild() 都会通过 mail_vaild_net_parse()分割mailbox 通过得到的 service 和 驱动的名字比较。若相符则正常返回。这个函数是重点后面详细讲,返回选择好的驱动返回,接着走。

1
d ? mail_open_work (d,stream,name,options) : stream;

-> mail_open_work( ) imap-2007f/src/c-client/mail.c 1260:

选择好驱动以后,进入mail_open_work(), 因为传入streamnil,需要初始化一个新的stream 结构,然后调用相应mail驱动的open() 函数,这里是imap_open()

1
return ((*d->open)(stream)) ? stream : mail_close(stream);

0x03 imap_open 执行过程

-> imap_open() imap-2007f/src/c-client/imap4r1.c:783

这里首先会进入mail_valid_net_parse()这个刚才函数,这里是为了将$mailbox分割出一个NETMBX结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct net_mailbox {
char host[NETMAXHOST]; /* host name (may be canonicalized) */
char orighost[NETMAXHOST]; /* host name before canonicalization */
char user[NETMAXUSER]; /* user name */
char authuser[NETMAXUSER]; /* authentication user name */
char mailbox[NETMAXMBX]; /* mailbox name */
char service[NETMAXSRV]; /* service name */
unsigned long port; /* TCP port number */
unsigned int anoflag : 1; /* anonymous */
unsigned int dbgflag : 1; /* debug flag */
unsigned int secflag : 1; /* secure flag */
unsigned int sslflag : 1; /* SSL driver flag */
unsigned int trysslflag : 1; /* try SSL driver first flag */
unsigned int novalidate : 1; /* don't validate certificates */
unsigned int tlsflag : 1; /* TLS flag */
unsigned int notlsflag : 1; /* do not do TLS flag */
unsigned int readonlyflag : 1;/* want readonly */
unsigned int norsh : 1; /* don't use rsh/ssh */
unsigned int loser : 1; /* server is a loser */
unsigned int tlssslv23 : 1; /* force SSLv23 client method over TLS */
} NETMBX;

其中包括后面需要用到的各种参数。也是后面判断进入各种流程的重要依据.

->mail_valid_net_parse() -> mail_valid_net_parse_work() imap-2007f/src/c-client/mail.c 734

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (*name++ != '{') return NIL;
if (*name == '[') { /* if domain literal, find its ending */
if (!((v = strpbrk (name,"]}")) && (*v++ == ']'))) return NIL;
}
/* find end of host name */
else if (!(v = strpbrk (name,"/:}"))) return NIL;
/* validate length, find mailbox part */
if (!((i = v - name) && (i < NETMAXHOST) && (t = strchr (v,'}')) &&
((j = t - v) < MAILTMPLEN) && (strlen (t+1) < (size_t) NETMAXMBX)))
return NIL; /* invalid mailbox */
strncpy (mb->host,name,i); /* set host name */
strncpy (mb->orighost,name,i);
mb->host[i] = mb->orighost[i] = '';
strcpy (mb->mailbox,t+1)

重点看host ,前面都是基础的判断条件,去掉了[] 包裹的内容。

“/:}”中第一个出现的字符标志为hostname的结束符, 却最后又以}判断 server部分的结束,}之后判定为邮箱的名字。当出现一种情况}同时是hostname的结束符,也是整个server部分的结束符。当出现这种下,中间存在目标邮箱地址的端口号和指定的flag都会被略过,会出现下面if判断条件不成立。

1
if (t - v) // t-v == 0

因为不会进入接下的if语句,就不会进行目标邮箱server的端口赋值即mb->port和各种/flag的判断和对mb的赋值 。即使原来的mailbox里flag参数中存在/norsh 也不会起作用。这个分割函数其实很有意思,因为没有进入if语句 也没有得到相应的service 参数来选择对应的驱动,但是会自动赋值为调用者的service名字,前面说了各个mail驱动的vaild的函数会选择是否适用于当前mailbox,都会默认传递自己的service的名字,可以说4个驱动在这种情况都是可以用的,但是很巧的是 imapdriver是第一个注册的。所以理所当然的走到了imap下。

1
if (!*mb->service) strcpy (mb->service,service);

回到 ->imap_open

经过分割得到的mb 参数。因为没有进入if语句会丢失很多赋值过程

1
2
3
4
5
if (mb.dbgflag) stream->debug = T;
if (mb.readonlyflag) stream->rdonly = T;
if (mb.anoflag) stream->anonymous = T;
if (mb.secflag) stream->secure = T;
if (mb.trysslflag || imap_tryssl) stream->tryssl = T;

列如这些都不会进如 if,因为/flag 都没有判断。

1
2
3
4
if (stream->anonymous || mb.port || mb.sslflag || mb.tlsflag)
reply = (LOCAL->netstream = net_open (&mb,NIL,defprt,ssld,"*imaps",
sslport)) ?
imap_reply (stream,NIL) : NIL;

相应的 stream->anonymous mb.port mb.sslflag mb.tlsflag都是NIL

1
else if (reply = imap_rimap (stream,"*imap",&mb,usr,tmp));

0x04 imap_rimap 执行过程

->imap_rimap imap-2007f/src/c-client/imap4r1.c:1022

1
if (!mb->norsh && (tstream = net_aopen (NIL,mb,service,usr)))

-> net_aopen() /root/bypass_disable_function/imap-2007f/src/c-client/mail.c:6218

1
2
if (!dv) dv = &tcpdriver;
if (tstream = (*dv->aopen) (mb,service,user))

-> tcp_aopen(unix) imap-2007f/src/osdep/unix/tcp_unix.c imap-2007f/src/osdep/unix/tcp_unix.c:330

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifdef SSHPATH                       /* ssh path defined yet? */
if (!sshpath) sshpath = cpystr (SSHPATH);
#endif
#ifdef RSHPATH /* rsh path defined yet? */
if (!rshpath) rshpath = cpystr (RSHPATH);
#endif
if (*service == '*') { /* want ssh? */
/* return immediately if ssh disabled */
if (!(sshpath && (ti = sshtimeout))) return NIL;
/* ssh command prototype defined yet? */
if (!sshcommand) sshcommand = cpystr ("%s %s -l %s exec /etc/r%sd");
}
/* want rsh? */
else if (rshpath && (ti = rshtimeout)) {
/* rsh command prototype defined yet? */
if (!rshcommand) rshcommand = cpystr ("%s %s -l %s exec /etc/r%sd");
}
else return NIL;

关于rsh的寻址问题其他文章也讲的很清楚了。SSHPATHRSHPATH都先从/etc/c-client.cf 的配置文件得到,一般都是为空,但RSHPATH在编译的时候可以从makefile里面可以被指定为 /usr/bin/rsh,在debianrsh 又是指向 ssh

图片2.png
1
2
3
4
else if (rshpath && (ti = rshtimeout)) {
/* rsh command prototype defined yet? */
if (!rshcommand) rshcommand = cpystr ("%s %s -l %s exec /etc/r%sd");
}

rshcommand是默认nil,这里赋值。

1
else sprintf (tmp,rshcommand,rshpath,host,mb->user[0] ? mb->user : myusername (),service);

写入tmp里面,rshpath/usr/bin/rshhostmb->host经过tcp_canonical 并通过dns解析,返回原值。user 为 myusername()调用者是谁,我这里是root ,service 即“imap”

1
2
3
4
tmp` 应为 `“rshpath mb->host -l myusername() exec /usr/sbin/rimapd”
for (i = 1,path = argv[0] = strtok_r (tmp," ",&r);
(i < MAXARGV) && (argv[i] = strtok_r (NIL," ",&r)); i++);
argv[i] = NIL;

这个地方也很关键,是分割pathargs 的位置,这里也有跟国内的两篇的文章有出入,并不是他们说的不能用$mailbox 包含空格 ,因为会被转义,?????,这是转义吗?这是以空格为标志分割命令执行的参数。所以这里不能用空格 可以用 \t$IFS来代替,

其最后execv (path,argv)

1
execv("/usr/bin/rsh",["/usr/bin/rsh",host ...]

这里已经可以通过修改hostname达到参数注入,同样ssh 也有那么一个参数可以执行任意命令-oProxyCommand,避免不要的麻烦,可以base64,因为涉及到getshell 可能会出现写路径出现/,因为前面说了/同样是可以判定hostname的结束符,这样把}提前就没有意义了。下面的strace 结果可能会看的更清楚

图片1.png

0x02 Payload && 官方修复分析

附上官方验证性payload ,至于修复php官方 默认将rsh和ssh 的连接超时设置为了0

1
2
3
4
5
6
7
8
9
STD_PHP_INI_BOOLEAN("imap.enable_insecure_rsh", "0", PHP_INI_SYSTEM, OnUpdateBool, enable_rsh, zend_imap_globals, imap_globals)

...

if (!IMAPG(enable_rsh)) {
/* disable SSH and RSH, see https://bugs.php.net/bug.php?id=77153 */
mail_parameters (NIL, SET_RSHTIMEOUT, 0);
mail_parameters (NIL, SET_SSHTIMEOUT, 0);
}

rshtimeoutsshtimeout 同样也可以来源于 /etc/c-client.cf ,一般情况这个文件是空的,同样在tcp_unxi.c 中也对这个两个值进行了赋值,默认都为15.

tcp_aopen() imap-2007f/src/osdep/unix/tcp.unix.c 349

1
else if (rshpath && (ti = rshtimeout)) return NIL

这样一来就给限制住了,直接返回nil. 下面是官方测试用的payload

1
2
3
$payload = "echo 'BUG'> " . __DIR__ . '/__bug';
$payloadb64 = base64_encode($payload);
$server = "x -oProxyCommand=echo\t$payloadb64|base64\t-d|sh}";

0x03 对其整个过程的思考和总结

在php 使用imap_open的时候一定需要注意 :第一个参数$mailbox php本身并没有对其进行检验是否符合格式的操作。只是判断了第一个字符是不是{,在c的imap-2007f库中虽然进行了参数化,但在处理上还是有一定的问题。

比如在最后tcp_aopen() imap-2007f/src/osdep/unix/tcp.unix.c:371

1
strcpy(host,tcp_canonical(mb->host))

tcp_canonical 仅仅对hostname 进行了 ip_nametoadress 其本身就是调用了gethostname,若解析不了则原hostname 返回,这过程是不是存在一点问题?

所以在使用imap_open 中对hostname 一定要严格限定,应过滤相应的/ } 这些字符。

在选择外部调用的问题上,是否应该完全交付处理,这问题出在信任链上。

0x04 整个过程的调用链

img

0x05 参考连接

https://lab.wallarm.com/rce-in-php-or-how-to-bypass-disable-functions-in-php-installations-6ccdbf4f52bb

https://nosec.org/home/detail/2044.html

https://xz.aliyun.com/t/4113

https://github.com/asmlib/imap-2007f