ThinkPHP 系列框架梳理

Scroll Down

ThinkPHP 5.0.x

目录结构

project  应用部署目录
├─application           应用目录(可设置)
│  ├─common             公共模块目录(可更改)
│  ├─index              模块目录(可更改) module
│  │  ├─config.php      模块配置文件
│  │  ├─common.php      模块函数文件
│  │  ├─controller      控制器目录 controller 
│  │  ├─model           模型目录
│  │  ├─view            视图目录
│  │  └─ ...            更多类库目录
│  ├─command.php        命令行工具配置文件
│  ├─common.php         应用公共(函数)文件
│  ├─config.php         应用(公共)配置文件
│  ├─database.php       数据库配置文件
│  ├─tags.php           应用行为扩展定义文件
│  └─route.php          路由配置文件
├─extend                扩展类库目录(可定义)
├─public                WEB 部署目录(对外访问目录)**
│  ├─static             静态资源存放目录(css,js,image)
│  ├─index.php          应用入口文件
│  ├─router.php         快速测试文件
│  └─.htaccess          用于 apache 的重写
├─runtime               应用的运行时目录(可写,可设置)
├─vendor                第三方类库目录(Composer)**
├─thinkphp              框架系统目录
│  ├─lang               语言包目录
│  ├─library            框架核心类库目录
│  │  ├─think           Think 类库包目录 **
│  │  └─traits          系统 Traits 目录
│  ├─tpl                系统模板目录
│  ├─.htaccess          用于 apache 的重写
│  ├─.travis.yml        CI 定义文件
│  ├─base.php           基础定义文件
│  ├─composer.json      composer 定义文件
│  ├─console.php        控制台入口文件
│  ├─convention.php     惯例配置文件
│  ├─helper.php         助手函数文件(可选)
│  ├─LICENSE.txt        授权说明文件
│  ├─phpunit.xml        单元测试配置文件
│  ├─README.md          README 文件
│  └─start.php          框架引导文件 **
├─build.php             自动生成定义文件(参考)
├─composer.json         composer 定义文件 **
├─LICENSE.txt           授权说明文件
├─README.md             README 文件
├─think                 命令行入口文件

生命周期

这里根据官方文档的说明,一步步跟进调试源码,来梳理TP5.0.x的整个执行流程,并熟悉核心类库的各个核心类。

入口文件

  • 通常是public/index.php,可以更改
  • 常用于定义系统常量以及加载引导文件

引导文件

默认是/thinkphp/start.php,并且加载同目录下base.php文件,主要完成以下操作

1.加载系统常量、环境常量和变量(.env)

常见的常量有:

2.注册自动加载机制

任何关于类加载的操作,核心处理类为\think\Loader,该类定义了autoload方法,并在引导文件时注册该方法为自动类加载机制的处理方法。

image-20210526180904722

常见类库文件的位置及其根命名空间(默认):

根命名空间描述类库目录
think系统核心类库thinkphp/library/think
traits系统Trait类库thinkphp/library/traits
app应用类库application
thinkcomposer第三方类库vendor/topthink
自定义自定义类库extend/自定义名

3.

路由

路由模式

三种路由模式

  • 普通模式(默认)

该模式下关闭路由'url_route_on' => false,不会解析任何路由规则,而是完全通过默认的PATH_INFO模式来访问,即

http://serverName/index.php/module/controller/action/param/value/...

对于参数解析,默认成对解析,也可以改为顺序解析,即每个参数都是值,索引递增,'url_param_type' => 1

  • 混合模式
'url_route_on'  =>  true,
'url_route_must'=>  false,

该模式下,定义路由规则的则按路由规则访问,未定义的则按普通模式访问。

  • 强制模式
'url_route_on'  		=>  true,
'url_route_must'		=>  true,

该模式默认不开启,每一个访问必须严格根据路由定义进行dispatch,如果为定义相关路由,则直接抛出异常

路由定义

  • 动态注册
think\Route::rule('路由表达式','路由地址','请求类型','路由参数(数组)','变量规则(数组)');

默认在应用目录下的route.php文件,如application/route.php,用法

// 注册路由到index模块的News控制器的read操作
Route::rule('new/:id','index/News/read');

对应不同请求类型的还有便利方法有

