SCTF2020复现记录
比赛的时候忙着最优化,做了个CloudDisk就溜了,不过打了也是丢人…
Web
BestLanguage
给了一个laravel,版本为5.5.39
- web.php中的路由
1 | Route::get('/',"IndexController@init"); |
- IndexController.php
1 |
|
预期
nginx特性
/tmp/{filename}
路由看起来可以直接读文件,/tmp/../../flag
试试
这实际上是nginx的一个特性,nginx会将url中的../
转化为绝对路径,并判断其是否超过了url中的根目录,超过了则返回http 400
借用下官方writeup的图
trick1 写文件
继续看其他路由,可以发现:
upload()
会将文件写到"/var/tmp/".md5($_SERVER["REMOTE_ADDR"])."/"
下,但md5($_SERVER["REMOTE_ADDR"])
需要在init()
中创建- 然而
init()
中会验证ip是否为内网ip…看起来需要一个ssrf? - 唯一跟ssrf沾点边的就是
moveLog()
中的
1 | $content = file_get_contents("http://127.0.0.1/tmp/".md5('127.0.0.1')."/".$filename,false,stream_context_create($opts)); |
可这真的能够访问到/
吗…
- 如果不用
upload()
去写文件的话,那moveLog()
也就无法使用了
实际上这里需要用一个php文件操作的trick,文件名xxx/.
时会认为是xxx
因此访问filename=.
即可在/var/tmp/
下写文件,文件名为md5($_SERVER["REMOTE_ADDR"])
这个trick的原理和xxx/../
是一样的:
- php读取、写入文件,都会调用
php_stream_open_wrapper_ex
来打开流php_stream_open_wrapper_ex
最后会使用tsrm_realpath
函数来将filename给标准化成一个绝对路径。
- 而判断文件存在、重命名、删除文件等操作则无需打开文件流,无法使用这个trick
trick2 移动文件
1 | $content = file_get_contents("http://127.0.0.1/tmp/".md5('127.0.0.1')."/".$filename,false,stream_context_create($opts)); |
写入文件后自然会想到用moveLog()
移动文件,但此时文件名是md5($_SERVER["REMOTE_ADDR"])
,可以利用url和路径的差异,使用?
或#
截断url,比如
1 | ../ee3973488c12ec9231af25c6e84309d3?xxx/../test |
会将/var/tmp/ee3973488c12ec9231af25c6e84309d3
的内容写到storage_path("logs")/test
还是因为nginx特性的原因,只能将文件写到storage_path("logs")
下
laravel session反序列化
storage_path("logs")/framework/sessions/
下存储了laravel的session文件,如果能覆盖的话可以通过session反序列化拿到flag
不过laravel不使用原生的php session,而是自己弄了一套,session文件名可以根据session id和app key得到固定值
为了防御session固定攻击,laravel每次访问session id都会变…但是session文件名不会变
题目给出了app key,本地搭建环境,远程使用和本地相同的session id即可保证session文件名相同
最后的步骤:
- 本地搭建环境,记录此时的
laravel_session
和对应的session文件名 - phpggc生成laravel 5.5.39的反序列化payload
1 | php .\phpggc -a Laravel/RCE2 system 'cat /flag' |
- 将
laravel_session
设置成本地值,将payload base64编码后写入/var/tmp/md5($_SERVER["REMOTE_ADDR"])
- 覆盖session文件
1 | /move/log/..%2Fee3973488c12ec9231af25c6e84309d3%3Fxxx%2F..%2F..%2Fframework%2Fsessions%2FXQJBTIU1E9D5imnmUmI1g9tVC4yWDKYYneRb3CUH |
- 注意这一步要把session改成别的或删掉,不然session文件会在请求处理后修改回去
XQJBTIU1E9D5imnmUmI1g9tVC4yWDKYYneRb3CUH
为本地session文件名
- 将session重新设成本地值,任意访问一个路由即可得到flag
非预期
CVE-2018-15133直接打
非预期2
在http://phoebe233.cn/index.php/archives/53/#bestlanguage看到的
1 | /index.php/tmp/../../flag |
多加一个index.php
就能绕过nginx的特性了…
pysandbox
1 | from flask import Flask, request |
只允许ascii码42-122的字符,最大的问题就是()
没了,无法调用函数
app.static_folder
第一种解法就是常规的flask ssti思路。既然无法调用函数,那就从flask的配置入手,将app.static_folder
设为/
1 | cmd=app.static_folder%3Dapp.root_path |
1 | /static/flag |
函数劫持
思路是找到一个参数可控的函数,用xxx=eval
将其劫持
可以添加一个路由显示导入的模块
1 |
|
官方wp找的是werkzeug.urls.url_parse
1 | def url_parse(url, scheme=None, allow_fragments=True): |
因为werkzeug
不在当前模块的全局名称空间中,所以要用继承链去访问。用shrine的脚本找一下
1 | def search(obj, max_depth): |
返回obj.environ['werkzeug.server.shutdown'].__globals__['uri_to_iri'].__module__
,需要修改一下通过request.environ['werkzeug.server.shutdown'].__globals__['uri_to_iri'].__globals__['url_parse']
访问到werkzeug.urls.url_parse
接着还需要bypass单引号,常见的request.args
因为不是jinja2环境用不了,但可以用request
的其他可控属性
之后就可以用url代码执行
lambda表达式劫持ord
http://phoebe233.cn/index.php/archives/53/#menu_index_3大佬的解法…
正常情况下lambda表达式是需要空格的,不能绕过过滤,比如
1 | __builtins__.ord=lambda a:45 |
配合数组参数可以无空格
1 | __builtins__.ord=lamdba*a:45 |
然后就可以为所欲为了-_-