Route::get('new/:id','News/read'); // 定义GET请求路由规则
Route::post('new/:id','News/update'); // 定义POST请求路由规则
Route::put('new/:id','News/update'); // 定义PUT请求路由规则
Route::delete('new/:id','News/delete'); // 定义DELETE请求路由规则
Route::any('new/:id','News/read'); // 所有请求都支持的路由规则
  • 路由表达式

/为参数分割符,支持一下几种形式

'/' => 'index' // 首页访问路由

'my'        =>  'Member/myinfo' // 静态地址路由

'blog/:id'  =>  'Blog/read' // 静态地址和动态地址结合

':user/:blog_id'=>'Blog/read' // 全动态地址

'blog/:year/[:month]'=>'Blog/archive' //可选路径

'new/:cate$'=> 'News/category' //规则匹配检测的时候只是对URL从头开始匹配,只要URL地址包含了定义的路由规则就会匹配成功,如果希望完全匹配,可以在路由表达式最后使用$符号,配置'route_complete_match'  =>  true,全部使用完全匹配

'blog/:id'=>'blog/read?status=1&app_id=5' //额外参数,相当于隐式设定初值
  • 路由参数

数组形式,对路由规则的补充,常见有以下配置

[
	'ext' => 'html',
	'route_complete_match' => false,
	
]
  • 变量规则

数组形式,对路由表达式中定义的变量增加约束规则(正则),比如限制为数字、字符串等,通常是一系列简单正则的组合,如

[
	'id'=>'\d+',
	'name'=>'\w+'
]
  • 定义路由配置文件

在我们所说的route.php的最后通过返回数组的方式直接定义路由规则,所有跟配置相关的php形式,都是返回数组(底层实现就是使用include包含并赋值的)

默认情况下,只会加载一个路由配置文件route.php,如果你需要定义多个路由文件,可以修改route_config_file配置参数

'route_config_file' =>  ['route', 'route1', 'route2'],

变量规则

  • 类方法
Route::pattern(name, pattern) // 定义全局变量,即所有路由的同名name变量都使用该规则,会被局部变量定义覆盖

Route::rule系列 // 局部变量
  • 规则
全局变量
Route::pattern('name', '\w+')

局部变量
Route::get('new/:id','News/read',[],['id'=>'\w+']);

完整URL检查
['__url__'=>'new\/\w+$'] //完整URL检查,用在注册路由时
如:Route::get('new/:id','News/read',[],['__url__'=>'new\/\w+$']);

组合变量:相当于一个/x/的x部分由多种类型的x=abc组成,因此要为abc分别制定规则,abc需要用<a>来包裹
Route::get('item-<name>-<id>','product/detail',[],['name'=>'\w+','id'=>'\d+']);

路由参数

常用的有

参数说明实例
method请求类型检测,支持多个请求类型用于Route::any
extURL后缀检测,支持匹配多个后缀V5.0.7版本以上,extdeny_ext参数允许设置为空,分别表示不允许任何后缀以及必须使用后缀访问。
deny_extURL禁止后缀检测,支持匹配多个后缀同上
https检测是否https请求
domain域名检测支持完整域名检测、子域名检测(部分匹配即可)
before_behavior前置行为(检测)支持使用行为对路由进行检测是否匹配,如果行为方法返回false表示当前路由规则无效。
after_behavior后置行为(执行)路由匹配后,对路由的额外处理
callback自定义检测方法使用自定义函数进行检测,false不通过,有点类似前置行为检测
merge_extra_vars合并额外参数通常用于完整路由,如果有额外的参数则合并作为变量值,即多余的部分合并为一个整体,如new/:name$规则下new/thinkphp/hello,则name匹配thinkphp/hello
bind_model绑定模型(V5.0.1+'bind_model'=>['User','name'],当前路由匹配后绑定一个模型,依赖注入获取
cache请求缓存(V5.0.1+'cache'=>3600,单位 s
param_depr路由参数分隔符(V5.0.2+
ajaxAjax检测(V5.0.2+
pjaxPjax检测(V5.0.2+

V5.0.7版本以上,extdeny_ext参数允许设置为空,分别表示不允许任何后缀以及必须使用后缀访问。

路由地址*

这一部分涉及TP5.0.13的RCE,利用captcha路由到方法,绕过module分支,不用开启debug下即可RCE。

\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");

用以下方式访问即可触发RCE

?s=captcha
POST:
a=calc&filter[]=system&_method=__construct

这里比较绕,需要理清楚\/的区别,命名空间用\,默认PATH_INFO的分隔符用/

类型格式(指的是路由定义中的路由地址格式,不是URL传入格式)用法说明
路由到模块/控制器'[模块/控制器/操作]?额外参数1=值1&额外参数2=值2...'默认用法,不开启路由模式时也按该方式解析。支持额外参数,支持多级控制器,如index/group.blog/read,相当于访问二级的group/blog控制器
路由到重定向地址'外部地址'(默认301重定向) 或者 ['外部地址','重定向代码']开头为/,则本地重定向;为http:s?则重定向到外部,有利于网站前移,注意区分开头有无/,若无则表示默认的路由方式,若有则表示重定向
路由到控制器方法'@[模块/控制器/]操作'模块/控制器若绑定的话可以省略,该方式不会初始化模块,直接调用操作,相当于调用Loader::action()方法
路由到类的方法'\完整的命名空间类::静态方法' 或者 '\完整的命名空间类@动态方法'这个是上面方式的拓展,路由到的类方法不局限于模块中的控制器类,而是可以路由到任意类的方法,注意此处用命名空间,如\app\index\service\Blog::read
路由到闭包函数闭包函数定义(支持参数传入)路由地址的位置变成闭包函数,那么直接调用闭包函数而不去寻找控制器。如Route::get('hello/:name',function($name){ return 'Hello,'.$name; });

资源路由

需要在配置文件中用__rest__来定义资源路由

    '__rest__'=>[
        // 指向index模块的blog控制器,相当于在写路由规则时,blog等同于index/blog,也就是URL访问可以省略模块了
        'blog'=>'index/blog',
    ],

这个比较难理解的地方是资源路由的嵌套,还是要注意区分rule()方法的几个参数。我理解的资源路由相当于一种标准的资源对象,有点类似于java Bean,其类方法由特定的规则或类型组成(比如规定方法名称必须是什么样)。然后通过URL中特定的访问规则(即路由规则),从而定位到规定资源路由控制器类的特定操作。这里的资源路由类需要由以下方法。

标识请求类型生成路由规则对应操作方法(默认)
indexGETblogindex
createGETblog/createcreate
savePOSTblogsave
readGETblog/:id 注意id变量read
editGETblog/:id/editedit
updatePUTblog/:idupdate
deleteDELETEblog/:iddelete

两个方法

方法1

Route::resource('路由规则','资源路由控制器类',选项)

比如:

Route::resource('blog','index/blog',['var'=>['blog'=>'blog_id']]);

访问(GET方法访问):

http://serverName/blog/         //对应index/blog/index
http://serverName/blog/128 		//对应index/blog/read/:id 
http://serverName/blog/28/edit  //对应index/blog/edit/:id 
http://serverName/blog/create 	//对应index/blog/edit/:id 

而进一步对应

public function index()
public function read($id)
public function edit($id)
public function create

方法2

Route::rest('原操作名', ['method', '/更改后的操作名', '更改后的操作名'])

该方法用于更改默认的资源操作,支持批量更改,如

Route::rest('create',['GET', '/add','add']);

那么访问

http://serverName/blog/create
变成
http://serverName/blog/add

资源嵌套

Route::resource('blog.comment','index/comment');

最后指向资源处理控制器还是comment,所以在__rest__中注册时要注册comment的,不同的是路由规则变了,且允许两个变量,一个变量对应blog的,一个变量对应comment的,比如

blog/:blog_id/comment/:id/edit

前缀的变量名默认为xxx_id

对应的操作

namespace app\index\controller;
class Comment{
    public function edit($id,$blog_id){
    }
}

其他特性

其他特性大部分都是为了简化URL的特性。

  • 快捷路由

给模块/控制器一个别名,且针对操作可以指定method前缀

Route::controller('user','index/User');

定义

class User {
    public function getInfo() //get类型
    {
    }
}

访问

get http://localhost/user/info
  • 路由别名

快捷路由第一个功能的plus版,且额外支持其他的路由参数

  • 路由绑定

三种绑定方式:绑定到模块/控制器/操作(直接访问操作)、绑定到命名空间(访问类/操作)、绑定到类(直接访问操作)

控制器

请求

请求的核心类是\think\Request类,可以使用以下两种方式生成一个该类对象

$request = Request::instance();
$request = request();

获取请求/变量信息*

支持一堆获取请求信息的方式。包括获取$_GET等外部请求信息,以及路由、模块、控制器等其他信息内置请求信息。有几种类型:

1.针对$_GET、$_POST、$_REQUEST、$_SERVER、$_SESSION、$_COOKIE、$_ENV等系统变量
Request::instance()->变量类型方法('变量名/变量修饰符','默认值','过滤方法')
助手函数:request()->变量类型方法('变量名/变量修饰符','默认值','过滤方法')
助手函数:input('变量类型方法.变量名');

2.针对其他具体的请求信息,如ip,pathinfo等
Request::instance()->信息名()
助手函数:request()->信息名()
    
3.获取HTTP头部信息,返回数组
$head = Request::instance()->header(["具体信息"])
$head['user-agent'] $head['accept']

第一种类型的变量类型方法有以下几种:

方法描述
param获取当前请求的变量
get获取 $_GET 变量
post获取 $_POST 变量
put获取 PUT 变量
delete获取 DELETE 变量
session获取 $_SESSION 变量
cookie获取 $_COOKIE 变量
request获取 $_REQUEST 变量
server获取 $_SERVER 变量
env获取 $_ENV 变量
route获取 路由(包括PATHINFO) 变量
file获取 $_FILES 变量

这里特别注意一下过滤方法,即第三个参数,其默认配置为

默认全局过滤方法
default_filter'  => 'htmlspecialchars'

这里可以看到,过滤方法名就是php内置函数htmlspecialchars,所以这里可以联想一下,其底层实现有没有可能使用call_user_func之类的函数调用方法,而TP5的调用任意method的RCE的Sink点确实在filterValue方法中使用了call_user_func。所以针对过滤函数、回调方法之类的关键词,可以稍微关注一下其底层实现,从特性定义来思考到其底层实现的一些潜在的危险操作。

更改变量

// 更改GET变量
Request::instance()->get(['id'=>10]);
// 更改POST变量
Request::instance()->post(['name'=>'thinkphp']);
// 无法更改param方法变量

请求类型

使用isXxx的函数形式,返回bool值

// 是否为 GET 请求
if (Request::instance()->isGet()) echo "当前为 GET 请求";
// 是否为 POST 请求
if (Request::instance()->isPost()) echo "当前为 POST 请求";
// 是否为 PUT 请求
if (Request::instance()->isPut()) echo "当前为 PUT 请求";
// 是否为 DELETE 请求
if (Request::instance()->isDelete()) echo "当前为 DELETE 请求";
// 是否为 Ajax 请求
if (Request::instance()->isAjax()) echo "当前为 Ajax 请求";
// 是否为 Pjax 请求
if (Request::instance()->isPjax()) echo "当前为 Pjax 请求";
// 是否为手机访问
if (Request::instance()->isMobile()) echo "当前为手机访问";
// 是否为 HEAD 请求
if (Request::instance()->isHead()) echo "当前为 HEAD 请求";
// 是否为 Patch 请求
if (Request::instance()->isPatch()) echo "当前为 PATCH 请求";
// 是否为 OPTIONS 请求
if (Request::instance()->isOptions()) echo "当前为 OPTIONS 请求";
// 是否为 cli
if (Request::instance()->isCli()) echo "当前为 cli";
// 是否为 cgi
if (Request::instance()->isCgi()) echo "当前为 cgi";

助手函数:
request()->isGet()

请求伪装*

这个也涉及TP5调用Request类方法来RCE。其中我们传入的_method就是请求伪装变量,其会保存在$this->method中,从而通过

$this->{$this->method}($_POST);

导致调用任意Requst类方法方法

有以下几种伪装形式

1.伪装method:
POST: _method=xxx
可以设置为任何合法的请求类型,包括GET、POST、PUT和DELETE等。
可更改伪装变量名: 'var_method' => '_m'

2.伪装AJAX/PJAX
GET : ?_ajax=1 
GET : ?_pjax=1 
可更改伪装变量名: 'var_ajax' => '_a'

伪静态

伪静态即给访问的URL地址添加特定后缀,具有静态页面的URL特征,也有利于优化SEO

设置

'url_html_suffix' => 'html|...|...' //默认为html
'url_html_suffix' => false //关闭

访问

http://serverName/index/blog/3
http://serverName/index/blog/3.html

获取

Request::instance()->ext();

几种注入

1.方法注入

即动态自定义Request类的方法

Request::hook('方法调用名','方法定义名');

定义

function getUserInfo(Request $request, $userId) //第一个参数必须为Request $xxx
{
    // 根据$userId获取用户信息
    return $info;
}

调用

Request::instance()->user("xxx");

2.属性注入

顾名思义,可以为当前Request类注入一些属性

绑定

Request::instance()->bind('user',new User);
Request::instance()->user = new User;

获取

Request::instance()->user;
$this->request->user; //控制器注入请求对象的话
request()->user; //助手函数

3.依赖注入

依赖注入相当于将一个对象通过方法的参数注入进该方法中,从而在该方法中可以获取/调用这个注入对象的所有属性/方法,主要是通过bindParams和getParamValue来完成底层实现,最后通过反射类的invokeArgs方法完成调用,支持的依赖注入方式有

  • 自动注入请求

即在操作方法中定义Request $request,即可自动注入Request类,其底层先通过ReflectionMethod类的getParameters获取被注入的操作方法的参数,包含request,然后获取该参数名后再调用ReflectionMethod类的getClass获取参数的类型提示类,若该类不为空。则先判断该类是否可以通过Request::instance()->$name的方式直接获取(即之前是否属性注入),若有,则直接返回,若无,则进一步判断该类是否有public static属性的invoke方法,若有,则直接注入Request对象,若无则实例化要注入的对象,最后返回。

因此根据底层分析,也就衍生出了一系列功能

1.架构方法注入
namespace app\index\controller;

use think\Request;

class Index
{
	protected $request;
    
	public function __construct(Request $request)
    {
    	$this->request = $request;
    }
    
    public function hello()
    {
        return 'Hello,' . $this->request->param('name') . '!';
    }
    
}

2.操作方法注入
    public function hello(Request $request)
    {
        return 'Hello,' . $request->param('name') . '!';
    }
  • 其他对象注入

其他对象注入支持事先的属性注入,若未绑定,则注入一个默认初始化的对象,相当于

# 事先属性注入
Request::instance()->bind('user', new \app\index\model\User(1));

# 未注入,则默认
Request::instance()->bind('user', new \app\index\model\User());

同样地

1.架构方法注入
namespace app\index\controller;

use app\index\model\User;
use think\Request;

class Index
{
	protected $request;
    protected $user;
    
	public function __construct(Request $request, User $user)
    {
    	$this->request = $request;
        $this->user = $user;
    }
    
}

2.操作方法注入
  • invoke方法自动注入Request对象
namespace app\index\model;

use think\Model;
class User extends Model
{
	public static function invoke(Request $request)
    {
    	$id = $request->param('id');
        return User::get($id);
    }
}

参数绑定

支持名称对参数绑定(默认)和顺序参数绑定(简化URL),相当于URL中的变量与操作参数定义的同名变量一一对应,有点类似于函数的名称参数。底层实现还是依靠ReflectionMethod类的getParameters的方法,然后再getName获取名称,如果存在则直接带名称调用invokeArgs,否则抛出索引不存在异常,其实现模板应该是参考的这个

https://www.php.net/manual/zh/reflectionmethod.invokeargs

We can do black magic, which is useful in templating block calls:

<?php
     $object->__named('methodNameHere', array('arg3' => 'three', 'arg1' => 'one'));

     ...

      /**
       * Pass method arguments by name //注意这里
       *
       * @param string $method
       * @param array $args
       * @return mixed
       */
      public function __named($method, array $args = array())
      {
        $reflection = new ReflectionMethod($this, $method);

        $pass = array();
        foreach($reflection->getParameters() as $param)
        {
          /* @var $param ReflectionParameter */
          if(isset($args[$param->getName()]))
          {
            $pass[] = $args[$param->getName()];
          }
          else
          {
            $pass[] = $param->getDefaultValue();
          }
        }

        return $reflection->invokeArgs($this, $pass);
      }
?>